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

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


The following commit(s) were added to refs/heads/main by this push:
     new 869cebca perf(core): evaluate string IN filters with a hash set (#901)
869cebca is described below

commit 869cebcab32212a6f0f5ce71e2ca7ea56174ebf9
Author: Y Ethan Guo <[email protected]>
AuthorDate: Thu Oct 29 22:57:05 2026 -0700

    perf(core): evaluate string IN filters with a hash set (#901)
    
    `SchemableFilter::apply_comparison` evaluated `IN` and `NOT IN` as one 
equality kernel pass per value, so the cost of a key-list filter grew with the 
list. For string columns (`Utf8`, `LargeUtf8`, `Utf8View`) the values now go 
into a `foldhash` set once and each row is probed once, and the column's null 
buffer carries over to the result, so null rows stay null as the kernels left 
them. Other column types keep the comparison chain. The set uses `foldhash` 
because with std's SipHash it  [...]
    
    Reading 100 record keys out of a 10M-row table through `Table::read` with 
an `IN` filter took about twice as long as reading one key; with this change 
the two cost the same.
---
 crates/core/src/expr/filter.rs | 122 ++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 121 insertions(+), 1 deletion(-)

diff --git a/crates/core/src/expr/filter.rs b/crates/core/src/expr/filter.rs
index 506305e0..afbf3cd7 100644
--- a/crates/core/src/expr/filter.rs
+++ b/crates/core/src/expr/filter.rs
@@ -21,10 +21,13 @@ use crate::Result;
 use crate::error::CoreError;
 use crate::expr::ExprOperator;
 use arrow_arith::boolean;
-use arrow_array::{ArrayRef, BooleanArray, Datum, Scalar, StringArray};
+use arrow_array::cast::AsArray;
+use arrow_array::{ArrayAccessor, ArrayRef, BooleanArray, Datum, Scalar, 
StringArray};
+use arrow_buffer::BooleanBuffer;
 use arrow_cast::{CastOptions, cast_with_options};
 use arrow_ord::cmp::{eq, gt, gt_eq, lt, lt_eq, neq};
 use arrow_schema::{DataType, Field, Schema};
+use std::collections::HashSet;
 use std::str::FromStr;
 
 #[derive(Debug, Clone)]
@@ -365,6 +368,9 @@ impl SchemableFilter {
             ExprOperator::Gt => gt(value, &self.values[0]),
             ExprOperator::Gte => gt_eq(value, &self.values[0]),
             ExprOperator::In => {
+                if let Some(mask) = self.string_membership(value, false) {
+                    return Ok(mask);
+                }
                 // IN: value == values[0] OR value == values[1] OR ...
                 let mut result = eq(value, &self.values[0])?;
                 for filter_value in &self.values[1..] {
@@ -374,6 +380,9 @@ impl SchemableFilter {
                 Ok(result)
             }
             ExprOperator::NotIn => {
+                if let Some(mask) = self.string_membership(value, true) {
+                    return Ok(mask);
+                }
                 // NOT IN: value != values[0] AND value != values[1] AND ...
                 let mut result = neq(value, &self.values[0])?;
                 for filter_value in &self.values[1..] {
@@ -385,6 +394,55 @@ impl SchemableFilter {
         }
         .map_err(|e| e.into())
     }
+
+    /// `IN` / `NOT IN` over a string column as one hash set probe per row.
+    ///
+    /// The comparison chain above costs a pass over the column per value, so a
+    /// key lookup slows down with the number of keys asked for. `None` for a
+    /// non-string column, which keeps the chain. A null row is null either
+    /// way, as the comparison kernels leave it.
+    fn string_membership(&self, value: &dyn Datum, negate: bool) -> 
Option<BooleanArray> {
+        // One value is one kernel pass; the set only pays off past that.
+        if self.values.len() < 2 {
+            return None;
+        }
+        let (column, _) = value.get();
+        // The values were cast to the column's own string type.
+        fn first_str(array: &dyn arrow_array::Array) -> Option<&str> {
+            if let Some(a) = array.as_string_opt::<i32>() {
+                Some(a.value(0))
+            } else if let Some(a) = array.as_string_opt::<i64>() {
+                Some(a.value(0))
+            } else {
+                array.as_string_view_opt().map(|a| a.value(0))
+            }
+        }
+        // With std's SipHash the probe loses to the chain below about ten 
values.
+        let set: HashSet<&str, foldhash::fast::RandomState> = self
+            .values
+            .iter()
+            .map(|scalar| first_str(scalar.get().0))
+            .collect::<Option<_>>()?;
+        fn probe<'a>(
+            column: impl ArrayAccessor<Item = &'a str>,
+            set: &HashSet<&str, foldhash::fast::RandomState>,
+            negate: bool,
+        ) -> BooleanArray {
+            let values = BooleanBuffer::collect_bool(column.len(), |row| {
+                set.contains(column.value(row)) != negate
+            });
+            BooleanArray::new(values, column.nulls().cloned())
+        }
+        if let Some(column) = column.as_string_opt::<i32>() {
+            Some(probe(column, &set, negate))
+        } else if let Some(column) = column.as_string_opt::<i64>() {
+            Some(probe(column, &set, negate))
+        } else {
+            column
+                .as_string_view_opt()
+                .map(|column| probe(column, &set, negate))
+        }
+    }
 }
 
 #[cfg(test)]
@@ -571,6 +629,68 @@ mod tests {
         Ok(())
     }
 
+    /// `IN` and `NOT IN` on a string column take the hash set path; a null row
+    /// stays null, and the result matches the comparison chain the other
+    /// column types still use.
+    #[test]
+    fn test_in_on_string_columns_matches_the_comparison_chain() -> Result<()> {
+        use arrow_array::{LargeStringArray, StringViewArray};
+        let values = vec![Some("a"), Some("b"), None, Some("c"), Some("a")];
+        let expected_in =
+            BooleanArray::from(vec![Some(true), Some(false), None, Some(true), 
Some(true)]);
+        let expected_not_in = BooleanArray::from(vec![
+            Some(false),
+            Some(true),
+            None,
+            Some(false),
+            Some(false),
+        ]);
+        for data_type in [DataType::Utf8, DataType::LargeUtf8, 
DataType::Utf8View] {
+            let column: ArrayRef = match data_type {
+                DataType::Utf8 => Arc::new(StringArray::from(values.clone())),
+                DataType::LargeUtf8 => 
Arc::new(LargeStringArray::from(values.clone())),
+                _ => Arc::new(StringViewArray::from(values.clone())),
+            };
+            let schema = Schema::new(vec![Field::new("s", data_type.clone(), 
true)]);
+            let in_filter =
+                SchemableFilter::try_from((col("s").in_list(["a", "c", "zz"]), 
&schema))?;
+            assert!(
+                in_filter.string_membership(&column, false).is_some(),
+                "{data_type} takes the set path"
+            );
+            assert_eq!(
+                in_filter.apply_comparison(&column)?,
+                expected_in,
+                "{data_type}"
+            );
+            let not_in_filter =
+                SchemableFilter::try_from((col("s").not_in_list(["a", "c", 
"zz"]), &schema))?;
+            assert_eq!(
+                not_in_filter.apply_comparison(&column)?,
+                expected_not_in,
+                "{data_type}"
+            );
+        }
+        // A single value stays on the kernel, and a non-string column keeps
+        // the comparison chain.
+        let schema = create_test_schema();
+        let strings: ArrayRef = Arc::new(StringArray::from(vec!["a", "b"]));
+        let single = 
SchemableFilter::try_from((col("string_col").in_list(["a"]), &schema))?;
+        assert!(single.string_membership(&strings, false).is_none());
+        assert_eq!(
+            single.apply_comparison(&strings)?,
+            BooleanArray::from(vec![true, false])
+        );
+        let ints: ArrayRef = Arc::new(Int64Array::from(vec![Some(40), None, 
Some(60)]));
+        let filter = SchemableFilter::try_from((col("int_col").in_list(["40", 
"60"]), &schema))?;
+        assert!(filter.string_membership(&ints, false).is_none());
+        assert_eq!(
+            filter.apply_comparison(&ints)?,
+            BooleanArray::from(vec![Some(true), None, Some(true)])
+        );
+        Ok(())
+    }
+
     #[test]
     fn test_schemable_filter_apply_comparison_all_operators() -> Result<()> {
         // Single parameterized test covering all 8 operators across both a 
string

Reply via email to