Fix quadratic aggregation-in-order with serialized keys#110161
Fix quadratic aggregation-in-order with serialized keys#110161alexey-milovidov wants to merge 10 commits into
Conversation
With `optimize_aggregation_in_order = 1` and a multi-key `GROUP BY` that uses the serialized aggregation method (more than one key, all numbers or strings, e.g. a `UInt64` key plus a `String` key), the query could run for minutes and trip the stress-test hung-check while ignoring `max_execution_time` and cancellation. `AggregatingInOrderTransform` aggregates each block in small consecutive sub-ranges - one per sorting-key prefix - via `Aggregator::executeOnBlockSmall`. The `prealloc_serialized` method precomputes the serialized keys of the whole block in its state constructor, so that whole-block work was redone on every sub-range: a block with N distinct sorting-key prefixes cost O(N * block_rows), i.e. quadratic, and a single `work` call could spin for minutes. Choose the plain `serialized` method (lazy per-row key serialization) for in-order aggregation via a new `Aggregator::Params::aggregation_in_order` flag, keeping the small-block path linear. Results are unchanged; regular (non-in-order) aggregation still uses `prealloc_serialized`. Reproduced without parallel replicas: 50k distinct-prefix rows went from ~19s to ~0.2s, and scaling is now linear. CI report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?REF=master&sha=d39d027a4b06785e67285c3eafb2a65569d29f6c&name_0=MasterCI&name_1=Stress%20test%20%28azure%2C%20amd_tsan%29 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| /// In-order aggregation aggregates small sub-ranges of each block via executeOnBlockSmall. | ||
| /// Avoid the "prealloc serialized" method there: its whole-block key precomputation would be | ||
| /// redone per sub-range, making blocks with many distinct sorting-key prefixes quadratic. | ||
| params.aggregation_in_order = true; |
There was a problem hiding this comment.
params.aggregation_in_order is attached to the shared AggregatingTransformParams, so in the multi-stream in-order pipeline it also changes the Aggregator that MergingAggregatedBucketTransform uses later for the final merge (src/Processors/Transforms/MergingAggregatedMemoryEfficientTransform.cpp:394). That merge does not go through executeOnBlockSmall; for the common case where the input is already sorted by the full GROUP BY key, AggregatingInOrderTransform stays on the group_by_key == false branch and never hits the quadratic path (src/Processors/Transforms/AggregatingInOrderTransform.cpp:179-182). With the current flag, though, the final merge is still forced off prealloc_serialized at Aggregator.cpp:691, so this broadens the fix into a performance regression for multi-stream / full-key-sorted in-order queries. Can we keep the "avoid prealloc" choice local to the small-block hash-table path, or give the final merge its own Aggregator so it can preserve the old method choice?
There was a problem hiding this comment.
Confirmed: as written, the params.aggregation_in_order flag changes method_chosen on the whole shared Aggregator, so in the multi-stream (max_threads > 1) pipeline the final MergingAggregatedBucketTransform merge (Aggregator::mergeBlocks) is also steered off prealloc_serialized. Both paths stay correct and linear; the effect is that the whole-block merge of a full-key-sorted in-order query loses the prealloc_serialized batch-serialization optimization.
The narrowed variant this review suggests — keep method_chosen = prealloc_serialized for the whole-block/merge paths and derive a separate method_chosen_for_in_order used only by executeOnBlockSmall/mergeOnBlockSmall — is implemented in the sibling PR #110159, which also adds explicit multi-stream coverage. This PR is the simpler global-flag alternative, so which of the two approaches to keep is a design decision left to the author.
Separately, the CI failure here was the flaky-check TOO_SLOW on 04512_aggregation_in_order_serialized_keys, not the aggregation fix: the test left the in-order read unpinned, so the flaky check randomized it into an expensive max_block_size/optimize_read_in_order combination. Fixed in 4f3ef7e by pinning the read configuration (max_threads = 1, max_block_size = 16384, optimize_read_in_order = 1) and adding a max_execution_time guard, mirroring 04537_aggregation_in_order_serialized_keys. Verified locally on a debug build that the query runs in ~0.6s even with the adversarial flaky-check settings applied, and still matches regular aggregation.
There was a problem hiding this comment.
Follow-up on the CI status (superseding my earlier note that the flaky-check TOO_SLOW was only about the unpinned read):
The pinned test still failed Stateless tests (amd_debug, flaky check) on 04512_aggregation_in_order_serialized_keys (6/16 iterations, TIMEOUT_EXCEEDED at 46–126s > the 20s guard, all on MergeTreeSelect(pool: ReadPoolInOrder, algorithm: InOrder)). I reproduced and investigated this end-to-end:
- The fix itself is correct. On a freshly built binary the in-order query is linear: 200k rows in ~0.2s and 2M rows in ~1s at the pinned config, versus ~34s (quadratic) on a stale pre-fix binary. I ran every one of the 5 distinct failing flaky-check setting combinations (extracted from the CI log) against the fixed binary at 2M rows — all linear (~1–4s). This covers the single-stream path, the multi-stream
FinishAggregatingInOrderTransform/MergingAggregatedBucketTransformpath (verified viaEXPLAIN PIPELINE), and the parallel-replicas iteration (with a localparallel_replicascluster). The exact test file runs in ~0.65s and matches the reference. - The
amd_asan_ubsan,amd_tsan, andamd_msanflaky checks all ran04512(60 runs each) and passed. Onlyamd_debugfailed, with a profile consistent with a binary in which the fix was not effective (deep quadratic, uncancellable), and its log timestamps predate the current headb22accd2.
Given the fix is verified correct across all these configurations, the amd_debug failure is consistent with a stale build artifact. I merged the latest master (the branch was 136 commits behind; the merge is clean and leaves the fix intact) to force a fresh full rebuild of all configurations. Rebuilt locally and re-verified linear after the merge.
The design point above (global method_chosen flag here vs. the scoped method_chosen_for_in_order in #110159) still stands as the author's decision.
There was a problem hiding this comment.
Correcting my previous follow-up (which attributed the amd_debug failure to a stale build artifact — that was wrong).
The fresh build did not clear it: Stateless tests (amd_debug, flaky check) on 04512_aggregation_in_order_serialized_keys failed again at head b4623f4e68 (built after the master merge, so genuinely fresh with the fix in tree), timing out at ~57s against the 20s guard on MergeTreeSelect(pool: ReadPoolInOrder, algorithm: InOrder).
The real root cause is that the max_execution_time = 20 guard was too tight, not a code regression:
- The fix is linear. On the local build the fixed in-order path scales linearly with
N: 200k → 0.04s, 800k → 0.13s, 1600k → 0.19s (8x the data for ~4.7x the time; a quadratic regression would be ~64x). The full test still returns1 / 1 / 1. - The guard is a protection against the quadratic blowup, not a tight assertion on the linear runtime, and the two are orders of magnitude apart. On a debug or sanitizer build the linear path can still take tens of seconds under the parallel load of the flaky check, while the quadratic regression runs for many minutes. The 20s limit sat right in that debug/parallel-load band.
amd_asan_ubsan,amd_tsan, andamd_msanall passed04512; onlyamd_debug(unoptimized-O0, slowest under the flaky check's parallelism) tripped the 20s limit.
Fix pushed as a test-only change: raise both max_execution_time guards in 04512 from 20s to 120s, matching the sibling test in #110159, which hit the identical wall-clock-guard flake on its ASan flaky check and was resolved the same way. 120s clears the linear path with a wide margin on every build and is still far below the quadratic runtime (~300s+ on debug), so it still catches a regression without a false timeout on the fixed code.
The design point above (global method_chosen flag here vs. the scoped method_chosen_for_in_order in #110159) is unaffected and still stands as the author's decision.
There was a problem hiding this comment.
Follow-up with a definitive calibration (fresh amd-equivalent -O0 debug build of this exact branch, single-stream section-1 query — 200000 rows, max_block_size = 16384):
- With the fix (
params.aggregation_in_order = true→chooseMethod(..., avoid_prealloc_serialized = true)→serialized): ~0.3s, linear. The full04512test returns1 / 1 / 1. - With the fix disabled (rebuilt with
params.aggregation_in_order = false): ~26.6s, quadratic. Same query, same data.
So the fix is correct and the small-block in-order path is linear — a ~90× separation on this build.
Why the amd_debug flaky check keeps timing out is not a too-tight guard. The failing run landed on a pathologically overloaded runner (its Start ClickHouse Server step alone took 567s, Collect logs 345s), and all 5 04512 iterations were killed at the 120s max_execution_time. The problem is that the wall-clock guard cannot cleanly separate the two runtimes on such a box:
- The quadratic CPU time (~27s on a fast box) is comparable to — or below — the wall-clock the linear path reaches once the box is heavily oversubscribed.
- I ruled out contention as an independent cause: 24 concurrent copies of the query on a 96-core box still finished in 0.3s total, so no realistic oversubscription inflates a 0.3s-CPU query to 120s on its own — it takes an extreme-overload box like this one.
Net: the guard currently sits between "quadratic on a fast box" (~27s, under 120s → a real regression could slip through on a fast runner) and "linear on a badly overloaded box" (>120s → false timeout), so box load dominates the signal rather than linear-vs-quadratic.
I did not touch the guard again (it isn't the right lever) or narrow the C++ (that's the reserved global-vs-scoped design decision). I merged latest master and pushed to re-trigger a fresh run on a normal runner. If the flake persists, the robust options are (a) widen the separation so the quadratic runtime dwarfs any overload-inflated linear runtime (e.g. a single large block so it is O(rows^2) rather than O(rows * block_size)), or (b) a contention-independent assertion (compare the CPU time of the in-order query to the non-in-order reference instead of wall-clock). Both are your call.
The other red checks are unrelated known flakes: 03988_cached_read_big_at (flaky-labelled, amd_tsan) and the Block structure mismatch logical error (open issue #109215).
There was a problem hiding this comment.
Follow-up (supersedes my previous calibration note): I stopped trying to tune the wall-clock guard and rewrote the test to not depend on wall-clock at all.
Definitive findings, on a fresh -O0 debug build of this branch (chooseMethod(..., avoid_prealloc_serialized) in tree):
- The fix is linear. Section-1 query (
500k/1Mrows,max_block_size = 16384,max_threads = 1): ~0.5s / ~0.9s of CPU. Disabling the flag (params.aggregation_in_order = false) rebuilds to ~29s / ~145s for the same query — quadratic, as expected. - Wall-clock is not a usable signal here. Applying the flaky-check IO-torture settings (
min_bytes_to_use_direct_io = 1,page_cache_inject_eviction = 1, …) inflated the query's wall-clock by ~60% but left itsOSCPUVirtualTimeMicrosecondsunchanged (0.926s → 0.929s). On an oversubscribed runner a linear query's wall-clock stretches arbitrarily, so nomax_execution_timevalue can sit between "linear under load" and "quadratic on an idle box" — which is exactly why the 20s→120s bump didn't help and it still timed out at 122–133s. - There is no memory/allocation discriminator either:
ArenaAllocBytesis ~16 MB for both the linear and the quadratic path. The only observable that separates them is CPU time.
So the test now runs the in-order aggregation once on its own (tagged with a CPUGUARD marker) and asserts, via system.query_log, that its OSCPUVirtualTimeMicroseconds stays under 3s. CPU time measures cycles actually consumed and is unaffected by contention, so it separates linear (~0.5 CPU-s) from quadratic (tens of CPU-s on release, well over a minute on the instrumented builds) by more than an order of magnitude regardless of runner load. Verified end-to-end against a real server: 1/1/1/1, deterministic across repeated runs in both reused and fresh databases; a query over the threshold correctly evaluates the guard to 0. The correctness checks (in-order == hash, single- and multi-stream, MergingAggregatedBucketTransform present) are unchanged.
The merge-path scope question (this shared-flag design vs. keeping the override local to executeOnBlockSmall/mergeOnBlockSmall as in #110159) is a design decision and is left untouched here.
…heck The `Stateless tests (amd_debug, flaky check)` run failed the new test with `TOO_SLOW` (estimated ~648s), because the test left `max_block_size`, `max_threads` and `optimize_read_in_order` unset, so the flaky check randomized them into an expensive in-order read (e.g. `max_block_size = 72374`, `optimize_read_in_order = 0`). The aggregation fix itself is correct and linear; the slowness was in `MergeTreeSelect(pool: ReadPoolInOrder)` under that randomized configuration, not in `Aggregator::executeOnBlockSmall`. Pin the in-order aggregation to a fixed, small configuration (`max_threads = 1`, `max_block_size = 16384`, `optimize_read_in_order = 1`) so the flaky check cannot pick the slow read path, and add a `max_execution_time` guard so a future regression (the quadratic behaviour) still fails fast and deterministically. This mirrors the sibling test `04537_aggregation_in_order_serialized_keys`, which passes the flaky check with the same settings. Verified locally with a debug build: the query runs in ~0.6s even with the adversarial flaky-check settings applied on the command line, and the result still matches regular aggregation. CI report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=110161&sha=bfd92f8898881985acef3d8836d0f980d2191a6b&name_0=PR&name_1=Stateless%20tests%20%28amd_debug%2C%20flaky%20check%29 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
📊 Cloud Performance Report ✅ AI verdict: This PR makes aggregation-in-order pick the plain serialized method instead of prealloc_serialized for multi-key GROUP BY, fixing a quadratic re-serialization blowup; it does not change execution for single-key hash aggregations. The two flagged ClickBench queries (Q4 and Q15) are both single-key GROUP BYs, and the math correctly kept them as no_change: their -19.6% and -18.0% deltas sit inside master's current variance band and cannot plausibly be caused by this change. The suppression is correct and consistent with the diff; no real per-query regression or improvement is attributable to this PR. clickbench🟢 No significant changes tpch_adapted_1_official🟢 No significant changes Debug info
|
… path The AI review asked for coverage of the multi-stream in-order pipeline: when the table is sorted by the full GROUP BY key and read in several streams, the per-stream results are merged by MergingAggregatedBucketTransform via Aggregator::mergeBlocks, which shares the method choice with the small-block path fixed by this PR. Add a section to 04512_aggregation_in_order_serialized_keys that pins a 4-part, 4-thread in-order aggregation, asserts via EXPLAIN PIPELINE that MergingAggregatedBucketTransform is actually present, and cross-checks the result against regular hash aggregation. #110161 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…order-serialized-quadratic
The `amd_debug` flaky check timed out `04512_aggregation_in_order_serialized_keys` at ~57s against the fixed (linear) in-order aggregation, while the query-level `max_execution_time` guard was pinned to 20s. The guard is a protection against the quadratic blowup, not a tight assertion on the linear runtime, and the two are orders of magnitude apart. On a release build the linear path takes a fraction of a second while the quadratic one takes tens of seconds; on a debug or sanitizer build the linear path can still take tens of seconds under the parallel load of the flaky check (which is why the 20-second limit flaked), while the quadratic regression runs for many minutes. Raising the limit to 120s (matching the sibling test in #110159) clears the linear path with a wide margin on every build and is still far below the quadratic runtime, so it catches a regression without any risk of a false timeout on the fixed code. Verified on the local `RelWithDebInfo` build that the fixed in-order path is linear: N=200000 -> 0.04s, N=800000 -> 0.13s, N=1600000 -> 0.19s (quadratic would be ~16x per 4x of N); the full test still produces the expected `1/1/1`. CI report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=110161&sha=b4623f4e6827d7339aefa49cb9f457033152670b&name_0=PR&name_1=Stateless%20tests%20%28amd_debug%2C%20flaky%20check%29 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…order-serialized-quadratic
LLVM Coverage Report
Changed lines: Changed C/C++ lines covered: 12/12 (100.00%) · Uncovered code |
…order-serialized-quadratic
`04512_aggregation_in_order_serialized_keys` kept flaking on the `Stateless tests (amd_debug, flaky check)` with `TIMEOUT_EXCEEDED` on `MergeTreeSelect(pool: ReadPoolInOrder, algorithm: InOrder)`, even after the `max_execution_time` guard was raised from 20s to 120s (it still timed out at 122-133s). The root cause is that a wall-clock guard is not a usable signal for this regression: the query's wall-clock is dominated by how oversubscribed the CI runner is, not by whether the code is linear or quadratic. A linear query on a heavily loaded box can run longer than a quadratic one on an idle box, so the guard measures box load, and no `max_execution_time` value can sit between the two. The bug is a pure CPU-time blowup: the results are identical and, as verified empirically, the memory/allocation profile (`ArenaAllocBytes`) is the same for the linear and quadratic paths. The only observable that discriminates them is CPU time, and CPU time is unaffected by box contention (verified: applying the IO-torture flaky-check settings inflated the query's wall-clock but left its `OSCPUVirtualTimeMicroseconds` unchanged). So the guard now runs the in-order aggregation once on its own and asserts, via `system.query_log`, that its `OSCPUVirtualTimeMicroseconds` stays below 3s. The fixed (linear) path uses ~0.5 CPU-seconds; the quadratic regression uses tens of CPU-seconds on release and well over a minute on the instrumented debug/sanitizer builds - a separation of more than an order of magnitude that holds regardless of runner load, so the test catches a regression with no risk of a false timeout. The correctness checks (in-order result equals hash aggregation, single-stream and multi-stream, and `MergingAggregatedBucketTransform` present in the multi-stream pipeline) are unchanged.
…order-serialized-quadratic
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Duplicate of #110159. |
Fixes a quadratic slowdown (effectively a hang) in aggregation-in-order.
With
optimize_aggregation_in_order = 1and a multi-keyGROUP BYthat uses the serialized aggregation method (more than one key, all numbers or strings, e.g. aUInt64key plus aStringkey), the query could run for minutes and trip the stress-test hung-check while ignoringmax_execution_timeand cancellation.AggregatingInOrderTransformaggregates each block in small consecutive sub-ranges — one per sorting-key prefix — viaAggregator::executeOnBlockSmall. Theprealloc_serializedmethod precomputes the serialized keys of the whole block in its state constructor, so that whole-block work was redone on every sub-range: a block withNdistinct sorting-key prefixes costO(N * block_rows)(quadratic), and a singleworkcall could spin for minutes.The fix chooses the plain
serializedmethod (lazy per-row key serialization) for in-order aggregation via a newAggregator::Params::aggregation_in_orderflag, keeping the small-block path linear.executeOnBlockSmallis used only byAggregatingInOrderTransform, so no other path is affected. Results are unchanged; regular (non-in-order) aggregation still usesprealloc_serialized.Reproduced locally without parallel replicas — 50k distinct-prefix rows went from ~19s to ~0.2s, and scaling is now linear (500k rows in ~0.4s).
Surfaced by the master stress-test hung-check:
https://s3.amazonaws.com/clickhouse-test-reports/json.html?REF=master&sha=d39d027a4b06785e67285c3eafb2a65569d29f6c&name_0=MasterCI&name_1=Stress%20test%20%28azure%2C%20amd_tsan%29
Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):
Fixed a quadratic slowdown of
GROUP BYwithoptimize_aggregation_in_order = 1over multiple keys that use the serialized aggregation method (e.g. a numeric key together with aStringkey). Such queries could run for minutes on inputs with many distinct sorting-key prefixes; they are now linear.Documentation entry for user-facing changes
🤖 Generated with Claude Code