Skip to content

Commit 423cee5

Browse files
committed
Better fixed-width buffer filtering
Signed-off-by: Robert Kruszewski <github@robertk.io>
1 parent 0c7e0ff commit 423cee5

9 files changed

Lines changed: 454 additions & 98 deletions

File tree

vortex-array/src/arrays/filter/execute/buffer.rs

Lines changed: 188 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,32 +3,130 @@
33

44
//! Buffer-level filter dispatch.
55
//!
6-
//! Provides [`filter_buffer`] which filters a [`Buffer<T>`] by [`MaskValues`], attempting an
7-
//! in-place filter when the buffer has exclusive ownership.
6+
//! Reuses suitable cached mask representations and otherwise filters directly from the bitmap,
7+
//! avoiding temporary index or range allocations for one-shot filters.
8+
9+
use std::mem::size_of;
810

911
use vortex_buffer::Buffer;
1012
use vortex_mask::MaskValues;
1113

14+
use crate::arrays::filter::execute::byte_compress;
1215
use crate::arrays::filter::execute::slice;
1316

17+
const CACHED_INDICES_MAX_DENSITY: f64 = 0.5;
18+
const IN_PLACE_MIN_DENSITY: f64 = 0.5;
19+
const MIN_SLICES_AVERAGE_RUN_LENGTH: usize = 8;
20+
1421
/// Filter a [`Buffer<T>`] by [`MaskValues`], returning a new buffer.
1522
///
16-
/// This will attempt to filter in-place (via [`Buffer::try_into_mut`]) when the buffer has
17-
/// exclusive ownership, avoiding an extra allocation.
23+
/// Dense uniquely owned buffers are compacted in place. Shared and sparse buffers allocate an
24+
/// exact-sized output and choose between cached indices, cached long ranges, bitmap iteration,
25+
/// and byte-compress based on the mask and element width.
1826
pub(super) fn filter_buffer<T: Copy>(buffer: Buffer<T>, mask: &MaskValues) -> Buffer<T> {
19-
match buffer.try_into_mut() {
20-
Ok(mut buffer_mut) => {
21-
let new_len = slice::filter_slice_mut_by_mask_values(buffer_mut.as_mut_slice(), mask);
22-
buffer_mut.truncate(new_len);
23-
buffer_mut.freeze()
27+
assert_eq!(buffer.len(), mask.len());
28+
29+
let buffer = if mask.density() >= IN_PLACE_MIN_DENSITY {
30+
match buffer.try_into_mut() {
31+
Ok(mut buffer_mut) => {
32+
let new_len = filter_slice_in_place(buffer_mut.as_mut_slice(), mask);
33+
buffer_mut.truncate(new_len);
34+
return buffer_mut.freeze();
35+
}
36+
Err(buffer) => buffer,
2437
}
25-
// Otherwise, allocate a new buffer and fill it in.
26-
Err(buffer) => slice::filter_slice_by_mask_values(buffer.as_slice(), mask),
38+
} else {
39+
buffer
40+
};
41+
42+
filter_slice(buffer.as_slice(), mask)
43+
}
44+
45+
fn filter_slice<T: Copy>(values: &[T], mask: &MaskValues) -> Buffer<T> {
46+
if let Some(slices) = useful_cached_slices(mask) {
47+
return slice::filter_slice_by_slices(values, slices, mask.true_count());
48+
}
49+
50+
if mask.density() <= CACHED_INDICES_MAX_DENSITY
51+
&& let Some(indices) = mask.cached_indices()
52+
{
53+
return slice::filter_slice_by_indices(values, indices);
54+
}
55+
56+
if mask.density() >= byte_compress_density_threshold::<T>() {
57+
byte_compress::filter_buffer(values, mask)
58+
} else {
59+
slice::filter_slice_by_bitmap(values, mask)
60+
}
61+
}
62+
63+
fn filter_slice_in_place<T: Copy>(values: &mut [T], mask: &MaskValues) -> usize {
64+
if let Some(slices) = useful_cached_slices(mask) {
65+
return slice::filter_slice_mut_by_slices(values, slices);
66+
}
67+
68+
if mask.density() <= CACHED_INDICES_MAX_DENSITY
69+
&& let Some(indices) = mask.cached_indices()
70+
{
71+
return slice::filter_slice_mut_by_indices(values, indices);
72+
}
73+
74+
slice::filter_slice_mut_by_bitmap(values, mask)
75+
}
76+
77+
fn useful_cached_slices(mask: &MaskValues) -> Option<&[(usize, usize)]> {
78+
mask.cached_slices().filter(|slices| {
79+
slices.len() == 1 || mask.true_count() / slices.len() >= MIN_SLICES_AVERAGE_RUN_LENGTH
80+
})
81+
}
82+
83+
fn byte_compress_density_threshold<T>() -> f64 {
84+
let width = size_of::<T>();
85+
86+
// The scalar byte-LUT has a later crossover on AArch64, where the word-at-a-time set-bit walk
87+
// is particularly efficient. Wider values also favor the word walk until all-set bytes become
88+
// common. Keep these cases covered by `benches/filter_fixed_width.rs`.
89+
if cfg!(target_arch = "aarch64") {
90+
return match width {
91+
8 => 0.75,
92+
_ => 0.9,
93+
};
94+
}
95+
96+
match width {
97+
1 => 0.0,
98+
2 | 4 => 0.5,
99+
8 => 0.75,
100+
_ => 0.875,
101+
}
102+
}
103+
104+
/// Materialize sparse indices when enough sibling arrays will reuse the same mask.
105+
pub(super) fn prepare_mask_for_reuse(mask: &MaskValues, consumers: usize) {
106+
if consumers <= 1 || mask.cached_indices().is_some() || mask.cached_slices().is_some() {
107+
return;
108+
}
109+
110+
let density_threshold = if consumers >= 3 { 0.1 } else { 0.05 };
111+
if mask.density() > density_threshold {
112+
return;
27113
}
114+
115+
let first = mask.bit_buffer().select(0);
116+
let last = mask.bit_buffer().select(mask.true_count() - 1);
117+
if first
118+
.zip(last)
119+
.is_some_and(|(first, last)| last - first + 1 == mask.true_count())
120+
{
121+
return;
122+
}
123+
124+
let _ = mask.indices();
28125
}
29126

30127
#[cfg(test)]
31128
mod tests {
129+
use vortex_buffer::BitBuffer;
32130
use vortex_buffer::BufferMut;
33131
use vortex_buffer::buffer;
34132
use vortex_mask::Mask;
@@ -80,4 +178,83 @@ mod tests {
80178
let result = filter_buffer(buf, mask_values(&mask));
81179
assert_eq!(result, buffer![1u32, 2, 3, 4, 6, 7, 8, 10]);
82180
}
181+
182+
#[test]
183+
fn test_filter_shared_buffer_by_cached_indices() {
184+
let buf = Buffer::from(BufferMut::from_iter(0u64..16));
185+
let shared = buf.clone();
186+
let mask = Mask::from_indices(16, [1, 5, 9, 15]);
187+
188+
let result = filter_buffer(buf, mask_values(&mask));
189+
assert_eq!(result, buffer![1u64, 5, 9, 15]);
190+
assert_eq!(shared.len(), 16);
191+
}
192+
193+
#[test]
194+
fn test_filter_shared_buffer_by_cached_slices() {
195+
let buf = Buffer::from(BufferMut::from_iter(0u32..32));
196+
let shared = buf.clone();
197+
let mask = Mask::from_slices(32, vec![(3, 15), (20, 30)]);
198+
199+
let result = filter_buffer(buf, mask_values(&mask));
200+
let expected = (3u32..15).chain(20..30).collect::<Vec<_>>();
201+
assert_eq!(result.as_slice(), expected.as_slice());
202+
assert_eq!(shared.len(), 32);
203+
}
204+
205+
#[test]
206+
fn test_filter_shared_dense_bitmap() {
207+
let buf = Buffer::from(BufferMut::from_iter(0u16..128));
208+
let shared = buf.clone();
209+
let mask = Mask::from_iter((0..128).map(|index| index != 63));
210+
211+
let result = filter_buffer(buf, mask_values(&mask));
212+
let expected = (0u16..128).filter(|&value| value != 63).collect::<Vec<_>>();
213+
assert_eq!(result.as_slice(), expected.as_slice());
214+
assert_eq!(shared.len(), 128);
215+
}
216+
217+
#[test]
218+
fn test_filter_unaligned_bitmap_words() {
219+
const LEN: usize = 151;
220+
const OFFSET: usize = 5;
221+
222+
let backing = BitBuffer::from_iter(
223+
std::iter::repeat_n(false, OFFSET).chain((0..LEN).map(|index| index % 7 == 2)),
224+
);
225+
let mask = Mask::from_buffer(BitBuffer::new_with_offset(
226+
backing.inner().clone(),
227+
LEN,
228+
OFFSET,
229+
));
230+
let buf = Buffer::from(BufferMut::from_iter(0u64..LEN as u64));
231+
232+
let result = filter_buffer(buf, mask_values(&mask));
233+
let expected = (0u64..LEN as u64)
234+
.filter(|value| value % 7 == 2)
235+
.collect::<Vec<_>>();
236+
assert_eq!(result.as_slice(), expected.as_slice());
237+
}
238+
239+
#[test]
240+
fn test_prepare_sparse_mask_for_sibling_reuse() {
241+
let two_consumers = Mask::from_iter((0..100).map(|index| index % 20 == 0));
242+
let values = mask_values(&two_consumers);
243+
assert!(values.cached_indices().is_none());
244+
prepare_mask_for_reuse(values, 2);
245+
assert!(values.cached_indices().is_some());
246+
247+
let three_consumers = Mask::from_iter((0..100).map(|index| index % 10 == 0));
248+
let values = mask_values(&three_consumers);
249+
assert!(values.cached_indices().is_none());
250+
prepare_mask_for_reuse(values, 2);
251+
assert!(values.cached_indices().is_none());
252+
prepare_mask_for_reuse(values, 3);
253+
assert!(values.cached_indices().is_some());
254+
255+
let contiguous = Mask::from_iter((0..100).map(|index| (20..25).contains(&index)));
256+
let values = mask_values(&contiguous);
257+
prepare_mask_for_reuse(values, 3);
258+
assert!(values.cached_indices().is_none());
259+
}
83260
}

vortex-array/src/arrays/filter/execute/byte_compress.rs

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,16 @@
11
// SPDX-License-Identifier: Apache-2.0
22
// SPDX-FileCopyrightText: Copyright the Vortex contributors
33

4-
//! Byte-level compress for primitive filtering using a `1 << 8 = 256`-entry lookup table.
4+
//! Byte-level compress for fixed-width filtering using a `1 << 8 = 256`-entry lookup table.
55
//!
66
//! For each byte of the mask (8 bits -> 8 source elements), a precomputed
77
//! permutation table compacts the selected bytes in a single indexed copy,
88
//! avoiding the overhead of materializing indices or slices.
99
10-
use std::mem::size_of;
11-
1210
use vortex_buffer::Buffer;
1311
use vortex_buffer::BufferMut;
1412
use vortex_mask::MaskValues;
1513

16-
const BYTE_COMPRESS_DENSITY_THRESHOLD: f64 = 0.5;
17-
1814
/// For each mask byte (0..256), stores the element indices to keep and the count.
1915
///
2016
/// `BYTE_COMPRESS_LUT[mask_byte]` = `([i0, i1, ..., i7], popcount)` where
@@ -45,10 +41,10 @@ static BYTE_COMPRESS_LUT: &[([u8; 8], u8); 256] = &{
4541
///
4642
/// Processes the mask one byte at a time (8 source elements per byte),
4743
/// using a precomputed permutation to compact selected elements.
48-
pub(super) fn filter_buffer<T: Copy>(buffer: Buffer<T>, mask: &MaskValues) -> Buffer<T> {
49-
debug_assert_eq!(buffer.len(), mask.len());
44+
pub(super) fn filter_buffer<T: Copy>(buffer: impl AsRef<[T]>, mask: &MaskValues) -> Buffer<T> {
45+
let src = buffer.as_ref();
46+
debug_assert_eq!(src.len(), mask.len());
5047

51-
let src = buffer.as_slice();
5248
let true_count = mask.true_count();
5349

5450
if true_count == 0 {
@@ -59,14 +55,7 @@ pub(super) fn filter_buffer<T: Copy>(buffer: Buffer<T>, mask: &MaskValues) -> Bu
5955
let mask_bytes = mask_buffer.inner().as_ref();
6056
let mask_offset = mask_buffer.offset();
6157

62-
// Fast path: byte-wide values benefit from avoiding index materialization more often. Wider
63-
// values need enough selected values to justify scanning every mask byte directly.
64-
if size_of::<T>() == 1 || mask.density() >= BYTE_COMPRESS_DENSITY_THRESHOLD {
65-
return filter_bitpacked(src, mask_bytes, mask_offset, true_count);
66-
}
67-
68-
// Slow path: lower-density wide values are better handled by the generic path.
69-
super::slice::filter_slice_by_mask_values(src, mask)
58+
filter_bitpacked(src, mask_bytes, mask_offset, true_count)
7059
}
7160

7261
fn filter_bitpacked<T: Copy>(

vortex-array/src/arrays/filter/execute/decimal.rs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,48 @@ pub fn filter_decimal(array: &DecimalArray, mask: &Arc<MaskValues>) -> DecimalAr
3232
}
3333

3434
#[cfg(test)]
35-
mod test {
35+
mod tests {
36+
use rstest::rstest;
37+
3638
use crate::IntoArray;
3739
use crate::VortexSessionExecute;
3840
use crate::array_session;
3941
use crate::arrays::filter::execute::decimal::DecimalArray;
4042
use crate::compute::conformance::filter::test_filter_conformance;
4143
use crate::dtype::DecimalDType;
44+
use crate::dtype::i256;
45+
46+
#[rstest]
47+
#[case(DecimalArray::from_iter(
48+
[1i8, 2, 3, 4, 5],
49+
DecimalDType::new(2, 0),
50+
))]
51+
#[case(DecimalArray::from_iter(
52+
[10i16, 20, 30, 40, 50],
53+
DecimalDType::new(3, 0),
54+
))]
55+
#[case(DecimalArray::from_iter(
56+
[100i32, 200, 300, 400, 500],
57+
DecimalDType::new(5, 0),
58+
))]
59+
#[case(DecimalArray::from_iter(
60+
[1_000i64, 2_000, 3_000, 4_000, 5_000],
61+
DecimalDType::new(10, 0),
62+
))]
63+
#[case(DecimalArray::from_iter(
64+
[10_000i128, 20_000, 30_000, 40_000, 50_000],
65+
DecimalDType::new(19, 0),
66+
))]
67+
#[case(DecimalArray::from_iter(
68+
[1i128, 2, 3, 4, 5].map(i256::from_i128),
69+
DecimalDType::new(39, 0),
70+
))]
71+
fn test_filter_decimal_physical_type_conformance(#[case] array: DecimalArray) {
72+
test_filter_conformance(
73+
&array.into_array(),
74+
&mut array_session().create_execution_ctx(),
75+
);
76+
}
4277

4378
#[test]
4479
fn test_filter_decimal128_conformance() {

vortex-array/src/arrays/filter/execute/mod.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
//!
66
//! The main entrypoint is [`execute_filter`] which filters any [`Canonical`] array.
77
8+
use std::ops::Range;
89
use std::sync::Arc;
910

1011
use vortex_error::VortexExpect;
@@ -48,6 +49,16 @@ fn filter_validity(validity: Validity, mask: &Arc<MaskValues>) -> Validity {
4849
.vortex_expect("Somehow unable to wrap filter around a validity array")
4950
}
5051

52+
pub(super) fn contiguous_filter_range(mask: &Mask) -> Option<Range<usize>> {
53+
let start = mask.first()?;
54+
let end = mask.last()?.checked_add(1)?;
55+
(end - start == mask.true_count()).then_some(start..end)
56+
}
57+
58+
pub(super) fn prepare_mask_for_reuse(mask: &MaskValues, consumers: usize) {
59+
buffer::prepare_mask_for_reuse(mask, consumers);
60+
}
61+
5162
/// Check for some fast-path execution conditions before calling [`execute_filter`].
5263
pub(super) fn execute_filter_fast_paths(
5364
array: ArrayView<'_, Filter>,
@@ -65,6 +76,11 @@ pub(super) fn execute_filter_fast_paths(
6576
return Ok(Some(array.child().clone()));
6677
}
6778

79+
// Filtering by one contiguous range is exactly a slice and can remain zero-copy.
80+
if let Some(range) = contiguous_filter_range(array.filter_mask()) {
81+
return array.child().slice(range).map(Some);
82+
}
83+
6884
// Also check if the array itself is completely null, in which case we only care about the total
6985
// number of nulls, not the values.
7086
let child_arr = array.array();
@@ -120,3 +136,34 @@ pub(super) fn execute_filter(canonical: Canonical, mask: &Arc<MaskValues>) -> Ca
120136
}
121137
}
122138
}
139+
140+
#[cfg(test)]
141+
mod tests {
142+
use vortex_error::VortexResult;
143+
144+
use super::*;
145+
use crate::VortexSessionExecute;
146+
use crate::array_session;
147+
use crate::arrays::PrimitiveArray;
148+
149+
#[test]
150+
fn contiguous_filter_executes_as_zero_copy_slice() -> VortexResult<()> {
151+
let array = PrimitiveArray::from_iter(0i32..8);
152+
let original = array.to_buffer::<i32>();
153+
let filtered = array
154+
.into_array()
155+
.filter(Mask::from_slices(8, vec![(2, 6)]))?
156+
.execute::<PrimitiveArray>(&mut array_session().create_execution_ctx())?;
157+
let filtered_values = filtered.to_buffer::<i32>();
158+
159+
assert_eq!(filtered_values.as_slice(), &[2, 3, 4, 5]);
160+
assert_eq!(filtered_values.as_ptr(), original.as_ptr().wrapping_add(2));
161+
Ok(())
162+
}
163+
164+
#[test]
165+
fn fragmented_filter_is_not_a_contiguous_range() {
166+
let mask = Mask::from_indices(8, [1, 2, 5, 6]);
167+
assert_eq!(contiguous_filter_range(&mask), None);
168+
}
169+
}

0 commit comments

Comments
 (0)