This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 4a933eca perf(arrow): evaluate In/NotIn with a hash set for byte,
string, and integer columns (#619)
4a933eca is described below
commit 4a933ecaa705fa92c09f014653e29eb972dc6b58
Author: Jordan Epstein <[email protected]>
AuthorDate: Sun Jul 26 21:00:48 2026 -0500
perf(arrow): evaluate In/NotIn with a hash set for byte, string, and
integer columns (#619)
---
crates/paimon/src/arrow/residual.rs | 211 ++++++++++++++++++++++++++++++++++++
1 file changed, 211 insertions(+)
diff --git a/crates/paimon/src/arrow/residual.rs
b/crates/paimon/src/arrow/residual.rs
index 60cfe401..56b77050 100644
--- a/crates/paimon/src/arrow/residual.rs
+++ b/crates/paimon/src/arrow/residual.rs
@@ -540,6 +540,10 @@ fn evaluate_set_membership_predicate(
});
}
+ if let Some(mask) = set_membership_hash_mask(array, op, literals) {
+ return Ok(mask);
+ }
+
let mut combined = match op {
PredicateOperator::In => BooleanArray::from(vec![false; array.len()]),
PredicateOperator::NotIn => {
@@ -566,6 +570,132 @@ fn evaluate_set_membership_predicate(
Ok(combined)
}
+/// One-pass hash-set evaluation of `In`/`NotIn` for byte-like, string, and
+/// integer columns. The general path above OR-combines one comparison kernel
+/// per literal — O(rows × literals) — which turns a large pushed-down literal
+/// set (an engine probing a batch of keys as `In`) quadratic. Returns `None`
+/// for column/literal shapes outside the fast path, or when any literal would
+/// not convert for the column, so the general path keeps its exact semantics —
+/// including its error behavior for unconvertible literals.
+fn set_membership_hash_mask(
+ array: &ArrayRef,
+ op: PredicateOperator,
+ literals: &[Datum],
+) -> Option<BooleanArray> {
+ use arrow_schema::DataType as ArrowType;
+ use std::collections::HashSet;
+
+ let keep = matches!(op, PredicateOperator::In);
+
+ fn byte_mask<'a>(
+ values: impl Iterator<Item = Option<&'a [u8]>>,
+ literals: &[Datum],
+ keep: bool,
+ ) -> Option<BooleanArray> {
+ let set = literals
+ .iter()
+ .map(|literal| match literal {
+ Datum::Bytes(bytes) => Some(bytes.as_slice()),
+ _ => None,
+ })
+ .collect::<Option<HashSet<_>>>()?;
+ Some(
+ values
+ .map(|value| Some(value.is_some_and(|v| set.contains(v) ==
keep)))
+ .collect(),
+ )
+ }
+
+ fn str_mask<'a>(
+ values: impl Iterator<Item = Option<&'a str>>,
+ literals: &[Datum],
+ keep: bool,
+ ) -> Option<BooleanArray> {
+ let set = literals
+ .iter()
+ .map(|literal| match literal {
+ Datum::String(value) => Some(value.as_str()),
+ _ => None,
+ })
+ .collect::<Option<HashSet<_>>>()?;
+ Some(
+ values
+ .map(|value| Some(value.is_some_and(|v| set.contains(v) ==
keep)))
+ .collect(),
+ )
+ }
+
+ fn int_mask<T>(array: &ArrayRef, literals: &[Datum], keep: bool) ->
Option<BooleanArray>
+ where
+ T: arrow_array::types::ArrowPrimitiveType,
+ T::Native: TryFrom<i128> + std::hash::Hash + Eq,
+ {
+ let array = array
+ .as_any()
+ .downcast_ref::<arrow_array::PrimitiveArray<T>>()?;
+ let set = literals
+ .iter()
+ .map(|literal| integer_literal(literal).and_then(|v|
T::Native::try_from(v).ok()))
+ .collect::<Option<HashSet<_>>>()?;
+ Some(
+ array
+ .iter()
+ .map(|value| Some(value.is_some_and(|v| set.contains(&v) ==
keep)))
+ .collect(),
+ )
+ }
+
+ match array.data_type() {
+ ArrowType::Binary => byte_mask(
+ array.as_any().downcast_ref::<BinaryArray>()?.iter(),
+ literals,
+ keep,
+ ),
+ ArrowType::LargeBinary => byte_mask(
+ array
+ .as_any()
+ .downcast_ref::<arrow_array::LargeBinaryArray>()?
+ .iter(),
+ literals,
+ keep,
+ ),
+ ArrowType::BinaryView => byte_mask(
+ array
+ .as_any()
+ .downcast_ref::<arrow_array::BinaryViewArray>()?
+ .iter(),
+ literals,
+ keep,
+ ),
+ ArrowType::Utf8 => str_mask(
+ array.as_any().downcast_ref::<StringArray>()?.iter(),
+ literals,
+ keep,
+ ),
+ ArrowType::LargeUtf8 => str_mask(
+ array
+ .as_any()
+ .downcast_ref::<arrow_array::LargeStringArray>()?
+ .iter(),
+ literals,
+ keep,
+ ),
+ ArrowType::Utf8View => str_mask(
+ array
+ .as_any()
+ .downcast_ref::<arrow_array::StringViewArray>()?
+ .iter(),
+ literals,
+ keep,
+ ),
+ ArrowType::Int8 => int_mask::<arrow_array::types::Int8Type>(array,
literals, keep),
+ ArrowType::Int16 => int_mask::<arrow_array::types::Int16Type>(array,
literals, keep),
+ ArrowType::Int32 => int_mask::<arrow_array::types::Int32Type>(array,
literals, keep),
+ ArrowType::Int64 => int_mask::<arrow_array::types::Int64Type>(array,
literals, keep),
+ _ => None,
+ }
+}
+
fn evaluate_column_predicate(
column: &ArrayRef,
scalar: &Scalar<ArrayRef>,
@@ -985,6 +1115,87 @@ mod tests {
(0..col.len()).map(|i| col.value(i).to_string()).collect()
}
+ #[test]
+ fn test_in_hash_path_filters_exactly_with_nulls() {
+ let f = int_field(0, "age");
+ let b = int_batch("age", vec![Some(10), None, Some(20), Some(40),
Some(50)]);
+ let pred = leaf(
+ 0,
+ DataType::Int(IntType::new()),
+ PredicateOperator::In,
+ vec![Datum::Int(20), Datum::Int(40), Datum::Int(999)],
+ );
+ let fp = file_predicates(vec![pred], vec![f.clone()]);
+ let out = filter_record_batch_by_predicates(b, &fp, &[f]).unwrap();
+ assert_eq!(int_values(&out), vec![20, 40]);
+ }
+
+ #[test]
+ fn test_not_in_hash_path_excludes_nulls() {
+ let f = str_field(0, "name");
+ let b = str_batch(
+ "name",
+ vec![Some("apple"), None, Some("banana"), Some("cherry")],
+ );
+ let pred = leaf(
+ 0,
+ DataType::VarChar(VarCharType::string_type()),
+ PredicateOperator::NotIn,
+ vec![Datum::String("banana".to_string())],
+ );
+ let fp = file_predicates(vec![pred], vec![f.clone()]);
+ let out = filter_record_batch_by_predicates(b, &fp, &[f]).unwrap();
+ assert_eq!(str_values(&out), vec!["apple", "cherry"]);
+ }
+
+ #[test]
+ fn test_in_hash_path_on_binary_column() {
+ use crate::spec::VarBinaryType;
+ let array: ArrayRef = Arc::new(BinaryArray::from(vec![
+ Some(b"aa".as_slice()),
+ None,
+ Some(b"bb".as_slice()),
+ Some(b"cc".as_slice()),
+ ]));
+ let data_type =
+ DataType::VarBinary(VarBinaryType::try_new(true,
VarBinaryType::MAX_LENGTH).unwrap());
+ let literals = vec![Datum::Bytes(b"bb".to_vec()),
Datum::Bytes(b"zz".to_vec())];
+ let mask =
+ evaluate_set_membership_predicate(&array, &data_type,
PredicateOperator::In, &literals)
+ .unwrap();
+ assert_eq!(
+ mask.iter().collect::<Vec<_>>(),
+ vec![Some(false), Some(false), Some(true), Some(false)]
+ );
+ let mask = evaluate_set_membership_predicate(
+ &array,
+ &data_type,
+ PredicateOperator::NotIn,
+ &literals,
+ )
+ .unwrap();
+ assert_eq!(
+ mask.iter().collect::<Vec<_>>(),
+ vec![Some(true), Some(false), Some(false), Some(true)]
+ );
+ }
+
+ #[test]
+ fn test_in_unconvertible_literal_still_errors() {
+ // An out-of-range literal must keep the general path's error behavior:
+ // the hash path declines the literal set and the per-literal loop
+ // raises the unconvertible-literal error it always has.
+ use crate::spec::TinyIntType;
+ let array: ArrayRef =
Arc::new(arrow_array::Int8Array::from(vec![Some(1i8), Some(2)]));
+ let result = evaluate_set_membership_predicate(
+ &array,
+ &DataType::TinyInt(TinyIntType::new()),
+ PredicateOperator::In,
+ &[Datum::Long(300)],
+ );
+ assert!(result.is_err());
+ }
+
#[test]
fn test_gt_filters_exactly() {
let f = int_field(0, "age");