Benchmark comparison kernels on the CPU-feature legs - #9541
Benchmark comparison kernels on the CPU-feature legs#9541joseph-isaacs wants to merge 3 commits into
Conversation
Comparison bottoms out in `map_bits_into`: `vortex-array`'s `collect_zip_bits` / `collect_bits` are an allocation plus one call into that kernel, and nothing measured it directly. The existing `compare` benchmarks in `vortex-array` cover the path end to end, through expression execution, where the lane loop is not the whole cost. Add `vortex-compute/benches/compare_bits.rs`. The `zip_bits_*` and `const_bits_*` benchmarks measure the kernel alone over caller-owned words, for integer, total-ordered float, and `i128` (decimal) lanes; they carry `#[cpu_features]`, so each walltime leg reports its own series. That is the shape the attribute is for: one branch-free lane loop whose cost is how well the build auto-vectorizes it. The `collect_*` benchmarks wrap the same kernels in the allocate-and- freeze that `vortex-array` performs. They stay untagged and run in the sharded simulation job, where the wrapper is what instruction counts catch. Signed-off-by: "Joe Isaacs" <joe.isaacs@live.co.uk>
`collect_bool.rs` tagged only the bare word loops, so the shipped entry points — `BitBuffer::collect_bool` under a comparison predicate, and the `&[bool]` conversion — were measured in simulation alone, never on real silicon per feature set. Predicate evaluation and allocation sit next to the pack loop in shipped code, and how the pair schedules is exactly what differs between legs. Add `*_dispatch` siblings carrying `#[cpu_features]` for both, sharing a body with the originals. The originals stay untagged and keep their simulation history: a tagged benchmark is skipped in simulation, so one name cannot serve both modes. Signed-off-by: "Joe Isaacs" <joe.isaacs@live.co.uk>
The primitive compare kernel dispatches on the operator outside the lane loop, so each of the six is a separate instantiation that vectorizes on its own terms. The benchmarks measured `Gte` for the array, nullable, constant and float shapes and `Eq` for one of them, which said nothing about the rest. Parameterize those four shapes over all six operators and drop `compare_int_eq`, now covered by `compare_int[=]`. Boolean, string, decimal and struct comparison are unchanged. Signed-off-by: "Joe Isaacs" <joe.isaacs@live.co.uk>
Merging this PR will improve performance by 26.06%
|
| Mode | Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|---|
| ⚡ | WallTime | words_gather_dispatch_avx512[65536] |
1,351 ns | 952 ns | +41.91% |
| ⚡ | Simulation | cold_misaligned[(16, 64)] |
429.3 µs | 383.4 µs | +11.98% |
| 🆕 | Simulation | compare_float[!=] |
N/A | 201.7 µs | N/A |
| 🆕 | Simulation | compare_float[<] |
N/A | 206 µs | N/A |
| 🆕 | Simulation | compare_float[<=] |
N/A | 204.4 µs | N/A |
| 🆕 | Simulation | compare_float[=] |
N/A | 201.6 µs | N/A |
| 🆕 | Simulation | compare_float[>] |
N/A | 205.8 µs | N/A |
| 🆕 | Simulation | compare_float[>=] |
N/A | 204 µs | N/A |
| 🆕 | Simulation | compare_int_constant[!=] |
N/A | 137.4 µs | N/A |
| 🆕 | Simulation | compare_int_constant[<] |
N/A | 137.4 µs | N/A |
| 🆕 | Simulation | compare_int_constant[<=] |
N/A | 138.1 µs | N/A |
| 🆕 | Simulation | compare_int_constant[=] |
N/A | 137.5 µs | N/A |
| 🆕 | Simulation | compare_int_constant[>] |
N/A | 137.8 µs | N/A |
| 🆕 | Simulation | compare_int_constant[>=] |
N/A | 137.4 µs | N/A |
| 🆕 | Simulation | compare_int_nullable[!=] |
N/A | 207.4 µs | N/A |
| 🆕 | Simulation | compare_int_nullable[<] |
N/A | 207.6 µs | N/A |
| 🆕 | Simulation | compare_int_nullable[<=] |
N/A | 208 µs | N/A |
| 🆕 | Simulation | compare_int_nullable[=] |
N/A | 207 µs | N/A |
| 🆕 | Simulation | compare_int_nullable[>] |
N/A | 207.5 µs | N/A |
| 🆕 | Simulation | compare_int_nullable[>=] |
N/A | 207.4 µs | N/A |
| ... | ... | ... | ... | ... | ... |
ℹ️ Only the first 20 benchmarks are displayed. Go to the app to view all benchmarks.
Tip
Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.
Comparing claude/codspeed-comparison-benchmarks-k1uyp0 (f3b62bf) with develop (68cdf00)
Footnotes
-
59 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩
Summary
Every native Vortex comparison over primitive and decimal lanes bottoms out in
IndexedSourceExt::map_bits_into:vortex-array'scollect_zip_bits/collect_bits(
scalar_fn/fns/binary/compare/mod.rs) are aBufferMut<u64>allocation plus one call into thatkernel. Nothing measured it directly. The existing
comparebenchmarks invortex-arraycoverthe path end to end, through expression execution, where the lane loop is not the whole cost, and
vortex-compute/benches/lane_kernels.rsbenches the cast andLaneZipadd kernels but nevermap_bits_into.Before this PR,
vortex-buffer/benches/collect_bool.rswas the only file in the workspacecarrying
#[cpu_features], so the walltime CPU-feature legs measured bit packing and nothingelse. Comparison is the other big auto-vectorized lane loop and belongs there too.
Changes
Comparison lane kernels, per feature set
New bench target
vortex-compute/benches/compare_bits.rs, in two families.zip_bits_*andconst_bits_*measure the kernel alone, writing into caller-owned words:i32,i64, total-orderedf64(total_cmp, matchingNativePType::is_lt), andi128for thedecimal path, array-vs-array via
LaneZipand array-vs-constant over a plain slice. They carry#[vortex_bench_support::cpu_features], so each walltime leg reports its own series. That is theshape the attribute is for: one branch-free lane loop whose entire cost is how well the build
auto-vectorizes it.
collect_zip_bits_*andcollect_bits_*wrap the same kernels in the allocate-and-freeze thatvortex-arrayperforms. They stay untagged and run in the sharded simulation job, where thatwrapper is the part instruction counts catch. The split is forced rather than chosen:
#[cpu_features]expands toignore = env!("VORTEX_BENCH_VARIANT") == "simulation", so a taggedbenchmark cannot also run in simulation — the same structure
collect_bool.rsalready uses(
words_gather_dispatchtagged,from_bool_slicenot).Boolean comparison is word-wise
BitBufferbit ops and bytes/nested comparison are branchy viewwalks, so neither is the auto-vectorized lane loop this attribute is for; both are left alone.
No workflow change was needed: the walltime job derives its package list by grepping bench
sources for
cpu_features], and that now resolves tovortex-bufferandvortex-compute.collect_boolentry points, per feature setcollect_bool.rstagged only the bare word loops, so the shipped entry points —BitBuffer::collect_boolunder a comparison predicate, and the&[bool]conversion — weremeasured in simulation alone, never on real silicon per feature set, even though predicate
evaluation and allocation sit next to the pack loop in shipped code.
*_dispatchsiblingscarrying
#[cpu_features]now measure both on the legs, sharing a body with the originals. Theoriginals stay untagged and keep their simulation history.
Every operator in the primitive compare benchmarks
The primitive kernel dispatches on the operator outside the lane loop, so each of the six is a
separate instantiation that vectorizes on its own terms.
compare.rsmeasuredGtefor thearray, nullable, constant and float shapes and
Eqfor one of them. Those four shapes are nowparameterized over all six operators, reported as
compare_int[>=]and so on, and the redundantcompare_int_eqis gone. Boolean, string, decimal and struct comparison are unchanged.Checks
cargo bench -p vortex-compute --bench compare_bits -- --test: all ten run under bare names.Same for
-p vortex-buffer --bench collect_booland-p vortex-array --bench compare, thelast showing the six operator cases under each parameterized shape.
VORTEX_BENCH_VARIANT=simulation: every tagged benchmark reports(ignored), everyuntagged one runs.
VORTEX_BENCH_VARIANT=avx2 VORTEX_BENCH_PREFIX=avx2:: VORTEX_BENCH_SUFFIX=_avx2and theworkflow's
.*::avx2::filter: exactly the tagged benchmarks, namedavx2::zip_bits_i64_gte_avx2and so on. Same check under theneonleg's values.zip_bits_i32_gte4.7 µs,zip_bits_i64_gte5.5 µs,zip_bits_i128_gte6.9 µs,zip_bits_f64_lt10.6 µs — the float total-ordering kernel costsroughly twice the integer one, which is the kind of gap the legs exist to move.
cargo +nightly fmt --all -- --checkclean, andcargo clippy --all-targets --all-featuresclean for
vortex-compute,vortex-bufferandvortex-array.Not run: workspace-wide clippy and tests (the change is confined to three crates' bench targets),
and the CodSpeed jobs themselves.