bench(predicate_eval): add expensive-first and wide-column shapes - #25032
Open
adriangb wants to merge 3 commits into
Open
bench(predicate_eval): add expensive-first and wide-column shapes#25032adriangb wants to merge 3 commits into
adriangb wants to merge 3 commits into
Conversation
Two shapes were missing from the predicate_eval suite, both of which distinguish a compact-once evaluation loop from simply rebuilding a left-deep AND in the learned order. costsel q04 is the mirror of q03: the expensive predicate (`regexp_like(s, 'rare')`, ~0.1%) is also the selective one and is written first, so the as-written order is already optimal. Together q03/q04 bracket a reorderer's behaviour when cost and selectivity point the same way. cardinality q34 repeats q32's k = 8 predicate over a new 64-column `ints_wide` dataset. The dataset is `ints` widened from 16 to 64 columns, keeping the same multipliers for c0..c15 (all coprime to 100), so the predicate has exactly the same selectivities -- verified: both tables return count(*) = 5000 at PRED_ROWS=100000. Only the width of the batches flowing through the filter changes, which isolates the per-conjunct cost of materializing a filtered batch. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The suite's template had no `result` directive, so `--result-mode validate` verified nothing. Every predicate_eval query is a `count(*)` whose value is fixed by the generated data, so persist those counts once and point the template at them: validation now also checks that a reordering under test still returns the same rows. The counts are persisted at the suite defaults (PRED_ROWS=1000000, PRED_FILL=30), so validation assumes those; the scale and width subgroups pin their own values per query and hold at any setting. Add a `nulls` subgroup (q90/q91): the selective conjunct's column is NULL on 10% of rows. `BinaryExpr`'s AND pre-selection bails when the left-hand boolean array has any NULL (`check_short_circuit` returns early on `null_count() > 0`), so a nullable selective conjunct gates nothing wherever it is written -- q90 writes it first, q91 last. An adaptive reorderer that ranks by selectivity alone will hoist it and gain nothing. Add two more drift shapes. The existing flip lands within the first few batches at 1M rows, so a one-shot warm-up already sees the post-flip order; q82 flips at the halfway point, which makes a warm-up-and-freeze decision wrong for half the scan. q83 swaps the favoured conjunct every 131072 rows. That was meant to be a per-partition skew, but `CREATE TABLE ... AS SELECT` fans a `generate_series` scan out with `RoundRobinBatch`, which deals whole batches round-robin, so any contiguous block is sprayed across every partition; it is therefore a within-partition alternating-block shape and is documented as such. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #25032 +/- ##
==========================================
- Coverage 81.72% 81.72% -0.01%
==========================================
Files 1127 1127
Lines 416237 416310 +73
Branches 416237 416310 +73
==========================================
+ Hits 340156 340213 +57
- Misses 56092 56104 +12
- Partials 19989 19993 +4 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…arquet files
q83 was meant to give each scan partition its own selectivity profile, so that
one global ordering decision is necessarily backwards for half of the streams.
That shape is not reachable through `CREATE TABLE ... AS SELECT`: the plan for a
`generate_series` scan is a single partition fanned out by a `RepartitionExec`
with `RoundRobinBatch(target_partitions)`, which deals whole *batches*
round-robin, so any contiguous block of rows is sprayed across every partition
and the block index -- not the partition -- decides which conjunct wins. The
query was therefore committed as a within-partition alternating-block shape and
documented as such.
Build the table out of files instead. `load/drift_files.sql` writes 16 small
Parquet files, each `PRED_ROWS / 16` rows with a fixed profile -- odd files
favour `a_sel = 0` (~0.1%, against `b_sel = 0` at ~50%), even files the mirror,
at the same rates as q80/q81/q82 -- and registers the directory as one external
table.
Two read-side settings make the partitioning match the files, and neither alone
is enough. `target_partitions = 16` matches the file count, so
`FileGroup::split_files` puts one file in each group and `EnforceDistribution`
adds no `RoundRobinBatch` above the scan; at the machine default (12 here) the
16 files chunk into 8 groups of 2 -- one file of each profile per group -- and a
`RoundRobinBatch(12)` then re-deals those batches anyway.
`repartition_file_scans = false` stops `FileGroupPartitioner` re-deriving the
groups as byte ranges over the total file bytes, which ignores file identity and
at the default produces groups like `[f00:0..224901, f01:0..74967]`. `load` and
the benchmarked `run` share one `SessionContext`, so both settings reach the
measured query, and the scan then plans as
FilterExec: a_sel@0 = 0 AND b_sel@1 = 0, projection=[]
DataSourceExec: file_groups={16 groups: [[.../f00.parquet],
[.../f01.parquet], [.../f02.parquet], ...]}, ...
with the filter directly on the scan and no byte ranges, so a pooled warm-up
that settles one order is backwards for half of the partitions.
The files land in a gitignored `predicate_eval/scratch/`, mirroring
`parquet_row_filter_skip`; `DROP TABLE` in the shared cleanup drops the table and
the next load overwrites the files. Loading q83 costs ~50ms more than q80 at the
default `PRED_ROWS`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Which issue does this PR close?
Rationale for this change
The
predicate_evalsuite (the conjunctive-filter microbenchmarks added for adaptive predicate ordering in #11262) was missing a few shapes that distinguish evaluation strategies for AND chains, and it had no result checking at all:An expensive-selective conjunct written before a cheap-unselective one. The suite already had the reverse (
costselq03: cheapc0 < 90at ~90% first, expensiveregexp_like(s, 'rare')at ~0.1% second). Without the mirror shape there is no case in the suite where the as-written order is already the optimal one, so the suite cannot tell "reordered well" apart from "reordered at all". It also exercises a distinct code path: measuring a cheap conjunct on the small survivor batch left after pre-selection by an expensive one, rather than measuring an expensive conjunct on a full batch.A many-column table. Every existing
cardinalityquery runs over the 16-columnintsdataset, so the cost of materializing a filtered batch per level is small and roughly constant across the suite. Re-running the k = 8 predicate over a 64-column table separates the cost of evaluating the conjuncts from the cost of filtering the batch at each level, which is what per-level batch filtering actually pays for on wide inputs.No result validation. The suite's template had no
resultdirective, so--result-mode validateverified nothing on it. A reordering under test must not change which rows survive the filter, and every query here is acount(*)whose value is fixed by the generated data — a checked-in expected count is therefore a free correctness check on top of the timing.Nullable conjuncts. Every predicate in the suite was non-nullable, so the suite never exercised the one case where writing the selective conjunct first buys nothing:
BinaryExpr's AND pre-selection bails out when the left-hand boolean array contains any NULL.Drift that a warm-up cannot absorb. The existing
driftdataset flips which conjunct is selective 10% of the way through the table, which at the defaultPRED_ROWSis within the first few batches — a one-shot warm-up already lands on the post-flip order and is right for the remaining ~90% of the scan. There was no shape where a decide-once strategy is wrong for a large part of the scan, and none at all where the right order differs between concurrently running partitions rather than over time.What changes are included in this PR?
Three commits, all entirely under
benchmarks/(plus the suite's own docs).1. Expensive-first and wide-column shapes — five new files under
benchmarks/sql_benchmarks/predicate_eval/:queries/costsel/q04.sql+benchmarks/costsel/q04.benchmark—costsel_q04_expensive_selective_then_cheap_unselective: the mirror of q03. Same two predicates, written the other way round, so the as-written order is already the best one. Together q03/q04 bracket a reorderer's behaviour when cost and selectivity point the same way.queries/cardinality/q34.sql+benchmarks/cardinality/q34.benchmark—cardinality_q34_k8_wide64: q32's k = 8 predicate (seven ~90% compares followed by one ~5% compare) run over the new 64-column dataset.load/ints_wide.sql—intswidened from 16 to 64 Int64 columns. It extendsints.sql's multiplier sequence rather than replacing it:c0..c15keep exactly the same multipliers (all coprime to 100, so residues stay uniform and columns stay mutually decorrelated), which means q34's predicate has exactly the same hidden selectivities as q32's. Only the width of the batches flowing through the filter changes.2. Result validation, a
nullssubgroup, and a late-flip drift shape:result sql_benchmarks/predicate_eval/results/${NAME}.csv, and the expectedcount(*)for all 32 benchmarks (existing and new) is persisted underpredicate_eval/results/.--result-mode validatenow checks the row counts as well as running the timing. Caveat, documented in the template and the suite's README entry: the counts were persisted at the suite defaults (PRED_ROWS=1000000,PRED_FILL=30), so validation assumes those. Thescaleandwidthsubgroups pin their ownPRED_ROWS/PRED_FILLas template parameters (which win over the environment), so those validate at any setting of the knobs.nullssubgroup (load/nulls.sql,queries/nulls/q90.sql,q91.sql, and their.benchmarkfiles).c_selis NULL on exactly 10% of rows and uniform on[0,100)elsewhere, soc_sel < 5is true on exactly 4% of rows and NULL on 10%.check_short_circuitreturnsShortCircuitStrategy::Noneas soon as the left-hand boolean array hasnull_count() > 0, so the nullable selective conjunct gates nothing wherever it is written: q90 writes it first, q91 writes it last. A reorderer that ranks by selectivity alone will hoist it to the front and gain nothing, which is what these two measure.driftq82 — late flip (load/drift_half.sql). The same mirrored predicates as q80/q81, but the flip is at the halfway point of the table, so a warm-up-and-freeze decision is wrong for half the scan.predicate_evalrow inbenchmarks/sql_benchmarks/README.mdnow lists thenullssubgroup and the result-validation behaviour with its default-PRED_ROWScaveat;benchmarks/bench.shand the suite description listnullstoo, andbench.sh'srun_predicate_evalcomment notes that q83 writes intopredicate_eval/scratch/and pins its owntarget_partitions.3. Per-partition drift (q83) — replaces the alternating-block shape from commit 2, which could only approximate it:
driftq83 — per-partition skew (load/drift_files.sql). Each scan partition gets its own fixed selectivity profile, so one global ordering decision is necessarily backwards for half of the streams — the case a decide-once or re-sample-a-shared-decision strategy cannot get right, and a per-stream decision can. That shape is not reachable from aCREATE TABLE ... AS SELECTMemTable: agenerate_seriesscan is one partition fanned out byRepartitionExecwithRoundRobinBatch(target_partitions), which deals whole batches round-robin, so partitionpholds batchesp, p+P, p+2P, ...and any contiguous block of rows is sprayed across every partition. The load script therefore writes 16 small Parquet files instead —PRED_ROWS / 16rows each, odd files favouringa_sel = 0(~0.1%, againstb_sel = 0at ~50%) and even files the mirror, at the same rates q80/q81/q82 use — into a gitignoredpredicate_eval/scratch/(mirroringparquet_row_filter_skip), and registers the directory as one external table.Two read-side settings pin one file per partition, and neither alone is enough.
target_partitions = 16matches the file count, soFileGroup::split_filesputs one file in each group andEnforceDistributionadds noRoundRobinBatchabove the scan; at the machine default (12 cores here) the 16 files chunk into 8 groups of 2 — one file of each profile per group — and aRoundRobinBatch(12)then re-deals those batches anyway.repartition_file_scans = falsestopsFileGroupPartitionerre-deriving the groups as byte ranges over the total file bytes, which ignores file identity and at the default yields groups like[f00:0..224901, f01:0..74967].loadand the benchmarkedrunshare oneSessionContext(SqlBenchmark::initialize), so both settings reach the measured query. Pinningtarget_partitionsalso makes the shape identical on every machine, at the cost of over- or under-subscribing cores relative to the other drift queries; the load script and query comment say all of this.No Rust changes: the suite discovers the new
.benchmarkfiles automatically, and the only non-benchmark edits are the README/bench.sh/.suitetext above. The suite still sets no engine config globally — the two settings above live in q83's own load SQL, on that benchmark's ownSessionContext, because they are part of the data shape rather than a strategy under test.What is the testing strategy for this PR?
No unit tests: these are benchmark definitions, not library code. Verification is that the suite parses, every query runs and produces a result row, and every query's result matches its checked-in expected count.
All ten subgroups pass
--result-mode validateat the defaultPRED_ROWS(32 benchmarks,-i 1, release build, on this branch on top ofmainwith no other changes):cost,selectivity,width,scale,neutralandcorrelationpass the same way, as does a single run of the whole suite (predicate_eval -i 1 --result-mode validate: 32 benchmarks, exit 0).Validation was checked to actually be checking: overwriting one expected count (
nulls_q9020000 → 999999) makes the run fail withand restoring the file makes it pass again.
The new datasets were also verified directly.
drift_halfhas the flip exactly at the halfway row (a_sel = 0at 0.0998% / 50.0% over the two halves,b_sel = 0mirrored).nullshas exactly 100000 NULLs and 40000 rows withc_sel < 5atPRED_ROWS=1000000.drift_fileswas checked both per file and in the plan. Per file,f00(even) has 62500 rows witha_sel = 0on 31250 of them andb_sel = 0on 62;f01(odd) is the exact mirror; over the whole tablecount(*) = 1000000,a_sel = 0andb_sel = 0each on 250496 rows, and both on 992 — the persisted count.The partitioning was confirmed with
EXPLAIN(release build of this branch, 12-core box). One whole file per group, no byte ranges, and the filter directly on the scan:Both settings were checked to be load-bearing by dropping them. Without the
target_partitionspin the scan plans as 8 groups of 2 files with aRoundRobinBatch(12)on top; withrepartition_file_scansleft on it plans asfile_groups={12 groups: [[.../f00.parquet:0..224901, .../f01.parquet:0..74967], [.../f01.parquet:74967..224901, .../f02.parquet:0..149934], ...]}— both profiles in one stream either way.Validation was checked to be checking on the new query too: setting
drift_q83_per_partition_skew.csvto 991 fails withexpected value "991" but got value "992", and restoring it passes.The extra load cost is small. At the default
PRED_ROWS,--query 83 -i 1takes ~60 ms wall from a cleared scratch directory against ~10 ms for--query 80, and the wholedriftsubgroup (-i 1, cold scratch) runs in ~0.08 s.That q34 and q32 have identical selectivities was checked separately: both return
count(*) = 5000atPRED_ROWS=100000.Are there any user-facing changes?
No. Benchmark definitions only; nothing in this PR is part of any public API or affects query execution.
🤖 Generated with Claude Code