You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Note: this issue consolidates and replaces #7065, which dates from 2023 and predates much of the discussion.
What is going on (symptoms)
High-cardinality GROUP BY queries in DataFusion suffer from several challenges that look unrelated at first, but all trace back to the same root cause. The problems:
Aggregation memory is held until the hash table is fully drained. All group state is emitted as slices of one giant batch, so none of it is released until the last output batch. For a typical two-stage aggregation, this shows up as ~2x peak memory: while the first stage drains its 1 GB of state, the final stage is simultaneously building its own ~1 GB (measured by @2010YOUY01 in PoC: Blocked state management for hash aggregation #22712).
Queries with more than 2 GiB of overall string data in group keys can crash outright. All the bytes of Utf8/Binary group keys are interned into one contiguous buffer addressed by i32 offsets; once more than 2 GiB of key bytes accumulate, the offsets overflow and the query fails with offset overflow, buffer size > 2147483647:
Downstream memory accounting is off / operators spill when they don't need to. Each output batch reports the memory of the entire aggregation output via get_array_memory_size(), which causes unnecessary spilling in operators such as RepartitionExec and TopK:
The async runtime stalls when output begins. Producing output for >500k groups block a tokio worker thread for hundreds of milliseconds to seconds, causing latency spikes for everything else on that thread:
Potential copying performance. As groups accumulate, internal buffers repeatedly double in size and copy all existing data (up to 2 copies per element on average), which is likely expensive and cache/TLB unfriendly and could in theory be avoided.
Aggregate state is stored by one GroupsAccumulator per aggregate expression (not per group). Each accumulator manages the state for all groups, typically as a single Vec<T> (plus a null buffer), again indexed by a single group index (a usize).
Memory held until drained: output is produced via EmitTo::All: at end of input, all groups are materialized into one giant RecordBatch, which is then handed downstream as batch.slice(..) chunks of batch_size rows. The slices share the giant batch's buffers, so no memory is freed until the last slice is dropped.
Crash on wide string keys: for Utf8/Binary group keys, ByteGroupValueBuilder<i32> stores all key bytes in a single contiguous buffer addressed by i32 offsets, so accumulating more than 2 GiB of key bytes overflows.
Spilling / accounting: every slice of the giant batch reports the full underlying allocation to the memory accounting, not just its own rows.
Runtime stall: materializing all groups in a single EmitTo::All call is one large CPU-bound operation with no await point.
Copying performance: growing the contiguous buffers requires reallocating and copying all existing data.
The high level solution sketch
The fix that everyone seems to agree on is to store group keys and accumulator state in multiple blocks instead of one contiguous Vec. This is the approach used by DuckDB and most other databases with buffer managers, which don't have the luxury of large contiguous arrays. It was originally suggested for DataFusion by @yjshen in 2023.
At a high level, the idea is that:
Blocks store some number of rows (most likely target_batch_size)
Blocks are not resized; when a block fills up, a new block is allocated (which avoids copying and the growth is predictable and incremental)
A group index becomes some form of (block_id, offset) rather than a single group_idx
Emission now happens one block at a time (e.g. something like EmitTo::NextBlock), so memory is freed incrementally. The blocks now back individual output RecordBatches (fixing the accounting).
Since each block is capped at target_batch_size rows, they are far more likely to stay below 2 GiB of total string values (the i32 offset limit), avoiding the string offset overflow.
The idea is illustrated here:
┌──────────────┐ ┌──────────────┐
│┌────────────┐│ │┌────────────┐│
┌─────────┐ ││accumulator ││ ││accumulator ││
│ (0,5) │ ││ AGG ││ ││ SUM ││
├─────────┤ ││ ┌────────┐ ││ ││ ┌────────┐ ││
│ (1,3) │ ││ │ block │ ││ ││ │ block │ ││
├─────────┤ ││ │ 0 │ ││ ││ │ 0 │ ││
│ │ ││ │ │ ││ ││ │ │ ││
├─────────┤ ││ │ │ ││ ││ │ │ ││
│ (0,1) │ ││ │ │ ││ ││ │ │ ││
├─────────┤ ││ └────────┘ ││ ││ └────────┘ ││
│ │ ││ ││ ││ ││
└─────────┘ ││ ┌────────┐ ││ ││ ┌────────┐ ││
││ │ block │ ││ ││ │ block │ ││
Hash Table ││ │ 1 │ ││ ││ │ 1 │ ││
││ │ │ ││ ││ │ │ ││
││ │ │ ││ ││ │ │ ││
││ │ │ ││ ││ │ │ ││
││ └────────┘ ││ ││ └────────┘ ││
│└────────────┘│ │└────────────┘│
└──────────────┘ └──────────────┘
stores "group indexes" Each accumulator stores its state in
as (block_id, offset) fixed size blocks: a full block is
pairs into the block never resized; instead a new block
storage is allocated as needed, and whole
blocks can be emitted / freed
one at a time
GroupValues::intern assigns each distinct group key a dense index 0..n, and the hash table stores those raw indexes.
GroupsAccumulator::update_batch receives group_indices: &[usize] and total_num_groups: usize; implementations index their state Vecs directly with the group index and grow them with a single resize(total_num_groups).
pubtraitGroupValues:Send{// Required methodsfnintern(&mutself,cols:&[Arc<dynArray>],groups:&mutVec<usize>,// <---- groups are identified by contiguous `usize`) -> Result<(),DataFusionError>;
...
}
Because this assumption is spread across every GroupValues and GroupsAccumulator implementation — including user-defined aggregates and the FFI bindings — moving to a blocked (block_id, offset) index:
Potentially touches the whole ecosystem at once (aka is a massive change)
Likely involves an extra memory lookup in the hottest critical path: once to find the base pointer for the block_id and once to find the actual value within that block.
As part of #22710, @2010YOUY01 has been refactoring the monolithic GroupedHashAggregateStream into dedicated per-path streams, in large part to make changes like blocked state management feasible to implement and review. I (@alamb) thinks completing this refactor is a prerequisite for beginning the blocked state work in earnest: implementing it against the old multiplexed stream would be far more complex and would conflict with the refactoring itself.
The same blocked approach was also proposed independently by @alchemist51 in #19649, which includes an experiment (based on @Rachelint's #15591) where a high-cardinality query that fails with resource exhaustion in a 16 GB memory pool today completes once blocked state management is enabled.
An in-progress implementation for multi-column group-by (@rluvaton) using only EmitTo::NextBlock is being discussed on #15591. It is not yet a PR, but the work seems to be on the add-blocks-impl branch of their fork.
A complementary approach that reduces partial-stage state without changing these traits is cache-efficient (morsel-driven) partial aggregation, proposed by @Dandandan:
Related symptoms that will NOT be addressed by this issue
Note that other operators produce giant contiguous intermediate batches too, and show the same failure modes. This issue only covers aggregation; something similar will be needed for joins:
TPCDS Q72 Fails with OffsetOverflowError(2147731589) (AQE) datafusion-ballista#1826 (reported by @milenkovicm): TPC-DS Q72 in Ballista fails with the same OffsetOverflowError, but profiling in that thread suggests the oversized intermediate is produced primarily by the join chain (~1.12B rows, ~96 GB at the join output) before it reaches the partial aggregate — blocked aggregation state will not help there.
Note: this issue consolidates and replaces #7065, which dates from 2023 and predates much of the discussion.
What is going on (symptoms)
High-cardinality
GROUP BYqueries in DataFusion suffer from several challenges that look unrelated at first, but all trace back to the same root cause. The problems:Aggregation memory is held until the hash table is fully drained. All group state is emitted as slices of one giant batch, so none of it is released until the last output batch. For a typical two-stage aggregation, this shows up as ~2x peak memory: while the first stage drains its 1 GB of state, the final stage is simultaneously building its own ~1 GB (measured by @2010YOUY01 in PoC: Blocked state management for hash aggregation #22712).
Queries with more than 2 GiB of overall string data in group keys can crash outright. All the bytes of
Utf8/Binarygroup keys are interned into one contiguous buffer addressed byi32offsets; once more than 2 GiB of key bytes accumulate, the offsets overflow and the query fails withoffset overflow, buffer size > 2147483647:offset overflowdatafusion-comet#4718 (report from Comet by @comphead)Downstream memory accounting is off / operators spill when they don't need to. Each output batch reports the memory of the entire aggregation output via
get_array_memory_size(), which causes unnecessary spilling in operators such asRepartitionExecandTopK:RecordBatches rather than one large one #9562 (reported by @alamb)The async runtime stalls when output begins. Producing output for >500k groups block a tokio worker thread for hundreds of milliseconds to seconds, causing latency spikes for everything else on that thread:
Potential copying performance. As groups accumulate, internal buffers repeatedly double in size and copy all existing data (up to 2 copies per element on average), which is likely expensive and cache/TLB unfriendly and could in theory be avoided.
What is causing the problem
Note you can read more about the current group state in the blog Aggregating Millions of Groups Fast in Apache Arrow DataFusion 28.0.0
GroupedHashAggregateStreamstores all per-group state in single contiguous buffers that grow by doubling:GroupValuesimplementation (e.g.PrimitiveGroupValueBuilderorByteGroupValueBuilder). The hash table itself only stores group indexes —usizeoffsets into these buffers.GroupsAccumulatorper aggregate expression (not per group). Each accumulator manages the state for all groups, typically as a singleVec<T>(plus a null buffer), again indexed by a single group index (ausize).This contiguous layout explains each symptom:
EmitTo::All: at end of input, all groups are materialized into one giantRecordBatch, which is then handed downstream asbatch.slice(..)chunks ofbatch_sizerows. The slices share the giant batch's buffers, so no memory is freed until the last slice is dropped.Utf8/Binarygroup keys,ByteGroupValueBuilder<i32>stores all key bytes in a single contiguous buffer addressed byi32offsets, so accumulating more than 2 GiB of key bytes overflows.EmitTo::Allcall is one large CPU-bound operation with no await point.The high level solution sketch
The fix that everyone seems to agree on is to store group keys and accumulator state in multiple blocks instead of one contiguous
Vec. This is the approach used by DuckDB and most other databases with buffer managers, which don't have the luxury of large contiguous arrays. It was originally suggested for DataFusion by @yjshen in 2023.At a high level, the idea is that:
target_batch_size)(block_id, offset)rather than a singlegroup_idxEmitTo::NextBlock), so memory is freed incrementally. The blocks now back individual outputRecordBatches (fixing the accounting).target_batch_sizerows, they are far more likely to stay below 2 GiB of total string values (thei32offset limit), avoiding the string offset overflow.The idea is illustrated here:
Why this is hard to fix
The reason this is so hard to implement is that the entire API is designed around a single
usizegroup index that is assumed to be contiguous and directly addressable:GroupValues::internassigns each distinct group key a dense index0..n, and the hash table stores those raw indexes.GroupsAccumulator::update_batchreceivesgroup_indices: &[usize]andtotal_num_groups: usize; implementations index their stateVecs directly with the group index and grow them with a singleresize(total_num_groups).EmitTo::First(n): after emitting the firstngroups, every remaining group index is renumbered down byn— a notion that only makes sense when the state is one contiguous array.For example
GroupValues::intern:Because this assumption is spread across every
GroupValuesandGroupsAccumulatorimplementation — including user-defined aggregates and the FFI bindings — moving to a blocked(block_id, offset)index:block_idand once to find the actual value within that block.We have discussed ways to address both issues:
(block_id, offset)indexes once the table grows past a threshold — at that point accesses are cache misses anyway, so the extra lookup matters less. See Intermediate result blocked approach to aggregation memory management #15591 (comment) and the earlier version of the same idea in PoC: Blocked state management for hash aggregation #22712 (comment).Past attempts and prototypes
There is a long and distinguished history of trying to address this problem:
RecordBatches (@JasonLi-cn)supports_blocked_groups/alter_block_sizetrait additions, blockedPrimitiveGroupsAccumulator+GroupValuesPrimitiveBatchedVec<T>bench (@Dandandan): O(1) per-block emission is achievable in small stepsEmitTo::FirstBlockas an API-only first step (@hhhizzz)One seemingly obvious alternative would be to use
EmitTo::First(n)to incrementally emit data from the front of the state. However, this does not work either as it is destructive: it requires shifting all remaining elements to the start of the buffers and renumbering every remaining group index. @ahmed-mez tried incremental emission with these mechanics in #19562 and measured it ~15x slower at high cardinality.As part of #22710, @2010YOUY01 has been refactoring the monolithic
GroupedHashAggregateStreaminto dedicated per-path streams, in large part to make changes like blocked state management feasible to implement and review. I (@alamb) thinks completing this refactor is a prerequisite for beginning the blocked state work in earnest: implementing it against the old multiplexed stream would be far more complex and would conflict with the refactoring itself.The same blocked approach was also proposed independently by @alchemist51 in #19649, which includes an experiment (based on @Rachelint's #15591) where a high-cardinality query that fails with resource exhaustion in a 16 GB memory pool today completes once blocked state management is enabled.
An in-progress implementation for multi-column group-by (@rluvaton) using only
EmitTo::NextBlockis being discussed on #15591. It is not yet a PR, but the work seems to be on theadd-blocks-implbranch of their fork.A complementary approach that reduces partial-stage state without changing these traits is cache-efficient (morsel-driven) partial aggregation, proposed by @Dandandan:
Related issues
RecordBatches rather than one large one #9562Related symptoms that will NOT be addressed by this issue
Note that other operators produce giant contiguous intermediate batches too, and show the same failure modes. This issue only covers aggregation; something similar will be needed for joins:
OffsetOverflowError(2147731589)(AQE) datafusion-ballista#1826 (reported by @milenkovicm): TPC-DS Q72 in Ballista fails with the sameOffsetOverflowError, but profiling in that thread suggests the oversized intermediate is produced primarily by the join chain (~1.12B rows, ~96 GB at the join output) before it reaches the partial aggregate — blocked aggregation state will not help there.batch_sizeinstead of emitting everything at once" across operators.