Skip to content

bench: add predicate_eval SQL micro-benchmark suite for conjunctive filter evaluation - #22704

Merged
adriangb merged 2 commits into
apache:mainfrom
pydantic:predicate-eval-benchmarks
Jun 8, 2026
Merged

bench: add predicate_eval SQL micro-benchmark suite for conjunctive filter evaluation#22704
adriangb merged 2 commits into
apache:mainfrom
pydantic:predicate-eval-benchmarks

Conversation

@adriangb

@adriangbadriangb commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

This PR does not close an issue. It adds a benchmark suite to support work and
discussion around predicate ordering in filter evaluation (e.g. the static
reordering in #22343 and the runtime/statistics-based reordering explored in
#22698). It deliberately benchmarks no specific implementation — see below.

Rationale for this change

Conjunctive (AND) filter evaluation in FilterExec is a left-deep
BinaryExpr(And) chain, and the order conjuncts are evaluated in can change
runtime by large factors: once a leading conjunct passes few enough rows the
batch is physically compacted before the rest, so a cheap-and-selective
predicate evaluated early saves later predicates work. Predicate ordering is
therefore an active area (static heuristics, runtime/adaptive schemes, cost
models).

There is currently no benchmark suite that isolates the dimensions that drive
this. Existing macro-benchmarks (TPC-H/DS, ClickBench) only incidentally
exercise filter ordering, so they can't show why a change to ordering helped
or hurt, or guard the order-insensitive case against regressions.

What changes are included in this PR?

A new SQL benchmark suite, benchmarks/sql_benchmarks/predicate_eval, built on
the existing .benchmark template framework (no engine code, no new Rust). It
sets no engine config of its own and measures DataFusion's built-in short-circuit
by default; a system under test is toggled purely via its native
DATAFUSION_EXECUTION_* env var (the bench harness builds its SessionContext
with SessionConfig::from_env), so the same scenarios can characterise the
baseline, a static heuristic, an adaptive scheme, or a cost model and be
compared apples-to-apples.

It is organised into 10 subgroups (select with BENCH_SUBGROUP), each varying
one property of conjunctive filter evaluation while holding the others fixed:

SubgroupWhat it varies (others held fixed)
costselcost and selectivity point in different directions (expensive predicate is the selective one)
costper-predicate cost, at equal selectivity
selectivityper-predicate selectivity, at equal cost
cardinalityconjunct count k = 2/4/8/16
widthstring-column width (PRED_FILL = 2 / 30 / 170 chars)
scalerow count 5k / 100k / 5M / 50M
neutralpredicates are interchangeable (equal cost, none selective) — an order-insensitive control
correlationconditional vs marginal selectivity (independent / positively / anti-correlated)
driftselectivity that changes across the scan
nullsnull density (two- vs three-valued predicate results)

Each query's comment notes the per-predicate cost/selectivity that the data
generation hides from the SQL. Data is synthetic and generated inline by each
subgroup's load SQL (no external files); PRED_ROWS sizes it and PRED_FILL
sets string width. Wired into bench.sh (./bench.sh run predicate_eval) and
documented in benchmarks/sql_benchmarks/README.md.

The design was informed by surveying how Velox drives the analogous decision
(it ranks by cycles-per-row-eliminated, time / (rows_in - rows_out)).

Note: the scale subgroup's q52/q53 build 5M / 50M-row tables (the latter
~9 GB); run a single point with BENCH_QUERY if that is too heavy.

Are these changes tested?

These are benchmark definitions, not engine code. Each .benchmark includes an
assert that the generated table is non-empty, and every subgroup was run
locally at small PRED_ROWS to confirm the suite parses, loads, asserts, and
executes end-to-end. The queries are order-invariant (SELECT count(*) ...), so
any predicate-ordering system can also be checked for correctness by diffing
counts with the optimization on vs. off.

Are there any user-facing changes?

No. This only adds an opt-in benchmark suite and its documentation; no public
API, engine behavior, or default configuration changes.

@adriangb
adriangbforce-pushed the predicate-eval-benchmarks branch 2 times, most recently from a09a05a to 22c632fCompareJune 2, 2026 04:41
@adriangb
adriangb marked this pull request as ready for review June 2, 2026 04:46
@adriangb

Copy link
Copy Markdown
ContributorAuthor

Results

Run config (defaults):BENCH_NAME=predicate_eval PRED_ROWS=1000000 (all 10 subgroups, scale subgroup at its own 5k/100k/5M/50M sizes), criterion 10 samples/query, no engine config set (measures DataFusion's built-in left-deep AND short-circuit).

This was run on a noisy laptop, noise is expected.

costsel — cost & selectivity point opposite ways (expensive predicate is the selective one)

BenchmarkMedianMeanMean 95% CI
costsel_q01_regexp_selective_last5.113 ms5.184 ms[5.097 ms, 5.287 ms]
costsel_q02_regexp_selective_first1.642 ms1.654 ms[1.636 ms, 1.675 ms]
costsel_q03_cheap_unselective_then_expensive_selective1.264 ms1.265 ms[1.250 ms, 1.283 ms]

cost — per-predicate cost, at equal selectivity

BenchmarkMedianMeanMean 95% CI
cost_q10_expensive_first2.261 ms2.545 ms[2.062 ms, 3.152 ms]
cost_q11_cheap_first653.311 µs660.827 µs[648.549 µs, 678.636 µs]

selectivity — per-predicate selectivity, at equal cost

BenchmarkMedianMeanMean 95% CI
selectivity_q20_unselective_first454.933 µs455.685 µs[449.862 µs, 462.258 µs]
selectivity_q21_selective_first420.744 µs422.432 µs[418.718 µs, 426.746 µs]

cardinality — conjunct count k = 2/4/8/16

BenchmarkMedianMeanMean 95% CI
cardinality_q30_k2424.973 µs429.211 µs[414.203 µs, 447.021 µs]
cardinality_q31_k4608.709 µs613.319 µs[590.725 µs, 637.308 µs]
cardinality_q32_k83.018 ms2.874 ms[2.195 ms, 3.556 ms]
cardinality_q33_k163.996 ms4.392 ms[3.787 ms, 5.123 ms]

width — string-column width (PRED_FILL = 2/30/170 chars)

BenchmarkMedianMeanMean 95% CI
width_q40_narrow8.387 ms7.571 ms[6.594 ms, 8.428 ms]
width_q41_wide5.822 ms6.005 ms[5.740 ms, 6.326 ms]
width_q42_xwide30.149 ms30.270 ms[29.570 ms, 31.062 ms]

scale — row count 5k/100k/5M/50M

BenchmarkMedianMeanMean 95% CI
scale_q50_5k195.848 µs194.429 µs[190.381 µs, 197.835 µs]
scale_q51_100k300.590 µs301.308 µs[299.721 µs, 303.113 µs]
scale_q52_5m5.157 ms5.231 ms[5.114 ms, 5.361 ms]
scale_q53_50m48.703 ms48.883 ms[48.221 ms, 49.680 ms]

neutral — order-insensitive control (equal cost, none selective)

BenchmarkMedianMeanMean 95% CI
neutral_q60_cheap_uniform797.491 µs816.330 µs[768.721 µs, 869.888 µs]
neutral_q61_expensive_uniform4.263 ms4.463 ms[4.072 ms, 4.905 ms]

correlation — conditional vs marginal selectivity (indep/pos/anti-correlated)

BenchmarkMedianMeanMean 95% CI
correlation_q70_independent443.514 µs455.220 µs[442.026 µs, 472.727 µs]
correlation_q71_positive353.478 µs371.662 µs[349.020 µs, 401.121 µs]
correlation_q72_anti370.724 µs372.424 µs[356.075 µs, 389.509 µs]

drift — selectivity changes across the scan

BenchmarkMedianMeanMean 95% CI
drift_q80_a_then_b355.596 µs358.509 µs[351.533 µs, 365.869 µs]
drift_q81_b_then_a323.365 µs323.517 µs[319.436 µs, 327.678 µs]

nulls — null density (two- vs three-valued predicate results)

BenchmarkMedianMeanMean 95% CI
nulls_q90_no_nulls_control415.150 µs434.152 µs[412.615 µs, 463.365 µs]
nulls_q91_half_null382.150 µs391.865 µs[375.653 µs, 409.933 µs]

Total runtime is ~5 min, but it is dominated by criterion overhead (warmup, etc.).

@alambalamb left a comment

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.

I ran it locally like

andrewlamb@Andrews-MacBook-Pro-3:~/Software/datafusion$ ./benchmarks/bench.sh run predicate_eval
***************************
DataFusion Benchmark Script
COMMAND: run
BENCHMARK: predicate_eval
QUERY: All
DATAFUSION_DIR: /Users/andrewlamb/Software/datafusion/benchmarks/..
BRANCH_NAME: predicate-eval-benchmarks
DATA_DIR: /Users/andrewlamb/Software/datafusion/benchmarks/data
RESULTS_DIR: /Users/andrewlamb/Software/datafusion/benchmarks/results/predicate-eval-benchmarks
CARGO_COMMAND: cargo run --release
PREFER_HASH_JOIN: true
SIMULATE_LATENCY: false***************************
Running predicate_eval benchmark (subgroup=all, rows=1000000)...
+ env BENCH_NAME=predicate_eval PRED_ROWS=1000000 bash -c 'cargo bench --bench sql'
Finished `bench` profile [optimized] target(s) in 0.46s
Running benches/sql.rs (/Users/andrewlamb/Software/datafusion/target/release/deps/sql-b0eb418b4acab8cd)
Gnuplot not found, using plotters backend
Loading benchmarks...
Loaded benchmarks in 7 ms ...
predicate_eval/cardinality_q30_k2_cardinality
time: [465.56 µs 468.56 µs 471.54 µs]
change: [−4.9556% −3.6923% −2.3399%] (p = 0.00 < 0.05)
Performance has improved.
predicate_eval/cardinality_q31_k4_cardinality
time: [699.03 µs 713.94 µs 732.46 µs]
change: [+0.2133% +2.5165% +5.6037%] (p = 0.10 > 0.05)
No change in performance detected.
Found 1 outliers among 10 measurements (10.00%)
1 (10.00%) high severe
Benchmarking predicate_eval/cardinality_q32_k8_cardinality: Collecting 10 samples in estimated 5.0021 s (4330 iterations)
...

I tried to verify with instruments

BENCH_NAME=predicate_eval PRED_ROWS=1000000 bash -c 'cargo bench --profile=profiling --bench sql'
Compiling datafusion-benchmarks v53.1.0 (/Users/andrewlamb/Software/datafusion/benchmarks)
^C Building [=======================> ] 450/457: mem_profile(bin), external_aggr(bin), sql(bench), benchmark_runner(bin), gen_wide_data(bin), imdb(bin), dfbench(bin)

But building the --profiling was building a bunch of binaries all at once. 😢

@@ -0,0 +1,7 @@
subgroup cardinality

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.

@Omega359 -- it is cool to see the new benchmark runners in action

@@ -0,0 +1,2 @@
name = "predicate_eval"
description = "Implementation-agnostic micro-benchmark suite for conjunctive (AND) filter evaluation. Characterises FilterExec along the axes that drive any predicate-ordering / AQE system: per-predicate cost and selectivity, the cost-weighted (cost/(1-sel)) interaction, conjunct count, string-column width, row count, predicate correlation, selectivity drift, nulls, and a pure-overhead control. Subgroups (select with BENCH_SUBGROUP): costsel, cost, selectivity, cardinality, width, scale, neutral, correlation, drift, nulls. The suite measures DataFusion's built-in left-deep AND short-circuit by default and sets no engine config of its own; toggle a reordering system under test with its native DATAFUSION_* env var (the harness reads SessionConfig::from_env), e.g. DATAFUSION_EXECUTION_ADAPTIVE_FILTER_REORDERING=true. Size synthetic data with PRED_ROWS and string-column width with PRED_FILL."

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.

i found this description somewhat hard to parse. I think it might be better to keep it short explaining it tests differnt predicate patterns that test for adaptivity and perhaps linking to the relevant tickets for more context.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

addressed, thanks!

Comment threadbenchmarks/bench.sh Outdated
bash -c "$SQL_CARGO_COMMAND"
}

# Runs the predicate_eval benchmark suite. Data is generated inline by the

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.

Same comment from the description also applies here

@adriangb
adriangb enabled auto-merge June 4, 2026 15:22
@2010YOUY01

Copy link
Copy Markdown
Contributor

Subgroup What it varies (others held fixed)
costsel cost and selectivity point in different directions (expensive predicate is the selective one)
cost per-predicate cost, at equal selectivity
selectivity per-predicate selectivity, at equal cost
cardinality conjunct count k = 2/4/8/16
width string-column width (PRED_FILL = 2 / 30 / 170 chars)
scale row count 5k / 100k / 5M / 50M
neutral predicates are interchangeable (equal cost, none selective) — an order-insensitive control
correlation conditional vs marginal selectivity (independent / positively / anti-correlated)
drift selectivity that changes across the scan
nulls null density (two- vs three-valued predicate results)

Regarding the design: overall, I think it's great. In particular, I think it's important to test the drift dimension.

One suggestion: nulls seem redundant here. Efficiently handling nulls is the responsibility of the expr evaluator. Within the scope of filter reordering, I think null density is at the same dimension as selectivity or cost, so we could remove it for simplicity.

adriangband others added 2 commits June 8, 2026 17:57
…ilter evaluation
Add `benchmarks/sql_benchmarks/predicate_eval`, an implementation-agnostic
SQL benchmark suite that isolates the cost axes driving conjunctive (AND)
filter evaluation in FilterExec's left-deep short-circuit path: cost-weighted
ordering (cost/(1-sel)), per-predicate cost, selectivity, conjunct count,
string-column width, row count, predicate correlation, selectivity drift,
nulls, and an order-neutral overhead/regression guard.
The suite sets no engine config of its own. It measures DataFusion's built-in
short-circuit by default; a predicate-ordering system under test is toggled via
its native DATAFUSION_* env var (the bench harness builds its SessionContext
with SessionConfig::from_env). Synthetic data is generated inline by each
subgroup's load SQL and sized with PRED_ROWS / PRED_FILL.
Built on the existing .benchmark template framework (no engine code). Wired
into bench.sh (`./bench.sh run predicate_eval`) and documented in the
sql_benchmarks README.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address review feedback: the suite/bench.sh/README descriptions were hard
to parse. Lead with the point -- each subgroup is a different predicate
pattern, used to test how an adaptive predicate-ordering system behaves
across them -- and link to apache#11262 as an example of the
change these benchmarks are meant to help drive, instead of inlining the
full cost-axis breakdown.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@adriangb
adriangbforce-pushed the predicate-eval-benchmarks branch from 46967db to 903d349CompareJune 8, 2026 23:13
@adriangb
adriangb added this pull request to the merge queueJun 8, 2026
Merged via the queue into apache:main with commit 0f8a121Jun 8, 2026
35 checks passed
@adriangb
adriangb deleted the predicate-eval-benchmarks branch June 8, 2026 23:47
AdamGS pushed a commit to AdamGS/arrow-datafusion that referenced this pull request Jun 11, 2026
…ilter evaluation (apache#22704)
## Which issue does this PR close?
<!-- No tracking issue; this is a standalone benchmark contribution. -->
This PR does not close an issue. It adds a benchmark suite to support
work and
discussion around predicate ordering in filter evaluation (e.g. the
static
reordering in apache#22343 and the runtime/statistics-based reordering
explored in
apache#22698). It deliberately benchmarks *no specific implementation* — see
below.
## Rationale for this change
Conjunctive (`AND`) filter evaluation in `FilterExec` is a left-deep
`BinaryExpr(And)` chain, and the order conjuncts are evaluated in can
change
runtime by large factors: once a leading conjunct passes few enough rows
the
batch is physically compacted before the rest, so a cheap-and-selective
predicate evaluated early saves later predicates work. Predicate
ordering is
therefore an active area (static heuristics, runtime/adaptive schemes,
cost
models).
There is currently no benchmark suite that isolates the dimensions that
drive
this. Existing macro-benchmarks (TPC-H/DS, ClickBench) only incidentally
exercise filter ordering, so they can't show *why* a change to ordering
helped
or hurt, or guard the order-insensitive case against regressions.
## What changes are included in this PR?
A new SQL benchmark suite, `benchmarks/sql_benchmarks/predicate_eval`,
built on
the existing `.benchmark` template framework (no engine code, no new
Rust). It
sets no engine config of its own and measures DataFusion's built-in
short-circuit
by default; a system under test is toggled purely via its native
`DATAFUSION_EXECUTION_*` env var (the bench harness builds its
`SessionContext`
with `SessionConfig::from_env`), so the same scenarios can characterise
the
baseline, a static heuristic, an adaptive scheme, or a cost model and be
compared apples-to-apples.
It is organised into 10 subgroups (select with `BENCH_SUBGROUP`), each
varying
one property of conjunctive filter evaluation while holding the others
fixed:
| Subgroup | What it varies (others held fixed) |
|---|---|
| `costsel` | cost and selectivity point in different directions
(expensive predicate is the selective one) |
| `cost` | per-predicate cost, at equal selectivity |
| `selectivity` | per-predicate selectivity, at equal cost |
| `cardinality` | conjunct count `k = 2/4/8/16` |
| `width` | string-column width (`PRED_FILL` = 2 / 30 / 170 chars) |
| `scale` | row count `5k / 100k / 5M / 50M` |
| `neutral` | predicates are interchangeable (equal cost, none
selective) — an order-insensitive control |
| `correlation` | conditional vs marginal selectivity (independent /
positively / anti-correlated) |
| `drift` | selectivity that changes across the scan |
| `nulls` | null density (two- vs three-valued predicate results) |
Each query's comment notes the per-predicate cost/selectivity that the
data
generation hides from the SQL. Data is synthetic and generated inline by
each
subgroup's load SQL (no external files); `PRED_ROWS` sizes it and
`PRED_FILL`
sets string width. Wired into `bench.sh` (`./bench.sh run
predicate_eval`) and
documented in `benchmarks/sql_benchmarks/README.md`.
The design was informed by surveying how Velox drives the analogous
decision
(it ranks by cycles-per-row-eliminated, `time / (rows_in - rows_out)`).
> Note: the `scale` subgroup's `q52`/`q53` build 5M / 50M-row tables
(the latter
> ~9 GB); run a single point with `BENCH_QUERY` if that is too heavy.
## Are these changes tested?
These are benchmark definitions, not engine code. Each `.benchmark`
includes an
`assert` that the generated table is non-empty, and every subgroup was
run
locally at small `PRED_ROWS` to confirm the suite parses, loads,
asserts, and
executes end-to-end. The queries are order-invariant (`SELECT count(*)
...`), so
any predicate-ordering system can also be checked for correctness by
diffing
counts with the optimization on vs. off.
## Are there any user-facing changes?
No. This only adds an opt-in benchmark suite and its documentation; no
public
API, engine behavior, or default configuration changes.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
kumarUjjawal pushed a commit to kumarUjjawal/datafusion that referenced this pull request Jun 22, 2026
…22919)
## Which issue does this PR close?
- Related to apache#11262 (predicate evaluation ordering). Extends the
`predicate_eval`
suite added in apache#22704. No single issue closed.
## Rationale for this change
The `correlation` subgroup's existing cases (q70–q72) use two predicates
of equal
cost and equal selectivity. For two conjuncts the evaluation cost of an
order is
`cost(first) + selectivity(first) × cost(second)`, which is symmetric
here — so
the two orders cost the same and correlation only affects the result
cardinality.
These cases measure the *overhead* of an ordering system, but give it no
opportunity: nothing in the suite rewards (or even detects)
correlation-aware
ordering.
This adds a case with real, measurable headroom that **only** joint
statistics can
find. A cheap integer predicate (`c0 = 1`, ~30%) is a perfect proxy for
three
string regexes (on `s1`/`s2`/`s3`); a fourth regex (on `s4`) has the
same ~30%
selectivity and the same cost but is independent. The four string
columns are
deliberately **identical in shape** — equal width, one marker at the
same offset,
an equally cheap regex each — so marginally the four regexes are
indistinguishable
in *any* position: neither a per-predicate cost/selectivity estimate nor
runtime
timing can prefer one over another. Conditionally — behind the proxy —
the three
correlated regexes keep every survivor while the `s4` regex still
discards ~70%.
The query is written in the natural-but-pessimal order (the redundant
regexes
grouped with their proxy, the informative one last). On an M-series
laptop the
written order runs ~1.7x slower than the hand-optimal order `[c0, s4,
s1/s2/s3]`
(16.5 ms vs 9.7 ms median per iteration), so:
- an ordering system using *marginal* per-predicate statistics (or an
independence assumption) is blind to the difference — every ranking of
the four
regexes looks equivalent;
- a system measuring the predicates' *joint* behaviour can reliably
collect ~1.7x.
## What changes are included in this PR?
- `load/corrproxy.sql` — the correlated-proxy dataset (deterministic,
generated
from `generate_series` like the existing datasets;
`PRED_ROWS`/`PRED_FILL`
knobs as elsewhere). The proxy and independent conditions are factored
into a
`WITH base` CTE as named booleans so each invariant has a single
definition.
- `queries/correlation/q73.sql`, `benchmarks/correlation/q73.benchmark`
— the new
case, following the suite's existing conventions.
Run with: `BENCH_NAME=predicate_eval BENCH_SUBGROUP=correlation cargo
bench --bench sql`
## Are these changes tested?
The suite's shared template asserts the query returns rows; the case
runs green
locally alongside q70–q72. The dataset invariants were verified on 1M
rows: equal
column widths (63), marginal selectivities all ~0.30, and
conditional-on-`c0`
selectivity 1.0 for `s1`/`s2`/`s3` vs ~0.30 for `s4`.
## Are there any user-facing changes?
No — benchmark-only.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@adriangb@2010YOUY01@alamb@xudong963