Skip to content

fix: coalesce AQE shuffle partitions per alignment group - #2181

Draft
andygrove wants to merge 10 commits into
apache:mainfrom
andygrove:aqe-coalesce-alignment-groups
Draft

fix: coalesce AQE shuffle partitions per alignment group#2181
andygrove wants to merge 10 commits into
apache:mainfrom
andygrove:aqe-coalesce-alignment-groups

Conversation

@andygrove

Copy link
Copy Markdown
Member

Which issue does this PR close?

Closes #2166.
Closes #2167.

Rationale for this change

CoalescePartitionsRule bailed out of an entire stage under two conditions: when any leaf ExchangeExec was a broadcast exchange (#2166), and when the leaves disagreed on upstream partition count M (#2167). Both guards were added in response to an out-of-bounds panic on TPC-H Q22, and both were documented in the code as temporary.

They share one root cause. The rule read M from leaf 0's declared partitioning but indexed the summed byte vector by the resolved shuffle_partitions() vector. Those two disagree for a broadcast exchange: ExchangeExec::new_with_details forces Partitioning::UnknownPartitioning(1) when broadcast is set, while StageOutput::partition_locations_broadcast() returns one entry per upstream partition. A broadcast leaf at index 0 therefore yielded m = 1, and a sibling with 8 resolved partitions wrote past the end of vec![0u64; 1].

Fixing the cause makes both guards unnecessary. Broadcast leaves have no partition structure to coalesce and are never a co-partitioned join sibling, so they can be excluded rather than allowed to disable the stage. Leaves with different M provably cannot be sides of the same partitioned join, since EnforceDistribution equalises the partition counts of a Partitioned join's inputs, so they can be grouped and decided independently.

The practical effect of the guards was that any stage mixing a broadcast leg with shuffle legs, or holding leaves at different partition counts, kept one task per upstream partition no matter how small the per-partition output. That is exactly the small-task overhead the rule exists to remove.

What changes are included in this PR?

The rule (ballista/scheduler/src/state/aqe/optimizer_rule/coalesce_partitions.rs) is restructured into a short driver over pure functions: classify each leaf, group the survivors by M, sum each group's per-partition bytes, bin-pack each group independently, and attach one shared CoalescePlan to every member of a decided group.

  • M now comes from the resolved shuffle shape, checked against the declared partition count. A mismatch is reported and its group skipped, so the Q22 out-of-bounds is unrepresentable rather than guarded against.
  • Broadcast leaves are excluded from grouping instead of disabling it.
  • Remaining leaves are grouped by M and decided per group.
  • Pass-through exchanges (partitioning: None) are also excluded. DistributedExchangeRule inserts these beneath SortPreservingMergeExec and CoalescePartitionsExec. A coalesced reader concatenates several upstream partitions into one output partition and ShuffleReaderExec randomises location order for executor load balancing, so per-partition ordering does not survive coalescing and a SortPreservingMergeExec above would merge unsorted streams. This one predates the change (the old rule coalesced the same single-leaf shape), but per-group coalescing widens where it can fire, so it is fixed here rather than left in place.
  • Missing num_bytes on a partition location now makes a leaf unusable and skips its group. Previously it counted as zero bytes, which under-counted and over-coalesced into oversized tasks.
  • K == 1 is allowed. The old K <= 1 guard refused to act on a stage whose whole output fits in one target-sized partition, which is where per-task overhead dominates most. The only degenerate case now is K >= M, which subsumes M == 1 correctly.
  • ExchangeExec::set_coalesce takes an Option, and the rule clears every leaf it collects before deciding anything. A group can therefore never be left with one member carrying a decision from an earlier pass and a sibling carrying none. Relatedly, ExchangeExec::to_broadcast no longer clones the coalesce slot into the promoted node, which also removes the aliasing that made the two nodes share one slot.

Module documentation now records the alignment-group argument, why grouping by M is sufficient rather than merely convenient, why the wholesale clear is safe only while actionable_stages runs the rule and the adapter in the same per-stage closure, and what the rule does not reason about (skew).

Tests. Unit tests for the extracted pure functions and for leaf classification, including a regression guard for the Q22 shape. Rule-level tests for a stage mixing a broadcast leaf with shuffle leaves and for a stage with heterogeneous M, asserting via Arc::ptr_eq that group members share one plan rather than merely agreeing on K. End-to-end tests through AdaptivePlanner for a broadcast join stage, for a stage collapsing to K == 1, and for the ORDER BY shape that must not coalesce. The six pre-existing snapshots are unchanged.

Are there any user-facing changes?

No public API changes; ExchangeExec and the rule are private to the scheduler crate. No configuration keys added or changed.

Behaviour changes only when ballista.planner.coalesce.enabled=true, which is off by default, as is the adaptive planner itself. Under that flag, stages that previously kept one task per upstream partition may now run fewer, larger tasks, and a stage whose output fits in one target-sized partition may collapse to a single task.

Benchmarks

Plan shape changes when coalesce.enabled=true, so TPC-H numbers under that flag are wanted before merge, Q22 in particular since it is the query that originally panicked.

andygrove added 10 commits July 25, 2026 08:02
set_coalesce now takes an Option so a rule pass can unset a decision, and
to_broadcast no longer carries a coalesce plan into a node that cannot use
one.
Makes the summing and bin-packing testable without a planner, and replaces
the K<=1 and K>=M guards with the single K>=M test, which allows a tiny stage
to collapse to one downstream partition.
The rule read M from leaf 0's declared partitioning but indexed the summed
byte vector by the resolved location vector, which is how a broadcast leaf
(declared 1, resolved M) drove the TPC-H Q22 out-of-bounds. Classification
now checks the two against each other and treats missing byte statistics as
unusable rather than as zero.
Groups leaf exchanges by upstream partition count and decides each group
independently, instead of bailing the whole stage when any leaf is a
broadcast exchange or when leaves disagree on partition count.

Closes apache#2166
Closes apache#2167
Document what the coalesce rule does not reason about — per-partition
ordering does not survive a coalesced read, and the bin-pack never splits
a skewed partition — so the alignment argument is not over-trusted. Record
why grouping by M stays checkable by inspection, and why the wholesale
clear is only safe while the rule and the adapter share one per-stage
closure.

Restore the bin-pack K/M log line on both paths and the root-identity line
that attributes leaf and group lines to their stage, and summarise a
Sizes leaf by length rather than dumping every element.

Make the unreachable non-Sizes arm of the per-group collection panic
rather than silently under-coalesce, and give the grouping fixture's
leaves distinct plan ids.

Add end-to-end coverage for the two cases only the decision layer had:
a stage holding a broadcast leaf beside a shuffle leaf, driven through
AdaptivePlanner to the ShuffleReaderExec, and a stage packing to K = 1.
…t groups

CoalescePartitionsRule could attach a coalesce decision to a pass-through
ExchangeExec (partitioning: None) sitting directly beneath a
SortPreservingMergeExec. DistributedExchangeRule inserts exactly this shape
to carry a stage's ordering across the boundary; a coalesced
ShuffleReaderExec shuffles its concatenated partition locations, destroying
that ordering and letting the SortPreservingMergeExec above silently emit
wrongly ordered ORDER BY results.

Exclude pass-through leaves from alignment groups the same way broadcast
leaves already are: classify_leaf reports a new PassThrough LeafKind, and
group_by_upstream_count drops it via the same non-poisoning continue path
so it does not suppress coalescing for unrelated same-M siblings.
… ordering

The Algorithm section's step 3 still said only broadcast leaves drop out
during classification, contradicting the pass-through exclusion added
above it. Also note why the broadcast check in classify_leaf must run
before the partitioning::is_none() check: both new_broadcast and
to_broadcast set partitioning to None, so reordering the checks would
silently misclassify every broadcast leaf as pass-through.
Grouping now hands each member its own size slice instead of an index into a
parallel vector, so the caller sums a group without re-matching on LeafKind
and the unreachable! that guarded that match is gone.

The rule also decides first and applies second, writing every leaf's slot
exactly once per pass with its final value. That removes the window in which
a leaf held a cleared slot, and with it the cross-module requirement that
actionable_stages keep optimize and adapt in one closure.

Also: name the pass-through condition as ExchangeExec::preserves_child_ordering
so the rule and maintains_input_order share one definition, read byte counts
under the lock rather than deep-cloning every PartitionLocation, and drop the
hand-spelled LeafKind label arms that Debug already produces.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant