This is an automated email from the ASF dual-hosted git repository.

alamb pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-rs.git


The following commit(s) were added to refs/heads/main by this push:
     new 5a2db27df7 perf(arrow-cmp): Speed up eq/neq of a byte-view array 
against a short scalar  (#10689)
5a2db27df7 is described below

commit 5a2db27df70abf6d9ce09403b6b33248503a0092
Author: Giladi <[email protected]>
AuthorDate: Tue Aug 18 22:25:01 2026 +0300

    perf(arrow-cmp): Speed up eq/neq of a byte-view array against a short 
scalar  (#10689)
    
    # Which issue does this PR close?
    
    - Closes #10688
    
    # 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 :)
    
    ---------
    
    Co-authored-by: Andrew Lamb <[email protected]>
---
 arrow-ord/src/cmp.rs | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 99 insertions(+)

diff --git a/arrow-ord/src/cmp.rs b/arrow-ord/src/cmp.rs
index ad99b09e0e..6c9d6115c6 100644
--- a/arrow-ord/src/cmp.rs
+++ b/arrow-ord/src/cmp.rs
@@ -274,6 +274,30 @@ fn compare_op(op: Op, lhs: &dyn Datum, rhs: &dyn Datum) -> 
Result<BooleanArray,
         ree: r_ree_info.as_ref().map(|(_, info)| info),
     };
 
+    // Special case: equality against a short scalar that fits the inlined 
prefix
+    // of a view array, which reduces the comparison to a scan of fixed-width
+    // integers
+    if matches!(op, Op::Equal | Op::NotEqual)
+        && l_side.dict.is_none()
+        && r_side.dict.is_none()
+        && l_side.ree.is_none()
+        && r_side.ree.is_none()
+    {
+        let sides = match (l_s, r_s) {
+            (false, true) => Some((l, r, &l_nulls, &r_nulls)),
+            (true, false) => Some((r, l, &r_nulls, &l_nulls)),
+            _ => None,
+        };
+        // A null constant makes every row null, which this path cannot express
+        if let Some((values, scalar, values_nulls, scalar_nulls)) = sides
+            && scalar_nulls.as_ref().is_none_or(|n| n.null_count() == 0)
+            && let Some(mask) = eq_inline_scalar(values, scalar, l_t, 
matches!(op, Op::NotEqual))
+        {
+            let nulls = values_nulls.clone().filter(|n| n.null_count() > 0);
+            return Ok(BooleanArray::new(mask, nulls));
+        }
+    }
+
     // Defer computation as may not be necessary
     let values = || -> BooleanBuffer {
         let d = downcast_primitive_array! {
@@ -370,6 +394,46 @@ impl SideInfo<'_> {
     }
 }
 
+/// Longest constant whose length and bytes both fit a view's low 64 bits
+const MAX_LOW_HALF_LEN: u32 = 4;
+
+/// Attempts a special case: comparing every value of a byte-view array 
against a
+/// short constant that fits entirely within a view's inlined prefix
+///
+/// Returns `None` for any other shape, which the caller then handles on the
+/// generic path.
+fn eq_inline_scalar(
+    l: &dyn Array,
+    r: &dyn Array,
+    data_type: &DataType,
+    negate: bool,
+) -> Option<BooleanBuffer> {
+    let (values, needle) = match data_type {
+        DataType::Utf8View => (
+            l.as_string_view().views(),
+            *r.as_string_view().views().first()?,
+        ),
+        DataType::BinaryView => (
+            l.as_binary_view().views(),
+            *r.as_binary_view().views().first()?,
+        ),
+        _ => return None,
+    };
+    // Only a constant whose length and bytes fit the view's low half. Wider
+    // constants need the whole 128-bit view, and comparing that is slower than
+    // the generic path's early exit on a length mismatch.
+    let needle_len = needle as u32;
+    if needle_len > MAX_LOW_HALF_LEN {
+        return None;
+    }
+    let significant = u64::MAX >> (32 - needle_len * 8);
+    let needle = needle as u64 & significant;
+    Some(collect_bool(values.len(), negate, |idx| {
+        let view = unsafe { *values.get_unchecked(idx) };
+        view as u64 & significant == needle
+    }))
+}
+
 /// Perform a potentially vectored `op` on the provided `ArrayOrd`
 fn apply<T: ArrayOrd>(
     op: Op,
@@ -1137,6 +1201,41 @@ mod tests {
         );
     }
 
+    /// A null constant makes every row null, whatever the values are
+    #[test]
+    fn test_byte_view_eq_null_scalar() {
+        let a = arrow_array::StringViewArray::from(vec![Some(""), Some("x"), 
None]);
+        let scalar = arrow_array::StringViewArray::new_null(1);
+
+        let r = neq(&a, &Scalar::new(&scalar)).unwrap();
+
+        assert_eq!(r.null_count(), 3);
+    }
+
+    /// A null row's view is arbitrary, so validity decides, not bytes
+    #[test]
+    fn test_byte_view_eq_null_row() {
+        let a = arrow_array::StringViewArray::from(vec![Some(""), Some("x"), 
None]);
+        let scalar = arrow_array::StringViewArray::from(vec![""]);
+
+        let r = neq(&a, &Scalar::new(&scalar)).unwrap();
+
+        assert!(!r.value(0));
+        assert!(r.value(1));
+        assert!(r.is_null(2));
+    }
+
+    /// The constant may sit on either side; equality is symmetric
+    #[test]
+    fn test_byte_view_eq_scalar_either_side() {
+        let a =
+            arrow_array::StringViewArray::from(vec![Some(""), Some("xxxx"), 
Some("xxxxxxxxxxxxx")]);
+        let scalar = 
Scalar::new(arrow_array::StringViewArray::from(vec!["xxxx"]));
+
+        assert_eq!(eq(&a, &scalar).unwrap(), eq(&scalar, &a).unwrap());
+        assert_eq!(eq(&a, &scalar).unwrap().true_count(), 1);
+    }
+
     #[test]
     fn test_string_view_eq() {
         let a = arrow_array::StringViewArray::from(vec![

Reply via email to