peter-toth commented on code in PR #57742:
URL: https://github.com/apache/spark/pull/57742#discussion_r3735867587


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/HashAggregateExec.scala:
##########
@@ -845,29 +1158,56 @@ case class HashAggregateExec(
       }
     }
 
-    val declareRowBuffer: String = if (isFastHashMapEnabled) {
-      val fastRowType = if (isVectorizedHashMapEnabled) {
-        classOf[MutableColumnarRow].getName
+    val declareRowBuffer: String = {
+      val declareBuffers = if (isFastHashMapEnabled) {
+        val fastRowType = if (isVectorizedHashMapEnabled) {
+          classOf[MutableColumnarRow].getName
+        } else {
+          "UnsafeRow"
+        }
+        s"""
+           |UnsafeRow $unsafeRowBuffer = null;
+           |$fastRowType $fastRowBuffer = null;
+         """.stripMargin
+      } else {
+        s"UnsafeRow $unsafeRowBuffer = null;"
+      }
+      val declareBypassed = if (adaptivePartialAggEnabled) {
+        s"boolean $adaptiveRowBypassedTerm = false;"
       } else {
-        "UnsafeRow"
+        ""
       }
       s"""
-         |UnsafeRow $unsafeRowBuffer = null;
-         |$fastRowType $fastRowBuffer = null;
+         |$declareBuffers
+         |$declareBypassed
        """.stripMargin
-    } else {
-      s"UnsafeRow $unsafeRowBuffer = null;"
     }
 
     // We try to do hash map based in-memory aggregation first. If there is 
not enough memory (the
     // hash map will return null for new key), we spill the hash map to disk 
to free memory, then
     // continue to do in-memory aggregation and spilling until all the rows 
had been processed.
     // Finally, sort the spilled aggregate buffers by key, and merge them 
together for same key.
+    //
+    // With adaptive partial aggregation, once pass-through is active 
`updateRowInHashMap` fills the
+    // single-row buffer built above; we then emit `key ++ buffer` straight to 
the parent so the row
+    // skips both the fast map and the regular map.
+    val emitPassThroughRow = if (adaptivePartialAggEnabled) {
+      val numBypassingRows = metricTerm(ctx, "numBypassingRows")
+      s"""
+         |if ($adaptiveRowBypassedTerm) {
+         |  $numBypassingRows.add(1);
+         |  $outputFunc(${unsafeRowKeyCode.value}, $unsafeRowBuffer);

Review Comment:
   **Finding 1.** The eight cells do agree now, but they are eight variations 
of one plan shape. `spark.range(0, 9, 1, 1)` gives a single input partition, so 
`SinglePartition` satisfies the Final's `ClusteredDistribution`, no `Exchange` 
is inserted, and `Partial` and `Final` fuse into one `WholeStageCodegen` 
(finding 12). In that shape the partial aggregate's `outputFunc` feeds the 
Final's `doConsume` — a map insert — so nothing ever reaches 
`BufferedRowIterator.currentRows`, `shouldStop()` stays false for the whole 
build, `doAgg` consumes the partition in one call, and `outputMap()` is only 
reached from `adaptiveFinalOutput`, i.e. after every pass-through row. That is 
why the generated path looks like "maps drain afterwards" there.
   
   With an `Exchange` it does not. `shouldStop()` becomes true as soon as the 
first bypassed row is appended, `doAgg` returns, and `adaptiveResumeBuild` 
drains the map on the next `processNext()` — so the generated order is `[first 
bypassed row] [map] [rest]`, i.e. map-first for every group except the one that 
first bypassed row belongs to.
   
   Measured on both heads, `minRows = 8`, `enableTwoLevelAggMap = false`, 
everything else default, reading the `k = -1` row:
   
   ```scala
   spark.range(0, 40, 1, splits)
     .select(when($"id" === 0 || $"id" === dupAt, lit(-1L)).otherwise($"id") as 
"k", $"id" as "v")
     .groupBy($"k").agg(first($"v") as "f", last($"v") as "l")
   ```
   
   | head | plan shape | colliding row | reference `(f,l)` | generated | 
interpreted |
   | --- | --- | --- | --- | --- | --- |
   | `f2616ecb556` | no `Exchange` | `id=8`, first bypassed | `(0,8)` | `(8,0)` 
| `(0,8)` |
   | `f2616ecb556` | no `Exchange` | `id=9`, later | `(0,9)` | `(9,0)` | 
`(0,9)` |
   | `f2616ecb556` | `Exchange` | `id=8`, first bypassed | `(0,8)` | `(8,0)` | 
`(0,8)` |
   | `f2616ecb556` | `Exchange` | `id=9`, later | `(0,9)` | `(0,9)` | `(0,9)` |
   | `87937dbd6d0` | no `Exchange` | `id=8`, first bypassed | `(0,8)` | `(8,0)` 
| `(8,0)` |
   | `87937dbd6d0` | no `Exchange` | `id=9`, later | `(0,9)` | `(9,0)` | 
`(9,0)` |
   | `87937dbd6d0` | `Exchange` | `id=8`, first bypassed | `(0,8)` | `(8,0)` | 
`(8,0)` |
   | `87937dbd6d0` | `Exchange` | `id=9`, later | `(0,9)` | **`(0,9)`** | 
**`(9,0)`** |
   
   `numBypassingRows` is 32 (`splits=1`) or 24 (`splits=2`) in every cell, so 
the bypass fires throughout.
   
   Two things follow. The paths agreed in one of these four cases before and in 
three now, but the case they now disagree in is the one with an `Exchange` and 
a collision that is not adjacent to the flip — the shape the feature is for. 
And the interpreted order you replaced matched a non-bypassed run in **all 
four** cases, while the generated order matches in one; after the change the 
interpreted order matches in none. So the target to align on is the order the 
interpreted path already had, not the generated one — and it is the only order 
that can match, because a group's map buffer holds its *earlier* rows, so its 
streamed rows have to merge after it.
   
   On why the other direction did not work — I do not think either obstacle is 
load-bearing:
   
   - **No `return` from `doConsume` is needed.** Call `outputMap()` inline in 
`emitPassThroughRow`, before `outputFunc(...)`, and drop the `if (shouldStop()) 
return;` for that one call. The map is already frozen at that point and both 
probes are skipped once `adaptivePassThroughTerm` is set (`:1003-1010`, 
`:1046`), so nothing touches it afterwards, and the child's loop and resume 
state are untouched because control never leaves `doConsume`.
   - **"Several rows in one `doConsume` call" is not a contract the buffer 
relies on.** `GenerateExec.codeGenCollection`/`codeGenIterableOnce` append an 
entire collection per `doConsume` with no stop check — that is finding 10. The 
cost of draining inline is a one-off `currentRows` spike bounded by the size of 
the map you were about to free.
   
   The dropped rows under a fused `UnionExec` sound like the bug `dc2091212e1` 
fixed rather than a consequence of draining, but if it reproduces with 
`postChildProduce` in place I would like to see it.
   
   Failing that, finding 15 removes the choice entirely: if a group is never 
split between the map and the stream, there is no merge order to pick.
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/HashAggregateExec.scala:
##########
@@ -711,6 +995,54 @@ case class HashAggregateExec(
       } else {
         findOrInsertRegularHashMap
       }
+
+      // Every row is either accepted by an aggregation map or streamed 
through -- the fast map
+      // serves a row without it ever reaching the regular map, so both 
buffers are consulted to
+      // tell the two apart.
+      //
+      // An accepted row counts toward the compaction ratio, so the numerator 
matches the
+      // operator-level denominator. Counting inside the regular-map branch 
alone would drop the
+      // rows the fast map absorbed from the ratio and bypass an aggregation 
that is in fact
+      // reducing. A row no map holds is streamed once pass-through is active: 
both probes are
+      // skipped (guarded above), so neither buffer is set, and `rowBypassed` 
marks exactly those
+      // rows. The row that fails to insert at the spill boundary lands here 
too, while the row
+      // that merely flipped pass-through at the check point is already 
aggregated in the map that
+      // took it and must not be re-emitted.
+      val countOrPassThroughRow = if (adaptivePartialAggEnabled) {
+        val heldByAMap = if (isFastHashMapEnabled) {
+          s"($fastRowBuffer != null || $unsafeRowBuffer != null)"
+        } else {
+          s"($unsafeRowBuffer != null)"
+        }
+        // The grouping key was already projected in 
`findOrInsertRegularHashMap`
+        // (`unsafeRowKeyCode.code`), so `unsafeRowKeyCode.value` holds this 
row's key. Only build
+        // the single-row partial buffer here.
+        s"""
+           |if ($heldByAMap) {
+           |  if (!$adaptivePassThroughTerm) {
+           |    $processedRowsTerm += 1;
+           |    if ($processedRowsTerm == $adaptiveNextCheckRowTerm) {

Review Comment:
   **Finding 2.** Re-measured on `87937dbd6d0` — unchanged, 100,000 partial 
output rows with the feature off against 500,000 with it on, `numBypassingRows` 
400,000, all settings at their defaults.
   
   On your question: of the two levers, I would take the first (scale the first 
check point to what the task has seen) as the near-term answer and not the 
second. Un-bypassing needs the map back, and the honest version of that is not 
"rebuild" — it is a fresh epoch, exactly like the one `processInputs` already 
starts after a spill, which means the same machinery twice and a 
`mapOutputDone` flag that has to become repeatable in codegen. That is a lot of 
moving parts for a decision that is still a bet on one window.
   
   What I would rather compare both against is finding 15: stop *growing* the 
map at the flip instead of abandoning it, and stream only rows whose key the 
map does not already hold. On this exact input the 100 tail keys are already 
resident when the flip fires, so they keep aggregating and the output goes back 
to ~100k rows. But I want to be straight that it bounds this rather than fixing 
it — invert the input to 100k distinct keys followed by 400k rows over 100 
*brand-new* keys and all 400k still stream. Its real appeal is finding 1: a 
group is never split between the map and the stream, so there is no merge order 
to pick. And its real cost is that it keeps the per-row probe, which your own 
no-spill benchmark says is worth 1.7x on its own (473.8 -> 283.8 ns/row with no 
spill anywhere), so it would need re-benchmarking before anyone commits to it.
   
   Thanks for the `adaptiveNextCheckRow *= 2` pointer — I had not looked at 
`a62ca30775c`. Having read it, I do not think dropping it cost you anything 
here, and it is worth saying why so it does not get proposed back as the fix. 
The doubling only made *later* checks rarer; the first one still landed at 
`sampleRows`, and with a one-way flip that first check is the one that commits 
the task, so the exposure was identical. Its stated purpose was cost control 
("a low-cardinality input is re-evaluated only rarely"), but the per-row work 
is the counter increment, which doubling does not remove — it saves one 
comparison per 100k rows, against the ~1.5 ns/row the low-cardinality benchmark 
attributes to the whole check. And because a later check can only ever *start* 
a bypass, never undo one, rarer later checks made finding 6 slightly worse. So 
cloud-fan's simplification looks right on the merits as well as on complexity.
   
   Whichever lever lands, while the flip stays one-way I would default 
`spark.sql.execution.aggregate.adaptivePartialAggregation.enabled` to `false` 
for one release, since the downside here is unbounded while the upside is 
bounded by the cost of maintaining the map.
   



##########
docs/sql-performance-tuning.md:
##########
@@ -181,6 +181,58 @@ Missing or inaccurate statistics will hinder Spark's 
ability to select an optima
 - **Query plan estimates**: You can inspect Spark's cost estimates in the 
optimized query plan via [`EXPLAIN COST`](sql-ref-syntax-qry-explain.html) or 
`DataFrame.explain(mode="cost")`.
 - **Runtime statistics**: You can inspect these statistics in the [SQL 
UI](web-ui.html#sql-tab) under the "Details" section as a query is running. 
Look for `Statistics(..., isRuntime=true)` in the plan.
 
+## Optimizing the Aggregate
+
+### Adaptive Partial Aggregation
+
+A grouping aggregation normally runs in two phases: a partial aggregation 
before the shuffle and a
+final aggregation after it. The partial aggregation is only worthwhile when it 
actually reduces the
+number of rows; when the grouping keys are close to unique it maintains -- and 
possibly spills -- an
+aggregation map roughly as large as its input while emitting almost as many 
rows as it consumed.
+
+When adaptive partial aggregation is enabled, hash aggregation measures the 
compaction ratio (the
+number of processed rows divided by the number of keys held in its aggregation 
maps) at runtime
+and, if the partial aggregation is not collapsing enough rows to be 
worthwhile, stops populating
+the aggregation map and passes the remaining rows through as single-row 
partial aggregation buffers
+for the final aggregation to merge. Query results are unchanged. The ratio is 
evaluated

Review Comment:
   **Finding 6.** Agreed on the sequencing — settle the policy under finding 2 
and let this wording follow, rather than rewording now and changing behaviour 
after.
   
   One note on the tension you describe: your option 2 and finding 2 stop 
pulling against each other under finding 15. If the flip only stops the map 
*growing*, an input that turns high-cardinality late is handled because its new 
keys stream, so "a query that only becomes ineffective later in its input is 
still caught" becomes true as written without making any single window 
decisive. That is the one part of finding 15 I would call a clean fix rather 
than a trade. If the policy lands on either of the levers instead, option 1 is 
the right call and I would narrow the sentence to the spill check.
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/TungstenAggregationIterator.scala:
##########
@@ -354,6 +410,47 @@ class TungstenAggregationIterator(
     }
   }
 
+  ///////////////////////////////////////////////////////////////////////////
+  // Part 5b: Methods and fields used by adaptive partial aggregation 
pass-through.
+  ///////////////////////////////////////////////////////////////////////////
+
+  // Indicates that partial aggregation has been bypassed and the remaining 
input rows should be
+  // passed through as single-row partial buffers. Set in `processInputs` by 
either check point.
+  // It may coexist with earlier spills, so the output order is: sort-based 
(or map) output first,
+  // then the pass-through rows.
+  private[this] var passThrough: Boolean = false
+
+  // The row that could not be inserted at the spill check. It is stashed here 
(as
+  // a copy) so it becomes the first pass-through row rather than being lost.
+  private[this] var pendingPassThroughRow: InternalRow = null
+
+  // A reused aggregation buffer for building single-row partial buffers 
during pass-through. It is
+  // re-initialized from `initialAggregationBuffer` for every passed-through 
row.
+  private[this] lazy val passThroughAggregationBuffer: UnsafeRow = 
createNewAggregationBuffer()
+
+  // Whether there are remaining pass-through rows to emit.
+  private def passThroughHasNext: Boolean =
+    passThrough && (pendingPassThroughRow != null || inputIter.hasNext)
+
+  // Emits the next input row as a single-row partial aggregation buffer, i.e. 
a group of size one.
+  // The output (grouping key ++ buffer) is a valid partial buffer that the 
downstream Final
+  // aggregation merges, so the result is identical to running partial 
aggregation on this row.
+  private def nextPassThroughOutput(): UnsafeRow = {

Review Comment:
   **Finding 7.** A separate PR is fine by me. Timing the work where it happens 
changes what `aggTime` means for the sort-fallback path too, which predates 
this PR, so it deserves its own change rather than riding along here — and the 
codegen half is already correct at this head, so the gap is confined to the 
interpreted path.
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/HashAggregateExec.scala:
##########
@@ -626,13 +788,27 @@ case class HashAggregateExec(
        |  long $beforeAgg = System.nanoTime();
        |  $doAggFuncName(partitionIndex);
        |  $aggTime.add((System.nanoTime() - $beforeAgg) / $NANOS_PER_MILLIS);
+       |  $adaptiveStopCheck
        |}
-       |// output the result
-       |$outputFromFastHashMap
-       |$outputFromRegularHashMap
+       |$adaptiveResumeBuild
+       |$adaptiveFinalOutput
      """.stripMargin
   }
 
+  // Blocking operators normally suppress the child's `shouldStop()` check 
because they buffer all
+  // output. With adaptive partial aggregation, pass-through rows are appended 
to the output buffer
+  // while consuming child input, so the stop check must be re-enabled to keep 
the buffer bounded.
+  override def needStopCheck: Boolean = adaptivePartialAggEnabled

Review Comment:
   **Finding 10.** The comment at `:827-838` reads correctly now, thanks — and 
`GenerateExec` has no `shouldStop()` anywhere in the file, so what it says is 
accurate.
   
   On the `buffered=1` measurement: I think it read the wrong buffer. If the 
probe used a single-partition input (the way the suite's generator test does), 
the plan has no `Exchange` — `SinglePartition` satisfies the Final's 
`ClusteredDistribution`, so `Partial` and `Final` fuse into one 
`WholeStageCodegen`, which I measured on this head (finding 12). In that plan 
the partial aggregate's `outputFunc` feeds the Final's `doConsume`, i.e. a 
hash-map insert, so no bypassed row ever enters 
`BufferedRowIterator.currentRows`; the `currentRows` the probe samples belongs 
to the stage `WholeStageCodegenExec` terminates, which there is the Final 
aggregate. `buffered=1` is the Final's own output row, and `shouldStop()` is 
false throughout the build — which is also why `bypassed=998` and `buffered=1` 
can hold at once.
   
   Re-running it with two or more input partitions should give the `Exchange`, 
and then `currentRows` is the partial aggregate's own buffer. That is also the 
only shape where the bound you documented can be exceeded, so it is where the 
number is meaningful.
   
   Either way I am fine with the outcome — documenting it is a reasonable call 
for a Non-blocking item, and it now says the failure mode is heap rather than 
spill I/O.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to