Skip to content

Commit 21c1bd7

Browse files
committed
Better fixed-width buffer filtering
Signed-off-by: Robert Kruszewski <github@robertk.io>
1 parent d2b2378 commit 21c1bd7

11 files changed

Lines changed: 616 additions & 98 deletions

File tree

vortex-array/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,10 @@ harness = false
248248
name = "filter_bool"
249249
harness = false
250250

251+
[[bench]]
252+
name = "filter_fixed_width"
253+
harness = false
254+
251255
[[bench]]
252256
name = "list_length"
253257
harness = false
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: Copyright the Vortex contributors
3+
4+
//! Compact fixed-width filter benchmarks.
5+
//!
6+
//! The cases cover the dispatch dimensions without a large Cartesian product so CodSpeed's
7+
//! instruction-count simulation remains inexpensive.
8+
9+
#![expect(
10+
clippy::cast_possible_truncation,
11+
clippy::cast_precision_loss,
12+
clippy::unwrap_used
13+
)]
14+
15+
use std::sync::LazyLock;
16+
17+
use divan::Bencher;
18+
use vortex_array::ArrayRef;
19+
use vortex_array::Canonical;
20+
use vortex_array::IntoArray;
21+
use vortex_array::VortexSessionExecute;
22+
use vortex_array::array_session;
23+
use vortex_array::arrays::DecimalArray;
24+
use vortex_array::arrays::PrimitiveArray;
25+
use vortex_array::dtype::DecimalDType;
26+
use vortex_array::dtype::i256;
27+
use vortex_buffer::BitBuffer;
28+
use vortex_mask::Mask;
29+
use vortex_session::VortexSession;
30+
31+
fn main() {
32+
LazyLock::force(&SESSION);
33+
divan::main();
34+
}
35+
36+
static SESSION: LazyLock<VortexSession> = LazyLock::new(array_session);
37+
38+
// Keep each case small: the sweep has 24 cases and the targeted sections add only seven more.
39+
const LEN: usize = 16_384;
40+
const DENSITIES: &[f64] = &[0.01, 0.5, 0.8, 0.95];
41+
const CACHED_DENSITIES: &[f64] = &[0.01, 0.1];
42+
43+
#[derive(Clone, Copy, Debug)]
44+
enum Pattern {
45+
Random,
46+
Runs,
47+
Contiguous,
48+
}
49+
50+
const PATTERNS: &[Pattern] = &[Pattern::Random, Pattern::Runs, Pattern::Contiguous];
51+
52+
fn random_mask(density: f64) -> Mask {
53+
let threshold = (density * u64::MAX as f64) as u64;
54+
let mut state = 0x1234_5678_9abc_def0u64;
55+
Mask::from_buffer(BitBuffer::from_iter((0..LEN).map(|_| {
56+
state ^= state << 13;
57+
state ^= state >> 7;
58+
state ^= state << 17;
59+
state <= threshold
60+
})))
61+
}
62+
63+
fn pattern_mask(pattern: Pattern) -> Mask {
64+
match pattern {
65+
Pattern::Random => random_mask(0.5),
66+
Pattern::Runs => Mask::from_iter((0..LEN).map(|index| (index / 32).is_multiple_of(2))),
67+
Pattern::Contiguous => Mask::from_slices(LEN, vec![(LEN / 4, LEN * 3 / 4)]),
68+
}
69+
}
70+
71+
fn bench_filter(
72+
bencher: Bencher,
73+
array: ArrayRef,
74+
make_mask: impl Fn() -> Mask + Sync,
75+
cache_indices: bool,
76+
) {
77+
bencher
78+
.with_inputs(|| {
79+
let mask = make_mask();
80+
if cache_indices {
81+
let _ = mask.values().unwrap().indices();
82+
}
83+
(array.clone(), mask, SESSION.create_execution_ctx())
84+
})
85+
.bench_refs(|(array, mask, ctx)| {
86+
divan::black_box(
87+
array
88+
.clone()
89+
.filter(mask.clone())
90+
.unwrap()
91+
.execute::<Canonical>(ctx)
92+
.unwrap(),
93+
);
94+
});
95+
}
96+
97+
fn i8_array() -> ArrayRef {
98+
PrimitiveArray::from_iter((0..LEN).map(|index| index as i8)).into_array()
99+
}
100+
101+
fn i16_array() -> ArrayRef {
102+
PrimitiveArray::from_iter((0..LEN).map(|index| index as i16)).into_array()
103+
}
104+
105+
fn i32_array() -> ArrayRef {
106+
PrimitiveArray::from_iter((0..LEN).map(|index| index as i32)).into_array()
107+
}
108+
109+
fn i64_array() -> ArrayRef {
110+
PrimitiveArray::from_iter((0..LEN).map(|index| index as i64)).into_array()
111+
}
112+
113+
fn i128_array() -> ArrayRef {
114+
DecimalArray::from_iter(
115+
(0..LEN).map(|index| index as i128),
116+
DecimalDType::new(19, 0),
117+
)
118+
.into_array()
119+
}
120+
121+
fn i256_array() -> ArrayRef {
122+
DecimalArray::from_iter(
123+
(0..LEN).map(|index| i256::from_i128(index as i128)),
124+
DecimalDType::new(39, 0),
125+
)
126+
.into_array()
127+
}
128+
129+
macro_rules! random_density_benchmark {
130+
($name:ident, $array:ident) => {
131+
#[divan::bench(args = DENSITIES)]
132+
fn $name(bencher: Bencher, density: f64) {
133+
bench_filter(bencher, $array(), || random_mask(density), false);
134+
}
135+
};
136+
}
137+
138+
random_density_benchmark!(random_i8, i8_array);
139+
random_density_benchmark!(random_i16, i16_array);
140+
random_density_benchmark!(random_i32, i32_array);
141+
random_density_benchmark!(random_i64, i64_array);
142+
random_density_benchmark!(random_i128, i128_array);
143+
random_density_benchmark!(random_i256, i256_array);
144+
145+
#[divan::bench(args = PATTERNS)]
146+
fn patterns_i128(bencher: Bencher, pattern: Pattern) {
147+
bench_filter(bencher, i128_array(), || pattern_mask(pattern), false);
148+
}
149+
150+
#[divan::bench(args = CACHED_DENSITIES)]
151+
fn cached_indices_i32(bencher: Bencher, density: f64) {
152+
bench_filter(bencher, i32_array(), || random_mask(density), true);
153+
}
154+
155+
#[divan::bench(args = CACHED_DENSITIES)]
156+
fn cached_indices_i128(bencher: Bencher, density: f64) {
157+
bench_filter(bencher, i128_array(), || random_mask(density), true);
158+
}

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
}

0 commit comments

Comments
 (0)