Skip to content

Commit 397944d

Browse files
Implement FixedSizeBinary zero-copy reinterpretation optimization
FixedSizeBinary(N) arrays share the same contiguous buffer layout as primitive arrays, so for power-of-2 widths (1, 2, 4, 8, 16) we can zero-copy reinterpret them and use the optimized primitive filters (bitmap, branchless, hash) instead of falling through to the NestedTypeFilter fallback.
1 parent 8cd4638 commit 397944d

3 files changed

Lines changed: 243 additions & 9 deletions

File tree

datafusion/physical-expr/src/expressions/in_list.rs

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -923,6 +923,138 @@ mod tests {
923923
])
924924
}
925925

926+
#[test]
927+
fn test_in_list_fixed_size_binary_canonical_path() -> Result<()> {
928+
let width = 16;
929+
let matching = vec![0x10; width as usize];
930+
let also_in_list = vec![0x20; width as usize];
931+
let not_in_list = vec![0x30; width as usize];
932+
933+
let schema = Schema::new(vec![Field::new(
934+
"a",
935+
DataType::FixedSizeBinary(width),
936+
true,
937+
)]);
938+
let col_a = col("a", &schema)?;
939+
let list_array = Arc::new(FixedSizeBinaryArray::try_from(vec![
940+
matching.as_slice(),
941+
also_in_list.as_slice(),
942+
])?) as ArrayRef;
943+
let expr = Arc::new(InListExpr::try_new_from_array(
944+
Arc::clone(&col_a),
945+
list_array,
946+
false,
947+
&schema,
948+
)?) as Arc<dyn PhysicalExpr>;
949+
950+
let batch_array = Arc::new(FixedSizeBinaryArray::try_from_sparse_iter_with_size(
951+
vec![
952+
Some(matching.as_slice()),
953+
Some(not_in_list.as_slice()),
954+
None,
955+
]
956+
.into_iter(),
957+
width,
958+
)?) as ArrayRef;
959+
let batch = RecordBatch::try_new(Arc::new(schema), vec![batch_array])?;
960+
961+
let result = expr.evaluate(&batch)?.into_array(batch.num_rows())?;
962+
let result = as_boolean_array(&result);
963+
assert_eq!(
964+
result,
965+
&BooleanArray::from(vec![Some(true), Some(false), None])
966+
);
967+
968+
Ok(())
969+
}
970+
971+
#[test]
972+
fn test_in_list_fixed_size_binary_offset_path() -> Result<()> {
973+
let width = 16;
974+
let before_slice = vec![0x01; width as usize];
975+
let in_slice = vec![0x02; width as usize];
976+
let not_in_slice = vec![0x03; width as usize];
977+
978+
let schema = Schema::new(vec![Field::new(
979+
"a",
980+
DataType::FixedSizeBinary(width),
981+
false,
982+
)]);
983+
let col_a = col("a", &schema)?;
984+
let parent = Arc::new(FixedSizeBinaryArray::try_from(vec![
985+
before_slice.as_slice(),
986+
in_slice.as_slice(),
987+
not_in_slice.as_slice(),
988+
])?) as ArrayRef;
989+
let sliced_list = parent.slice(1, 1);
990+
let expr = Arc::new(InListExpr::try_new_from_array(
991+
Arc::clone(&col_a),
992+
sliced_list,
993+
false,
994+
&schema,
995+
)?) as Arc<dyn PhysicalExpr>;
996+
997+
let batch_array = Arc::new(FixedSizeBinaryArray::try_from(vec![
998+
in_slice.as_slice(),
999+
before_slice.as_slice(),
1000+
])?) as ArrayRef;
1001+
let batch = RecordBatch::try_new(Arc::new(schema), vec![batch_array])?;
1002+
1003+
let result = expr.evaluate(&batch)?.into_array(batch.num_rows())?;
1004+
let result = as_boolean_array(&result);
1005+
assert_eq!(result, &BooleanArray::from(vec![Some(true), Some(false)]));
1006+
1007+
Ok(())
1008+
}
1009+
1010+
#[test]
1011+
fn test_in_list_fixed_size_binary_offset_path_with_nulls() -> Result<()> {
1012+
let width = 16;
1013+
let before_slice = vec![0xAA; width as usize];
1014+
let in_slice = vec![0xBB; width as usize];
1015+
let not_in_slice = vec![0xCC; width as usize];
1016+
1017+
let schema = Schema::new(vec![Field::new(
1018+
"a",
1019+
DataType::FixedSizeBinary(width),
1020+
true,
1021+
)]);
1022+
let col_a = col("a", &schema)?;
1023+
let parent = Arc::new(FixedSizeBinaryArray::try_from_sparse_iter_with_size(
1024+
vec![
1025+
Some(before_slice.as_slice()),
1026+
None,
1027+
Some(in_slice.as_slice()),
1028+
]
1029+
.into_iter(),
1030+
width,
1031+
)?) as ArrayRef;
1032+
let sliced_list = parent.slice(1, 2);
1033+
let expr = Arc::new(InListExpr::try_new_from_array(
1034+
Arc::clone(&col_a),
1035+
sliced_list,
1036+
false,
1037+
&schema,
1038+
)?) as Arc<dyn PhysicalExpr>;
1039+
1040+
let batch_array = Arc::new(FixedSizeBinaryArray::try_from_sparse_iter_with_size(
1041+
vec![
1042+
Some(in_slice.as_slice()),
1043+
Some(not_in_slice.as_slice()),
1044+
None,
1045+
]
1046+
.into_iter(),
1047+
width,
1048+
)?) as ArrayRef;
1049+
let batch = RecordBatch::try_new(Arc::new(schema), vec![batch_array])?;
1050+
1051+
let result = expr.evaluate(&batch)?.into_array(batch.num_rows())?;
1052+
let result = as_boolean_array(&result);
1053+
assert_eq!(result, &BooleanArray::from(vec![Some(true), None, None]));
1054+
1055+
Ok(())
1056+
}
1057+
9261058
/// Test IN LIST for date types (Date32, Date64).
9271059
///
9281060
/// Test data: 0 (in list), 2 (not in list), [1, 3] (other list values)

datafusion/physical-expr/src/expressions/in_list/strategy.rs

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,19 @@
1515
// specific language governing permissions and limitations
1616
// under the License.
1717

18+
//! Filter selection strategy for InList expressions
19+
//!
20+
//! Selects the optimal lookup strategy based on data type and list size:
21+
//!
22+
//! - 1-byte types (Int8/UInt8): bitmap (32 bytes, O(1) bit test)
23+
//! - 2-byte types (Int16/UInt16): bitmap (8 KB, O(1) bit test)
24+
//! - 4-byte types (Int32/Float32): branchless (up to 32) or hash
25+
//! - 8-byte types (Int64/Float64): branchless (up to 16) or hash
26+
//! - 16-byte types (Decimal128): branchless (up to 4) or hash
27+
//! - Utf8View/BinaryView: short-string or masked-view filters
28+
//! - FixedSizeBinary(1/2/4/8/16): reinterpreted as fixed-width values
29+
//! - Other types: ArrayStaticFilter fallback
30+
1831
use std::sync::Arc;
1932

2033
use arrow::array::ArrayRef;
@@ -28,7 +41,8 @@ use super::static_filter::{StaticFilter, handle_dictionary};
2841
use super::transform::{
2942
make_bitmap_filter, make_branchless_filter, make_byte_view_masked_filter,
3043
make_utf8view_branchless_filter, make_utf8view_hash_filter,
31-
reinterpret_any_primitive_to, utf8view_all_short_strings,
44+
matches_reinterpreted_width, reinterpret_any_primitive_to,
45+
utf8view_all_short_strings,
3246
};
3347

3448
/// Maximum list size for branchless lookup on 1-byte primitives (Int8, UInt8).
@@ -127,6 +141,11 @@ pub(super) fn instantiate_static_filter(
127141
};
128142
}
129143

144+
// FixedSizeBinary with power-of-2 width: reinterpret as primitive
145+
if let &DataType::FixedSizeBinary(byte_width) = dt {
146+
return instantiate_fixed_size_binary_filter(in_array, byte_width);
147+
}
148+
130149
let strategy = select_strategy(dt, len);
131150

132151
match (dt, strategy) {
@@ -160,6 +179,44 @@ pub(super) fn instantiate_static_filter(
160179
}
161180
}
162181

182+
/// Creates the optimal filter for FixedSizeBinary(N) arrays.
183+
///
184+
/// Widths 1, 2, 4, 8, and 16 share the same contiguous values-buffer layout
185+
/// as the corresponding primitive width, so they can use the optimized
186+
/// bitmap, branchless, or direct-probe filters without copying values.
187+
fn instantiate_fixed_size_binary_filter(
188+
in_array: ArrayRef,
189+
byte_width: i32,
190+
) -> Result<Arc<dyn StaticFilter + Send + Sync>> {
191+
let len = in_array.len() - in_array.null_count();
192+
match byte_width {
193+
1 => make_bitmap_filter::<UInt8Type>(&in_array),
194+
2 => make_bitmap_filter::<UInt16Type>(&in_array),
195+
4 => {
196+
if len <= BRANCHLESS_MAX_4B {
197+
make_branchless_filter::<UInt32Type>(&in_array)
198+
} else {
199+
make_direct_probe_filter_reinterpreted::<UInt32Type>(&in_array)
200+
}
201+
}
202+
8 => {
203+
if len <= BRANCHLESS_MAX_8B {
204+
make_branchless_filter::<UInt64Type>(&in_array)
205+
} else {
206+
make_direct_probe_filter_reinterpreted::<UInt64Type>(&in_array)
207+
}
208+
}
209+
16 => {
210+
if len <= BRANCHLESS_MAX_16B {
211+
make_branchless_filter::<Decimal128Type>(&in_array)
212+
} else {
213+
make_direct_probe_filter_reinterpreted::<Decimal128Type>(&in_array)
214+
}
215+
}
216+
_ => Ok(Arc::new(ArrayStaticFilter::try_new(in_array)?)),
217+
}
218+
}
219+
163220
fn dispatch_branchless(
164221
arr: &ArrayRef,
165222
) -> Option<Result<Arc<dyn StaticFilter + Send + Sync>>> {
@@ -215,7 +272,7 @@ where
215272
if in_array.data_type() == &D::DATA_TYPE {
216273
return Ok(Arc::new(DirectProbeFilter::<D>::try_new(in_array)?));
217274
}
218-
if in_array.data_type().primitive_width() != Some(size_of::<D::Native>()) {
275+
if !matches_reinterpreted_width(in_array.data_type(), size_of::<D::Native>()) {
219276
return Err(exec_datafusion_err!(
220277
"DirectProbeFilter: expected {}-byte primitive array, got {}",
221278
size_of::<D::Native>(),
@@ -253,7 +310,7 @@ where
253310
negated: bool,
254311
) -> Result<arrow::array::BooleanArray> {
255312
handle_dictionary!(self, v, negated);
256-
if v.data_type().primitive_width() != Some(size_of::<D::Native>()) {
313+
if !matches_reinterpreted_width(v.data_type(), size_of::<D::Native>()) {
257314
return Err(exec_datafusion_err!(
258315
"DirectProbeFilter: expected {}-byte primitive array, got {}",
259316
size_of::<D::Native>(),
@@ -271,7 +328,7 @@ where
271328
mod tests {
272329
use super::*;
273330

274-
use arrow::array::{ArrayRef, BooleanArray, Float64Array};
331+
use arrow::array::{ArrayRef, BooleanArray, FixedSizeBinaryArray, Float64Array};
275332

276333
#[test]
277334
fn direct_probe_strategy_handles_reinterpreted_slices() -> Result<()> {
@@ -312,4 +369,39 @@ mod tests {
312369

313370
Ok(())
314371
}
372+
373+
#[test]
374+
fn fixed_size_binary_uses_bitmap_and_direct_probe_paths() -> Result<()> {
375+
let haystack: ArrayRef = Arc::new(FixedSizeBinaryArray::try_from(vec![
376+
[0x12, 0x34].as_slice(),
377+
[0xab, 0xcd].as_slice(),
378+
])?);
379+
let filter = instantiate_static_filter(haystack)?;
380+
let needles = FixedSizeBinaryArray::try_from(vec![
381+
[0xab, 0xcd].as_slice(),
382+
[0xff, 0xff].as_slice(),
383+
])?;
384+
assert_eq!(
385+
filter.contains(&needles, false)?,
386+
BooleanArray::from(vec![Some(true), Some(false)])
387+
);
388+
389+
let values: Vec<_> = (0_u32..33).map(u32::to_le_bytes).collect();
390+
let haystack_values: Vec<_> = values.iter().map(|v| v.as_slice()).collect();
391+
let haystack: ArrayRef =
392+
Arc::new(FixedSizeBinaryArray::try_from(haystack_values)?);
393+
let filter = instantiate_static_filter(haystack)?;
394+
let direct_hit = 7_u32.to_le_bytes();
395+
let direct_miss = 99_u32.to_le_bytes();
396+
let needles = FixedSizeBinaryArray::try_from(vec![
397+
direct_hit.as_slice(),
398+
direct_miss.as_slice(),
399+
])?;
400+
assert_eq!(
401+
filter.contains(&needles, false)?,
402+
BooleanArray::from(vec![Some(true), Some(false)])
403+
);
404+
405+
Ok(())
406+
}
315407
}

datafusion/physical-expr/src/expressions/in_list/transform.rs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ use std::sync::Arc;
2828

2929
use arrow::array::{Array, ArrayRef, AsArray, BooleanArray, PrimitiveArray};
3030
use arrow::buffer::ScalarBuffer;
31-
use arrow::datatypes::{ArrowPrimitiveType, ByteViewType, Decimal128Type};
31+
use arrow::datatypes::{ArrowPrimitiveType, ByteViewType, DataType, Decimal128Type};
3232
use arrow::util::bit_iterator::BitIndexIterator;
3333
use datafusion_common::hash_utils::RandomState;
3434
use datafusion_common::{Result, exec_datafusion_err};
@@ -49,6 +49,16 @@ fn views_as_i128(views: &ScalarBuffer<u128>) -> &[i128] {
4949
views.inner().typed_data()
5050
}
5151

52+
#[inline]
53+
pub(crate) fn matches_reinterpreted_width(dt: &DataType, width: usize) -> bool {
54+
dt.primitive_width() == Some(width)
55+
|| matches!(
56+
dt,
57+
DataType::FixedSizeBinary(byte_width)
58+
if usize::try_from(*byte_width).ok() == Some(width)
59+
)
60+
}
61+
5262
/// Reinterpreting filter for bitmap lookups (u8/u16).
5363
struct ReinterpretedBitmap<T: BitmapFilterType> {
5464
inner: BitmapFilter<T>,
@@ -62,7 +72,7 @@ impl<T: BitmapFilterType> StaticFilter for ReinterpretedBitmap<T> {
6272
fn contains(&self, v: &dyn Array, negated: bool) -> Result<BooleanArray> {
6373
handle_dictionary!(self, v, negated);
6474

65-
if v.data_type().primitive_width() != Some(size_of::<T::Native>()) {
75+
if !matches_reinterpreted_width(v.data_type(), size_of::<T::Native>()) {
6676
return Err(exec_datafusion_err!(
6777
"BitmapFilter: expected {}-byte primitive array, got {}",
6878
size_of::<T::Native>(),
@@ -94,7 +104,7 @@ where
94104
fn contains(&self, v: &dyn Array, negated: bool) -> Result<BooleanArray> {
95105
handle_dictionary!(self, v, negated);
96106

97-
if v.data_type().primitive_width() != Some(size_of::<T::Native>()) {
107+
if !matches_reinterpreted_width(v.data_type(), size_of::<T::Native>()) {
98108
return Err(exec_datafusion_err!(
99109
"BranchlessFilter: expected {}-byte primitive array, got {}",
100110
size_of::<T::Native>(),
@@ -156,7 +166,7 @@ where
156166
if in_array.data_type() == &T::DATA_TYPE {
157167
return Ok(Arc::new(BitmapFilter::<T>::try_new(in_array)?));
158168
}
159-
if in_array.data_type().primitive_width() != Some(size_of::<T::Native>()) {
169+
if !matches_reinterpreted_width(in_array.data_type(), size_of::<T::Native>()) {
160170
return Err(exec_datafusion_err!(
161171
"BitmapFilter: expected {}-byte primitive array for {} bitmap, got {}",
162172
size_of::<T::Native>(),
@@ -190,7 +200,7 @@ where
190200
let arr = if is_native {
191201
Arc::clone(in_array)
192202
} else {
193-
if in_array.data_type().primitive_width() != Some(width) {
203+
if !matches_reinterpreted_width(in_array.data_type(), width) {
194204
return Err(exec_datafusion_err!(
195205
"BranchlessFilter: expected {width}-byte primitive array, got {}",
196206
in_array.data_type()

0 commit comments

Comments
 (0)