refactor(hash-aggr): Support spilling for partial and final mode aggregation - #24061
Conversation
partial and final mode aggregation
| /// into an ordered streaming aggregation, which ensures bounded memory usage and | ||
| /// evaluates the final result. | ||
| /// - [`OrderedFinalAggregateStream`] is reused for the streaming aggregation. | ||
| pub(crate) struct PartialHashAggregateStream { |
There was a problem hiding this comment.
We could put partial and final stream to two different files, I plan to this after this PR.
| | 3 | 1 | 2.0 | | ||
| | 3 | 2 | 5.0 | | ||
| | 4 | 3 | 11.0 | | ||
| | 4 | 1 | 4.0 | |
There was a problem hiding this comment.
they're both valid partial aggregation output, due to early emitting under memory limit.
| // enlarge memory limit to let the final aggregation finish | ||
| new_spill_ctx(2, 2600) | ||
| // Enlarge the memory limit enough to replay spilled states. | ||
| new_spill_ctx(2, 4640) |
There was a problem hiding this comment.
The legacy implementation underestimate the memory usage somehow, so here it relaxes the memory budget
|
|
||
| assert_eq!(3, output_rows); | ||
| if spill { | ||
| // When spilling, the output rows metrics become partial output size + final output size |
There was a problem hiding this comment.
This should be a bug in the legacy implementation. Final aggregation should return the same output_rows regardless of the spilling condition.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #24061 +/- ##
==========================================
+ Coverage 80.88% 81.05% +0.17%
==========================================
Files 1101 1105 +4
Lines 375720 380386 +4666
Branches 375720 380386 +4666
==========================================
+ Hits 303895 308318 +4423
- Misses 53729 53848 +119
- Partials 18096 18220 +124 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
👀 |
alamb
left a comment
There was a problem hiding this comment.
Thank you @2010YOUY01
I went through this PR carefully and I (again) found it clear and well written and easy to follow
It is somewhat unfortunate, perhaps, that the state machines for the different spillable hash streams are so similar (we now have I think 3 state machines that have the various spilling / merging states) -- where I think the difference in the streams is largely related to what triggers a spill (vs an emit) and the type of hash table they have
Maybe we can find some way to extract out the state machine into a common structure (with inline functions or something) to capture the differences
| let spill_schema = Arc::clone(spill_manager.schema()); | ||
| // The merge and replay table are two components of the same aggregate | ||
| // operator. Keep them under one consumer registration so a fair memory | ||
| // pool does not divide this operator's quota between its own phases. |
There was a problem hiding this comment.
is this a behavior change? Or does it mirror what the old operator does?
(It seems reasonable to me, but I wanted to check)
There was a problem hiding this comment.
Yes, it mirrors the existing behavior. It's only get caught by the existing test after this PR, likely because partial/final 2-staged aggregation has better test coverage.
| } | ||
|
|
||
| if self.hit_soft_group_limit(original_state.hash_table()) { | ||
| if self.hit_soft_group_limit(&hash_table) { |
There was a problem hiding this comment.
Claude code flagged this as a potential bug -- if the limit is set, this code path appears to simply drop the spill_context and any spilled content thus far
There was a problem hiding this comment.
Maybe update to
let spilled = spill_context
.as_ref()
.is_some_and(|ctx| ctx.has_spills());
if self.hit_soft_group_limit(&hash_table) && !spilled {
// existing early-output path
}🤔
But somehow you have to switch to emitting 🤔
There was a problem hiding this comment.
Updated in 084f633
I applied the same logic: if spilled then don't trigger soft limit optimization. Those limits are usually small constants, so they're unlikely to be co-exist with spilling, so I think this extra check would be enough.
BTW I found only good AI models are able to find such tricky bugs effectively, they're really hard to construct tests for. It's quite valuable to let coding agents scan the codebase to find logic inconsistencies.
| original_state, | ||
| )); | ||
| // Check memory reservation, and potentially spill. | ||
| let timer = elapsed_compute.timer(); |
There was a problem hiding this comment.
a nit here is that you could potentially use the same timer as above rather than making a new one here
There was a problem hiding this comment.
It seems not possible, timer.done() would take self.
There was a problem hiding this comment.
As in I was thinking that you could avopid timer.done() -- as the destructor also stops the timer I think
| &original_state, | ||
| PartialHashAggregateState::SkippingAggregation { .. } | ||
| )); | ||
| let PartialHashAggregateState::SkippingAggregation { mut hash_table } = |
There was a problem hiding this comment.
you could potentially avoid these internal errors by simply passing in hash_table rather than an original_state
The call site already matched on the type of PartialHashAggregateState
There was a problem hiding this comment.
I think the benefit of this pattern is, all the logic are implemented within this state function, and the main event loop poll_next() can be kept minimal, the entry-condition check like this one won't get scattered to 2 places.
The downside, as you pointed out, is that it is more verbose. I don’t have a strong preference at this point.
There was a problem hiding this comment.
Me neither - this way is fine too
| self.input = Box::pin(EmptyRecordBatchStream::new(input_schema)); | ||
| } | ||
|
|
||
| fn break_with_err(error: DataFusionError) -> FinalHashAggregateStateTransition { |
There was a problem hiding this comment.
this structure is quite elegant and easy to follow 👍
|
@alamb thank you for the review |
|
Thaks again @2010YOUY01 |
|
I am keen to get this refactor wrapped up before DF 55 so merging it in |
Which issue does this PR close?
Part of #22710
Rationale for this change
This PR adds existing spilling feature into the new 2-staged (partial and final) aggregation. The high-level implementation idea is the same as the legacy implementation.
For the algorithm description for this feature, see top comment change at
datafusion/physical-plan/src/aggregates/hash_stream.rsWhat changes are included in this PR?
The key changes to the operator state machine are:
In file
datafusion/physical-plan/src/aggregates/hash_stream.rsPartialHashAggregateStream::poll_next()FinalHashAggregateStream::poll_next()Use this as the starting point, you can navigate to all the related changes, for example adding new states to implement larger-than-memory execution.
This PR also includes small fixes to memory reservation in
OrderedFinalAggregateStream. The bugs are caught by existing tests on aggregation spilling that is enabled in this PR.Are these changes tested?
Existing tests
codecovAre there any user-facing changes?
No