Repository navigation
Speed up the decimal widening copy - #10989
Conversation
Merging this PR will regress 3 benchmarks1 warning
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing |
4842279 to
b25317d
Compare
e2f5b42 to
4bfc9d7
Compare
16050fb to
e7b5ab7
Compare
4bfc9d7 to
b2cbb37
Compare
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>
2128166 to
d09193e
Compare
| Buffer::from_trusted_len_iter( | ||
| values | ||
| .iter() | ||
| .map(|&value| <W as BigCast>::from(value).unwrap_or_default()), | ||
| ) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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.
|
im going to merge this but I think we can potentially speed up map_into as well, would be a followup PR |
Pull request removed from stack
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>
d09193e to
0da7340
Compare
Tracking Issue: #10701
Stacked on #10990, which adds the
copy_to_canonical_widthbenchmark first so that CodSpeed measures this change.Summary
copy_to_canonical_widthwidens decimal values whenever a path needs the canonical array, such as the Arrow and DuckDB imports and interleave. Once DecimalByteParts decodes to a decimalWiden(#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, andi64toi128is now about 1.75x faster on Apple M4.Changes
widen_valuescollected throughBufferMut::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 vectorizedi32toi64, but left other widths scalar, such asi64toi128. The loop now builds the buffer withBuffer::from_trusted_len_iterand 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_buffernow callswiden_valuesinstead of its own copy of the loop.A runtime
assert!checks the width order, and folds away because both sides are constants. Aconstassert doesn't compile here: the callers dispatch withif const, and rustc still evaluates the inline constants of every function that the disabled branches name.Widening into
i256got 1.24x (i128toi256) to 1.7x (i32toi256) 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 decimalWidenonce #10946 and #10905 land, so one loop for every width is the simpler trade.test_widen_values_sign_extendschecks 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.i32_to_i64i64_to_i128i128_to_i256same_width_i64(control)API Changes
None.