This is an automated email from the ASF dual-hosted git repository.
alamb pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-rs.git
The following commit(s) were added to refs/heads/main by this push:
new 3e03c57afd perf: Add APIs for filter with known selected-row count
(#11206)
3e03c57afd is described below
commit 3e03c57afd96232ccb88a73511849fedfeaf43f4
Author: Neil Conway <[email protected]>
AuthorDate: Tue Sep 29 16:29:55 2026 -0400
perf: Add APIs for filter with known selected-row count (#11206)
# Which issue does this PR close?
- Closes #11205.
# Rationale for this change
`filter`, `filter_record_batch`,
`BatchCoalescer::push_batch_with_filter`, and `FilterBuilder` start by
calling `BooleanArray::true_count` on the caller-provided mask. However,
some callers may already know the number of rows selected by the mask --
for example, because they built the mask one row at a time, derived it
from another data structure (e.g., a validity bitmap), or needed to
compute it for some other reason. In those situations, calling
`true_count` requires a redundant full pass over the bitmap.
Some examples from DataFusion where the count is known before the kernel
runs:
- `FilterExec` with a `LIMIT` counts the mask to track how many rows
remain, then `push_batch_with_filter` counts it again.
- `CASE` evaluation filters the remaining rows for each branch; the
count of each new mask is the previous predicate's `count()` subtracted
from the remaining row count.
- `AND`/`OR` short-circuit evaluation counts the left side to decide
whether to pre-select rows, then drops the count and filters the batch.
- Aggregate `FILTER` clauses count the selection to decide whether
filtering is worthwhile, then filter with the same mask.
# What changes are included in this PR?
- Add `FilterBuilder::with_count`, a `pub unsafe fn` that supplies the
number of rows the mask selects so that `build` does not count them. To
make this possible, `FilterBuilder` now counts the mask, picks the
iteration strategy, and applies `optimize` in `build`, instead of in
`new` and `optimize`.
- Add `BatchCoalescer::push_batch_with_filter_builder`, which takes a
`FilterBuilder` instead of a mask. It is a safe method: the count is
supplied when the `FilterBuilder` is created, so `with_count` is the
only new `unsafe` API.
- The docs of `filter`, `filter_record_batch`, and
`push_batch_with_filter` point to `FilterBuilder::with_count` for
callers that already know the count.
- Add tests.
`with_count` is marked `unsafe` because the filter kernels rely on the
count being correct: it sizes the filtered output, and a wrong count
leads to out-of-bounds writes. The count is only checked with
`debug_assert_eq!`.
# Are these changes tested?
Yes. Existing tests pass. `test_push_batch_with_filter_builder` covers
the new `BatchCoalescer` method, using one builder with a
caller-supplied count and one the caller optimized. Doc examples cover
`FilterBuilder::with_count` and
`BatchCoalescer::push_batch_with_filter_builder`.
# Are there any user-facing changes?
Two new public APIs: `FilterBuilder::with_count` and
`BatchCoalescer::push_batch_with_filter_builder`. The work done by
`FilterBuilder::new` and `FilterBuilder::optimize` now happens in
`FilterBuilder::build`; this is not visible through the public API.
# AI usage
Developed with Claude Code (Fable 5.1 and Opus 5.5). I have reviewed and
understand the resulting code.
---
arrow-select/src/coalesce.rs | 83 +++++++++++++++++++++++++++++++++-----
arrow-select/src/filter.rs | 96 ++++++++++++++++++++++++++++++++------------
2 files changed, 143 insertions(+), 36 deletions(-)
diff --git a/arrow-select/src/coalesce.rs b/arrow-select/src/coalesce.rs
index 929526ef40..863d391829 100644
--- a/arrow-select/src/coalesce.rs
+++ b/arrow-select/src/coalesce.rs
@@ -238,6 +238,10 @@ impl BatchCoalescer {
/// This is semantically equivalent of calling [`Self::push_batch`]
/// with the results from [`crate::filter::filter_record_batch`]
///
+ /// If the number of rows that `filter` selects is already known,
+ /// [`Self::push_batch_with_filter_builder`] with
+ /// [`FilterBuilder::with_count`] avoids counting them again.
+ ///
/// # Example
/// ```
/// # use arrow_array::{record_batch, BooleanArray};
@@ -262,7 +266,45 @@ impl BatchCoalescer {
batch: RecordBatch,
filter: &BooleanArray,
) -> Result<(), ArrowError> {
- self.push_batch_with_filtered_columns(batch, filter)
+ self.push_batch_with_filter_builder(batch, FilterBuilder::new(filter))
+ }
+
+ /// Push a batch into the Coalescer after applying the filter described by
+ /// `filter_builder`.
+ ///
+ /// This is [`Self::push_batch_with_filter`] for a [`FilterBuilder`] the
+ /// caller has already created. For example, callers that already know how
+ /// many rows the filter selects can provide that number with
+ /// [`FilterBuilder::with_count`] so that it is not counted again.
+ ///
+ /// Callers do not need to call [`FilterBuilder::optimize`]: like
+ /// [`Self::push_batch_with_filter`], this optimizes the filter when
`batch`
+ /// has more than one column, or one column for which
+ /// [`FilterBuilder::is_optimize_beneficial`] returns true. A filter the
+ /// caller already optimized stays optimized.
+ ///
+ /// # Example
+ /// ```
+ /// # use arrow_array::{record_batch, BooleanArray};
+ /// # use arrow_select::coalesce::BatchCoalescer;
+ /// # use arrow_select::filter::FilterBuilder;
+ /// let batch = record_batch!(("a", Int32, [1, 2, 3])).unwrap();
+ /// let filter = BooleanArray::from(vec![true, false, true]);
+ /// // SAFETY: the filter selects two rows
+ /// let filter_builder = unsafe {
FilterBuilder::new(&filter).with_count(2) };
+ /// let mut coalescer = BatchCoalescer::new(batch.schema(), 1000);
+ /// coalescer.push_batch_with_filter_builder(batch,
filter_builder).unwrap();
+ /// coalescer.finish_buffered_batch().unwrap();
+ /// let expected_batch = record_batch!(("a", Int32, [1, 3])).unwrap();
+ /// assert_eq!(coalescer.next_completed_batch().unwrap(), expected_batch);
+ /// ```
+ pub fn push_batch_with_filter_builder(
+ &mut self,
+ batch: RecordBatch,
+ filter_builder: FilterBuilder,
+ ) -> Result<(), ArrowError> {
+ let predicate = Self::filter_predicate_for_batch(&batch,
filter_builder);
+ self.push_batch_with_filtered_columns(batch, &predicate)
}
/// Push a batch into the Coalescer after applying a set of indices
@@ -606,10 +648,8 @@ impl BatchCoalescer {
impl BatchCoalescer {
fn filter_predicate_for_batch(
batch: &RecordBatch,
- filter: &BooleanArray,
- selected_count: usize,
+ mut filter_builder: FilterBuilder,
) -> FilterPredicate {
- let mut filter_builder = FilterBuilder::new_with_count(filter,
selected_count);
if batch.num_columns() > 1
|| (batch.num_columns() > 0
&&
FilterBuilder::is_optimize_beneficial(batch.schema_ref().field(0).data_type()))
@@ -622,9 +662,10 @@ impl BatchCoalescer {
fn push_batch_with_filtered_columns(
&mut self,
batch: RecordBatch,
- filter: &BooleanArray,
+ predicate: &FilterPredicate,
) -> Result<(), ArrowError> {
- let filter_len = filter.len();
+ let filter_len = predicate.filter_len();
+ let selected_count = predicate.count();
let batch_num_rows = batch.num_rows();
let batch_num_columns = batch.num_columns();
@@ -634,7 +675,6 @@ impl BatchCoalescer {
)));
}
- let selected_count = filter.true_count();
if selected_count == 0 {
return Ok(());
}
@@ -662,16 +702,14 @@ impl BatchCoalescer {
if should_materialize_filter {
// Use materialized filtering when sparse per-column copying is
unavailable.
- let predicate = Self::filter_predicate_for_batch(&batch, filter,
selected_count);
let filtered_batch = predicate.filter_record_batch(&batch)?;
return self.push_batch(filtered_batch);
}
- let predicate = Self::filter_predicate_for_batch(&batch, filter,
selected_count);
let (_schema, arrays, _num_rows) = batch.into_parts();
for (in_progress, array) in
self.in_progress_arrays.iter_mut().zip(arrays) {
- in_progress.copy_rows_by_filter_from(array, &predicate)?;
+ in_progress.copy_rows_by_filter_from(array, predicate)?;
}
self.buffered_rows += selected_count;
@@ -788,7 +826,7 @@ mod tests {
use arrow_array::types::Int32Type;
use arrow_array::{
BinaryViewArray, Int32Array, Int64Array, RecordBatchOptions,
StringArray, StringViewArray,
- TimestampNanosecondArray, UInt32Array, UInt64Array, make_array,
+ TimestampNanosecondArray, UInt32Array, UInt64Array, make_array,
record_batch,
};
use arrow_buffer::BooleanBufferBuilder;
use arrow_schema::{DataType, Field, Schema};
@@ -1025,6 +1063,29 @@ mod tests {
.run();
}
+ #[test]
+ fn test_push_batch_with_filter_builder() {
+ let batch = record_batch!(("a", Int32, [1, 2, 3, 4])).unwrap();
+ // The null is not selected, so the filter selects two rows.
+ let filter = BooleanArray::from(vec![Some(true), None, Some(false),
Some(true)]);
+ let expected = record_batch!(("a", Int32, [1, 4])).unwrap();
+
+ // SAFETY: the count matches the filter.
+ let with_count = unsafe { FilterBuilder::new(&filter).with_count(2) };
+ // The coalescer does not optimize a single primitive column itself, so
+ // this covers a predicate that only the caller optimized.
+ let optimized = FilterBuilder::new(&filter).optimize();
+ for filter_builder in [with_count, optimized] {
+ let mut coalescer = BatchCoalescer::new(batch.schema(), 10);
+ coalescer
+ .push_batch_with_filter_builder(batch.clone(), filter_builder)
+ .unwrap();
+ coalescer.finish_buffered_batch().unwrap();
+ assert_eq!(coalescer.next_completed_batch().unwrap(), expected);
+ assert!(coalescer.next_completed_batch().is_none());
+ }
+ }
+
#[test]
fn test_coalesce_non_null() {
Test::new("coalesce_non_null")
diff --git a/arrow-select/src/filter.rs b/arrow-select/src/filter.rs
index ed9b0da637..9baccf745c 100644
--- a/arrow-select/src/filter.rs
+++ b/arrow-select/src/filter.rs
@@ -181,6 +181,9 @@ pub fn prep_null_mask_filter(filter: &BooleanArray) ->
BooleanArray {
/// In contrast to this function, it is then the responsibility of the caller
/// to use [FilterBuilder::optimize] if appropriate.
///
+/// If the number of rows that `predicate` selects is already known,
+/// [`FilterBuilder::with_count`] avoids counting them again.
+///
/// # See also
/// * [`FilterBuilder`] for more control over the filtering process.
/// * [`filter_record_batch`] to filter a [`RecordBatch`]
@@ -223,6 +226,9 @@ pub fn filter(values: &dyn Array, predicate: &BooleanArray)
-> Result<ArrayRef,
/// calling [FilterPredicate::filter_record_batch].
/// In contrast to this function, it is then the responsibility of the caller
/// to use [FilterBuilder::optimize] if appropriate.
+///
+/// If the number of rows that `predicate` selects is already known,
+/// [`FilterBuilder::with_count`] avoids counting them again.
pub fn filter_record_batch(
record_batch: &RecordBatch,
predicate: &BooleanArray,
@@ -248,33 +254,62 @@ pub fn filter_record_batch(
#[derive(Debug)]
pub struct FilterBuilder {
filter: BooleanArray,
- count: usize,
- strategy: IterationStrategy,
+ /// The number of rows `filter` selects, if provided by
[`Self::with_count`]
+ count: Option<usize>,
+ optimize: bool,
}
impl FilterBuilder {
/// Create a new [`FilterBuilder`] that can be used to construct a
[`FilterPredicate`]
pub fn new(filter: &BooleanArray) -> Self {
- Self::new_with_count(filter, filter.true_count())
- }
-
- pub(crate) fn new_with_count(filter: &BooleanArray, count: usize) -> Self {
let filter = match filter.null_count() {
0 => filter.clone(),
_ => prep_null_mask_filter(filter),
};
- let strategy = IterationStrategy::default_strategy(filter.len(),
count);
-
Self {
filter,
- count,
- strategy,
+ count: None,
+ optimize: false,
}
}
- /// Compute an optimized representation of the provided `filter` mask that
can be
- /// applied to an array more quickly.
+ /// Set the number of rows that the filter selects, so that [`Self::build`]
+ /// does not have to count them.
+ ///
+ /// Callers that build a mask row by row, or derive it from a validity
+ /// buffer with a cached null count, often already hold this number.
+ ///
+ /// # Safety
+ ///
+ /// `count` must equal [`BooleanArray::true_count`] of the filter passed to
+ /// [`Self::new`]: the number of `true` values that are not null.
+ ///
+ /// # Example
+ /// ```
+ /// # use arrow_array::{BooleanArray, Int32Array};
+ /// # use arrow_select::filter::FilterBuilder;
+ /// let values = Int32Array::from(vec![1, 2, 3, 4]);
+ /// let mask = BooleanArray::from(vec![Some(true), None, Some(true),
Some(false)]);
+ /// // The null is not selected, so the mask selects two rows.
+ /// // SAFETY: the count matches the mask.
+ /// let predicate = unsafe { FilterBuilder::new(&mask).with_count(2)
}.build();
+ /// assert_eq!(predicate.count(), 2);
+ /// let filtered = predicate.filter(&values).unwrap();
+ /// assert_eq!(filtered.as_ref(), &Int32Array::from(vec![1, 3]));
+ /// ```
+ pub unsafe fn with_count(mut self, count: usize) -> Self {
+ debug_assert_eq!(
+ count,
+ self.filter.true_count(),
+ "count must match the number of rows the filter selects"
+ );
+ self.count = Some(count);
+ self
+ }
+
+ /// Compute an optimized representation of the provided `filter` mask in
+ /// [`Self::build`], so that it can be applied to an array more quickly.
///
/// When filtering multiple arrays (e.g. a [`RecordBatch`] or a
/// [`StructArray`] with multiple fields), optimizing the filter can
provide
@@ -284,17 +319,7 @@ impl FilterBuilder {
/// than the original mask, so it is often faster to filter a single array,
/// without filter optimization.
pub fn optimize(mut self) -> Self {
- match self.strategy {
- IterationStrategy::SlicesIterator => {
- let slices = SlicesIterator::new(&self.filter).collect();
- self.strategy = IterationStrategy::Slices(slices)
- }
- IterationStrategy::IndexIterator => {
- let indices = IndexIterator::new(&self.filter,
self.count).collect();
- self.strategy = IterationStrategy::Indices(indices)
- }
- _ => {}
- }
+ self.optimize = true;
self
}
@@ -316,10 +341,26 @@ impl FilterBuilder {
/// Construct the final `FilterPredicate`
pub fn build(self) -> FilterPredicate {
+ let count = self.count.unwrap_or_else(|| self.filter.true_count());
+ let mut strategy =
IterationStrategy::default_strategy(self.filter.len(), count);
+ if self.optimize {
+ match strategy {
+ IterationStrategy::SlicesIterator => {
+ let slices = SlicesIterator::new(&self.filter).collect();
+ strategy = IterationStrategy::Slices(slices)
+ }
+ IterationStrategy::IndexIterator => {
+ let indices = IndexIterator::new(&self.filter,
count).collect();
+ strategy = IterationStrategy::Indices(indices)
+ }
+ _ => {}
+ }
+ }
+
FilterPredicate {
filter: self.filter,
- count: self.count,
- strategy: self.strategy,
+ count,
+ strategy,
}
}
}
@@ -483,6 +524,11 @@ impl FilterPredicate {
self.count
}
+ /// Length of the filter mask, including rows that are not selected
+ pub(crate) fn filter_len(&self) -> usize {
+ self.filter.len()
+ }
+
/// Return a [`FilterSelection`] for iterating over the rows selected by
/// this [`FilterPredicate`].
pub(crate) fn selection(&self) -> FilterSelection<'_> {