Skip to content

Speed up the decimal widening copy - #10989

Merged
connortsui20 merged 1 commit into
developfrom
ct/decimal-widen-copy
Oct 30, 2026
Merged

connortsui20 merged 1 commit into
developfrom
ct/decimal-widen-copy

Conversation

@connortsui20

@connortsui20 connortsui20 commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Tracking Issue: #10701

Stacked on #10990, which adds the copy_to_canonical_width benchmark first so that CodSpeed measures this change.

Summary

copy_to_canonical_width widens decimal values whenever a path needs the canonical array, such as the Arrow and DuckDB imports and interleave. Once DecimalByteParts decodes to a decimal Widen (#10946, #10905), every export of a narrow column to DuckDB, or to Arrow above precision 18, pays for this copy. Its loop left some widths scalar, and i64 to i128 is now about 1.75x faster on Apple M4.

Changes

widen_values collected through BufferMut::extend_iter, whose write loop has two exits (the reserved count and the end of the iterator), and its checked cast added a panic edge. LLVM still vectorized i32 to i64, but left other widths scalar, such as i64 to i128. The loop now builds the buffer with Buffer::from_trusted_len_iter and falls back to zero on a failed cast, which can't happen when the target is at least as wide as the source, so it has one exit and no panic edge. widened_buffer now calls widen_values instead of its own copy of the loop.

A runtime assert! checks the width order, and folds away because both sides are constants. A const assert doesn't compile here: the callers dispatch with if const, and rustc still evaluates the inline constants of every function that the disabled branches name.

Widening into i256 got 1.24x (i128 to i256) to 1.7x (i32 to i256) slower on Apple M4, and the cause is not known. That only happens at precision 39 to 76, and most such columns will decode to a decimal Widen once #10946 and #10905 land, so one loop for every width is the simpler trade.

test_widen_values_sign_extends checks all 15 widening pairs against a per-value cast, with enough values to run both a vector body and a scalar tail.

The CodSpeed simulation run (x86 with AVX2) showed no change in any case, so #10990 now runs the benchmark on the walltime CPU-feature legs, where the NEON leg is the one that matches the M4 result.

Benchmark results (Apple M4)

cargo bench -p vortex-array --bench copy_to_canonical_width, 16,384 values with no nulls. Before is the benchmark commit (#10990) and after is the tip of this PR, so the comparison isolates this layer. Each number is the median of three alternating runs of each binary, with 300 samples per run. These numbers come from the earlier version of the benchmark, which also had half-null cases with the same ratios.

case before (ns) after (ns) before / after
i32_to_i64 1103 1083 1.02
i64_to_i128 4624 2624 1.76
i128_to_i256 6749 8332 0.81
same_width_i64 (control) 1176 1177 1.00

API Changes

None.

@connortsui20 connortsui20 added the changelog/performance A performance improvement label Oct 29, 2026
@codspeed

codspeed Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Merging this PR will regress 3 benchmarks

1 warning

⚠️ Walltime results may be unreliable

Some benchmarks used the Walltime instrument outside CodSpeed Macro Runners, which can make the results inconsistent.

Learn more about Macro Runners

See affected benchmarks

⚡ 5 improved benchmarks
❌ 3 regressed benchmarks
✅ 2310 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
❌ CPU Simulation missing_stats[16] 67.2 µs 85.1 µs -20.99%
❌ CPU Simulation encode_primitives[u8, (2000, 4)] 134.8 µs 163.7 µs -17.67%
❌ CPU Simulation random_i8[0.01] 99 µs 113.2 µs -12.55%
⚡ CPU Simulation filter_powerlaw_by_random[10000] 1,125.5 ns 583.5 ns +92.87%
⚡ Walltime i128_to_i256_neon 14.4 µs 10.5 µs +37.35%
⚡ Walltime i64_to_i128_neon 13.5 µs 10.3 µs +30.78%
⚡ CPU Simulation take_filter_primitive_small_uncached_random_mask_random_indices[4096, 10] 24.5 µs 20.9 µs +17.22%
⚡ CPU Simulation max_f64 312.9 µs 283.2 µs +10.48%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing ct/decimal-widen-copy (0da7340) with develop (1850cab)

Open in CodSpeed

@connortsui20
connortsui20 changed the base branch from develop to ct/decimal-widen-copy-bench October 29, 2026 19:39
@connortsui20
connortsui20 force-pushed the ct/decimal-widen-copy branch from 4842279 to b25317d Compare October 29, 2026 19:40
@connortsui20
connortsui20 added this pull request to stack #10991 October 29, 2026 19:40
@connortsui20
connortsui20 force-pushed the ct/decimal-widen-copy branch 2 times, most recently from e2f5b42 to 4bfc9d7 Compare October 29, 2026 20:20
@connortsui20
connortsui20 marked this pull request as ready for review October 29, 2026 20:26
@connortsui20
connortsui20 force-pushed the ct/decimal-widen-copy-bench branch from 16050fb to e7b5ab7 Compare October 29, 2026 20:33
@connortsui20
connortsui20 force-pushed the ct/decimal-widen-copy branch from 4bfc9d7 to b2cbb37 Compare October 29, 2026 20:33
Base automatically changed from ct/decimal-widen-copy-bench to develop October 29, 2026 21:07
connortsui20 added a commit that referenced this pull request Oct 29, 2026
Tracking Issue: #10701

Bottom of stack #10991. #10989 builds on this PR.

## Summary

Every path that needs the canonical decimal array widens its values with
`copy_to_canonical_width`: the Arrow and DuckDB imports, interleave, and
(after #10946) the execution of a decimal `Widen`. No benchmark covered
that widening path. This lands before #10989 so that CodSpeed measures
the loop change there against it.

## Changes

Adds a `copy_to_canonical_width` bench that copies 16,384 values for
`i32` to `i64`, `i64` to `i128` and `i128` to `i256`, with a same-width
`i64` copy as the control. Widening never reads the validity, so the
cases have no nulls.

How the compiler vectorizes the widening loop depends on the target
features, so every case carries `#[cpu_features]` and runs on the AVX2,
AVX-512 and NEON walltime legs instead of in simulation.

## API Changes

None.

Signed-off-by: Connor Tsui <connor.tsui20@gmail.com>
@connortsui20
connortsui20 force-pushed the ct/decimal-widen-copy branch 2 times, most recently from 2128166 to d09193e Compare October 29, 2026 21:30
Comment on lines +174 to +178
Buffer::from_trusted_len_iter(
values
.iter()
.map(|&value| <W as BigCast>::from(value).unwrap_or_default()),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would check the lanezip (and related) these are written so they simd better. I think from_trusted_len_iter cannot quite avoid bounds check

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

initial test shows that the lanezip is slower for i32->i64 on M4, am spinning up some EC2 instances to double check (and also ask why)

@connortsui20 connortsui20 Oct 30, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping the current loop in this PR: the bounds-check concern does not reproduce, and map_into introduces large regressions on ARM and AVX-512. The follow-up is to improve map_into's tiling separately, since a diagnostic larger-tile run removes the ARM regression.

Benchmark results and diagnostic experiment

Baseline is this PR at d09193ef532c. The candidate replaces the trusted-iterator construction in widen_values with the related unary map_into helper, keeping the cast and output allocation. Rust 1.99.1, LLVM 23.1.1, bench profile, 16 CGUs, no LTO, MiMalloc, 16,384 non-null values. Each result is the median paired candidate/baseline ratio from seven alternating pairs, with 300 samples and a 0.3-second minimum per case. Higher is slower. EC2 runs were pinned to CPU 2, with its SMT sibling disabled on C7i. The M4 runs were unpinned.

Case M4 Max Graviton3, NEON C7i, AVX2 C7i, AVX-512
i32 -> i64 1.82x 1.80x 1.01x 1.00x
i64 -> i128 0.95x 0.47x 1.03x 1.03x
i128 -> i256 1.00x 0.83x 1.00x 1.55x
Same-width copy control 1.00x 1.00x 1.00x 1.00x

The C7i runs use the AVX2 and full AVX-512 feature sets from CI on the same Xeon Platinum 8488C. Graviton3 uses +neon without SVE.

For the i32 -> i64 diagnostic, separate interleaved three-way runs gave:

Machine Current loop map_into, 64-row tiles map_into, 1024-row tiles
M4 Max 1.166 us 2.124 us 1.165 us
Graviton3 1.788 us 3.251 us 1.786 us

The 64-row body uses narrower loads and individual stores. A 128-row experiment restored wider paired operations but recovered only part of the slowdown. The 1024-row version restores a compact rolled vector loop with the same inner instruction sequence as the current implementation. Allocation and conversion work are unchanged. This isolates a lane-tiling/code-generation sensitivity, while the exact hardware bottleneck remains unresolved. A separate prototype keeps map_into unchanged by default and adds an explicit widening opt-in: retain the tiled loop up to 128 values, then use one untiled loop. In six alternating pairs at 16,384 values, this reduces time against stock map_into by about 48% on M4 and 44% on Graviton3 for i32 -> i64, and 35% on C7i AVX-512 for i128 -> i256. AVX2 is roughly unchanged. Some short integer cases still cost about 1 ns more, floating-point results are mixed, and unchanged controls show placement sensitivity. That supports a selective follow-up, rather than replacing every map loop.

@connortsui20
connortsui20 enabled auto-merge (squash) October 30, 2026 14:36
@connortsui20

Copy link
Copy Markdown
Member Author

im going to merge this but I think we can potentially speed up map_into as well, would be a followup PR

auto-merge was automatically disabled October 30, 2026 14:36

Pull request removed from stack

@connortsui20
connortsui20 removed this pull request from stack #10991 October 30, 2026 14:36
@connortsui20
connortsui20 enabled auto-merge (squash) October 30, 2026 14:45
LLVM left some widening pairs scalar, such as `i64` to `i128`.
`widen_values` and `widened_buffer` widened each value with a checked
cast and an `expect`, and collected through `BufferMut::extend_iter`,
whose write loop has two exits.

Build the buffer with `from_trusted_len_iter`, like the Arrow exporter,
and fall back to zero instead of panicking. The fallback is dead when
the target is at least as wide as the source, so the loop has one exit
and no panic edge. `widened_buffer` now calls `widen_values`. A runtime
assert guards the widths and folds away. A `const` assert does not
compile, because the disabled `if const` branches of the callers still
name the narrower pairs.

On Apple M4, `i64` to `i128` is about 1.75x faster and `i32` to `i64`
is unchanged, but widening into `i256` is 1.24x to 1.7x slower, for a
reason we did not find. That widening only happens at precision 39 to
76, and once #10946 and #10905 land, most such columns decode to a
`Widen`, so one loop for every width is the simpler trade.

Signed-off-by: Connor Tsui <connor.tsui20@gmail.com>
@connortsui20
connortsui20 force-pushed the ct/decimal-widen-copy branch from d09193e to 0da7340 Compare October 30, 2026 14:45
@connortsui20
connortsui20 merged commit 416e3a5 into develop Oct 30, 2026
73 of 74 checks passed
@connortsui20
connortsui20 deleted the ct/decimal-widen-copy branch October 30, 2026 14:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog/performance A performance improvement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants