[SPARK-58210][SQL] Enable ReplaceHashWithSortAgg and CombineAdjacentAggregation by default#57363
[SPARK-58210][SQL] Enable ReplaceHashWithSortAgg and CombineAdjacentAggregation by default#57363ulysses-you wants to merge 6 commits into
Conversation
…ggregation by default ### What changes were proposed in this pull request? This PR enables two physical aggregate rules by default and decouples their configurations: - `spark.sql.execution.replaceHashWithSortAgg` now defaults to `true`. It replaces a hash-based aggregate with a sort aggregate when the aggregate's child already satisfies the grouping-key sort order. - `spark.sql.execution.combineAdjacentAggregation` now defaults to `true` and is no longer a fallback of `replaceHashWithSortAgg`; it is an independent config. It merges an adjacent partial/final aggregate pair (with no shuffle between them) into a single complete-mode aggregate. Affected tests and golden files are updated accordingly, and a migration-guide entry is added. ### Why are the changes needed? Both rules improve aggregate execution and have been available but off by default. `CombineAdjacentAggregation` was introduced (SPARK-43317) as a `fallbackConf` of `replaceHashWithSortAgg`, so enabling one enabled both. They are logically independent (one reuses existing ordering, the other merges an adjacent pair), so this PR turns each on by default and gives each its own config, letting users enable/disable them separately. ### Does this PR introduce any user-facing change? Yes. On default config, Spark may now plan a sort aggregate instead of a hash aggregate (when the child is already sorted on the grouping keys) and may merge an adjacent partial/final aggregate pair into a single complete-mode aggregate. The physical plan of some queries changes as a result. To restore the previous behavior, set `spark.sql.execution.replaceHashWithSortAgg` and/or `spark.sql.execution.combineAdjacentAggregation` to `false`. ### How was this patch tested? Updated existing tests: - `ReplaceHashWithSortAggSuite` / `CombineAdjacentAggregationSuite`: the `checkAggs` helper now toggles both configs together; the former "falls back to replaceHashWithSortAgg" test is replaced by an independence test. - `SQLMetricsSuite` (SPARK-25497): pins `combineAdjacentAggregation=false` so the single-partition query keeps its partial/final structure for the limit/codegen metric assertions. - `PlannerSuite` (SPARK-40086): accepts a sort aggregate for the single-partition sorted-input queries while still asserting no extra shuffle. - `HiveUDAFSuite` (SPARK-24935): pins `combineAdjacentAggregation=false` because the two-buffer UDAF is designed around the partial/final mode split. Regenerated golden files (via `SPARK_GENERATE_GOLDEN_FILES=1`): - `sql-tests/results/explain-cbo.sql.out` - `tpcds-plan-stability` approved plans for the affected TPC-DS queries. Closes apache#40990 Co-Authored-By: Claude <noreply@anthropic.com>
…uide combineAdjacentAggregation is new in Spark 4.3 (SPARK-43317), so there is no previous default to upgrade from; only replaceHashWithSortAgg (which existed since 3.3.0 with default false) belongs in the upgrade notes. Co-Authored-By: Claude <noreply@anthropic.com>
|
cc @viirya @cloud-fan @sunchao @wangyum thank you |
sunchao
left a comment
There was a problem hiding this comment.
Summary
Prior state and problem
replaceHashWithSortAgg previously defaulted to false, while combineAdjacentAggregation inherited that setting. This PR separates the configurations and enables both physical optimizations by default, making adjacent Partial/Final aggregation eligible for Complete-mode execution without an explicit opt-in.
Design approach
The configuration independence is sensible, and the bottom-up traversal for ReplaceHashWithSortAgg appears consistent with the ordering guarantees exposed by its children. The risky part is enabling CombineAdjacentAggregation for every adjacent pair without first establishing that each aggregate's update/evaluate lifecycle is equivalent to its partial/merge/evaluate lifecycle.
Correctness / compatibility analysis
I found one release-blocking compatibility failure for mode-aware Hive UDAFs and a separate numerical behavior change for built-in statistical aggregates. Both are reachable on single-partition or already-partitioned inputs, under both normal and adaptive preparation paths. I did not find correctness problems in aggregate modes, result attributes, filters, offsets, required distributions, streaming separation, or the four combinations of the configuration flags.
Key design decisions
Keeping the settings independent is the right direction. Default-on combining should fail closed for aggregate implementations that are not known to have stable Complete semantics, or those implementations should be adapted so Complete uses their proper lifecycle.
Implementation sketch
The patch changes both defaults to true, removes the combine setting's fallback, applies adjacent-aggregate combining independently of hash-to-sort replacement, updates affected tests, and regenerates plan goldens and benchmark outputs.
Behavioral changes worth calling out
The change can do more than alter plan shape: it can crash a supported mode-aware Hive UDAF and can change statistical outputs between NaN and 0.0. Because the settings are independent, disabling only hash-to-sort replacement does not restore the previous aggregate staging.
Suggested improvements
Please make Hive UDAFs Complete-safe or exclude them from the rewrite, deliberately fix or guard the statistical merge behavior with AQE/non-AQE parity tests, and document the independent combine rollback setting in the 4.2-to-4.3 migration guide.
| // for merging partial buffers), so it does not support a single `Complete`-mode aggregate. | ||
| // Keep the partial/final pair uncombined so the sort-based fallback path below is exercised | ||
| // in the mode this UDAF is designed for. | ||
| withSQLConf(SQLConf.COMBINE_ADJACENT_AGGREGATION_ENABLED.key -> "false") { |
There was a problem hiding this comment.
[P1] Please do not disable this regression test to accommodate the new default. A Complete object aggregate calls HiveUDAFFunction.update, which creates a PARTIAL1-mode buffer, and then eval, which passes that buffer to the separately initialized FINAL evaluator. Mode-aware Hive UDAFs may legally use different buffer classes in those modes, so the default rewrite can turn a previously supported query into a ClassCastException. I reproduced this and also verified that a variant which correctly handles Hive's native COMPLETE lifecycle still fails through Spark's mixed-evaluator path. Please make the Hive wrapper Complete-safe or exclude these UDAFs before enabling the rule by default, and keep this test exercising the default path.
There was a problem hiding this comment.
Thanks for the detailed repro. Fixed in the latest push by making the Hive wrapper Complete-safe rather than excluding UDAFs or pinning the test.
HiveUDAFFunction keeps two evaluators: partial1HiveEvaluator (PARTIAL1, buffer created in update) and finalHiveEvaluator (FINAL, buffer created in merge). eval always terminated via the FINAL evaluator, so a buffer that only went through update (the Complete-mode path) was a PARTIAL1-mode buffer handed to terminate, which mode-aware UDAFs reject.
merge already converted a PARTIAL1 buffer to a FINAL buffer on demand. I extracted that into toFinalBuffer and call it from eval as well, so Complete-mode (update without merge) terminates against a FINAL buffer. SPARK-24935 now runs on the default (combined) path and asserts the single Complete-mode ObjectHashAggregateExec, with both sort-fallback thresholds still checked. I verified a mode-aware UDAF variant no longer throws ClassCastException.
The change is in sql/hive/.../hiveUDFs.scala; HiveUDAFSuite, ObjectHashAggregateSuite, and UDAQuerySuite pass.
| .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE) | ||
| .fallbackConf(REPLACE_HASH_WITH_SORT_AGG_ENABLED) | ||
| .booleanConf | ||
| .createWithDefault(true) |
There was a problem hiding this comment.
[P2] Enabling this rewrite changes built-in aggregate results, not only the physical plan. With AQE off and a single-partition group containing two finite 1e155 values, disabling combining returns NaN for var_pop, covar_pop, and regr_sxy, while Complete returns 0.0. The old NaN comes from overflowing the first merge into the empty Final buffer, so 0.0 is mathematically preferable, but a default physical optimization should not silently introduce config-dependent results. Please fix and test the empty-buffer merge as a deliberate correctness change, or keep these aggregates out of combining until the modes are equivalent.
There was a problem hiding this comment.
Great catch, and thanks for pinpointing the empty-buffer merge. Fixed as a deliberate correctness change in the merge expressions rather than excluding the aggregates.
Root cause: CentralMomentAgg.merge computes delta * deltaN * n1 * n2 and Covariance.merge computes dx * dyN * n1 * n2. When one side is an empty buffer (n1 == 0 or n2 == 0) and the other side has a large average, delta * deltaN (or dx * dyN) overflows to Infinity before being multiplied by the zero count, and Infinity * 0 = NaN corrupts the merged moments. The existing newN === 0 guard only covers both-empty. The old default (no combining) hit this on a single-partition group of two large equal values, returning NaN; the Complete-mode default sidestepped the merge and returned the correct 0.0, so the two configs disagreed.
Fix: force delta/dx/dy to 0.0 when either side is empty, and set newAvg/newXAvg/newYAvg directly to the non-empty side's average. The delta terms then vanish cleanly and the merged buffer equals the non-empty side. Both the combined and the separate paths now return 0.0 for var_pop/covar_pop/regr_sxy.
Added SPARK-58210: empty-buffer merge must not overflow to NaN for statistical aggregates in DataFrameAggregateSuite, covering var_pop, covar_pop, regr_sxy across AQE on/off x combine on/off. The group-by, linear-regression, and aggregates_part1 SQL golden files are unchanged (regenerated to confirm).
| - Since Spark 4.3, the configuration key `spark.sql.sources.v2.bucketing.allowJoinKeysSubsetOfPartitionKeys.enabled` has been renamed to `spark.sql.sources.v2.bucketing.allowKeysSubsetOfPartitionKeys.enabled` to reflect that it now applies to storage-partitioned joins, aggregates, and windows. The old key continues to work as an alias. | ||
| - Since Spark 4.3, the Spark Thrift Server rejects setting JVM system properties through the `set:system:` session configuration overlay (for example, in a JDBC connection string). To restore the previous behavior, set `spark.sql.legacy.hive.thriftServer.allowSettingSystemProperties` to `true`. | ||
| - Since Spark 4.3, the adaptive execution rule `org.apache.spark.sql.execution.adaptive.DynamicJoinSelection` has been renamed to `DemoteBroadcastHashJoin`, which now only demotes broadcast hash joins (emitting `NO_BROADCAST_HASH`). Its selection of shuffled hash join over sort merge join has moved to a new physical rule gated by `spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.enabled` (default `true`). If you previously disabled the shuffled-hash-join preference by listing `org.apache.spark.sql.execution.adaptive.DynamicJoinSelection` in `spark.sql.adaptive.optimizer.excludedRules`, that name no longer matches any rule (unknown names are silently ignored); set `spark.sql.adaptive.convertSortMergeJoinToShuffledHashJoin.enabled` to `false` instead. | ||
| - Since Spark 4.3, `spark.sql.execution.replaceHashWithSortAgg` defaults to `true`. Spark now replaces a hash-based aggregate with a sort aggregate when the aggregate's child is already sorted on the grouping keys. To restore the previous behavior, set `spark.sql.execution.replaceHashWithSortAgg` to `false`. |
There was a problem hiding this comment.
[P2] Please document spark.sql.execution.combineAdjacentAggregation here as well. Although the config itself is introduced during 4.3 development, its default-on execution behavior is new for users upgrading from 4.2. The two settings are now independent, so following this rollback instruction and disabling only replaceHashWithSortAgg still leaves adjacent Partial/Final aggregation combined. Please restore the removed entry and state that both settings may need to be false to recover the previous staging.
There was a problem hiding this comment.
Good point — restored. The migration guide now has a separate entry for spark.sql.execution.combineAdjacentAggregation under "Upgrading from Spark SQL 4.2 to 4.3", and it states explicitly that the setting is independent of replaceHashWithSortAgg, so disabling only replaceHashWithSortAgg still leaves adjacent aggregation combined; both must be set to false to fully restore the previous partial/final staging.
…-buffer merge fix Addresses review feedback on apache#57363. ### P1: mode-aware Hive UDAFs under Complete-mode aggregation CombineAdjacentAggregation merges an adjacent Partial/Final pair into a single Complete-mode aggregate. For ObjectHashAggregateExec backed by a Hive UDAF, the Complete path calls HiveUDAFFunction.update (which creates a PARTIAL1-mode buffer) and then eval, which passed that buffer to the FINAL evaluator's terminate. Mode-aware Hive UDAFs may legally use different buffer classes per mode, so this turned a previously supported query into a ClassCastException. HiveUDAFFunction already converts a PARTIAL1 buffer to a FINAL buffer on demand inside merge. Extract that conversion into toFinalBuffer and also apply it in eval, so the Complete-mode path (update without merge) terminates against a FINAL buffer. HiveUDAFSuite's SPARK-24935 test now runs on the default (combined) path again instead of pinning combineAdjacentAggregation=false. ### P2: empty-buffer merge overflow for statistical aggregates CentralMomentAgg.merge and Covariance.merge compute delta * deltaN * n1 * n2 (or dx * dyN * n1 * n2). When one side is an empty buffer (n1 == 0 or n2 == 0) and the other side has a large average, delta * deltaN overflows to Infinity before being multiplied by the zero count, and Infinity * 0 = NaN corrupts the merged moments. The old default (no combining) hit this on single-partition groups of large equal values, returning NaN for var_pop/covar_pop/regr_sxy, while the new Complete-mode default returned the mathematically correct 0.0. Force delta/dx/dy to 0.0 when either side is empty, and set newAvg/newXAvg/ newYAvg directly to the non-empty side's average. Both the combined and the separate paths now return 0.0. Added an AQE on/off x combine on/off parity test in DataFrameAggregateSuite covering var_pop, covar_pop, and regr_sxy. ### P2: migration guide Restore the spark.sql.execution.combineAdjacentAggregation entry under "Upgrading from Spark SQL 4.2 to 4.3" and note that, since the two settings are now independent, disabling only replaceHashWithSortAgg still leaves adjacent aggregation combined; both must be false to restore the previous staging. Co-Authored-By: Claude <noreply@anthropic.com>
viirya
left a comment
There was a problem hiding this comment.
I traced the two fixes from the last round (Hive UDAF Complete-safety and the empty-buffer overflow) against the PR-head source, and both look correct and complete.
The toFinalBuffer extraction is a clean refactor rather than new logic — it reuses the exact PARTIAL1->FINAL conversion merge already did. For the previously-reachable modes (Final/PartialMerge), the buffer is already canDoMerge=true, so toFinalBuffer returns it untouched and behavior is unchanged; only the newly-reachable Complete path (update-without-merge) gets converted, which is exactly the fix. Non-mode-aware UDAFs stay correct too, and there's no extra work on the Final path.
The statistical-merge fix is also complete. Forcing delta/dx/dy to 0.0 when one side is empty makes every higher-order term (m2/m3/m4, ck) collapse cleanly to the non-empty side, and setting newAvg/newXAvg/newYAvg directly finishes it — the merged buffer equals the non-empty side exactly. The both-sides-non-empty path is byte-identical, so existing results don't regress. The AQE x combine parity test reproduces the reported case directly. The cross-reference comment in Covariance pointing back to CentralMomentAgg is a nice touch for maintainability.
One thing worth documenting: ReplaceHashWithSortAgg switches transformDown to transformUp, but this isn't mentioned in the description, the commit messages, or anywhere in the discussion. It looks intentional and correct to me — SortAggregateExec is order-preserving while HashAggregateExec is not, so replacing a child hash agg with a sort agg changes that child's outputOrdering, and bottom-up traversal lets a parent then see the satisfied ordering and be replaced in turn (each replacement is still gated by the ordering check, so it can only enable more valid replacements, never wrong ones). That's a genuine behavior change that likely accounts for some of the 34 golden-plan diffs — could you add a sentence to the PR description (or an inline comment) explaining why bottom-up is needed here? It'll save the next reader from having to reconstruct the reasoning.
Otherwise LGTM — the config decoupling, the test updates that toggle both settings, and the migration-guide entries all read cleanly.
|
Thanks @viirya for the thorough trace — agreed on both fixes. Re: |
sunchao
left a comment
There was a problem hiding this comment.
Follow-up review of the current head: one remaining statistical-aggregate correctness issue and two independently verified regression-test coverage gaps.
| Seq(true, false).foreach { combine => | ||
| withSQLConf(SQLConf.COMBINE_ADJACENT_AGGREGATION_ENABLED.key -> combine.toString) { | ||
| checkAnswer( | ||
| df.selectExpr("var_pop(a)", "covar_pop(a, a)", "regr_sxy(a, a)"), |
There was a problem hiding this comment.
[P2] Please extend this empty-buffer fix and AQE-by-combining regression to corr and regr_r2 as well. PearsonCorrelation.merge still evaluates dx * dxN * n1 * n2 and dy * dyN * n1 * n2 before the zero-count multiplication, so the same Infinity * 0 corruption survives this change. I reproduced this at this exact head with AQE disabled and Seq((1.0e155, 1.0e-150), (1.000000000000001e155, 2.0e-150)).toDF("x", "y").repartition(1).selectExpr("corr(x, y)", "regr_r2(y, x)"): with combineAdjacentAggregation=true, the result is (1.0, 1.0000000000000004); with it disabled, both results are NaN. Since this PR enables combining by default, a physical-optimization toggle still changes the observable answers. Please guard the Pearson empty-buffer merge as well and cover both expressions across AQE on/off and combining on/off.
There was a problem hiding this comment.
Good catch -- fixed by guarding the Pearson merge the same way Covariance/CentralMomentAgg already are, rather than excluding the expressions.
PearsonCorrelation.merge computed dx * dxN * n1 * n2 (and the dy variant) for xMk/yMk before the zero-count multiply, so when one side is an empty buffer and the other has a large average, dx * dxN overflowed to Infinity and Infinity * 0 = NaN corrupted the moments. I now force dx/dy to 0.0 when either side is empty and set newXAvg/newYAvg directly to the non-empty side's average, mirroring the existing fix (with a back-reference comment to CentralMomentAgg).
Added SPARK-58210: empty-buffer merge must not overflow to NaN for Pearson correlation in DataFrameAggregateSuite, using your exact repro ((1.0e155, 1.0e-150), (1.000000000000001e155, 2.0e-150), repartitioned to 1) and asserting corr(x, y) and regr_r2(y, x) are finite and config-independent across AQE on/off x combine on/off.
| // `MockUDAF2` deliberately uses distinct aggregation-buffer classes per mode (one for | ||
| // consuming original input via PARTIAL1, another for merging partial buffers via FINAL). | ||
| // The Hive UDAF wrapper must convert the PARTIAL1 buffer to a FINAL buffer on `eval` so the | ||
| // Complete-mode path (which calls `update` but not `merge`) terminates correctly. | ||
| val df = sql("SELECT id % 2, mock2(id) FROM v GROUP BY id % 2") |
There was a problem hiding this comment.
[P3] Please construct a fresh query inside each fallback-threshold withSQLConf block. This one DataFrame is executed first with threshold 1, and SparkPlan.executeRDD memoizes the resulting RDD; the subsequent checkAnswer(df, ...) under threshold 100 reuses that already-created execution instead of constructing an ObjectHashAggregateExec execution under the new threshold. I verified that both checks use the same execution-RDD ID, so this does not exercise both fallback and non-fallback despite the test's claim. ObjectHashAggregateSuite also explicitly cautions against reusing a DataFrame while changing this threshold. Rebuilding sql(...) inside each configuration, and ideally asserting the fallback metric, would make the lifecycle fix actually cover both paths.
There was a problem hiding this comment.
You're right -- the single reused DataFrame meant executeRDD replayed the threshold-1 execution under threshold 100, so only one path ran. Fixed: I rebuild sql(query) inside each withSQLConf block, and now assert the numTasksFallBacked metric -- > 0 at threshold 1 (fallback) and == 0 at threshold 100 (non-fallback) -- so both paths are actually exercised. Added a numFallbackTasks helper summing the metric across the ObjectHashAggregateExec nodes.
| // `CombineAdjacentAggregation` would merge adjacent partial/final pairs and change the counts). | ||
| withSQLConf( | ||
| SQLConf.REPLACE_HASH_WITH_SORT_AGG_ENABLED.key -> "true", | ||
| SQLConf.COMBINE_ADJACENT_AGGREGATION_ENABLED.key -> "true") { |
There was a problem hiding this comment.
[P3] Please add a traversal regression with replaceHashWithSortAgg=true and combineAdjacentAggregation=false, and assert that both the partial and final nodes become SortAggregateExec. This helper currently enables combining together with replacement, so the partial/final pair is collapsed before the test can establish that bottom-up traversal replaces both nodes; the together case therefore observes only one sort aggregate. I mutation-tested the exact head by reverting transformUp to transformDown: both the normal and AQE together tests still passed. Consequently the test cited as coverage for the traversal change cannot detect reverting that change. A sorted-input two-node case with combining disabled, run with AQE both on and off, would fail under transformDown and cover the behavior.
There was a problem hiding this comment.
Agreed, the together case enabled combining and collapsed the pair before traversal, so it couldn't detect reverting transformUp. Added SPARK-58210: bottom-up traversal replaces both partial and final hash aggregate with replaceHashWithSortAgg=true and combineAdjacentAggregation=false, over the SMJ -> partial -> final shape, asserting 0 hash / 2 sort.
I ran the same mutation you did: reverting transformUp -> transformDown now fails this test in both AQE modes (1 hash agg remains instead of 0), and it passes once reverted back. So the traversal change is now genuinely covered.
… tests - Guard PearsonCorrelation.merge against empty-buffer Infinity*0=NaN, matching the Covariance/CentralMomentAgg fix, so corr/regr_r2 are config-independent. - Add corr/regr_r2 empty-buffer regression across AQE on/off x combine on/off. - Rebuild the query inside each fallback-threshold block in HiveUDAFSuite (executeRDD memoizes the RDD) and assert numTasksFallBacked so both the fallback and non-fallback paths are exercised. - Add a bottom-up traversal regression in ReplaceHashWithSortAggSuite with combining disabled, asserting both partial and final become SortAggregateExec; fails under transformDown, unlike the combining-enabled together case. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
sunchao
left a comment
There was a problem hiding this comment.
Thanks for addressing the review comments. The Pearson merge fix, independently exercised Hive fallback paths, and bottom-up aggregation regression all look good. LGTM.
|
Is it possible splitting this PR into two before merging, because it currently bundles two logically independent changes:
Bundling them makes the merge fix impossible to backport to 4.2.x / 3.5.x. If we later find a regression from the default flip and want to revert just that, the correctness fix would go with it. |
|
Good call @wangyum, agreed. I've split out two standalone correctness fixes, each independent of the default flip and backportable on its own: #57460 (SPARK-58291) -- statistical-aggregate empty-buffer merge overflow:
#57464 (SPARK-58294) -- Hive UDAF
On the Once both are reviewed, I'll rebase this PR to drop those files, leaving only the default flip + config decoupling (plus the golden-file and benchmark updates) here. Ordering note: #57460 and #57464 should merge first, then this PR rebases on top. If this PR drops the fixes before they land, its own |
…ffer overflow The Pearson correlation, covariance, and central-moment merge expressions overflow to Infinity and then NaN via `Infinity * 0` when one side is an empty buffer with a large average: `delta * deltaN` (and the `dx`/`dy` variants) overflow before being multiplied by the zero count. Force the delta terms to 0.0 and set the merged average directly to the non-empty side when either buffer is empty, mirroring the guard each other aggregate already applies. Extracted from apache#57363 as a standalone correctness fix, independent of the replaceHashWithSortAgg default flip, so it can be backported on its own. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
What changes were proposed in this pull request?
This PR enables two physical aggregate rules by default, decouples their configurations, and makes them safe to enable together:
spark.sql.execution.replaceHashWithSortAggnow defaults totrue. It replaces a hash-based aggregate with a sort aggregate when the aggregate's child already satisfies the grouping-key sort order.spark.sql.execution.combineAdjacentAggregationnow defaults totrueand is no longer afallbackConfofreplaceHashWithSortAgg; it is an independent config. It merges an adjacent partial/final aggregate pair (with no shuffle between them) into a single complete-mode aggregate.ReplaceHashWithSortAggswitches its plan traversal fromtransformDowntotransformUp.HashAggregateExecdoes not expose a grouping-key output ordering (outputOrdering = Nil), whileSortAggregateExecdoes (it is order-preserving on the grouping keys). WithtransformDown, an outer (final) aggregate inspected before its inner (partial) aggregate sees the partial'sNilordering and is not replaced; withtransformUp, the partial is replaced first, so its sort-aggregate output ordering satisfies the final aggregate, which is then also replaced. This lets nested partial/final pairs both be converted when the leaf child is already sorted.HiveUDAFFunctionis made Complete-safe (see below) so a Complete-modeObjectHashAggregateExec(produced by combining) does not crash mode-aware Hive UDAFs.CentralMomentAgg.merge/Covariance.mergeare fixed so merging a non-empty buffer into an empty one no longer overflows toNaN(see below).Affected tests and golden files are updated, and migration-guide entries are added under "Upgrading from Spark SQL 4.2 to 4.3".
Why are the changes needed?
Both rules improve aggregate execution and have been available but off by default.
CombineAdjacentAggregationwas introduced (SPARK-43317) as afallbackConfofreplaceHashWithSortAgg, so enabling one enabled both. They are logically independent (one reuses existing ordering, the other merges an adjacent pair), so this PR turns each on by default and gives each its own config, letting users enable/disable them separately.Enabling combining by default surfaced two issues that the old default hid, both fixed here:
ObjectHashAggregateExeccallsHiveUDAFFunction.update(PARTIAL1 buffer) and theneval(FINAL evaluator). UDAFs that use different buffer classes per mode threwClassCastException.HiveUDAFFunctionalready converted a PARTIAL1 buffer to a FINAL buffer on demand inmerge; that conversion is extracted intotoFinalBufferand also applied ineval, so the Complete path terminates against a FINAL buffer.CentralMomentAgg.merge/Covariance.mergecomputedelta * deltaN * n1 * n2(ordx * dyN * n1 * n2). When one side is an empty buffer (n1 == 0orn2 == 0) and the other has a large average,delta * deltaNoverflows toInfinitybefore multiplying by the zero count, andInfinity * 0 = NaNcorrupts the moments. The old default (no combining) hit this on single-partition groups of large equal values, returningNaNforvar_pop/covar_pop/regr_sxy; the Complete-mode default returned the correct0.0. The fix forcesdelta/dx/dyto0.0when either side is empty and sets the merged average to the non-empty side, so both paths now return0.0. This is a standalone correctness fix that also helps the separate partial/final path on multi-partition inputs.Does this PR introduce any user-facing change?
Yes. On default config, Spark may now plan a sort aggregate instead of a hash aggregate (when the child is already sorted on the grouping keys) and may merge an adjacent partial/final aggregate pair into a single complete-mode aggregate. The physical plan of some queries changes as a result.
var_pop/covar_pop/regr_sxyon single-partition groups of large equal values now return0.0instead ofNaN. To restore the previous behavior, setspark.sql.execution.replaceHashWithSortAggand/orspark.sql.execution.combineAdjacentAggregationtofalse(both must befalseto fully restore the previous partial/final staging, since the settings are now independent).How was this patch tested?
Updated existing tests:
ReplaceHashWithSortAggSuite/CombineAdjacentAggregationSuite: thecheckAggshelper now toggles both configs together; the former "falls back to replaceHashWithSortAgg" test is replaced by an independence test. ThetransformUptraversal is exercised by the "replace partial and final hash aggregate together with sort aggregate" case (SMJ -> partial -> final), which expects both aggregates replaced.SQLMetricsSuite(SPARK-25497): pinscombineAdjacentAggregation=falseso the single-partition query keeps its partial/final structure for the limit/codegen metric assertions.PlannerSuite(SPARK-40086): accepts a sort aggregate for the single-partition sorted-input queries while still asserting no extra shuffle.HiveUDAFSuite(SPARK-24935): runs on the default (combined) path now that the Hive wrapper is Complete-safe; asserts the single Complete-modeObjectHashAggregateExecand checks both sort-fallback thresholds.DataFrameAggregateSuite: addedSPARK-58210: empty-buffer merge must not overflow to NaN for statistical aggregates, coveringvar_pop,covar_pop,regr_sxyacross AQE on/off x combine on/off.Regenerated golden files via
SPARK_GENERATE_GOLDEN_FILES=1:sql-tests/results/explain-cbo.sql.outtpcds-plan-stabilityapproved plans for the affected TPC-DS queries.A total of 34 golden plans changed, all caused by the two rules enabled in this PR, with no regressions.
Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code