Skip to content

perf(scheduler): run one stage when the plan asks for the same stage twice - #2345

Draft
Dandandan wants to merge 2 commits into
apache:mainfrom
Dandandan:perf/aqe-reuse-identical-stages
Draft

perf(scheduler): run one stage when the plan asks for the same stage twice#2345
Dandandan wants to merge 2 commits into
apache:mainfrom
Dandandan:perf/aqe-reuse-identical-stages

Conversation

@Dandandan

@Dandandan Dandandan commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

None filed. Found by profiling the executed TPC-H plans: q15 computes its revenue0 view 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 revenue0 view twice — once joined to supplier, once for max(total_revenue) — so the whole view is computed twice, a filtered lineitem scan and a group-by per copy:

SortShuffleWriterExec: partitioning=Hash([l_suppkey@0], 4)
  AggregateExec: mode=Partial, gby=[l_suppkey], aggr=[sum(l_extendedprice * (1 - l_discount))]
    FilterExec: l_shipdate >= 1996-01-01 AND l_shipdate < 1996-04-01
      DataSourceExec: lineitem

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_links was already a list, so a stage feeding several consumers needed no new plumbing; identify_runnable_stages now 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, no DynEq/DynHash — so something has to stand in for it, and rendered text is not safe: two different in-memory tables both display as DataSourceExec: 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-scheduler passes (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:

query base this PR median paired ratio per-pair ratios
q15 0.493s 0.327s 0.675 0.65, 0.62, 0.71, 0.70
q1 (control) 0.754s 0.748s 0.974 1.00, 0.95, 1.11, 0.95

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 nation scan, 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.

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>
@milenkovicm

Copy link
Copy Markdown
Contributor

is this similar to #1954 ?

@Dandandan

Copy link
Copy Markdown
Contributor Author

is this similar to #1954 ?

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
Dandandan force-pushed the perf/aqe-reuse-identical-stages branch from 8580fbc to 4d1e23b Compare August 18, 2026 11:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants