Skip to content

Combine I/O cost with selectivity in PREWHERE condition ordering - #110695

Open
Avogar wants to merge 5 commits into
masterfrom
prewhere-cost-with-selectivity
Open

Combine I/O cost with selectivity in PREWHERE condition ordering#110695
Avogar wants to merge 5 commits into
masterfrom
prewhere-cost-with-selectivity

Conversation

@Avogar

@Avogar Avogar commented Jul 16, 2026

Copy link
Copy Markdown
Member

When use_statistics=1 (the default since auto_statistics_types was introduced), the PREWHERE optimizer sorted conditions by estimated_row_count alone, with columns_size only as a tiebreaker. This caused expensive conditions (e.g. Map column, ~500KB) to be placed before cheap ones (e.g. scalar column, ~1KB) whenever the expensive condition appeared more selective — ignoring the I/O cost difference.

Apply the classic conjunctive filter ordering rule: sort by cost / (1 - selectivity), i.e. the I/O cost per rejected row. This is computed as columns_size / max(1, total_rows - estimated_row_count) and replaces the separate estimated_row_count, columns_size pair in the condition comparison tuple.

When statistics are unavailable (estimated_row_count=0, total_rows=0), the formula degrades to columns_size, preserving the existing behavior.

Changelog category (leave one):

  • Performance Improvement

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

Combine I/O cost with selectivity in PREWHERE condition ordering. It fixes performance regression in PREWHERE execution in some cases introduced after #101275. Part of #110462

When `use_statistics=1` (the default since `auto_statistics_types`
was introduced), the PREWHERE optimizer sorted conditions by
`estimated_row_count` alone, with `columns_size` only as a tiebreaker.
This caused expensive conditions (e.g. Map column, ~500KB) to be
placed before cheap ones (e.g. scalar column, ~1KB) whenever the
expensive condition appeared more selective — ignoring the I/O cost
difference.

Apply the classic conjunctive filter ordering rule: sort by
`cost / (1 - selectivity)`, i.e. the I/O cost per rejected row.
This is computed as `columns_size / max(1, total_rows - estimated_row_count)`
and replaces the separate `estimated_row_count, columns_size` pair in
the condition comparison tuple.

When statistics are unavailable (`estimated_row_count=0`, `total_rows=0`),
the formula degrades to `columns_size`, preserving the existing behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@clickhouse-gh

clickhouse-gh Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [3344dbe]

Summary:

job_name test_name status info comment
Fast test FAIL
02458_relax_too_many_parts FAIL cidb
04812_prewhere_statistics_tautology_last FAIL cidb
Build profile diff ERROR
Code Review DROPPED
Fast test (arm_darwin) DROPPED
Build (amd_debug) DROPPED
Build (amd_asan_ubsan) DROPPED
Build (amd_tsan) DROPPED
Build (amd_msan) DROPPED
Build (amd_binary) DROPPED
Build (arm_debug) DROPPED

@clickhouse-gh clickhouse-gh Bot added the pr-performance Pull request with some performance improvements label Jul 16, 2026
@hanfei1991 hanfei1991 self-assigned this Jul 16, 2026
@@ -494,6 +497,12 @@ void MergeTreeWhereOptimizer::analyzeImpl(Conditions & res, const RPNBuilderTree
pk_positions.emplace(cond.min_position_in_primary_key);
}

/// Combine I/O cost with selectivity using the classic conjunctive filter ordering rule:
/// sort by cost / (1 - selectivity), i.e. cost per rejected row.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Sorry, I didn't understand why we order by cost per rejected row. Any reference?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is what Claude told me, and I trusted this (but this is why I asked you to review as well):

This is the classic rank-ordering theorem for conjunctive predicates (sometimes called the KBZ ordering).

Consider two filters A and B with I/O costs c_A, c_B and selectivities s_A, s_B (fraction of rows that pass):

  • A then B: total cost = c_A + s_A * c_B (always pay c_A; pay c_B only for the s_A fraction that passes A)
  • B then A: total cost = c_B + s_B * c_A

A-first is cheaper when:

c_A + s_A * c_B  <  c_B + s_B * c_A
c_A (1 - s_B)    <  c_B (1 - s_A)
c_A / (1 - s_A)  <  c_B / (1 - s_B)

So the optimal order is: sort by c_i / (1 - s_i) ascending -- i.e., cost per rejected row. This generalizes to any number of independent conjuncts.

In the code, columns_size / max(1, total_rows - estimated_row_count) is proportional to c / (1 - s) since total_rows - estimated_row_count = total_rows * (1 - s), and the total_rows denominator is a constant across all conditions that doesn't affect the sort order.

References:

  • Ibaraki & Kameda, "On the Optimal Nesting Order for Computing N-Relational Joins", ACM TODS 1984
  • Krishnamurthy, Boral & Zaniolo, "Optimization of Nonrecursive Queries", VLDB 1986
  • Hellerstein & Stonebraker, "Predicate Migration: Optimizing Queries with Expensive Predicates", SIGMOD 1993

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

That makes sense. The fast test is flaky because sometimes the column_size is zero. I asked AI and got reply

Compact parts intentionally don't track per-column sizes.                                                                                                                                                                                                                    

If that's true, maybe we should compare selectivity when column_size is 0.

@Avogar

Avogar commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

Tests with statistics failed, need to investigate. Maybe this approach doesn't work

Avogar and others added 2 commits August 5, 2026 16:08
…ilable

The new `cost_with_selectivity` score is `columns_size / max(1, total_rows -
estimated_row_count)`. On compact parts per-column compressed sizes are not
tracked, so `columns_size` is 0 and the score collapses to 0 for every
condition. Since this score is the only selectivity-bearing key in the sort
tuple, all conditions tie and the original WHERE order is kept, discarding the
selectivity estimate entirely.

Fall back to `estimated_row_count` when `columns_size == 0` so ordering by
selectivity is preserved. On wide parts (sizes known) the cost formula is
unchanged.

This fixes the `02864_statistics_usage` and `04266_statistics_basic_prewhere`
fast-test failures. Adds `04811_prewhere_statistics_ordering_compact_parts` to
cover the compact-part case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cond.cost_with_selectivity = static_cast<double>(cond.estimated_row_count);
else
cond.cost_with_selectivity = static_cast<double>(cond.columns_size)
/ std::max(1.0, static_cast<double>(total_rows) - static_cast<double>(cond.estimated_row_count));

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.

The cost / (1 - selectivity) rule only works if a predicate that rejects zero rows sorts last. Here max(1, total_rows - estimated_row_count) gives that case a finite score equal to columns_size, so a cheap tautology can still be scheduled before a genuinely selective expensive predicate. That is a regression from the old estimated_row_count ordering, which always pushed estimated_row_count == total_rows to the end.

I think this needs an explicit "rejects 0 rows => sort last" sentinel (for example std::numeric_limits<double>::infinity()) plus a regression that combines estimated_row_count == total_rows with very different columns_size values.

@clickhouse-gh

clickhouse-gh Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

📊 Cloud Performance Report

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

no significant changes detected. K_source=6 K_base=30 flagged=0/65

clickbench

🟢 No significant changes

tpch_adapted_1_official

🟢 No significant changes

Debug info
  • StressHouse run: 7b9a6dcb-1ad5-406c-a660-52029884a8a4
  • MIRAI run: 08e9ffee-9059-40f0-80af-6af30289b614
  • PR check IDs:
    • clickbench_233290_1786140078
    • clickbench_233296_1786140078
    • clickbench_233311_1786140079
    • tpch_adapted_1_official_233321_1786140078
    • tpch_adapted_1_official_233348_1786140079
    • tpch_adapted_1_official_233363_1786140079

The cost-based PREWHERE ordering score divides by columns_size (compressed
column size), so the condition order depends on part type and serialization -
settings CI randomizes. This made several prewhere-ordering tests flaky.

Pin the selectivity-oriented tests (04266, 04304, 03580, 04053) to compact
parts, where per-column sizes are 0 and ordering falls back to selectivity
alone, independent of size-affecting randomization. For 04513 (which must stay
wide to exercise the cost path) split `modality` 50/50 so the reject-count ratio
is ~2x while the Map column dwarfs the scalar, keeping cheap-filter-first stable.

Verified with clickhouse-test --test-runs 100 -j 10: all affected tests pass
100/100 under randomized settings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@clickhouse-gh

clickhouse-gh Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Build profile diff (arm_release)

No arm_release build profile data for commit 3344dbe - the build was skipped, reused from cache, or predates profile upload.

The cost-per-rejected-row score used max(1, total_rows - estimated_row_count) as
the denominator, so a predicate that rejects no rows (estimated_row_count ==
total_rows) got a finite score equal to columns_size. A cheap tautology could
then be scheduled before a genuinely selective but expensive predicate, which
regresses the old estimated_row_count ordering.

Give such predicates an infinite score so they always sort last, and keep the
explicit no-statistics (total_rows == 0) and compact-part (columns_size == 0)
fallbacks. Adds 04812_prewhere_statistics_tautology_last.

Addresses review comment:
#110695 (comment)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

2 participants