Skip to content

Adaptive (runtime, stats-based) conjunct reordering for FilterExec - #22698

Open
adriangb wants to merge 21 commits into
apache:mainfrom
pydantic:lift-selectivity-stats
Open

Adaptive (runtime, stats-based) conjunct reordering for FilterExec#22698
adriangb wants to merge 21 commits into
apache:mainfrom
pydantic:lift-selectivity-stats

Conversation

@adriangb

@adriangb adriangb commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

Predicate evaluation order matters: a selective conjunct run first gates the
work of the conjuncts after it. Two mechanisms already order and gate
conjuncts, and both decide statically:

  • the logical optimizer sorts conjuncts cheap-before-expensive by a static
    cost class (perf: Reorder predicates in conjuncts via simple heuristic #22343). It is blind to selectivity, so a cheap-but-unselective
    conjunct still sorts ahead of an expensive-but-very-selective one, and
    conjuncts within one class keep their written order;
  • BinaryExpr's AND pre-selects: when the conjuncts evaluated so far keep
    at most 20% of rows (and produce no nulls) it filters the batch before
    evaluating the next one. It cannot gate a conjunct on a more selective one
    written after it.

This PR adds runtime, statistics-based reordering for FilterExec: each
conjunct's selectivity and per-row cost are measured on the rows that reach
it, the conjuncts are ranked by rows discarded per nanosecond, and the ranking
is adopted only if it is materially cheaper than the written order. Once
adopted, the learned order is materialised once as an ordinary AND chain and
evaluated by BinaryExpr like any other predicate. The module contains no
conjunction-evaluation logic of its own on any path; it only measures, ranks,
and builds the chain. It is off by default
(datafusion.execution.adaptive_filter_reordering).

What changes are included in this PR?

Everything lives in a new private module
datafusion/physical-plan/src/adaptive_filter.rs; FilterExec gains a shared
per-execution state field, a metric, and a two-arm match in the stream poll
loop.

  • Warm-up: for 8 batches (pooled across all partition streams of the
    operator) the written order is evaluated by BinaryExpr as a right-nested
    AND chain in which each conjunct is wrapped in a small measuring
    expression that records rows seen, rows passed and time. BinaryExpr's
    pre-selection does the compaction, so each conjunct is measured on exactly
    the rows BinaryExpr hands it. The wrappers disappear once the order is
    settled.
  • Settle: rank by (1 + rows_in - rows_out) / time (the key Velox uses,
    Pedreira et al. VLDB 2022); adopt the ranking only if its expected cost is
    at least 5% below the written order's. If not, the written order is kept.
  • Settled order as a right-nested AND: whichever order settles is built
    once as c1 AND (c2 AND (... AND cn)) and handed to BinaryExpr.
    Right-nesting is what makes this work: BinaryExpr pre-selection filters
    the batch it is given, so the survivors of the first conjunct stay compacted
    through the rest of the chain. A left-nested chain (what the planner and
    conjunction() build) pre-selects on the accumulated prefix and re-filters
    the original batch and scatters back at every level; see the measurements
    below. This also applies when the written order is kept: on
    predicate_eval it made no difference on 20 of 24 shapes and was 21–31%
    faster on the two many-cheap-conjunct shapes whose accumulated prefix
    crosses the 20% pre-selection threshold while no single conjunct does.
  • Metric: adaptive_reorders on FilterExec (per partition, only present
    when the flag is on) shows in EXPLAIN ANALYZE whether a reorder was
    adopted.
  • Safety rails: volatile predicates are never reordered; reset_state
    gives re-executions fresh measurements; predicate rewrites reset the pooled
    state; results are order-independent.
  • Config flag plus regenerated configs.md / information_schema.

Known limitations (documented in the module): measurements are
conditional on the written order, so correlated conjuncts can be misjudged;
the settle is one-shot with no drift re-measurement; the settle cost model
does not yet include evaluation overhead, so on very cheap predicates a
reorder can be adopted that buys nothing (see k4 below).

Measurements behind the settled-path design

Same binary, settled path selected by an environment switch, 8 interleaved
rounds × 40 iterations on predicate_eval, ratios of medians. A = flag off,
B = a dedicated compact-once evaluation loop (an earlier revision of this PR),
C = learned order rebuilt as a left-nested AND, D = learned order rebuilt as
a right-nested AND (this PR).

query B/A C/A D/A
costsel_q01 (5 regexps, selective last) 0.40 0.41 0.41
width q40 / q41 / q42 0.39 / 0.41 / 0.33 0.39 / 0.40 / 0.33 0.39 / 0.40 / 0.33
cardinality k2 / k4 / k8 1.01 / 1.01 / 1.03 0.98 / 1.19 / 1.37 0.98 / 1.13 / 1.04
cardinality k16 0.69 0.98 0.71
q02, q03 (already optimal) 1.00 1.01–1.03 0.99–1.02

The right-nested rebuild matches the dedicated loop everywhere except a
~10% residual on the 0.6 ms k4 query, which is evaluator fixed cost on a
reorder that buys nothing there; tightening the settle guard to account for
evaluation overhead is a follow-up.

tpch_sf10 (same binary, flag off → on): Q6 1.19× faster, Q12 1.45× faster,
all other queries unchanged; tpcds_sf1 and clickbench neutral within the A/A
noise floor. See the bot runs in the PR comments.

Are these changes tested?

  • Unit tests for ranking, cost model, warm-up boundary, cross-stream pooling,
    the right-nested shape of the rebuilt chain, the metric transition, lazy
    pool init, and both directions of the fallible-predicate side effect (an
    adopted reorder can introduce or avoid a divide-by-zero).
  • An end-to-end flag-on FilterExec test (4 partitions, nullable column,
    repeated execution with and without reset_state).
  • adaptive_filter.slt: results identical on and off, EXPLAIN identical on
    and off, and an EXPLAIN ANALYZE assertion that adaptive_reorders fires
    on a predicate written selective-last.

Are there any user-facing changes?

One new config option, datafusion.execution.adaptive_filter_reordering
(experimental, default false), and one new FilterExec metric,
adaptive_reorders. When enabled, query results never change, but the
observable side effects of fallible predicates can, in either direction:
reordering b <> 0 AND 1/b > 2 can make a divide-by-zero error appear or
disappear. Predicates containing volatile expressions are never reordered.

@github-actions github-actions Bot added documentation Improvements or additions to documentation physical-expr Changes to the physical-expr crates sqllogictest SQL Logic Tests (.slt) common Related to common crate physical-plan Changes to the physical-plan crate labels Jun 1, 2026
@adriangb

This comment has been minimized.

@adriangbot

This comment has been minimized.

@adriangbot

This comment has been minimized.

@adriangbot

This comment has been minimized.

@github-actions

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown

Thank you for opening this pull request!

Reviewer note: cargo-semver-checks reported the current version number is not SemVer-compatible with the changes in this pull request (compared against the base branch).

Details
     Cloning apache/main
    Building datafusion-common v55.0.0 (current)
       Built [  41.289s] (current)
     Parsing datafusion-common v55.0.0 (current)
      Parsed [   0.058s] (current)
    Building datafusion-common v55.0.0 (baseline)
       Built [  30.257s] (baseline)
     Parsing datafusion-common v55.0.0 (baseline)
      Parsed [   0.059s] (baseline)
    Checking datafusion-common v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.888s] 223 checks: 222 pass, 1 fail, 0 warn, 31 skip

--- failure constructible_struct_adds_field: struct exhaustively constructible through public API adds field ---

Description:
A pub struct that could be exhaustively constructed with a literal using only public API has a new pub field, breaking existing exhaustive literals.
        ref: https://doc.rust-lang.org/reference/expressions/struct-expr.html
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.50.0/src/lints/constructible_struct_adds_field.ron

Failed in:
  field ExecutionOptions.adaptive_filter_reordering in /home/runner/work/datafusion/datafusion/datafusion/common/src/config.rs:894

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  74.941s] datafusion-common
    Building datafusion-physical-plan v55.0.0 (current)
       Built [  34.231s] (current)
     Parsing datafusion-physical-plan v55.0.0 (current)
      Parsed [   0.145s] (current)
    Building datafusion-physical-plan v55.0.0 (baseline)
       Built [  33.609s] (baseline)
     Parsing datafusion-physical-plan v55.0.0 (baseline)
      Parsed [   0.134s] (baseline)
    Checking datafusion-physical-plan v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.884s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  70.235s] datafusion-physical-plan
    Building datafusion-sqllogictest v55.0.0 (current)
       Built [  86.736s] (current)
     Parsing datafusion-sqllogictest v55.0.0 (current)
      Parsed [   0.022s] (current)
    Building datafusion-sqllogictest v55.0.0 (baseline)
       Built [  86.403s] (baseline)
     Parsing datafusion-sqllogictest v55.0.0 (baseline)
      Parsed [   0.020s] (baseline)
    Checking datafusion-sqllogictest v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.120s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 175.736s] datafusion-sqllogictest

@github-actions github-actions Bot added the auto detected api change Auto detected API change label Jun 1, 2026
@adriangbot

This comment has been minimized.

@adriangbot

This comment has been minimized.

@adriangbot

This comment has been minimized.

@adriangb

This comment has been minimized.

@adriangbot

This comment has been minimized.

@adriangbot

This comment has been minimized.

@adriangbot

This comment has been minimized.

@adriangbot

This comment has been minimized.

@adriangbot

This comment has been minimized.

@adriangbot

This comment has been minimized.

@adriangb

This comment has been minimized.

@adriangbot

This comment has been minimized.

@adriangbot

This comment has been minimized.

@adriangbot

This comment has been minimized.

@adriangbot

This comment has been minimized.

@adriangb
adriangb force-pushed the lift-selectivity-stats branch 3 times, most recently from a24471d to 4d7b733 Compare June 2, 2026 02:29
@adriangb

adriangb commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

run benchmark tpch10

baseline:
ref: 12c9a05
changed:
ref: 12c9a05

@adriangb

adriangb commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Benchmark summary for head 12c9a05 (no custom evaluator; learned order evaluated by BinaryExpr as a right-nested AND). Two runs on the same binary: flag off vs on (trigger), and main vs PR with the flag off on both sides (trigger).

Flag off → on

  • tpch_sf10: Q6 1.20x and Q12 1.21x faster, everything else unchanged. Q12 was 1.45x with the earlier dedicated compact-once loop, so evaluating the learned order through BinaryExpr gives up about half of that win on a cheap 5-conjunct predicate.
  • clickbench: Q35 1.29x, Q36–Q42 1.10–1.32x faster; Q26/Q27 ~5–9% slower (tight stddev); net −0.6%.
  • tpcds_sf1: net +1.8% slower, with ~10 queries 5–14% slower (Q4, Q6, Q61, Q62, Q75, Q82 have tight stddev, the rest are noisy). These are cheap-comparison predicates: the settle guard only compares measured conjunct cost, not BinaryExpr's per-level evaluation overhead, so it adopts reorders that buy nothing. Same effect as the k4 microbenchmark in the description. Fix: account for evaluator overhead in the guard (follow-up, or in this PR if preferred).

Main vs PR, flag off both sides

  • tpcds and clickbench: neutral.
  • tpch: +2–3% total with Q9/Q13/Q18/Q22 6–13% slower. Q9 has a single-conjunct filter, which the flag-off path cannot touch, so this looks like binary layout / noise; a pinned A/A run on the PR head is queued to confirm.

@adriangbot

Copy link
Copy Markdown

🤖 Benchmark running (GKE) | trigger
Instance: c4a-highmem-16 (12 vCPU / 65 GiB) | Linux bench-c5567324414-2198-b29tp 6.12.94+ #1 SMP Fri Jul 17 09:42:57 UTC 2026 aarch64 GNU/Linux

CPU Details (lscpu)
Architecture:                            aarch64
CPU op-mode(s):                          64-bit
Byte Order:                              Little Endian
CPU(s):                                  16
On-line CPU(s) list:                     0-15
Vendor ID:                               ARM
Model name:                              Neoverse-V2
Model:                                   1
Thread(s) per core:                      1
Core(s) per cluster:                     16
Socket(s):                               -
Cluster(s):                              1
Stepping:                                r0p1
BogoMIPS:                                2000.00
Flags:                                   fp asimd evtstrm aes pmull sha1 sha2 crc32 atomics fphp asimdhp cpuid asimdrdm jscvt fcma lrcpc dcpop sha3 sm3 sm4 asimddp sha512 sve asimdfhm dit uscat ilrcpc flagm sb paca pacg dcpodp sve2 sveaes svepmull svebitperm svesha3 svesm4 flagm2 frint svei8mm svebf16 i8mm bf16 dgh rng bti
L1d cache:                               1 MiB (16 instances)
L1i cache:                               1 MiB (16 instances)
L2 cache:                                32 MiB (16 instances)
L3 cache:                                80 MiB (1 instance)
NUMA node(s):                            1
NUMA node0 CPU(s):                       0-15
Vulnerability Gather data sampling:      Not affected
Vulnerability Indirect target selection: Not affected
Vulnerability Itlb multihit:             Not affected
Vulnerability L1tf:                      Not affected
Vulnerability Mds:                       Not affected
Vulnerability Meltdown:                  Not affected
Vulnerability Mmio stale data:           Not affected
Vulnerability Reg file data sampling:    Not affected
Vulnerability Retbleed:                  Not affected
Vulnerability Spec rstack overflow:      Not affected
Vulnerability Spec store bypass:         Mitigation; Speculative Store Bypass disabled via prctl
Vulnerability Spectre v1:                Mitigation; __user pointer sanitization
Vulnerability Spectre v2:                Mitigation; CSV2, BHB
Vulnerability Srbds:                     Not affected
Vulnerability Tsa:                       Not affected
Vulnerability Tsx async abort:           Not affected
Vulnerability Vmscape:                   Not affected

Comparing 12c9a05 (12c9a05) to 12c9a05 diff

Run configuration
run benchmark tpch10
baseline:
  ref: "12c9a05490090905317181368e0915b02e7d6924"
changed:
  ref: "12c9a05490090905317181368e0915b02e7d6924"

Results will be posted here when complete


File an issue against this benchmark runner

@adriangbot

Copy link
Copy Markdown

🤖 Benchmark completed (GKE) | trigger

Instance: c4a-highmem-16 (12 vCPU / 65 GiB)

Comparing 12c9a05 (12c9a05) to 12c9a05 diff

Run configuration
run benchmark tpch10
baseline:
  ref: "12c9a05490090905317181368e0915b02e7d6924"
changed:
  ref: "12c9a05490090905317181368e0915b02e7d6924"
CPU Details (lscpu)
Architecture:                            aarch64
CPU op-mode(s):                          64-bit
Byte Order:                              Little Endian
CPU(s):                                  16
On-line CPU(s) list:                     0-15
Vendor ID:                               ARM
Model name:                              Neoverse-V2
Model:                                   1
Thread(s) per core:                      1
Core(s) per cluster:                     16
Socket(s):                               -
Cluster(s):                              1
Stepping:                                r0p1
BogoMIPS:                                2000.00
Flags:                                   fp asimd evtstrm aes pmull sha1 sha2 crc32 atomics fphp asimdhp cpuid asimdrdm jscvt fcma lrcpc dcpop sha3 sm3 sm4 asimddp sha512 sve asimdfhm dit uscat ilrcpc flagm sb paca pacg dcpodp sve2 sveaes svepmull svebitperm svesha3 svesm4 flagm2 frint svei8mm svebf16 i8mm bf16 dgh rng bti
L1d cache:                               1 MiB (16 instances)
L1i cache:                               1 MiB (16 instances)
L2 cache:                                32 MiB (16 instances)
L3 cache:                                80 MiB (1 instance)
NUMA node(s):                            1
NUMA node0 CPU(s):                       0-15
Vulnerability Gather data sampling:      Not affected
Vulnerability Indirect target selection: Not affected
Vulnerability Itlb multihit:             Not affected
Vulnerability L1tf:                      Not affected
Vulnerability Mds:                       Not affected
Vulnerability Meltdown:                  Not affected
Vulnerability Mmio stale data:           Not affected
Vulnerability Reg file data sampling:    Not affected
Vulnerability Retbleed:                  Not affected
Vulnerability Spec rstack overflow:      Not affected
Vulnerability Spec store bypass:         Mitigation; Speculative Store Bypass disabled via prctl
Vulnerability Spectre v1:                Mitigation; __user pointer sanitization
Vulnerability Spectre v2:                Mitigation; CSV2, BHB
Vulnerability Srbds:                     Not affected
Vulnerability Tsa:                       Not affected
Vulnerability Tsx async abort:           Not affected
Vulnerability Vmscape:                   Not affected
Details

Comparing HEAD and lift-selectivity-stats
--------------------
Benchmark tpch_sf10.json
--------------------
┏━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Query     ┃      HEAD ┃ lift-selectivity-stats ┃        Change ┃
┡━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ QQuery 1  │ 309.12 ms │              310.16 ms │     no change │
│ QQuery 2  │  88.20 ms │               89.82 ms │     no change │
│ QQuery 3  │ 213.44 ms │              215.35 ms │     no change │
│ QQuery 4  │ 111.09 ms │              110.52 ms │     no change │
│ QQuery 5  │ 349.03 ms │              336.55 ms │     no change │
│ QQuery 6  │ 129.94 ms │              120.53 ms │ +1.08x faster │
│ QQuery 7  │ 498.17 ms │              434.84 ms │ +1.15x faster │
│ QQuery 8  │ 377.68 ms │              351.85 ms │ +1.07x faster │
│ QQuery 9  │ 572.52 ms │              511.88 ms │ +1.12x faster │
│ QQuery 10 │ 294.39 ms │              290.48 ms │     no change │
│ QQuery 11 │  60.46 ms │               61.21 ms │     no change │
│ QQuery 12 │ 177.09 ms │              179.06 ms │     no change │
│ QQuery 13 │ 304.59 ms │              289.74 ms │     no change │
│ QQuery 14 │ 167.70 ms │              167.52 ms │     no change │
│ QQuery 15 │ 295.95 ms │              295.09 ms │     no change │
│ QQuery 16 │  63.87 ms │               63.17 ms │     no change │
│ QQuery 17 │ 603.51 ms │              547.95 ms │ +1.10x faster │
│ QQuery 18 │ 670.37 ms │              669.23 ms │     no change │
│ QQuery 19 │ 240.73 ms │              237.34 ms │     no change │
│ QQuery 20 │ 267.74 ms │              257.92 ms │     no change │
│ QQuery 21 │ 659.43 ms │              646.42 ms │     no change │
│ QQuery 22 │  59.75 ms │               65.21 ms │  1.09x slower │
└───────────┴───────────┴────────────────────────┴───────────────┘
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━┓
┃ Benchmark Summary                     ┃           ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━┩
│ Total Time (HEAD)                     │ 6514.77ms │
│ Total Time (lift-selectivity-stats)   │ 6251.86ms │
│ Average Time (HEAD)                   │  296.13ms │
│ Average Time (lift-selectivity-stats) │  284.18ms │
│ Queries Faster                        │         5 │
│ Queries Slower                        │         1 │
│ Queries with No Change                │        16 │
│ Queries with Failure                  │         0 │
└───────────────────────────────────────┴───────────┘

Distribution per query (min / mean ±stddev / max):

Comparing HEAD and lift-selectivity-stats
--------------------
Benchmark tpch_sf10.json
--------------------
┏━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Query     ┃                               HEAD ┃             lift-selectivity-stats ┃        Change ┃
┡━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ QQuery 1  │  309.12 / 313.62 ±5.20 / 323.31 ms │  310.16 / 315.91 ±3.88 / 321.44 ms │     no change │
│ QQuery 2  │     88.20 / 89.39 ±0.90 / 90.96 ms │     89.82 / 92.26 ±2.82 / 97.60 ms │     no change │
│ QQuery 3  │  213.44 / 217.39 ±2.88 / 221.45 ms │  215.35 / 217.75 ±1.38 / 219.23 ms │     no change │
│ QQuery 4  │  111.09 / 113.33 ±2.06 / 116.62 ms │  110.52 / 112.48 ±1.59 / 114.69 ms │     no change │
│ QQuery 5  │ 349.03 / 356.88 ±13.13 / 383.09 ms │  336.55 / 346.73 ±8.76 / 357.64 ms │     no change │
│ QQuery 6  │  129.94 / 132.21 ±2.04 / 135.51 ms │  120.53 / 122.12 ±1.60 / 125.10 ms │ +1.08x faster │
│ QQuery 7  │  498.17 / 502.68 ±3.59 / 507.76 ms │  434.84 / 440.38 ±5.25 / 446.82 ms │ +1.14x faster │
│ QQuery 8  │  377.68 / 382.01 ±2.83 / 384.83 ms │  351.85 / 357.34 ±4.77 / 364.82 ms │ +1.07x faster │
│ QQuery 9  │ 572.52 / 589.80 ±17.28 / 621.88 ms │  511.88 / 521.46 ±6.77 / 529.77 ms │ +1.13x faster │
│ QQuery 10 │  294.39 / 305.92 ±6.60 / 314.98 ms │  290.48 / 295.44 ±5.75 / 306.61 ms │     no change │
│ QQuery 11 │     60.46 / 64.42 ±5.94 / 76.22 ms │     61.21 / 64.68 ±5.16 / 74.77 ms │     no change │
│ QQuery 12 │  177.09 / 180.62 ±3.15 / 186.22 ms │  179.06 / 188.60 ±9.27 / 205.59 ms │     no change │
│ QQuery 13 │  304.59 / 312.15 ±9.82 / 330.76 ms │ 289.74 / 317.41 ±19.35 / 342.27 ms │     no change │
│ QQuery 14 │  167.70 / 172.69 ±4.57 / 178.98 ms │  167.52 / 172.40 ±6.87 / 185.98 ms │     no change │
│ QQuery 15 │  295.95 / 299.46 ±2.42 / 303.42 ms │  295.09 / 298.52 ±2.45 / 301.16 ms │     no change │
│ QQuery 16 │     63.87 / 67.34 ±2.48 / 70.92 ms │     63.17 / 66.44 ±1.82 / 68.42 ms │     no change │
│ QQuery 17 │ 603.51 / 642.63 ±29.96 / 677.92 ms │  547.95 / 558.18 ±7.56 / 570.71 ms │ +1.15x faster │
│ QQuery 18 │ 670.37 / 692.50 ±13.82 / 706.93 ms │ 669.23 / 696.93 ±24.24 / 737.32 ms │     no change │
│ QQuery 19 │ 240.73 / 255.69 ±12.56 / 271.71 ms │ 237.34 / 249.55 ±12.09 / 267.48 ms │     no change │
│ QQuery 20 │  267.74 / 275.84 ±4.19 / 279.49 ms │  257.92 / 268.85 ±8.19 / 280.14 ms │     no change │
│ QQuery 21 │ 659.43 / 682.32 ±23.38 / 722.74 ms │ 646.42 / 698.36 ±38.21 / 734.75 ms │     no change │
│ QQuery 22 │     59.75 / 62.18 ±2.07 / 65.38 ms │     65.21 / 69.64 ±3.96 / 75.09 ms │  1.12x slower │
└───────────┴────────────────────────────────────┴────────────────────────────────────┴───────────────┘
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━┓
┃ Benchmark Summary                     ┃           ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━┩
│ Total Time (HEAD)                     │ 6711.05ms │
│ Total Time (lift-selectivity-stats)   │ 6471.43ms │
│ Average Time (HEAD)                   │  305.05ms │
│ Average Time (lift-selectivity-stats) │  294.16ms │
│ Queries Faster                        │         5 │
│ Queries Slower                        │         1 │
│ Queries with No Change                │        16 │
│ Queries with Failure                  │         0 │
└───────────────────────────────────────┴───────────┘

Resource Usage

tpch10 — base (merge-base)

Metric Value
Wall time 35.0s
Peak memory 4.6 GiB
Avg memory 1.5 GiB
CPU user 339.8s
CPU sys 19.9s
Peak spill 0 B

tpch10 — branch

Metric Value
Wall time 35.0s
Peak memory 5.3 GiB
Avg memory 1.7 GiB
CPU user 329.8s
CPU sys 18.9s
Peak spill 0 B

File an issue against this benchmark runner

@adriangb

adriangb commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

A/A control on tpch10 (trigger: same PR head, same config on both sides) shows ±7–15% per-query swings (Q7 1.14x, Q9 1.13x, Q17 1.15x, Q22 1.12x) and a 4% total difference between two runs of the identical binary. So the flag-off tpch "regression" in the main-vs-PR run above is noise; nothing in the flag-off path shows up above the floor. The same floor applies to single per-query flags in the other suites, so the tpcds +1.8% net with the flag on is suggestive, not proven; the microbenchmark result behind it (cheap-predicate reorders adopted with no payoff) is the more reliable evidence.

adriangb and others added 21 commits September 7, 2026 11:38
…act-once core)

Add runtime, statistics-based conjunct reordering for `FilterExec`, off by
default behind `datafusion.execution.adaptive_filter_reordering`.

A conjunctive predicate is evaluated through a compact-once loop: conjunct
masks are AND-combined and the working batch is physically compacted to the
surviving rows once the accumulated mask is selective enough, so a selective
conjunct shrinks the batch the conjuncts after it must decode. This
compaction — not reordering a fused `BinaryExpr` AND, which does not compact
between conjuncts — is the source of the win, and reordering compounds it.

Each conjunct is timed and counted on the rows that reach it during a short
warm-up; the conjuncts are then ranked by rows discarded per nanosecond
(`(1 - pass_rate) / cost_per_row`) and, if the ranked order is materially
cheaper than the written one, it is adopted and frozen. Results, plan, and
EXPLAIN are unchanged; volatile predicates are never reordered.

This is the minimal core. Benchmarks (predicate_eval) confirm it captures the
"buried selective conjunct" wins (costsel_q01 ~-14%, width ~-12%) but also
that compact-once regresses cheap-predicate conjunctions (cardinality k8
~+37%) where the compaction overhead is not repaid — a guard that keeps the
plain fused evaluation for those is added in the next commit. Cross-stream
sharing, drift re-measurement, and confidence-interval statistics are later
layers.

Tested by unit tests (compact-once result-equivalence in any order, ranking,
expected-cost weighting, adopt/keep decisions) and an end-to-end
`adaptive_filter.slt` asserting identical results and EXPLAIN with the flag on
and off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lh7i9DyeFWuTFWjogrVNkb
A `FilterExec` is split across many partition streams, each seeing a slice of
the data. With per-stream warm-up, every stream pays its own measurement cost,
and when each stream is only a handful of batches long that warm-up is most of
its work — so the reordering win never materialises (and the warm-up overhead
shows up as a regression). Benchmarked: at 12 partitions the costsel_q01 win
collapsed from -67% (single stream) to -14%.

Share the measurements. `AdaptiveFilterShared` holds a per-conjunct stats pool
plus a settled-order epoch, common to every stream of one `FilterExec`. Each
stream measures a batch into a local accumulator and folds it into the pool;
the first stream to reach `WARMUP_BATCHES` pooled batches decides the order and
publishes it by bumping the epoch. Other streams poll the epoch with one
relaxed atomic load per batch and adopt the published order without paying
warm-up. The warm-up is thus paid ~once per query, not once per stream.

Restores the recovered win at default partitioning: width -56..-66%,
costsel_q01 -58%, cardinality k16 -26% (was -12%, -14%, -6% without sharing),
matching or beating the full design. Steady-state regressions on conjunctions
where compaction does not pay (neutral_q61 ~+11%, cardinality k4 ~+5%,
costsel_q02/q03 ~+3-5%) remain — a Fused-vs-CompactOnce guard addresses those
in the next commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lh7i9DyeFWuTFWjogrVNkb
The compact-once loop wins by gating expensive conjuncts behind a selective
one, but its per-conjunct bookkeeping (mask AND, true_count, the compaction
copy) is pure overhead when there is nothing to gate. On a conjunction of
interchangeable predicates — several equally expensive, equally unselective
regexps, say — the warm-up settles on the written order (nothing to reorder)
yet still paid compact-once on every batch, regressing ~11% vs the plain
predicate (predicate_eval neutral_q61).

Guard it: compact-once is adopted only when the warm-up actually reorders the
conjuncts. When the settled order equals the written order, evaluate the
predicate as-is — byte-for-byte the flag-off path, zero overhead. Since every
real win reorders (a selective conjunct moves toward the front), this keeps the
full win while removing the no-reorder regression.

predicate_eval (vs flag off): neutral_q61 +11% -> ~0; wins preserved
(costsel_q01 -60%, width -58..-66%, cardinality k16 -31%). A small residual
remains on low-cardinality cheap conjunctions that do reorder (k4/k8 ~+3-4%),
where compaction's cost is not repaid by gating so few/cheap predicates; the
full champion/challenger arbiter regresses these more (~+10%), so a heavier
guard is not worth it here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lh7i9DyeFWuTFWjogrVNkb
Two points raised by @xudong963 that carried over into the compact-once
rewrite of the adaptive `FilterExec` conjunct evaluator:

- Replace `.expect("u32 live")` on the live-row index downcast with a
  let-else returning `internal_err!`, so a broken invariant surfaces as a
  clean error rather than a panic.
- Add a `debug_assert!` documenting that live-row indices are tracked in
  arrow's `u32` `filter`/`take` index space, making the `num_rows as u32`
  cast's precondition explicit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1mvYrjFyTy2kbBoGrWzT6
@xudong963 noted the pooled adaptive-conjunct measurements live on the
`FilterExec` plan node and are reused by every `execute()` call, leaking
the learned conjunct order across independent executions.

Implement `ExecutionPlan::reset_state` for `FilterExec` — the sanctioned
mechanism for exactly this (its trait docs cite `DynamicFilterPhysicalExpr`;
`CrossJoinExec`, `HashJoinExec`, and `SortExec` use it for their build-side
/ dynamic-filter state). It returns a fresh node with a new
`AdaptiveFilterShared` (and fresh metrics), so a re-executed plan re-learns
from scratch, while preserving the still-valid predicate, input, and cached
plan properties. Reordering only ever affects performance, never results,
so this closes a perf-staleness gap, not a correctness bug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1mvYrjFyTy2kbBoGrWzT6
- An empty batch no longer consumes the warm-up: a run of empty batches
  would settle the written order on no evidence, permanently disabling
  adaptation for the stream.
- A conjunct evaluated faster than the timer's resolution now clamps its
  cost to 1ns instead of dropping out of the ranking as unmeasured
  (which sorted the cheapest conjunct last — backwards).
- The u32::MAX row-count guard is now a real internal error instead of a
  debug_assert; in release the indices would have silently wrapped.

Also documents the known limitations of the one-shot, conditional-stats
settle (correlated conjuncts, no drift re-measurement) in the module doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgXQmKrKgre4epbcSxUHNo
The previous test stored the table as a single batch, so with
WARMUP_BATCHES = 8 the flag-on queries only ever exercised the measuring
path. Store 4000 rows as 64-row batches so the warm-up completes and the
settled (possibly reordered) path runs end-to-end, and add a query whose
conjunct produces NULLs to exercise the null-mask path through real SQL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgXQmKrKgre4epbcSxUHNo
The config docs claimed reordering was the only observable difference;
in fact side effects of fallible predicates can change even when no
reorder is adopted, because while measuring (and after a reorder)
conjuncts are evaluated only on rows that survived the conjuncts before
them. Say so explicitly, with an example, and note in FilterExec that
Clone sharing the pooled measurements is deliberate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgXQmKrKgre4epbcSxUHNo
…atch strategy

Two review responses:

- AdaptiveConjunction::try_new no longer takes an `enabled` bool that
  short-circuits to None; whether the feature is on is FilterExec's
  policy, so the flag check moves to FilterExec::execute and try_new
  answers only the structural question (reorderable, non-volatile
  conjunction).
- The evaluator's per-batch behaviour is now observable: evaluate is a
  thin wrapper over evaluate_traced, which also reports the
  BatchStrategy used (Measure / Fused / Reordered). Two scenario tests
  exercise the input/output contract end to end — batches in, masks +
  strategy trace out — with per-conjunct costs injected by seeding the
  shared pool with synthetic measurements (the stand-in for a mocked
  clock), so which strategy gets adopted is deterministic: warm-up
  settles on a reorder for cheap-unselective + expensive-selective
  conjuncts, and on the written fused predicate for interchangeable
  ones.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgXQmKrKgre4epbcSxUHNo
`AdaptiveFilterShared` carried an `epoch` atomic that unsettled streams
polled once per batch, taking the mutex only when it changed. The atomic
bought nothing: every unsettled stream already locks `inner` on each
non-empty measured batch to pool its counts, so it was one extra word of
state and a second synchronisation point for the same information.

Unsettled streams now read the published decision straight from the
mutex at the top of `evaluate_traced` — the same adoption point the
epoch had, before the current batch is evaluated — and
`pool_and_maybe_settle` adopts a decision another stream published
between the two lock acquisitions instead of returning early. A settled
stream never touches the shared state at all, as before.

Also replace the defensive stats-length reset in `pool_and_maybe_settle`
with lazy init plus a `debug_assert_eq!`: no site shares one
`AdaptiveFilterShared` across different predicates (the builder,
predicate rewrites and `reset_state` all allocate fresh; `Clone`,
`with_fetch` and `with_batch_size` share it for the same predicate), so
a length mismatch is a bug, not a case to paper over.

No behavioural change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…metrics

Adaptive conjunct reordering was invisible from the outside: with the flag
on there was no way to tell from a real query whether the runtime actually
adopted a reordered evaluation order, or settled on the written one.

Add an `adaptive_reorders` counter to `FilterExecMetrics`, incremented once
per partition stream at the batch on which that stream adopts a reordered
(compacting) decision — including streams that pick up a decision another
stream published. `AdaptiveConjunction` stays free of metrics types: it
exposes a one-shot `take_adopted_reorder()` transition signal and the
`FilterExec` stream does the counting.

The counter is registered only when adaptive reordering is enabled for the
execution, so the default flag-off path's metrics are unchanged.

Also assert in `adaptive_filter.slt` that the reorder happens, via
`EXPLAIN ANALYZE` on a predicate written selective-conjunct-last, and add
the flag-off `EXPLAIN` that the file's "identical on and off" claim was
asserting against nothing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…FilterExec coverage

`streams_pool_measurements_and_share_settled_order` derived its `order ==
[1, 0]` / `compact` assertions from the real `Instant` timings of eight
hundred-row batches, so the settle guard made it depend on the measured
cost ratio staying inside the material-win window — a scheduling hiccup
on a shared runner flips it. Seed the shared pool two batches short of
the warm-up, as the scenario tests already do, so the two real batches
cannot move the ranking. Also correct its comment: `rem_euclid(25) < 5`
keeps 5 rows in 25 (20%), exactly the compact-once threshold.

`no_reorder_evaluates_plain_predicate` still measures real timings and is
left that way on purpose: both conjuncts pass ~96% of rows, and above
`1 - TIE_COST_FRACTION` the material-win guard cannot hold for any
positive costs, so no timing can produce a reorder there. Its doc now
says so.

New tests:

- `adaptive_filter_reordering_end_to_end` (filter.rs): a real four-
  partition, sixteen-batch-per-partition `FilterExec` run with
  `adaptive_filter_reordering` on. Asserts the rows equal the flag-off
  output, NULLs are dropped, `adaptive_reorders` is registered only with
  the flag on and counts an adoption, and that re-executing the node
  (state kept) and re-executing after `reset_state` (state dropped) both
  produce identical rows.
- `adopted_reorder_can_introduce_a_divide_by_zero` and
  `adopted_reorder_can_avoid_a_divide_by_zero_the_written_order_raises`:
  the two directions of the side effect the config option's doc warns
  about. The fused `BinaryExpr` `AND` pre-selects at its own 20%
  threshold, so `b <> 0 AND 1 / b > 2` succeeds flag-off when `b <> 0`
  holds for 15% of rows and errors once the conjuncts are reordered;
  mirrored, `1 / b > 2 AND a < 10` errors as written and succeeds once
  the selective conjunct is promoted and compacts the zeros away.
- `first_measured_batch_initialises_the_shared_pool`: the lazy
  `stats.is_empty()` sizing of the pooled registry, including that an
  empty batch does not size it.

The `FilterExec` conjuncts are too cheap for real timings to separate
reliably, so the pool is seeded through a new `#[cfg(test)]`
`AdaptiveFilterShared::seed_one_batch_short_of_warmup` — the same
mocked-clock stand-in the scenario tests use, reachable from filter.rs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Documentation-only pass over the adaptive filter, applying alamb's review
nits and correcting the stated baseline.

- Module doc: describe what conjunct evaluation already does today —
  `reorder_predicates` orders conjuncts cheap-before-expensive by a static
  cost class, and `BinaryExpr`'s `AND` pre-selects when the conjuncts so far
  keep <= 20% of the rows and produce no nulls. Spell out what pre-selection
  cannot do (gate on a later conjunct, fire through nulls, carry survivors
  compacted across a nested chain) instead of claiming the `AND` evaluates
  every conjunct on every row regardless of order.
- Add an intra-doc link to `BinaryExpr`, drop the "left-deep fused" jargon,
  expand the `regexp_like` example into a before/after evaluation order, and
  leave the flag's default value documented on the flag itself.
- Drop the unsupported "compact-once is itself a win even without
  reordering" claim; point at the PR for the measurements rather than
  quoting numbers.
- Fold the side-effect caveat, the conditional-statistics caveat and the
  one-shot caveat into a single "Known limitations" list instead of
  repeating them across the module; settle on one vocabulary (a decision is
  *settled*, a stream *adopts* it) and remove the leftover "publishes",
  "frozen" and "A/B-validated" wording.
- Config doc: shorter, and the side-effect caveat is now bidirectional (a
  divide-by-zero can appear *or* disappear).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… AND

Once the warm-up adopts a reorder, materialise the learned order once as a
right-nested `AND` chain, `(c_first AND (c_second AND (... AND c_last)))`,
and let `BinaryExpr` evaluate it like any other predicate, instead of
running the settled path through the per-conjunct compact-once loop.

Right-nesting is what makes this cheap: `BinaryExpr`'s pre-selection filters
the batch it is handed before evaluating its right-hand side, so the
survivors of the first (most selective) conjunct stay compacted for the
entire remainder of the chain. A left-nested chain -- what `conjunction()`
builds -- re-filters the original batch and scatters at every level, which
measures materially slower than the flag off on cheap 4-8 conjunct
predicates; right-nested is within noise of the compact-once loop.

The measuring path keeps the per-conjunct loop with compaction: it has to
time each conjunct on exactly the rows that reach it. `Settled` now carries
the expression to evaluate alongside the order, and `settle` builds it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… of a private evaluation loop

The warm-up used to walk the conjuncts itself: a private `eval_conjuncts`
loop that AND-ed the masks, compacted the working batch past its own
selectivity threshold, tracked live row indices and scattered the result
back to full length. That was a second conjunction-evaluation engine
living next to `BinaryExpr`'s, with its own compaction policy to keep in
step and its own null and index handling to get right.

Delete it. Each conjunct is now wrapped in a small measuring
`PhysicalExpr` that times the call and counts the rows it was handed and
the rows it kept, and the wrapped conjuncts are assembled into the
written order as the same right-nested `AND` chain the settled path
uses. `BinaryExpr` evaluates and pre-selects exactly as it would for the
plain predicate, so the compaction is its own and every conjunct is
measured on precisely the rows it hands over. The wrapper returns the
conjunct's result unchanged, nulls included; three-valued logic stays
`BinaryExpr`'s business.

The module now holds no evaluation logic of its own on any path: stats
and the shared pool, the ranking and cost model, `settle` plus the
right-nested chain builder, the measuring wrapper, and the per-stream
glue.

Behaviour is unchanged where it was observable: the pooled counts for
the first measured batch are identical, both divide-by-zero side-effect
tests still hold (the warm-up's pre-selection keeps `1 / b` away from the
zeros exactly as the old loop's compaction did), and the sqllogictests
are unaffected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ith_adaptive_reorder_metrics

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…a pre-evaluation lock

When the warm-up keeps the written order, evaluate it as the same
right-nested AND chain an adopted order uses instead of the planner's
left-nested expression: a left-nested chain pre-selects on the
accumulated prefix and pays a whole-batch filter and scatter at every
level where that prefix crosses the threshold.

Unsettled streams no longer take the shared lock before evaluating a
batch; a decision made meanwhile is picked up when the batch's counts
are pooled, and those counts are discarded.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Remove the test-only tracing machinery from the production path, simplify
the metric plumbing and cut the documentation down to saying each thing
once, where a reader first meets it. No behaviour change.

- delete `BatchStrategy` and `evaluate_traced`; `evaluate` is the only
  entry point, and `order` is gone from `Settled` and
  `AdaptiveConjunction` (`reordered` is the decision). The tests that
  used the trace now assert on `settled`/`reordered` and on the shape of
  `settled_predicate`.
- `AdaptiveConjunction::try_new` takes the `adaptive_reorders` `Count`
  and increments it in `adopt`, so `adopted_reorder`,
  `take_adopted_reorder` and `FilterExecMetrics::record_adaptive_reorder`
  are gone and the poll loop is a plain two-arm match. `execute` decides
  `AdaptiveConjunction::applies` first so the counter exists before the
  evaluator that increments it, and is still registered exactly when the
  adaptive path is active.
- `right_nested_conjunction` is infallible, which lets `settle` drop its
  `predicate` fallback and `AdaptiveConjunction` drop the field.
- one seeding helper (`seed_one_batch_short_of_warmup`) for both the
  module tests and the `FilterExec` end-to-end test.
- drop `AdaptiveFilterShared::new` and `MeasuredConjunct::return_field`
  (the trait default derives the same field from `data_type`/`nullable`).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@adriangb
adriangb force-pushed the lift-selectivity-stats branch from 8ae31d3 to be499fa Compare September 7, 2026 16:38
@adriangb

adriangb commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

I pulled out benchmarks so that they can outlive this PR and serve as a baseline: #25032

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

Labels

auto detected api change Auto detected API change common Related to common crate documentation Improvements or additions to documentation performance Make DataFusion faster physical-plan Changes to the physical-plan crate sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants