Repository navigation
fix: release per-batch accumulator scratch after a clustered aggregate emit under memory pressure - #26782
Conversation
…emit Add GroupsAccumulator::shrink_to_fit, a no-op by default, and call it for every accumulator from ClusteredAggregateTable::shrink_to_fit. HllGroupsAccumulator drops its per-batch hashes, and GroupsAccumulatorAdapter drops its per-batch scratch, which it otherwise keeps while the active group remains. Closes apache#26781. Co-authored-by: Isaac <no-reply@databricks.com>
The clustered final and single streams also call ClusteredAggregateTable::shrink_to_fit after the memory-pressure emit. Add a Single-mode APPROX_DISTINCT test, and let the clustered test helper take the aggregate function. Co-authored-by: Isaac <no-reply@databricks.com>
d746c88 to
f2f305d
Compare
There was a problem hiding this comment.
Looks good to me. Thanks @viirya
I also had the agent do an ablation test and confirm that tests fail without this chaneg
---- aggregates::clustered_partial_stream::tests::accumulator_scratch_under_memory_pressure stdout ----
Error: Context("aggregates approx_distinct", ResourcesExhausted("Failed to allocate additional 122.9 KB for ClusteredPartialAggregateStream[0] with 5.4 KB already allocated for this reservation - 94.6 KB remain available for the total memory pool: greedy(used: 5.4 KB, pool_size: 100.0 KB)"))
---- aggregates::clustered_single_stream::tests::completed_groups_release_accumulator_scratch stdout ----
Error: ResourcesExhausted("Failed to allocate additional 58.9 KB for ClusteredSingleAggregateStream[0] with 5.4 KB already allocated for this reservation - 58.6 KB remain available for the total memory pool: greedy(used: 5.4 KB, pool_size: 64.0 KB)")| self.buffer.group_indices.clear(); | ||
| self.buffer.group_indices.shrink_to_fit(); | ||
| for acc in &mut self.buffer.accumulators { | ||
| acc.shrink_to_fit(); |
There was a problem hiding this comment.
I think this is the actual change to call shrink-to-fit on the accumulators
There was a problem hiding this comment.
take_state_batch below has the same gap. The partial stream calls it in the OOM branch for partial group clustering. It clears the key and index buffers but leaves HllGroupsAccumulator::hashes counted, and Outputting then reserves memory_size() again. From reading the code (I haven't run it), APPROX_DISTINCT(k0), APPROX_DISTINCT(v) GROUP BY k0, k1 with the input sorted on k0 only should still fail under the 100 KiB pool, because the two hashes buffers alone are about 128 KB. Could take_state_batch run the same accumulator loop?
There was a problem hiding this comment.
Good catch, confirmed. With APPROX_DISTINCT(v) GROUP BY k, k2 over input sorted on k only, a 64 KiB limit failed after the emit with "Failed to allocate additional 64.0 KB ... with 600.0 B already allocated": the table was empty, but hashes was still counted.
The non-clustered AggregateHashTable::take_state_batch had the same gap. It is used by the partial hash stream's early emit and by the single/final hash streams before spilling. A partial APPROX_DISTINCT(v) GROUP BY k over unsorted input failed the same way under 64 KiB.
In 32b641f, both take_state_batch now call shrink_to_fit on every accumulator. New tests, each of which fails without its fix:
clustered_partial_stream::tests::emitted_states_release_accumulator_scratch(partial group clustering)hash_stream::tests::emitted_states_release_accumulator_scratch(no clustering)
comphead
left a comment
There was a problem hiding this comment.
Thanks @viirya. Releasing scratch through a no-op trait default mirrors GroupValues::shrink_to_fit and leaves existing implementations untouched, which looks right to me.
Design questions:
- The next
update_batchregrows the scratch, so under sustained pressure every batch allocates and frees it again, and the real peak duringupdate_batchstays the same. Only the reservation between batches fits. That matches how #26726 treatsgroup_indices, so it is fine for this PR. ForHllGroupsAccumulator, hashing in fixed-size chunks (slices ofarrayare zero-copy) would boundhashesregardless of batch size and make its override unnecessary. Worth a follow-up? - The shrink only runs after a completed group is emitted, because
take_completed_state_batch()?.ok_or(oom)?comes first. A batch that holds one long-running key still fails under the same limit, although shrinking alone would make the table fit. Was shrinking before giving up considered?
Nit:
groups.shrink_to_fit()inHllGroupsAccumulatorandstates.shrink_to_fit()in the adapter look like no-ops on this path. With full clustering only the active group stays, andsplit_vec_min_allocalready moves it into a fresh allocation when most groups are emitted. Dropping them would keep both overrides to the scratch they actually release.
| fn shrink_to_fit(&mut self) { | ||
| let vec_size_pre = self.states.allocated_size(); | ||
| self.states.shrink_to_fit(); | ||
| self.adjust_allocation(vec_size_pre, self.states.allocated_size()); | ||
| let scratch = std::mem::take(&mut self.scratch); | ||
| self.free_allocation(scratch.allocated_size()); | ||
| } |
There was a problem hiding this comment.
free_scratch_if_empty already has the take-and-free logic. Could both call a small free_scratch() helper, so the scratch accounting lives in one place?
There was a problem hiding this comment.
Done. free_scratch_if_empty and shrink_to_fit both call a new free_scratch().
| /// release that scratch too, or reserving the table fails the limit | ||
| /// although the active group needs a few KB. The output must not change. | ||
| #[tokio::test] | ||
| async fn accumulator_scratch_under_memory_pressure() -> Result<()> { |
There was a problem hiding this comment.
This could reuse the shared helpers that completed_groups_release_accumulator_scratch already uses. That drops the run_aggregates split, the local build, the Aggregates alias and three imports:
for udaf in [approx_distinct_udaf(), any_value_udaf()] {
let plan = aggregate_over_sorted_input(
AggregateMode::Partial,
&["k"],
distinct_key_batches(),
udaf,
)?;
assert_eq!(plan.group_clustering_mode(), &GroupClusteringMode::Full);
assert_output_unchanged_under_limit(&plan, 64 * 1024).await?;
}It builds one APPROX_DISTINCT instead of two, so the limit has to sit below a single hashes buffer, as in the single-mode test. I expect 64 KiB to work for both cases but haven't run it.
All three streams go through the same ClusteredAggregateTable::shrink_to_fit, and the single stream's call site is already covered by completed_groups_release_batch_capacity. So with the loop above, completed_groups_release_accumulator_scratch repeats the same path and one of the two tests could go.
There was a problem hiding this comment.
Done, thanks. 64 KiB works for both APPROX_DISTINCT and ANY_VALUE. Without GroupsAccumulator::shrink_to_fit, they fail asking for 58.9 KB and 283.0 KB. The test is now completed_groups_release_accumulator_scratch. I dropped run_aggregates and the single-mode test.
Both ClusteredAggregateTable::take_state_batch (partial group clustering) and AggregateHashTable::take_state_batch (partial hash early emit, single/final hash spills) now call GroupsAccumulator::shrink_to_fit, so the per-batch scratch no longer stays reserved after every state is emitted. Add a test for each. Review follow-ups: drop the no-op groups/states shrinks, share free_scratch() in GroupsAccumulatorAdapter, and consolidate the clustered tests onto the shared helpers. Co-authored-by: Isaac <no-reply@databricks.com>
|
Thanks @alamb for the review and the ablation. @comphead, thanks for the careful review. I pushed 32b641f:
On the design questions:
|
sunchao
left a comment
There was a problem hiding this comment.
The three new regression tests passed. I found two sorted inputs where the stream completes a group, tries to release scratch, and still exhausts the pool. Releasing the specific buffer identified in each comment let the same input finish under the same 16 KiB limit.
I ran 348 repository aggregate tests against the production source at 32b641fb7. All passed. The PR build and the control build used Rust 1.99.0 and the same compatible dependency lock with Arrow 60.0.0 because the local registry lacked the pinned uuid 1.29.0.
| } | ||
|
|
||
| fn shrink_to_fit(&mut self) { | ||
| self.free_scratch(); |
There was a problem hiding this comment.
[P2] Release the active group’s empty row-index buffer as well
free_scratch() releases the adapter-wide Scratch, but each surviving AccumulatorState also has an indices: Vec<u32>. invoke_per_accumulator charges its capacity as it grows, then calls indices.clear() after consuming the rows. The new hook leaves that empty allocation charged.
I reproduced this with a fully sorted partial ANY_VALUE(v) GROUP BY k, one 8,192-row batch, two keys, and a 16 KiB greedy pool. With keys [0, 1, 1, …, 1], group 0 completes and the active group retains 8,192 index slots. The stream then fails reserving another 27.1 KiB. Reversing the frequencies to [0, 0, …, 0, 1] returns both groups under the same limit. Independently, an adapter-only probe measured 33,032 bytes after shrinking, including 32,768 bytes of empty indices. There is a completed group to emit here, which distinguishes this from #26911. In a control build that released those empty indices and updated the accounting, the original input returned both groups at 16 KiB.
Could shrink_to_fit() release unused index capacity in the remaining states and subtract the released bytes from allocation_bytes? A skewed active-group case would complement the current distinct-key fixture.
There was a problem hiding this comment.
Thanks, confirmed: your skewed input ([0, 1, 1, …, 1]) failed under 16 KiB asking for 27.1 KB. In a8b5fb3, GroupsAccumulatorAdapter::shrink_to_fit also releases the empty indices of the remaining states and subtracts them from allocation_bytes. It skips non-empty indices, which only a failed update leaves behind. New test: clustered_partial_stream::tests::active_group_releases_adapter_indices.
| /// accumulator may keep a buffer it sized for the last input batch. | ||
| /// | ||
| /// The default does nothing. | ||
| fn shrink_to_fit(&mut self) {} |
There was a problem hiding this comment.
[P2] Ordered FIRST_VALUE / LAST_VALUE also retain counted batch scratch
FirstLastGroupsAccumulator uses the default hook. Its take_state truncates extreme_of_each_group_buf.1, a BooleanBufferBuilder used to track each batch's winners. Truncation preserves capacity, and size() still charges that capacity. The bitmap reached 16 KiB in my test even though only one group remained.
I reproduced this with a fully clustered partial FIRST_VALUE(v ORDER BY v) GROUP BY k over one batch of 131,072 distinct sorted Int64 keys (v = k). The 16 KiB greedy pool refused another 11.1 KiB after the completed prefix was emitted. With 32 KiB it returned all 131,072 rows. The 8,192-key control also succeeded at 16 KiB. Rebuilding just the winner bitmap at the surviving group count let the original 131,072-key input return all rows at 16 KiB.
Could the native first/last accumulator release its batch scratch through this hook and cover the ordered case? The ORDER BY matters because it selects the native implementation.
There was a problem hiding this comment.
Good find, thanks. I missed this in the sweep. BooleanBufferBuilder::truncate keeps the capacity, and size() counts it. Your 131,072-key input failed under 16 KiB asking for 11.1 KB.
In a8b5fb3, FirstLastGroupsAccumulator::shrink_to_fit rebuilds the scoreboard bitmap at the remaining group count, all false, and clears extreme_buf_dirty. Both users of the bitmap accept that state: the pre-ordered path expects it all false between batches, and get_filtered_extreme_of_each_group resets it when it starts. The other fields don't need it: extreme_of_each_group_buf.0 goes through take_needed, and is_sets is rebuilt by finish(). New test: clustered_partial_stream::tests::completed_groups_release_first_value_bitmap, using FIRST_VALUE(v ORDER BY v).
| .build_arc()?, | ||
| ), | ||
| ); | ||
| let output = collect(aggregate.execute(0, context)?).await?; |
There was a problem hiding this comment.
Optional test cleanup. The new collect_under_limit(&aggregate, 64 * 1024) already constructs a migration-enabled context and collects the stream. Could this reuse it and assert early_emit_count > 0, as the existing hash-stream test does? The row count alone could also pass if a future skip-aggregation threshold sends the input straight through.
There was a problem hiding this comment.
Done. It now uses collect_under_limit and asserts early_emit_count > 0.
…to_fit GroupsAccumulatorAdapter keeps the capacity of each group's row indices after a batch, and FirstLastGroupsAccumulator truncates its per-batch scoreboard bitmap without releasing its capacity. Release both in shrink_to_fit, and cover them with skewed ANY_VALUE and ordered FIRST_VALUE tests. The hash stream test now reuses collect_under_limit and checks early_emit_count. Co-authored-by: Isaac <no-reply@databricks.com>
sunchao
left a comment
There was a problem hiding this comment.
Thanks for adding both 16 KiB reproductions. At a8b5fb3be, the skewed ANY_VALUE case releases the surviving group's empty row indices and updates the accounting. The ordered FIRST_VALUE case releases the old winner bitmap while preserving the active group's result. The hash-stream test also now checks that early emission actually happened.
All 350 physical-plan aggregate tests and 44 first/last tests passed locally. I used Rust 1.99.0 and a compatible dependency lock because the local registry lacked the pinned smallvec version. The fork Rust workflow passed on this commit.
One separate follow-up for the preordered first/last fast path. After 131,072 groups, the empty touched_groups_buf can still hold 1 MiB while size() reports 296 bytes for one remaining group. That undercount predates this PR. Tracking its capacity in size() and releasing it alongside the bitmap would make the pool reflect the actual allocation. The two reported reservation failures are fixed.
Which issue does this PR close?
Rationale for this change
#26726 lets a fully clustered aggregate (partial, final and single) continue under memory pressure: it emits the completed groups, then releases the capacity that the group values and
group_indicesgrew for the batch, so that reserving the table fits again. Accumulators can also keep per-batch scratch thatsize()counts, andGroupsAccumulatorhad no way to release it. Such a query still fails withResourcesExhausted, although the table holds only the active group.For example, with 8192-row batches and a 100 KiB pool:
APPROX_DISTINCT(k), APPROX_DISTINCT(v) GROUP BY k: eachHllGroupsAccumulatorkeeps a 64 KiBhashesbuffer, and the stream fails to reserve ~123 KB.ANY_VALUE(v) GROUP BY k, or any aggregate without a nativeGroupsAccumulator:GroupsAccumulatorAdapterkeeps its per-batchScratch(groups_with_rows,offsets,batch_indices, ~160 KiB). It releases that only when every group is emitted, but the clustered emit keeps the active group. The stream fails to reserve ~283 KB.What changes are included in this PR?
GroupsAccumulator::shrink_to_fit, which does nothing by default, documented likeGroupValues::shrink_to_fit.ClusteredAggregateTable::shrink_to_fitcalls it for every accumulator, throughHashAggregateAccumulator::shrink_to_fit. The clustered partial, final and single streams all callClusteredAggregateTable::shrink_to_fitafter the memory-pressure emit, so all three release accumulator scratch.ClusteredAggregateTable::take_state_batch(partial group clustering emits every state) and the non-clusteredAggregateHashTable::take_state_batch(partial hash early emit, single/final hash spills) call it too, since the accumulators otherwise keep their scratch reserved after every state is emitted.HllGroupsAccumulatordropshashes.GroupsAccumulatorAdapterdrops itsScratchthrough a sharedfree_scratch(), and the empty rowindicesof the remaining groups. Both updateallocation_bytes.FirstLastGroupsAccumulator(orderedFIRST_VALUE/LAST_VALUE) rebuilds its per-batch scoreboard bitmap at the remaining group count, sincetake_statetruncates it but keeps its capacity.The other
GroupsAccumulatorimplementations keep only per-group state (or no counted per-batch scratch), so they keep the default.ForeignGroupsAccumulator(FFI) also keeps the default, because forwarding the call would need a new function pointer inFFI_GroupsAccumulator. That can be a follow-up if a foreign accumulator needs it.What is the testing strategy for this PR?
New tests, each of which fails with
ResourcesExhaustedwithout its part of the change, under aGreedyMemoryPool(64 KiB unless noted) that fits the active groups but not the scratch:clustered_partial_stream::tests::completed_groups_release_accumulator_scratch: full group clustering, each batch boundary shares a key, exact comparison with an unbounded run. CoversAPPROX_DISTINCT(HllGroupsAccumulator) andANY_VALUE(GroupsAccumulatorAdapter).clustered_partial_stream::tests::active_group_releases_adapter_indices: the active group holds most rows of the batch, so the adapter must release itsindices(16 KiB pool).clustered_partial_stream::tests::completed_groups_release_first_value_bitmap:FIRST_VALUE(v ORDER BY v)over 131,072 groups (16 KiB pool).clustered_partial_stream::tests::emitted_states_release_accumulator_scratch: partial group clustering (take_state_batch).hash_stream::tests::emitted_states_release_accumulator_scratch: unsorted input, partial hash early emit (AggregateHashTable::take_state_batch), checked throughearly_emit_count.clustered_test_util::sum_over_sorted_inputnow delegates to a newaggregate_over_sorted_inputthat takes the aggregate function, which in turn usesaggregate_expr_over_sorted_inputto configure the expression (for exampleORDER BY v). A newcollect_under_limitruns a plan under a limit.Are there any user-facing changes?
A new provided method,
GroupsAccumulator::shrink_to_fit, with a no-op default. Existing implementations don't need to change.This pull request and its description were written by Isaac.