dongjoon-hyun commented on code in PR #59823:
URL: https://github.com/apache/spark/pull/59823#discussion_r4389483397


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala:
##########
@@ -4685,16 +4685,39 @@ object SQLConf {
       .doc("The minimum compaction ratio required to keep the pre-shuffle 
partial aggregation " +
         s"(see '${ADAPTIVE_PARTIAL_AGGREGATION_ENABLED.key}'). The compaction 
ratio is the " +
         "number of processed rows divided by the number of keys held in the 
aggregation maps, " +
-        "so a ratio of 10 means the partial aggregation collapses ten rows 
into one. When an " +
-        "evaluation finds the ratio below this value, the partial aggregation 
is bypassed for " +
-        "the rest of the input. A larger value bypasses more aggressively.")
+        "so a ratio of 10 means the partial aggregation collapses ten rows 
into one. When the " +
+        "periodic evaluation finds the ratio below this value, the partial 
aggregation is " +
+        "bypassed for the rest of the input. A larger value bypasses more 
aggressively. The " +
+        "evaluation when the map is about to spill demands at least this value 
too, see " +
+        
"'spark.sql.execution.aggregate.adaptivePartialAggregation.minCompactionToSpill'.")
       .version("4.4.0")
       .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
       .doubleConf
       .checkValue(v => v >= 1.0 && v.isFinite,
         "The minimum compaction ratio must be a finite value of at least 1.0.")
       .createWithDefault(1.05)
 
+  val ADAPTIVE_PARTIAL_AGGREGATION_MIN_COMPACTION_TO_SPILL =
+    
buildConf("spark.sql.execution.aggregate.adaptivePartialAggregation.minCompactionToSpill")
+      .doc("The minimum compaction ratio required to keep the pre-shuffle 
partial aggregation " +
+        "when its map is about to spill (see " +
+        s"'${ADAPTIVE_PARTIAL_AGGREGATION_ENABLED.key}'). Keeping the 
aggregation then means " +
+        "aggregating the rest of the input through spills, which pays off only 
if that input " +
+        "keeps repeating keys, so this evaluation measures the recent rows: 
those processed " +
+        "since the periodic evaluation before last, against the keys they 
added. When keys " +
+        "repeat across the input, that ratio climbs as the map fills. When it 
is below the " +
+        "larger of this value and " +
+        s"'${ADAPTIVE_PARTIAL_AGGREGATION_MIN_COMPACTION.key}', the map is not 
spilled and the " +
+        "rest of the input is passed through. Like the periodic evaluation, it 
only runs once " +
+        "the current map has processed " +
+        s"'${ADAPTIVE_PARTIAL_AGGREGATION_MIN_ROWS.key}' rows.")
+      .version("4.4.0")
+      .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+      .doubleConf
+      .checkValue(v => v >= 1.0 && v.isFinite,
+        "The minimum compaction ratio must be a finite value of at least 1.0.")
+      .createWithDefault(2.0)

Review Comment:
   **[1] Above the `[minRows, 2 * minRows)` band, the recent-rows ratio reaches 
2.0 only when the map holds more than about 57% of a task's distinct keys, 
however often each key repeats.**
   
   [ulysses-you's 
comment](https://github.com/apache/spark/pull/59823#issuecomment-6436941615) 
shows that inside the band the decision depends on the map capacity. It still 
does above the band, where the window is in effect. For keys drawn uniformly 
from D distinct keys, a row adds a key with probability 1 - k/D while the map 
holds k keys, so the recent rows compact by roughly 1/(1 - k/D) just before the 
fill. That measures how much of the key space fits in the map, not how often 
the keys repeat.
   
   Take the example of [item 
12](https://github.com/apache/spark/pull/59764#discussion_r4371926323) of my 
review on PR 59764: 6M rows over 600K distinct keys per task, so 10 rows per 
key, with `minRows = 100000`, but with room for 240K keys instead of 400K:
   
   - The map fills after about 306K rows, past three periodic checks, so the 
spill check judges rows 200K to 306K: about 106K rows over 70K new keys, 1.52.
   - 1.52 is below 2.0, so the task passes the rest through and shuffles about 
5.9M rows. Keeping the aggregation would shuffle about 0.6M, after about 19 
epochs that spill about 4.5M rows in total. Before this PR the task kept 
aggregating, because the epoch ratio, 1.28, is above 1.05.
   
   With room for 400K keys, as in item 12, the window reads 2.63 and the task 
keeps aggregating. The `keys recur` benchmark case is on that side too: its map 
fills at 220K rows and holds about 67% of the 200K keys, so the window reads 
about 2.2. By expected-value arithmetic, the window stays below 2.0 until the 
map holds about 57% of the keys, whether each key repeats 10 or 100 times per 
task.
   
   Could the description and the docs say that the spill check keeps 
aggregating recurring keys only when the map holds most of a task's distinct 
keys? Or, since the description's own signal is that the recent ratio "climbs 
above the epoch's" when keys repeat and matches it when each key's rows arrive 
together, could the check compare the two ratios instead of the recent one with 
an absolute 2.0? In the example above the recent ratio is about 1.19 times the 
epoch's (1.52 against 1.28), while for `floor(2 * id / 3)` the two match.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/TungstenAggregationIterator.scala:
##########
@@ -271,10 +288,14 @@ class TungstenAggregationIterator(
           // Periodic check: if the aggregation is not collapsing enough rows, 
bypass it for the
           // rest of the input.
           if (adaptivePartialAggEnabled && processedRows == nextCheckRow) {
-            if (ineffective()) {
+            if (ineffective(processedRows, hashMap.getNumKeys(), 
adaptiveMinCompaction)) {
               passThrough = true
             } else {
               nextCheckRow += minRows
+              windowRows = checkRows

Review Comment:
   **[2] `minRows` now also sets how many rows the spill check samples, from 
`minRows` to `2 * minRows - 1` of the latest rows, and neither config doc says 
so.**
   
   The window starts at the periodic check before last (L295-298 here and 
HashAggregateExec.scala:1188-1191), so its length follows the periodic cadence 
rather than the size of the epoch. With the default and a map that fills after 
several million rows, the decision that cannot be undone rests on the last 100K 
to 200K rows. With `minRows = 1000` it rests on the last 1000 to 1999 rows, so 
a short run of new keys right before the fill can pull the window below 2.0 
even when the epoch as a whole keeps repeating keys. The suite's forced-spill 
cells, with `minRows = 8`, judge 8 to 15 rows.
   
   The `minRows` entry (SQLConf.scala:4672-4676 and sql-performance-tuning.md 
L248-254) describes it only as the periodic interval and the floor of the spill 
check, and the advice to raise it (sql-performance-tuning.md L226-229) does not 
say that this also widens the spill-check sample. ulysses-you's item 2 already 
asks to align the same entry for `minRows = 0`. Could the entry also say that 
the spill check judges the rows since the check before last, or could the 
window be sized as a fraction of the epoch instead?



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/benchmark/AdaptivePartialAggregationBenchmark.scala:
##########
@@ -135,6 +135,55 @@ object AdaptivePartialAggregationBenchmark extends 
SqlBasedBenchmark {
         extraConf = spillCheckConf)
       benchmark.run()
     }
+
+    // Inputs that compact by about 1.5 within each map epoch, read from four 
partitions so an
+    // `Exchange` separates the Partial from the Final, and with a map forced 
to fill every few
+    // hundred thousand rows. Whether keeping the aggregation through the 
spills pays off depends
+    // on whether the rest of the input keeps repeating keys:
+    //   - each key's rows arrive together, so later epochs share no keys and 
passing the rest
+    //     through at the first fill avoids spilling every epoch;
+    //   - keys recur uniformly across the task (10 rows per key), so the 
final merge collapses
+    //     them and keeping the aggregation shuffles a tenth of the rows;
+    //   - 10 hot keys hold 30% of the rows and the rest are unique, so 
passing through avoids the
+    //     spills but sends every hot-key row to the reducer of its key.
+    // `minCompactionToSpill = 1.05` judges the spill check by 
`minCompaction`; the default, 2.0,
+    // demands that the recent rows compact by 2.0.
+    val spillCheckCases = Seq(
+      ("keys arrive together", "cast(id * 2 / 3 as bigint)", 300000),
+      ("keys recur", "pmod(xxhash64(id), 200000)", 220000),
+      ("hot keys", "if(id % 100 < 30, id % 10, id + 1000)", 300000))
+    spillCheckCases.foreach { case (name, key, fillRows) =>
+      runBenchmark(s"spill check with an Exchange, $name") {

Review Comment:
   **[5] The result files were not regenerated for these cases.**
   
   `sql/core/benchmarks/AdaptivePartialAggregationBenchmark-results.txt` and 
its `-jdk21` and `-jdk25` variants still hold only the four groups from 
5bcd5f59b9f, so the 20% and 35% numbers in the description are not recorded 
anywhere a later run can be compared with. The class doc at L31-40 also still 
says that every scenario runs the `adaptive = T/F` matrix over the 
high/low-cardinality and spill grid, while these groups compare 
`minCompactionToSpill` values. The four existing groups decide the same at 2.0, 
so only the new groups change. Could you regenerate the three files with the 
"Run benchmarks" workflow and extend the class doc?



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/aggregate/AdaptivePartialAggregationSuite.scala:
##########
@@ -1278,30 +1377,40 @@ class AdaptivePartialAggregationSuite extends QueryTest 
with SharedSparkSession
     // failed insertion that becomes the first pass-through row, so both 
execution paths must judge
     // the same row set and reach the same decision. `id % 40` over 400 rows 
gives 40 keys when the
     // map fills at 50 aggregated rows, i.e. a compaction ratio of exactly 
1.25: demanding 1.25 the
-    // aggregation is kept (`50 < 40 * 1.25` is false), demanding 1.3 it is 
bypassed.
-    Seq(1.25 -> false, 1.3 -> true).foreach { case (minCompaction, 
shouldBypass) =>
+    // aggregation is kept (`50 < 40 * 1.25` is false), demanding 1.3 it is 
bypassed. The spill
+    // check demands the larger of `minCompaction` and `minCompactionToSpill`, 
so either one can
+    // set the boundary.
+    Seq(
+      (1.0, 1.25, false),
+      (1.0, 1.3, true),
+      (1.3, 1.0, true)

Review Comment:
   **[4] Nit: the boundary test does not cover `minCompaction` on the keep 
side, and with `minRows = 0` it never reaches the windowed comparison.**
   
   The cells cover `minCompactionToSpill` at the boundary from both sides, but 
`minCompaction` only from the bypass side. A `(1.25, 1.0, false)` cell would 
pin that the larger value applies at equality too.
   
   The windowed form, `processedRows - windowRows` against `(keys - windowKeys) 
* threshold`, meets its boundary only through the tie in [3]. A cell with 
`minRows = 10` could reach it on purpose. For example, 10 rows over 9 keys 
followed by keys that each take two rows (ids 10-29 add 10 keys) fill at 30 
rows with a window of exactly 2.0 over an epoch of 1.58. The task then keeps 
aggregating at 2.0 and passes through at 2.1, while a whole-epoch check would 
pass through at both.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/TungstenAggregationIterator.scala:
##########
@@ -225,10 +230,16 @@ class TungstenAggregationIterator(
       // The spill check waits for `minRows` rows of the current epoch, and 
for at least one row
       // when `minRows = 0`.
       val spillCheckMinRows = math.max(minRows, 1L)
-      // The partial aggregation is ineffective when it does not collapse 
`minCompaction` rows into
-      // one key. There is no fast map on this path, so the map's keys are all 
the operator holds.
-      def ineffective(): Boolean =
-        processedRows < hashMap.getNumKeys().toDouble * adaptiveMinCompaction
+      // The epoch's processed rows and keys at its last periodic check, and 
at the check before
+      // that, where the window of recent rows that the spill check judges 
starts.
+      var checkRows = 0L
+      var checkKeys = 0L
+      var windowRows = 0L

Review Comment:
   **[6] Nit: `checkRows` and `windowRows` follow from `nextCheckRow`.**
   
   The periodic checks run at exact multiples of `minRows` within an epoch, and 
`nextCheckRow` advances only when a check passes. So at any spill check 
`checkRows == nextCheckRow - minRows` and `windowRows == max(0, nextCheckRow - 
2 * minRows)`, which is also 0 when `minRows = 0`. Only the two key snapshots 
need state. Deriving the row side would drop two variables with their resets 
and rotations here (L235-238, L273-276 and L295-298), and two mutable states in 
HashAggregateExec.scala (L591-594, L1081-1084 and L1188-1191). This is 
optional, since the explicit snapshots are easy to follow.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/aggregate/AdaptivePartialAggregationSuite.scala:
##########
@@ -1227,15 +1325,16 @@ class AdaptivePartialAggregationSuite extends QueryTest 
with SharedSparkSession
           "spark.sql.TungstenAggregate.testFallbackStartsAt" -> s"$fastCap, 
40") ++
           fixedPlanConfs): _*) {
         val df = () => spark.range(0, 400, 1, 1)
-          .select(when($"id" < 100, $"id" % 5).otherwise($"id") as "k", $"id" 
as "v")
+          .select(when($"id" < 80, $"id" % 5).otherwise($"id") as "k", $"id" 
as "v")
           .groupBy($"k")
           .agg(sum($"v") as "s")
         withClue(s"wholeStage=$wholeStage twoLevelMap=$twoLevelMap") {
           val c = runAndReadCounters(df)
           assert(c.tasksFallBacked > 0,
             "the low-cardinality prefix should still spill and fall back to 
sort")
-          assert(c.passedThrough > 0,
-            "the high-cardinality tail after the spill should be passed 
through")
+          val firstPassedThrough = if (wholeStage && twoLevelMap) 92 else 88

Review Comment:
   **[3] In the two-level codegen cell, the expected 92 depends on a window of 
exactly 2.0.**
   
   In that cell the second epoch starts at id 44, and its periodic checks run 
at 32 rows (id 75, where the epoch has one key, 4) and at 40 rows (id 83, five 
keys: 4 and 80-83). When id 84 finds the map full, the window is 8 rows over 4 
keys, exactly 2.0, and the task keeps aggregating only because `8 < 4 * 2.0` is 
false. Over the whole epoch it would see 40 rows over 5 keys, so the tie comes 
from the window.
   
   With a default of 2.1, or `<=` in the comparison, this cell passes through 
at id 84 and the count becomes 316 instead of 308, which the comment at 
L1305-1313 does not prepare a reader for. The other cells see 8 rows over 0 
keys at id 80 and do not depend on the threshold. Could the test pin 
`minCompactionToSpill` below 2.0, e.g. 1.5, which keeps all four counts, or 
mention the tie in the comment?



-- 
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