Skip to content

[EPIC] Split Aggregation Logic into Dedicated Streams #22710

Description

@2010YOUY01

Is your feature request related to a problem or challenge?

* “Stream” here refers to an XxxStream in DataFusion, which implements the per-partition state machine for operators such as AggregateExec.

Conventional wisdom says we should maximize code reuse. In practice, over-applying this principle can lead to code that is difficult to understand, maintain, and extend.

GroupedHashAggregateStream is a good example. Today it is shared across many semantically distinct execution paths.

pub(crate) struct GroupedHashAggregateStream {

For example, in multi-stage repartition based hash aggregation, the partial and final aggregation stages have fundamentally different semantics:

  • Partial aggregation: raw input → partial state
  • Final aggregation: partial state → final result

* e.g. for avg(x), partial state is sum(x) and count(x) for each group, that is performed in partial stage. Final result means avg(x) for each group that directly maps to the output result.

There are additional semantic variants, such as partial state → partial state. Beyond that, there are several orthogonal dimensions:

  • Is the input ordered by the grouping keys? If so, streaming aggregation may be possible.
  • Does the aggregation exceed the memory budget and require spilling?
  • Are there specialized fast paths applicable to a particular execution path?

As more dimensions are multiplexed into a single implementation, complexity grows combinatorially:

Execution path count =
    semantic_variant_count
  × spilling_variant_count
  × ordering_variant_count
  × ...

At this point, the code looks like a neural network written in Rust: many interacting branches, but no clear separation of responsibilities.

Issues

Error-prone

The current implementation relies on combinations of flags to determine which execution path is active. This makes invalid states representable, increasing the risk of subtle bugs.

Difficult to test

It is nearly impossible to exhaustively test all execution paths and state transitions. As a result, invalid state combinations can easily escape test coverage.

Difficult to review

Review complexity grows with the number of multiplexed dimensions.

When reviewing a function, it is often unclear:

  • Which execution paths reach this code?
  • Is a change correct for all paths?
  • Does an optimization for one path introduce regressions in another?

Reasoning about correctness becomes increasingly difficult.

Difficult to extend

Performance engineering often requires specialization.

There are still several promising optimization opportunities for hash aggregation, but implementing them within the existing structure would further increase complexity, making the code even harder to understand and review.

Case Study: Blocked State Management

I think this is a concrete example of the challenges mentioned above: blocked state management is an important feature for memory-efficient hash aggregation. Despite significant effort from multiple very good contributors, it has still not landed after roughly three years when it was proposed.

My interpretation is that the existing implementation has accumulated enough complexity that substantial changes become difficult to design, review, and validate.

Proposed Solution

Split the heavily multiplexed GroupedHashAggregateStream into a set of focused streams.

Each stream should implement a single semantic execution path and encapsulate its own state machine.

I think it addresses all the existing issues mentioned above:

  • Invalid states become unrepresentable.
  • Test coverage becomes more targeted and practical.
  • Review scope becomes significantly smaller.
  • Specialized optimizations become easier to implement.

The tradeoff is some duplication of structs and state machine implementations. However, this is often preferable to concentrating all complexity into a single, highly coupled implementation.

Implementation Strategy

The migration can be performed incrementally.

Individual execution paths can be extracted into dedicated streams while leaving the existing implementation unchanged. Once all paths have been migrated, the original implementation can be removed.

AggregateExec::execute() {
    match self.choose_stream() {
        PartialMode => build_partial_stream(),
        FinalMode => build_final_stream(),
        // ...

        // Original implementation
        _ => build_fallback_stream(),
    }
}

Open Questions

This idea may also apply to other operators.

For example, joins often contain specialized semantics for semi, anti, and mark joins. Implementing short-circuit optimizations for these join types may be simpler if each variant is represented by its own dedicated state machine rather than being multiplexed into a single implementation.

Implementation Tracker

Smaller clean-ups to do

Describe the solution you'd like

No response

Describe alternatives you've considered

No response

Additional context

No response

Activity

  1. mkleen commented on Jun 2, 2026

    @mkleen
    Contributor

    take

  2. mkleen commented on Jun 2, 2026

    @mkleen
    Contributor

    @2010YOUY01 After some deeper review, i would like to resign from this task. This is outside my comfort zone.

  3. self-assigned this
    on Jun 3, 2026
  4. 2010YOUY01 commented on Jun 3, 2026

    @2010YOUY01
    ContributorAuthor

    @kumarUjjawal Thank you. I’m currently working on splitting out the partial and final aggregation paths. There are a few related paths as well, and I’m still figuring out the best way to organize the refactor.

    One orthogonal path you could try is the case where the input is already ordered by the grouping keys. Today, GroupsHashAggregation still performs regular hash aggregation there, with eager output for memory efficiency. That path may be worth specializing further for performance.

  5. kumarUjjawal commented on Jun 3, 2026

    @kumarUjjawal
    Contributor

    Thank you @2010YOUY01 for the heads up. I will look into the GroupsHashAggregation

  6. alamb commented on Jun 4, 2026

    @alamb
    Contributor

    Split the heavily multiplexed GroupedHashAggregateStream into a set of focused streams.

    I think this is a great idea @2010YOUY01 and I think it will in fact make it easier to evolve the grouping code

    Also heads up to @Rachelint as they have been working in this area.

  7. alamb commented on Jun 4, 2026

    @alamb
    Contributor

    Also FYI @avantgardnerio as this may make memory auditing / tracking easier

  8. changed the title [-]Split Aggregation Logic into Dedicated Streams[/-] [+][EPIC] Split Aggregation Logic into Dedicated Streams[/+] on Jun 5, 2026
  9. 44 remaining items

  10. rluvaton commented on Sep 1, 2026

    @rluvaton
    Member

    What is the scope of the two PRs? since implementing blocks is easier after the simplifications PR are merged (which are pending as your request)

  11. 2010YOUY01 commented on Sep 2, 2026

    @2010YOUY01
    ContributorAuthor

    @rluvaton All of them are aggregate planning related, so It's now good to continue simplification PRs on the state machines, I'll take a look shortly.

    There are other open PRs are changing the aggregate code, I'll try to coordinate in #24704 to avoid conflicts.

  12. added a commit that references this issue on Sep 6, 2026
    5b389eb
  13. alamb commented on Sep 10, 2026

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

Metadata

Metadata

Labels

enhancementNew feature or request

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions