perf(scheduler): run one stage when the plan asks for the same stage twice - #2345
Draft
Dandandan wants to merge 2 commits into
Draft
perf(scheduler): run one stage when the plan asks for the same stage twice#2345Dandandan wants to merge 2 commits into
Dandandan wants to merge 2 commits into
Conversation
AQE re-optimizes the physical plan after every stage completion, and Ballista's rule list re-ran FilterPushdown each time. Pushing an already-pushed filter appends it to the scan again, so a scan accumulated one copy of its predicate per replan that touched its branch. Across the 22 TPC-H queries at SF10 that left 17 scans in 10 queries carrying duplicated predicates — 50 redundant conjuncts, up to six copies of `r_name = EUROPE` in q2 and of the nation filter in q7. Every copy is evaluated per row and again in the row-group pruning predicate, and it inflates the plan that is serialized to every task. Move FilterPushdown into `plan_preparation_optimizers`, which runs once before the per-replan rules. No plan changes beyond the removed duplicates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
|
is this similar to #1954 ? |
Contributor
Author
Yes it is! |
…twice
A query that reads the same data the same way twice plans two exchanges over
identical inputs, and each becomes its own stage. TPC-H q15 references the
`revenue0` view twice — once joined to supplier, once for `max(total_revenue)`
— so the whole view is computed twice:
SortShuffleWriterExec: partitioning=Hash([l_suppkey@0], 4)
AggregateExec: mode=Partial, gby=[l_suppkey], aggr=[sum(...)]
FilterExec: l_shipdate >= 1996-01-01 AND l_shipdate < 1996-04-01
DataSourceExec: lineitem
Where two exchanges cover identical inputs and want the same partitioning,
point the duplicates at the first one's stage by sharing its stage id and its
resolved-partition slot, so the stage runs once and both consumers read its
output — Spark's ReusedExchange. `output_links` is already a list, so a stage
may feed several consumers.
Identity is the serialized plan, not its rendered text. DataFusion gives
physical plans no structural equality (`ExecutionPlan: Any + Debug + DisplayAs
+ Send + Sync`), and text is not a safe substitute: two different in-memory
tables both render as `DataSourceExec: partitions=4, partition_sizes=[1, 1, 1,
1]`, so matching on it merges unrelated scans and turns a join into a
self-join. The bytes a task is shipped are what decides whether two copies
compute the same rows, and a plan the codec cannot encode is one this rule
leaves alone.
Scope is kept to pipelines whose scans read files. That is not needed for
correctness once identity is the encoded plan; it keeps the rule to the case it
was written for.
q15 goes from 7 stages to 6 and from two lineitem scans to one; q11 from 6 to
5. Row counts unchanged on all 22 queries.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dandandan
force-pushed
the
perf/aqe-reuse-identical-stages
branch
from
August 18, 2026 11:19
8580fbc to
4d1e23b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Which issue does this PR close?
None filed. Found by profiling the executed TPC-H plans: q15 computes its
revenue0view twice.Stacked on #2344 — the first commit here is that PR's, so review the second (
perf(scheduler): run one stage when the plan asks for the same stage twice). The dependency is real, not just convenience: without #2344 a scan's rendered predicate carries one copy per replan, so two structurally identical subplans can render differently and the match below misses them.Rationale for this change
A query that reads the same data the same way twice plans two exchanges over identical inputs, and each becomes its own stage. TPC-H q15 references the
revenue0view twice — once joined tosupplier, once formax(total_revenue)— so the whole view is computed twice, a filtered lineitem scan and a group-by per copy:Spark plans the same query with a
ReusedExchange. Ballista had no equivalent: across the 22 executed plans, no stage was ever consumed by more than one stage.What changes are included in this PR?
ReuseIdenticalStagesRule, after the rule that creates the exchanges: where two exchanges cover structurally identical inputs and want the same partitioning, the duplicates are pointed at the first one's stage by sharing its stage id and its resolved-partition slot. The stage runs once and both consumers read its output.output_linkswas already a list, so a stage feeding several consumers needed no new plumbing;identify_runnable_stagesnow returns a shared stage once so it is not launched twice.How two exchanges are judged identical is the part to review. DataFusion gives physical plans no structural equality —
ExecutionPlan: Any + Debug + DisplayAs + Send + Sync, noDynEq/DynHash— so something has to stand in for it, and rendered text is not safe: two different in-memory tables both display asDataSourceExec: partitions=4, partition_sizes=[1, 1, 1, 1], so matching on text merges unrelated scans and turns a join into a self-join. Eight AQE tests failed exactly that way on a first attempt.This matches on the serialized physical plan instead, encoded with the same codec that ships plans to executors. Those bytes are what a task actually runs, so equal bytes mean either copy computes the same rows, and a plan the codec cannot encode is one the rule leaves alone. Scope is additionally kept to pipelines whose scans read files — not needed for correctness once identity is the encoded plan, but it keeps the rule to the case it was written for. Lifting it would also merge in-memory scans of identical content, which is sound and needs the stage counts in two AQE tests updated.
Are these changes tested?
cargo test -p ballista-schedulerpasses (362 + 25); clippy clean.Plan effect: q15 goes from 7 stages to 6 and from two lineitem scans to one; q11 from 6 stages to 5. All 22 TPC-H queries return unchanged row counts.
Paired A/B at SF10 on two executors x 4 vcores, baseline = the parent commit so this measures the rule alone. Four repetitions per query, order flipped each rep, best of 3 iterations per run:
q15 is 1.5x faster with the control clean. An earlier revision that matched on plan text measured 0.588 on q15 (0.56–0.61 across four pairs) with controls at 0.998 and 0.993, and merged exactly the same stages; the difference between the two figures is run-to-run variation plus the planning cost of encoding each exchange input. q11 measured 0.966 (0.91–0.98) — its duplicate is a 25-row
nationscan, so there is little there to win.The win is structural: an entire filtered lineitem scan and group-by stop being computed. That should hold at scale, where q15 takes 23.7s in the SF1000 benchmark.
Are there any user-facing changes?
No API or configuration change. A query that plans the same stage twice now runs it once, so plans have fewer stages and stage ids shift accordingly.