Repository navigation
perf: filter only aggregate argument columns - #27119
Conversation
Filter only the input columns read by aggregate arguments and ORDER BY expressions. Share the selected batch across skip-partial arguments while preserving FILTER-before-evaluation semantics.
adriangb
left a comment
There was a problem hiding this comment.
Thanks! Some small nits that probably meed performance confirmation.
| batch: &RecordBatch, | ||
| selection: &BooleanArray, | ||
| ) -> Result<RecordBatch> { | ||
| let schema = batch.schema_ref(); |
There was a problem hiding this comment.
Nit: skip-partial calls select for every batch, also when the filter keeps all rows or no rows. evaluate_compacted_args skips these cases. A null rejects its row, and has_false does not count nulls, so the all-rows check also needs null_count() == 0.
| let schema = batch.schema_ref(); | |
| // A null in `selection` rejects its row, like `false`. | |
| if !selection.has_true() { | |
| return Ok(batch.slice(0, 0)); | |
| } | |
| if selection.null_count() == 0 && !selection.has_false() { | |
| return Ok(batch.clone()); | |
| } | |
| let schema = batch.schema_ref(); |
With an always-true filter on the #27114 data and a high-cardinality GROUP BY, the summed arguments_time is 15 ms on main, 31 ms on this PR, and 19 ms with this change. Wall time does not change.
There was a problem hiding this comment.
Added the all-selected fast path with the null_count() == 0 guard. For empty selections, the skip-partial caller now directly creates typed null arguments and retains the original filter, avoiding selected-batch construction entirely. I avoided batch.slice(0, 0) because it still slices every input column, which was slower for wide batches in local testing. In a local all-selected/high-cardinality run, summed partial arguments_time dropped from about 12.6 ms to 6.4 ms, with wall time essentially unchanged.
| use arrow::array::{ArrayRef, BooleanArray}; | ||
| use arrow::record_batch::RecordBatch; | ||
| use datafusion_common::{Result, ScalarValue}; | ||
| use arrow::array::{ArrayRef, BooleanArray, NullArray, new_null_array}; |
There was a problem hiding this comment.
Needed for null_count.
| use arrow::array::{ArrayRef, BooleanArray, NullArray, new_null_array}; | |
| use arrow::array::{Array, ArrayRef, BooleanArray, NullArray, new_null_array}; |
There was a problem hiding this comment.
Done, added the Array import for null_count().
| let next = filter.select(&batch, &BooleanArray::from(vec![true; 4]))?; | ||
| assert_eq!(next.num_rows(), 4); |
There was a problem hiding this comment.
With the fast path, an all-true selection returns the input schema. A partial selection keeps this check.
| let next = filter.select(&batch, &BooleanArray::from(vec![true; 4]))?; | |
| assert_eq!(next.num_rows(), 4); | |
| let next = | |
| filter.select(&batch, &BooleanArray::from(vec![true, true, false, true]))?; | |
| assert_eq!(next.num_rows(), 3); |
There was a problem hiding this comment.
Updated the cache check to use two partial selections. I also added separate assertions that an all-selected result shares the original schema and each input array.
alamb
left a comment
There was a problem hiding this comment.
Thanks @xudong963 and @adriangb -- this makes sense to me
| if selected.num_rows() == 0 { | ||
| // A fallible expression may fail even for an empty batch, so it | ||
| // is not evaluated. | ||
| let data_type = self.expr.data_type(batch.schema_ref())?; |
There was a problem hiding this comment.
The group by should already know its output -- calling data_type once per batch is non trivial in some cases -- I woudl recommend computing this once and then just copying the type per batch
There was a problem hiding this comment.
Done. AggregateArgument now computes and stores its expression’s type during initialization, using the original input schema.
Empty-selection paths reuse that type, and accumulator reconstruction preserves it. This also handles cases such as AVG, where the argument and aggregate result types differ.
| let selection = self | ||
| .predicate | ||
| .evaluate(batch)? | ||
| .into_array(batch.num_rows())?; |
There was a problem hiding this comment.
why turn it into an array? If it was a ColumnarValue::Scalar we could probably have a special path (can do as a follow on)
There was a problem hiding this comment.
Agreed, a scalar predicate could avoid materializing a BooleanArray. I'll leave this for a follow-up it would need to handle scalar true/false/NULL consistently across the compacted and skip-partial paths.
| let schema = batch.schema_ref(); | ||
| // As in `filter_record_batch`, optimizing the filter pays off only | ||
| // when it is applied to several arrays. | ||
| let optimize = match self.argument_columns.as_slice() { |
There was a problem hiding this comment.
how about just using https://docs.rs/arrow/latest/arrow/array/struct.RecordBatch.html#method.project
and then calling the actual filter kernel?
I think that would be a lot faster and you can take advantage of all the crazy filter ing tricks in arrow
There was a problem hiding this comment.
The current implementation already usesArrow’s FilterBuilderand FilterPredicate::filter. The complication with RecordBatch::project is that it changes column positions, while the argument expressions still reference the original indices.
I tried projecting, filtering, and restoring the original positions locally, but that was slightly slower in the cases tested. I’ve kept the current approach here; remapping the expressions once to use a compact projected batch could be worth exploring separately.
| return Arc::clone(selected); | ||
| } | ||
|
|
||
| let fields = schema |
There was a problem hiding this comment.
This looks like maybe it is a manual reimplementation of https://docs.rs/arrow/latest/arrow/datatypes/struct.Schema.html#method.project ?
There was a problem hiding this comment.
Schema::project removes fields and changes their positions. This helper needs to preserve the original positions and replace unused fields with Null so existing argument expressions remain valid. I added a comment explaining why projection is not a direct replacement here.
| #[test] | ||
| fn literal_selection_matches_evaluate_selection() -> Result<()> { | ||
| fn selection_matches_physical_expr_evaluate_selection() -> Result<()> { | ||
| // a = [0, 1, 2] |
There was a problem hiding this comment.
can we test this with SLT rather than with a new unit test?
There was a problem hiding this comment.
done, nice suggestion!
|
|
||
| /// Aggregate argument that reads `column` and records the data types of | ||
| /// the columns of every batch it is evaluated against. | ||
| #[derive(Debug)] |
There was a problem hiding this comment.
this seems like a lot of test code, but it does test a reasonable property so it is probably ok
There was a problem hiding this comment.
Thanks, I kept this property test. It checks that unused columns are replaced with placeholders during argument evaluation, which SQL result tests cannot observe.
Reuse all-selected batches, bypass empty skip-partial selections, and cache aggregate argument types. Move selection semantics coverage to SQLLogicTest.
Which issue does this PR close?
FILTER (WHERE ...)copy every input column once per aggregate #27114.Rationale for this change
Each aggregate with
FILTERcurrently filters every input column before evaluating its arguments. Queries with several filtered aggregates repeatedly copy columns that each aggregate does not read. This change filters only the columns needed by that aggregate, while preserving evaluation after filtering so rejected rows cannot cause errors such as division by zero.What changes are included in this PR?
ORDER BYexpressions. Unread columns use data-freeNullArrayplaceholders to keep column indices valid.For the issue's 20M-row reproducer with 15 filtered aggregates, local medians of 21 warm runs after three warm-ups were:
70f2ed01b)GROUP BYGROUP BY quantity % 8Both builds used
release-nonlto; all results matched. The correspondingCASE WHENcontrols measured 46/47 ms and 80/86 ms. Other local builds were running concurrently, so these timings include scheduling noise.What is the testing strategy for this PR?
cargo fmt --allandcargo clippy --profile ci --all-targets --all-features -- -D warningspassed.aggregate_filterCriterion benchmark against main, covering column/literal arguments, selection rates, group cardinality, and unfiltered controls. An initial 5.8% slowdown in an unfiltered control did not consistently reproduce in three alternating-order repetitions (fix/main ratios 0.998, 0.943, and 1.019).Are there any user-facing changes?
Lower copying overhead for aggregate
FILTERqueries. SQL behavior and public APIs are unchanged.