Skip to content

Commit 05de52d

Browse files
committed
perf(coalesce): fuse Boolean filter copying
Pack selected Boolean values and validity bits directly into the in-progress builders, avoiding temporary filtered arrays. Check filter masks before reading source words so sparse inputs skip empty chunks. Use separate loops for sources with and without actual nulls, and carry compressed fragments across chunk boundaries while preserving lazy validity allocation.
1 parent 69be95c commit 05de52d

2 files changed

Lines changed: 338 additions & 3 deletions

File tree

‎arrow-select/src/coalesce/boolean.rs‎

Lines changed: 333 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,136 @@ use super::InProgressArray;
1919
use crate::filter::FilterPredicate;
2020
use arrow_array::cast::AsArray;
2121
use arrow_array::{Array, ArrayRef, BooleanArray};
22-
use arrow_buffer::{BooleanBuffer, BooleanBufferBuilder, NullBufferBuilder};
22+
use arrow_buffer::bit_chunk_iterator::BitChunks;
23+
use arrow_buffer::{BooleanBuffer, BooleanBufferBuilder, NullBufferBuilder, bit_util};
2324
use arrow_schema::ArrowError;
2425
use std::sync::Arc;
2526

27+
/// Joins compressed bit fragments into complete output words.
28+
#[derive(Default)]
29+
struct WordPacker {
30+
/// Bits waiting to be appended.
31+
pending_bits: u64,
32+
/// Number of populated low bits in `pending_bits`.
33+
pending_len: u32,
34+
}
35+
36+
impl WordPacker {
37+
fn push(&mut self, compressed_bits: u64, bit_count: u32) -> Option<u64> {
38+
self.pending_bits |= compressed_bits << self.pending_len;
39+
let total_len = self.pending_len + bit_count;
40+
if total_len < 64 {
41+
self.pending_len = total_len;
42+
return None;
43+
}
44+
45+
let complete_word = self.pending_bits;
46+
// Keep bits that crossed the word boundary. Split the shift to avoid
47+
// shifting by 64 when `pending_len == 0`.
48+
self.pending_bits = (compressed_bits >> 1) >> (63 - self.pending_len);
49+
self.pending_len = total_len - 64;
50+
Some(complete_word)
51+
}
52+
53+
fn finish(self) -> Option<(u64, usize)> {
54+
(self.pending_len != 0).then_some((self.pending_bits, self.pending_len as usize))
55+
}
56+
}
57+
58+
fn copy_filter_words_without_nulls(
59+
output_values: &mut BooleanBufferBuilder,
60+
source_value_chunks: &BitChunks<'_>,
61+
filter_chunks: &BitChunks<'_>,
62+
) -> usize {
63+
let mut value_packer = WordPacker::default();
64+
let mut selected_count = 0;
65+
66+
// Check the mask first so sparse filters skip reading most source words.
67+
for (index, filter_mask) in filter_chunks.iter().enumerate() {
68+
let selected_in_word = filter_mask.count_ones();
69+
if selected_in_word == 0 {
70+
continue;
71+
}
72+
73+
let compressed_values = bit_util::compress(source_value_chunks.chunk(index), filter_mask);
74+
if let Some(complete_word) = value_packer.push(compressed_values, selected_in_word) {
75+
output_values.append_word(complete_word, 64);
76+
}
77+
selected_count += selected_in_word as usize;
78+
}
79+
80+
let filter_mask = filter_chunks.remainder_bits();
81+
let selected_in_word = filter_mask.count_ones();
82+
if selected_in_word != 0 {
83+
let compressed_values =
84+
bit_util::compress(source_value_chunks.remainder_bits(), filter_mask);
85+
if let Some(complete_word) = value_packer.push(compressed_values, selected_in_word) {
86+
output_values.append_word(complete_word, 64);
87+
}
88+
selected_count += selected_in_word as usize;
89+
}
90+
91+
if let Some((pending_bits, pending_len)) = value_packer.finish() {
92+
output_values.append_word(pending_bits, pending_len);
93+
}
94+
selected_count
95+
}
96+
97+
fn copy_filter_words_with_nulls(
98+
output_values: &mut BooleanBufferBuilder,
99+
output_validity: &mut NullBufferBuilder,
100+
source_value_chunks: &BitChunks<'_>,
101+
source_validity_chunks: &BitChunks<'_>,
102+
filter_chunks: &BitChunks<'_>,
103+
) -> usize {
104+
let mut value_packer = WordPacker::default();
105+
let mut validity_packer = WordPacker::default();
106+
let mut selected_count = 0;
107+
108+
// Check the mask first so sparse filters skip reading most source words.
109+
for (index, filter_mask) in filter_chunks.iter().enumerate() {
110+
let selected_in_word = filter_mask.count_ones();
111+
if selected_in_word == 0 {
112+
continue;
113+
}
114+
115+
let compressed_values = bit_util::compress(source_value_chunks.chunk(index), filter_mask);
116+
if let Some(complete_word) = value_packer.push(compressed_values, selected_in_word) {
117+
output_values.append_word(complete_word, 64);
118+
}
119+
let compressed_validity =
120+
bit_util::compress(source_validity_chunks.chunk(index), filter_mask);
121+
if let Some(complete_word) = validity_packer.push(compressed_validity, selected_in_word) {
122+
output_validity.append_word(complete_word, 64);
123+
}
124+
selected_count += selected_in_word as usize;
125+
}
126+
127+
let filter_mask = filter_chunks.remainder_bits();
128+
let selected_in_word = filter_mask.count_ones();
129+
if selected_in_word != 0 {
130+
let compressed_values =
131+
bit_util::compress(source_value_chunks.remainder_bits(), filter_mask);
132+
if let Some(complete_word) = value_packer.push(compressed_values, selected_in_word) {
133+
output_values.append_word(complete_word, 64);
134+
}
135+
let compressed_validity =
136+
bit_util::compress(source_validity_chunks.remainder_bits(), filter_mask);
137+
if let Some(complete_word) = validity_packer.push(compressed_validity, selected_in_word) {
138+
output_validity.append_word(complete_word, 64);
139+
}
140+
selected_count += selected_in_word as usize;
141+
}
142+
143+
if let Some((pending_bits, pending_len)) = value_packer.finish() {
144+
output_values.append_word(pending_bits, pending_len);
145+
}
146+
if let Some((pending_bits, pending_len)) = validity_packer.finish() {
147+
output_validity.append_word(pending_bits, pending_len);
148+
}
149+
selected_count
150+
}
151+
26152
#[derive(Debug)]
27153
pub(crate) struct InProgressBooleanArray {
28154
source: Option<ArrayRef>,
@@ -77,8 +203,57 @@ impl InProgressArray for InProgressBooleanArray {
77203
source: ArrayRef,
78204
filter: &FilterPredicate,
79205
) -> Result<(), ArrowError> {
80-
let filtered = filter.filter(source.as_ref())?;
81-
self.append_array(filtered.as_boolean());
206+
if filter.filter_len() > source.len() {
207+
return Err(ArrowError::InvalidArgumentError(format!(
208+
"Filter predicate of length {} is larger than target array of length {}",
209+
filter.filter_len(),
210+
source.len()
211+
)));
212+
}
213+
214+
let source = source.as_boolean();
215+
// Read values and mask in aligned words even when the source is sliced.
216+
let filter_chunks = filter.filter_mask().bit_chunks();
217+
let source_values = source.values();
218+
let source_value_chunks = BitChunks::new(
219+
source_values.values(),
220+
source_values.offset(),
221+
filter.filter_len(),
222+
);
223+
// Ignore present-but-all-valid buffers to avoid allocating output validity
224+
// for data with no actual nulls.
225+
let source_validity_chunks =
226+
source
227+
.nulls()
228+
.filter(|nulls| nulls.null_count() != 0)
229+
.map(|nulls| {
230+
let nulls = nulls.inner();
231+
BitChunks::new(nulls.values(), nulls.offset(), filter.filter_len())
232+
});
233+
234+
let output_values = self
235+
.values
236+
.get_or_insert_with(|| BooleanBufferBuilder::new(self.batch_size));
237+
238+
// Branch once here so non-null inputs avoid validity work in the chunk loop.
239+
let selected_count = if let Some(source_validity_chunks) = &source_validity_chunks {
240+
copy_filter_words_with_nulls(
241+
output_values,
242+
&mut self.nulls,
243+
&source_value_chunks,
244+
source_validity_chunks,
245+
&filter_chunks,
246+
)
247+
} else {
248+
let selected_count = copy_filter_words_without_nulls(
249+
output_values,
250+
&source_value_chunks,
251+
&filter_chunks,
252+
);
253+
self.nulls.append_n_non_nulls(selected_count);
254+
selected_count
255+
};
256+
debug_assert_eq!(selected_count, filter.count());
82257
Ok(())
83258
}
84259

@@ -109,6 +284,7 @@ impl InProgressArray for InProgressBooleanArray {
109284
mod tests {
110285
use super::*;
111286
use crate::filter::FilterBuilder;
287+
use arrow_buffer::NullBuffer;
112288

113289
#[test]
114290
fn copies_slices_and_sparse_filters() {
@@ -156,4 +332,158 @@ mod tests {
156332
assert_eq!(second.as_boolean(), &source.slice(2, 2));
157333
assert_eq!(in_progress.finish().unwrap().len(), 0);
158334
}
335+
336+
const FILTER_LEN: usize = 269;
337+
const SOURCE_OFFSET: usize = 3;
338+
339+
#[test]
340+
fn filtered_words_without_nulls_match_filter_kernel() {
341+
let source = non_null_source();
342+
assert!(source.nulls().is_none());
343+
344+
assert_filtered_words_match_filter_kernel(source);
345+
}
346+
347+
#[test]
348+
fn filtered_words_with_all_valid_buffer_match_filter_kernel() {
349+
let len = SOURCE_OFFSET + FILTER_LEN;
350+
let values = (0..len).map(source_value).collect();
351+
let source = BooleanArray::new(values, Some(NullBuffer::new_valid(len)))
352+
.slice(SOURCE_OFFSET, FILTER_LEN);
353+
assert!(source.nulls().is_some());
354+
assert_eq!(source.null_count(), 0);
355+
356+
assert_filtered_words_match_filter_kernel(source);
357+
}
358+
359+
#[test]
360+
fn filtered_words_with_nulls_match_filter_kernel() {
361+
let source = BooleanArray::from_iter(
362+
(0..SOURCE_OFFSET + FILTER_LEN)
363+
.map(|index| (index % 11 != 0).then_some(index % 3 == 0 || index % 7 == 0)),
364+
)
365+
.slice(SOURCE_OFFSET, FILTER_LEN);
366+
assert!(source.null_count() > 0);
367+
368+
assert_filtered_words_match_filter_kernel(source);
369+
}
370+
371+
#[test]
372+
fn filtered_words_handle_sliced_filter() {
373+
const FILTER_OFFSET: usize = 5;
374+
375+
let filter: BooleanArray = std::iter::repeat_n(false, FILTER_OFFSET)
376+
.chain((0..FILTER_LEN).map(|index| index.is_multiple_of(20)))
377+
.collect();
378+
let filter = filter.slice(FILTER_OFFSET, FILTER_LEN);
379+
assert_eq!(filter.values().offset(), FILTER_OFFSET);
380+
381+
assert_filter_copy_matches_kernel("sliced filter", &non_null_source(), &filter);
382+
}
383+
384+
#[test]
385+
fn filtered_words_treat_null_filter_entries_as_false() {
386+
let mut filter_values = vec![false; FILTER_LEN];
387+
for index in [1, 65, 129, 268] {
388+
filter_values[index] = true;
389+
}
390+
let mut filter_validity = vec![true; FILTER_LEN];
391+
filter_validity[65] = false;
392+
let filter = BooleanArray::new(
393+
BooleanBuffer::from(filter_values),
394+
Some(NullBuffer::new(BooleanBuffer::from(filter_validity))),
395+
);
396+
assert!(filter.value(65));
397+
assert!(filter.is_null(65));
398+
399+
assert_filter_copy_matches_kernel("null filter entry", &non_null_source(), &filter);
400+
}
401+
402+
fn assert_filtered_words_match_filter_kernel(source: BooleanArray) {
403+
let selections = [
404+
("none", vec![]),
405+
("all", (0..FILTER_LEN).collect()),
406+
(
407+
"word boundaries",
408+
vec![0, 1, 7, 63, 64, 65, 127, 128, 191, 192, 255, 256, 268],
409+
),
410+
(
411+
"carry across source words",
412+
// The first two chunks leave 24 carried bits; the third completes a word.
413+
(0..40).chain(64..112).chain(128..168).collect(),
414+
),
415+
("alternating", (0..FILTER_LEN).step_by(2).collect()),
416+
];
417+
418+
for (case, selected_indices) in &selections {
419+
let filter: BooleanArray = (0..FILTER_LEN)
420+
.map(|index| selected_indices.contains(&index))
421+
.collect();
422+
assert_filter_copy_matches_kernel(case, &source, &filter);
423+
}
424+
}
425+
426+
fn assert_filter_copy_matches_kernel(case: &str, source: &BooleanArray, filter: &BooleanArray) {
427+
const PREFIX_LEN: usize = 5;
428+
let expect_lazy_validity = source.null_count() == 0;
429+
430+
for optimize in [false, true] {
431+
let builder = FilterBuilder::new(filter);
432+
let predicate = if optimize {
433+
builder.optimize().build()
434+
} else {
435+
builder.build()
436+
};
437+
let filtered = predicate.filter(source).unwrap();
438+
let prefix = source.slice(0, PREFIX_LEN);
439+
let expected = crate::concat::concat(&[&prefix, filtered.as_ref()]).unwrap();
440+
441+
let source = Arc::new(source.clone()) as ArrayRef;
442+
let mut in_progress = InProgressBooleanArray::new(FILTER_LEN);
443+
in_progress.set_source(Some(Arc::clone(&source)));
444+
in_progress.copy_rows(0, PREFIX_LEN).unwrap();
445+
in_progress.set_source(None);
446+
in_progress
447+
.copy_rows_by_filter_from(source, &predicate)
448+
.unwrap();
449+
450+
let output = in_progress.finish().unwrap();
451+
assert_eq!(
452+
output.as_boolean(),
453+
expected.as_boolean(),
454+
"{case}, optimize={optimize}"
455+
);
456+
if expect_lazy_validity {
457+
assert!(
458+
output.nulls().is_none(),
459+
"{case}, optimize={optimize} should preserve lazy validity"
460+
);
461+
}
462+
}
463+
}
464+
465+
fn non_null_source() -> BooleanArray {
466+
let source: BooleanArray = (0..SOURCE_OFFSET + FILTER_LEN).map(source_value).collect();
467+
source.slice(SOURCE_OFFSET, FILTER_LEN)
468+
}
469+
470+
fn source_value(index: usize) -> bool {
471+
index.is_multiple_of(3) || index.is_multiple_of(7)
472+
}
473+
474+
#[test]
475+
fn filter_longer_than_source_returns_error() {
476+
let source = Arc::new(BooleanArray::from(vec![true, false])) as ArrayRef;
477+
let filter_mask = BooleanArray::from(vec![true, false, true]);
478+
let predicate = FilterBuilder::new(&filter_mask).build();
479+
let mut in_progress = InProgressBooleanArray::new(8);
480+
481+
let error = in_progress
482+
.copy_rows_by_filter_from(source, &predicate)
483+
.unwrap_err();
484+
assert_eq!(
485+
error.to_string(),
486+
"Invalid argument error: Filter predicate of length 3 is larger than target array of length 2"
487+
);
488+
}
159489
}

‎arrow-select/src/filter.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,11 @@ impl FilterPredicate {
530530
self.filter.len()
531531
}
532532

533+
/// Returns the normalized filter mask, with nulls replaced by `false`.
534+
pub(crate) fn filter_mask(&self) -> &BooleanBuffer {
535+
self.filter.values()
536+
}
537+
533538
/// Return a [`FilterSelection`] for iterating over the rows selected by
534539
/// this [`FilterPredicate`].
535540
pub(crate) fn selection(&self) -> FilterSelection<'_> {

0 commit comments

Comments
 (0)