IN LIST: reuse primitive filters for FixedSizeBinary - #24102
IN LIST: reuse primitive filters for FixedSizeBinary#24102geoffreyclaude wants to merge 2 commits into
Conversation
9915d25 to
6f820d7
Compare
|
run benchmark in_list |
|
🤖 Benchmark running (GKE) | trigger CPU Details (lscpu)Comparing codex/in-list-fixed-size-binary-hashset (6f820d7) to b6cccfe diff Run configurationrun benchmark in_list
baseline:
ref: "b6cccfeef1a78ab424ffec87afb40958c33fb897"Results will be posted here when complete File an issue against this benchmark runner |
|
🤖 Benchmark completed (GKE) | trigger Instance: Comparing codex/in-list-fixed-size-binary-hashset (6f820d7) to b6cccfe diff Run configurationrun benchmark in_list
baseline:
ref: "b6cccfeef1a78ab424ffec87afb40958c33fb897"CPU Details (lscpu)Details
Resource Usagein_list — base (merge-base)
in_list — branch
File an issue against this benchmark runner |
|
run benchmark in_list_strategy |
|
🤖 Benchmark running (GKE) | trigger CPU Details (lscpu)Comparing codex/in-list-fixed-size-binary-hashset (6f820d7) to 426b351 (merge-base) diff Run configurationrun benchmark in_list_strategyResults will be posted here when complete File an issue against this benchmark runner |
|
🤖 Benchmark running (GKE) | trigger CPU Details (lscpu)Comparing codex/in-list-fixed-size-binary-hashset (6f820d7) to b6cccfe diff Run configurationrun benchmark in_list_strategy
baseline:
ref: "b6cccfeef1a78ab424ffec87afb40958c33fb897"Results will be posted here when complete File an issue against this benchmark runner |
|
🤖 Benchmark completed (GKE) | trigger Instance: Comparing codex/in-list-fixed-size-binary-hashset (6f820d7) to 426b351 (merge-base) diff Run configurationrun benchmark in_list_strategyCPU Details (lscpu)Details
Resource Usagein_list_strategy — base (merge-base)
in_list_strategy — branch
File an issue against this benchmark runner |
|
🤖 Benchmark completed (GKE) | trigger Instance: Comparing codex/in-list-fixed-size-binary-hashset (6f820d7) to b6cccfe diff Run configurationrun benchmark in_list_strategy
baseline:
ref: "b6cccfeef1a78ab424ffec87afb40958c33fb897"CPU Details (lscpu)Details
Resource Usagein_list_strategy — base (merge-base)
in_list_strategy — branch
File an issue against this benchmark runner |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #24102 +/- ##
==========================================
- Coverage 81.32% 81.32% -0.01%
==========================================
Files 1117 1118 +1
Lines 396269 396517 +248
Branches 396269 396517 +248
==========================================
+ Hits 322260 322454 +194
- Misses 55186 55202 +16
- Partials 18823 18861 +38 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
0ae4133 to
89d1595
Compare
59667db to
0a20207
Compare
12224fb to
2117edf
Compare
9b316c1 to
7ff49f9
Compare
…#24283) ## Which issue does this PR close? - Part of apache#19241. - Stacked on [apache#23014](apache#23014). - Next in stack: [apache#24102](apache#24102). ## Rationale for this change apache#23014 adds fast direct comparisons for short primitive `IN` lists. For longer lists, DataFusion uses a bitmap, a hash set, or another specialized filter when one exists, and otherwise uses the general filter. apache#24102 needs to make that same choice after reading `FixedSizeBinary` values as same-width primitive keys. Today, the choice is part of the native primitive path, so apache#24102 would have to repeat its limits and fallback rules. This PR moves that choice beside the primitive filters and makes it reusable. The limits count only non-null list values. A separate commit uses one `PrimitiveHashSetFilter<T, K>` for the existing integer and floating-point hash-set paths. Integers keep their native keys; `Float32` and `Float64` keep their bitwise keys, including distinct signed zeros and NaN encodings. Filter selection and SQL behavior are unchanged. The only new lookup path is for `Decimal128`. Lists with at most four non-null values still use direct comparisons. Larger lists now use a hash set over the stored `i128` values instead of the general Arrow filter. Values are not rescaled, and the existing precision, scale, and null rules are unchanged. apache#24102 also uses this path for 16-byte `FixedSizeBinary` values. ## What changes are included in this PR? - Makes primitive filter selection reusable by apache#24102. - Uses one primitive hash-set implementation for integer and floating-point values, while preserving their existing key semantics. - Uses `PrimitiveHashSetFilter<Decimal128Type>` for `Decimal128` lists with more than four non-null values. - Adds four `Decimal128` benchmark cases at list sizes 5 and 64. ## Are these changes tested? Tests cover the direct-comparison boundary, an all-null list, and bitwise float hash keys, including signed zero and distinct NaN encodings. Existing end-to-end Decimal tests cover hits, misses, nulls, `IN`, and `NOT IN`. ## Are there any user-facing changes? No. SQL results and public APIs are unchanged. ## Benchmark snapshot The benchmark uses list size 5, the first size above the direct-comparison limit, and a larger size of 64. Each size is measured with 0% and 50% matches. Filter construction is outside the timed loop. Lower is better. An [automated run](apache#24283 (comment)) compared this PR's [benchmark-only first commit](apache@8649e80) with the [completed implementation](apache@510bf3e). The benchmark source is identical in both states, so the comparison isolates the production changes. All four cases improved, with a 34.2% geometric-mean reduction in execution time. | Benchmark | Before | After | Change | |---|---:|---:|---:| | `primitive/decimal128/large_list/list=5/match=0%` | 20.8 us | 13.5 us | -35.1% (1.54x faster) | | `primitive/decimal128/large_list/list=5/match=50%` | 34.3 us | 26.6 us | -22.4% (1.29x faster) | | `primitive/decimal128/large_list/list=64/match=0%` | 20.3 us | 13.2 us | -35.0% (1.54x faster) | | `primitive/decimal128/large_list/list=64/match=50%` | 34.8 us | 19.9 us | -42.8% (1.75x faster) |
7ff49f9 to
77498a0
Compare
|
run benchmark in_list_strategy |
|
🤖 Benchmark running (GKE) | trigger CPU Details (lscpu)Comparing codex/in-list-fixed-size-binary-hashset (77498a0) to a1c8762 diff Run configurationrun benchmark in_list_strategy
baseline:
ref: "a1c876203c7655dae4d218ad51b53067b3110b96"Results will be posted here when complete File an issue against this benchmark runner |
|
🤖 Benchmark completed (GKE) | trigger Instance: Comparing codex/in-list-fixed-size-binary-hashset (77498a0) to a1c8762 diff Run configurationrun benchmark in_list_strategy
baseline:
ref: "a1c876203c7655dae4d218ad51b53067b3110b96"CPU Details (lscpu)Details
Resource Usagein_list_strategy — base (merge-base)
in_list_strategy — branch
File an issue against this benchmark runner |
7770d0b to
20399e4
Compare
20399e4 to
1217f5e
Compare
|
run benchmark in_list_strategy |
|
🤖 Benchmark running (GKE) | trigger CPU Details (lscpu)Comparing codex/in-list-fixed-size-binary-hashset (1217f5e) to ab4b5e0 diff Run configurationrun benchmark in_list_strategy
baseline:
ref: "ab4b5e0d964dd2b76a68ba515764d186bb476298"Results will be posted here when complete File an issue against this benchmark runner |
|
🤖 Benchmark completed (GKE) | trigger Instance: Comparing codex/in-list-fixed-size-binary-hashset (1217f5e) to ab4b5e0 diff Run configurationrun benchmark in_list_strategy
baseline:
ref: "ab4b5e0d964dd2b76a68ba515764d186bb476298"CPU Details (lscpu)Details
Resource Usagein_list_strategy — base (merge-base)
in_list_strategy — branch
File an issue against this benchmark runner |
|
@alamb This one should be ready for review now! Only one left after this and the series is done |
alamb
left a comment
There was a problem hiding this comment.
Thank you @geoffreyclaude - this is clever and looks great to me
As before my only concern is the potential binary size implications of this change. However, I think we could fix the size issues in a follow on PR
- Avoid templates in FixedSizeBinary dispatch (see below)
- Refactor dictionary handling (#24658)
| } | ||
|
|
||
| /// Adapts a primitive filter to concrete, same-width `FixedSizeBinary` arrays. | ||
| struct FixedSizeBinaryFilter<T: ArrowPrimitiveType> { |
There was a problem hiding this comment.
I wonder if we really need this to be generic as it also creates a large number of monomorphized functions that basically do type dispatch.
For example, here is one potential fix (from claude) that avoids the templates and simply dispatches at runtime (claude claims it requires 1/3 of the code gen)
--- a/datafusion/physical-expr/src/expressions/in_list/fixed_size_binary_filter.rs
+++ b/datafusion/physical-expr/src/expressions/in_list/fixed_size_binary_filter.rs
@@ -38,7 +38,6 @@
//! Reinterpreting an aligned Arrow buffer is zero-copy. An unaligned buffer is
//! copied into aligned primitive storage before filter construction or probing.
-use std::marker::PhantomData;
use std::mem::size_of;
use std::sync::Arc;
@@ -84,16 +83,12 @@ where
}
/// Adapts a primitive filter to concrete, same-width `FixedSizeBinary` arrays.
-struct FixedSizeBinaryFilter<T: ArrowPrimitiveType> {
+struct FixedSizeBinaryFilter {
data_type: DataType,
inner: StaticFilterRef,
- _marker: PhantomData<T>,
}
-impl<T> StaticFilter for FixedSizeBinaryFilter<T>
-where
- T: ArrowPrimitiveType + Send + Sync + 'static,
-{
+impl StaticFilter for FixedSizeBinaryFilter {
fn null_count(&self) -> usize {
self.inner.null_count()
}
@@ -114,27 +109,25 @@ where
self.data_type
)
})?;
- let primitive = reinterpret_as_primitive::<T>(array)?;
- self.inner.contains(&primitive, negated)
+ let primitive = reinterpret(array)?;
+ self.inner.contains(primitive.as_ref(), negated)
}
}
-fn instantiate_for_primitive<T>(array: &FixedSizeBinaryArray) -> Result<StaticFilterRef>
-where
- T: ArrowPrimitiveType + Send + Sync + 'static,
-{
- let primitive: ArrayRef = Arc::new(reinterpret_as_primitive::<T>(array)?);
- let inner = instantiate_primitive_filter(&primitive)?.ok_or_else(|| {
- internal_datafusion_err!(
- "FixedSizeBinary filter: no primitive filter for {}",
- primitive.data_type()
- )
- })?;
- Ok(Arc::new(FixedSizeBinaryFilter::<T> {
- data_type: array.data_type().clone(),
- inner,
- _marker: PhantomData,
- }))
- +/// Reinterprets a supported-width array as its same-width primitive array.
+fn reinterpret(array: &FixedSizeBinaryArray) -> Result<ArrayRef> {
+ Ok(match array.value_size() {
+ 1 => Arc::new(reinterpret_as_primitive::<UInt8Type>(array)?) as ArrayRef,
+ 2 => Arc::new(reinterpret_as_primitive::<UInt16Type>(array)?),
+ 4 => Arc::new(reinterpret_as_primitive::<UInt32Type>(array)?),
+ 8 => Arc::new(reinterpret_as_primitive::<UInt64Type>(array)?),
+ 16 => Arc::new(reinterpret_as_primitive::<Decimal128Type>(array)?),
+ width => {
+ return Err(internal_datafusion_err!(
+ "FixedSizeBinary filter: unsupported width {width}"
+ ));
+ }
+ })
}
/// Creates an optimized filter for supported concrete `FixedSizeBinary` arrays.
@@ -144,19 +137,24 @@ pub(super) fn instantiate_fixed_size_binary_filter(
let DataType::FixedSizeBinary(width) = in_array.data_type() else {
return Ok(None);
};
+ if !matches!(width, 1 | 2 | 4 | 8 | 16) {
+ return Ok(None);
+ }
let Some(array) = in_array.as_fixed_size_binary_opt() else {
return Ok(None);
};
- let filter = match width {
- 1 => instantiate_for_primitive::<UInt8Type>(array)?,
- 2 => instantiate_for_primitive::<UInt16Type>(array)?,
- 4 => instantiate_for_primitive::<UInt32Type>(array)?,
- 8 => instantiate_for_primitive::<UInt64Type>(array)?,
- 16 => instantiate_for_primitive::<Decimal128Type>(array)?,
- _ => return Ok(None),
- };
- Ok(Some(filter))
+ let primitive = reinterpret(array)?;
+ let inner = instantiate_primitive_filter(&primitive)?.ok_or_else(|| {
+ internal_datafusion_err!(
+ "FixedSizeBinary filter: no primitive filter for {}",
+ primitive.data_type()
+ )
+ })?;
+ Ok(Some(Arc::new(FixedSizeBinaryFilter {
+ data_type: in_array.data_type().clone(),
+ inner,
+ })))
}
#[cfg(test)]There was a problem hiding this comment.
Thanks, I tried this after applying the dictionary-dispatch change from #24662. Once dictionary handling is outside the concrete filters, each FixedSizeBinaryFilter<T>::contains becomes much smaller (about 768 → 143 lines of LLVM IR). Removing the remaining generic wrapper saves only another ~572 lines (~0.09% of the crate), while adding runtime width dispatch and an Arc allocation for every input batch. I therefore suggest keeping the small generic adapter.
| } | ||
|
|
||
| fn contains(&self, v: &dyn Array, negated: bool) -> Result<BooleanArray> { | ||
| handle_dictionary!(self, v, negated); |
There was a problem hiding this comment.
One thing I noticed while reviewing this that probably would reduce the code size in general, including here, is to avoid handling dictionaries within each type (which results in many different copies of each of the filter types).
Instead, we can probably reduce the code by handling it the top level somehow
So instead of FixedSizeBinaryFilter handling all the dictionary types itself, instead we might instead instantiate the FixedSizeBinaryFilter` for only values, and do the dictionary handling at a higher layer
Maybe something like
// dictionary_filter.rs
pub(super) struct DictionaryFilter {
/// The haystack's (non-dictionary) value type.
values_type: DataType,
inner: StaticFilterRef,
}
impl StaticFilter for DictionaryFilter {
fn null_count(&self) -> usize {
self.inner.null_count()
}
fn contains(&self, v: &dyn Array, negated: bool) -> Result<BooleanArray> {
downcast_dictionary_array! {
v => {
if v.values().data_type() == &self.values_type {
let values_contains =
self.inner.contains(v.values().as_ref(), negated)?;
let result = take(&values_contains, v.keys(), None)?;
return Ok(downcast_array(result.as_ref()));
}
}
_ => {}
}
self.inner.contains(v, negated)
}
}There was a problem hiding this comment.
I filed this idea as an issue for follow up:
There was a problem hiding this comment.
Agreed. This is what I implemented in the stacked #24662. Dictionary handling is now a dedicated DictionaryFilter around the selected concrete filter, reducing the generated IR for the in_list module by about 24% without any visible regression in the benchmarks.
|
I also made a PR to add some SLT coverage for this feature: |
|
BTW I made a small test that creates a 1.6GB file and then does a query -- Create a parquet file with 100M random UUIDv4s stored as FixedSizeBinary(16)
-- for benchmarking https://github.com/apache/datafusion/pull/24102
--
-- Run with: datafusion-cli -f /tmp/create_uuids.sql
--
-- uuid() generates a fresh random v4 UUID string per row; stripping the
-- hyphens and hex-decoding yields the 16 raw bytes.
COPY (
SELECT
arrow_cast(
decode(replace(uuid(), '-', ''), 'hex'),
'FixedSizeBinary(16)'
) AS uuid
FROM generate_series(1, 100000000)
) TO '/tmp/uuids.parquet';Then SELECT count(*) FROM '/tmp/uuids.parquet' WHERE uuid IN (
arrow_cast(decode('d19615973bb644aa8f154cc4affd48d5', 'hex'), 'FixedSizeBinary(16)'),
arrow_cast(decode('cdf8a5c807f443d0bcd14e3791be9632', 'hex'), 'FixedSizeBinary(16)'),
arrow_cast(decode('6469a75155a9422d8d9e91b80ff26523', 'hex'), 'FixedSizeBinary(16)'),
arrow_cast(decode('67b98b1c0cc4411e865c7344b039446d', 'hex'), 'FixedSizeBinary(16)'),
arrow_cast(decode('1535a198832948a79b4424485da0c9b5', 'hex'), 'FixedSizeBinary(16)'),
arrow_cast(decode('ed8a5d9fff914f59b627924a58fa385d', 'hex'), 'FixedSizeBinary(16)'),
-- ... 100 in total ...Scripts: To run: datafusion-cli -f create_uuids.sql
datafusion-cli -f query_uuids.sqlResults are a few percent end to end performance improvement: Full results
andrewlamb@Andrews-MacBook-Pro-3:~/Downloads$ datafusion-cli -f /tmp/query_uuids.sql | grep Elapsed
Elapsed 0.125 seconds.
Elapsed 0.117 seconds.
Elapsed 0.112 seconds.
Elapsed 0.114 seconds.
Elapsed 0.114 seconds.
Elapsed 0.117 seconds.
Elapsed 0.118 seconds.
Elapsed 0.116 seconds.
Elapsed 0.123 seconds.
Elapsed 0.113 seconds.
Elapsed 0.111 seconds.
Elapsed 0.112 seconds.
Elapsed 0.122 seconds.
Elapsed 0.124 seconds.
Elapsed 0.137 seconds.
Elapsed 0.124 seconds.
Elapsed 0.115 seconds.
Elapsed 0.113 seconds.
Elapsed 0.108 seconds.
Elapsed 0.108 seconds.
andrewlamb@Andrews-MacBook-Pro-3:~/Downloads$ ./datafusion-cli-codex_in-list-fixed-size-binary-hashset -f /tmp/query_uuids.sql | grep Elapsed
Elapsed 0.132 seconds.
Elapsed 0.112 seconds.
Elapsed 0.111 seconds.
Elapsed 0.118 seconds.
Elapsed 0.096 seconds.
Elapsed 0.128 seconds.
Elapsed 0.095 seconds.
Elapsed 0.096 seconds.
Elapsed 0.106 seconds.
Elapsed 0.102 seconds.
Elapsed 0.106 seconds.
Elapsed 0.108 seconds.
Elapsed 0.120 seconds.
Elapsed 0.133 seconds.
Elapsed 0.119 seconds.
Elapsed 0.130 seconds.
Elapsed 0.115 seconds.
Elapsed 0.098 seconds.
Elapsed 0.103 seconds.
Elapsed 0.115 seconds.The PR branch is about 4% faster on the mean (0.117s → 0.112s) and its best runs are notably faster (min 0.108s → 0.095s, ~12%), |
## Which issue does this PR close? - part of apache#23307 - related to apache#24102 ## Rationale for this change I want full end to end coverage of `IN` lists and their optimizations as sqllogictests, not just unit tests ## What changes are included in this PR? 1. Add SLT coverage for FixedSizeBinary arrays (to cover the specializations added in apache#24102) ## Are these changes tested? They are only tests ## Are there any user-facing changes? No, tests only
@alamb Thanks for putting this together! I tried the same test locally with only 4 values, which exercises the direct-comparison path. The filter itself was about 2x faster, and the full query improved from ~128ms to ~111ms. With 100 values, I also see only a few percent end-to-end improvement, consistent with your results. |
I think a few percent end to end is a pretty good result, to be clear. In fact I was debugging a query just today that has an |
|
@geoffreyclaude shall we merge this PR and handle the code duplication in follow ups? Or would you like to work on it more before merging? |
@alamb You can merge it. The DictionaryFilter fix PR you suggested should be independent as it touches all filters. |
Which issue does this PR close?
Rationale for this change
Today,
FixedSizeBinaryIN LISTexpressions use the general filter, even when their width matches a primitive type with a faster specialized filter. This makes repeated evaluation slower than necessary for common fixed-width values.A
FixedSizeBinary(N)value is exactlyNbytes long. For widths 1, 2, 4, 8, and 16, DataFusion already has fastIN LISTfilters for primitive values of the same size. This PR letsFixedSizeBinaryreuse them.For example, a 4-byte value can be read as a
UInt32lookup key. This does not turn the value into a number or perform arithmetic. The list and input use the same mapping, so two keys match exactly when the original bytes match. Machine byte order may change the numeric interpretation, but it does not change equality.The filter depends on the width and the number of non-null values in the list:
UInt8UInt16UInt32UInt64Decimal128(i128storage)#24283 provides this choice, so this PR only needs to map each supported width to the matching primitive key. Other widths keep using the general filter.
Arrow buffers are normally aligned for the selected primitive key and can be read without copying. If a buffer is not aligned for that key type, the list is copied once when the filter is built, while an unaligned input is copied before each evaluation. After unwrapping any dictionary, the input must have the same
FixedSizeBinary(N)type as the list.What changes are included in this PR?
FixedSizeBinarywidths 1, 2, 4, 8, and 16.IN, andNOT IN.FixedSizeBinarybenchmark coverage from 8 to 14 cases, including both match rates for an input unaligned fori128access.Are these changes tested?
Tests cover every supported width around its direct-comparison limit; bitmap and hash-set hits and misses; slices and dictionaries; input and list nulls;
INandNOT IN; wrong widths and Arrow types; unsupported widths; and aligned and unaligned buffers.Are there any user-facing changes?
No. SQL results and public APIs are unchanged.
Benchmark snapshot
The benchmark repeatedly evaluates an 8,192-row batch; filter construction is outside the timed loop. Each selected configuration is measured with 0% and 50% matches:
fsb1/list=16exercises the direct-comparison boundary for 1-byte keys.fsb2/list=64exercises the bitmap path.fsb16/list=4exercises the direct-comparison boundary for 16-byte keys.fsb16/list=64,list=256, andlist=10000exercise hash-set scaling.fsb16/list=64/input=unalignedexercises the per-evaluation input-copy path for buffers unaligned fori128access.Lower is better.
An automated run compared this PR's benchmark-only first commit with the completed implementation. The benchmark source is identical in both states, so the comparison isolates the production changes.
Across this selected case set, speedups range from 1.51x to 12.11x, and the geometric mean is 3.17x (68.5% lower execution time).
The aligned and unaligned
fsb16/list=64cases contain identical values, so their results show the per-evaluation input-copy overhead: +1.6 us (12%) with 0% matches and +3.5 us (17%) with 50% matches.fixed_size_binary/fsb1/list=16/match=0%fixed_size_binary/fsb1/list=16/match=50%fixed_size_binary/fsb2/list=64/match=0%fixed_size_binary/fsb2/list=64/match=50%fixed_size_binary/fsb16/list=4/match=0%fixed_size_binary/fsb16/list=4/match=50%fixed_size_binary/fsb16/list=64/match=0%fixed_size_binary/fsb16/list=64/match=50%fixed_size_binary/fsb16/list=256/match=0%fixed_size_binary/fsb16/list=256/match=50%fixed_size_binary/fsb16/list=10000/match=0%fixed_size_binary/fsb16/list=10000/match=50%fixed_size_binary/fsb16/list=64/match=0%/input=unalignedfixed_size_binary/fsb16/list=64/match=50%/input=unaligned