Skip to content

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

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

adriangb wants to merge 27 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.1.0 (current)
       Built [  35.588s] (current)
     Parsing datafusion-common v55.1.0 (current)
      Parsed [   0.064s] (current)
    Building datafusion-common v55.1.0 (baseline)
       Built [  35.198s] (baseline)
     Parsing datafusion-common v55.1.0 (baseline)
      Parsed [   0.065s] (baseline)
    Checking datafusion-common v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.663s] 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 [  72.856s] datafusion-common
    Building datafusion-physical-expr v55.1.0 (current)
       Built [  30.878s] (current)
     Parsing datafusion-physical-expr v55.1.0 (current)
      Parsed [   0.050s] (current)
    Building datafusion-physical-expr v55.1.0 (baseline)
       Built [  30.107s] (baseline)
     Parsing datafusion-physical-expr v55.1.0 (baseline)
      Parsed [   0.051s] (baseline)
    Checking datafusion-physical-expr v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.334s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  62.378s] datafusion-physical-expr
    Building datafusion-physical-plan v55.1.0 (current)
       Built [  39.359s] (current)
     Parsing datafusion-physical-plan v55.1.0 (current)
      Parsed [   0.169s] (current)
    Building datafusion-physical-plan v55.1.0 (baseline)
       Built [  39.482s] (baseline)
     Parsing datafusion-physical-plan v55.1.0 (baseline)
      Parsed [   0.161s] (baseline)
    Checking datafusion-physical-plan v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.664s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [  81.274s] datafusion-physical-plan
    Building datafusion-sqllogictest v55.1.0 (current)
       Built [ 103.788s] (current)
     Parsing datafusion-sqllogictest v55.1.0 (current)
      Parsed [   0.022s] (current)
    Building datafusion-sqllogictest v55.1.0 (baseline)
       Built [ 102.972s] (baseline)
     Parsing datafusion-sqllogictest v55.1.0 (baseline)
      Parsed [   0.024s] (baseline)
    Checking datafusion-sqllogictest v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.089s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 209.551s] 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 and others added 24 commits September 16, 2026 15:23
…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>
…llers

`check_short_circuit` decides what an `AND` does with its right-hand side
from the left-hand side's true count, null count and length: skip it when
the left side is all false, pre-select when the left side has no nulls and
keeps at most `PRE_SELECTION_THRESHOLD` of the rows, otherwise evaluate it
on the whole batch.

Anything that models the cost of a conjunction has to reproduce that rule,
and a private threshold behind an inline condition makes it easy to drift
from. Lift the decision into `and_rhs_evaluation`, returning the new
`AndRhsEvaluation`, and have `check_short_circuit` decide by it, so the
rule has one definition. Export it, and the threshold, alongside
`BinaryExpr`.

Behaviour is unchanged: the null and empty-batch cases return exactly what
they returned before. The `true` count is now computed before the null
check rather than after it, which costs a popcount over the values buffer
on the nulls-present path that previously bailed out first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZtdyL1QXmSiQdMRYmqTkV
…s it

Two review findings on the adaptive filter, both of which made the flag
claim more than it did.

Keep the written tree. The warm-up rebuilt the predicate as a right-nested
chain over the wrappers, and a warm-up that found nothing better returned
a right-nested rebuild of the written order too. Reassociating an `AND`
changes where pre-selection fires even when the conjunct sequence is
unchanged, so enabling the flag could change what a fallible conjunct sees
while `adaptive_reorders` stayed 0. For `((a < 50 AND b < 3) AND 1/z > 2)`,
the written tree gates `1/z` on the 15% that `a < 50 AND b < 3` keeps,
while the right-nested form gates it on `b < 3` alone, at 30%, which does
not pre-select at all — so it divides by zero. The warm-up now wraps the
conjunct leaves in place, leaving the tree exactly as written, and a
warm-up that adopts no reorder hands back the written `Arc` itself. An
adopted reorder is still materialised right-nested, which is what makes it
pay.

Cost what `AND` really hands on. `expected_cost_per_row` multiplied by
each conjunct's pass rate, as if every conjunct narrowed the batch for the
ones after it. `BinaryExpr` only narrows it when it pre-selects: with no
nulls and at most `PRE_SELECTION_THRESHOLD` of the rows kept. Conjuncts
keeping 30% and 90% both leave the next one facing the whole batch, yet
both were credited with a discount; a conjunct that looked selective only
because it produced nulls was credited most of all, though nulls disable
pre-selection outright. `MeasuredConjunct` now classifies every batch
through `and_rhs_evaluation` — the function `BinaryExpr` itself decides by,
so the two cannot drift — and `ConjunctStats::downstream_weight` reports
the rows the conjuncts after it were actually handed. Measuring this per
batch, rather than inferring it from pooled rates, also keeps a conjunct
whose selectivity straddles the threshold honestly weighted.

Tests cover the reassociation case end to end, the wrapped tree's shape,
the weight just below, exactly at and just above the threshold, a nullable
conjunct measured against the same conjunct without nulls, and a reorder
that ranks better but cannot pre-select and so is rejected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZtdyL1QXmSiQdMRYmqTkV
The reviewed example pairs a conjunct keeping 30% with one keeping 90%;
the 30% half was only covered through a cost-model assertion. Assert its
weight alongside the others.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZtdyL1QXmSiQdMRYmqTkV
@adriangb
adriangb force-pushed the lift-selectivity-stats branch from be499fa to df919f9 Compare September 16, 2026 15:28
@github-actions github-actions Bot added the physical-expr Changes to the physical-expr crates label Sep 16, 2026
/// - for `AND`, when the proportion of `true` is less than or equal to 0.2
/// - for `OR`, when the proportion of `false` is less than or equal to 0.2
const PRE_SELECTION_THRESHOLD: f32 = 0.2;
pub const PRE_SELECTION_THRESHOLD: f32 = 0.2;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Can we add doc hidden so this is not as strongly part of the public API?

///
/// [`check_short_circuit`] decides by this function, so a caller that models
/// conjunction cost cannot drift away from what evaluation actually does.
pub fn and_rhs_evaluation(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Similarly, if we are making this pub just so it is cross-crate can we make this #[doc(hidden)]?

adriangb and others added 3 commits September 16, 2026 19:33
…ic docs

`AndRhsEvaluation` and `and_rhs_evaluation` are public, and their docs
linked `[`check_short_circuit`]`, which is private. The link resolves only
under `--document-private-items`, so the workspace doc build fails with
`rustdoc::private_intra_doc_links` under `RUSTDOCFLAGS=-D warnings`.

Name the function in plain backticks instead. Verified with the CI
command, `ci/scripts/rust_docs.sh`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZtdyL1QXmSiQdMRYmqTkV
…public docs

`PRE_SELECTION_THRESHOLD`, `AndRhsEvaluation` and `and_rhs_evaluation` are
`pub` only so that `physical-plan` can model `AND` pre-selection from the
same definition evaluation uses. They are not API worth advertising, so
mark them and their re-export `#[doc(hidden)]` and say why in a line of
doc comment.

`AndRhsEvaluation` is hidden alongside the function that returns it; left
visible it would document a type no visible signature mentions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZtdyL1QXmSiQdMRYmqTkV
…r-execution

The pooled measurements and the settled decision live on the `FilterExec`
node. `execute()` clones them, and the `ExecutionPlan` contract does not
promise `reset_state()` before every execution, so a second or concurrent
run of the same plan reuses what the first one learned. Two comments
implied otherwise: one called the measurements "per-execution", and
`reset_state`'s doc read as though re-executing were enough to reset them.

Say what actually holds, and record the lifetime among the module's known
limitations, where the other caveats are. Results are unaffected either
way; speed, `adaptive_reorders` and the side effects of a fallible
conjunct are not.

`reset_state()` discarding what was learned is now asserted on the state
itself rather than on `Arc` identity, and the end-to-end test pins the
other half: a second `execute()` keeps the settled decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZtdyL1QXmSiQdMRYmqTkV
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-expr Changes to the physical-expr crates 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