Skip to content

fix: [branch-56] size aggregate spill batches by bytes as well as rows (backport #25853) - #27243

Merged
andygrove merged 1 commit into
apache:branch-56from
andygrove:andygrove/backport_25853
Nov 9, 2026
Merged

andygrove merged 1 commit into
apache:branch-56from
andygrove:andygrove/backport_25853

Conversation

@andygrove

@andygrove andygrove commented Nov 8, 2026 •

Copy link
Copy Markdown
Member

Which issue does this PR close?

Rationale for this change

Aggregate spilling cuts batches by row count, so a row of aggregate state that holds a whole group (an array_agg list, a string_agg string) can make a spilled batch far larger than its row count suggests. Replay then fails with Failed to allocate additional .... #25853 bounds the batches by bytes as well, and was merged to main on 2026-11-08.

What changes are included in this PR?

A cherry-pick of #25853 (32dd1d2), with two changes for branch-56:

The Rust sources otherwise cherry-picked cleanly.

What is the testing strategy for this PR?

The same tests as #25853: the new aggregate_memory_spill.slt case, and the unit tests in spilled_row_sizes.rs, spill/mod.rs, sorts/stream.rs and sorts/streaming_merge.rs. Run on this branch:

cargo fmt --all
cargo clippy -p datafusion-physical-plan --no-deps --all-targets -- -D warnings
cargo test -p datafusion-physical-plan --lib                                   # 2933 passed
cargo test --profile release-nonlto -p datafusion-sqllogictest --test sqllogictests -- aggregate_memory_spill

(The clippy command also allowed clippy::from_iter_instead_of_collect, which fails on untouched code with the stable 1.97 toolchain used here.)

Are there any user-facing changes?

No API changes. Aggregates that spill state with large rows now spill and replay it in batches bounded by bytes, so queries that failed with Failed to allocate additional ... complete under the same memory limit.

)

- Closes apache#25851.

A grouped aggregate whose groups carry large state, such as `array_agg`
over a low-cardinality key, fails with `ResourcesExhausted` after it
spills, even though each group fits in memory and the same data spread
over many small groups succeeds.

Spilling writes the aggregate state in batches of at most `batch_size`
rows, whatever their size in bytes. With fewer groups than `batch_size`,
every group lands in one batch, and everything after the spill has to
hold that batch at once:

- With a single spill file, replay reads the batch directly and its
table must hold every group at once. In the reproducer from the issue,
that is 225 MB of state in a 128 MB pool, although each group is about
28 MB.
- With several spill files, the merge reserves twice the largest batch
of each file. When that does not fit, it tries to split the file, which
first reserves twice the batch it is about to split. Even with small
spilled batches, the merge combines rows until it has `batch_size` of
them, rebuilding a batch of large groups.

Aggregate spilling and replay now bound their batches by bytes as well
as rows, with a target of 1 MiB (`SPILL_BATCH_TARGET_BYTES`):

- `IncrementalSortIterator::with_max_batch_bytes` cuts the sorted state
into chunks by the size each row takes once spilled, so a batch only
exceeds the target when it holds a single larger group. Row sizes come
from the new `SpilledRowSizes`, which counts view values as they are
written after compaction and nested values by their elements.
- `StreamingMergeBuilder::with_max_batch_bytes` makes the merge emit a
batch once its rows reach the target, estimated from the average row
size of the batches they come from, as well as at `batch_size` rows. It
applies to every pass of the multi-level merge, so rows of small groups
still merge in batches of `batch_size` rows.
- Dictionaries whose values are mostly unique (fewer than two keys per
value) count the values their keys reference, and each chunk holds only
those values, since `take` keeps every value of a dictionary.
Dictionaries whose values many keys share count only their keys and stay
shared, as on `main`, so that each chunk does not write its own copy of
them.
- Replay compacts the views and dictionaries of the nested columns of
merged batches (`gc_nested_arrays`). A merged batch references the
buffers of every batch its rows come from, and `array_agg` keeps the
lists it merges until its group completes.

Both new options are crate-private and only used by aggregate spilling
and replay; other merges are unchanged. The intermediate merge sizing
from apache#25428 now receives the largest row count of the batches each run
actually wrote. This applies to every stream built on `AggregateSpill`
(hash and ordered, final and single-stage). The legacy
`GroupedHashAggregateStream`, used only when
`datafusion.execution.enable_migration_aggregate` is `false`, is
unchanged.

- New Case I in `aggregate_memory_spill.slt`, with a 24 MB limit, where
`EXPLAIN ANALYZE` assertions check that each query spilled. All of these
fail on `main` with `ResourcesExhausted`:
- `array_agg` over 32 groups of about 2 MB each, with two partitions
(one spill file per partition) and with one partition (several spill
files, merged).
- 14 large groups, adjacent in key order, spilled together with 62,500
small groups.
  - Runs of large groups followed by a run of small groups.
- `array_agg` over dictionary-encoded values, with two partitions and
with one.
  - `string_agg` with one partition.
- `array_agg` over dictionary values that each repeat within one group,
at 8 MB.
- Unit tests for `SpilledRowSizes` (including views that share bytes,
nested views, fixed-width rows, and dictionaries of unique, shared and
no values), for byte-limited chunks in `IncrementalSortIterator` and
their dictionaries, for byte-limited merges of spill files, and for
dictionary compaction.

With `datafusion-cli -m 128M`, the reproducer from the issue now returns
16 groups and 3,000,000 values, and so does the variant from the issue's
additional context. The reproducer with 3,000 one-value groups added,
`array_agg` over dictionary-encoded Parquet strings, and the reproducer
with its values cast to `Dictionary(Int32, Utf8)`, which fail on `main`,
also succeed.

Performance compared with `main`:

- `external_aggr` (TPC-H SF1, integer keys): no case is slower; 7 are
unchanged and 1 is 1.06x faster.
- A spilling `GROUP BY` over 5M string keys at 256 MB is unchanged (0.61
s against 0.62 s).
- 8M small groups plus 200 large `array_agg` groups take 0.80 s at 128
MB, against 2.99 s on `main`. There `main` spills 26M rows (1 GB) while
merging its runs, and this PR spills each of the 8M rows once. At 512
MB, where both spill twice, this PR takes 1.00 s against 0.96 s, from
measuring the rows of the spilled batch that holds the large groups.
- `sort_preserving_merge` shows no change beyond run-to-run noise.
Merges without a byte limit only check that none is set.
- `array_agg` over a dictionary of 100 distinct values (2M rows, 100K
groups, 24 MB) spills the same 10.5 MB (20-byte values) and 14.7 MB
(200-byte values) as `main`, in the same time.

Aggregations whose groups have large state can now complete under memory
limits where they previously failed after spilling. No API or
configuration changes.

(cherry picked from commit 32dd1d2)
@github-actions github-actions Bot added sqllogictest SQL Logic Tests (.slt) physical-plan Changes to the physical-plan crate labels Nov 8, 2026
@andygrove andygrove mentioned this pull request Nov 8, 2026
35 of 57 tasks
@andygrove
andygrove requested review from alamb and xudong963 November 8, 2026 17:22
@andygrove andygrove changed the title [branch-56] fix: size aggregate spill batches by bytes as well as rows (backport #25853) fix: [branch-56] size aggregate spill batches by bytes as well as rows (backport #25853) Nov 8, 2026
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.48311% with 58 lines in your changes missing coverage. Please review.
✅ Project coverage is 83.28%. Comparing base (766e259) to head (5fb670c).

Files with missing lines Patch % Lines
...usion/physical-plan/src/spill/spilled_row_sizes.rs 92.72% 19 Missing and 4 partials ⚠️
datafusion/physical-plan/src/spill/mod.rs 87.61% 1 Missing and 13 partials ⚠️
datafusion/physical-plan/src/sorts/stream.rs 90.09% 5 Missing and 6 partials ⚠️
...afusion/physical-plan/src/sorts/streaming_merge.rs 91.91% 3 Missing and 5 partials ⚠️
datafusion/physical-plan/src/aggregates/spill.rs 81.81% 0 Missing and 2 partials ⚠️
Flag Coverage Δ
all-tests 83.28% <91.48%> (+0.01%) ⬆️
sqllogictests 60.82% <86.42%> (+0.10%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

@@              Coverage Diff              @@
##           branch-56   #27243      +/-   ##
=============================================
+ Coverage      83.27%   83.28%   +0.01%     
=============================================
  Files           1169     1170       +1     
  Lines         474577   475237     +660     
  Branches      474577   475237     +660     
=============================================
+ Hits          395194   395804     +610     
- Misses         55458    55481      +23     
- Partials       23925    23952      +27     
Files with missing lines Coverage Δ
...ical-plan/src/aggregates/clustered_final_stream.rs 88.91% <ø> (ø)
datafusion/physical-plan/src/sorts/builder.rs 98.69% <100.00%> (ø)
datafusion/physical-plan/src/sorts/merge.rs 92.85% <100.00%> (+0.39%) ⬆️
...usion/physical-plan/src/sorts/multi_level_merge.rs 89.67% <100.00%> (+0.06%) ⬆️
datafusion/physical-plan/src/aggregates/spill.rs 91.39% <81.81%> (-0.81%) ⬇️
...afusion/physical-plan/src/sorts/streaming_merge.rs 93.29% <91.91%> (+0.52%) ⬆️
datafusion/physical-plan/src/sorts/stream.rs 85.43% <90.09%> (+1.00%) ⬆️
datafusion/physical-plan/src/spill/mod.rs 87.79% <87.61%> (+0.17%) ⬆️
...usion/physical-plan/src/spill/spilled_row_sizes.rs 92.72% <92.72%> (ø)

... and 8 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@andygrove
andygrove merged commit 3b69898 into apache:branch-56 Nov 9, 2026
42 checks passed
@alamb

alamb commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

physical-plan Changes to the physical-plan crate sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants