bench: Add drain-phase benchmark for grouped aggregation - #24795
Open
jayzhan211 wants to merge 1 commit into
Open
bench: Add drain-phase benchmark for grouped aggregation#24795jayzhan211 wants to merge 1 commit into
jayzhan211 wants to merge 1 commit into
Conversation
Adds `datafusion/physical-plan/benches/aggregate_drain.rs`, which measures what `AggregateExec` does after its input is exhausted: total drain time, time-to-first-batch, the longest gap between output batches (the long-poll proxy), peak memory, and memory still held at the 50%-drained mark. This is the measurement artifact for the blocked / chunked memory management epic (apache#24704) and the long-poll issue (apache#19906). Both the migrated `SingleHashAggregateStream` and the legacy `GroupedHashAggregateStream` drain by materializing every group with `EmitTo::All` and then handing out `batch.slice(..)` chunks, so neither releases memory until the drain ends; `--legacy` selects the fallback path for comparison. Key layout decides how much work the drain does. At 10M groups with five aggregates, flat keys drain in ~16ms because `emit(EmitTo::All)` is close to a buffer move, while a `List(Utf8)` key blocks the runtime for over a second in a single poll: nested keys and the `GroupValuesRows` fallback have to decode every group on the way out. The four default shapes span that range. Memory is reported both as `MemoryPool` reservation and as live heap bytes (via a counting global allocator), because the two disagree: the pool does not track the materialized output batch, so it can report near-zero while the operator still holds the whole result. Criterion is not used - the quantities of interest are within-run timings and memory samples rather than a throughput distribution.
jayzhan211
force-pushed
the
agg-drain-bench
branch
from
August 30, 2026 09:52
c0fb80a to
28db339
Compare
11 tasks
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #24795 +/- ##
=======================================
Coverage 81.52% 81.52%
=======================================
Files 1123 1123
Lines 405970 406041 +71
Branches 405970 406041 +71
=======================================
+ Hits 330978 331041 +63
- Misses 55627 55635 +8
Partials 19365 19365 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Which issue does this PR close?
Rationale for this change
The blocked / chunked state management work in #24704 needs a way to tell
whether it worked. The suites we normally judge aggregation PRs on — ClickBench
and h2o groupby — measure whole-query time, and the phase this work changes is a
small fraction of that, so they can neither show the win nor size it. They stay
as the regression gate; this benchmark is what shows the improvement.
It measures only the drain phase: what
AggregateExecdoes after its input isexhausted and it starts producing output. Today both implementations drain by
calling
emit(EmitTo::All)— materializing every group into one giantRecordBatch— and then handing that batch downstream asbatch.slice(..)chunks (
aggregate_hash_table/common.rs:265, labelled "temporary solution untilblocked state management is implemented", and
grouped_hash_stream.rs:1337).Two consequences, and the benchmark quantifies both:
so the runtime is blocked for the entire span ([EPIC] Eliminate Long Polls in HashAggregate via Chunked Storage and Incremental Emission #19906).
batch's buffers, so memory held at the halfway point is 100% of what was held
at the start ([EPIC] Use blocked / chunked memory management in hash aggregation #24704, symptom 1).
Baseline on
main(61bf6b9), 10M groups, five aggregates:Two things to read off it.
max_gap_ms ≈ ttfb_ms ≈ drain_msin every row. The entire drain happensinside one
poll_next, exactly as #19906 describes.Key layout decides the size of that poll, and the spread is 100x. This was
the surprise. For
Int64andUtf8keys,emit(EmitTo::All)is close to abuffer move —
take_neededis amem::take— so 10M groups drain in ~16 ms andthere is very little to win. Keys that go through arrow's row format have to
decode every group on the way out:
GroupValuesRows::emitcallsconvert_rowsover the whole table (
group_values/row.rs:215), and nested keys use arow-backed
GroupColumninside the vectorized path. AList(Utf8)key at 10Mgroups blocks the runtime for 1687 ms in a single poll — the ">1s stall at
~10M groups" reported in #19906, reproduced.
Worth stating explicitly for anyone measuring this work: on flat keys the
latency win is ~16 ms, so a benchmark run on
Int64keys alone will shownothing. The four default shapes are chosen to span that range.
pool_%andlive_%are 100% everywhere: nothing is released until the drainends, on either code path, in either accounting. Shapes not in the default set
behave the same way —
Struct(Int64, Utf8)drains in 174 ms at 10M groups andList(Int64)in 664 ms, both at 100% — and a ten-column flat key is cheap(~2 ms at 1M groups), so column count is not what drives the stall.
What changes are included in this PR?
One new benchmark,
datafusion/physical-plan/benches/aggregate_drain.rs, andits
[[bench]]entry. No changes to any non-test code.drain_msttfb_msmax_gap_mspeak_*pool_%/live_%Three design decisions worth flagging for review:
Criterion is not used. The quantities of interest are within-run timings and
memory samples, not a throughput distribution. The bench is
harness = falsewith a plain
mainthat prints a table.Memory is reported two ways, because they disagree.
poolis what theMemoryPoolhas reserved;liveis bytes actually live on the heap, from acounting global allocator. The pool does not track the materialized output
batch, so on the legacy path with
utf8keys it reports 0.1 MB reservedafter the first output batch while the process is holding 960 MB — symptom 3
in #24704. A pool-only measurement would be blind exactly where the problem is.
The ratio is anchored to the first output batch, not to the peak. Peak is
reached while the hash table is still being built, and the current code takes a
one-time step down from build state to materialized output.
at50 / peaktherefore reads ~50% today and looks like incremental release already works;
at50 / firstcorrectly reads 100%.Input is one row per group, so the build phase is as short as possible and the
drain is what is being measured. A wrapper
ExecutionPlanrecords when theinput is exhausted, which is what separates build from drain; a sampler thread
reads memory every 250 µs so peaks reached inside a single long poll are not
missed.
Defaults are
int64, utf8, dict, liststrkeys ×sum, wideaggregates ×10k, 10Mgroups — a cheap floor, the common case, theGroupValuesRowsfallback, and the shape that stalls. Note that
liststrat 10M groups holdsseveral GB.
Grouped aggregation is mid-migration (#22710), and both implementations drain
the same way, so both are covered: with
execution.enable_migration_aggregateon (the default) a single grouping set runs on
SingleHashAggregateStream, and--legacyturns the flag off to measureGroupedHashAggregateStream.How to read the output
One line per shape. The drain emits
groups / batch_sizebatches — 1221 of themat 10M groups with the default 8192 — so a healthy drain spreads its work across
1221 polls, and today's does not:
max_gap_msis the headline. It is the longest single stretch the tokioworker was blocked. Compare it against the mean gap,
drain_ms / batches. Forliststrthe mean gap is 1.4 ms butmax_gap_msis 1687 ms, i.e. one polldoes 100% of the work. That ratio is the long poll.
ttfb_ms ≈ drain_msis the same fact from the consumer's side. The firstrow downstream costs as much as all of them.
pool_%/live_%at 100% mean the operator is still holding everythingit held at the start of the drain when it is half-finished.
peak_livevspeak_poolshows whether output is materialized on top ofstate rather than moved out of it. For
liststr,peak_live(5227 MB) farexceeds
peak_pool(2966 MB) becauseconvert_rowsbuilds the whole outputwhile the row buffer is still alive; for flat keys the two nearly match
because emit is a move.
build_msis not part of the gate, but watch it: blocked storage puts a(block, offset)indirection on every group lookup, and that cost lands here.ClickBench and h2o are the real guard for it.
What the follow-up work should show
Targets for #24704 at 10M groups, derived from the baseline above. Hard gates
are marked; the rest are informational but should move in the stated direction.
liststr)max_gap_msttfb_mslive_%pool_%live_%— see failure modes belowdrain_mspeak_liveAnd on the flat shapes, which have almost nothing to win and everything to lose:
utf8@ 10Mdrain_msint64@ 10Mdrain_msdrain_msThe strongest single check is not a threshold at all:
max_gap_msshould stopdepending on group count. Run
--groups 10000000,20000000— todaymax_gap_msroughly doubles, because the poll materializes everything. After blocked
emission it should be flat, because a poll materializes one block regardless of
how many groups exist.
Failure modes this benchmark is designed to catch:
drain_msup 2x or more —EmitTo::First(n)shifting the remaining elementson contiguous storage, the O(remaining) trap from Incremental group emission in HashAggregate #19562.
live_%still 100% — blocks are being emitted but not dropped, or the outputbatches still share one allocation.
live_%drops butpool_%stays at 100% (or the reverse) — the reservationno longer describes reality. Both must move together, or downstream spill
decisions get worse rather than better.
max_gap_msdown butdrain_msup — work was spread out by making more ofit. A win on latency paid for with throughput.
Are these changes tested?
The benchmark is the test artifact; it adds no product code.
cargo test --benchesruns the binary with--test, which is handled as a fast smoke runat 1k groups that asserts each configuration emits exactly one row per group.
cargo fmt --allandcargo clippy --all-targets --all-features -- -D warningsare clean.
Are there any user-facing changes?
No. New benchmark only; no public API or behavior changes.