Skip to content

Push expressions of String columns below window functions in the query plan - #126558

Draft
yakov-olkhovskiy wants to merge 3 commits into
masterfrom
cursor/plan-push-down-expressions-below-window-380d
Draft

yakov-olkhovskiy wants to merge 3 commits into
masterfrom
cursor/plan-push-down-expressions-below-window-380d

Conversation

@yakov-olkhovskiy

@yakov-olkhovskiy yakov-olkhovskiy commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

closes https://github.com/ClickHouse/clickhouse-private/issues/78134

Alternative to #126062: the same fix as a query plan optimization instead of a change of the planner.

With the analyzer, the projection is computed after the window functions, so the Window step buffers the columns it reads. For SELECT toUInt64OrZero(splitByString('@', c2)[2]) AS v, count() OVER () AS n FROM t1, the window step buffers the String column c2 instead of a UInt64. The old query analysis computed such expressions before the window functions, and since 26.9 it cannot be selected.

Peak memory with clickhouse local --max_threads 1, on 10 million rows of 160-byte strings:

Query master this PR
SELECT count() FROM (SELECT DISTINCT toUInt64OrZero(splitByString('@', c2)[2]) AS v, count() OVER () AS n FROM ...) WHERE v > 0 (the original report) 2.64 GiB 268 MiB
... count() OVER (PARTITION BY k) ... (no external sorting) 153 MiB 138 MiB

This adds the optimization pushDownExpressionsBelowWindow (setting query_plan_push_down_expressions_below_window, enabled by default). It generalizes the existing pushDownVolumeReducingFunctions, which moves length, lengthUTF8, empty and notEmpty below Sorting and Filter steps, to the window case.

It looks at the Expression or Filter step directly above a Window step. It moves the expressions that read a single String / FixedString column and whose result type has a maximum size not larger than the smallest value of the column (the 8-byte offset of a String, N for FixedString(N)). They go below the chain of Window steps and the Sorting steps for them, and are merged into the Expression step there. The plan rewrite is the same as in pushDownVolumeReducingFunctions: ActionsDAG::split of the parent, and a new step whose first part is computed below. A Filter keeps the same rows on both sides of the window functions, so the outer WHERE v > 0 of the original query, merged with the projection, does not prevent it.

The expressions are moved only if this cannot make the buffered rows larger or change the results:

  • Nothing else reads the column after the window functions: other nodes and outputs of the parent DAG, the window function arguments, and the PARTITION BY / ORDER BY columns of the windows and sortings. Several expressions of one column are moved together, only if the sum of their result sizes fits. The largest expressions are tried first (toUInt8(length(s))), then the lowest, when an intermediate result is read by something else (v in v > 0).
  • The column does not share memory with another column. Two columns can share memory only through functions that may return an argument as is: an argument of the same type up to Nullable / LowCardinality, or a composite one, like with an identity CAST, toNullable, assumeNotNull, tupleElement or an alias. Following such functions through the steps below (Expression, Filter, Window, Union, header-preserving steps) to reads or aggregation, the column must have no common node with another column.
  • No function in the expression is stateful (runningDifference), non-deterministic in the scope of the query (blockSize, rand), or has side effects (sleepEachRow). The window and sorting steps change the order of rows and their blocks.
  • The expression is not executed lazily as an argument of a short-circuit function (if, multiIf, and, or). Otherwise it would be executed for all rows, and for example throw.
  • There is no LIMIT, LIMIT AFTER/UNTIL or ORDER BY ... LIMIT above the window functions, unless there is an aggregation in between. With a LIMIT, the projection may be needed only for some rows. This includes a LIMIT of an outer query, because the whole plan is visible.
  • With QUALIFY, the projection is a separate step above the Filter and is not changed, and an arrayJoin in the parent prevents the rewrite.

It is a second pass optimization:

  • It needs the steps above, to see a LIMIT.
  • liftUpFunctions (tryExecuteFunctionsAfterSorting), which moves expressions above Sorting steps, runs only in the first pass. So the expressions can also be moved below the Sorting steps for the windows without being moved back.
  • It runs before optimizeReadInOrder and optimizeWindowPerPartition, and the moved expressions are merged into the Expression step below the window sorting, so those see the same shape of the plan.
  • The window functions and the new step are only on the initiator, so the blocks sent by shards do not change. The test checks that the ReadFromRemote header is still the String column. It is not applied with make_distributed_plan.

Testing: the new test 05407_window_functions_push_down_expressions_below_window:

All 307 stateless tests that use window functions or QUALIFY pass locally with the test cluster configs, and so do the tests of settings documentation and history.

Changelog category (leave one):

  • Performance Improvement

Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):

Reduce the memory usage of queries with window functions: an expression of a String or FixedString column with a small fixed-size result, such as toUInt64OrZero(splitByString('@', s)[2]) or length(s), is now computed before the window functions if nothing else reads the column after them, so the window functions buffer the result instead of the column. Controlled by the new setting query_plan_push_down_expressions_below_window.

Open in Web Open in Cursor 

Workflow [PR]
Sync PR [sync-upstream/pr/126558]

…y plan

With the analyzer, the projection is computed after the window functions, so
the `Window` step buffers the columns it reads. For
`SELECT toUInt64OrZero(splitByString('@', s)[2]), count() OVER () FROM t` the
window step buffers the `String` column `s` instead of a `UInt64`, which
increased the peak memory usage about ten times compared with the old query
analysis, which computed such expressions before the window functions.

Add the query plan optimization `pushDownExpressionsBelowWindow` (setting
`query_plan_push_down_expressions_below_window`, enabled by default). It moves
the largest expressions of the `Expression` step above `Window` steps that read
a single `String` / `FixedString` column and have a fixed-size result not larger
than the smallest value of the column below the `Window` steps and the
`Sorting` steps for them. Like `pushDownVolumeReducingFunctions`, it is applied
only if nothing else reads the column after the window functions and the column
does not share memory with another column. Expressions that are executed lazily,
stateful, block-dependent or with side effects are not moved, nor are expressions
below a `LIMIT`, which may need them only for some rows. It is a second pass
optimization because it needs the steps above, and so that `liftUpFunctions`
does not move the expressions back above the window sorting.

Co-authored-by: yakov <yakov@clickhouse.com>
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@clickhouse-gh

clickhouse-gh Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [9fc06eb]


AI Review

Summary

The PR pushes small fixed-size expressions below window functions to reduce buffering. Three regressions were reproduced with the PR-head fast-test binary: two valid queries begin throwing, and a query over a Memory table exceeds a memory limit it previously satisfied. Changes are required before merging.

Findings

❌ Blockers

  • pushDownExpressionsBelowWindow.cpp:281–285: The early-termination guard omits LIMIT BY. A constant grouping key can cancel after the first output block, but pushing toUInt64 below the window evaluates later invalid strings. The reproduction returns 1, 10000 when disabled and throws CANNOT_PARSE_TEXT when enabled. Recognize early-terminating LimitByStep ancestors and add regression coverage.
  • pushDownExpressionsBelowWindow.cpp:520–522: Merging the pushed expression into a lower DAG containing arrayJoin evaluates a throwing conversion before an empty array eliminates its row. With query_plan_lower_array_join_function = 0, the reproduction returns 1, 1 when disabled and throws CANNOT_PARSE_TEXT when enabled. Keep the pushed step separate from such a DAG and cover this setting combination.

⚠️ Majors

  • pushDownExpressionsBelowWindow.cpp:240–242: Accepting every source as owning releasable memory increases buffering for uncompressed Memory tables, which retain the original columns in their storage snapshots. For one million strings, cityHash64 plus an unbounded window peaks at 16.50 MiB disabled and 20.09 MiB enabled; only the enabled query fails with max_memory_usage = '19M'. Exclude borrowed source columns unless the memory benefit is established, and add a focused regression test.
Missing context / blind spots
  • ⚠️ Full build and functional CI jobs were still pending when inspected. The three findings above were independently reproduced using the completed fast-test artifact for head 9fc06ebbfa4dd4c4ffec0e2f732de86556dcc107.
Final Verdict

❌ Block — handle early-terminating LIMIT BY, preserve the execution boundary after arrayJoin, and prevent the demonstrated Memory read regression. Add focused stateless coverage for all three cases.

@clickhouse-gh clickhouse-gh Bot added pr-performance Pull request with some performance improvements comp-query-optimizer Query plan optimization: physical plan steps, plan-level rewrites and optimizations (QueryPlan pa... labels Oct 18, 2026
cursoragent and others added 2 commits October 18, 2026 04:10
- `typeid_cast` does not match the classes derived from `ISourceStep`, so no read
  step was accepted as a source of separate columns. Use `dynamic_cast`.
- The outer `WHERE` of the original query is merged with the projection into the
  `Filter` step above the `Window` step. A `Filter` keeps the same rows on
  both sides of the window step, so its expressions are moved too.
- When an intermediate result is read by something else (`v` in `v > 0`), the
  largest expressions can not replace the column, so the lowest ones are tried.
- Two columns can share memory only through functions that may return an argument
  as is: with the same type up to `Nullable` / `LowCardinality`, or of a
  composite argument. Follow only those instead of all the arguments, so that
  `concat('p@', toString(number))` is not considered to share memory with
  `number % 3`.
- The `Sorting` steps for windows have `PARTITION BY` descriptions, and a
  `Window` step only adds columns, so both are passed through.

Co-authored-by: yakov <yakov@clickhouse.com>
The test checks that the window step does not buffer the column read only by the
expression (the query fails with `MEMORY_LIMIT_EXCEEDED` with the optimization
disabled), that the expression is computed below the `Window` and `Sorting`
steps in the plan, also on the initiator of a distributed query, and that it
stays after the window functions when the column is still read, the results are
larger, the column may share memory with another one, or with a fixed-size
grouping key, `LIMIT`, `QUALIFY`, lazily executed arguments, block-dependent
functions, side effects and `arrayJoin`.

Co-authored-by: yakov <yakov@clickhouse.com>
Comment on lines +281 to +285
if (typeid_cast<const LimitStep *>(step) || typeid_cast<const LimitRangeStep *>(step))
return true;

if (const auto * sorting = typeid_cast<const SortingStep *>(step); sorting && sorting->getLimit())
return true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Treat early-terminating LIMIT BY as a projection barrier

LimitByStep can also stop reading its input: when all grouping keys are constant, LimitByTransform cancels after satisfying the group limit. This guard misses that case, so pushing a throwing expression below an unbounded window evaluates rows that the original projection never reads. With the PR's fast-test binary, this returns 1, 10000 with the new optimization disabled, but throws CANNOT_PARSE_TEXT with it enabled:

SELECT toUInt64(s), count() OVER ()
FROM
(
    SELECT if(number < 1000, '1', 'x') AS s
    FROM numbers(10000)
)
LIMIT 1 BY 'group'
SETTINGS max_block_size = 10, max_threads = 1;

Include early-terminating LimitByStep ancestors in this check and add this case to the regression coverage.

Comment on lines +520 to +522
/// Merging into a `Filter` below would compute the expressions for the rows it removes.
if (typeid_cast<const ExpressionStep *>(bottom_node->step.get()))
tryMergeExpressions(&pushed_node, nodes, settings);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Keep the pushed expression after an arrayJoin in the lower DAG

The separate pushed step is safe here, but merging it into an expression containing arrayJoin can move it before rows are eliminated. ExpressionActions::linearizeActions delays array joins until other ready actions have executed, and tryMergeExpressions only guards stateful/non-deterministic functions. A deterministic conversion that can throw is therefore evaluated on an input row whose empty array previously removed it. This reproduces on the PR's fast-test binary:

SELECT toUInt64(s), count() OVER ()
FROM
(
    SELECT if(number = 0, 'x', '1') AS s,
           arrayJoin(if(number = 0, [], [1])) AS a
    FROM numbers(2)
)
SETTINGS query_plan_lower_array_join_function = 0,
         max_threads = 1;

With query_plan_push_down_expressions_below_window = 0 it returns 1, 1; with the default enabled it throws CANNOT_PARSE_TEXT for 'x'. Skip this merge when the lower expression contains an array join, keeping the pushed step after it, and cover this supported setting combination.

Comment on lines +240 to +242
/// `typeid_cast` does not match derived classes.
if (dynamic_cast<const ISourceStep *>(step) || typeid_cast<const AggregatingStep *>(step) || typeid_cast<const MergingAggregatedStep *>(step))
return true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Account for source columns whose storage remains owned elsewhere

An ISourceStep does not guarantee that removing its output releases the column's payload. An uncompressed Memory table returns columns from its retained storage snapshot, so the original window can buffer those pointers while computing the hash one output block at a time. Pushing the hash down retains an additional result column for the entire window without releasing the table's strings.

On the PR's fast-test binary, the final query below peaks at 16.50 MiB with the optimization disabled and 20.09 MiB enabled; setting max_memory_usage = '19M' makes only the enabled version fail with MEMORY_LIMIT_EXCEEDED:

CREATE TABLE m (s String) ENGINE = Memory;
INSERT INTO m SELECT toString(number) FROM numbers(1000000);
SELECT cityHash64(s), count() OVER () FROM m FORMAT Null
SETTINGS max_block_size = 8192, max_threads = 1,
         max_memory_usage = '19M';

EXPLAIN confirms the hash moves below Window. Conservatively exclude borrowed source columns such as these unless releasing their payload can be established, and add a memory regression test for this read path.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp-query-optimizer Query plan optimization: physical plan steps, plan-level rewrites and optimizations (QueryPlan pa... pr-performance Pull request with some performance improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants