Skip to content

Use NEON packing for wide primitive array comparisons - #10843

Merged
connortsui20 merged 1 commit into
developfrom
ct/compare-wide-packing-arm
Oct 27, 2026
Merged

connortsui20 merged 1 commit into
developfrom
ct/compare-wide-packing-arm

Conversation

@connortsui20

Copy link
Copy Markdown
Member

Related to #10676. This PR is independent of the RowFn stack.

Uses the existing fill-and-pack collector for i64, u64, and f64 array-pair comparisons on AArch64 with NEON. The dispatch and the unchecked slice reads stay inside the private primitive kernel, where equal lengths and the collector's index range establish the bounds proof. Constant comparisons, other primitive types, and validity handling retain their existing paths.

Extends bitmap-boundary tests to wide types with differently sliced inputs, and checks floating-point total ordering through full words and a tail.

Validation: 236 comparison tests and all-target, all-feature Clippy with warnings denied pass on macOS ARM. Native ARM and AVX2 builds pass the 455-case probe (452 reference-array checks plus three unchanged Boolean controls) and 3,456 comparison edge checks plus four arithmetic error checks.

On Graviton3, the two counterbalanced full-suite sessions show the following elapsed-time reductions in the long array-pair cohorts:

Type First session Confirmation Confirmation ratio and paired 95% interval
i64 15.6% 15.4% 0.846 [0.843, 0.856]
u64 14.1% 14.0% 0.860 [0.858, 0.864]
f64 11.8% 11.8% 0.882 [0.880, 0.886]

Each cohort contains 32 cases. Ratios are candidate/baseline, with a geometric mean across cases within a paired block and the median across 21 blocks. Intervals bootstrap those paired block ratios. Neither ARM session has a case crossing the 3% regression threshold with its interval above 1.

The AVX2 baseline and candidate .text sections are byte-for-byte identical. Control timings still vary, so no x86 speedup is attributed to this change. These are public-path microbenchmarks, not query results. Release-profile and AVX-512 timing were not repeated for this ARM-only variant.

Measurement protocol and reproduction

Baseline: a59294e35e7109fac894a7a38a68f42db0c3c967. Candidate: f018aaeef739145e1bc50079a441c5d9fd53404a.

Uses rustc 1.99.1 (LLVM 23.1.1), the repository's bench profile (16 codegen units, no LTO), MiMalloc, and CPU 2. Graviton3 uses -C target-feature=+neon -C force-frame-pointers=yes. The AVX2 control uses -C target-feature=+avx2 -C force-frame-pointers=yes on AMD EPYC 7R13. Optimized LLVM IR and assembly were emitted during both builds, and the resulting executables were timed without instrumentation.

Both labels run the same full 455-case suite in the same order. Each case has exactly 1,000 single-iteration samples. Each session has 21 baseline/baseline calibration blocks and 21 paired baseline/candidate blocks with alternating executable order. Separate arithmetic control binaries bracket the blocks. The two hosts are measured serially, and a second session confirms the result. The confirmation session reverses the starting order, so the combined sessions contain equal AB and BA counts. Preliminary fixed-order source comparisons were excluded, while valid identical-binary calibration was retained. The long-array cohorts each contain 32 cases, including partially valid inputs and lengths around the 16K boundary.

To reproduce, save the harness below as vortex-array/benches/interaction_probe.rs on each revision and add this target to vortex-array/Cargo.toml:

[[bench]]
name = "interaction_probe"
harness = false

Set RUSTFLAGS to the host flags above. Build both revisions with those same flags, retain separate immutable executables, and alternate the commands below. Unset VORTEX_PERF_CHECK for timing.

cargo +1.99.1 build --locked -p vortex-array --bench interaction_probe --profile bench
VORTEX_PERF_CHECK=1 "$binary" --test --color never
taskset -c 2 "$binary" --bench --color never --timer os --sample-count 1000 --sample-size 1 --skip-ext-time true
Exact benchmark harness

The measured harness SHA-256 is 0a7de7999381d237ad0471716107f7e1972a7c6b11c0936a7d3b7d8c15b6c37d.

// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright the Vortex contributors

//! Common public-path inputs for the RowFn interaction experiments.

#![expect(clippy::unwrap_used)]
#![expect(clippy::cast_possible_truncation)]
#![expect(clippy::cast_precision_loss)]

use std::fmt;
use divan::Bencher;
use mimalloc::MiMalloc;
use vortex_array::{ArrayRef, Canonical, IntoArray, VortexSessionExecute};
use vortex_array::arrays::{BoolArray, ConstantArray, PrimitiveArray};
use vortex_array::builtins::ArrayBuiltins;
use vortex_array::dtype::NativePType;
use vortex_array::scalar::Scalar;
use vortex_array::scalar_fn::fns::operators::Operator;
use vortex_array::validity::Validity;
use vortex_buffer::Buffer;

#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;

fn main() {
    if check_enabled() { eprintln!("detected SIMD level: {:?}", fearless_simd::Level::new()); }
    divan::main();
}

#[derive(Clone, Copy, Debug)]
enum Shape { Arrays, LeftConstant, RightConstant }
#[derive(Clone, Copy, Debug)]
enum InputValidity { Dense, AllValid, Nullable, Sparse }
#[derive(Clone, Copy, Debug)]
enum Comparison { Eq, Gte }
#[derive(Clone, Copy)]
struct Case { rows: usize, shape: Shape, validity: InputValidity, op: Comparison }
impl fmt::Display for Case {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "rows={},shape={:?},validity={:?},op={:?}", self.rows, self.shape, self.validity, self.op)
    }
}
const CASES: &[Case] = &[
    Case { rows: 64, shape: Shape::Arrays, validity: InputValidity::Dense, op: Comparison::Eq },
    Case { rows: 64, shape: Shape::Arrays, validity: InputValidity::Dense, op: Comparison::Gte },
    Case { rows: 64, shape: Shape::Arrays, validity: InputValidity::AllValid, op: Comparison::Eq },
    Case { rows: 64, shape: Shape::Arrays, validity: InputValidity::AllValid, op: Comparison::Gte },
    Case { rows: 64, shape: Shape::Arrays, validity: InputValidity::Nullable, op: Comparison::Eq },
    Case { rows: 64, shape: Shape::Arrays, validity: InputValidity::Nullable, op: Comparison::Gte },
    Case { rows: 64, shape: Shape::Arrays, validity: InputValidity::Sparse, op: Comparison::Eq },
    Case { rows: 64, shape: Shape::Arrays, validity: InputValidity::Sparse, op: Comparison::Gte },
    Case { rows: 64, shape: Shape::LeftConstant, validity: InputValidity::Dense, op: Comparison::Eq },
    Case { rows: 64, shape: Shape::LeftConstant, validity: InputValidity::Dense, op: Comparison::Gte },
    Case { rows: 64, shape: Shape::LeftConstant, validity: InputValidity::AllValid, op: Comparison::Eq },
    Case { rows: 64, shape: Shape::LeftConstant, validity: InputValidity::AllValid, op: Comparison::Gte },
    Case { rows: 64, shape: Shape::LeftConstant, validity: InputValidity::Nullable, op: Comparison::Eq },
    Case { rows: 64, shape: Shape::LeftConstant, validity: InputValidity::Nullable, op: Comparison::Gte },
    Case { rows: 64, shape: Shape::LeftConstant, validity: InputValidity::Sparse, op: Comparison::Eq },
    Case { rows: 64, shape: Shape::LeftConstant, validity: InputValidity::Sparse, op: Comparison::Gte },
    Case { rows: 64, shape: Shape::RightConstant, validity: InputValidity::Dense, op: Comparison::Eq },
    Case { rows: 64, shape: Shape::RightConstant, validity: InputValidity::Dense, op: Comparison::Gte },
    Case { rows: 64, shape: Shape::RightConstant, validity: InputValidity::AllValid, op: Comparison::Eq },
    Case { rows: 64, shape: Shape::RightConstant, validity: InputValidity::AllValid, op: Comparison::Gte },
    Case { rows: 64, shape: Shape::RightConstant, validity: InputValidity::Nullable, op: Comparison::Eq },
    Case { rows: 64, shape: Shape::RightConstant, validity: InputValidity::Nullable, op: Comparison::Gte },
    Case { rows: 64, shape: Shape::RightConstant, validity: InputValidity::Sparse, op: Comparison::Eq },
    Case { rows: 64, shape: Shape::RightConstant, validity: InputValidity::Sparse, op: Comparison::Gte },
    Case { rows: 8192, shape: Shape::Arrays, validity: InputValidity::Dense, op: Comparison::Eq },
    Case { rows: 8192, shape: Shape::Arrays, validity: InputValidity::Dense, op: Comparison::Gte },
    Case { rows: 8192, shape: Shape::Arrays, validity: InputValidity::AllValid, op: Comparison::Eq },
    Case { rows: 8192, shape: Shape::Arrays, validity: InputValidity::AllValid, op: Comparison::Gte },
    Case { rows: 8192, shape: Shape::Arrays, validity: InputValidity::Nullable, op: Comparison::Eq },
    Case { rows: 8192, shape: Shape::Arrays, validity: InputValidity::Nullable, op: Comparison::Gte },
    Case { rows: 8192, shape: Shape::Arrays, validity: InputValidity::Sparse, op: Comparison::Eq },
    Case { rows: 8192, shape: Shape::Arrays, validity: InputValidity::Sparse, op: Comparison::Gte },
    Case { rows: 8192, shape: Shape::LeftConstant, validity: InputValidity::Dense, op: Comparison::Eq },
    Case { rows: 8192, shape: Shape::LeftConstant, validity: InputValidity::Dense, op: Comparison::Gte },
    Case { rows: 8192, shape: Shape::LeftConstant, validity: InputValidity::AllValid, op: Comparison::Eq },
    Case { rows: 8192, shape: Shape::LeftConstant, validity: InputValidity::AllValid, op: Comparison::Gte },
    Case { rows: 8192, shape: Shape::LeftConstant, validity: InputValidity::Nullable, op: Comparison::Eq },
    Case { rows: 8192, shape: Shape::LeftConstant, validity: InputValidity::Nullable, op: Comparison::Gte },
    Case { rows: 8192, shape: Shape::LeftConstant, validity: InputValidity::Sparse, op: Comparison::Eq },
    Case { rows: 8192, shape: Shape::LeftConstant, validity: InputValidity::Sparse, op: Comparison::Gte },
    Case { rows: 8192, shape: Shape::RightConstant, validity: InputValidity::Dense, op: Comparison::Eq },
    Case { rows: 8192, shape: Shape::RightConstant, validity: InputValidity::Dense, op: Comparison::Gte },
    Case { rows: 8192, shape: Shape::RightConstant, validity: InputValidity::AllValid, op: Comparison::Eq },
    Case { rows: 8192, shape: Shape::RightConstant, validity: InputValidity::AllValid, op: Comparison::Gte },
    Case { rows: 8192, shape: Shape::RightConstant, validity: InputValidity::Nullable, op: Comparison::Eq },
    Case { rows: 8192, shape: Shape::RightConstant, validity: InputValidity::Nullable, op: Comparison::Gte },
    Case { rows: 8192, shape: Shape::RightConstant, validity: InputValidity::Sparse, op: Comparison::Eq },
    Case { rows: 8192, shape: Shape::RightConstant, validity: InputValidity::Sparse, op: Comparison::Gte },
    Case { rows: 65536, shape: Shape::Arrays, validity: InputValidity::Dense, op: Comparison::Eq },
    Case { rows: 65536, shape: Shape::Arrays, validity: InputValidity::Dense, op: Comparison::Gte },
    Case { rows: 65536, shape: Shape::Arrays, validity: InputValidity::AllValid, op: Comparison::Eq },
    Case { rows: 65536, shape: Shape::Arrays, validity: InputValidity::AllValid, op: Comparison::Gte },
    Case { rows: 65536, shape: Shape::Arrays, validity: InputValidity::Nullable, op: Comparison::Eq },
    Case { rows: 65536, shape: Shape::Arrays, validity: InputValidity::Nullable, op: Comparison::Gte },
    Case { rows: 65536, shape: Shape::Arrays, validity: InputValidity::Sparse, op: Comparison::Eq },
    Case { rows: 65536, shape: Shape::Arrays, validity: InputValidity::Sparse, op: Comparison::Gte },
    Case { rows: 65536, shape: Shape::LeftConstant, validity: InputValidity::Dense, op: Comparison::Eq },
    Case { rows: 65536, shape: Shape::LeftConstant, validity: InputValidity::Dense, op: Comparison::Gte },
    Case { rows: 65536, shape: Shape::LeftConstant, validity: InputValidity::AllValid, op: Comparison::Eq },
    Case { rows: 65536, shape: Shape::LeftConstant, validity: InputValidity::AllValid, op: Comparison::Gte },
    Case { rows: 65536, shape: Shape::LeftConstant, validity: InputValidity::Nullable, op: Comparison::Eq },
    Case { rows: 65536, shape: Shape::LeftConstant, validity: InputValidity::Nullable, op: Comparison::Gte },
    Case { rows: 65536, shape: Shape::LeftConstant, validity: InputValidity::Sparse, op: Comparison::Eq },
    Case { rows: 65536, shape: Shape::LeftConstant, validity: InputValidity::Sparse, op: Comparison::Gte },
    Case { rows: 65536, shape: Shape::RightConstant, validity: InputValidity::Dense, op: Comparison::Eq },
    Case { rows: 65536, shape: Shape::RightConstant, validity: InputValidity::Dense, op: Comparison::Gte },
    Case { rows: 65536, shape: Shape::RightConstant, validity: InputValidity::AllValid, op: Comparison::Eq },
    Case { rows: 65536, shape: Shape::RightConstant, validity: InputValidity::AllValid, op: Comparison::Gte },
    Case { rows: 65536, shape: Shape::RightConstant, validity: InputValidity::Nullable, op: Comparison::Eq },
    Case { rows: 65536, shape: Shape::RightConstant, validity: InputValidity::Nullable, op: Comparison::Gte },
    Case { rows: 65536, shape: Shape::RightConstant, validity: InputValidity::Sparse, op: Comparison::Eq },
    Case { rows: 65536, shape: Shape::RightConstant, validity: InputValidity::Sparse, op: Comparison::Gte },
    Case { rows: 16383, shape: Shape::Arrays, validity: InputValidity::Nullable, op: Comparison::Eq },
    Case { rows: 16383, shape: Shape::Arrays, validity: InputValidity::Nullable, op: Comparison::Gte },
    Case { rows: 16383, shape: Shape::Arrays, validity: InputValidity::Sparse, op: Comparison::Eq },
    Case { rows: 16383, shape: Shape::Arrays, validity: InputValidity::Sparse, op: Comparison::Gte },
    Case { rows: 16384, shape: Shape::Arrays, validity: InputValidity::Nullable, op: Comparison::Eq },
    Case { rows: 16384, shape: Shape::Arrays, validity: InputValidity::Nullable, op: Comparison::Gte },
    Case { rows: 16384, shape: Shape::Arrays, validity: InputValidity::Sparse, op: Comparison::Eq },
    Case { rows: 16384, shape: Shape::Arrays, validity: InputValidity::Sparse, op: Comparison::Gte },
    Case { rows: 16385, shape: Shape::Arrays, validity: InputValidity::Nullable, op: Comparison::Eq },
    Case { rows: 16385, shape: Shape::Arrays, validity: InputValidity::Nullable, op: Comparison::Gte },
    Case { rows: 16385, shape: Shape::Arrays, validity: InputValidity::Sparse, op: Comparison::Eq },
    Case { rows: 16385, shape: Shape::Arrays, validity: InputValidity::Sparse, op: Comparison::Gte },
    Case { rows: 32768, shape: Shape::Arrays, validity: InputValidity::Nullable, op: Comparison::Eq },
    Case { rows: 32768, shape: Shape::Arrays, validity: InputValidity::Nullable, op: Comparison::Gte },
    Case { rows: 32768, shape: Shape::Arrays, validity: InputValidity::Sparse, op: Comparison::Eq },
    Case { rows: 32768, shape: Shape::Arrays, validity: InputValidity::Sparse, op: Comparison::Gte },
];

trait Input: NativePType + Into<Scalar> {
    fn value(index: usize, offset: usize) -> Self;
}
macro_rules! input {
    ($($ty:ty),*) => {$(
        impl Input for $ty {
            fn value(index: usize, offset: usize) -> Self { ((index * 17 + offset * 31) % 251) as Self }
        }
    )*};
}
input!(u8, i64, u64, f32, f64);
fn validity(kind: InputValidity, rows: usize, offset: usize) -> Validity {
    match kind {
        InputValidity::Dense => Validity::NonNullable,
        InputValidity::AllValid => Validity::AllValid,
        InputValidity::Nullable => Validity::from_iter((0..rows).map(|i| (i + offset) % 10 != 0)),
        InputValidity::Sparse => Validity::from_iter((0..rows).map(|i| i % 10 == 0)),
    }
}
fn array<T: Input>(case: Case, offset: usize) -> ArrayRef {
    PrimitiveArray::new((0..case.rows).map(|i| T::value(i,offset)).collect::<Buffer<_>>(), validity(case.validity,case.rows,offset)).into_array()
}
fn operands<T: Input>(case: Case) -> (ArrayRef, ArrayRef) {
    let lhs=match case.shape { Shape::LeftConstant => ConstantArray::new(T::value(13,0),case.rows).into_array(), _ => array::<T>(case,0) };
    let rhs=match case.shape { Shape::RightConstant => ConstantArray::new(T::value(13,0),case.rows).into_array(), _ => array::<T>(case,1) };
    (lhs,rhs)
}
fn scalar_inputs<T: Input>(case: Case, index: usize) -> (T,T) {
    let lhs=match case.shape { Shape::LeftConstant => T::value(13,0), _ => T::value(index,0) };
    let rhs=match case.shape { Shape::RightConstant => T::value(13,0), _ => T::value(index,1) };
    (lhs,rhs)
}
fn check_enabled() -> bool { std::env::var_os("VORTEX_PERF_CHECK").is_some() }
fn measure(bencher: Bencher, lhs: ArrayRef, rhs: ArrayRef, op: Operator) {
    let session=vortex_session::VortexSession::empty();
    bencher.with_inputs(|| (&lhs,&rhs,session.create_execution_ctx())).bench_refs(|(lhs,rhs,ctx)| {
        lhs.clone().binary(rhs.clone(),op).unwrap().execute::<Canonical>(ctx).unwrap()
    });
}
#[divan::bench(args=CASES, types=[u8,i64,u64,f32,f64])]
fn compare<T: Input>(bencher: Bencher, case: &Case) {
    let case=*case;
    let (lhs,rhs)=operands::<T>(case);
    let op=match case.op { Comparison::Eq => Operator::Eq, Comparison::Gte => Operator::Gte };
    if check_enabled() {
        let session=vortex_session::VortexSession::empty();
        let mut ctx=session.create_execution_ctx();
        let actual=lhs.clone().binary(rhs.clone(),op).unwrap().execute::<Canonical>(&mut ctx).unwrap().into_array();
        let expected_values=(0..case.rows).map(|i| { let(a,b)=scalar_inputs::<T>(case,i); match case.op {Comparison::Eq=>a.is_eq(b),Comparison::Gte=>a.is_ge(b)} }).collect();
        let expected=BoolArray::new(expected_values,lhs.validity().unwrap().and(rhs.validity().unwrap()).unwrap()).into_array();
        vortex_array::assert_arrays_eq!(&actual,&expected,&mut ctx);
    }
    measure(bencher,lhs,rhs,op);
}
trait Arithmetic: Input {
    fn multiply(self, other: Self) -> Self;
    fn add(self, other: Self) -> Self;
}
impl Arithmetic for i64 { fn multiply(self, other: Self)->Self{self*other} fn add(self,other:Self)->Self{self+other} }
impl Arithmetic for u64 { fn multiply(self, other: Self)->Self{self*other} fn add(self,other:Self)->Self{self+other} }
#[divan::bench(args=[64,8192,32768], types=[i64,u64])]
fn multiply<T: Arithmetic>(bencher: Bencher, rows: usize) {
    let case=Case{rows,shape:Shape::Arrays,validity:InputValidity::Dense,op:Comparison::Eq};
    let(lhs,rhs)=operands::<T>(case);
    if check_enabled(){
        let session=vortex_session::VortexSession::empty();let mut ctx=session.create_execution_ctx();
        let actual=lhs.clone().binary(rhs.clone(),Operator::Mul).unwrap().execute::<Canonical>(&mut ctx).unwrap().into_array();
        let expected=PrimitiveArray::new((0..rows).map(|i|{let(a,b)=scalar_inputs::<T>(case,i);a.multiply(b)}).collect::<Buffer<_>>(),Validity::NonNullable).into_array();
        vortex_array::assert_arrays_eq!(&actual,&expected,&mut ctx);
    }
    measure(bencher,lhs,rhs,Operator::Mul);
}
#[divan::bench(args=[64,8192,32768], types=[i64,u64])]
fn add_nullable<T: Arithmetic>(bencher: Bencher, rows: usize) {
    let case=Case{rows,shape:Shape::RightConstant,validity:InputValidity::Nullable,op:Comparison::Eq};
    let(lhs,rhs)=operands::<T>(case);
    if check_enabled(){
        let session=vortex_session::VortexSession::empty();let mut ctx=session.create_execution_ctx();
        let actual=lhs.clone().binary(rhs.clone(),Operator::Add).unwrap().execute::<Canonical>(&mut ctx).unwrap().into_array();
        let expected=PrimitiveArray::new((0..rows).map(|i|{let(a,b)=scalar_inputs::<T>(case,i);Arithmetic::add(a,b)}).collect::<Buffer<_>>(),lhs.validity().unwrap()).into_array();
        vortex_array::assert_arrays_eq!(&actual,&expected,&mut ctx);
    }
    measure(bencher,lhs,rhs,Operator::Add);
}
#[divan::bench(args=[64,8192,65536])]
fn bool_control(bencher: Bencher, rows: usize) {
    let lhs=BoolArray::from_iter((0..rows).map(|i|i%3==0)).into_array();
    let rhs=BoolArray::from_iter((0..rows).map(|i|i%5==0)).into_array();
    measure(bencher,lhs,rhs,Operator::Eq);
}

Use the existing fill-and-pack collector for i64, u64, and f64 array pairs on AArch64 with NEON. Keep this dispatch and its bounds proof inside the private primitive comparison kernel. Extend word-boundary coverage to wide types with sliced inputs and exercise floating-point total ordering through full words and a tail.

Signed-off-by: Connor Tsui <connor.tsui20@gmail.com>
@codspeed

codspeed Bot commented Oct 26, 2026

Copy link
Copy Markdown

Merging this PR will regress 1 benchmark

4 warnings, 1 note

⚠️ 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

⚠️ Unable to measure the execution of 2 benchmarks

These benchmarks measured no execution time, so their results are not comparable and count as unchanged. This usually happens when the compiler removes the code under test.

Prevent compiler optimizations

See affected benchmarks

⚠️ 22 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

⚠️ Base and head ran on different runner types

Some benchmarks with significant performance changes were compared across different runner types, so the changes might not come from the code. Run base and head on the same runner type, or use CodSpeed Macro Runners for more stable results.

Learn more about Macro Runners

Open the report in CodSpeed to investigate

ℹ️ 40 benchmarks have no flamegraph

The flamegraph wasn't generated: the symbols of the profiling trace could not be resolved, because no information about the language frames was collected.

See affected benchmarks

⚡ 2 improved benchmarks
❌ 1 regressed benchmark
✅ 2436 untouched benchmarks
⏩ 12 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
❌ CPU Simulation test_repeated_scan_scalar_at[int_str_float] 472.4 µs 538.1 µs -12.2%
⚡ Walltime compare_u64_neon 4.3 µs 3.9 µs +11.1%
⚡ Walltime lt_i64_nullable_neon 3.2 µs 2.9 µs +10.58%

Tip

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


Comparing ct/compare-wide-packing-arm (f018aae) with develop (a59294e)

Open in CodSpeed

Footnotes

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

@connortsui20
connortsui20 marked this pull request as ready for review October 26, 2026 21:44
@connortsui20 connortsui20 added the changelog/performance A performance improvement label Oct 26, 2026
f: impl Fn(T, T) -> bool,
allocator: &BufferAllocatorRef,
) -> BitBuffer {
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]

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.

aarch64 => neon

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.

I don't think neon is an architecture though?

@joseph-isaacs joseph-isaacs left a comment

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.

Why not on avx...

@connortsui20
connortsui20 merged commit cf2fb6b into develop Oct 27, 2026
153 of 156 checks passed
@connortsui20
connortsui20 deleted the ct/compare-wide-packing-arm branch October 27, 2026 13:35
@connortsui20

Copy link
Copy Markdown
Member Author

@joseph-isaacs

From Claude so take with a grain of salt:

The short answer is that x86 can turn a vector compare into a bitmask in one instruction, at every element width. NEON can't. The lane kernel is near-optimal on x86 and expensive on ARM.

Details Now the two lower-level kernels: the existing `collect_zip_bits` lane kernel and the multiversioned fill-and-pack loop.

The short answer is that x86 can turn a vector compare into a bitmask in one instruction, at every element width. NEON can't. The lane kernel is near-optimal on x86 and expensive on ARM. This is reasoning from the code, not from reading the generated assembly, so treat it as a hypothesis.

The two kernel shapes

  • Lane kernel (collect_zip_bits in vortex-compute/src/lane_kernels/map_into.rs:266): for each 64-lane chunk it computes packed |= (f(a, b) as u64) << bit_idx. LLVM has to spot this shift-and-OR reduction and lower it to "vector compare, then movemask".
  • Fill-and-pack (collect_bool_words_with in vortex-buffer/src/bit/pack.rs): it writes 64 compare results into a [bool; 64] byte array. Then it packs that array with a byte-level pack kernel (pack_bool_word_neon, _avx2, and so on).

Why x86 gains nothing

  • AVX2 has vmovmskpd, which takes the 4 x i64 compare mask straight to 4 bits in a general register. AVX-512 is even better: vpcmpq writes a k-mask register directly. LLVM knows this pattern well, so the lane kernel becomes roughly load, compare, movmsk, shift/or, with no wasted work.
  • Fill-and-pack on AVX2 must first narrow the i64 masks to bytes. AVX2's pack instructions (vpackssdw, vpacksswb) only work inside each 128-bit half, so you also need vpermq to fix the lane order. Then vpmovmskb packs the bytes. That gives the same bitmask with more shuffle work, and the shuffles are often the bottleneck.
  • On baseline x86-64 (SSE2 only), there are no native 64-bit compares (pcmpeqq needs SSE4.1, pcmpgtq needs SSE4.2). Both kernels are limited by emulating the compare, so a better packer doesn't help.
  • The existing comment in collect_bits_dispatch says wide types kept the lane kernel to avoid AVX2 constant-case regressions. So fill-and-pack has already been measured losing or tying on x86 for these types.

Why NEON gains

  • AArch64 has no movemask. Every bitmask has to be built by AND-ing with bit-weight constants and then reducing across lanes (addp/addv or an OR tree).
  • With 64-bit elements, a 128-bit NEON register holds only 2 lanes. The lane kernel pays that bitmask cost for every 2 elements. It also needs a different shift or weight constant for each position in the 64-bit word, which adds register pressure or constant-pool loads. LLVM may not vectorize it at all and fall back to scalar cmp/cset/orr ... lsl.
  • Fill-and-pack narrows first. The uzp1/xtn narrowing ops are cheap and have high throughput on Neoverse V1 (Graviton3). It then runs the bitmask step once per 16 byte lanes, so the expensive step is shared across 8 times as many elements. That fits a roughly 12 to 15% gain.
  • On aarch64, collect_bool_words_multiversioned has no runtime dispatch. It falls straight through to the inline NEON path, so there is no #[target_feature] call boundary to pay for. On x86 that boundary is real and is part of why the docs warn about using the multiversioned path.

Two side notes

  • "AVX2 .text byte-for-byte identical" in the PR body only shows that the cfg gate works. It is not evidence about whether fill-and-pack would help on x86. The real x86 evidence is the earlier regression that led to the existing dispatch comment.
  • Constants keep the old path even on NEON. With a broadcast operand, the lane kernel has half the loads, so the narrowing overhead may not pay for itself there. That would be worth checking before anyone extends this.

If you want to confirm the core claim cheaply, look at the aarch64 assembly for the i64 lane kernel. If it is scalar cset/orr lsl or a 2-lane AND-and-reduce, that explains the gap.

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.

3 participants