-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Make push_batch_with_filter up to 3x faster
#8951
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 7 commits
6ecd42b
a8df36f
f20702b
124b4e3
79bd847
b2fc66f
0872a9b
b7b3f18
7758889
dcf4864
87626d1
7c46a72
6ee1f04
c39a455
dc0c45e
d2b5d29
18cf6fc
ddd0306
82acfe1
1acccc7
bb025cf
ca19422
f718f2e
b235243
ae995ba
e8919b1
46484ea
3022aa6
4eb65a0
b2f9e42
6bfd685
0f994fa
72c356a
432b760
a4dee8a
9927454
4a3ce6a
c587cf0
69dbab2
e28c305
077ad74
f7c430d
923c2b2
6d26fbc
91adb91
d807503
db37aa1
6234ee0
924e1fe
8485edf
9ee3cf1
684bc9f
b41cd0d
66c1dae
94317f7
717ed06
a064327
b8322ce
ac1afae
bf63ec5
5d5d1bf
9f43539
c3b76dc
4bdccc2
90c0a39
87fe9c8
a9444d9
7d1223d
91fe6f7
efeeded
b850d6f
2380b11
5678415
4951e5b
a4a49d1
e78fa28
e2ad508
414b3b9
840653b
a1d4097
9773da7
bd65f64
ec7ef9e
e76a5d7
58190b8
be6b796
f0b98d6
9aaca1f
16f5d86
a27e4ab
6de89a3
2b8711f
12c526b
310404c
92b1b46
c9ae743
5ae319f
bd4a1bc
99772cf
63443da
b9efe0c
cbaaf98
0dbd786
0a5a575
3fbd560
d3cf7cb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,7 +20,10 @@ | |
| //! | ||
| //! [`filter`]: crate::filter::filter | ||
| //! [`take`]: crate::take::take | ||
| use crate::filter::filter_record_batch; | ||
| use crate::filter::{ | ||
| FilterBuilder, FilterPredicate, IndexIterator, filter_record_batch, | ||
| is_optimize_beneficial_record_batch, | ||
| }; | ||
| use arrow_array::types::{BinaryViewType, StringViewType}; | ||
| use arrow_array::{Array, ArrayRef, BooleanArray, RecordBatch, downcast_primitive}; | ||
| use arrow_schema::{ArrowError, DataType, SchemaRef}; | ||
|
|
@@ -211,7 +214,10 @@ impl BatchCoalescer { | |
| /// Push a batch into the Coalescer after applying a filter | ||
| /// | ||
| /// This is semantically equivalent of calling [`Self::push_batch`] | ||
| /// with the results from [`filter_record_batch`] | ||
| /// with the results from [`filter_record_batch`], but avoids | ||
| /// materializing the intermediate filtered batch. | ||
| /// | ||
| /// [`filter_record_batch`]: crate::filter::filter_record_batch | ||
| /// | ||
| /// # Example | ||
| /// ``` | ||
|
|
@@ -237,10 +243,101 @@ impl BatchCoalescer { | |
| batch: RecordBatch, | ||
| filter: &BooleanArray, | ||
| ) -> Result<(), ArrowError> { | ||
| // TODO: optimize this to avoid materializing (copying the results | ||
| // of filter to a new batch) | ||
| let filtered_batch = filter_record_batch(&batch, filter)?; | ||
| self.push_batch(filtered_batch) | ||
| // We only support primitve now, fallback to filter_record_batch for other types | ||
| // Also, skip optimization when filter is not very selective | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure if always better to take into account
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, I do think it would be good to take into account |
||
| if batch | ||
| .schema() | ||
| .fields() | ||
| .iter() | ||
| .any(|field| !field.data_type().is_primitive()) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Because of the all-or-nothing check it has no effect on tpch/clickbench performance yet. I am not sure if we can somehow use the conventional filter kernel for non-supported arrays 🤔
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah I am thinking about that as well |
||
| || self | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think benchmarks show it is better to just use the kernel always (so I think this condition needs to go). |
||
| .biggest_coalesce_batch_size | ||
| .map(|biggest_size| filter.true_count() > biggest_size) | ||
| .unwrap_or(false) | ||
| { | ||
| let batch = filter_record_batch(&batch, filter)?; | ||
|
|
||
| self.push_batch(batch)?; | ||
| return Ok(()); | ||
| } | ||
|
|
||
| // Build an optimized filter predicate that chooses the best iteration strategy | ||
| let is_optimize_beneficial = is_optimize_beneficial_record_batch(&batch); | ||
| let selected_count = filter.true_count(); | ||
|
|
||
| // Fast path: skip if no rows selected | ||
| if selected_count == 0 { | ||
| return Ok(()); | ||
| } | ||
|
|
||
| // Fast path: if all rows selected, just push the batch | ||
| if selected_count == batch.num_rows() { | ||
| return self.push_batch(batch); | ||
| } | ||
|
|
||
| let (_schema, arrays, _num_rows) = batch.into_parts(); | ||
|
|
||
| // Setup input arrays as sources | ||
| assert_eq!(arrays.len(), self.in_progress_arrays.len()); | ||
| self.in_progress_arrays | ||
| .iter_mut() | ||
| .zip(&arrays) | ||
| .for_each(|(in_progress, array)| { | ||
| in_progress.set_source(Some(Arc::clone(array))); | ||
| }); | ||
|
Dandandan marked this conversation as resolved.
|
||
|
|
||
| // Choose iteration strategy based on the optimized predicate | ||
| self.copy_from_filter(filter, is_optimize_beneficial, selected_count)?; | ||
|
|
||
| // Clear sources to allow memory to be freed | ||
| for in_progress in self.in_progress_arrays.iter_mut() { | ||
| in_progress.set_source(None); | ||
| } | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| /// Helper to copy rows at the given indices, handling batch boundaries efficiently | ||
| /// | ||
| /// This method batches the index iteration to avoid per-row batch boundary checks. | ||
| fn copy_from_filter( | ||
| &mut self, | ||
| filter: &BooleanArray, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why getting |
||
| is_optimize_beneficial: bool, | ||
| count: usize, | ||
| ) -> Result<(), ArrowError> { | ||
| let mut remaining = count; | ||
| let mut offset = 0; | ||
|
|
||
| // We need to process the filter in chunks that fit the target batch size | ||
| while remaining > 0 { | ||
| let space_in_batch = self.target_batch_size - self.buffered_rows; | ||
| let to_copy = remaining.min(space_in_batch); | ||
|
|
||
| let sliced_filter = filter.slice(offset, to_copy); | ||
| let mut filter = FilterBuilder::new(&sliced_filter); | ||
|
|
||
| if is_optimize_beneficial { | ||
| filter = filter.optimize(); | ||
| } | ||
| let chunk_predicate = filter.build(); | ||
|
|
||
| // Copy all collected indices in one call per array | ||
| for in_progress in self.in_progress_arrays.iter_mut() { | ||
| in_progress.copy_rows_by_filter(&chunk_predicate)?; | ||
| } | ||
|
Comment on lines
+326
to
+329
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A performance improvement you can do here is copy X columns at a time like I did and explained in 4: the number 4 is a magic number, but you can pick other number like 2 to amortize the cost of boolean iterations |
||
|
|
||
| self.buffered_rows += to_copy; | ||
| offset += to_copy; | ||
| remaining -= to_copy; | ||
|
|
||
| // If we've filled the batch, finish it | ||
| if self.buffered_rows >= self.target_batch_size { | ||
| self.finish_buffered_batch()?; | ||
| } | ||
| } | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| /// Push all the rows from `batch` into the Coalescer | ||
|
|
@@ -571,6 +668,15 @@ trait InProgressArray: std::fmt::Debug + Send + Sync { | |
| /// Return an error if the source array is not set | ||
| fn copy_rows(&mut self, offset: usize, len: usize) -> Result<(), ArrowError>; | ||
|
|
||
| /// Copy rows at the given indices from the current source array into the in-progress array | ||
| fn copy_rows_by_filter(&mut self, filter: &FilterPredicate) -> Result<(), ArrowError> { | ||
| // Default implementation: iterate over indices from the filter | ||
|
alamb marked this conversation as resolved.
Outdated
|
||
| for idx in IndexIterator::new(filter.filter_array(), filter.count()) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I found it strange that the default implementation copied with a different iteration strategy -- Upon review, it seems like this code should never be called Therefore I would recommend making the default implementation Another potential way to make this clearer would be add a method like |
||
| self.copy_rows(idx, 1)?; | ||
| } | ||
| Ok(()) | ||
| } | ||
|
|
||
| /// Finish the currently in-progress array and return it as an `ArrayRef` | ||
| fn finish(&mut self) -> Result<ArrayRef, ArrowError>; | ||
| } | ||
|
|
@@ -579,6 +685,7 @@ trait InProgressArray: std::fmt::Debug + Send + Sync { | |
| mod tests { | ||
| use super::*; | ||
| use crate::concat::concat_batches; | ||
| use crate::filter::filter_record_batch; | ||
| use arrow_array::builder::StringViewBuilder; | ||
| use arrow_array::cast::AsArray; | ||
| use arrow_array::{ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,7 @@ | |
| // under the License. | ||
|
|
||
| use crate::coalesce::InProgressArray; | ||
| use crate::filter::{FilterPredicate, IndexIterator, IterationStrategy, SlicesIterator}; | ||
| use arrow_array::cast::AsArray; | ||
| use arrow_array::{Array, ArrayRef, ArrowPrimitiveType, PrimitiveArray}; | ||
| use arrow_buffer::{NullBufferBuilder, ScalarBuffer}; | ||
|
|
@@ -92,6 +93,105 @@ impl<T: ArrowPrimitiveType + Debug> InProgressArray for InProgressPrimitiveArray | |
| Ok(()) | ||
| } | ||
|
|
||
| /// Copy rows by indices using a predicate | ||
| fn copy_rows_by_filter(&mut self, filter: &FilterPredicate) -> Result<(), ArrowError> { | ||
| self.ensure_capacity(); | ||
|
|
||
| let s = self | ||
| .source | ||
| .as_ref() | ||
| .ok_or_else(|| { | ||
| ArrowError::InvalidArgumentError( | ||
| "Internal Error: InProgressPrimitiveArray: source not set".to_string(), | ||
| ) | ||
| })? | ||
| .as_primitive::<T>(); | ||
|
|
||
| let values = s.values(); | ||
| let count = filter.count(); | ||
|
|
||
| // Use the predicate's strategy for optimal iteration | ||
| match filter.strategy() { | ||
| IterationStrategy::SlicesIterator => { | ||
| // Copy values using slices | ||
| for (start, end) in SlicesIterator::new(filter.filter_array()) { | ||
| self.current.extend_from_slice(&values[start..end]); | ||
| } | ||
| // Copy nulls using slices | ||
| if let Some(nulls) = s.nulls().filter(|n| n.null_count() > 0) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't know if it would matter, but one difference with the filter kernel is that the filter kernel handles values in one loop and then nulls in a second ( Doing so would keep the inner loop smaller and make it easier to reuse the null filtering code between kernels However, this is also something we can do as a follow on PR / refactor when we add a second array type |
||
| for (start, end) in SlicesIterator::new(filter.filter_array()) { | ||
| let slice = nulls.slice(start, end - start); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One thing we could also check is adding a
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes that would probably be faster |
||
| self.nulls.append_buffer(&slice); | ||
| } | ||
| } else { | ||
| self.nulls.append_n_non_nulls(count); | ||
| } | ||
| } | ||
| IterationStrategy::Slices(slices) => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This code still needs tests (it is still uncovered I think) I can try and help find time to write some given how exciting this PR is |
||
| // Copy values using precomputed slices | ||
| for &(start, end) in slices { | ||
| self.current.extend_from_slice(&values[start..end]); | ||
| } | ||
| // Copy nulls using slices | ||
| if let Some(nulls) = s.nulls().filter(|n| n.null_count() > 0) { | ||
| for &(start, end) in slices { | ||
| let slice = nulls.slice(start, end - start); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note, null slices compute null count, so you might want to avoid that |
||
| self.nulls.append_buffer(&slice); | ||
| } | ||
| } else { | ||
| self.nulls.append_n_non_nulls(count); | ||
| } | ||
| } | ||
| IterationStrategy::IndexIterator => { | ||
| // Copy values and nulls for each index | ||
| if let Some(nulls) = s.nulls().filter(|n| n.null_count() > 0) { | ||
| for idx in IndexIterator::new(filter.filter_array(), count) { | ||
| if nulls.is_null(idx) { | ||
| self.nulls.append_null(); | ||
| } else { | ||
| self.nulls.append_non_null(); | ||
| } | ||
| self.current.push(values[idx]); | ||
| } | ||
| } else { | ||
| self.nulls.append_n_non_nulls(count); | ||
| for idx in IndexIterator::new(filter.filter_array(), count) { | ||
| self.current.push(values[idx]); | ||
| } | ||
| } | ||
| } | ||
| IterationStrategy::Indices(indices) => { | ||
| // Copy values and nulls using precomputed indices | ||
| if let Some(nulls) = s.nulls().filter(|n| n.null_count() > 0) { | ||
| for &idx in indices { | ||
| if nulls.is_null(idx) { | ||
| self.nulls.append_null(); | ||
| } else { | ||
| self.nulls.append_non_null(); | ||
| } | ||
| } | ||
| } else { | ||
| self.nulls.append_n_non_nulls(count); | ||
| } | ||
| self.current.extend(indices.iter().map(|&idx| values[idx])); | ||
| } | ||
| IterationStrategy::All => { | ||
| // Copy all values | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. While trying to write a test to cover this code, I am pretty sure this is unreachable (as if the entire batch is selected, then the fast path above like this is executed: // Fast path: if all rows selected, just push the batch
if selected_count == num_rows {
return self.push_batch(batch);
} |
||
| self.current.extend_from_slice(values); | ||
| if let Some(nulls) = s.nulls() { | ||
| self.nulls.append_buffer(nulls); | ||
| } else { | ||
| self.nulls.append_n_non_nulls(values.len()); | ||
| } | ||
| } | ||
| IterationStrategy::None => { | ||
| // Nothing to copy | ||
| } | ||
| } | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| fn finish(&mut self) -> Result<ArrayRef, ArrowError> { | ||
| // take and reset the current values and nulls | ||
| let values = std::mem::take(&mut self.current); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -80,13 +80,17 @@ impl Iterator for SlicesIterator<'_> { | |
| /// | ||
| /// This provides the best performance on most predicates, apart from those which keep | ||
| /// large runs and therefore favour [`SlicesIterator`] | ||
| struct IndexIterator<'a> { | ||
| pub struct IndexIterator<'a> { | ||
| remaining: usize, | ||
| iter: BitIndexIterator<'a>, | ||
| } | ||
|
|
||
| impl<'a> IndexIterator<'a> { | ||
| fn new(filter: &'a BooleanArray, remaining: usize) -> Self { | ||
| /// Creates a new [`IndexIterator`] from a [`BooleanArray`] | ||
| /// | ||
| /// # Panics | ||
| /// Panics if `filter` has null values | ||
| pub fn new(filter: &'a BooleanArray, remaining: usize) -> Self { | ||
| assert_eq!(filter.null_count(), 0); | ||
| let iter = filter.values().set_indices(); | ||
| Self { remaining, iter } | ||
|
|
@@ -167,6 +171,21 @@ pub fn filter(values: &dyn Array, predicate: &BooleanArray) -> Result<ArrayRef, | |
| filter_array(values, &predicate) | ||
| } | ||
|
|
||
| /// Determines if calling [FilterBuilder::optimize] is beneficial for the | ||
| /// given [`RecordBatch`]. | ||
| pub fn is_optimize_beneficial_record_batch(record_batch: &RecordBatch) -> bool { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I recommend putting this as a method on |
||
| let num_cols = record_batch.num_columns(); | ||
| if num_cols > 1 { | ||
| return true; | ||
| } | ||
| if num_cols == 1 { | ||
| return FilterBuilder::is_optimize_beneficial( | ||
| record_batch.schema_ref().field(0).data_type(), | ||
| ); | ||
| } | ||
| false | ||
| } | ||
|
|
||
| /// Returns a filtered [RecordBatch] where the corresponding elements of | ||
| /// `predicate` are true. | ||
| /// | ||
|
|
@@ -182,13 +201,7 @@ pub fn filter_record_batch( | |
| predicate: &BooleanArray, | ||
| ) -> Result<RecordBatch, ArrowError> { | ||
| let mut filter_builder = FilterBuilder::new(predicate); | ||
| let num_cols = record_batch.num_columns(); | ||
| if num_cols > 1 | ||
| || (num_cols > 0 | ||
| && FilterBuilder::is_optimize_beneficial( | ||
| record_batch.schema_ref().field(0).data_type(), | ||
| )) | ||
| { | ||
| if is_optimize_beneficial_record_batch(record_batch) { | ||
| // Only optimize if filtering more than one column or if the column contains multiple internal arrays | ||
| // Otherwise, the overhead of optimization can be more than the benefit | ||
| filter_builder = filter_builder.optimize(); | ||
|
|
@@ -276,15 +289,22 @@ impl FilterBuilder { | |
| } | ||
|
|
||
| /// The iteration strategy used to evaluate [`FilterPredicate`] | ||
| #[derive(Debug)] | ||
| enum IterationStrategy { | ||
| /// A lazily evaluated iterator of ranges | ||
| /// | ||
| /// This determines how the filter will iterate over the selected rows. | ||
| /// The strategy is chosen based on the selectivity of the filter. | ||
| #[derive(Debug, Clone)] | ||
| pub enum IterationStrategy { | ||
| /// A lazily evaluated iterator of ranges (slices) | ||
| /// | ||
| /// Best for high selectivity filters (> 80% of rows selected) | ||
| SlicesIterator, | ||
| /// A lazily evaluated iterator of indices | ||
| /// | ||
| /// Best for low selectivity filters (< 80% of rows selected) | ||
| IndexIterator, | ||
| /// A precomputed list of indices | ||
| Indices(Vec<usize>), | ||
| /// A precomputed array of ranges | ||
| /// A precomputed array of ranges (start, end) | ||
| Slices(Vec<(usize, usize)>), | ||
| /// Select all rows | ||
| All, | ||
|
|
@@ -295,7 +315,13 @@ enum IterationStrategy { | |
| impl IterationStrategy { | ||
| /// The default [`IterationStrategy`] for a filter of length `filter_length` | ||
| /// and selecting `filter_count` rows | ||
| fn default_strategy(filter_length: usize, filter_count: usize) -> Self { | ||
| /// | ||
| /// Returns: | ||
| /// - [`IterationStrategy::None`] if `filter_count` is 0 | ||
| /// - [`IterationStrategy::All`] if `filter_count == filter_length` | ||
| /// - [`IterationStrategy::SlicesIterator`] if selectivity > 80% | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think you changed the threshold to 90%
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes |
||
| /// - [`IterationStrategy::IndexIterator`] otherwise | ||
| pub fn default_strategy(filter_length: usize, filter_count: usize) -> Self { | ||
| if filter_length == 0 || filter_count == 0 { | ||
| return IterationStrategy::None; | ||
| } | ||
|
|
@@ -359,6 +385,16 @@ impl FilterPredicate { | |
| pub fn count(&self) -> usize { | ||
| self.count | ||
| } | ||
|
|
||
| /// Returns the iteration strategy used by this [`FilterPredicate`] | ||
| pub fn strategy(&self) -> &IterationStrategy { | ||
| &self.strategy | ||
| } | ||
|
|
||
| /// Returns the underlying filter array | ||
| pub fn filter_array(&self) -> &BooleanArray { | ||
| &self.filter | ||
| } | ||
| } | ||
|
|
||
| fn filter_array(values: &dyn Array, predicate: &FilterPredicate) -> Result<ArrayRef, ArrowError> { | ||
|
|
||

There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎉