Skip to content

Commit 81a084f

Browse files
authored
Merge pull request ClickHouse#110695 from ClickHouse/prewhere-cost-with-selectivity
Combine I/O cost with selectivity in PREWHERE condition ordering
2 parents 39de108 + 583ac08 commit 81a084f

13 files changed

Lines changed: 179 additions & 6 deletions

src/Storages/MergeTree/MergeTreeWhereOptimizer.cpp

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ MergeTreeWhereOptimizer::MergeTreeWhereOptimizer(
110110
if (it != column_sizes.end())
111111
total_size_of_queried_columns += it->second;
112112
}
113+
114+
if (estimator)
115+
total_rows = estimator->getTotalRows();
113116
}
114117

115118
void MergeTreeWhereOptimizer::optimize(SelectQueryInfo & select_query_info, const ContextPtr & context) const
@@ -509,6 +512,22 @@ void MergeTreeWhereOptimizer::analyzeImpl(Conditions & res, const RPNBuilderTree
509512
pk_positions.emplace(cond.min_position_in_primary_key);
510513
}
511514

515+
/// Combine I/O cost with selectivity using the classic conjunctive filter ordering rule:
516+
/// sort by cost / (1 - selectivity), i.e. cost per rejected row.
517+
const double rejected_rows = static_cast<double>(total_rows) - static_cast<double>(cond.estimated_row_count);
518+
if (total_rows == 0)
519+
/// No statistics: fall back to pure I/O cost.
520+
cond.cost_with_selectivity = static_cast<double>(cond.columns_size);
521+
else if (rejected_rows <= 0)
522+
/// Rejects no rows, so it is useless in PREWHERE regardless of its cost: schedule it last.
523+
cond.cost_with_selectivity = std::numeric_limits<double>::infinity();
524+
else if (cond.columns_size == 0)
525+
/// Compact parts don't track per-column compressed sizes: fall back to pure selectivity,
526+
/// otherwise every condition collapses to cost 0 and keeps its original position.
527+
cond.cost_with_selectivity = static_cast<double>(cond.estimated_row_count);
528+
else
529+
cond.cost_with_selectivity = static_cast<double>(cond.columns_size) / rejected_rows;
530+
512531
res.emplace_back(std::move(cond));
513532
}
514533
}
@@ -534,6 +553,7 @@ MergeTreeWhereOptimizer::Conditions MergeTreeWhereOptimizer::analyze(const RPNBu
534553
Condition cond({conjunct});
535554
cond.table_columns = columns;
536555
cond.columns_size = getColumnsSize(columns);
556+
cond.cost_with_selectivity = static_cast<double>(cond.columns_size);
537557
cond.viable =
538558
!has_invalid_column
539559
&& !columns.empty()

src/Storages/MergeTree/MergeTreeWhereOptimizer.h

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ class MergeTreeWhereOptimizer : private boost::noncopyable
8282
/// the lower the better
8383
UInt64 estimated_row_count = 0;
8484

85+
/// Combined I/O cost and selectivity score (lower is better): cost per rejected row,
86+
/// columns_size / (total_rows - estimated_row_count). A condition that rejects no rows
87+
/// gets +inf (scheduled last), and when per-column sizes are unavailable (columns_size=0,
88+
/// e.g. compact parts) it falls back to estimated_row_count so selectivity ordering is kept.
89+
double cost_with_selectivity = 0;
90+
8591
/// Does the condition contain primary key column?
8692
/// If so, it is better to move it further to the end of PREWHERE chain depending on minimal position in PK of any
8793
/// column in this condition because this condition have bigger chances to be already satisfied by PK analysis.
@@ -99,19 +105,20 @@ class MergeTreeWhereOptimizer : private boost::noncopyable
99105
}
100106
return fmt::format(
101107
"Condition(exp:{} viable: {}, good: {}, min_position_in_primary_key: {}, estimated_row_count: {}, "
102-
"columns_size: {}, table_columns.size: {})",
108+
"columns_size: {}, cost_with_selectivity: {}, table_columns.size: {})",
103109
names,
104110
viable,
105111
good,
106112
min_position_in_primary_key,
107113
estimated_row_count,
108114
columns_size,
115+
cost_with_selectivity,
109116
table_columns.size());
110117
}
111118

112119
auto tuple() const
113120
{
114-
return std::make_tuple(!viable, !good, -min_position_in_primary_key, estimated_row_count, columns_size, table_columns.size());
121+
return std::make_tuple(!viable, !good, -min_position_in_primary_key, cost_with_selectivity, table_columns.size());
115122
}
116123

117124
/// Is condition a better candidate for moving to PREWHERE?
@@ -186,6 +193,7 @@ class MergeTreeWhereOptimizer : private boost::noncopyable
186193
LoggerPtr log;
187194
std::unordered_map<std::string, UInt64> column_sizes;
188195
UInt64 total_size_of_queried_columns = 0;
196+
UInt64 total_rows = 0;
189197
};
190198

191199

src/Storages/Statistics/ConditionSelectivityEstimator.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ class ConditionSelectivityEstimator : public WithContext
101101
};
102102
using AtomMap = std::unordered_map<std::string, void(*)(RPNElement & out, const String & column, const Field & value)>;
103103
static const AtomMap atom_map;
104+
105+
UInt64 getTotalRows() const { return total_rows; }
106+
104107
private:
105108
friend class ColumnStatistics;
106109

tests/queries/0_stateless/03580_improve_prewhere.sql

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ CREATE TABLE test_improve_prewhere (
2020
value UInt32 STATISTICS(TDigest),
2121
date Date STATISTICS(CountMin),
2222
) ENGINE = MergeTree()
23-
ORDER BY primary_key;
23+
ORDER BY primary_key
24+
-- Pin compact parts (per-column sizes = 0) so PREWHERE ordering is by selectivity alone, stable under CI-randomized part-type/serialization settings.
25+
SETTINGS min_bytes_for_wide_part = 1000000000000, min_rows_for_wide_part = 1000000000000;
2426

2527
INSERT INTO test_improve_prewhere
2628
SELECT

tests/queries/0_stateless/04053_prewhere_statistics_decimal_overflow.sql

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ CREATE TABLE test_prewhere_decimal_overflow
1616
)
1717
ENGINE = MergeTree()
1818
ORDER BY (ts, id)
19-
SETTINGS min_bytes_for_wide_part = 0, min_rows_for_wide_part = 0, auto_statistics_types = '';
19+
-- Pin compact parts (per-column sizes = 0) so PREWHERE ordering is by selectivity alone, stable under CI-randomized serialization settings.
20+
SETTINGS min_bytes_for_wide_part = 1000000000000, min_rows_for_wide_part = 1000000000000, auto_statistics_types = '';
2021

2122
-- Single INSERT so all rows land in one part (needed for selectivity estimates to differ).
2223
INSERT INTO test_prewhere_decimal_overflow VALUES

tests/queries/0_stateless/04266_statistics_basic_prewhere.sql

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ CREATE TABLE test_basic_prewhere
3131
range_probe Int64 STATISTICS(tdigest)
3232
) ENGINE = MergeTree()
3333
ORDER BY id
34-
SETTINGS auto_statistics_types = '';
34+
-- Pin compact parts (per-column sizes = 0) so PREWHERE ordering is by selectivity alone, stable under CI-randomized part-type/serialization settings.
35+
SETTINGS auto_statistics_types = '', min_bytes_for_wide_part = 1000000000000, min_rows_for_wide_part = 1000000000000;
3536

3637
INSERT INTO test_basic_prewhere SELECT
3738
number,

tests/queries/0_stateless/04304_prewhere_statistics_column_grouping.sql

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ DROP TABLE IF EXISTS prewhere_stats_group;
88
CREATE TABLE prewhere_stats_group (
99
a UInt64 STATISTICS(tdigest, countmin),
1010
b UInt64 STATISTICS(countmin)
11-
) ENGINE = MergeTree ORDER BY tuple();
11+
) ENGINE = MergeTree ORDER BY tuple()
12+
-- Pin compact parts (per-column sizes = 0) so PREWHERE ordering is by selectivity alone, stable under CI-randomized part-type/serialization settings.
13+
SETTINGS min_bytes_for_wide_part = 1000000000000, min_rows_for_wide_part = 1000000000000;
1214

1315
INSERT INTO prewhere_stats_group SELECT number, number % 200 FROM numbers(5000) SETTINGS materialize_statistics_on_insert = 1;
1416

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- with statistics: cheap filter is placed before the expensive Map predicate
2+
1
3+
-- correctness: result is the same regardless of ordering
4+
0
5+
0
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
-- Regression test: when statistics are enabled, the PREWHERE optimizer must
2+
-- combine selectivity (estimated_row_count) with I/O cost (columns_size)
3+
-- using the classic cost / (1 - selectivity) rule, not sort by selectivity alone.
4+
--
5+
-- Without this fix, the auto-collected Uniq statistic on `modality` (2 distinct
6+
-- values → 50% selectivity) makes the optimizer place the expensive Map-key
7+
-- predicate first because its default 1% selectivity looks "more selective",
8+
-- ignoring that the Map column is ~500x more expensive to read.
9+
10+
SET enable_analyzer = 1;
11+
SET optimize_functions_to_subcolumns = 0;
12+
SET optimize_move_to_prewhere = 1;
13+
SET query_plan_optimize_prewhere = 1;
14+
SET allow_reorder_prewhere_conditions = 1;
15+
SET use_statistics = 1;
16+
SET explain_query_plan_default = 'legacy';
17+
18+
DROP TABLE IF EXISTS t_prewhere_stats_cost;
19+
CREATE TABLE t_prewhere_stats_cost (id UInt64, modality LowCardinality(String), h Map(String, String))
20+
ENGINE = MergeTree ORDER BY id SETTINGS min_bytes_for_wide_part = 0;
21+
22+
-- Split `modality` 50/50: the reject-count ratio is then ~2x while the Map column dwarfs the
23+
-- scalar, so the cost gap dominates and cheap-filter-first stays stable under randomized serialization.
24+
INSERT INTO t_prewhere_stats_cost
25+
SELECT number, if(number % 2 = 0, 'active', ''), map('k', repeat('v', 300), 'k2', repeat('w', 300))
26+
FROM numbers(200000);
27+
OPTIMIZE TABLE t_prewhere_stats_cost FINAL;
28+
29+
SELECT '-- with statistics: cheap filter is placed before the expensive Map predicate';
30+
SELECT position(explain, 'modality') > 0 AND position(explain, 'modality') < position(explain, 'arrayElement') AS cheap_first
31+
FROM (
32+
EXPLAIN actions = 1 SELECT count() FROM t_prewhere_stats_cost WHERE modality = '' AND h['k'] = 'nope'
33+
) WHERE explain LIKE '%Prewhere filter column%';
34+
35+
SELECT '-- correctness: result is the same regardless of ordering';
36+
SELECT count() FROM t_prewhere_stats_cost WHERE modality = '' AND h['k'] = 'nope';
37+
SELECT count() FROM t_prewhere_stats_cost WHERE modality = '' AND h['k'] = 'nope'
38+
SETTINGS allow_reorder_prewhere_conditions = 0;
39+
40+
DROP TABLE t_prewhere_stats_cost;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- compact part: selective condition ordered first despite zero column sizes
2+
1
3+
-- correctness: result is the same regardless of ordering
4+
10
5+
10

0 commit comments

Comments
 (0)