Skip to content

fix: reserve hash table growth before aggregating a batch - #26429

Open
andygrove wants to merge 2 commits into
apache:mainfrom
andygrove:fix-issue-25606
Open

andygrove wants to merge 2 commits into
apache:mainfrom
andygrove:fix-issue-25606

Conversation

@andygrove

Copy link
Copy Markdown
Member

Which issue does this PR close?

Rationale for this change

A hash aggregate can use more memory than its memory pool grants it while its hash table grows. A growing hash table allocates its larger table, at least twice the size, before it frees the current one. The hash aggregate streams aggregate a batch first and only then resize their reservation to the table's new size, so while the table grows they hold up to three times the old table, and the larger table is never reserved. As @EmilyMatt suggested in #25606, the memory for a batch should be reserved before it is inserted, spilling first if it does not fit.

What changes are included in this PR?

  • GroupValues::hash_table_growth_size(num_groups) returns the size of the larger hash table that interning num_groups new groups would allocate, or 0 if they fit. It defaults to 0, and the group values that use a hash table implement it: GroupValuesPrimitive, GroupValuesBytes, GroupValuesBytesView, GroupValuesColumn and GroupValuesRows. The estimate comes from the new datafusion_common::utils::memory::estimate_hash_table_growth, which builds on estimate_memory_size. ArrowBytesMap and ArrowBytesViewMap get a hash_table_growth_size method for the byte maps.
  • Before aggregating a batch, the final, single, partial and partial reduce hash aggregate streams grow their reservation by the table that their group values would grow to if every row of the batch, in every grouping set, were a new group. If the pool cannot provide it while the table holds groups, they spill (final and single) or emit (partial and partial reduce) those groups first, so that the table grows from empty. Either way, the reservation is resized to the table's actual size after the batch, as before.
  • The spill and early emit code that each stream runs on memory pressure moves into a function that both paths use.

The clustered aggregate streams, and memory that accumulators allocate as groups are added, are unchanged.

What is the testing strategy for this PR?

  • test_estimate_hash_table_growth checks the estimate against the allocation of a hashbrown table after it grows once and after it grows several times.
  • group_values_report_the_growth_of_their_hash_table checks that each group values implementation with a hash table reports its growth, and that boolean group values, which have none, report 0.
  • final_hash_spills_before_the_hash_table_grows, single_hash_spills_before_the_hash_table_grows and partial_hash_emits_before_the_hash_table_grows aggregate a batch that grows the hash table, under a memory limit that holds the table after either batch but not the larger table while the current one is held. The final and single aggregates now spill before the batch, and the partial aggregate emits the earlier groups before aggregating it. All three fail on main, where nothing spills or emits.

With datafusion-cli, 16 partitions sharing a greedy pool, and a GROUP BY over 5M string keys that spills, the query fails with ResourcesExhausted less often under tight limits (failures in 10 runs each):

Limit main This PR
64 MB 3 3
48 MB 7 3
32 MB 10 6

On both, the remaining failures come from reservations made while replaying the spilled runs.

Performance compared with main, alternating runs on the same machine:

  • aggregate_query_sql: every case is within 0.97x to 1.03x of main over 5 rounds, with a median of 1.00x. The cases without GROUP BY, which this PR does not change, are within 1.01x.
  • datafusion-cli, median of 7 runs: 500M rows into 100 groups take 0.70 s on both, 100M rows into 20M integer groups take 1.39 s against 1.41 s, 50M rows into 10M string groups take 0.81 s against 0.83 s, and the spilling GROUP BY over 5M string keys at 256 MB takes 0.16 s against 0.17 s.

Are there any user-facing changes?

Under a memory limit, hash aggregates spill or emit earlier than before when their hash table would grow past the limit. GroupValues has a new provided method, and datafusion_common::utils::memory, ArrowBytesMap and ArrowBytesViewMap have new public functions. No existing API changes.

A hash table that grows allocates its larger table before it frees the
current one. The hash aggregate streams aggregate a batch first and only
then grow their reservation to the table's new size, so while the table
grows they hold both tables, up to three times the old one, without
having reserved the larger one.

Before aggregating a batch, the final, single, partial and partial
reduce hash aggregate streams now reserve the table that their group
values would grow to if every row were a new group. If the pool cannot
provide it, they spill (final and single) or emit (partial and partial
reduce) the groups aggregated so far first, so that the table grows
from empty.

Closes apache#25606
@github-actions github-actions Bot added physical-expr Changes to the physical-expr crates common Related to common crate physical-plan Changes to the physical-plan crate labels Oct 16, 2026
@andygrove

Copy link
Copy Markdown
Member Author

@EmilyMatt could you help with reviews?

@andygrove
andygrove requested a review from comphead October 16, 2026 20:20
@codecov-commenter

codecov-commenter commented Oct 16, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.25656% with 54 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.89%. Comparing base (1da3cf0) to head (906ece5).
⚠️ Report is 347 commits behind head on main.

Files with missing lines Patch % Lines
...fusion/physical-plan/src/aggregates/hash_stream.rs 81.46% 10 Missing and 23 partials ⚠️
...sical-plan/src/aggregates/partial_reduce_stream.rs 42.30% 12 Missing and 3 partials ⚠️
...sion/physical-plan/src/aggregates/single_stream.rs 70.58% 0 Missing and 5 partials ⚠️
...plan/src/aggregates/aggregate_hash_table/common.rs 94.73% 1 Missing ⚠️
@@           Coverage Diff            @@
##             main   #26429    +/-   ##
========================================
  Coverage   82.89%   82.89%            
========================================
  Files        1153     1153            
  Lines      457487   457805   +318     
  Branches   457487   457805   +318     
========================================
+ Hits       379213   379490   +277     
- Misses      55264    55280    +16     
- Partials    23010    23035    +25     
Files with missing lines Coverage Δ
datafusion/common/src/utils/memory.rs 94.01% <100.00%> (+0.28%) ⬆️
datafusion/physical-expr-common/src/binary_map.rs 94.86% <100.00%> (+0.16%) ⬆️
...fusion/physical-expr-common/src/binary_view_map.rs 94.89% <100.00%> (+0.02%) ⬆️
...n/physical-plan/src/aggregates/group_values/mod.rs 90.86% <100.00%> (+3.02%) ⬆️
.../src/aggregates/group_values/multi_group_by/mod.rs 97.02% <100.00%> (+<0.01%) ⬆️
...n/physical-plan/src/aggregates/group_values/row.rs 88.23% <100.00%> (+0.08%) ⬆️
...c/aggregates/group_values/single_group_by/bytes.rs 91.30% <100.00%> (+0.23%) ⬆️
...regates/group_values/single_group_by/bytes_view.rs 91.59% <100.00%> (+0.21%) ⬆️
...gregates/group_values/single_group_by/primitive.rs 94.41% <100.00%> (+0.07%) ⬆️
...plan/src/aggregates/aggregate_hash_table/common.rs 92.24% <94.73%> (+0.08%) ⬆️
... and 3 more

... and 5 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.

@EmilyMatt

EmilyMatt commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Accidentally commented this in the wrong tab 🤦‍♀️
Copying here:

What I see:

  1. During the actual emit, for a brief instance, we are holding both the output arrays, and the state, meaning peak is still double, we need to have an actual estimation for the emitted size, I have a POC for this, can open a PR if you want, then you'll be able to use it to calculate the expected peak.

  2. remaining_groups.get_array_memory_size() only calculates the data, not the capacity, and since you add another spill trigger, we could be doing more of these emits where the the Vec's capacities are larger than data and so we emit more than we actually reserved and are releaseing, unfortunately I don't see any solution for this other than calling shrink_to_fit() for each array after emitting it(so we peak at each array's 2x size, rather than the whole batch's), adds an extra copy, but greatly reduces memory footprint.

  3. try_reserve_hash_table_growth still lets a batch through on failed reservation, instead we should slice it and try again, since our estimation depends on the the input array sizes, but also on the sliced data sizes. so it's possible that we can safely process the batch by simply doing the extra spill 🤷‍♀️

@comphead comphead left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reserving before intern is the right direction. Two points below are about how big the reservation is: it is too small when one batch grows the table more than once, and too large when the rows hit existing groups. The other two are small reuse cleanups.

  • common.rs:398: import DataFusionError instead of spelling out datafusion_common::DataFusionError::ResourcesExhausted.
  • final_hash_spills_before_the_hash_table_grows and single_hash_spills_before_the_hash_table_grows differ only in the mode. One test that loops over [AggregateMode::Final, AggregateMode::Single] covers both.

Comment on lines +151 to +154
// A full table grows to hold at least one more entry than its capacity,
// which doubles its buckets
let capacity = required.max(table.capacity().saturating_add(1));
estimate_memory_size::<T>(capacity, 0).unwrap_or(usize::MAX)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

intern inserts one row at a time, so a batch that needs more than one doubling grows the table several times. Each growth frees the previous table, so the peak is at the last growth: the final table plus the table half its size. This returns only the final table, so the reservation covers the current table plus the final one, not the peak.

A standalone probe on hashbrown 0.17.1 ((u64, usize) entries, every row new) shows the gap:

table before new rows reserved peak
empty 8192 278,528 417,808
1000 groups 8192 313,352 417,808
3000 groups 60,000 2,297,864 3,342,352

Adding the half-size table when it is larger than the current one matched the peak within the 16-byte trailing control group in every case. Also, required > capacity always holds after the early return, so the max has no effect:

Suggested change
// A full table grows to hold at least one more entry than its capacity,
// which doubles its buckets
let capacity = required.max(table.capacity().saturating_add(1));
estimate_memory_size::<T>(capacity, 0).unwrap_or(usize::MAX)
let grown = estimate_memory_size::<T>(required, 0).unwrap_or(usize::MAX);
// Growing more than once frees the current table before the last growth,
// which holds the final table and the one half its size
grown.saturating_add((grown / 2).saturating_sub(table.allocation_size()))

The "grow it several times" case in test_estimate_hash_table_growth only compares against the final allocation, so it doesn't catch this.

pub(in crate::aggregates) fn hash_table_growth_size(&self, num_rows: usize) -> usize {
let state = self.state.building();
// Each grouping set interns every row, so each can add a group per row
let num_groups = num_rows.saturating_mul(state.group_by.groups().len().max(1));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sizes the table as if every row were a new group, so it asks for memory whenever len + num_rows > capacity, even if every row matches an existing group. In the same probe, a 100-group table (capacity 112, 2,184 bytes) asks for 278,528 bytes for an 8192-row batch, and for 2,228,224 bytes with 8 grouping sets.

A spill or emit resets the table to capacity 0, so the next batch asks again. When the pool can't provide that, Final and Single write one tiny run per batch, and Partial emits after every batch. On main a low-cardinality aggregate under the same pool never spills, because its table never grows. I haven't run this end to end. Did the 32 MB and 48 MB runs record spill counts?

An option that avoids the worst-case guess is to reserve at the point of growth. intern would stop before the insert that grows the table and report how many rows it consumed. The stream then reserves one doubling, or spills, and continues with the rest of the batch. That is exact, covers the multi-growth peak above, and only adds a check on the growth path. It also overlaps with EmilyMatt's slicing suggestion.


/// Takes the partial states of the groups aggregated so far, to emit them
/// on memory pressure.
fn take_early_emit_states(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

take_early_emit_states only feeds emit_on_memory_pressure, and both callers pass hash_table.memory_size(). If emit_on_memory_pressure took &mut hash_table and did the take and the early_emit_count increment itself, this helper would go away, and both the new pre-batch block and the OOM branch would become a single call. The same applies to partial_reduce_stream.rs.


/// Sorts and spills one complete in-memory state run: the groups
/// aggregated so far.
fn spill(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aggregating::spill in single_stream.rs does the same take, spill and resize. take_state_batch is generic over the marker, so a single method on AggregateSpill that takes &mut AggregateHashTable<M> and the reservation can serve both streams.

# Conflicts:
#	datafusion/physical-plan/src/aggregates/group_values/multi_group_by/mod.rs
#	datafusion/physical-plan/src/aggregates/group_values/row.rs
#	datafusion/physical-plan/src/aggregates/hash_stream.rs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

common Related to common crate physical-expr Changes to the physical-expr crates physical-plan Changes to the physical-plan crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Map growth in aggregate can be 3x of size

4 participants