This is an automated email from the ASF dual-hosted git repository.
cloud-fan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new 0479b5ea56fd [SPARK-58511][SQL][FOLLOWUP] Rename numBypassingRows to
numPassthroughRows and never time pass-through rows
0479b5ea56fd is described below
commit 0479b5ea56fd2df691607780213faad9a5b2cbdd
Author: Wenchen Fan <[email protected]>
AuthorDate: Tue Oct 27 03:29:41 2026 +0000
[SPARK-58511][SQL][FOLLOWUP] Rename numBypassingRows to numPassthroughRows
and never time pass-through rows
### What changes were proposed in this pull request?
This is a followup of https://github.com/apache/spark/pull/57742.
- Renames the `HashAggregateExec` SQL metric `numBypassingRows` ("number of
bypassing rows") to `numPassthroughRows` ("number of pass-through output
rows"). It counts the rows the operator passes through as single-row partial
buffers, which the docs and the code call pass-through.
- Makes "time in aggregation build" (`aggTime`) mean the same thing on
every path once adaptive partial aggregation is on: the build up to the point
where pass-through starts, plus the map setup, which in a task that has spilled
writes the map to disk and merges it with the spilled data. It never includes
the pass-through rows.
- Pass-through rows are no longer timed. This removes the codegen timer
around each resumed build, which cost two `System.nanoTime()` calls per row,
comparable to the work of passing a row through, on the path this feature
exists to make cheap. It also reverts the drain timer that dd0f15465e9 added to
the interpreted path in #57742: `next()` is pulled lazily, so timing from the
first to the last passed-through row also counted the downstream work between
the calls, such as the shuff [...]
- On the codegen path, the build timer stops right after the map setup,
which runs once, before the aggregate outputs anything: for the first
passed-through row, or right after the build if no row is passed through. With
the feature on, the setup had moved outside every timer whenever pass-through
never started, and when the Partial and Final aggregates fuse, the timed build
went on to time every pass-through row and the Final's work on it.
Each task still makes two `System.nanoTime()` calls.
- Documents in `docs/web-ui.md` and `sql-performance-tuning.md` that
pass-through rows are counted but not timed, and links the adaptive partial
aggregation section from the metrics table.
An earlier revision of this PR also added a separate compaction threshold
for the evaluation before a spill. That change is reverted here and moves to
its own JIRA.
### Why are the changes needed?
Adaptive partial aggregation passes rows through because that is cheaper
than aggregating them, so timing every passed-through row costs time on exactly
the path the feature targets. Without the timers, `aggTime` also needs one
definition, because it was counting different work depending on the plan shape
and the execution path.
### Does this PR introduce _any_ user-facing change?
No. Adaptive partial aggregation is new in the unreleased 4.4.0 and
disabled by default. When it is enabled, the SQL UI shows the metric as "number
of pass-through output rows", and "time in aggregation build" covers the
aggregation up to the point where pass-through starts, plus the map setup.
### How was this patch tested?
- A new test checks the generated code of an adaptive aggregate separated
from its Final by an exchange: it calls `System.nanoTime()` only for the build
timer, whose stop is in the map setup, so a per-row timer cannot come back
unnoticed.
- `AdaptivePartialAggregationSuite` reads the renamed metric through one
helper.
`build/sbt "sql/testOnly *AdaptivePartialAggregationSuite"` passes.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 5.5)
This pull request and its description were written by Isaac.
Closes #59764 from cloud-fan/wenchen/spark-58511-passthrough-metric.
Authored-by: Wenchen Fan <[email protected]>
Signed-off-by: Wenchen Fan <[email protected]>
---
docs/sql-performance-tuning.md | 5 +-
docs/web-ui.md | 3 +-
.../execution/aggregate/HashAggregateExec.scala | 68 +++++---
.../aggregate/TungstenAggregationIterator.scala | 29 +---
.../AdaptivePartialAggregationSuite.scala | 187 +++++++++++++--------
5 files changed, 175 insertions(+), 117 deletions(-)
diff --git a/docs/sql-performance-tuning.md b/docs/sql-performance-tuning.md
index 62ea051a8aed..f38522110fd6 100644
--- a/docs/sql-performance-tuning.md
+++ b/docs/sql-performance-tuning.md
@@ -202,7 +202,10 @@ merges after that key's accumulated rows, keeping an
order-sensitive aggregate s
again right before the aggregation map would spill, in which case the spill is
skipped entirely.
Because pass-through cannot be reversed, the evaluation before a spill also
waits until the current
map has processed `minRows` rows, so a map that fills sooner spills as usual
instead of committing
-the task on a small sample.
+the task on a small sample. The SQL UI counts the passed-through rows in
+`number of pass-through output rows`. They are not timed: `time in aggregation
build` covers the
+aggregation up to the point where pass-through starts, and preparing the
aggregated map for output,
+which in a task that has spilled also writes the map to disk and merges it
with the spilled data.
Both evaluations use the cumulative rows and keys of the current map epoch
and, once triggered,
pass-through is not reversed for the rest of the task, so a skewed prefix
biases the outcome in
diff --git a/docs/web-ui.md b/docs/web-ui.md
index 7521fc3f03bc..c311d50daa63 100644
--- a/docs/web-ui.md
+++ b/docs/web-ui.md
@@ -379,7 +379,8 @@ Here is the list of SQL metrics:
<tr><td> <code>sort time</code> </td><td> the time spent on sorting </td><td>
Sort </td></tr>
<tr><td> <code>peak memory</code> </td><td> the peak memory usage in the
operator </td><td> Sort, HashAggregate </td></tr>
<tr><td> <code>spill size</code> </td><td> number of bytes spilled to disk
from memory in the operator </td><td> Sort, HashAggregate </td></tr>
-<tr><td> <code>time in aggregation build</code> </td><td> the time spent on
aggregation </td><td> HashAggregate, ObjectHashAggregate </td></tr>
+<tr><td> <code>time in aggregation build</code> </td><td> the time spent on
aggregation. For a HashAggregate that passes rows through (see <a
href="sql-performance-tuning.html#adaptive-partial-aggregation">adaptive
partial aggregation</a>), it excludes the passed-through rows </td><td>
HashAggregate, ObjectHashAggregate </td></tr>
+<tr><td> <code>number of pass-through output rows</code> </td><td> the number
of rows that <a
href="sql-performance-tuning.html#adaptive-partial-aggregation">adaptive
partial aggregation</a> passed through to the final aggregation without
aggregating them </td><td> HashAggregate </td></tr>
<tr><td> <code>avg hash probe bucket list iters</code> </td><td> the average
bucket list iterations per lookup during aggregation </td><td> HashAggregate
</td></tr>
<tr><td> <code>data size of build side</code> </td><td> the size of built hash
map </td><td> ShuffledHashJoin </td></tr>
<tr><td> <code>time to build hash map</code> </td><td> the time spent on
building hash map </td><td> ShuffledHashJoin </td></tr>
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/HashAggregateExec.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/HashAggregateExec.scala
index f64cec4bf244..ffb6773bfab8 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/HashAggregateExec.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/HashAggregateExec.scala
@@ -76,10 +76,11 @@ case class HashAggregateExec(
SQLMetrics.createAverageMetric(sparkContext, "avg hash probes per key"),
"numTasksFallBacked" -> SQLMetrics.createMetric(sparkContext, "number of
sort fallback tasks")
) ++ {
- // Only the aggregates that can actually bypass report this, so the rest
do not show a
- // constant 0 in the SQL UI (see `UnionExec.metrics` for the same
approach).
+ // Only the aggregates that can actually pass rows through report this, so
the rest do not
+ // show a constant 0 in the SQL UI (see `UnionExec.metrics` for the same
approach).
if (adaptivePartialAggEnabled) {
- Map("numBypassingRows" -> SQLMetrics.createMetric(sparkContext, "number
of bypassing rows"))
+ Map("numPassthroughRows" ->
+ SQLMetrics.createMetric(sparkContext, "number of pass-through output
rows"))
} else {
Map.empty[String, SQLMetric]
}
@@ -106,7 +107,8 @@ case class HashAggregateExec(
val aggTime = longMetric("aggTime")
val numTasksFallBacked = longMetric("numTasksFallBacked")
// Registered only when the feature applies, and only read from the
pass-through path.
- val numBypassingRows = if (adaptivePartialAggEnabled)
longMetric("numBypassingRows") else null
+ val numPassthroughRows =
+ if (adaptivePartialAggEnabled) longMetric("numPassthroughRows") else null
child.execute().mapPartitionsWithIndex { (partIndex, iter) =>
@@ -135,8 +137,7 @@ case class HashAggregateExec(
spillSize,
avgHashProbe,
numTasksFallBacked,
- numBypassingRows,
- aggTime,
+ numPassthroughRows,
adaptivePartialAggEnabled,
adaptiveMinRows,
adaptiveMinCompaction)
@@ -777,6 +778,20 @@ case class HashAggregateExec(
""".stripMargin
}
+ // With adaptive partial aggregation, `aggTime` times the build up to the
point where
+ // pass-through starts, plus the map iterator setup below (which in a task
that has spilled also
+ // writes the map to disk and merges it with the spilled data), but never
the pass-through rows:
+ // timing them would cost two `System.nanoTime()` calls per row,
comparable to passing a row
+ // through. The timer starts before the build and stops right after the
setup, which runs once,
+ // before the aggregate outputs anything: for the first passed-through
row, or right after the
+ // build if no row is passed through. Fused with the Final, the build then
goes on to pass the
+ // rest of the input through untimed.
+ val adaptiveBuildStartTerm = if (adaptivePartialAggEnabled) {
+ ctx.addMutableState(CodeGenerator.JAVA_LONG, "adaptiveBuildStart")
+ } else {
+ ""
+ }
+
// With adaptive partial aggregation the maps are frozen once pass-through
is active, so their
// output (which also frees them) can happen as soon as pass-through
fires, releasing the memory
// before the remaining input is streamed. The output loops are wrapped in
a function so the
@@ -784,21 +799,23 @@ case class HashAggregateExec(
// The done flag is set inside, after the loops, so a mid-output drain
(the loops return via
// `shouldStop()`) leaves it unset and the caller resumes the map iterator
on re-entry; once it
// is set the maps have been fully output and freed and will not be
touched again. The iterator
- // setup (`finishHashMap`, which destructs the map) is guarded to run only
once. The queue of
- // passed-through rows held behind the maps is flushed here, right after
the done flag is set:
- // the maps must precede the held rows so the downstream Final merges a
group's map buffer
- // before its pass-through buffers (a group can straddle the freeze, since
the maps only freeze
- // once pass-through fires), and the copies were made when the row was
queued, so emitting them
- // here cannot corrupt the build's reusable rows.
+ // setup (`finishHashMap`, which destructs the map) is guarded to run only
once, and stops the
+ // build timer (see `aggTime` above). The queue of passed-through rows
held behind the maps is
+ // flushed here, right after the done flag is set: the maps must precede
the held rows so the
+ // downstream Final merges a group's map buffer before its pass-through
buffers (a group can
+ // straddle the freeze, since the maps only freeze once pass-through
fires), and the copies were
+ // made when the row was queued, so emitting them here cannot corrupt the
build's reusable rows.
adaptiveOutputMapAndFlushFuncName = if (adaptivePartialAggEnabled) {
val name = ctx.freshName("outputMapAndFlush")
val pair = ctx.freshName("pendingRow")
+ val aggTime = metricTerm(ctx, "aggTime")
ctx.addNewFunction(name,
s"""
|private void $name() throws java.io.IOException {
| if (!$adaptiveMapSetupDoneTerm) {
| $finishHashMap
| $adaptiveMapSetupDoneTerm = true;
+ | $aggTime.add((System.nanoTime() - $adaptiveBuildStartTerm) /
$NANOS_PER_MILLIS);
| }
| $outputFromFastHashMap
| $outputFromRegularHashMap
@@ -827,8 +844,16 @@ case class HashAggregateExec(
outputFunc = generateResultFunction(ctx)
}
- val aggTime = metricTerm(ctx, "aggTime")
- val beforeAgg = ctx.freshName("beforeAgg")
+ // The build timer. With adaptive partial aggregation the map setup stops
it (see `aggTime`
+ // above), so its start is kept in a field.
+ val (startBuildTimer, stopBuildTimer) = if (adaptivePartialAggEnabled) {
+ (s"$adaptiveBuildStartTerm = System.nanoTime();", "")
+ } else {
+ val aggTime = metricTerm(ctx, "aggTime")
+ val beforeAgg = ctx.freshName("beforeAgg")
+ (s"long $beforeAgg = System.nanoTime();",
+ s"$aggTime.add((System.nanoTime() - $beforeAgg) / $NANOS_PER_MILLIS);")
+ }
// Split by an exchange, `doAgg` may start appending pass-through rows to
the output buffer
// mid-build. In that case `shouldStop()` becomes true and we must return
so the buffered rows
// are drained; on re-entry the frozen maps are output first
(`adaptiveOutputMapAndFlush`) and
@@ -858,14 +883,15 @@ case class HashAggregateExec(
} else {
""
}
+ // The resumed build only passes rows through, so it is not timed (see
`aggTime` above). Split
+ // by an exchange, a child that checks `shouldStop()` between rows returns
and re-enters here
+ // after every passed-through row, so a timer would cost two
`System.nanoTime()` calls per row;
+ // a one-to-many child such as `GenerateExec` re-enters once per input row
instead.
val adaptiveResumeBuild = if (adaptivePartialAggEnabled) {
- val beforeResumedAgg = ctx.freshName("beforeResumedAgg")
s"""
|if (!$adaptiveChildrenConsumedTerm) {
| $adaptiveOutputMapAndFlush
- | long $beforeResumedAgg = System.nanoTime();
| $doAggFuncName(partitionIndex);
- | $aggTime.add((System.nanoTime() - $beforeResumedAgg) /
$NANOS_PER_MILLIS);
| if (shouldStop()) return;
|}
""".stripMargin
@@ -886,9 +912,9 @@ case class HashAggregateExec(
| $createFastHashMap
| $addHookToCloseFastHashMap
| $hashMapTerm = $thisPlan.createHashMap();
- | long $beforeAgg = System.nanoTime();
+ | $startBuildTimer
| $doAggFuncName(partitionIndex);
- | $aggTime.add((System.nanoTime() - $beforeAgg) / $NANOS_PER_MILLIS);
+ | $stopBuildTimer
| $adaptiveStopCheck
|}
|$adaptiveResumeBuild
@@ -1324,10 +1350,10 @@ case class HashAggregateExec(
// buffered, `shouldStop()` never fires, and the single output call drains
the whole map before
// the held row follows.
val handlePassThroughRow = if (adaptivePartialAggEnabled) {
- val numBypassingRows = metricTerm(ctx, "numBypassingRows")
+ val numPassthroughRows = metricTerm(ctx, "numPassthroughRows")
s"""
|if ($adaptiveRowBypassedTerm) {
- | $numBypassingRows.add(1);
+ | $numPassthroughRows.add(1);
| if ($adaptiveMapOutputDoneTerm) {
| $outputFunc(${unsafeRowKeyCode.value}, $unsafeRowBuffer);
| } else {
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/TungstenAggregationIterator.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/TungstenAggregationIterator.scala
index 69c0d892eb39..c5f538841505 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/TungstenAggregationIterator.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/TungstenAggregationIterator.scala
@@ -17,8 +17,6 @@
package org.apache.spark.sql.execution.aggregate
-import java.util.concurrent.TimeUnit.NANOSECONDS
-
import org.apache.spark.{SparkException, TaskContext}
import org.apache.spark.internal.Logging
import org.apache.spark.sql.catalyst.InternalRow
@@ -99,8 +97,7 @@ class TungstenAggregationIterator(
spillSize: SQLMetric,
avgHashProbe: SQLMetric,
numTasksFallBacked: SQLMetric,
- numBypassingRows: SQLMetric,
- aggTime: SQLMetric,
+ numPassthroughRows: SQLMetric,
adaptivePartialAggEnabled: Boolean,
adaptiveMinRows: Long,
adaptiveMinCompaction: Double)
@@ -443,13 +440,6 @@ class TungstenAggregationIterator(
private def passThroughHasNext: Boolean =
passThrough && (pendingPassThroughRow != null || inputIter.hasNext)
- // `aggTime` only covers the build, which runs to completion in the
constructor before
- // pass-through is known; the interpreted path consumes the rest of the
input lazily through
- // `next()`. The drain is therefore timed there, bracketed once per drain
(not per row) so the
- // timing call does not distort the per-row work, mirroring the generated
path which times the
- // drain around each resume of the build.
- private var passThroughDrainStart: Long = -1L
-
// 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.
@@ -465,7 +455,7 @@ class TungstenAggregationIterator(
// Reset the buffer to initial values, then update it with this single row.
passThroughAggregationBuffer.copyFrom(initialAggregationBuffer)
processRow(passThroughAggregationBuffer, row)
- numBypassingRows += 1
+ numPassthroughRows += 1
generateOutput(groupingKey, passThroughAggregationBuffer)
}
@@ -551,16 +541,11 @@ class TungstenAggregationIterator(
}
} else {
// Adaptive partial aggregation bypassed partial aggregation: emit the
remaining input
- // rows as single-row partial buffers, after the frozen map output
above.
- if (passThroughDrainStart == -1L) {
- passThroughDrainStart = System.nanoTime()
- }
- val out = nextPassThroughOutput()
- if (!passThroughHasNext) {
- aggTime += NANOSECONDS.toMillis(System.nanoTime() -
passThroughDrainStart)
- passThroughDrainStart = -1L
- }
- out
+ // rows as single-row partial buffers, after the frozen map output
above. The drain is
+ // not timed: timing each row would cost two `System.nanoTime()` calls
per row, comparable
+ // to passing it through, and `next()` is pulled lazily, so timing
from the first to the
+ // last pass-through row would also count the downstream work between
the calls.
+ nextPassThroughOutput()
}
numOutputRows += 1
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/aggregate/AdaptivePartialAggregationSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/aggregate/AdaptivePartialAggregationSuite.scala
index 83665ec51c52..c4240ca308cf 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/aggregate/AdaptivePartialAggregationSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/aggregate/AdaptivePartialAggregationSuite.scala
@@ -41,7 +41,7 @@ import org.apache.spark.tags.ExtendedSQLTest
* aggregate shapes, key types, and `Expand`-bearing plans (ROLLUP / CUBE
/ GROUPING SETS /
* multi-distinct). Order-sensitive aggregates are tested against the
reference too, including
* under a fan-out child that queues its whole batch behind the frozen
map.
- * 2. Triggering: the `numBypassingRows` metric proves the bypass actually
fires when (and only
+ * 2. Triggering: the `numPassthroughRows` metric proves the bypass actually
fires when (and only
* when) it should -- high-cardinality input bypasses, low-cardinality
input keeps aggregating,
* the feature switch and eligibility rules are honored, and both check
points work.
* 3. Generated code: the pass-through path keeps the code layout that
bounds its size.
@@ -75,10 +75,23 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
// ones in `Partial`) that the feature now bypasses. It is a superset of the
phases the feature
// actually applies to: a pure `PartialMerge` de-duplication phase (excluded
by its required
// distribution) and an ineligible group-by-only `Final` (vacuously
all-`Partial`) also match,
- // but neither registers a `numBypassingRows` metric, so they add 0 to the
bypass count below.
+ // but neither registers a `numPassthroughRows` metric, so they add 0 to the
bypass count below.
private def isPartialPhase(agg: HashAggregateExec): Boolean =
agg.aggregateExpressions.forall(a => a.mode == Partial || a.mode ==
PartialMerge)
+ private val passThroughRowsMetric = "numPassthroughRows"
+
+ // The rows `agg` passed through. The metric is only registered on
aggregates the feature applies
+ // to; an aggregate without it passed nothing through.
+ private def passThroughRows(agg: HashAggregateExec): Long =
+ agg.metrics.get(passThroughRowsMetric).map(_.value).getOrElse(0L)
+
+ // The rows the partial phases of an already-collected `df` passed through.
+ private def passThroughRows(df: DataFrame): Long =
+ collect(df.queryExecution.executedPlan) {
+ case agg: HashAggregateExec if isPartialPhase(agg) =>
passThroughRows(agg)
+ }.sum
+
/**
* Runs `build` with adaptive partial aggregation disabled (the reference)
and then across the
* full configuration matrix with it enabled, asserting every enabled run
matches the reference.
@@ -137,16 +150,12 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
// operators the assertion reads.
val df = build(inputPartitions)
df.collect()
- val skipped = collect(df.queryExecution.executedPlan) {
- case agg: HashAggregateExec if isPartialPhase(agg) =>
- agg.metrics.get("numBypassingRows").map(_.value).getOrElse(0L)
- }.sum
+ val passedThrough = passThroughRows(df)
+ val msg = s"got $passedThrough passed-through rows"
if (expectBypass) {
- assert(skipped > 0,
- s"expected rows to bypass partial aggregation, got $skipped
bypassed rows")
+ assert(passedThrough > 0, s"expected rows to bypass partial
aggregation, $msg")
} else {
- assert(skipped == 0,
- s"expected no rows to bypass partial aggregation, got $skipped
bypassed rows")
+ assert(passedThrough == 0, s"expected no rows to bypass partial
aggregation, $msg")
}
checkAnswer(df, reference)
}
@@ -157,7 +166,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
/**
* The observable per-run counters we assert on, all read from the partial
`HashAggregateExec` in
* a single execution so the metrics are not double-counted:
- * - `skipped`: our self-reported `numBypassingRows` metric.
+ * - `passedThrough`: our self-reported `numPassthroughRows` metric.
* - `partialOutputRows`: the partial aggregate's own `numOutputRows`. An
independent,
* pre-existing counter driven by the normal output path, so it is the
ground truth for
* whether rows were streamed through -- it equals the distinct key
count when aggregation is
@@ -173,7 +182,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
* evidence the bypass replaced the sort fallback.
*/
private case class AggCounters(
- skipped: Long,
+ passedThrough: Long,
partialOutputRows: Long,
spillBytes: Long,
tasksFallBacked: Long)
@@ -200,9 +209,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
// A partial aggregate is always present for the grouped queries these
tests use.
assert(partialAggs.nonEmpty, "expected a partial HashAggregateExec in the
plan")
val counters = AggCounters(
- // The metric is only registered on aggregates the feature applies to;
an aggregate without
- // it bypassed nothing.
- skipped =
partialAggs.map(_.metrics.get("numBypassingRows").map(_.value).getOrElse(0L)).sum,
+ passedThrough = partialAggs.map(passThroughRows).sum,
partialOutputRows =
partialAggs.map(_.metrics("numOutputRows").value).sum,
spillBytes = partialAggs.map(_.metrics("spillSize").value).sum,
tasksFallBacked =
partialAggs.map(_.metrics("numTasksFallBacked").value).sum)
@@ -210,20 +217,21 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
counters
}
- private def numBypassingRows(build: () => DataFrame): Long =
runAndReadCounters(build).skipped
+ private def numPassthroughRows(build: () => DataFrame): Long =
+ runAndReadCounters(build).passedThrough
- // Returns the bypassed-row count per partial `HashAggregateExec` phase,
keyed by the number of
- // grouping keys, and verifies the run matches the feature-off reference. A
`count(DISTINCT ...)`
- // group-by has two such phases -- the de-duplication partial (grouping on
key + distinct
- // columns) and the distinct partial (grouping on the keys only, whose
non-distinct aggregates run
- // in `PartialMerge`) -- so their bypasses can be told apart by the grouping
key count.
- private def bypassRowsByGroupingKeyCount(build: () => DataFrame): Map[Int,
Long] = {
+ // Returns the pass-through row count per partial `HashAggregateExec` phase,
keyed by the number
+ // of grouping keys, and verifies the run matches the feature-off reference.
A
+ // `count(DISTINCT ...)` group-by has two such phases -- the de-duplication
partial (grouping on
+ // key + distinct columns) and the distinct partial (grouping on the keys
only, whose non-distinct
+ // aggregates run in `PartialMerge`) -- so their bypasses can be told apart
by the grouping key
+ // count.
+ private def passThroughRowsByGroupingKeyCount(build: () => DataFrame):
Map[Int, Long] = {
val df = build()
df.collect()
val byKeyCount = collect(df.queryExecution.executedPlan) {
case agg: HashAggregateExec if isPartialPhase(agg) =>
- agg.groupingExpressions.length ->
- agg.metrics.get("numBypassingRows").map(_.value).getOrElse(0L)
+ agg.groupingExpressions.length -> passThroughRows(agg)
}.groupBy(_._1).map { case (n, pairs) => n -> pairs.map(_._2).sum }
// The pure `PartialMerge` de-duplication phase (grouping on key +
distinct columns) is the one
// phase the `exists(_.mode == Partial)` guard exists to exclude, and only
when it carries
@@ -236,7 +244,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
case agg: HashAggregateExec if agg.aggregateExpressions.nonEmpty &&
agg.aggregateExpressions.forall(_.mode == PartialMerge) => agg
}
- assert(dedupPhases.forall(!_.metrics.contains("numBypassingRows")),
+ assert(dedupPhases.forall(!_.metrics.contains(passThroughRowsMetric)),
"the pure-PartialMerge de-duplication phase must stay ineligible")
checkAgainstReference(df, build)
byKeyCount
@@ -357,7 +365,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
.groupBy($"k")
.agg(sum($"v") as "s", count(lit(1)) as "c")
withClue(clue) {
- assert(numBypassingRows(df) > 0,
+ assert(numPassthroughRows(df) > 0,
"expected the equivalent spelling after the cutoff to bypass partial
aggregation")
}
}
@@ -502,12 +510,10 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
SQLConf.ENABLE_TWOLEVEL_AGG_MAP.key -> "false") ++ fixedPlanConfs):
_*) {
val df = query()
df.collect()
- val skipped = collect(df.queryExecution.executedPlan) {
- case agg: HashAggregateExec if isPartialPhase(agg) =>
- agg.metrics.get("numBypassingRows").map(_.value).getOrElse(0L)
- }.sum
- assert(skipped > 0,
- s"expected the large frozen map to eventually bypass, got $skipped
bypassed rows")
+ val passedThrough = passThroughRows(df)
+ assert(passedThrough > 0,
+ s"expected the large frozen map to eventually bypass, got
$passedThrough passed-through " +
+ "rows")
val reference = withSQLConf(
SQLConf.ADAPTIVE_PARTIAL_AGGREGATION_ENABLED.key -> "false") {
query().collect().toSeq
@@ -547,7 +553,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
| avg(v) FILTER (WHERE v > 25) AS a_gt25
|FROM t GROUP BY k""".stripMargin)
withClue(clue) {
- val byKeyCount = bypassRowsByGroupingKeyCount(df)
+ val byKeyCount = passThroughRowsByGroupingKeyCount(df)
assert(byKeyCount.get(2).exists(_ > 0),
s"expected the de-duplication partial (grouping on k, v) to
bypass, got $byKeyCount")
assert(byKeyCount.get(1).exists(_ > 0),
@@ -574,7 +580,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
.agg(countDistinct($"v") as "cd", sum($"w") as "s",
first($"w") as "f", last($"w") as "l")
withClue(s"$clue parts=$parts") {
- val byKeyCount = bypassRowsByGroupingKeyCount(df)
+ val byKeyCount = passThroughRowsByGroupingKeyCount(df)
assert(byKeyCount.get(2).exists(_ > 0),
s"expected the de-duplication partial (grouping on k, v) to
bypass, got $byKeyCount")
assert(byKeyCount.get(1).exists(_ > 0),
@@ -598,7 +604,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
.groupBy($"k")
.agg(approx_count_distinct($"x") as "acd", countDistinct($"v") as "cd")
withClue(clue) {
- val byKeyCount = bypassRowsByGroupingKeyCount(df)
+ val byKeyCount = passThroughRowsByGroupingKeyCount(df)
assert(byKeyCount.get(2).exists(_ > 0),
s"expected the de-duplication partial (grouping on k, v) to bypass,
got $byKeyCount")
assert(byKeyCount.get(1).exists(_ > 0),
@@ -618,7 +624,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
.groupBy($"k")
.agg(countDistinct($"v") as "cd")
withClue(clue) {
- assert(numBypassingRows(df) > 0,
+ assert(numPassthroughRows(df) > 0,
"expected a distinct partial aggregation to bypass for
high-cardinality input")
}
}
@@ -634,7 +640,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
.groupBy($"k")
.agg(countDistinct($"v") as "cd")
withClue(clue) {
- val byKeyCount = bypassRowsByGroupingKeyCount(df)
+ val byKeyCount = passThroughRowsByGroupingKeyCount(df)
assert(byKeyCount.get(2).exists(_ > 0),
s"expected the (k, v) de-duplication partial to bypass, got
$byKeyCount")
}
@@ -651,7 +657,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
.groupBy($"k")
.agg(countDistinct($"v") as "cd")
withClue(clue) {
- val byKeyCount = bypassRowsByGroupingKeyCount(df)
+ val byKeyCount = passThroughRowsByGroupingKeyCount(df)
assert(byKeyCount.get(1).exists(_ > 0),
s"expected the distinct partial (grouping on k) to bypass, got
$byKeyCount")
}
@@ -668,7 +674,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
.groupBy($"k")
.agg(countDistinct($"v") as "cd")
withClue(clue) {
- val byKeyCount = bypassRowsByGroupingKeyCount(df)
+ val byKeyCount = passThroughRowsByGroupingKeyCount(df)
assert(byKeyCount.get(2).exists(_ > 0),
s"expected the (k, v) de-duplication partial to bypass, got
$byKeyCount")
assert(byKeyCount.get(1).exists(_ > 0),
@@ -951,7 +957,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
.groupBy($"k")
.agg(sum($"v") as "s")
withClue(clue) {
- assert(numBypassingRows(highCard) > 0,
+ assert(numPassthroughRows(highCard) > 0,
"expected some rows to bypass partial aggregation for
high-cardinality input")
}
// Few distinct keys, high reduction: partial aggregation is effective,
nothing bypasses.
@@ -960,7 +966,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
.groupBy($"k")
.agg(sum($"v") as "s")
withClue(clue) {
- assert(numBypassingRows(lowCard) == 0,
+ assert(numPassthroughRows(lowCard) == 0,
"expected no rows to bypass partial aggregation for low-cardinality
input")
}
}
@@ -970,7 +976,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
forEachCodegenAndMap() { clue =>
val distinctKeys = () => spark.range(0, 200, 1, 1).select($"id" as
"k").distinct()
withClue(clue) {
- assert(numBypassingRows(distinctKeys) > 0,
+ assert(numPassthroughRows(distinctKeys) > 0,
"expected group-by-only rows to bypass partial aggregation for
high-cardinality input")
}
}
@@ -989,7 +995,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
"""SELECT k, sum(v) FILTER (WHERE v % 2 = 0) AS s
|FROM t GROUP BY k""".stripMargin)
withClue(clue) {
- assert(numBypassingRows(df) > 0,
+ assert(numPassthroughRows(df) > 0,
"expected filtered-aggregate rows to bypass partial aggregation
for high-cardinality " +
"input")
}
@@ -1013,7 +1019,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
|FROM t
|GROUP BY k1, k2 GROUPING SETS ((k1), (k2))""".stripMargin)
withClue(clue) {
- assert(numBypassingRows(gs) > 0,
+ assert(numPassthroughRows(gs) > 0,
"expected rows below an Expand to bypass partial aggregation for
high-cardinality " +
"input")
}
@@ -1038,7 +1044,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
.groupBy($"k")
.agg(sum($"v") as "s")
withClue(s"wholeStage=$wholeStage twoLevelMap=$twoLevelMap") {
- assert(numBypassingRows(df) == 0,
+ assert(numPassthroughRows(df) == 0,
"no rows should bypass partial aggregation when the feature is
disabled")
}
}
@@ -1053,7 +1059,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
val df = () => spark.range(0, 200, 1, 1)
.agg(sum($"id") as "s", count(lit(1)) as "c")
withClue(clue) {
- assert(numBypassingRows(df) == 0,
+ assert(numPassthroughRows(df) == 0,
"a global aggregation is not eligible and must never bypass")
}
}
@@ -1091,7 +1097,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
.groupBy($"k")
.agg(sum($"v") as "s")
withClue(clue) {
- assert(numBypassingRows(df) == 0,
+ assert(numPassthroughRows(df) == 0,
"an aggregation collapsing ~17 rows per key must not bypass;
fast-map hits are " +
"missing from the numerator if it does")
}
@@ -1113,8 +1119,8 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
.agg(sum($"v") as "s")
withClue(clue) {
val c = runAndReadCounters(df)
- assert(c.skipped == 32,
- s"expected the 32 rows after the flip to bypass, got ${c.skipped}")
+ assert(c.passedThrough == 32,
+ s"expected the 32 rows after the flip to bypass, got
${c.passedThrough}")
assert(c.partialOutputRows == 40,
s"expected every input row to stream through the partial, got
${c.partialOutputRows}")
}
@@ -1132,7 +1138,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
.agg(sum($"v") as "s")
withClue(clue) {
val c = runAndReadCounters(df)
- assert(c.skipped > 0,
+ assert(c.passedThrough > 0,
"the periodic check should bypass fully distinct input")
assert(c.spillBytes == 0, "the periodic check must decide before any
spill happens")
assert(c.tasksFallBacked == 0,
@@ -1155,7 +1161,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
.agg(sum($"v") as "s")
withClue(clue) {
val c = runAndReadCounters(df)
- assert(c.skipped == 0,
+ assert(c.passedThrough == 0,
"the spill check must not bypass an epoch smaller than minRows")
assert(c.tasksFallBacked > 0,
"an epoch below the minRows floor must spill and fall back to sort")
@@ -1187,7 +1193,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
.agg(sum($"v") as "s")
withClue(s"wholeStage=$wholeStage") {
val c = runAndReadCounters(df)
- assert(c.skipped == 400 - 19,
+ assert(c.passedThrough == 400 - 19,
"the spill check should bypass from the row that found the map
full")
// The spill check bypasses *instead of* falling back to sort-based
aggregation, so the
// sorter is never created. `numTasksFallBacked` is the reliable
per-operator signal for
@@ -1228,7 +1234,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
val c = runAndReadCounters(df)
assert(c.tasksFallBacked > 0,
"the low-cardinality prefix should still spill and fall back to
sort")
- assert(c.skipped > 0,
+ assert(c.passedThrough > 0,
"the high-cardinality tail after the spill should be passed
through")
}
}
@@ -1259,7 +1265,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
.agg(sum($"v") as "s")
withClue(s"wholeStage=$wholeStage twoLevelMap=$twoLevelMap") {
val c = runAndReadCounters(df)
- assert(c.skipped == 0, "feature disabled: nothing should bypass")
+ assert(c.passedThrough == 0, "feature disabled: nothing should
bypass")
assert(c.tasksFallBacked > 0,
"feature disabled: the forced fallback should trigger sort-based
aggregation")
}
@@ -1274,7 +1280,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
// 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) =>
- val skippedPerCodegen = Seq(true, false).map { wholeStage =>
+ val passedThroughPerCodegen = Seq(true, false).map { wholeStage =>
withSQLConf(
(Seq(
SQLConf.ADAPTIVE_PARTIAL_AGGREGATION_ENABLED.key -> "true",
@@ -1291,14 +1297,14 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
.groupBy($"k")
.agg(sum($"v") as "s")
withClue(s"minCompaction=$minCompaction wholeStage=$wholeStage") {
- numBypassingRows(df)
+ numPassthroughRows(df)
}
}
}
- withClue(s"minCompaction=$minCompaction skipped=$skippedPerCodegen") {
- assert(skippedPerCodegen.forall(_ > 0) == shouldBypass,
+ withClue(s"minCompaction=$minCompaction
passedThrough=$passedThroughPerCodegen") {
+ assert(passedThroughPerCodegen.forall(_ > 0) == shouldBypass,
s"expected bypass=$shouldBypass at the ratio boundary")
- assert(skippedPerCodegen.map(_ > 0).distinct.length == 1,
+ assert(passedThroughPerCodegen.map(_ > 0).distinct.length == 1,
"codegen and interpreted paths must reach the same decision at the
boundary")
}
}
@@ -1314,7 +1320,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
.groupBy($"k")
.agg(sum($"v") as "s")
withClue(clue) {
- assert(numBypassingRows(df) > 0,
+ assert(numPassthroughRows(df) > 0,
"an unreachable compaction ratio must bypass even a low-cardinality
input")
}
}
@@ -1331,7 +1337,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
.groupBy($"k")
.agg(sum($"v") as "s")
withClue(clue) {
- assert(numBypassingRows(df) == 0,
+ assert(numPassthroughRows(df) == 0,
"minRows = 0 must disable the periodic check, so nothing may bypass
without a spill")
}
}
@@ -1347,7 +1353,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
.agg(sum($"v") as "s")
withClue(clue) {
val c = runAndReadCounters(df)
- assert(c.skipped > 0,
+ assert(c.passedThrough > 0,
"the spill check must still bypass when the periodic check is
disabled")
assert(c.tasksFallBacked == 0,
"the spill check must replace the sort fallback, not trigger it")
@@ -1377,16 +1383,52 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
.groupBy($"k")
.agg(sum($"v") as "s")
withClue(clue) {
- assert(numBypassingRows(df) == 0,
+ assert(numPassthroughRows(df) == 0,
"no bypass expected before `minRows` rows have been processed")
}
}
}
- test("partial aggregate output row count reflects the bypass (independent of
the skip metric)") {
+ test("a split adaptive aggregate times only its build, never the
pass-through rows") {
+ // Split by an exchange, a passed-through row returns from the build and
re-enters it, so a
+ // timer around the resumed build or around each passed-through row would
cost two
+ // `System.nanoTime()` calls per row. The generated code of the partial
aggregate's stage, whose
+ // other operators take no time, must hold only the build timer: its
start, and its stop in the
+ // map setup of the output function, which runs once, before the aggregate
outputs anything.
+ withSQLConf(
+ (Seq(
+ SQLConf.ADAPTIVE_PARTIAL_AGGREGATION_ENABLED.key -> "true",
+ SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> "true",
+ SQLConf.ADAPTIVE_PARTIAL_AGGREGATION_MIN_ROWS.key -> "8") ++
fixedPlanConfs): _*) {
+ val df = spark.range(0, 400, 1, 2)
+ .select($"id".cast("string") as "k", $"id" as "v")
+ .groupBy($"k")
+ .agg(sum($"v") as "s")
+ df.collect()
+ assert(passThroughRows(df) > 0, "the high-cardinality input should be
passed through")
+ val partialStages = collect(df.queryExecution.executedPlan) {
+ case stage: WholeStageCodegenExec if (stage.child match {
+ case agg: HashAggregateExec => isPartialPhase(agg)
+ case _ => false
+ }) => stage
+ }
+ assert(partialStages.length == 1, "expected the partial aggregate in a
stage of its own")
+ val code = partialStages.head.doCodeGen()._2.body
+ def timerCalls(code: String): Seq[String] =
+ code.split("\n").filter(_.contains("System.nanoTime()")).toSeq
+ assert(timerCalls(code).length == 2, timerCalls(code).mkString("\n"))
+ val outputFunction = ("(?s)private void
\\w*outputMapAndFlush_\\d+\\(\\).*?" +
+ "(?=\\n\\s*(private|protected|public) |\\z)").r.findFirstIn(code)
+ assert(outputFunction.exists(timerCalls(_).length == 1),
+ "the map setup in the output function should stop the build timer")
+ }
+ }
+
+ test("partial aggregate output row count reflects the bypass (independent of
the pass-through " +
+ "metric)") {
// `numOutputRows` on the partial aggregate is the ground truth: it is
driven by the normal
- // aggregation output path, not by our self-reported `numBypassingRows`
metric. This test cross
- // checks the two and pins the observable data-side effect of bypassing.
+ // aggregation output path, not by our self-reported `numPassthroughRows`
metric. This test
+ // cross checks the two and pins the observable data-side effect of
bypassing.
val numRows = 200
forEachCodegenAndMap() { clue =>
// Fully distinct keys: once the bypass fires the operator stops
collapsing rows, so the
@@ -1398,12 +1440,13 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
.agg(sum($"v") as "s")
withClue(clue) {
val c = runAndReadCounters(highCard)
- assert(c.skipped > 0, "high-cardinality input should bypass")
+ assert(c.passedThrough > 0, "high-cardinality input should bypass")
// Every partial output row is either a real (aggregated) group or a
bypassed row, so the
- // partial output count must be at least the number of bypassed rows,
and it climbs toward
- // the input row count -- well above the heavy reduction a kept
aggregation would give.
- assert(c.partialOutputRows >= c.skipped,
- s"partial output ${c.partialOutputRows} should be >= bypassed rows
${c.skipped}")
+ // partial output count must be at least the number of passed-through
rows, and it climbs
+ // toward the input row count -- well above the heavy reduction a kept
aggregation would
+ // give.
+ assert(c.partialOutputRows >= c.passedThrough, s"partial output
${c.partialOutputRows} " +
+ s"should be >= passed-through rows ${c.passedThrough}")
assert(c.partialOutputRows > numRows / 2,
s"partial output (${c.partialOutputRows}) should climb toward the
input row count")
}
@@ -1416,7 +1459,7 @@ class AdaptivePartialAggregationSuite extends QueryTest
with SharedSparkSession
.agg(sum($"v") as "s")
withClue(clue) {
val c = runAndReadCounters(lowCard)
- assert(c.skipped == 0, "low-cardinality input should not bypass")
+ assert(c.passedThrough == 0, "low-cardinality input should not bypass")
assert(c.partialOutputRows == 5,
"an effective partial aggregate should emit exactly the distinct key
count")
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]