Skip to content

fix: charge retained scratch indices capacity in GroupsAccumulatorAdapter - #71

Closed
adriangb wants to merge 2 commits into
mainfrom
claude/groups-accumulator-indices-accounting
Closed

fix: charge retained scratch indices capacity in GroupsAccumulatorAdapter#71
adriangb wants to merge 2 commits into
mainfrom
claude/groups-accumulator-indices-accounting

Conversation

@adriangb

@adriangb adriangb commented Sep 1, 2026

Copy link
Copy Markdown
Member

GroupsAccumulatorAdapter never charges the capacity of its scratch indices vector to the MemoryPool. An aggregate holds megabytes that the pool does not see, so a memory limit does not stop it.

Reproduction

This needs only datafusion-cli. There is no patch, no custom allocator and no data file.

-- repro.sql
SET datafusion.execution.target_partitions = 1;
SET datafusion.execution.batch_size = 8192;
EXPLAIN ANALYZE
SELECT v / 8192 AS g, covar_samp(v, v) AS c
FROM generate_series(0, 1048575) AS t(v)
GROUP BY v / 8192;
datafusion-cli -m 1M -f repro.sql

covar_samp has no specialized GroupsAccumulator, so it runs through GroupsAccumulatorAdapter. The query makes 128 groups. Each group gets one full 8192-row batch. The scratch vectors hold 128 * 8192 * 4 bytes, which is 4 MiB against a 1 MiB limit.

Merge base da89c7c85b. The aggregate runs past the limit and does not spill:

AggregateExec: mode=Single, gby=[v@1 / 8192 as t.v / Int64(8192)], aggr=[covar_samp(t.v,t.v)],
metrics=[output_rows=128, elapsed_compute=14.48ms, output_bytes=2.0 KB, output_batches=1,
spill_count=0, spilled_bytes=0.0 B, spilled_rows=0, ...]

This branch. The aggregate sees the same bytes and spills:

AggregateExec: mode=Single, gby=[v@1 / 8192 as t.v / Int64(8192)], aggr=[covar_samp(t.v,t.v)],
metrics=[output_rows=128, elapsed_compute=15.02ms, output_bytes=2.0 KB, output_batches=2,
spill_count=5, spilled_bytes=9.2 KB, spilled_rows=128, ...]
merge base da89c7c85b this branch
spill_count 0 5
spilled_bytes 0.0 B 9.2 KB
spilled_rows 0 128

The query returns the same 128 rows on both builds. The run takes under a second. Both numbers repeat exactly across runs.

target_partitions = 1 makes the effect visible. The planner then folds the aggregate into one AggregateMode::Single node, which spills. An AggregateMode::Partial node uses OutOfMemoryMode::EmitEarly and sheds the bytes instead.

Which issue does this PR close?

No existing issue. I found this when I investigated a production out of memory. I can file an issue if you want it in the changelog.

Rationale for this change

The adapter keeps a running total in allocation_bytes. It measures AccumulatorState::size() before and after the accumulator work, then charges the difference.

The scratch vector grows in the per-row push loop. That loop runs before the adapter measures sizes_pre. The indices.clear() call after the work keeps the capacity. Both measurements therefore see the same capacity, the difference is always zero, and the adapter never charges the capacity.

evaluate and state have the opposite error. Both call free_allocation(state.size()) and release a capacity that the adapter never charged. allocation_bytes thus falls to zero across the partial emits.

The size of the hole is groups * rows_per_batch * 4 bytes.

How large the error is

An instrumented allocator measured these numbers, so the CLI cannot reproduce them. A counting GlobalAlloc gives the heap that the query holds. A peak-recording MemoryPool gives the reported bytes.

groups heap held reported, base error reported, this branch error
512 17,197,804 145,408 99.15% 16,922,624 1.60%
4,096 135,061,228 704,512 99.48% 134,922,240 0.10%

The peak heap agrees between the two builds to within 8 bytes. The memory use does not change. Only the reported number moves.

What changes are included in this PR?

A new private field indices_allocation_bytes records the capacity that the adapter already charged. Each batch totals the current capacity in the loop that already visits every group, then charges only the growth. An emit removes the capacity of the emitted state from that total.

This adds no size() call and no per-row work. It adds one usize addition per group per batch to an existing loop.

The invariant is allocation_bytes == sum(state.size()) + states.allocated_size(). Four new tests assert it against an oracle that they recompute from the states. All four fail on da89c7c85b and pass here.

Are there any user-facing changes?

No public API change and no change to query results. Only the accounting arithmetic changes.

A memory-limited aggregate now reports its true size to the MemoryPool. It can therefore spill, or fail where it cannot spill, in cases where it previously ran past its limit.

A note on metrics

grouped_hash_stream.rs records a peak_mem_used gauge from the pool reservation. That gauge is the exact number this PR corrects. EXPLAIN ANALYZE does not print it, and EXPLAIN ANALYZE VERBOSE does not print it either. The reproduction above therefore uses spill_count under a fixed limit. If the aggregate exposed peak_mem_used, a reviewer could see this bug with no memory limit at all.

@adriangb
adriangb marked this pull request as ready for review September 1, 2026 16:56
…dapter

`GroupsAccumulatorAdapter` tracks per-group memory in `allocation_bytes` by
measuring each `AccumulatorState::size()` before and after accumulator work and
applying the delta. `size()` includes the scratch `indices` vector's capacity,
but that capacity is never charged, because:

1. `indices` grows in the per-row push loop, which runs before `sizes_pre` is
   measured;
2. `indices.clear()` after the accumulator call retains the capacity.

So `sizes_pre` and `sizes_post` observe the identical `allocated_size()` on
every batch and the delta is always zero. The capacity is charged exactly zero
times, permanently, while `size()` is what the aggregate stream reports to the
`MemoryPool`, so the pool under-counts and memory-pressure handling is delayed.

The same asymmetry has a second effect at emit time: `evaluate` and `state`
call `free_allocation(state.size())`, which releases capacity that was never
charged, so `allocation_bytes` drifts down (and saturates at zero) across
partial emits.

Charge the growth explicitly. `indices_allocation_bytes` records the capacity
already charged; each batch totals the current capacity in the pass that
already visits every group and charges only the difference, so a group whose
`indices` grew once and was then cleared stays charged without being charged
again. Emitting a state drops its capacity from that total. The invariant is
now that `allocation_bytes` equals the sum of `AccumulatorState::size()` plus
the `states` vector allocation, which is what the added tests assert.

No new per-row work: the push loop is untouched, and no `size()` call is added
(`size()` was historically a bottleneck with many distinct groups, which is why
deltas are used). The added cost is one `usize` addition per group per batch in
an existing loop.

Measured on a 16384-row batch across 1000 groups, with 8192 rows in group 0 and
the rest spread evenly over the remaining 999, using a 16-byte accumulator:
168,096 bytes truly retained, 96,960 reported before, so 71,136 bytes (42%) went
unaccounted. Query results are unchanged.
@adriangb
adriangb force-pushed the claude/groups-accumulator-indices-accounting branch from 4255114 to 6b5b27b Compare September 1, 2026 17:59
@adriangb
adriangb changed the base branch from friendlymatthew/pydantic-main-df55 to main September 1, 2026 17:59
@adriangb

adriangb commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

Rebased from the DataFusion 55 fork branch (friendlymatthew/pydantic-main-df55) onto current main (da89c7c), and retargeted this PR at main.

One conflict, in the file this PR touches. Upstream added a #[cfg(test)] mod tests at the end of groups_accumulator.rs for the new evaluate_preserving / supports_evaluate_preserving methods, in the same place this PR added its own test module. Resolved by keeping both: one mod tests with the merged use block, upstream's adapter_preserving_evaluation_uses_accumulator_contract first, then this PR's helpers and three tests. No production code was dropped from either side.

Upstream has not addressed any of this. AccumulatorState is unchanged (still 40 bytes: Box<dyn Accumulator> fat pointer plus Vec<u32>), invoke_per_accumulator is unchanged, and the indices capacity is still charged zero times. The new evaluate_preserving does not touch indices and adjusts allocation_bytes with its own pre/post size() delta, so it preserves the invariant this PR establishes.

Revalidated on the new base rather than carried over: the three added tests still fail before the change and pass after it (the emit test still reports 0 bytes while holding 224). cargo check, cargo test (50 passed), cargo clippy --tests for datafusion-functions-aggregate-common, plus cargo fmt --check, all clean.

The measurement in the description moved slightly on the new base and has been updated: 168,096 bytes retained, 96,960 reported, 71,136 unaccounted (42%), on a precisely specified batch shape.

@macroscopeapp

macroscopeapp Bot commented Sep 1, 2026

Copy link
Copy Markdown

Macroscope has since reviewed this pull request. An earlier review was skipped by a cost limit; a review has now completed, so that notice no longer applies.

A `Single` mode aggregate spills rather than emitting groups early, so
the scratch capacity the adapter now charges is observable as a spill: at
128 groups of 8192 rows the retained `indices` hold 4 MiB against a 1 MiB
limit, which the base commit runs straight past with `spill_count` 0.
@github-actions github-actions Bot added the core label Sep 2, 2026
@adriangb

adriangb commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

Superseded by the upstream pull request: apache#24858

This copy existed only to run the change through review here before sending it upstream. That is done, so closing this one. The branch is unchanged and still backs the upstream pull request.

@adriangb adriangb closed this Sep 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant