Skip to content

Benchmark comparison kernels on the CPU-feature legs - #9541

Draft
joseph-isaacs wants to merge 3 commits into
developfrom
claude/codspeed-comparison-benchmarks-k1uyp0
Draft

Benchmark comparison kernels on the CPU-feature legs#9541
joseph-isaacs wants to merge 3 commits into
developfrom
claude/codspeed-comparison-benchmarks-k1uyp0

Conversation

@joseph-isaacs

@joseph-isaacs joseph-isaacs commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Every native Vortex comparison over primitive and decimal lanes bottoms out in
IndexedSourceExt::map_bits_into: vortex-array's collect_zip_bits / collect_bits
(scalar_fn/fns/binary/compare/mod.rs) are a BufferMut<u64> allocation plus one call into that
kernel. 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, and
vortex-compute/benches/lane_kernels.rs benches the cast and LaneZip add kernels but never
map_bits_into.

Before this PR, vortex-buffer/benches/collect_bool.rs was the only file in the workspace
carrying #[cpu_features], so the walltime CPU-feature legs measured bit packing and nothing
else. 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_* and const_bits_* measure the kernel alone, writing into caller-owned words: i32,
i64, total-ordered f64 (total_cmp, matching NativePType::is_lt), and i128 for the
decimal path, array-vs-array via LaneZip and array-vs-constant over a plain slice. They carry
#[vortex_bench_support::cpu_features], so each walltime leg reports its own series. That is the
shape the attribute is for: one branch-free lane loop whose entire cost is how well the build
auto-vectorizes it.

collect_zip_bits_* and collect_bits_* wrap the same kernels in the allocate-and-freeze that
vortex-array performs. They stay untagged and run in the sharded simulation job, where that
wrapper is the part instruction counts catch. The split is forced rather than chosen:
#[cpu_features] expands to ignore = env!("VORTEX_BENCH_VARIANT") == "simulation", so a tagged
benchmark cannot also run in simulation — the same structure collect_bool.rs already uses
(words_gather_dispatch tagged, from_bool_slice not).

Boolean comparison is word-wise BitBuffer bit ops and bytes/nested comparison are branchy view
walks, 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 to vortex-buffer and vortex-compute.

collect_bool entry points, per feature set

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, even though predicate
evaluation and allocation sit next to the pack loop in shipped code. *_dispatch siblings
carrying #[cpu_features] now measure both on the legs, sharing a body with the originals. The
originals 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.rs measured Gte for the
array, nullable, constant and float shapes and Eq for one of them. Those four shapes are now
parameterized over all six operators, reported as compare_int[>=] and so on, and the redundant
compare_int_eq is 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_bool and -p vortex-array --bench compare, the
    last showing the six operator cases under each parameterized shape.
  • With VORTEX_BENCH_VARIANT=simulation: every tagged benchmark reports (ignored), every
    untagged one runs.
  • With VORTEX_BENCH_VARIANT=avx2 VORTEX_BENCH_PREFIX=avx2:: VORTEX_BENCH_SUFFIX=_avx2 and the
    workflow's .*::avx2:: filter: exactly the tagged benchmarks, named
    avx2::zip_bits_i64_gte_avx2 and so on. Same check under the neon leg's values.
  • A real run at 50 samples over 8,192 lanes: zip_bits_i32_gte 4.7 µs, zip_bits_i64_gte 5.5 µs,
    zip_bits_i128_gte 6.9 µs, zip_bits_f64_lt 10.6 µs — the float total-ordering kernel costs
    roughly twice the integer one, which is the kind of gap the legs exist to move.
  • cargo +nightly fmt --all -- --check clean, and cargo clippy --all-targets --all-features
    clean for vortex-compute, vortex-buffer and vortex-array.

Not run: workspace-wide clippy and tests (the change is confined to three crates' bench targets),
and the CodSpeed jobs themselves.

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>
@joseph-isaacs joseph-isaacs changed the title Benchmark comparison lane kernels on the CPU-feature legs Benchmark comparison kernels on the CPU-feature legs Aug 21, 2026
@codspeed-hq

codspeed-hq Bot commented Aug 21, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 26.06%

⚠️ Unknown Walltime execution environment detected

Using the Walltime instrument on standard Hosted Runners will lead to inconsistent data.

For the most accurate results, we recommend using CodSpeed Macro Runners: bare-metal machines fine-tuned for performance measurement consistency.

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 2 improved benchmarks
✅ 1974 untouched benchmarks
🆕 60 new benchmarks
⏩ 59 skipped benchmarks1

Performance Changes

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)

Open in CodSpeed

Footnotes

  1. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant