Add GradualResizeProcessor to limit effective parallelism for GROUP BY on small data volumes - #99495
Add GradualResizeProcessor to limit effective parallelism for GROUP BY on small data volumes#99495alexey-milovidov wants to merge 189 commits into
GradualResizeProcessor to limit effective parallelism for GROUP BY on small data volumes#99495Conversation
When ClickHouse processes GROUP BY, it often overestimates the number of threads needed. With `max_threads = 64` but only a few thousand rows, all 64 `AggregatingTransform` instances get data, produce 64 partial hash tables, and the merge phase has to combine all of them — most nearly empty. This wastes time on merging overhead. The new `GradualResizeProcessor` starts by pushing data to only 1 output port, and gradually activates more ports as data volume grows. For small datasets, only 1-2 aggregating threads receive data; for large datasets, all threads are used as before. New settings (both default to 0 = disabled): - `min_rows_per_stream_for_gradual_resize` - `min_bytes_per_stream_for_gradual_resize` When either threshold is non-zero, the pre-aggregation `StrictResize` is replaced with `GradualResize` in the pipeline. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Workflow [PR], commit [0503bb8] Summary: ✅
AI ReviewSummaryThis PR adds an opt-in Final Verdict✅ No new blockers or majors. The remaining question is workload value and tuning rather than correctness: the feature is disabled by default and the current code/docs make that opt-in contract explicit. LLVM Coverage ReportMeasured on commit 0503bb8.
Changed lines: Changed C/C++ lines covered: 397/419 (94.75%) · Uncovered code |
ClickBench Q36-Q42 benchmarks show 1.11x geometric mean speedup with `min_rows_per_stream_for_gradual_resize = 1000`: - Q40 (426K-group filtered aggregate): 40ms → 29ms (1.38x) - Q38 (42K groups): 18ms → 14ms (1.29x) - Q37 (299K groups): 22ms → 19ms (1.16x) - Q36 (full-table 9.1M groups): 36ms → 38ms (5% regression, acceptable) - Q39, Q41, Q42 (very selective): neutral Higher thresholds (50K+ rows) cause regressions on large datasets by keeping parallelism too low for too long. 1000 rows/stream is the sweet spot: conservative enough to ramp up quickly for heavy queries, but effective at reducing merge overhead for filtered aggregates. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Skip finished outputs when dequeuing from `active_waiting_outputs` and when promoting entries from `inactive_waiting_outputs`. Without these guards, a downstream cancellation could mark an output as `Finished` while it was still enqueued, leading to an invalid `pushData` call. Also disable `min_rows_per_stream_for_gradual_resize` in three existing tests that validate pipeline structure or thread-count assumptions unrelated to gradual resize. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix stateless test `04039_gradual_resize_processor`: set `enable_analyzer = 1` at session level so the test works in old-analyzer CI configurations - Fix `test_storage_mysql::test_many_connections`: disable `GradualResize` for this test because 25 UNION ALL branches with a MySQL connection pool of 16 causes pool exhaustion — `GradualResize` bottlenecks data through 1 output initially, so MySQL sources hold connections longer and waiting sources time out Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…x performance regressions The original `GradualResizeProcessor` replaced `StrictResizeProcessor` in the aggregation pipeline but used non-strict FIFO routing — any input could feed any output. This destroyed thread/cache locality and caused regressions on large datasets: - `group_array_sorted`: up to +146% slower on AMD, +39% on ARM - `sort_patterns #4`: +408% on ARM - `optimize_functions_to_subcolumns`: +15-50% on AMD Root cause: even after all outputs activated (which happens after the first 65K-row chunk with threshold=1000), data was routed arbitrarily instead of maintaining the 1:1 input-output binding that `StrictResizeProcessor` provides. Fix: rewrite `GradualResizeProcessor` to use strict routing (like `StrictResizeProcessor`). Inputs start disabled and are only enabled when bound to a specific active output. This preserves thread locality for large datasets while still providing the gradual activation benefit for small GROUP BY queries. Performance report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=99495&sha=edb0902ed32ccbbf461112a71a027e24b1cdfcd2&name_0=PR Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
In this PR, we try to gradually increase the active input and output ports as incoming data reaches our processor and crosses some threshold. The issue with this is that this can lead stalling in the upstream as well (as discovered by the performance tests). This happens because since at the start most of the input ports (except the first one) has not set One idea could be that we keep all the input ports and gradually allow output ports. We try to push the input chunk to any of the available allowed output chunks; in this way this will not linearize the pipeline while keeping most of the benefit. |
I actually meant it this way. Will try... |
…ually activate outputs Address review feedback from #99495: - Keep all input ports active at all times to avoid upstream pipeline stalling. Previously, inputs were disabled by default and bound 1:1 to outputs, which caused upstream transforms (e.g. `FilterTransform`) to stall when most inputs were inactive. Now uses many-to-many routing (like `ResizeProcessor`) with all inputs always active, while only gradually activating output ports as data volume grows. - Fix potential integer overflow in `maybeActivateMoreOutputs` by using division-based comparison instead of multiplication (`total_rows / num_active >= threshold`). - Remove `.claude/learnings.md` artifact file that was accidentally included. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ually activate outputs Address review feedback from #99495: - Keep all input ports active at all times to avoid upstream pipeline stalling. Previously, inputs were disabled by default and bound 1:1 to outputs, which caused upstream transforms (e.g. `FilterTransform`) to stall when most inputs were inactive. Now uses many-to-many routing (like `ResizeProcessor`) with all inputs always active, while only gradually activating output ports as data volume grows. - Fix potential integer overflow in `maybeActivateMoreOutputs` by using division-based comparison instead of multiplication (`total_rows / num_active >= threshold`). - Remove `.claude/learnings.md` artifact file that was accidentally included. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ke `StrictResizeProcessor` The original implementation enabled all inputs unconditionally, used decoupled queues for routing, and called `getNumRows`/`bytes` on every chunk even after all outputs were activated. This caused measurable steady-state overhead vs `StrictResizeProcessor`, leading to performance regressions in CI perf tests. The new implementation adopts `StrictResizeProcessor`'s pattern: - Each input is paired 1:1 with an output via `waiting_output` - Inputs are disabled after delivering data (`pullData(set_not_needed=true)`) - Inputs are only re-enabled when an output needs data (demand-driven) - Per-chunk row/byte accounting is skipped once all outputs are active (`all_outputs_active` flag) Once all outputs are activated, the processor behaves identically to `StrictResizeProcessor` with zero additional overhead. CI perf report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=99495&sha=edb0902ed32ccbbf461112a71a027e24b1cdfcd2&name_0=PR Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…mplementation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…istory` Test `02995_new_settings_history` checks that newly added settings are in versions strictly greater than `26.4`. Both `min_rows_per_stream_for_gradual_resize` and `min_bytes_per_stream_for_gradual_resize` were previously placed in the `26.4` block, so the test reported them as missing. CI report: ClickHouse#99495 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Set `min_rows_per_stream_for_gradual_resize` default to `1000` so the gradual-activation behavior for `GROUP BY` aggregation streams is on out of the box. Users still pay the merge-overhead cost for small result sets only when they explicitly opt out by setting it to `0`. Per review thread: ClickHouse#99495 (comment) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the one-output-at-a-time ramp in `maybeActivateMoreOutputs` with a single jump to all outputs once `total_rows_pushed >= min_rows_per_output` (or the bytes equivalent) is true. Why: the gradual ramp made early outputs receive disproportionately more chunks during the warm-up phase, leaving aggregator #0 with a much larger partial hash table than the rest. That imbalance persists for the whole run, and the downstream merge has to combine N uneven partial states. For heavy aggregate states the merge cost scales super-linearly with table size, so the skew hurts even on large queries that should clearly saturate all aggregating threads. Performance evidence on `f9aea621a2cb`: - `group_array_sorted` from `sorted_50m`/`sorted_100m`: +100%..+150% - `hash_table_sizes_stats` `numbers(5M) GROUP BY number`: +55% - `tpch` Q22: +22%/+37% With "jump to all", small queries still see only 1 aggregator (under the configured threshold), and any query past the threshold immediately uses the full N — bounding the early skew to at most `min_rows_per_output` rows instead of `min_rows_per_output * N / 2`. Linked discussion: ClickHouse#99495 (comment) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When `num_streams` is not divisible by the number of split groups, `addSplitResizeTransform` pads the last group with `NullSource` / `NullSink`. The padded `NullSink` output is finished immediately by `NullSink::prepare`, so `GradualResizeProcessor::prepare` sees it as `Finished` before any data flows and never adds it to `waiting_outputs` — even after `maybeActivateMoreOutputs` sets `all_outputs_active`. This test exercises that path with 14 upstream streams and `min_outstreams_per_resize_after_split = 4` (groups = 3, each 5x5; the third group has one padded input and one padded output) and checks that the query returns all 140000 rows, i.e. no data is dropped on the padded path. Addresses review thread on ClickHouse#99495.
Cover the branch in `GradualResizeProcessor::prepare` that promotes a waiting inactive output when an *active* output finishes before the row/byte threshold is crossed. The existing `04039_gradual_resize_processor.sql` only exercises finished *inactive* outputs (padded `NullSink` after `addSplitResizeTransform`); this test forces the deadlock-avoidance path by raising `min_rows_per_stream_for_gradual_resize` above the input size so the row threshold never fires, then using `max_rows_to_group_by` with `group_by_overflow_mode = 'break'` to make the active `AggregatingTransform` close its input port (= the `GradualResize` output port) after a single chunk. Addresses outstanding review thread on `04039_gradual_resize_processor.sql` (thread `PRRT_kwDOA5dJV85_IY73`) in ClickHouse#99495. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`LimitTransform::makeChunkWithPreviousRow` asserts `row < chunk.getNumRows()` and is called from `preparePair` with `data.current_chunk.getNumRows() - 1` whenever `with_ties && rows_read == offset + limit`. This silently assumes the current chunk has at least one row. The read-in-order pipeline violates that assumption. With settings `optimize_read_in_order = 1`, `read_in_order_use_virtual_row = 1` and `read_in_order_use_virtual_row_per_block = 1`, `VirtualRowTransform` emits zero-row chunks carrying `MergeTreeReadInfo` as sort-key markers for `MergingSortedTransform`. In the single-stream case there is no merging, so `RemoveVirtualRowTransform` is inserted to clean up — but it only stripped the `ChunkInfo` and left the empty chunk on the pipeline. When such a chunk arrived at `LimitTransform` after the previous data chunk had pushed `rows_read` to exactly `offset + limit`, the call became `makeChunkWithPreviousRow(empty_chunk, 0 - 1)` and the `UInt64` underflow tripped the assertion (server abort in debug builds; surfaces as a `LOGICAL_ERROR` in release builds). The AST fuzzer hit this on master across multiple unrelated PRs (ClickHouse#99495, ClickHouse#104268, ClickHouse#101158) over the past 30 days. Minimal reproducer: CREATE TABLE t (id UInt64, m Map(String, String), INDEX idx_mk mapKeys(m) TYPE bloom_filter GRANULARITY 1) ENGINE = MergeTree ORDER BY id SETTINGS index_granularity = 1; INSERT INTO t VALUES (1, {'1':'1'}); INSERT INTO t VALUES (2, {'2':'2'}); INSERT INTO t VALUES (3, {'3':'3'}); SELECT id FROM t PREWHERE mapContains(m, toFixedString('1', 1)) ORDER BY id ASC LIMIT 1 WITH TIES SETTINGS optimize_read_in_order = 1, read_in_order_use_virtual_row = 1, read_in_order_use_virtual_row_per_block = 1; Two fixes: 1. `LimitTransform.cpp`: skip the `makeChunkWithPreviousRow` call when the current chunk has zero rows. There is no row to remember and the `previous_row_chunk` saved from the prior non-empty chunk is still the correct tie key for the boundary. 2. `RemoveVirtualRowTransform` (`SortingStep.cpp`): actually consume the virtual-row chunk — `chunk.clear()` it and enable `skip_empty_chunks` on the base `ISimpleTransform` so it is not re-emitted. This matches the existing comment ("we need to remove virtual row before output") and protects other downstream transforms from receiving these pointless markers. Regression test `04247_99495_limit_with_ties_virtual_row_empty_chunk` aborts master in debug builds and passes after this change. CI report: https://s3.amazonaws.com/clickhouse-test-reports/PRs/99495/b699481fd40feb67d515e47725de159f20dd1ff0/ast_fuzzer_amd_debug/ Related: ClickHouse#99495 (sighting PR)
…essor # Conflicts: # src/Core/ProtocolDefines.h # src/Core/SettingsChangesHistory.cpp
…rebased `AggregationPushdown` clones the planned `GROUP BY` step and calls `rebaseOntoInput` with the join keys it pushes the aggregation below, but `group_by_keys_semantically_constant` was decided by the planner for the *original* key set. Carrying it over kept claiming a single group for a step that now groups by the join keys, so the gradual pre-aggregation resize the user opted into with `min_rows_per_stream_for_gradual_resize` silently stopped applying on that carrier. `rebaseOntoInput` now drops the mark, exactly like the hash-table stats identity its caller resets for the same reason; the remaining header-based `ColumnConst` check still catches keys that are constant in the new input. New `AggregatingStep.RebaseOntoInputClearsSemanticallyConstantKeys` unit test with both halves of the pair: the mark alone keeps the strict resize, and the rebased step takes the gradual one. The functional surface is not reachable without a stateless worker cluster - the pushed partial aggregation runs on a worker, so it shows up in neither `EXPLAIN PIPELINE` nor `system.processors_profile_log` of the initiator. Addresses the review thread on `AggregatingStep.cpp:754`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
🕵 Round 307 (head Master merge (
AI Review Major — stale constant-key mark on the Cascades pushdown carrier ( AI Review Tests — AI Review Tests — The one red on The cloud benchmark verdict is still based on a false premise ("on by default"): both thresholds default to Verified on a server built from this head: all The draft status stays, and the two reserved design questions (locality of the ramp-up, projection parity) are untouched. |
A storage that reports `hasEvenlyDistributedRead` already spreads the rows over the reading streams, so `AggregatingStep::transformPipeline` skips the pre-aggregation resize entirely and the gradual/strict choice never arises locally. The query plan serialization does not carry that property: every deserialized `AggregatingStep` is reconstructed with `storage_has_evenly_distributed_read` unset, so a shipped plan fragment does reach the resize branch, and the serialized `gradual_resize_enabled` bit made it build a `GradualResize` that the same query never builds when it is planned locally - the pipeline shape would depend on the transport rather than on the source contract. The bit is now left off the wire for such a step, so a shipped plan keeps the strict resize that is built there today. New test `05182_gradual_resize_evenly_distributed_read_shipped_plan` pins both directions over `serialize_query_plan = 1`: a `Memory` source (evenly distributed read) builds no `GradualResize` on the shard, while the same shipped-plan shape over a `MergeTree` source still does. Verified against a control binary without the fix, where the first assertion returns `1`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
🕵 Round 308 (head Shipped plan over an evenly distributed read ( New test Current-head benchmark. Measured with both thresholds at Local verification: private server on a non-default port, all 8 The pull request stays a draft: the steady-state locality trade-off and the projection-path parity remain design calls for @alexey-milovidov. |
The per-group threshold-scaling case asserts how many `AggregatingTransform` were fed rows by the split `GradualResize` groups. Its threshold arithmetic assumes that the whole 1000000-row fixture flows through one local pre-aggregation pipeline: with `max_threads = 16` and `min_outstreams_per_resize_after_split = 4` each of the 4 groups must see about 250000 rows, so that only the per-group threshold (100000) can fire and not the global one (400000). With parallel replicas the rows are spread over the replicas, so a single group sees well below 100000 and never activates its remaining outputs - the assertion then reports `4\t0` instead of `4\t1`, reproducibly, on `Stateless tests (amd_llvm_coverage, ParallelReplicas, s3 storage, parallel)`. Pin `enable_parallel_replicas = 0` for the test: every assertion in it is about the shape of, and the number of rows pushed through, the local pre-aggregation pipeline. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… text Two actions required by the AI review of `4ecefaef0356`: - `min_outstreams_per_resize_after_split` described itself as applying only to `Resize` and `StrictResize`, but `Pipe::resizeGradual` now reuses the same split path, so the description contradicted the two new `*_for_gradual_resize` settings. It now names `GradualResize` too and points at the threshold division among the split groups. - The `SettingsChangesHistory` entry for `min_rows_per_stream_for_gradual_resize` claimed the setting improves "performance on small data volumes". The benchmark measured on the current head supports no such blanket claim (one narrow shape gains about 3%, other shapes are neutral or slower when the threshold holds the pipeline at too few streams), so this user-facing release text is now a factual capability description that also states the setting is disabled by default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…essor # Conflicts: # src/Core/ProtocolDefines.h
The descriptions of `min_rows_per_stream_for_gradual_resize` and `min_bytes_per_stream_for_gradual_resize` read as if any keyed `GROUP BY` could take the gradual path, but `AggregatingStep::transformPipeline` consults the thresholds only when it builds the hash-based pre-aggregation resize stage. Aggregation in order of the sorting key, storages with evenly distributed reads (`Memory`, `system.numbers`), aggregation over independent partitions and single-stream reads never reach that branch, so the settings are silently ignored there. Both descriptions now state the contract as "only when the planner builds the hash-based pre-aggregation resize stage" and enumerate those bypasses next to the already documented ones. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
🕵 Merged The sole red on the previous head, |
…ade load Since ClickHouse#116783 the stress randomization enables `cast_keep_nullable = 1` in a third of the runs, and, unlike its sibling arms, without the `not upgrade_check` guard. The upgrade check runs that load against the previous release's server (26.8.2.7), which predates ClickHouse#119385: it matches the sorting key `CAST(json.b, 'String')` to the same expression in `ORDER BY` by name and arity only, although under `cast_keep_nullable = 1` the query types it `Nullable(String)` while the key is `String`. Read-in-order with `read_in_order_use_virtual_row = 1` then aborts the shipped 26.8 server in `setVirtualRow` with `Logical error: Virtual row has different type` while running `03277_json_subcolumns_in_primary_key`, so `Upgrade check (amd_release)` went red on dozens of unrelated pull requests (57 on 2026-09-11, 23 on 2026-09-12, 7 on 2026-09-13). A master fix cannot clear it, because the exception is raised by the old binary. Guard the arm like the sibling `serialize_query_plan` arm (5620afa). CI: https://s3.amazonaws.com/clickhouse-test-reports/praktika.html?PR=99495&sha=c84da07b7cc18cc399ef06b7fc656f99a72b713a&name_0=PR&name_1=Upgrade%20check%20(amd_release) PR: ClickHouse#99495 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ade load Since #116783 the stress randomization enables `cast_keep_nullable = 1` in a third of the runs, and, unlike its sibling arms, without the `not upgrade_check` guard. The upgrade check runs that load against the previous release's server (26.8.2.7), which predates #119385: it matches the sorting key `CAST(json.b, 'String')` to the same expression in `ORDER BY` by name and arity only, although under `cast_keep_nullable = 1` the query types it `Nullable(String)` while the key is `String`. Read-in-order with `read_in_order_use_virtual_row = 1` then aborts the shipped 26.8 server in `setVirtualRow` with `Logical error: Virtual row has different type` while running `03277_json_subcolumns_in_primary_key`, so `Upgrade check (amd_release)` went red on dozens of unrelated pull requests (57 on 2026-09-11, 23 on 2026-09-12, 7 on 2026-09-13). A master fix cannot clear it, because the exception is raised by the old binary. Guard the arm like the sibling `serialize_query_plan` arm (5620afa). CI: https://s3.amazonaws.com/clickhouse-test-reports/praktika.html?PR=99495&sha=c84da07b7cc18cc399ef06b7fc656f99a72b713a&name_0=PR&name_1=Upgrade%20check%20(amd_release) PR: #99495 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 6b1566c)
|
🕵 Status on head Repaired the private |
|
As the settings are disabled by default, why do we see any performance difference in performance tests? |
|
Now the question - is there any value in having these fine-tuning settings without any changes by default? |
When ClickHouse processes GROUP BY, it often overestimates the number of threads needed. With
max_threads = 64but only a few thousand rows, all 64AggregatingTransforminstances get data, produce 64 partial hash tables, and the merge phase has to combine all of them — most nearly empty. This wastes time on merging overhead, which is especially noticeable for heavy aggregate states such asuniq,uniqExact,groupArray, etc.The new
GradualResizeProcessorstarts by pushing data to a single output port (or one port per split group whenmin_outstreams_per_resize_after_splitapplies), and activates all aggregation streams at once as soon as the configured row or byte threshold is crossed. For small datasets, only one aggregating thread receives data (or one per split group); for large datasets, all threads are used as before.New settings:
min_rows_per_stream_for_gradual_resize(default:0)min_bytes_per_stream_for_gradual_resize(default:0)Both settings default to
0, so the optimization is opt-in. Set either setting to a non-zero threshold to replace the pre-aggregationStrictResizewithGradualResizefor keyedGROUP BYqueries.Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):
Add opt-in settings
min_rows_per_stream_for_gradual_resizeandmin_bytes_per_stream_for_gradual_resizethat can improveGROUP BYperformance on small data volumes.Workflow [PR]
Sync PR [sync-upstream/pr/99495]