Skip to content

Support PREWHERE and trivial count for Memory tables - #116248

Open
alexey-milovidov wants to merge 12 commits into
masterfrom
memory-prewhere
Open

Support PREWHERE and trivial count for Memory tables#116248
alexey-milovidov wants to merge 12 commits into
masterfrom
memory-prewhere

Conversation

@alexey-milovidov

@alexey-milovidov alexey-milovidov commented Aug 25, 2026

Copy link
Copy Markdown
Member

Related: ClickHouse/ClickBench#1590

Changelog category (leave one):

  • Performance Improvement

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

Support PREWHERE (including the automatic move of WHERE conditions by optimize_move_to_prewhere) for Memory tables: only the columns of the conditions are read at first, and the remaining columns are read only for the blocks where some rows pass, and only for the passing rows. This is especially beneficial for tables with SETTINGS compress = true, because for a selective condition most columns are never decompressed. Additionally, SELECT count() FROM table on a Memory table is now served from metadata, and system.columns shows real per-column sizes for Memory tables.

The motivation is benchmarking a compressed in-memory table on ClickBench (ClickHouse/ClickBench#1590), where the last seven queries (CounterID = 62) lose the primary index of MergeTree and previously had to decompress every referenced column of every block.

Implementation:

  • StorageMemory::supportsPrewhere is now true. MemorySource applies the pushed-down row-level security filter and PREWHERE inside the reading source: it materializes only the filter-input columns, executes the filter steps, skips a block entirely when no row passes, and reads the remaining columns only for the surviving rows (IColumn::filter with the combined mask). The block layout is kept in exact correspondence with the output header, which SourceStepWithFilter::applyPrewhereActions builds by running the same actions on the sample block.
  • StorageMemory::getColumnSizes reports real per-column in-memory sizes (compressed sizes when compress = true). This is what enables the plan-level WHERE -> PREWHERE optimization (it declines on storages with no column sizes) and lets MergeTreeWhereOptimizer order conditions by the actual cost of reading their columns.
  • StorageMemory::supportsTrivialCountOptimization is now true, guarded against tables that are filled during query execution (materialized CTEs, GLOBAL subquery temporary tables) and against pinned snapshots (atomic CREATE MATERIALIZED VIEW ... POPULATE), where totalRows must not be observed at planning time.

Benchmark (ClickBench queries, 10M-row hits subset in a Memory table with compress = true, 96-core aarch64, hot runs, new binary with the optimizations toggled off/on via optimize_move_to_prewhere / optimize_trivial_count_query):

Query off on speedup
Q0 SELECT COUNT(*) 0.003 0.001 3.0x
Q23 SELECT * ... URL LIKE '%google%' ORDER BY ... LIMIT 10 0.100 0.078 1.3x
Q36 WHERE CounterID = 62 AND EventDate ... 0.032 0.022 1.5x
Q38 0.025 0.010 2.5x
Q40 0.019 0.009 2.1x
Q41 0.018 0.008 2.3x
Q42 0.015 0.008 1.9x
SELECT * point lookup by WatchID 0.094 0.044 2.1x

The remaining queries are unchanged within noise. The effect grows with table size and filter selectivity: on the full 100M-row dataset the eliminated blocks dominate.


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

alexey-milovidov and others added 4 commits August 25, 2026 05:07
PREWHERE (and the pushed-down row-level security filter) is applied inside
MemorySource: only the columns of the conditions are read at first, and the
remaining columns are read only for the blocks where some rows pass and only
for the passing rows. For a table with SETTINGS compress = true a selective
condition skips decompression of all other columns for the blocks it
eliminates.

StorageMemory::getColumnSizes reports real per-column in-memory sizes
(compressed sizes when compress = true), which both enables the automatic
WHERE -> PREWHERE move in the query plan optimization and lets it order
conditions by the actual cost of reading their columns.

SELECT count() FROM table is served from metadata (totalRows is exact,
maintained under the write mutex).

Motivated by benchmarking a compressed Memory table:
ClickHouse/ClickBench#1590

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s and docs

A materialized CTE and a GLOBAL subquery temporary table are filled during
query execution, after the planner would have observed totalRows (as zero),
so the trivial count optimization must not apply to them.

Also update the in-code Memory engine documentation and add functional and
performance tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ents

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@clickhouse-gh

clickhouse-gh Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [9614350]

Summary:

job_name test_name status info comment
Stateless tests (arm_binary, parallel) FAIL
Server liveness check failed FAIL cidb
Segmentation fault (STID: 2994-5fc0) FAIL cidb
Integration tests (amd_asan_ubsan, db disk, old analyzer, 6/6) ERROR
Container memory budget exceeded (/docker) ERROR cidb
Build (amd_darwin) DROPPED
Build (arm_darwin) DROPPED
Build (arm_v80compat) DROPPED
Build (amd_freebsd) DROPPED
Build (ppc64le) DROPPED
Build (amd_compat) DROPPED
Build (amd_musl) DROPPED
Build (riscv64) DROPPED

AI Review

Summary

This PR adds in-source PREWHERE evaluation and metadata count() for Memory tables, plus per-column size reporting to unlock the planner's WHERE -> PREWHERE optimization. The current code fixes the earlier set-preparation issue, but I do not think it is ready to merge yet because the new trivial-count contract is still unsafe through wrappers and the new source-local filter path regresses read accounting.

Findings

❌ Blockers

  • [src/Storages/StorageMemory.cpp:767] Enabling trivial count for Memory also enables it through wrappers such as StorageMerge, whose parent-level trivial-count path sums child totalRows() before child row policies are attached. As a result, SELECT count() on a Merge table over Memory can return an unfiltered row count even though the normal read path would apply the child table's row policy.
    Suggested fix: either make the wrapper path decline or correctly account for child row policies before using metadata counts, or keep StorageMemory::supportsTrivialCountOptimization disabled until that exists.

⚠️ Majors

  • [src/Processors/QueryPlan/ReadFromMemoryStorageStep.cpp:145] The new in-source filter path now reports progress only for rows that survive PREWHERE, because ISource auto-progress uses the returned chunk size and fully filtered blocks hit continue without any explicit progress() call. That under-reports read_rows / SelectedRows and can weaken max_rows_to_read and read-quota enforcement for selective Memory scans.
    Suggested fix: disable auto-progress for the filtered MemorySource path and report progress() explicitly for every scanned block, including blocks whose surviving row count is zero.
Missing context / blind spots
  • ⚠️ There is no runnable clickhouse binary in this checkout, so I could not execute a direct local repro for the progress-accounting path; the finding is based on the current ISource / ReadProgressCallback / PipelineExecutor control flow.
Final Verdict
  • Status: ⚠️ Request changes
  • Minimum required actions:
    1. Close the row-policy gap before advertising trivial count from Memory through wrappers such as StorageMerge.
    2. Restore source-level read progress accounting for filtered Memory reads so limits and query statistics continue to see scanned rows.

@clickhouse-gh clickhouse-gh Bot added the pr-performance Pull request with some performance improvements label Aug 25, 2026
@clickhouse-gh

clickhouse-gh Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

📊 Cloud Performance Report

✅ AI verdict: no_change — no significant changes across 41 queries analysed

This PR adds PREWHERE and row-level-filter support plus a trivial count() optimization to the Memory table engine and its reading source. All flagged clickbench queries (Q18, Q28, Q30, Q31, Q33) run against MergeTree tables and never exercise the Memory read path, so none of the +5% to +19% per-query deltas can be caused by this change. Each envelope-suppressed row sits inside master's current variance band and Q28 shows mixed test signals, so these are run-to-run variance rather than PR effects. No regression or improvement is attributable to the diff, and no action is warranted.

clickbench

⚠️ 3 inconclusive

Flagged queries (3 of 43)
Query Verdict Baseline median (ms) PR median (ms) Change q-value Hint
⚠️ 28 not_sure 1104 1042 -5.6% <0.0001 Q28 does not touch the Memory-engine read path this PR changes; the -5.6% with mixed signals from the two tests is noise, correctly left as not_sure.
⚠️ 30 not_sure 238 283 +18.9% <0.0001 The Memory-engine changes cannot affect this MergeTree query; the +18.9% stays within master's normal variance band and is run-to-run noise.
⚠️ 31 not_sure 314 360 +14.6% <0.0001 Same off-path story: this query never exercises the Memory read path, and the +14.6% sits inside master's current variance band, so it is noise.

Change = percent below ×2; the ratio of medians (×N faster/slower) beyond, where percent understates the scale. q-value = BH-FDR adjusted p; smaller is stronger evidence. MIRAI flags a query when q < fdr_q (default 0.10) — the value the verdict is based on.

tpch_adapted_1_official

🟢 No significant changes

Debug info
  • StressHouse run: 9d9f2b2b-18d2-4201-a3a4-e11312d3e323
  • MIRAI run: f8320eea-f647-4ee1-8422-20b4651c65b0
  • PR check IDs:
    • clickbench_756544_1788279421
    • clickbench_756550_1788279422
    • clickbench_756557_1788279421
    • tpch_adapted_1_official_756868_1788279434
    • tpch_adapted_1_official_756971_1788279444
    • tpch_adapted_1_official_757002_1788279445

alexey-milovidov and others added 4 commits August 27, 2026 11:53
Virtual columns (e.g. `_table`) are materialized outside the reading
source, so the in-source filter cannot read them: `SELECT * FROM t
PREWHERE _table = 't'` failed with `NOT_FOUND_COLUMN_IN_BLOCK`.
Declare `supportedPrewhereColumns`, so both the analyzer and the
plan-level WHERE -> PREWHERE optimization reject such conditions with
`ILLEGAL_PREWHERE`, as asserted by `03094_virtual_column_table_name`.

https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=116248&sha=ebb9ae35257ee1588af487bfadfbb6002ac9c7d6&name_0=PR&name_1=Fast%20test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ake)

Generated by running the tests; the values match manual computation and
the outputs observed in the CI report
https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=116248&sha=ebb9ae35257ee1588af487bfadfbb6002ac9c7d6&name_0=PR&name_1=Fast%20test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- `03610_disjunctions_pushdown_optimization` already pins
  `optimize_move_to_prewhere` and `query_plan_optimize_prewhere` to 1,
  so the pushed-down disjunctions over `Memory` tables now become
  PREWHERE; update the expected plan accordingly.
- `03777_join_precalculate_keys` and
  `03707_analyzer_convert_outer_any_to_inner` assert on join plans, so
  pin `optimize_move_to_prewhere = 0` to keep the asserted plans
  independent of the (harness-randomized) PREWHERE move.
- `03562_short_circuit_for_and_or` asserts on `read_rows` of count
  subqueries to prove short circuit; pin
  `optimize_trivial_count_query = 0`, because serving the count of a
  `Memory` table from metadata would defeat that signal.

https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=116248&sha=ebb9ae35257ee1588af487bfadfbb6002ac9c7d6&name_0=PR&name_1=Fast%20test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/Storages/StorageMemory.cpp
Comment thread src/Storages/StorageMemory.cpp Outdated
@clickhouse-gh

clickhouse-gh Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Build profile diff (arm_release)

Comparing 961435020 with master 9f35a2b2a (stripped binary size, per-symbol sizes and ThinLTO time; compile times per translation unit against the most recent warmup build that recompiled it).

✅ No significant changes.

Binary sizes

programs/clickhouse-stripped: smaller than the master baseline by the known offset between the two builds, so the difference is not shown. A delta that differs from the offset by more than 50% of it is shown, in either direction.

The official master build is compiled with -g and a pull request build is not, and XRay counts debug instructions towards its instrumentation threshold, so master instruments thousands of functions more and its binary is ~0.4% larger no matter what the pull request does.

Object file sizes

7 object files changed (+69.56 KiB total), 0 added.

Object file Master PR Δ
src/CMakeFiles/dbms.dir/Processors/QueryPlan/ReadFromMemoryStorageStep.cpp.o 204.52 KiB 253.39 KiB +48.87 KiB (+23.89%)
src/CMakeFiles/dbms.dir/Storages/StorageMemory.cpp.o 600.66 KiB 621.62 KiB +20.96 KiB (+3.49%)

716 more object files are built by the master warmup baseline only (it builds every object-file target, a pull request build only clickhouse-bundle) and not compared.

Compile time of recompiled translation units

13 translation units recompiled, 75 s compile time in total, 13 of them have a recent master baseline.

Job report

…ocks

`SELECT count()` on a `Memory` table is served from the row counter, while ordinary
reads use the set of blocks captured in `getStorageSnapshot`. The counters were
separate atomics updated around `data.set`, so a concurrent reader could observe a
row count that corresponded to no state the table ever had.

Move the row and byte counters into the `MultiVersion` object that holds the blocks,
as the pre-existing `TODO` in `getStorageSnapshot` suggested. They are now published
atomically together with the blocks they describe, `totalRows` is the exact row count
of a committed state, and `SnapshotData::rows` is exact rather than approximate.

Also restrict `supportedPrewhereColumns` to the stored columns without a `DEFAULT`
expression (the same restriction as `StorageFile`): such a column is absent from the
blocks written before `ALTER TABLE ... ADD COLUMN`, and the in-source filter reads it
as the default value of its type instead of evaluating the expression. `ALIAS` and
`EPHEMERAL` columns are excluded as well, because they are never stored.

Add `05052_memory_prewhere_added_column` and `05053_memory_trivial_count_concurrent`.
Comment thread src/Processors/QueryPlan/ReadFromMemoryStorageStep.cpp
…er test

`ReadFromMemoryStorageStep` evaluates the row-level security filter and
`PREWHERE` inside `MemorySource`, so a condition such as
`PREWHERE k IN (SELECT ...)` carries a `FutureSet` that has to be ready by
the time the source runs. The pipeline-level `CreatingSetsStep` normally
fills it in, but `DelayedPortsProcessor` can be short-circuited by a
downstream processor that closes its inputs early, which is why
`ReadFromMergeTree` builds those sets in place for its storage-level
`PREWHERE`. Do the same in `makeSourceFilter`, excluding the sets of
`GLOBAL IN`, to which `ReadFromRemote` still has to attach an external
table. Added `05057_memory_prewhere_in_subquery` covering explicit and
optimizer-moved `PREWHERE ... IN (SELECT ...)` and a row policy with `IN`.

`05052_memory_prewhere_added_column` failed with the old analyzer: it
substitutes an `ALIAS` column expression into `PREWHERE` before the storage
sees it, so `PREWHERE a = 4` is not rejected there. Pin `enable_analyzer`
for that assertion. Renumbered the two tests that collided with master.

https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=116248&sha=de59dc98eb76385347efe69e31ca5bf9b02bdf47&name_0=PR&name_1=Stateless%20tests%20%28amd_llvm_coverage%2C%20old%20analyzer%2C%20s3%20storage%2C%20DBReplicated%2C%20parallel%2C%202%2F3%29
#116248
return Chunk(std::move(columns), num_rows);
if (filter)
{
if (auto chunk = generateFiltered(src))

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.

ISource still auto-reports progress from the returned Chunk, so after this change it sees only the rows that survive PREWHERE, and this continue path reports nothing at all for a block that the filter eliminates completely. That regresses read_rows / SelectedRows accounting and weakens max_rows_to_read / read-quota enforcement for selective Memory scans; if every block is filtered out, PipelineExecutor never calls ReadProgressCallback::onProgress() for this source. Could we switch the filtered MemorySource path to manual progress() calls and account src.rows() for every scanned block, even when no rows survive?

if (query_context->hasQueryContext() && query_context->getQueryContext()->getPinnedStorageSnapshot(getStorageID().uuid))
return false;
}
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.

Returning true here also turns on trivial count through wrappers such as StorageMerge, which decides at the parent level and then sums each child table->totalRows(query_context). Child row policies are only attached later when StorageMerge builds the child read plans (RowPolicyData in createChildrenPlans), so SELECT count() on a Merge table over a Memory child with a row policy can now return the unfiltered row count instead of the policy-filtered one. Direct Memory row-policy tests cover only the base table path. I think we either need to make the wrapper path row-policy-aware before advertising trivial count from Memory, or keep this returning false until that exists.

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

Labels

pr-performance Pull request with some performance improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant