Skip to content

[SPARK-58511][SQL] Bypass ineffective pre-shuffle partial aggregation at runtime - #57742

Open
ulysses-you wants to merge 2 commits into
apache:masterfrom
ulysses-you:worktree-adaptive-partial-agg
Open

[SPARK-58511][SQL] Bypass ineffective pre-shuffle partial aggregation at runtime#57742
ulysses-you wants to merge 2 commits into
apache:masterfrom
ulysses-you:worktree-adaptive-partial-agg

Conversation

@ulysses-you

@ulysses-you ulysses-you commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

This PR makes hash aggregation detect at runtime that a pre-shuffle partial aggregation is not reducing rows, and bypass it: the remaining input rows are passed through as single-row partial buffers that the downstream Final aggregation merges, so the output contract is unchanged.

Two decision tiers, both evaluated only while the regular (second-level) map is still fully in memory (sorter == null):

  • no-spill tier: from sampleRows regular-map rows on, bypass if distinctKeys / regularRows >= noSpillReductionRatioThreshold. The sampling window doubles after each sub-threshold check, so a low-cardinality input is re-evaluated only rarely while a late high-cardinality tail can still be caught.
  • on-spill tier: when the map cannot allocate for a new key (it would otherwise spill), bypass instead if the ratio is at least spillReductionRatioThreshold. This threshold is more aggressive because a spilling partial aggregation starts paying disk I/O.

Only a pre-shuffle Partial hash aggregation with grouping keys is eligible (requiredChildDistributionExpressions.isEmpty identifies that phase and, importantly, keeps a group-by-only aggregate's Final phase out - its aggregateExpressions is empty, so a mode-only check would admit it vacuously). Both the codegen path (HashAggregateExec) and the interpreted path (TungstenAggregationIterator) are covered.

Only the regular (second-level) map is governed, and the reduction ratio uses the rows that entered it as the denominator. The append-only fast hash map never spills, so a fast-map hit counts in neither the numerator nor the denominator; using total rows would keep the ratio below the threshold even for fully distinct input, because the fast map absorbs the first 2^16 keys.

Once pass-through is active the maps are frozen, so they are output - which also frees their memory - before the remaining input is streamed, rather than being held until the end.

DISTINCT aggregates are eligible: in the multi-phase distinct plan the intermediate PartialMerge phase is not Partial mode (and requires a distribution), so it always aggregates and de-duplicates, and the rows reaching the distinct Partial phase therefore carry exactly one distinct value each.

New configs, all under spark.sql.execution.aggregate.adaptivePartialAggregation.*:

config default meaning
enabled true the feature switch
sampleRows 100000 rows before the first no-spill evaluation
noSpillReductionRatioThreshold 0.9 no-spill tier threshold
spillReductionRatioThreshold fallback noSpillReductionRatioThreshold on-spill tier threshold

A numBypassingRows SQL metric reports how many rows bypassed.

Why are the changes needed?

For high-cardinality grouping keys the pre-shuffle partial aggregation reduces little or nothing, but still pays for maintaining - and often spilling - an aggregation map as large as the input. Bypassing it at runtime removes that cost while keeping the two-phase plan intact, so the decision needs no planner-side statistics and adapts per task.

Benchmark (AdaptivePartialAggregationBenchmark, run in GitHub Actions, JDK 25):

scenario codegen adaptive = F adaptive = T relative
high card, no spill on 4087 ms 2443 ms 1.7X
high card, no spill off 4924 ms 3220 ms 1.5X
low card, no spill on 267 ms 292 ms 0.9X
low card, no spill off 1290 ms 1301 ms 1.0X
high card, spill on 7986 ms 4354 ms 1.8X
high card, spill off 9386 ms 5298 ms 1.8X
low card, spill on 774 ms 794 ms 1.0X
low card, spill off 1362 ms 1368 ms 1.0X

The bypassing cases win in both tiers and on both execution paths; the low-cardinality cases, where the tiers correctly decline to bypass, show no regression beyond the small per-row sampling overhead.

This is related to, but independent of, the static spark.sql.execution.bypassPartialAggregation (SPARK-57688), which drops the partial aggregation at planning time. The runtime version keeps the partial aggregation when it does reduce rows and only bypasses when the observed ratio says it does not; the two can be used together.

Does this PR introduce any user-facing change?

No, query results are unchanged. The feature is enabled by default and only changes how the pre-shuffle partial aggregation is executed, plus the new numBypassingRows metric in the SQL UI.

How was this patch tested?

New AdaptivePartialAggregationSuite (34 tests), in two halves:

  • Correctness: output identical to the feature-off reference across the full matrix of codegen on/off, two-level map on/off, and spill/no-spill, over a range of aggregate shapes (multi-slot buffers, imperative buffers, FILTER (WHERE ...)), key types (string, decimal, date, nullable), group-by-only aggregates with duplicate keys, empty input, and Expand-bearing plans (ROLLUP / CUBE / GROUPING SETS / multi-distinct).
  • Triggering: the numBypassingRows metric proves the bypass fires when (and only when) it should - high cardinality bypasses, low cardinality does not, the feature switch and eligibility rules are honored, and both tiers work. These tests also compare against the feature-off reference so a bypassing run can never pass on metrics alone. For count(DISTINCT v) GROUP BY k the bypasses of the two Partial phases are told apart by grouping-key count and asserted separately.

New AdaptivePartialAggregationBenchmark covering the {high, low}-cardinality x {no-spill, on-spill} grid, each across codegen on/off and the adaptive switch; results are included.

Generated code for simple query select c2, count(*) from t3 group by c2:

/* 001 */ public Object generate(Object[] references) {
/* 002 */   return new GeneratedIteratorForCodegenStage1(references);
/* 003 */ }
/* 004 */
/* 005 */ // codegenStageId=1
/* 006 */ final class GeneratedIteratorForCodegenStage1 extends org.apache.spark.sql.execution.BufferedRowIterator {
/* 007 */   private Object[] references;
/* 008 */   private scala.collection.Iterator[] inputs;
/* 009 */   private boolean hashAgg_initAgg_0;
/* 010 */   private boolean hashAgg_adaptivePassThrough_0;
/* 011 */   private long hashAgg_regularMapRowCount_0;
/* 012 */   private long hashAgg_adaptiveNextSampleRow_0;
/* 013 */   private boolean hashAgg_adaptiveChildrenConsumed_0;
/* 014 */   private boolean hashAgg_adaptiveMapOutputDone_0;
/* 015 */   private boolean hashAgg_adaptiveMapSetupDone_0;
/* 016 */   private org.apache.spark.unsafe.KVIterator hashAgg_mapIter_0;
/* 017 */   private org.apache.spark.sql.execution.UnsafeFixedWidthAggregationMap hashAgg_hashMap_0;
/* 018 */   private org.apache.spark.sql.execution.UnsafeKVExternalSorter hashAgg_sorter_0;
/* 019 */   private int columnartorow_batchIdx_0;
/* 020 */   private org.apache.spark.sql.vectorized.ColumnarBatch[] columnartorow_mutableStateArray_1 = new org.apache.spark.sql.vectorized.ColumnarBatch[1];
/* 021 */   private org.apache.spark.sql.execution.vectorized.OnHeapColumnVector[] columnartorow_mutableStateArray_2 = new org.apache.spark.sql.execution.vectorized.OnHeapColumnVector[1];
/* 022 */   private scala.collection.Iterator[] columnartorow_mutableStateArray_0 = new scala.collection.Iterator[1];
/* 023 */   private org.apache.spark.sql.catalyst.expressions.codegen.UnsafeRowWriter[] hashAgg_mutableStateArray_0 = new org.apache.spark.sql.catalyst.expressions.codegen.UnsafeRowWriter[5];
/* 024 */
/* 025 */   public GeneratedIteratorForCodegenStage1(Object[] references) {
/* 026 */     this.references = references;
/* 027 */   }
/* 028 */
/* 029 */   public void init(int index, scala.collection.Iterator[] inputs) {
/* 030 */     partitionIndex = index;
/* 031 */     this.inputs = inputs;
/* 032 */
/* 033 */     hashAgg_adaptiveNextSampleRow_0 = 1L;
/* 034 */     hashAgg_mutableStateArray_0[0] = new org.apache.spark.sql.catalyst.expressions.codegen.UnsafeRowWriter(2, 0);
/* 035 */     columnartorow_mutableStateArray_0[0] = inputs[0];
/* 036 */     hashAgg_mutableStateArray_0[1] = new org.apache.spark.sql.catalyst.expressions.codegen.UnsafeRowWriter(1, 0);
/* 037 */     hashAgg_mutableStateArray_0[2] = new org.apache.spark.sql.catalyst.expressions.codegen.UnsafeRowWriter(1, 0);
/* 038 */     hashAgg_mutableStateArray_0[3] = new org.apache.spark.sql.catalyst.expressions.codegen.UnsafeRowWriter(1, 0);
/* 039 */     hashAgg_mutableStateArray_0[4] = new org.apache.spark.sql.catalyst.expressions.codegen.UnsafeRowWriter(1, 0);
/* 040 */
/* 041 */   }
/* 042 */
/* 043 */   private void hashAgg_doAggregateWithKeysOutput_0(UnsafeRow hashAgg_keyTerm_0, UnsafeRow hashAgg_bufferTerm_0)
/* 044 */   throws java.io.IOException {
/* 045 */     ((org.apache.spark.sql.execution.metric.SQLMetric) references[5] /* numOutputRows */).add(1);
/* 046 */
/* 047 */     boolean hashAgg_isNull_0 = hashAgg_keyTerm_0.isNullAt(0);
/* 048 */     long hashAgg_value_0 = hashAgg_isNull_0 ?
/* 049 */     -1L : (hashAgg_keyTerm_0.getLong(0));
/* 050 */     long hashAgg_value_1 = hashAgg_bufferTerm_0.getLong(0);
/* 051 */
/* 052 */     hashAgg_mutableStateArray_0[0].reset();
/* 053 */
/* 054 */     hashAgg_mutableStateArray_0[0].zeroOutNullBytes();
/* 055 */
/* 056 */     hashAgg_mutableStateArray_0[0].writeNullable(0, hashAgg_value_0, hashAgg_isNull_0);
/* 057 */
/* 058 */     hashAgg_mutableStateArray_0[0].write(1, hashAgg_value_1);
/* 059 */     append((hashAgg_mutableStateArray_0[0].getRow()).copy());
/* 060 */
/* 061 */   }
/* 062 */
/* 063 */   private void hashAgg_doAggregateWithKeys_0(int partitionIndex) throws java.io.IOException {
/* 064 */     if (columnartorow_mutableStateArray_1[0] == null) {
/* 065 */       columnartorow_nextBatch_0();
/* 066 */     }
/* 067 */     while ( columnartorow_mutableStateArray_1[0] != null) {
/* 068 */       int columnartorow_numRows_0 = columnartorow_mutableStateArray_1[0].numRows();
/* 069 */       int columnartorow_localEnd_0 = columnartorow_numRows_0 - columnartorow_batchIdx_0;
/* 070 */       for (int columnartorow_localIdx_0 = 0; columnartorow_localIdx_0 < columnartorow_localEnd_0; columnartorow_localIdx_0++) {
/* 071 */         int columnartorow_rowIdx_0 = columnartorow_batchIdx_0 + columnartorow_localIdx_0;
/* 072 */         boolean columnartorow_isNull_0 = columnartorow_mutableStateArray_2[0].isNullAt(columnartorow_rowIdx_0);
/* 073 */         long columnartorow_value_0 = columnartorow_isNull_0 ? -1L : (columnartorow_mutableStateArray_2[0].getLong(columnartorow_rowIdx_0));
/* 074 */
/* 075 */         hashAgg_doConsume_0(columnartorow_value_0, columnartorow_isNull_0);
/* 076 */         if (shouldStop()) { columnartorow_batchIdx_0 = columnartorow_rowIdx_0 + 1; return; }
/* 077 */       }
/* 078 */       columnartorow_batchIdx_0 = columnartorow_numRows_0;
/* 079 */       columnartorow_nextBatch_0();
/* 080 */     }
/* 081 */     // clean up resources
/* 082 */     if (columnartorow_mutableStateArray_1[0] != null) {
/* 083 */       columnartorow_mutableStateArray_1[0].close();
/* 084 */     }
/* 085 */
/* 086 */     hashAgg_adaptiveChildrenConsumed_0 = true;
/* 087 */   }
/* 088 */
/* 089 */   private void hashAgg_doConsume_0(long hashAgg_expr_0_0, boolean hashAgg_exprIsNull_0_0) throws java.io.IOException {
/* 090 */     UnsafeRow hashAgg_unsafeRowAggBuffer_0 = null;
/* 091 */     boolean hashAgg_adaptiveRowBypassed_0 = false;
/* 092 */
/* 093 */     // generate grouping key
/* 094 */     hashAgg_mutableStateArray_0[3].reset();
/* 095 */
/* 096 */     hashAgg_mutableStateArray_0[3].zeroOutNullBytes();
/* 097 */
/* 098 */     hashAgg_mutableStateArray_0[3].writeNullable(0, hashAgg_expr_0_0, hashAgg_exprIsNull_0_0);
/* 099 */     if (!hashAgg_adaptivePassThrough_0) {
/* 100 */       hashAgg_regularMapRowCount_0 += 1;
/* 101 */
/* 102 */       int hashAgg_unsafeRowKeyHash_0 = (hashAgg_mutableStateArray_0[3].getRow()).hashCode();
/* 103 */       if (true) {
/* 104 */         // try to get the buffer from hash map
/* 105 */         hashAgg_unsafeRowAggBuffer_0 =
/* 106 */         hashAgg_hashMap_0.getAggregationBufferFromUnsafeRow((hashAgg_mutableStateArray_0[3].getRow()), hashAgg_unsafeRowKeyHash_0);
/* 107 */       }
/* 108 */
/* 109 */       if (hashAgg_unsafeRowAggBuffer_0 == null) {
/* 110 */         // The map is full and would spill. Pre-spill, decide whether to bypass instead.
/* 111 */         if (hashAgg_sorter_0 == null &&
/* 112 */           (double) hashAgg_hashMap_0.getNumKeys() >=
/* 113 */           hashAgg_regularMapRowCount_0 * 0.8D) {
/* 114 */           hashAgg_adaptivePassThrough_0 = true;
/* 115 */         } else {
/* 116 */           if (hashAgg_sorter_0 == null) {
/* 117 */             hashAgg_sorter_0 = hashAgg_hashMap_0.destructAndCreateExternalSorter();
/* 118 */           } else {
/* 119 */             hashAgg_sorter_0.merge(hashAgg_hashMap_0.destructAndCreateExternalSorter());
/* 120 */           }
/* 121 */
/* 122 */           // the hash map had be spilled, it should have enough memory now,
/* 123 */           // try to allocate buffer again.
/* 124 */           hashAgg_unsafeRowAggBuffer_0 = hashAgg_hashMap_0.getAggregationBufferFromUnsafeRow(
/* 125 */             (hashAgg_mutableStateArray_0[3].getRow()), hashAgg_unsafeRowKeyHash_0);
/* 126 */           if (hashAgg_unsafeRowAggBuffer_0 == null) {
/* 127 */             // failed to allocate the first page
/* 128 */             throw QueryExecutionErrors.aggregateOutOfMemoryError();
/* 129 */           }
/* 130 */
/* 131 */         }
/* 132 */       } else if (hashAgg_sorter_0 == null &&
/* 133 */         hashAgg_regularMapRowCount_0 == hashAgg_adaptiveNextSampleRow_0) {
/* 134 */         if ((double) hashAgg_hashMap_0.getNumKeys() >=
/* 135 */           hashAgg_regularMapRowCount_0 * 0.95D) {
/* 136 */           hashAgg_adaptivePassThrough_0 = true;
/* 137 */         } else {
/* 138 */           hashAgg_adaptiveNextSampleRow_0 = hashAgg_adaptiveNextSampleRow_0 * 2;
/* 139 */         }
/* 140 */       }
/* 141 */     }
/* 142 */
/* 143 */     if (hashAgg_adaptivePassThrough_0 && hashAgg_unsafeRowAggBuffer_0 == null) {
/* 144 */       hashAgg_adaptiveRowBypassed_0 = true;
/* 145 */       hashAgg_mutableStateArray_0[4].reset();
/* 146 */
/* 147 */       hashAgg_mutableStateArray_0[4].write(0, 0L);
/* 148 */       hashAgg_unsafeRowAggBuffer_0 = (hashAgg_mutableStateArray_0[4].getRow());
/* 149 */     }
/* 150 */
/* 151 */     // common sub-expressions
/* 152 */
/* 153 */     // evaluate aggregate functions and update aggregation buffers
/* 154 */
/* 155 */     long hashAgg_value_11 = hashAgg_unsafeRowAggBuffer_0.getLong(0);
/* 156 */
/* 157 */     long hashAgg_value_10 = -1L;
/* 158 */
/* 159 */     hashAgg_value_10 = org.apache.spark.sql.catalyst.util.MathUtils.addExact(hashAgg_value_11, 1L, ((org.apache.spark.sql.catalyst.trees.SQLQueryContext) references[8] /* errCtx */));
/* 160 */
/* 161 */     hashAgg_unsafeRowAggBuffer_0.setLong(0, hashAgg_value_10);
/* 162 */
/* 163 */     if (hashAgg_adaptiveRowBypassed_0) {
/* 164 */       ((org.apache.spark.sql.execution.metric.SQLMetric) references[9] /* numBypassingRows */).add(1);
/* 165 */       hashAgg_doAggregateWithKeysOutput_0((hashAgg_mutableStateArray_0[3].getRow()), hashAgg_unsafeRowAggBuffer_0);
/* 166 */     }
/* 167 */
/* 168 */   }
/* 169 */
/* 170 */   private void hashAgg_outputMap_0() throws java.io.IOException {
/* 171 */     if (!hashAgg_adaptiveMapSetupDone_0) {
/* 172 */       hashAgg_mapIter_0 = ((org.apache.spark.sql.execution.aggregate.HashAggregateExec) references[0] /* plan */).finishAggregate(hashAgg_hashMap_0, hashAgg_sorter_0, ((org.apache.spark.sql.execution.metric.SQLMetric) references[1] /* peakMemory */), ((org.apache.spark.sql.execution.metric.SQLMetric) references[2] /* spillSize */), ((org.apache.spark.sql.execution.metric.SQLMetric) references[3] /* avgHashProbe */), ((org.apache.spark.sql.execution.metric.SQLMetric) references[4] /* numTasksFallBacked */));
/* 173 */       hashAgg_adaptiveMapSetupDone_0 = true;
/* 174 */     }
/* 175 */
/* 176 */     while ( hashAgg_mapIter_0.next()) {
/* 177 */       UnsafeRow hashAgg_aggKey_0 = (UnsafeRow) hashAgg_mapIter_0.getKey();
/* 178 */       UnsafeRow hashAgg_aggBuffer_0 = (UnsafeRow) hashAgg_mapIter_0.getValue();
/* 179 */       hashAgg_doAggregateWithKeysOutput_0(hashAgg_aggKey_0, hashAgg_aggBuffer_0);
/* 180 */       if (shouldStop()) return;
/* 181 */     }
/* 182 */     hashAgg_mapIter_0.close();
/* 183 */     if (hashAgg_sorter_0 == null) {
/* 184 */       hashAgg_hashMap_0.free();
/* 185 */     }
/* 186 */
/* 187 */     hashAgg_adaptiveMapOutputDone_0 = true;
/* 188 */   }
/* 189 */
/* 190 */   protected void processNext() throws java.io.IOException {
/* 191 */     if (!hashAgg_initAgg_0) {
/* 192 */       hashAgg_initAgg_0 = true;
/* 193 */
/* 194 */       hashAgg_hashMap_0 = ((org.apache.spark.sql.execution.aggregate.HashAggregateExec) references[0] /* plan */).createHashMap();
/* 195 */       long hashAgg_beforeAgg_0 = System.nanoTime();
/* 196 */       hashAgg_doAggregateWithKeys_0(partitionIndex);
/* 197 */       ((org.apache.spark.sql.execution.metric.SQLMetric) references[10] /* aggTime */).add((System.nanoTime() - hashAgg_beforeAgg_0) / 1000000);
/* 198 */       if (shouldStop()) return;
/* 199 */     }
/* 200 */
/* 201 */     if (!hashAgg_adaptiveChildrenConsumed_0) {
/* 202 */       if (!hashAgg_adaptiveMapOutputDone_0) {
/* 203 */         hashAgg_outputMap_0();
/* 204 */         if (shouldStop()) return;
/* 205 */       }
/* 206 */
/* 207 */       hashAgg_doAggregateWithKeys_0(partitionIndex);
/* 208 */       if (shouldStop()) return;
/* 209 */     }
/* 210 */
/* 211 */     if (!hashAgg_adaptiveMapOutputDone_0) {
/* 212 */       hashAgg_outputMap_0();
/* 213 */       if (shouldStop()) return;
/* 214 */     }
/* 215 */   }
/* 216 */
/* 217 */   private void columnartorow_nextBatch_0() throws java.io.IOException {
/* 218 */     columnartorow_mutableStateArray_1[0] = org.apache.spark.sql.execution.ColumnarToRowExec.advanceBatch(
/* 219 */       columnartorow_mutableStateArray_0[0], columnartorow_mutableStateArray_1[0], ((org.apache.spark.sql.execution.metric.SQLMetric) references[7] /* numInputBatches */), ((org.apache.spark.sql.execution.metric.SQLMetric) references[6] /* numOutputRows */));
/* 220 */     if (columnartorow_mutableStateArray_1[0] != null) {
/* 221 */       columnartorow_batchIdx_0 = 0;
/* 222 */       columnartorow_mutableStateArray_2[0] = (org.apache.spark.sql.execution.vectorized.OnHeapColumnVector) columnartorow_mutableStateArray_1[0].column(0);
/* 223 */
/* 224 */     }
/* 225 */   }
/* 226 */ }

Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code (Claude Opus 4.5)

… at runtime

When a pre-shuffle partial aggregation is not reducing rows (the distinct-key ratio is
too high), maintaining an aggregation map is not worthwhile. This change makes hash
aggregation detect that at runtime and bypass the partial aggregation: the remaining
input rows are passed through as single-row partial buffers that the downstream Final
aggregation merges, avoiding the cost of maintaining and spilling a large map.

Two decision tiers, both evaluated only while the regular (second-level) map is still in
memory:
- no-spill tier: from a sample of rows on, bypass if the reduction ratio is at least
  `noSpillReductionRatioThreshold`. The sampling window doubles after each sub-threshold
  check, so low-cardinality input is re-checked only rarely while a late high-cardinality
  tail can still be caught.
- on-spill tier: when the map is about to spill, bypass instead if the ratio is at least
  `spillReductionRatioThreshold`.

Only pre-shuffle `Partial` hash aggregation with grouping keys is eligible. Both the
codegen path (HashAggregateExec) and the interpreted path (TungstenAggregationIterator)
are covered. Once pass-through is active the maps are frozen, so they are output (and
their memory released) before the remaining input is streamed.

New configs under spark.sql.execution.aggregate.adaptivePartialAggregation.* (enabled by
default), a `numBypassingRows` metric, tests, and a benchmark are added.

Co-Authored-By: Claude <noreply@anthropic.com>
@ulysses-you

Copy link
Copy Markdown
Contributor Author

@cloud-fan cloud-fan 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.

1 blocking, 2 non-blocking, 2 nits.
The optimization is promising and broadly tested, but the policy should be simplified before adding several independently tunable configuration contracts.

Design / architecture (1)

  • sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala:4188: Start with one minimum-row setting and one reduction threshold shared by periodic and pre-spill decisions. -- see inline

Correctness (1)

  • sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/HashAggregateExec.scala:913: The spill-tier ratio uses different row sets in code-generated and interpreted execution. -- see inline

Suggestions (1)

  • sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/TungstenAggregationIterator.scala:257: The interpreted hot loop repeatedly checks and unwraps immutable configuration. -- see inline

Nits: 2 minor items (see inline comments).

Verification

I traced eligibility and pass-through behavior through both code-generated and interpreted execution, including the pre-spill decision, map output, single-row partial-buffer construction, and downstream final aggregation. The two paths currently use different spill-tier denominators. No tests were run as part of this review.

.checkValue(_ > 0, "The sample row count must be positive.")
.createWithDefault(100000)

val ADAPTIVE_PARTIAL_AGGREGATION_NO_SPILL_REDUCTION_RATIO_THRESHOLD =

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.

Could we start with one minimum-row setting and one reduction threshold, applying the same policy both periodically and immediately before spilling? The separate no-spill and spill thresholds plus exponential resampling add configuration and behavioral complexity without showing that these dimensions must be independently tunable. At the spill boundary, switch to pass-through when the common policy says aggregation is ineffective; otherwise spill normally. This also gives the code-generated and interpreted paths one invariant to implement and test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Adding two configs is a trade-off, due to that it is difficult to accurately determine how much data reduction rate can offset the performance loss caused by sort and spill. There is a way to use the spill threshold, setting spill thresholds to 0, so it can always passing through directly when spill happens.

I set both of these configs to the same default value to make their behavior the same by default.

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.

Thanks, the shared default plus the concrete spill-cost rationale addresses my concern about introducing two policies by default.

|// generate grouping key
|${unsafeRowKeyCode.code}
|if (!$adaptivePassThroughTerm) {
| $regularMapRowCountTerm += 1;

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.

Evaluate the spill ratio over the same row set in both execution paths. This increment counts the failed insertion that becomes the first pass-through row, while the interpreted path evaluates before counting it. Move the increment after the spill decision and add an exact threshold-boundary test with codegen enabled and disabled.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

good catch, addressed

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.

Confirmed: both paths now evaluate the spill ratio over the pre-failure rows, and the boundary test covers codegen on and off.

// the reduction ratio is too high to be worthwhile, bypass partial aggregation for the
// rest. The window doubles after each sub-threshold check so low-cardinality input is
// re-evaluated only rarely while a late high-cardinality tail can still be caught.
if (adaptivePartialAggConfig.isDefined && externalSorter == null &&

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.

Extract stable primitive settings before entering the input loop. The current Option.isDefined/get checks repeat for every aggregated row even though eligibility and thresholds cannot change during the iterator's lifetime.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

good point, addressed

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.

Confirmed, the immutable adaptive settings are now extracted before the hot loop.

| $sorterTerm.merge($hashMapTerm.destructAndCreateExternalSorter());
|}
|$resetCounter
|// the hash map had be spilled, it should have enough memory now,

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.

Suggested change
|// the hash map had be spilled, it should have enough memory now,
|// the hash map had been spilled, so it should have enough memory now,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed

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.

Confirmed, thanks.


// Force the regular map to spill quickly and disable the no-spill tier (huge sample). With
// fully distinct keys the reduction ratio is 1.0, so at the spill boundary the on-spill
// (Tier 2) tier bypasses instead of spilling; the baseline spills repeatedly and falls back

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.

Suggested change
// (Tier 2) tier bypasses instead of spilling; the baseline spills repeatedly and falls back
// tier (Tier 2) bypasses instead of spilling; the baseline spills repeatedly and falls back

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed

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.

Confirmed, thanks.

- Make `spillReductionRatioThreshold` fall back to `noSpillReductionRatioThreshold`
  so both tiers apply one policy by default.
- Evaluate the spill-tier ratio over the same row set in both execution paths: the
  codegen path now counts only the rows that made it into the map, matching the
  interpreted path, and skips the decision until at least one row is counted.
  Add a test asserting both paths decide identically at the exact ratio boundary.
- Unwrap the adaptive config once before the interpreted input loop instead of
  re-checking the `Option` for every row.
- Fix two comment typos.

Co-Authored-By: Claude <noreply@anthropic.com>
s"falls back to '${ADAPTIVE_PARTIAL_AGGREGATION_NO_SPILL_REDUCTION_RATIO_THRESHOLD.key}' " +
"so that both tiers apply one policy by default. Setting it lower makes the bypass more " +
"likely once spilling is imminent, and 0 always bypasses instead of spilling.")
.version("4.3.0")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Just leaving a note that branch-4.3 has already been cut, so please check whether these changes are intended to go there as well @ulysses-you

@cloud-fan cloud-fan 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.

4 addressed, 1 remaining, 3 new. (0 newly introduced, 3 late catches, 0 previously raised.)
3 blocking, 1 non-blocking, 0 nits.
The partial-buffer mechanism is sound, but the decision policy and spill lifecycle should converge with DBR before this becomes a second implementation contract.

Remaining from prior review (1)

  • Could we start with one minimum-row setting and one reduction threshold? DBR already implements this feature and this OSS implementation will eventually be imported there, so please align normal and spill-boundary checks on the same rows / keys <= minCompaction predicate after minRows rather than creating a second operational contract. -- existing thread

Design / architecture (2)

  • sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/HashAggregateExec.scala:206: Measure the operator's total processed rows and total in-memory keys rather than only regular-map traffic. -- see inline
  • sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/HashAggregateExec.scala:925: Keep adaptive pass-through available for new in-memory map epochs after earlier spills. -- see inline

Suggestions (1)

  • sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/HashAggregateExec.scala:814: Propagate the child's result-copy requirement instead of forcing copies for every adaptive aggregate. -- see inline

Verification

I compared the OSS implementation with DBR's code-generated and interpreted adaptive partial aggregation paths. DBR uses one minimum-row setting and one compaction threshold, counts fast-map and regular-map keys over all processed rows, evaluates new in-memory map epochs after spills, and propagates the child's result-copy requirement. No tests were run as part of this review.

// Codegen state for adaptive partial aggregation. When the pre-shuffle reduction ratio of the
// regular (second-level) hash map is too low, the operator stops populating the map and instead
// streams each remaining row through as a single-row partial buffer for the Final aggregate to
// merge. Only the regular map is governed: the append-only fast hash map keeps absorbing hot

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.

Please measure all processed rows against the total number of in-memory keys, including both maps. DBR uses the operator-level invariant—equivalent to fastMap.rowCount + regularMap.getNumKeys over all processed rows—so limiting both sides to regular-map traffic makes two-level-map routing change the decision and creates a second policy to maintain.

| }
| if ($unsafeRowBuffer != null) {
| $regularMapRowCountTerm += 1;
| if ($sorterTerm == null &&

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.

Please keep adaptive pass-through available after earlier spills. DBR intentionally resets the processed-row count after each spill and evaluates the new in-memory map epoch; if it activates, the existing sorter and current map drain before the remaining rows pass through. Permanently gating on sorter == null materially diverges for inputs whose cardinality becomes unfavorable later.

// appended in the same child loop iteration before any drain, and they all alias the single
// result `UnsafeRow`. Copy the result so the buffered rows do not collapse into the last one.
override def needCopyResult: Boolean =
adaptivePartialAggConfig.isDefined || super.needCopyResult

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.

Please propagate the child's copy requirement instead of enabling copies for every adaptive aggregate. DBR gates this on the child, and ExpandExec already reports needCopyResult = true, so doing the same preserves the aliasing fix without adding row.copy() to ordinary output.

@viirya viirya left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I traced the pass-through mechanism end to end on both paths and the core is correct: a bypassed row builds a fresh initialValues buffer, runs the normal update, and is emitted as (key, single-row partial buffer) — exactly what the partial map would hold for a one-row group, so the Final merge produces the same result. The eligibility gate is right (requiredChildDistributionExpressions.isEmpty correctly excludes the group-by-only Final phase, with a good comment on why the mode check alone would admit it vacuously), the Expand-aliasing copy is genuinely needed, and the interpreted path mirrors the codegen contract (copyFrom(initialAggregationBuffer) + processRow). The correctness matrix in the test suite is broad and always compares against the feature-off reference, so results can't silently diverge. No result-correctness concerns from me.

The remaining open items are all about policy/efficiency/metrics rather than result correctness — worth converging but none change query output:

  • aggTime under-reports when pass-through fires (new). The timer wraps only the first doAgg inside if (!initAgg) (HashAggregateExec.scala:792-794); once pass-through activates mid-build, the bulk of the remaining input is consumed through adaptiveResumeBuild's doAgg call (line 771), which is outside the timer. For a high-cardinality input that bypasses early, most of the build work isn't counted, so the SQL UI's aggregation time is materially low. Consider timing each resumed build (or the whole build lifecycle).
  • Config version("4.3.0") vs. the actual target. dev/next_version_candidates.py now reports branch-4.x -> 4.4.0 (branch-4.3 is cut, as @uros-b noted), so unless this is intended for a branch-4.3 backport the configs should say 4.4.0 — otherwise the SQL config docs claim a released version already has this feature. This is still an open question on the thread.
  • I agree with @cloud-fan's two remaining design points, and I'd frame them as effectiveness rather than correctness so they're easier to weigh: the permanent sorter == null gate means an input whose cardinality turns unfavorable only after an early spill can never bypass (the doc's "a late high-cardinality tail can still be caught" holds only on the no-spill path); and needCopyResult = adaptivePartialAggConfig.isDefined || super.needCopyResult over-copies — it's safe, but since HashAggregateExec is a blocking operator super.needCopyResult is always false and won't pick up the child, so gating on the child's requirement (which ExpandExec already reports as true) preserves the aliasing fix while dropping the copy for ordinary single-output children.

The mechanism itself is sound and the tests are thorough; these are convergence/accuracy items rather than blockers on result correctness, but the version tag and the aggTime gap are worth resolving before merge.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants