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 <[email protected]>
@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.

connortsui20 added a commit that referenced this pull request Nov 5, 2026
Tracking Issue: #9128

## Summary

Combines planning and execution for eligible RowFn batches in one type
dispatch. This is the shared framework layer for #10676, which contains
the primitive comparison migration. It builds on #10899, which separates
batch inspection from planning and adds the regression cases this PR
must keep passing.

## Changes

- Decodes a direct Boolean batch straight from the borrowed inputs,
after `BatchProbe` has classified all of them, so a later null literal
still skips an earlier fallible expression or device transfer. Fallible
decoders keep valid-row execution.
- Finishes all-null, all-constant, null-free, and dense batches in the
planning visit. Every other batch comes back as a `PartiallyValidBatch`,
the only input that dense retry and valid-row execution accept, so every
caller runs the fast paths first.
- Constructs Boolean output with its validity in the shared executor.
Dense execution masks any output that does not carry that validity, and
a validity of the wrong length returns an error. Primitive-specific CPU
and lane-width packing policies stay out of this layer.
- In builds that enable AVX-512F and AVX-512BW at compile time,
`collect_bool_words_multiversioned` uses the inline packing loop instead
of the `#[target_feature]` copy, so its inlining no longer depends on
codegen-unit placement.

## Performance

This PR lowers the fixed cost of each RowFn call, so CodSpeed shows no
gain for it alone. The gain shows up in #10676, which moves primitive
comparison onto the RowFn executor. To isolate this PR, #11220 applied
the two #10676 commits directly on `develop`. Without this PR,
comparisons on small arrays are 10% to 16% slower than `develop` in CPU
simulation, including the zone map pruning benchmarks, `i64_lt`, and
`timestamp_gte`. The added time barely changes between 16 and 1,024
zones (18.0 µs and 17.3 µs for `int_gt`), so it is paid per call, not
per row. On top of this PR, #10676 shows none of these regressions, and
its walltime comparison gains are larger: `compare_u64_avx2` improves by
31% instead of 16%, and seven more comparison benchmarks improve by 12%
to 18%.

In the AVX-512 build, the RowFn changes moved the deferred Boolean
kernel's AVX-512 packing loop out of its caller's codegen unit, so LLVM
could no longer inline it. CodSpeed reported the two partially valid
`deferred_bool_avx512` cases as 42% and 16% slower. With the last
commit, CodSpeed no longer flags either case. Builds that detect AVX-512
at runtime compile the same code as before. #11220 also shows
`compare_u8_avx512` and `compare_f32_avx512` as 14% and 10% slower
without this PR, which matches the same packing problem, but that
build's assembly wasn't inspected.

CodSpeed on the current head flags three varbin comparison cases and
`random_i8[0.01]` as 12% to 18% slower, and one `take_filter` case as
13% faster. None of them runs a RowFn. #10676 reverses the varbin and
`take_filter` changes without touching their code, so they follow code
placement. `random_i8[0.01]` isn't a stable signal either: its `develop`
baseline moved from 99.6 µs to 119.3 µs between `bc58587` and `c9a3fbe`,
and #11220 moved it 13% the other way.

<details>
<summary>CodSpeed results for #10676 with and without this PR</summary>

The first column is #11220, which compares the #10676 commits on
`develop` with `develop` (`c9a3fbe`). The second column is the #10676
report, which compares the same commits on this PR with this PR
(`b0a64dd`). Against `develop` (`bc58587`), this PR flags none of these
benchmarks. CodSpeed marks the walltime runners as unreliable, and the
two columns come from different runs.

| Benchmark | Mode | Without this PR | With this PR |
| --- | --- | ---: | ---: |
| `int_gt[16]` | CPU simulation | 15.6% slower | not flagged |
| `int_gt[1024]` | CPU simulation | 14.5% slower | not flagged |
| `float_gt[16]` | CPU simulation | 12.2% slower | not flagged |
| `float_gt[1024]` | CPU simulation | 12.1% slower | not flagged |
| `or_chain[16]` | CPU simulation | 12.3% slower | not flagged |
| `or_chain[1024]` | CPU simulation | 11.9% slower | not flagged |
| `is_not_null_pred[16]` | CPU simulation | 11.6% slower | not flagged |
| `is_not_null_pred[1024]` | CPU simulation | 10.5% slower | not flagged
|
| `i64_lt` | CPU simulation | 11.7% slower | not flagged |
| `timestamp_gte` | CPU simulation | 10.3% slower | not flagged |
| `bench_compare_sliced_dict_primitive[(1000, 10000)]` | CPU simulation
| 11.5% slower | not flagged |
| `encode_primitives[f32, (2000, 2)]` | CPU simulation | 12.8% slower |
not flagged |
| `compare_u8_avx512` | Walltime | 14.4% slower | not flagged |
| `compare_u8_neon` | Walltime | 13.3% slower | not flagged |
| `compare_f32_avx512` | Walltime | 10.4% slower | not flagged |
| `compare_float_avx2` | Walltime | 18.4% faster | 28.2% faster |
| `compare_u64_avx2` | Walltime | 15.7% faster | 30.8% faster |
| `lt_i64_nullable_avx2` | Walltime | not flagged | 18.4% faster |
| `compare_int_constant_neon` | Walltime | not flagged | 16.2% faster |
| `compare_int_eq_avx2` | Walltime | not flagged | 15.4% faster |
| `compare_f32_avx2` | Walltime | not flagged | 15.4% faster |
| `compare_int_constant_avx2` | Walltime | not flagged | 15.3% faster |
| `compare_int_avx2` | Walltime | not flagged | 13.5% faster |
| `compare_int_nullable_avx2` | Walltime | not flagged | 12.5% faster |

</details>

<details>
<summary>AVX-512 investigation on EC2</summary>

C7i metal (Xeon Platinum 8488C) with the CodSpeed AVX-512 build flags,
the bench profile, one isolated CPU, and 21 paired blocks of 1,000
samples. Ratios are the median paired ratio to `develop` (`8c87c4c457`).
Below 1 is faster. An A/A comparison of two identical binaries flags
none of the 100 cases.

| `deferred_bool_avx512` case | `develop` | Before fix | With fix |
| --- | ---: | ---: | ---: |
| `ConstantLhs, PartialAccepted` | 32.1 µs | 1.354 | 1.025 |
| `Columns, PartialAccepted` | 37.7 µs | 1.159 | 1.018 |

Before the fix, the kernel executed 15% more instructions per row. LLVM
inline remarks show `collect_bool_words_avx512` inlined on `develop` and
"definition is unavailable" on this branch.

Across all 100 `#[cpu_features]` cases, the fix also returns
`compare_string_lt` to the `develop` speed. It makes two
`intersect_by_rank` cases and one `starts_with` case 3.5% to 5.6%
slower, but those execute the same instructions as before, so the
difference is code placement. The AVX2 build is unchanged.

</details>

<details>
<summary>Historical EC2 framework measurements</summary>

These numbers belong to an older framework replay and do not establish
the performance of this revision. Ratios are the median of 21 paired
candidate/baseline elapsed-time ratios. Below 1 is faster. This compares
the framework alone, F `94be28140d5bd7e6b654af5e462253add2e49044`, with
D `0291778db36809f77ce0219bf9850506d3cba3b6`.

| Workload | C7g NEON | C6a AVX2 | C7i AVX2 build | C7i AVX-512 build |
| --- | ---: | ---: | ---: | ---: |
| `u64` multiply, 8,192 rows | 1.732 | 0.979 | 0.992 | 0.998 |
| Nullable `i64` add, 32,768 rows | 1.006 | 1.004 | 1.154 | 1.005 |

The actual framework pair order was imbalanced: D ran before F in 16
blocks and after it in five. Treat these figures as diagnostic
observations, not reliable causal effect sizes.

EC2 C7g (Neoverse V1), C6a (EPYC 7R13), and C7i (Xeon Platinum 8488C).
Rust 1.99.1 / LLVM 23.1.1, bench profile with 16 CGUs and no LTO,
MiMalloc, CPU 2 pinned. The same 455-case public-path microbenchmark
suite ran serially across hosts, with 21 paired blocks, 10 ms per case,
A/A calibration, and a separate control executable. These are
microbenchmarks, not query results. C7i can dispatch to AVX-512 at
runtime even in its AVX2 build.

</details>

The separately confirmed NEON comparison gains were published in #10843,
which has merged. They do not measure this framework.

CI passed on `b0a64dd`, including all-target, all-feature Clippy with
warnings denied and the Linux, Windows, sanitizer, and Miri test jobs.
The CodSpeed check fails on the flags described above.

---------

Signed-off-by: Connor Tsui <[email protected]>
connortsui20 added a commit that referenced this pull request Nov 12, 2026
Tracking Issue: #9128

## Summary

Moves primitive comparisons onto the shared RowFn executor from #10675,
so comparison code only selects the predicate while the framework
handles decoding, constants, validity, and Boolean output.

## Changes

Preserves the public `Binary` identity and floating-point total
ordering. Uses borrowed operands and dtypes, and moves a constant left
operand to the right with the swapped operator. Relies on the existing
`primitive_comparisons_across_bitmap_words` test for all six swapped
operators and keeps the existing comparison benchmarks.

Based on #10675. Keeps the wide-type, sliced-input, bitmap-boundary,
NaN, and signed-zero tests added upstream in #10843.

## Performance

CodSpeed compares this PR with #10675 (`b0a64dd`). Nine primitive
comparison benchmarks are 12% to 31% faster in walltime, led by
`compare_u64_avx2` and `compare_float_avx2` at 31% and 28%, and no
comparison benchmark regresses.

These gains depend on #10675. #11220 measured the same two commits
directly on `develop`. Without #10675, comparisons on small arrays were
10% to 16% slower than `develop` in CPU simulation, and only two
walltime gains remained, at 16% and 18%. The #10675 description has the
full comparison.

The baseline already includes the private NEON packing kernel from
#10843, which this PR replaces with RowFn execution. So the gains above
are measured against that improved baseline, and the old packing gains
aren't credited to this PR.

<details>
<summary>CodSpeed comparison benchmarks against #10675</summary>

CodSpeed marks the walltime runners as unreliable, but all nine cases
move in the same direction.

| Benchmark | Faster by |
| --- | ---: |
| `compare_u64_avx2` | 30.8% |
| `compare_float_avx2` | 28.2% |
| `lt_i64_nullable_avx2` | 18.4% |
| `compare_int_constant_neon` | 16.2% |
| `compare_int_eq_avx2` | 15.4% |
| `compare_f32_avx2` | 15.4% |
| `compare_int_constant_avx2` | 15.3% |
| `compare_int_avx2` | 13.5% |
| `compare_int_nullable_avx2` | 12.5% |

</details>

<details>
<summary>Historical EC2 accumulated-stack measurements</summary>

These runs measured an earlier accumulated stack, not this layer in
isolation or the current head. They showed gains on some configurations
and regressions on others.

Ratios are the median of 21 paired candidate/baseline elapsed-time
ratios. Below 1 is faster. This compares the earlier complete migration,
M0 `ddf8a16df6b7d7c20cfd860e5f23438d97ca4238`, with D
`0291778db36809f77ce0219bf9850506d3cba3b6`. That baseline predates
#10843. The array-pair cases use all-valid nullable inputs. The addition
case is an arithmetic control whose algorithm was unchanged.

| Workload | C7g NEON | C6a AVX2 | C7i AVX2 build | C7i AVX-512 build |
| --- | ---: | ---: | ---: | ---: |
| `f32` array-pair Gte, 8,192 rows | 1.014 | 1.040 | 0.873 | 1.036 |
| `i64` array-pair Gte, 8,192 rows | 0.900 | 1.139 | 0.929 | 0.943 |
| Nullable `i64` add, 32,768 rows | 1.002 | 1.123 | 1.154 | 1.004 |

For example, the old stack took about 10% less time for the ARM `i64`
comparison, but about 14% more for the same case on AVX2-only C6a. C6a
nullable `i64` addition took about 12% more time. These whole-executable
measurements do not establish that the comparison algorithm caused
movement in arithmetic controls.

In the four-label matrix, D ran before M0 in 11 blocks and after it in
ten. The experimental one-pass decoder variants are excluded from this
table.

EC2 C7g (Neoverse V1), C6a (EPYC 7R13), and C7i (Xeon Platinum 8488C).
Rust 1.99.1 / LLVM 23.1.1, bench profile with 16 CGUs and no LTO,
MiMalloc, CPU 2 pinned. The same 455-case public-path microbenchmark
suite ran serially across hosts, with 21 paired blocks, 10 ms per case,
A/A calibration, and a separate control executable. These are
microbenchmarks, not query results. C7i can dispatch to AVX-512 at
runtime even in its AVX2 build.

Saved evidence is in the local investigation archive
`row-fn-evaluation-20261026`: `manifest.json`, `*-matrix-*-medians.csv`,
`*-matrix-*-summary.json`, and `results/<host>/results/matrix-*.json`.

</details>

<details>
<summary>Confirmed independent packing gains, now in develop via
#10843</summary>

The ARM-only private-kernel change reduced long array-pair cohort time
on Graviton3 as follows. Each cohort contains 32 cases, including
partially valid inputs and lengths around 16K.

| Cohort | Confirmation elapsed-time ratio | Paired 95% interval | Time
reduction |
| --- | ---: | --- | ---: |
| `i64` | 0.846 | 0.843 to 0.856 | 15.4% |
| `u64` | 0.860 | 0.858 to 0.864 | 14.0% |
| `f64` | 0.882 | 0.880 to 0.886 | 11.8% |

Baseline `a59294e35e7109fac894a7a38a68f42db0c3c967`, candidate
`f018aaeef739145e1bc50079a441c5d9fd53404a`. The cohort uses a geometric
mean of case ratios within each block, then the median across blocks.
Two 21-block sessions used complementary AB/BA orders, the same full
455-case suite, and 1,000 single-iteration samples per case. The initial
fixed-order source comparisons were discarded. The AVX2 builds had
identical `.text` bytes, so no x86 speedup was attributed to this
change. See #10843 for the harness and full methodology.

These gains required neither this PR nor #10675.

</details>

On `e724610`, CI passed all-target, all-feature Clippy with warnings
denied and the Linux, CPU feature, sanitizer, and Miri test jobs.

---------

Signed-off-by: Connor Tsui <[email protected]>
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