Skip to content

feat(physical-optimizer): skip a named rule handed a plan it has been seen to leave alone - #25356

Draft
zhuqi-lucas wants to merge 14 commits into
apache:mainfrom
zhuqi-lucas:skip-unchanged-physical-rule
Draft

zhuqi-lucas wants to merge 14 commits into
apache:mainfrom
zhuqi-lucas:skip-unchanged-physical-rule

Conversation

@zhuqi-lucas

@zhuqi-lucas zhuqi-lucas commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Closes #25355.

Rationale for this change

The logical optimizer iterates and stops on convergence (max_passes + LogicalPlanSignature). The physical optimizer runs its list once, which suits the default chain: every rule able to invalidate distribution or ordering requirements is deliberately ordered before the single EnsureRequirements, as the per-rule comments in physical-optimizer/src/optimizer.rs state.

Custom rule lists do not get that for free. A rewrite inserted after that point (a scan rewrite, a distributed-execution boundary, a view substitution) invalidates requirements again and needs its own enforcement pass behind it. Those passes are not wasted in principle, they are wasted whenever the rewrite in front of them did not actually fire, which for most rewrites is most queries.

Measured on a chain with six enforcement passes over a 34-node plan, four of the six left the plan byte-identical and still paid the full tree walk, about 30ms each in a debug build. This recovers two of those four, not all four: after anything changes the plan, the first pass that finds nothing to do still has to run in order to establish that the plan is a fixpoint.

What changes are included in this PR?

  • datafusion.optimizer.skip_unchanged_physical_rules: comma separated rule names, empty by default.
  • For each named rule, optimize_physical_plan remembers the plans that rule has been observed to return unchanged. A later pass handed one of them gets it back instead of re-deriving it.
  • A plan is recorded only after the rule has run on it and produced that same plan, so a skip replays an observed outcome rather than predicting one.
  • Debug builds re-run a skipped rule and require it to still leave the plan alone.
  • A skipped rule still reports to the observer, so EXPLAIN VERBOSE output is identical either way.

The two design points that are easy to get wrong

I had both of these wrong in the first draft of this PR; each was caught by enabling the feature against a real rule chain.

Key on the plan the rule was given, not the plan it last returned. The tempting version skips when a rule is handed back its own output. That assumes a rule reaches its fixpoint in one pass. EnsureRequirements does not: two consecutive applications both changed the plan, the second moving a RepartitionExec below a SortExec and switching that sort to per-partition. The earlier design skipped exactly that pass, which would have silently cost the rewrite.

Compare plans by content, not by pointer. A rule that changes nothing still commonly rebuilds the tree. Of the 23 no-op calls above, only 7 also returned the input object, so pointer identity would have found less than a third of them. That is not something the rule could report its way out of: replace_children_if_necessary already hands back the original plan when the child pointers are unchanged, so the passes that truly did nothing do keep their input. The other 16 lost identity because the phases inside the rule rewrote the tree and then rewrote it back, which is #25360. HashSet<String> compares on collision, so two plans that hash alike are not confused.

Bound on the guarantee: comparing rendered plans

ExecutionPlan has no structural equality, so this compares displayable(...).indent(true) output. That is not a perfect plan identity. Two plans differing only in something the display does not print would compare equal, and one of them would be skipped wrongly. The debug self-check uses the same comparison, so it cannot catch that class either.

I am not aware of a better option today, and this is the same notion of plan identity the test suite already relies on throughout, but it is a bound rather than a proof and reviewers should weigh it as one. If ExecutionPlan ever grows a structural comparison this should move to it.

Why names in the config rather than a method on the rule

The first draft added PhysicalOptimizerRule::skip_if_unchanged() with EnsureRequirements opting in. That puts the decision on the wrong object. Whether a chain repeats a rule is a property of the chain, and upstream cannot know what a downstream chain and node types will do to a rule's behaviour — the non-convergence above does not reproduce on the built-in chain with built-in sources, but is reliable in the chain that motivated this.

It also carried a trap: the optimizer asks only the outermost rule, so a rule wrapped for timing or tracing had to forward the answer, and a wrapper leaving the default in place disabled the feature silently. Same shape as #25316, where a wrapper drops schema_check(). Names need no cooperation from a wrapper, which already reports the name it wraps because that is what EXPLAIN VERBOSE shows.

No trait or other public API change results.

One sharp edge, documented and pinned

A configured name stands for a behaviour, because every rule answering to it shares one record. OutputRequirements breaks that in the built-in chain: the instance that adds requirements and the instance that removes them again report the same name. The config documents it, and builtin_chain_repeats_two_rule_names pins both repeated names so a rename is noticed.

Measurements

Through a downstream chain with six enforcement passes, on a 34-node plan. Release build, 20 warm samples per arm after three discarded:

planning wall physical optimization enforcement passes run
off 36.1 ms (p10 35.5, p90 37.1) 22.4 ms 6
on 30.7 ms (p10 30.5, p90 31.8) 15.8 ms 4

Physical optimization -29.3%, planning wall -15.0%. The two wall-clock distributions do not overlap between p10 and p90. EXPLAIN VERBOSE is byte-identical between the arms.

Correctness was checked separately in a debug build with the self-check active over the same chain and endpoint: no violations, and the same identical plans. The self-check is compiled out in release, so the release run says nothing about correctness on its own.

Worth knowing for review: because the self-check re-runs a skipped rule, the saving appears in release and not in debug. The tests encode that explicitly rather than hiding it.

Are these changes tested?

Eleven tests in physical_planner, including:

  • a rule needing several passes to converge keeps running, and the plan comes out as it does with the optimization off — the case that makes the rejected key wrong;
  • nine plans through the built-in chain plus two trailing EnsureRequirements passes come out byte-identical with the optimization on and off;
  • inert when the name is absent, unknown or misspelt; the list is read with the spacing people write;
  • a wrapper is followed by the name it reports, not its own;
  • the record does not leak between plans;
  • EXPLAIN VERBOSE still lists every rule;
  • every built-in rule is idempotent over a nine-query corpus, and the two rule names the built-in chain repeats are pinned.

Existing suites pass in debug and release.

Are there any user-facing changes?

One new config option, empty by default, so existing sessions behave exactly as before.

…e-run

The logical optimizer iterates and stops on convergence via
LogicalPlanSignature. The physical optimizer runs its list once, which
suits the default chain -- everything able to invalidate distribution or
ordering requirements is deliberately ordered before the single
EnsureRequirements. Custom rule lists do not have that luxury: a rewrite
inserted after that point invalidates requirements again and needs its
own enforcement pass, and some of those passes run on a plan no
preceding rule touched.

Rules already return their input Arc untouched when they have nothing to
do, so pointer identity is an exact, allocation-free 'nothing happened'
signal. A rule can now declare skip_if_unchanged(); when the config flag
datafusion.optimizer.skip_unchanged_physical_rules is on, the optimizer
remembers the plan each opted-in rule returned and skips the call when
handed back that same object.

The memo lives in the optimization run, keyed by rule name, so nothing
leaks across queries (rule instances are shared) and a rule listed twice
as two instances still matches. Debug builds run a skipped rule anyway
and assert it changed nothing, so a rule that declares purity without
having it fails a test rather than a query -- Spark is the only surveyed
engine that checks this, and the engines that rely on counters instead
have public incidents from non-idempotent rules.

Both flags default to off, so nothing changes until a rule and the
session agree.
@github-actions github-actions Bot added documentation Improvements or additions to documentation core Core DataFusion crate common Related to common crate labels Sep 16, 2026
@github-actions

github-actions Bot commented Sep 16, 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 v55.1.0 (current)
       Built [  71.279s] (current)
     Parsing datafusion v55.1.0 (current)
      Parsed [   0.041s] (current)
    Building datafusion v55.1.0 (baseline)
       Built [  77.080s] (baseline)
     Parsing datafusion v55.1.0 (baseline)
      Parsed [   0.040s] (baseline)
    Checking datafusion v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.578s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 151.450s] datafusion
    Building datafusion-common v55.1.0 (current)
       Built [  41.893s] (current)
     Parsing datafusion-common v55.1.0 (current)
      Parsed [   0.074s] (current)
    Building datafusion-common v55.1.0 (baseline)
       Built [  40.652s] (baseline)
     Parsing datafusion-common v55.1.0 (baseline)
      Parsed [   0.070s] (baseline)
    Checking datafusion-common v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.633s] 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 OptimizerOptions.skip_unchanged_physical_rules in /home/runner/work/datafusion/datafusion/datafusion/common/src/config.rs:1584

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  84.808s] datafusion-common
    Building datafusion-sqllogictest v55.1.0 (current)
       Built [ 113.255s] (current)
     Parsing datafusion-sqllogictest v55.1.0 (current)
      Parsed [   0.028s] (current)
    Building datafusion-sqllogictest v55.1.0 (baseline)
       Built [ 116.736s] (baseline)
     Parsing datafusion-sqllogictest v55.1.0 (baseline)
      Parsed [   0.027s] (baseline)
    Checking datafusion-sqllogictest v55.1.0 -> v55.1.0 (no change; assume patch)
     Checked [   0.091s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 233.321s] datafusion-sqllogictest

@github-actions github-actions Bot added the auto detected api change Auto detected API change label Sep 16, 2026
The three existing tests exercise the mechanism; this one exercises the
shape it exists for -- enforcement passes separated by rewrites, where
only the passes following a rewrite that actually fired have work to do.
The rule derives everything from the plan and the config, and the config
is fixed for an optimization run, so running it again on a plan it just
produced cannot change anything. Without this the new trait method has no
implementor in tree and rule lists that enforce requirements after their
own rewrites -- the case the feature exists for -- get nothing.
@github-actions github-actions Bot added the optimizer Optimizer rules label Sep 16, 2026
The map was allocated on every physical planning run even with the
feature off. Making it an Option keeps the disabled path free of it.
The optimizer consults the rule it holds, so a rule that runs other rules
inside its own optimize() decides for all of them. A wrapper leaving this
at the default silently opts its inner rules out of the skip, which is
the same trap apache#25316 describes for schema_check(). Say so where an
implementor reads it, with the all() form, since a wrapper is skippable
only if every rule it would have run is.
The option was added to OptimizerOptions without the matching rows in
information_schema.slt, which SHOW ALL asserts exhaustively.
The debug self-check asserted that re-running a skipped rule returned
the same Arc. EnsureRequirements fails that: it rebuilds the tree and
hands back a fresh object describing an identical plan, so enabling the
optimization over the real rule panicked in debug builds.

Identity was the wrong thing to assert. What the skip relies on is that
a second pass would arrive at the same plan, which is idempotence; the
check now compares the rendered plans. That a rule rebuilds the tree
also sharpens the motivation, since the pass being skipped reconstructs
the whole plan to end up where it started.

Docs in the trait, the config option and EnsureRequirements said 'pure
function' and 'returns its input untouched', both of which read as
identity, and are corrected to say idempotent.

Also collapses the nested if the skip introduced, which clippy rejects.

Covers the mechanism with three further tests: a wrapper rule that
forwards the opt-in is skipped while one that drops it is not; a query
planned through the built-in rules with two extra EnsureRequirements
passes appended produces an identical plan with the optimization on and
off; and EXPLAIN VERBOSE renders the same output either way, since a
skipped rule still reports to the observer.
Replaces the `PhysicalOptimizerRule::skip_if_unchanged` opt-in with a
list of rule names in the config option, which now holds names rather
than a bool.

The opt-in was a trait method that exactly one built-in rule set. An
audit of all 21 built-in rules, added here as a test, finds every one of
them idempotent, so singling out EnsureRequirements was arbitrary and
opting in all 21 would be 21 unverified claims plus a decision to make
for each new rule. Whether a chain repeats a rule is a property of the
chain, not of the rule, so the chain's owner is who can say it.

Naming rules also removes a trap the trait version carried. The
optimizer only asks the outermost rule, so a rule wrapped for timing or
tracing had to forward the answer, and one that left the default in
place silently turned the optimization off. Names need no such
cooperation: a wrapper already reports the name it wraps, because that
is what EXPLAIN VERBOSE shows.

It costs a semver-visible trait method and gains reach over rules the
caller does not own. The debug self-check still verifies every skip.

Tests: skips a repeated pass; inert when the name is absent, unknown or
misspelt; reads a list with the spacing people write; handles an
interleaved enforce/rewrite chain; does not leak between plans; follows
the name a wrapper reports and not the wrapper's own; leaves a corpus of
nine plans byte-identical through the built-in chain plus two trailing
EnsureRequirements passes; keeps EXPLAIN VERBOSE output unchanged; and
holds every built-in rule to the idempotence the config asserts.
Every rule answering to a configured name shares one memo entry, so two
rules that behave differently must not share a name. The built-in chain
breaks that: OutputRequirements reports one name for the instance that
adds requirements and the instance that removes them again, and naming
it lets the first one's output suppress the second. The debug self-check
catches it, but the config had claimed the built-in list holds no rule
twice, which is wrong for OutputRequirements and ProjectionPushdown.

Documents the constraint and pins the two repeated names in a test, so
this is revisited if either rule is renamed. Giving OutputRequirements a
distinct name per mode would make it nameable, and would disambiguate it
in EXPLAIN VERBOSE too, but that is a separate change.
@zhuqi-lucas zhuqi-lucas changed the title feat(physical-optimizer): let a rule opt into skipping an unchanged re-run feat(physical-optimizer): skip a named rule handed back the plan it just produced Sep 16, 2026
@github-actions github-actions Bot added sqllogictest SQL Logic Tests (.slt) and removed optimizer Optimizer rules labels Sep 16, 2026
@codecov-commenter

codecov-commenter commented Sep 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.43411% with 70 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.29%. Comparing base (dcd385e) to head (1f9dc3b).
⚠️ Report is 10 commits behind head on main.

Files with missing lines Patch % Lines
datafusion/core/src/physical_planner.rs 86.43% 21 Missing and 49 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #25356      +/-   ##
==========================================
+ Coverage   81.97%   82.29%   +0.31%     
==========================================
  Files        1137     1137              
  Lines      429889   430727     +838     
  Branches   429889   430727     +838     
==========================================
+ Hits       352408   354447    +2039     
+ Misses      56432    54803    -1629     
- Partials    21049    21477     +428     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

The skip keyed on the plan a rule last returned, on the assumption that
a rule handed back its own output has nothing left to do. That assumption
is false: a rule is not required to reach its fixpoint in one pass, and
EnsureRequirements routinely does not. Enabling it against a real chain
tripped the debug self-check, with the second pass moving a
RepartitionExec below a SortExec and switching the sort to per-partition,
so the skipped pass would have silently cost that rewrite.

Key on the plan the rule was *given* instead, and record it only after
the rule has run and returned that same plan. A skip then replays an
outcome already observed rather than predicting one, and a rule still
converging records nothing and keeps running.

Plans are compared by rendered form rather than by pointer. A rule that
changes nothing still commonly rebuilds the tree, so pointer identity
cannot see a fixpoint: on a real 34-node plan, of 23 passes that left the
plan byte-identical only 7 also returned the input object. Collisions are
handled by HashSet<String> comparing on hit rather than trusting a hash.
The config stays out of the key because what is recorded is scoped to one
planning run, where it cannot change.

Measured through a downstream chain that enforces requirements six times:
physical optimization 208.9ms -> 147.8ms, planning wall 350.7ms ->
295.8ms, two passes skipped, EXPLAIN VERBOSE byte-identical.

Adds a test for the case that makes the old key wrong: a rule needing
several passes to converge must keep running, and the plan must come out
as it does with the optimization off.
@zhuqi-lucas zhuqi-lucas changed the title feat(physical-optimizer): skip a named rule handed back the plan it just produced feat(physical-optimizer): skip a named rule handed a plan it has been seen to leave alone Sep 16, 2026
The generated configs.md table pads every cell to the widest one, so a
description longer than the current maximum reflows all 150 rows and
buries the one row that was actually added. Trims it back under that
width; the reasoning it carried is in the optimizer loop's comments and
in the issue.
@zhuqi-lucas
zhuqi-lucas marked this pull request as ready for review September 17, 2026 03:53
Copilot AI lite review requested due to automatic review settings September 17, 2026 03:53

Copilot AI 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.

🟡 Changes recommended

Rendered-plan identity can skip semantically different plans, and the idempotence test does not inspect the duplicated rule’s intermediate output.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds an opt-in physical optimizer rule-skipping cache keyed by previously observed unchanged plans.

Changes:

  • Adds skip_unchanged_physical_rules configuration and documentation.
  • Implements per-planning-run memoization, debug validation, and observer preservation.
  • Adds extensive physical planner tests and information-schema coverage.
File summaries
File Description
datafusion/core/src/physical_planner.rs Implements caching and tests.
datafusion/common/src/config.rs Defines the new optimizer option.
docs/source/user-guide/configs.md Documents configuration behavior.
datafusion/sqllogictest/test_files/information_schema.slt Updates configuration output expectations.
Review details
  • Files reviewed: 4/4 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread datafusion/core/src/physical_planner.rs Outdated
Comment on lines +3008 to +3011
let before = displayable(new_plan.as_ref()).indent(true).to_string();
if seen
.get(optimizer.name())
.is_some_and(|plans| plans.contains(&before))
Comment thread datafusion/core/src/physical_planner.rs Outdated
Comment on lines +4195 to +4201
for (position, rule) in stock.iter().enumerate() {
let mut doubled = stock.clone();
doubled.insert(position + 1, Arc::clone(rule));
assert_eq!(
corpus_plans("", doubled).await?,
baseline,
"running '{}' (position {position}) twice changed the plan, so it \
Two problems raised in review, both real.

The memo keyed plans on displayable(...).indent(true). That renderer has
show_schema off by default and prints only what each node chooses to, so
a rule could return a plan with a different schema or different execution
properties and still produce the same string. In release the later pass
would be skipped and that rewrite silently lost, and the debug self-check
repeated the same incomplete comparison so it could not catch it.

plan_fingerprint now asks for the schema and appends each node's
PlanProperties, partitioning, ordering, emission type and boundedness, in
the same pre-order. Two tests pin what that buys: plans differing only in
nullability, and plans differing only in partitioning, each render
identically without it and compare unequal with it.

This is still short of a structural identity and the PR says so: anything
a node neither prints nor exposes through PlanProperties stays invisible,
and ExecutionPlan has no structural comparison to use instead.

The idempotence test compared the plan after the whole chain had run. A
later rule can normalize away a rewrite the duplicated rule made on its
second invocation, so a rule that is not idempotent could pass. It now
captures the plan immediately after each rule through the observer and
compares the two snapshots belonging to the duplicated rule.

A further test proves that distinction is what makes the check work: a
rule that rewrites on its second call, paired with a rule that strips
that rewrite, leaves the final plan identical to the first pass, so
comparing ends of chains sees nothing while comparing per rule does.
@zhuqi-lucas
zhuqi-lucas marked this pull request as draft September 17, 2026 08:44
Ordering is the property this feature cares about most, since
EnsureRequirements, the rule that motivates it, exists to rewrite ordering
and distribution, yet nothing held the fingerprint to distinguishing it.

Adds a constructor on the existing NoOpExecutionPlan that claims an output
ordering. Its rendering is unchanged, which is the point: the test first
asserts the two plans render identically with the schema shown, then that
their orderings genuinely differ, then that the fingerprints do not match.

Removing the properties from the fingerprint fails this test and the
partitioning one, and leaves the nullability one passing, since that
depends on show_schema rather than on the properties.
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 core Core DataFusion crate documentation Improvements or additions to documentation sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Physical optimizer re-runs rules on plans they have already settled

3 participants