Skip to content

Speed up eq/neq of a byte-view array against a short scalar - #10689

Open
giladkl wants to merge 2 commits into
apache:mainfrom
giladkl:perf/byte-view-scalar-eq
Open

Speed up eq/neq of a byte-view array against a short scalar #10689
giladkl wants to merge 2 commits into
apache:mainfrom
giladkl:perf/byte-view-scalar-eq

Conversation

@giladkl

@giladkl giladkl commented Aug 14, 2026

Copy link
Copy Markdown

Which issue does this PR close?

Rationale for this change

col = 'x' / col <> '' over a Utf8View or BinaryView column is one of the hottest kernels in an analytical scan, and today the compare walks the full 128-bit view through a sequence of branches even when the scalar is small.

For a short constant almost none of that is needed. A constant of four bytes or fewer is described entirely by a view's low 64 bits, which hold the length and the first four bytes, so masking those and comparing them against the constant resolved once up front settles a row with a single narrow integer compare over the flat &[u128] view slice. That loop is branch-free and vectorizes.

What changes are included in this PR?

  • arrow-ord: an eq_inline_scalar helper, and a guard in compare_op that routes to it for Op::Equal / Op::NotEqual when one side is a scalar. Everything else falls through to the existing generic path untouched: dictionary and REE inputs, a null scalar (which the fast path's null handling cannot express), non-view types, and constants longer than four bytes.
  • arrow: a stringview_scalar_eq benchmark group in comparison_kernels.rs sweeping three sizes, so the cache-resident and bandwidth-bound ends of the range are both visible.

Constants wider than four bytes are deliberately left alone. They need the whole 128-bit view, and comparing that measured slower than the generic path's early exit on a length mismatch (a six-byte constant regressed 11.8%), so the cap is a measured limit rather than an arbitrary one.

Are these changes tested?

Yes. Three tests are added alongside the existing byte-view comparison tests:

  • test_byte_view_eq_null_scalar — a null constant makes every row null, covering the shape the fast path declines.
  • test_byte_view_eq_null_row — null rows in the values array stay null.
  • test_byte_view_eq_scalar_either_side — the scalar on the left gives the same answer as on the right.

The existing arrow-ord suite (272 tests) passes unchanged.

Benchmark evidence, cargo bench --bench comparison_kernels -- stringview_scalar_eq on an Intel Xeon @ 2.80GHz (66 MiB L3):

┌───────────┬─────────┬─────────┬─────────┬────────┐
│ rows │ views │ before │ after │ change │
├───────────┼─────────┼─────────┼─────────┼────────┤
│ 65,536 │ 1 MiB │ 111.4us │ 47.7us │ -57.2% │
├───────────┼─────────┼─────────┼─────────┼────────┤
│ 1,048,576 │ 16 MiB │ 2.04ms │ 983.2us │ -51.7% │
├───────────┼─────────┼─────────┼─────────┼────────┤
│ 8,388,608 │ 128 MiB │ 19.71ms │ 14.85ms │ -24.7% │
└───────────┴─────────┴─────────┴─────────┴────────┘

The win narrows at the largest size, where the views no longer fit in cache and the kernel becomes bound by memory bandwidth rather than by the comparison.

Are there any user-facing changes?

Nope :)

The byte-view comparison benchmarks build eight million rows, which is
134MB of views alone. That is far past cache, so those kernels are bound
by memory bandwidth and a change to the per-row comparison is mostly
hidden: the same comparison measured cache-resident separates by more
than twice as much.

Add a `stringview_scalar_eq` group that runs the same comparison at
65,536, one million and eight million rows, so a change can be read
across the range rather than at one point on it. The existing benchmarks
are left alone.
A constant of four bytes or fewer is described entirely by a view's low
64 bits, which hold the length and the first four bytes. Masking those
and comparing them against the constant, resolved once up front, settles
a row with one narrow integer compare over a flat slice, in place of the
generic path's branchy walk over the full 128-bit view reached through a
closure over an `(array, index)` pair. The result is branch-free and
vectorizes.

A wider constant needs the whole view, and comparing that measured
slower than the generic path's early exit on a length mismatch, so those
keep it.

`stringview_scalar_eq`, against main:

    rows          main        this        change
    65,536        111.4us     47.7us      -57.2%
    1,048,576     2.04ms      983.2us     -51.7%
    8,388,608     19.71ms     14.85ms     -24.7%

The win narrows as the views outgrow cache and the kernel becomes bound
by memory bandwidth rather than by the comparison.
@github-actions github-actions Bot added arrow Changes to the arrow crate arrow-ord labels Aug 14, 2026

@alamb alamb 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.

Thanks @giladkl -- this looks quite cool. Let's get this benchmark pulled out as a separate PR and then we can evaluate this PR more fully


// eq scalar benchmarks across sizes: 16 bytes of view a row, so the largest is bandwidth-bound

let mut group = c.benchmark_group("stringview_scalar_eq");

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.

Can you please add this benchmark as a separate PR so I can use our automated benchmarking scripts?

@alamb alamb changed the title Perf/byte view scalar eq Optimizer Utf8View / ByteView comparison for short strings Aug 15, 2026
Comment thread arrow-ord/src/cmp.rs

/// Compares every value of a byte-view array against an inlined constant
///
/// `None` for any other shape, which the generic path below then handles.

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.

"returns None" ?

Comment thread arrow-ord/src/cmp.rs
/// Longest constant whose length and bytes both fit a view's low 64 bits
const MAX_LOW_HALF_LEN: u32 = 4;

/// Compares every value of a byte-view array against an inlined constant

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.

Maybe point out this attempts a special case with a shot constant

@alamb alamb changed the title Optimizer Utf8View / ByteView comparison for short strings Speed up eq/neq of a byte-view array against a short scalar Aug 15, 2026
Comment thread arrow-ord/src/cmp.rs
ree: r_ree_info.as_ref().map(|(_, info)| info),
};

// Equality against an inlined constant is a scan of fixed-width integers

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.

maybe you could also mention this is a special case for comparing short strings with the inlined prefix of a view array

@alamb

alamb commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

I reviewed this code and it looks good and correct to me. I would just like to get some confirmation on the performance from our benchmark runner and then I think we cna merge it in. Thank you @giladkl

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

Labels

arrow Changes to the arrow crate arrow-ord performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Speed up eq/neq of a byte-view array against a short scalar

2 participants