jayzhan211 opened a new pull request, #24795:
URL: https://github.com/apache/datafusion/pull/24795

   ## Which issue does this PR close?
   
   - Part of #24704.
   - Part of #19906.
   
   ## 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 `AggregateExec` does after its input 
is
   exhausted and it starts producing output. Today both implementations drain by
   calling `emit(EmitTo::All)` — materializing every group into one giant
   `RecordBatch` — and then handing that batch downstream as `batch.slice(..)`
   chunks (`aggregate_hash_table/common.rs:265`, labelled "temporary solution 
until
   blocked state management is implemented", and `grouped_hash_stream.rs:1337`).
   Two consequences, and the benchmark quantifies both:
   
   1. **The whole drain is one poll.** Time-to-first-batch equals total drain 
time,
      so the runtime is blocked for the entire span (#19906).
   2. **Nothing is released until the drain ends.** Every slice shares the giant
      batch's buffers, so memory held at the halfway point is 100% of what was 
held
      at the start (#24704, symptom 1).
   
   Baseline on `main` (61bf6b96c), 10M groups, five aggregates:
   
   ```
       groups       key    agg   build_ms   drain_ms   ttfb_ms   max_gap_ms  
peak_pool  peak_live  pool_%  live_%
     10000000     int64   wide      284.0       16.6      16.2         16.2   
1158.2MB   1168.2MB  100.0%  100.0%
     10000000      utf8   wide      512.6       16.1      15.8         15.8   
1574.3MB   1616.2MB  100.0%  100.0%
     10000000      dict   wide     1035.4       33.2      32.8         32.8   
1702.4MB   1798.4MB  100.0%  100.0%
     10000000   liststr   wide     1091.4     1687.1    1686.7       1686.7   
2966.3MB   5226.8MB  100.0%  100.0%
   ```
   
   Two things to read off it.
   
   **`max_gap_ms ≈ ttfb_ms ≈ drain_ms` in every row.** The entire drain happens
   inside 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 `Int64` and `Utf8` keys, `emit(EmitTo::All)` is close to a
   buffer move — `take_needed` is a `mem::take` — so 10M groups drain in ~16 ms 
and
   there is very little to win. Keys that go through arrow's row format have to
   decode every group on the way out: `GroupValuesRows::emit` calls 
`convert_rows`
   over the whole table (`group_values/row.rs:215`), and nested keys use a
   row-backed `GroupColumn` inside the vectorized path. A `List(Utf8)` key at 
10M
   groups 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 `Int64` keys alone will show
   nothing. The four default shapes are chosen to span that range.
   
   `pool_%` and `live_%` are 100% everywhere: nothing is released until the 
drain
   ends, 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 
and
   `List(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`, and
   its `[[bench]]` entry. No changes to any non-test code.
   
   | metric | meaning |
   |---|---|
   | `drain_ms` | input exhausted → last output batch |
   | `ttfb_ms` | input exhausted → first output batch |
   | `max_gap_ms` | longest interval between consecutive output batches — the 
long-poll proxy |
   | `peak_*` | peak memory over the run |
   | `pool_%` / `live_%` | memory at the 50%-drained mark over memory after the 
first batch: ~100% means nothing is released until the drain ends, ~50% means 
memory is released as output is produced |
   
   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 = false`
   with a plain `main` that prints a table.
   
   **Memory is reported two ways, because they disagree.** `pool` is what the
   `MemoryPool` has reserved; `live` is bytes actually live on the heap, from a
   counting global allocator. The pool does not track the materialized output
   batch, so on the legacy path with `utf8` keys it reports **0.1 MB** reserved
   after 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 / peak`
   therefore reads ~50% today and looks like incremental release already works;
   `at50 / first` correctly 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 `ExecutionPlan` records when the
   input 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.
   
   ```sh
   cargo bench -p datafusion-physical-plan --features test_utils --bench 
aggregate_drain
   
   # just the shape that stalls
   cargo bench -p datafusion-physical-plan --features test_utils --bench 
aggregate_drain -- \
       --keys liststr --groups 10000000 --aggs wide
   
   # the pre-migration path
   cargo bench -p datafusion-physical-plan --features test_utils --bench 
aggregate_drain -- --legacy
   ```
   
   Defaults are `int64, utf8, dict, liststr` keys × `sum, wide` aggregates ×
   `10k, 10M` groups — a cheap floor, the common case, the `GroupValuesRows`
   fallback, and the shape that stalls. Note that `liststr` at 10M groups holds
   several GB.
   
   Grouped aggregation is mid-migration (#22710), and both implementations drain
   the same way, so both are covered: with 
`execution.enable_migration_aggregate`
   on (the default) a single grouping set runs on `SingleHashAggregateStream`, 
and
   `--legacy` turns the flag off to measure `GroupedHashAggregateStream`.
   
   ### How to read the output
   
   One line per shape. The drain emits `groups / batch_size` batches — 1221 of 
them
   at 10M groups with the default 8192 — so a healthy drain spreads its work 
across
   1221 polls, and today's does not:
   
   - **`max_gap_ms` is the headline.** It is the longest single stretch the 
tokio
     worker was blocked. Compare it against the mean gap, `drain_ms / batches`. 
For
     `liststr` the mean gap is 1.4 ms but `max_gap_ms` is 1687 ms, i.e. one poll
     does 100% of the work. That ratio *is* the long poll.
   - **`ttfb_ms ≈ drain_ms` is the same fact from the consumer's side.** The 
first
     row downstream costs as much as all of them.
   - **`pool_%` / `live_%` at 100%** mean the operator is still holding 
everything
     it held at the start of the drain when it is half-finished.
   - **`peak_live` vs `peak_pool`** shows whether output is materialized on top 
of
     state rather than moved out of it. For `liststr`, `peak_live` (5227 MB) far
     exceeds `peak_pool` (2966 MB) because `convert_rows` builds the whole 
output
     while the row buffer is still alive; for flat keys the two nearly match
     because emit is a move.
   - **`build_ms`** is 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.
   
   | metric | today (`liststr`) | expected after | why |
   |---|---|---|---|
   | `max_gap_ms` | 1687 | **< 20** (gate) | one block of work per poll, not 
the whole table. Mean gap is 1.4 ms; anything under ~10x that is healthy |
   | `ttfb_ms` | 1687 | **< 20** (gate) | first batch costs one block |
   | `live_%` | 100% | **≤ 60%** (gate) | half the groups emitted ⇒ about half 
the memory released. 50% is ideal; the hash table itself may not shrink in step 
|
   | `pool_%` | 100% | **≤ 60%** (gate) | must move *with* `live_%` — see 
failure modes below |
   | `drain_ms` | 1687 | 1400–2000 (gate: ≤ 110% of baseline) | same total 
work, spread out. This is where #19562 died |
   | `peak_live` | 5227 MB | ~3000–3500 MB | the full-output spike disappears; 
only state plus one block is live. Flat keys will barely move, since they have 
no spike to remove |
   
   And on the flat shapes, which have almost nothing to win and everything to 
lose:
   
   | shape | metric | today | requirement |
   |---|---|---|---|
   | `utf8` @ 10M | `drain_ms` | 16.1 | ≤ 18 (gate) |
   | `int64` @ 10M | `drain_ms` | 16.6 | ≤ 18 (gate) |
   | any @ 10k | `drain_ms` | ~0 | no measurable regression (gate) |
   
   The strongest single check is not a threshold at all: **`max_gap_ms` should 
stop
   depending on group count.** Run `--groups 10000000,20000000` — today 
`max_gap_ms`
   roughly 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_ms` up 2x or more — `EmitTo::First(n)` shifting the remaining 
elements
     on contiguous storage, the O(remaining) trap from #19562.
   - `live_%` still 100% — blocks are being emitted but not dropped, or the 
output
     batches still share one allocation.
   - `live_%` drops but `pool_%` stays at 100% (or the reverse) — the 
reservation
     no longer describes reality. Both must move together, or downstream spill
     decisions get worse rather than better.
   - `max_gap_ms` down but `drain_ms` up — work was spread out by making more of
     it. 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
   --benches` runs the binary with `--test`, which is handled as a fast smoke 
run
   at 1k groups that asserts each configuration emits exactly one row per group.
   
   `cargo fmt --all` and `cargo clippy --all-targets --all-features -- -D 
warnings`
   are clean.
   
   ## Are there any user-facing changes?
   
   No. New benchmark only; no public API or behavior changes.
   


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