Skip to content

perf: let a partial aggregate skip aggregation for AVG - #7543

Open
andygrove wants to merge 2 commits into
apache:mainfrom
andygrove:perf-avg-skip-partial-aggregation
Open

andygrove wants to merge 2 commits into
apache:mainfrom
andygrove:perf-avg-skip-partial-aggregation

Conversation

@andygrove

Copy link
Copy Markdown
Member

Which issue does this PR close?

N/A. This was split out of #7289.

Rationale for this change

spark.comet.exec.aggregate.skipPartial.enabled lets a native partial aggregate stop aggregating once its input looks mostly distinct and send the rest of the task's rows to the shuffle as partial states. It admits only partial aggregates whose functions are all single-argument COUNT, so a high-cardinality GROUP BY that also computes an AVG never benefits. DataFusion builds each row's partial state with GroupsAccumulator::convert_to_state, which Comet's AVG accumulators did not implement.

What changes are included in this PR?

  • AvgGroupsAccumulator (Comet's Float64 AVG, which also serves integral inputs after a cast to double, and small decimals that Spark rewrites to avg(UnscaledValue(x))) now implements convert_to_state. Each row becomes its value and a count of one, or a zero sum and a zero count for a null or filtered-out row, which is what state() emits for such a group.
  • configure_skip_partial_aggregation admits AVG next to COUNT, only when the aggregate's result type is DOUBLE. A decimal result is built as AvgDecimal, whose convert_to_state returns a "not implemented" error, so admitting it would fail a query once skipping started. A decimal AVG keeps the plan from skipping, as before. Implementing the decimal conversion is left out: its partial state carries overflow in the shared null buffer of the sum and count, which a per-row conversion would have to reproduce exactly.
  • The .doc of spark.comet.exec.aggregate.skipPartial.enabled and the Adaptive Partial Aggregation section of the tuning guide describe the admitted aggregates.

This remains opt-in behind the existing experimental spark.comet.exec.aggregate.skipPartial.enabled setting, which defaults to false. Nothing changes for a query that does not enable it.

How are these changes tested?

  • Rust: convert_to_state_matches_one_row_groups checks that converting rows gives the same states as aggregating each row alone into its own group, including a null row and a filtered-out row. skip_partial_eligibility_is_fail_closed covers the gate: AVG with a DOUBLE result is admitted, alone and next to COUNT; AVG without an input, without a result type, with a decimal result (alone and next to COUNT), or with a FLOAT result is not.
  • Scala: skip partial aggregation converts AVG rows to partial states in CometAggregateSuite runs high-cardinality GROUP BY queries over avg of an int, a double, and combinations with count, and over avg of a DECIMAL(38, 10), with nulls in the values and keys that repeat across tasks. It lowers the probe row threshold to 100 so skipping starts, checks each answer against Spark, and checks the skipped_aggregation_rows metric: positive for the int and double cases, zero for the decimal case.

Commands run: cargo test -p datafusion-comet-spark-expr avg, cargo test -p datafusion-comet --lib skip_partial, cargo clippy --all-targets --workspace -- -D warnings, cargo fmt --check, and ./mvnw test -Dtest=none -Dsuites="org.apache.comet.exec.CometAggregateSuite skip partial aggregation" on JDK 17.

A partial aggregate that stops aggregating sends each row on as a partial
state, which DataFusion builds with GroupsAccumulator::convert_to_state.
Comet's Float64 AvgGroupsAccumulator, which runs Spark's average of a decimal
as avg(UnscaledValue(x)), returned NotImplemented. Each row's state is now its
value and a count of one, or a zero sum and count for a null or filtered-out
row, what state() emits for such a group.
configure_skip_partial_aggregation admitted only single-argument COUNT. It now
also admits AVG whose result is DOUBLE, which the planner builds as Comet's
Float64 AVG and which converts input rows to partial states. A decimal result
is built as AvgDecimal, which cannot convert rows to states, so it stays out;
admitting it would fail the query once skipping started.
@github-actions github-actions Bot added enhancement New feature or request performance area:aggregation Hash aggregates, aggregate expressions labels Nov 6, 2026

@comphead comphead left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked convert_to_state against state() for a one-row group and against both merges that can consume it (Comet's merge_batch, which reads .values(), and Spark's Average, since supportsNativePartialToSparkFinal is true for a non-decimal AVG), and the (0.0, 0) rows are right for both. DataFusion 55 removed supports_convert_to_state(), so the allow-list is the only option here, and the DOUBLE result check picks out the planner's Float64 Avg branch. The comments are about the small-decimal case, a batch-level conversion, and FILTER coverage.

"id % 12000 AS k",
"CASE WHEN id % 5 = 0 THEN NULL ELSE CAST(id % 17 AS INT) END AS i",
"CASE WHEN id % 7 = 0 THEN NULL ELSE CAST(id % 11 AS DOUBLE) / 3 END AS d",
"CASE WHEN id % 3 = 0 THEN NULL ELSE CAST(id % 13 AS DECIMAL(10, 2)) / 7 END AS small",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CAST(... AS DECIMAL(10, 2)) / 7 is written as DECIMAL(14, 6) (Divide.resultDecimalType with the literal as DECIMAL(1, 0)). DecimalAggregates only rewrites avg to avg(UnscaledValue(x)) when precision + 4 <= 15, so avg(small) plans as AvgDecimal like avg(big) and never skips. The small-decimal path the description mentions is not exercised. Would dropping the / 7 (keeping DECIMAL(10, 2)) and asserting skipped(avgQuery("avg(small)")) > 0L cover it? I have not run it.

aggregation for high-cardinality grouping when it is not reducing the number of rows enough. This
experimental optimization is disabled by default. It currently applies only to fused native
shuffle-writer plans whose partial aggregates are grouping-only or single-argument `COUNT`.
shuffle-writer plans whose partial aggregates are grouping-only, single-argument `COUNT`, or `AVG` over non-decimal inputs.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The gate checks for a DOUBLE result, not a non-decimal input. Spark rewrites avg over a decimal with precision 11 or lower into avg(UnscaledValue(x)), which has a DOUBLE result and is admitted, so avg over DECIMAL(10, 2) does skip. Could this line, the matching one in the Eligibility paragraph below, and the .doc in CometConf say AVG except over decimals with precision above 11? Both lines here also run past the wrap the rest of the section uses.

Comment on lines +344 to +347
let values = values[0].as_primitive::<T>();
let mut sums = Vec::with_capacity(values.len());
let mut counts = Vec::with_capacity(values.len());
for idx in 0..values.len() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once skipping starts, every remaining row of the task goes through this loop, one is_valid and push per row. With no nulls and no filter, the common case, the input buffer can be reused as the sums with no copy and the counts filled with ones, as DataFusion's AvgGroupsAccumulator::convert_to_state does. Otherwise one NullBuffer::union of the filter (via filter_to_nulls in collect.rs) and the input nulls gives the mask, and the sums and counts come from one zip over it. Since Comet's merge_batch reads .values(), masked slots still need a zero sum, which this keeps. I have not compiled it.

Some(-2.0),
Some(4.0),
]));
let filter = BooleanArray::from(vec![Some(true), Some(true), Some(true), Some(false)]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filter here has no null slot, so the filter.is_valid(idx) branch is not covered, and none of the Scala queries has a FILTER clause, so opt_filter is only reached from this test. Could one entry here be None, and could the Scala test add something like avg(i) FILTER (WHERE d > 1), which yields a null predicate wherever d is null? A sliced input such as values.slice(1, 3) would also cover the offset handling.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:aggregation Hash aggregates, aggregate expressions enhancement New feature or request performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants