Skip to content

Generate GroupByHash output in multiple RecordBatches rather than one large one #9562

Description

@alamb

Is your feature request related to a problem or challenge?

  1. The AggregateExec generates one single (giant) RecordBatch on output (source)
  2. Which is then emitted in parts (via RecordBatch::slice(), which does not actually allocate any additional memory) (source)

This has at least two potential downsides:

  1. No memory is freed until the GroupByHash has output every output row
  2. As we see in Further refine the Top K sort operator #9417, if there are upstream operators like TopK that hold references to any of these sliced RecordBatchs, those slices are treated as though they were an additional allocation that needs to be tracked (source)

Something like this in pictures:

                                                 Output             
                           ▲               RecordBatches are        
                           │                 slices into a          
                  ┌────────────────┐          single large          
                  │  RecordBatch   │─ ─ ─ ┐   output batch          
                  └────────────────┴ ─ ─ ┐                          
                           ▲              │                         
                           │             │        ┌────────────────┐
                  ┌────────────────┐      └ ─ ─ ─▶│                │
  output stream   │  RecordBatch   │     │        ├ ─ ─ ─ ─ ─ ─ ─ ─│
                  └────────────────┘      ─ ─ ─ ─▶│                │
                                                  ├ ─ ─ ─ ─ ─ ─ ─ ─│
                         ...                      │                │
                                                  │                │
                           ▲                      │      ...       │
                           │                      │                │
                  ┌────────────────┐              │                │
                  │  RecordBatch   ├ ─ ─ ┐        │                │
                  └────────────────┘              │                │
                           ▲             │        ├ ─ ─ ─ ─ ─ ─ ─ ─│
                           │              ─ ─ ─ ─▶│                │
                           │                      └────────────────┘
                           │                                        
               ┏━━━━━━━━━━━━━━━━━━━━━━━┓                            
               ┃                       ┃       Single RecordBatch   
               ┃                       ┃                            
               ┃                       ┃                            
               ┃                       ┃                            
               ┃                       ┃                            
               ┃    GroupByHashExec    ┃                            
               ┃                       ┃                            
               ┃                       ┃                            
               ┃                       ┃                            
               ┃                       ┃                            
               ┃                       ┃                            
               ┃                       ┃                            
               ┗━━━━━━━━━━━━━━━━━━━━━━━┛                            

Describe the solution you'd like

If we had infinite time / engineering hours I think a better approach would actually be to change GroupByHash so it didn't create a single giant contiguous RecordBatch

Instead it would be better if GroupByHash produced a Vec<RecordBatch> and then incrementally fed those batches out

Doing this would allow the GroupByHash to release memory incrementally as it output. This is analogous to how @korowa made join output incremental in #8658

Perhaps something like

                            ▲                                        
                            │                                        
                   ┌────────────────┐                    Output      
  output stream    │  RecordBatch   │              RecordBatches are 
                   └────────────────┘              created in smaller
                            ▲                      chunks and emitted
                            │                          one by one    
                            │                                        
                            │                                        
                ┏━━━━━━━━━━━━━━━━━━━━━━━┓          ┌────────────────┐
                ┃                       ┃          │  RecordBatch   │
                ┃                       ┃          └────────────────┘
                ┃                       ┃          ┌────────────────┐
                ┃                       ┃          │  RecordBatch   │
                ┃                       ┃          └────────────────┘
                ┃    GroupByHashExec    ┃          ┌────────────────┐
                ┃                       ┃          │  RecordBatch   │
                ┃                       ┃          └────────────────┘
                ┃                       ┃                 ...        
                ┃                       ┃          ┌────────────────┐
                ┃                       ┃          │  RecordBatch   │
                ┃                       ┃          └────────────────┘
                ┗━━━━━━━━━━━━━━━━━━━━━━━┛                            
                                                    Vec<RecordBatch> 
                                                                     
                                                                     

Describe alternatives you've considered

No response

Additional context

@yjshen notes:

To improve AggExec's mono output pattern, #7065 might be similar to the idea of incremental output.

Activity

  1. guojidan commented on Mar 12, 2024

    @guojidan
    Contributor

    take

  2. guojidan commented on Mar 12, 2024

    @guojidan
    Contributor

    This issue is interesting, let me try implement it

  3. alamb commented on Mar 13, 2024

    @alamb
    ContributorAuthor

    FYI this issue may be tricky -- as it will be performance critical -- I will be happy to assist

  4. guojidan commented on Mar 22, 2024

    @guojidan
    Contributor

    this issue is a bit tricky for me 😢 , I can only think of the following approaches:
    change the GroupedHashAggregateStream::emit() function return a vector Result<Vec<RecordBatch>>, each RecordBatch num_rows equal GroupedHashAggregateStream::batch_size, and change ExecutionState::ProducingOutput(RecordBatch) to ExecutionState::ProducingOutput(Vec<RecordBatch>), then GroupedHashAggregateStream::poll_next() function returns one element of a vector at one loop, like let output = batchs.pop() source

  5. alamb commented on Mar 22, 2024

    @alamb
    ContributorAuthor

    this issue is a bit tricky for me 😢 , I can only think of the following approaches: change the GroupedHashAggregateStream::emit() function return a vector Result<Vec<RecordBatch>>, each RecordBatch num_rows equal GroupedHashAggregateStream::batch_size, and change ExecutionState::ProducingOutput(RecordBatch) to ExecutionState::ProducingOutput(Vec<RecordBatch>), then GroupedHashAggregateStream::poll_next() function returns one element of a vector at one loop, like let output = batchs.pop() source

    I think this approach sounds good -- nice proposal

    One thing that could help keep the PRs small and manageable would be to switch the APIs as described above but you could avoid having to change all the GroupAccumulators in one PR by returning return a Vec<> of size 1 for most of them.

    Then we can make subsequent PRs to switch over other groups accumulators as needed

  6. Rachelint commented on Jul 30, 2024

    @Rachelint
    Contributor

    Actually, I found the slice function is not trivial because the eager computation for the null count.
    One way I could think to solve it is making the computation lazy, see arrow-rs pr(q32 1.10~1.15x faster without the computation for null count):
    apache/arrow-rs#6155

    The alternative may be the way mentioned in this issue.

  7. Rachelint commented on Jul 30, 2024

    @Rachelint
    Contributor

    Hi @guojidan, seems there is no update for some months, can I pick this up?
    I want to try it and compare the effect with lazy null count computation.

  8. guojidan commented on Jul 31, 2024

    @guojidan
    Contributor

    Hi @guojidan, seems there is no update for some months, can I pick this up? I want to try it and compare the effect with lazy null count computation.

    Thanks. You can take this. I am working on another project now.

  9. joroKr21 commented on May 16, 2025

    @joroKr21
    Contributor

    I reached the same conclusion. Had a hash join after aggregation and it wanted to allocated > 16GB memory for couple hundred MB of aggregation state 😢

    I think this approach sounds good -- nice proposal

    One thing that could help keep the PRs small and manageable would be to switch the APIs as described above but you could avoid having to change all the GroupAccumulators in one PR by returning return a Vec<> of size 1 for most of them.

    Then we can make subsequent PRs to switch over other groups accumulators as needed

    @alamb why are you talking about accumulator APIs? ExecutionState::ProducingOutput is purely local to GroupedHashAggregateStream. I already tried implementing this change and it worked great for my use case - the join no longer ran out of memory 😄

  10. alamb commented on May 16, 2025

    @alamb
    ContributorAuthor

    @alamb why are you talking about accumulator APIs? ExecutionState::ProducingOutput is purely local to GroupedHashAggregateStream. I already tried implementing this change and it worked great for my use case - the join no longer ran out of memory 😄

    I was thinking that the output of the aggregate exec is both the group columns and the aggregate columns. If we want to produce the output without one giant array, I think we would need to update both the group columns and aggregates

    Note that @Rachelint is actively working on a PR

  11. joroKr21 commented on May 16, 2025

    @joroKr21
    Contributor

    Well the hacky solution is just EmitTo::First(batch_size) until done 😄
    I guess the concern is that it's not efficient because it's using Vec::split_off under the hood in most cases?
    Although that's not mandated by the API. But currently some plans simply don't work because of the memory overcounting.

  12. alamb commented on May 16, 2025

    @alamb
    ContributorAuthor

    Well the hacky solution is just EmitTo::First(batch_size) until done 😄 I guess the concern is that it's not efficient because it's using Vec::split_off under the hood in most cases? Although that's not mandated by the API. But currently some plans simply don't work because of the memory overcounting.

    I think that will effectively copy all the values potentially total_size/batch_size times.

    But it will result in smaller allocations of record batches for sure

  13. alamb commented on Aug 27, 2026

    @alamb
    ContributorAuthor

    I filed a ticket / epic to track the idea of blocked state management here

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

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions