Repository navigation
Fold nullable min, max, sum and NaN counts with a masked lane kernel - #10947
robert3005 wants to merge 4 commits into
Conversation
Merging this PR will regress 3 benchmarks2 warnings
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing Footnotes
|
With nulls present, the primitive min/max and sum kernels walked the runs of valid values one at a time, so a nullable column with short runs, such as one with half its rows null, reduced thousands of one- or two-element slices. Add a masked reduction lane kernel, IndexedFoldExt::fold_masked, which folds every lane into an accumulator and reads null lanes as an identity of the fold. Lanes are selected against constant bits of each 32-lane half of the validity word, which needs no per-lane variable shift and so vectorizes on baseline x86-64, and halves with no valid lane are skipped. Integer min/max read null lanes as a valid value, and integer sums narrower than 64 bits read them as zero, so both cost the same at any validity density. 64-bit integer sums keep their per-element overflow check, and floats keep their in-order runs. Signed-off-by: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X1HnweRGxeDWgQwXXM6f5s
Apply fold_masked to the other aggregate kernels that walked runs of valid values or zipped values with validity bits: - Float min/max fold total-order keys, integers that order exactly as total_cmp does, so the reduction is plain integer min/max and its result is bit-for-bit that of the previous total_cmp reduction, all-valid or not. - Decimal min/max read null lanes as a valid value, like integers. - Float NaN counts read null lanes as zero, which is not NaN. - Float sums read null lanes as zero, adding the values in the same order, which leaves the result unchanged. Decimal sums and the grouped sums keep their paths: decimal sums are bound by their checked adds, and grouped sums over short groups pay more to set up the fold per group than the fold saves. Signed-off-by: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X1HnweRGxeDWgQwXXM6f5s
Float sums, float min/max and 64-bit integer sums did not vectorize: the compiler cannot reorder float adds, float compares or checked adds, so each ran one element at a time, and with nulls each run of valid values ran separately. Add IndexedFoldExt::fold_lanes and fold_lanes_masked. They fold lane i into accumulator i % LANES and merge the accumulators at the end, so one row of lanes is one vector step for any fold. The masked form tests each lane against a constant bit of the validity word, with no branch. An opaque barrier before each row stops the vectorizers from packing one lane of several rows with strided loads. Use the folds in the aggregates: - Float sums use lane folds. NaN skipping runs a second pass only when the first sum is NaN. - Float min/max use float compares, one fold per extremum. A cold pass settles a valid NaN and the sign of a zero extremum. - 64-bit sums add with wrapping and OR together a bound on each value's magnitude. Values past the bound get an exact 128-bit sum, so a sum overflows only if its exact total does not fit. - Narrower integer sums use 16 lanes, except signed values on x86-64 without SSE4.1, which has no vector sign extension. On Graviton4, Sapphire Rapids, Zen 4 and Apple M4 the float sums are 4.6x to 8.8x faster, and up to 30x with scattered nulls. Float max is 2.4x to 8.8x faster, and 64-bit sums are 1.8x to 3.4x faster. Signed-off-by: Robert Kruszewski <github@robertk.io> Claude-Session: https://claude.ai/code/session_018SWbEqgj32dnKrPu3naMzN
a2047c6 to
a3f0e9d
Compare
Format the changed crates with the pinned nightly. Let clippy accept v - v in the float min/max kernel, because it finds NaN and infinite values. Run integer_stats_all_nullable on 16K values instead of 64K. Signed-off-by: Robert Kruszewski <github@robertk.io>
Summary
Several aggregate kernels handle nulls by walking runs of valid values one at a time, or by zipping each value with its validity bit through an iterator. When nulls are scattered, so runs are only one or two values long, that is slow, and the cost depends heavily on how the nulls are spread.
This PR adds a masked reduction lane kernel. It folds every lane and reads null lanes as an identity of the fold, then applies it to every aggregate kernel where it measurably helps. The result is a branch-free reduction that costs the same whatever the validity density.
Changes
vortex-compute: newIndexedFoldExt::fold_masked(lane_kernels/fold.rs).IndexedSourceinto an accumulator, reading null lanes as a caller-chosen identity.min_max/primitive.rs): null lanes read as a valid value from the array, which leaves both extrema unchanged.min_max/primitive.rs), on both the all-valid and nullable paths:total_cmpdoes, which is howtotal_cmpis implemented.total_cmpreduction returned, NaN placement included.min_max/decimal.rs): null lanes read as a valid value.nan_count(nan_count/primitive.rs): null lanes read as zero, which isn't NaN.sum/primitive.rs):SUM_CHUNKblocks with one overflow check per block, as on the all-valid path.+0.0, so it is never-0.0, the one value adding zero would change; the result is unchanged.Left unchanged, because the fold measured no better:
i32sum with clustered nulls went from 6.3 µs to 7.3 µs.Not applicable:
is_sortedwith nulls depends on where the nulls fall, so it isn't a fold.Tests:
total_cmpforf16/f32/f64, and the round trip preserves the bits.Benches:
cargo bench -p vortex-compute --bench fold_masked: the kernel against arrow-rs's nullablemin/sum.cargo bench -p vortex-array --bench aggregate_nullable: the aggregate path with nulls scattered at random. The existing benches only have clustered nulls, which is the run-walking path's best case.aggregate_nullablemedians, 15,000 rows, 50% valid, scattered:f64min/maxf64min/max, all validi64) min/maxf64NaN countf64sumThe "before" column here is this PR's first commit, so it already includes the integer fold.
Integer kernels, measured on a separate branch through the aggregate's
accumulateon 15,000 nullablei32s (that bench is not in this PR):At 99% valid, integer min/max is now slightly slower (~8–9 µs against ~6.7 µs), because the run walk had very few runs there.
Kernel medians from
fold_masked, 15,000i32s, the same at every density:fold_maskedi32→i64, overflow-checked)i32)Checks run:
cargo clippy -p vortex-compute -p vortex-array --all-targets --all-features -- -D warningscargo test -p vortex-compute(33 passed)cargo test -p vortex-array --lib(5,155 passed)🤖 Generated with Claude Code
https://claude.ai/code/session_01X1HnweRGxeDWgQwXXM6f5s