Repository navigation
feat(scheduler): stage a join's build side before deciding the join - #2434
Conversation
AQE's `Repartition` action shuffles both join inputs at once. When the build side's size is only an estimate, that estimate is the sole reason the join is not a broadcast, and the probe side is a fact table, this commits to the single most expensive stage in the query before any measurement exists. TPC-H q8 at SF1000 shuffles all 6B rows of lineitem to join against 1.33M rows of filtered `part`, and AQE can never walk it back: once `Repartition` fires, `selection_state` is `Repartitioned` and only Hash and SortMerge remain reachable. Add `JoinSelectionAction::StageBuildSide`, which shuffles the smaller side alone on the join key and leaves the probe side untouched. The resolver re-runs once that stage completes, reads the exchange's measured statistics, and usually promotes the join to `CollectLeft`, so the probe side is never shuffled. It fires at most once per join, guarded on neither child already being an exchange, and `Repartition` now reuses an exchange already on the join key rather than nesting a second stage boundary inside it. Also stop applying `broadcast_join_threshold_rows` as a ceiling over the schema-derived byte estimate. The config documents it as the fallback for when byte statistics are unavailable, but the code applied both, so a narrow build side well inside the byte budget was rejected on row count alone: 1M `i64` keys is 8 MB against a 128 MiB default budget. The row rule now decides only when no byte estimate can be formed, and `0` still disables that path.
42a6ebb to
da92ba8
Compare
|
Looks promising: |
Q8 96.02 -> 40.68, Q9 107.89 -> 51.94 and Q21 95.63 -> 65.69 as the build side is staged and measured before the join strategy is chosen. Suite total 817.07 -> 696.09 against an unchanged Spark 3.4 baseline of 845.21 on the same cluster shape. Pin the version row to this PR's head and note that the numbers do not reproduce from main until it lands.
|
Reviewed this against the PR head ( First, the part you asked people to push on — the one-sided shuffle safety argument holds. I traced it rather than taking it on faith: Two things I'd want changed before merge. 1. Staging fires for join types that can never broadcastThe
I confirmed it with a throwaway test on the q8 shape:
One line fixes it: (JoinInputState::Unknown, PartitionMode::Partitioned)
if bc.stage_build_side_enabled()
&& !self.null_aware
+ // Measuring the build side can only change the outcome if
+ // CollectLeft is reachable at all, which is a property of the
+ // (post-swap) join type alone.
+ && collect_left_broadcast_safe(build_side_join_type)
&& !self.left.is::<ExchangeExec>()Applied locally: 415/415 pass, no changes needed to any test in this PR — they all use On the +3.1s band: this is a candidate partial explanation, not a diagnosis. q16 ( 2. Nothing tests the multi-pass pathAll the new coverage is single-pass: The harness for exactly this already exists in Worth noting Smaller
On defaulting to
|
| join: Arc<DynamicJoinSelectionExec>, | ||
| /// Which child to give an exchange: `true` for `left`, `false` for | ||
| /// `right`. | ||
| build_is_left: bool, |
There was a problem hiding this comment.
perhaps having enum BuildSide::Left, Right would be more explanatory and this is generic enum that can be reused it other parts
There was a problem hiding this comment.
Agreed the bool was opaque. I went with DataFusion's existing datafusion::common::JoinSide rather than defining a new BuildSide — it is already the generic Left/Right/None enum for exactly this, and it is already imported elsewhere in this crate (physical_optimizer/join_selection.rs), so it is one less type to keep in sync.
The variant is now build_side: JoinSide, and SelectJoinRule matches on it instead of indexing children with a bool. JoinSide::None is rejected with an internal error, since the resolver only ever produces Left or Right.
| /// | ||
| /// Both sides are compared in bytes. A missing probe-side size declines: with | ||
| /// nothing to compare against there is no evidence the round trip pays off. | ||
| fn should_stage_build_side( |
There was a problem hiding this comment.
Would prefer something like
requires_build_staging, build_staging_is_neeed, etc
There was a problem hiding this comment.
Renamed to requires_build_staging.
| /// `p_type = '...'`, and measures three orders of magnitude under it. A raw | ||
| /// fact-table scan sits far beyond this multiple and is shuffled without the | ||
| /// extra round trip. | ||
| const STAGE_BUILD_SIDE_MAX_ESTIMATE_MULTIPLE: usize = 32; |
There was a problem hiding this comment.
any chances the user wants to override this without rebuilding from source?
There was a problem hiding this comment.
Good call — both are config keys now, so they are tunable without a rebuild:
ballista.optimizer.stage_build_side_min_probe_ratio(UInt64, default10)ballista.optimizer.stage_build_side_max_estimate_multiple(UInt64, default32)
They are read into a new BuildStagingLimits struct alongside broadcast_join_threshold_bytes, which is also where the reasoning for each figure now lives. Both are only consulted when stage_build_side is enabled.
Config docs regenerated via ./dev/update_config_docs.sh, and stage_build_side_ships_the_documented_defaults pins the shipped values, since those are what the SF1000 numbers were measured under.
…ypes Staging a join's build side can only change the outcome when CollectLeft is reachable, and that is a property of the post-swap join type alone: partition_mode is CollectLeft only when `under_threshold && collect_left_broadcast_safe(..)`. Without that term in the staging guard a Full join (Full.swap() == Full), or a left-sided semi/anti/outer join whose build side is already the left input, would stage, fail the exchange guard on the second pass, and fall through to Repartition having bought a serialised stage boundary and no decision. The swap cannot rescue those cases either: supports_swap_join_order is left_bytes > right_bytes, so a build side that is already the left input is the smaller one, and measuring it only shrinks it further. Also from review: - Replace `build_is_left: bool` with DataFusion's existing `JoinSide` rather than a new enum, and reject `JoinSide::None` explicitly. - Rename `should_stage_build_side` to `requires_build_staging`. - Promote the two gating figures to config keys, read through a new BuildStagingLimits struct, so they can be tuned without rebuilding: stage_build_side_min_probe_ratio and stage_build_side_max_estimate_multiple. - Restore the hash_build_fits doc comment, which the previous commit orphaned onto a constant. - Add state/aqe/test/stage_build_side.rs, covering the two-pass mechanism end to end: stage then broadcast against the measured size, the too-large fallthrough reusing the staged exchange rather than nesting a second one, and a Left join never staging. The broadcast_join_threshold_rows semantics change moves to apache#2444.
|
Thanks for tracing the safety argument rather than taking it on faith — that was the part I most wanted someone to actually walk, and your reading of Both blocking items are addressed, plus the three inline ones. Pushed. 1. Join-type guard — fixedYou're right, and it's a real waste rather than a cosmetic one. Applied exactly as you wrote it. One thing worth adding, because it was the only way I could see the guard being too strict: the swap decision can't rescue those cases either. Both directions are pinned now, across all ten join types: 2. Multi-pass coverage — addedNew file Three tests:
418 passed, 0 failed. Clippy and rustfmt clean. 3. Splitting the row-threshold change — doneNow #2444. Agreed on the reasoning: it isn't what fixes q8, and it deliberately replaces a test that asserted the old semantics, so the two should be revertable independently. SmallerExchange guard depth. Acknowledged, and left as is deliberately — I'd rather not widen it without a shape that actually motivates it. The fire-at-most-once bound is unaffected, because staging always produces a direct
The rule is entirely join-agnostic: it collects leaf The window you're describing — build leaf carrying a plan from a one-leaf pass while the probe leaf carries none — can't occur, because the rule only runs after a full replan. The thing I'd raise separately, which this PR doesn't introduce but does sit close to: nothing ever clears a Executor loss and a resolved exchange's Defaults and benchmark numbersKeeping Both caveats are now stated inline in the description rather than in a footnote: one iteration per query, and the totals were measured before the join-type guard, so they include stages that were staged and thrown away on anti and outer joins. The post-fix total should land slightly under 696.09s. I haven't re-measured on a cluster yet. Your partial explanation for the +3.1s band holds up and I've written it into the description: q16 ( |
|
some s3 tests are failing |
|
its the same minio issue observed in ci: UPD: #2446 |
comphead
left a comment
There was a problem hiding this comment.
Ran a reuse / simplification / efficiency / altitude pass over the diff, following up on my earlier review. No correctness findings. The one-sided shuffle argument still holds and I think the mechanism is at the right depth. Everything below is quality only, and none of it is blocking.
Rough total if all of it is taken: ~340 lines, out of ~917 lines of new Rust (~90 production, ~250 test).
Best value-to-effort, all mechanical and behaviour-preserving:
- fold the six
requires_build_stagingtests (~80) - un-fork
q8_shaped_action*fromrun_to_actual_join(~42) - merge the two join-type loops onto the existing
collect_left_broadcast_safeoracle (~35) BuildStagingLimitsto plain params, matching the three sibling predicates in the same file (~31)mock_partitions_with_statistics_no_datatomock_partitions_with_size(0, 0)(~21)
Two things I checked and am not asking for:
- Dropping
stage_build_side_max_estimate_multiple. A filtered fact-table scan reportsInexactand can sit orders of magnitude over budget, so theInexactgate does not already cover it. The multiple does real work. - Turning the two knobs into constants. House style is config keys (all eight pre-existing numeric AQE knobs are), and I was the one who asked for them to be tunable.
Not introduced here, noted for later: to_actual_join builds five independent StatisticsContexts per call (L288, L306, L324, L326, plus one inside supports_collect_by_thresholds), each with a fresh empty StatsCache, so each child subtree is walked about 2.5 times with no memo sharing. To the PR's credit it adds zero new statistics computation and reuses what is already there. It just adds a fourth consumer, which makes this a natural moment to thread one context through.
Tiny, and invisible to CI: dynamic_join.rs:901's use datafusion::common::{ColumnStatistics, Statistics, stats::Precision}; is now fully shadowed by use super::*, since the parent import gained Statistics and Precision. rustc does not warn on an explicit use over a glob, so clippy -D warnings will not catch it.
| StageBuildSide { | ||
| join: Arc<DynamicJoinSelectionExec>, | ||
| /// Which child to give an exchange. Only ever [`JoinSide::Left`] or | ||
| /// [`JoinSide::Right`] — the resolver picks the side the join would | ||
| /// build from, which is never absent. | ||
| build_side: JoinSide, | ||
| }, |
There was a problem hiding this comment.
Two things collapse here.
join is a field-identical copy of the node SelectJoinRule is already visiting (see the with_selection_state call below), so the payload can go and the arm can rebuild from the visited node.
JoinSide is a three-variant type for a two-state fact, which is what forces the unreachable JoinSide::None arm in join_selection.rs. The BuildSide { Left, Right } I originally suggested would have made that unrepresentable. A plain build_is_right: bool does too, and the producer below already computes exactly that as swap_inputs.
~13 lines.
| (JoinInputState::Unknown, PartitionMode::Partitioned) | ||
| if bc.stage_build_side_enabled() | ||
| && !self.null_aware | ||
| // Measuring the build side can only change the decision if | ||
| // `CollectLeft` is reachable at all, and that is a property | ||
| // of the (post-swap) join type alone — no measurement makes | ||
| // a `Full`, or a left-sided semi/anti/outer, join | ||
| // broadcastable. `partition_mode` above is `CollectLeft` | ||
| // only when this same predicate holds, so without it here | ||
| // staging would buy a serialised stage boundary and no | ||
| // decision. | ||
| && collect_left_broadcast_safe(build_side_join_type) | ||
| // Firing at most once per join is what keeps this from | ||
| // ping-ponging: once it fires, one child is an | ||
| // `ExchangeExec` and this guard never passes again. | ||
| && !self.left.is::<ExchangeExec>() | ||
| && !self.right.is::<ExchangeExec>() | ||
| && requires_build_staging( | ||
| build_stats, | ||
| if swap_inputs { &stats_left } else { &stats_right }, |
There was a problem hiding this comment.
Three things in one guard.
!self.null_aware(L400) is already implied.partition_modeisif self.null_aware || (..) { CollectLeft } else { Partitioned }, so matchingPartitionedguarantees it.- The 8-line comment at L401-408 sits inside a match pattern. The same rationale is already on
requires_build_staging, on the join-type test, and instage_build_side.rs. Keep one copy. if swap_inputs { &stats_left } else { &stats_right }(L417) is the thirdswap_inputsternary in this function. L292 and L328 already do it forbuild_sideandbuild_stats.
Structural: this arm and the fallthrough at L433 have the identical pattern, separated only by the guard, so a reader scans 25 lines past the pattern before learning what it matches. Hoisting probe_stats next to build_stats and folding the conditions into one should_stage_build_side(..) predicate collapses both into one arm, and retires the "the caller is responsible for the fourth condition" paragraph at L720.
| Ok(JoinSelectionAction::StageBuildSide { | ||
| join: Arc::new(self.with_selection_state(JoinInputState::Unknown)), | ||
| build_side: if swap_inputs { |
There was a problem hiding this comment.
with_selection_state(Unknown) is a no-op here. JoinInputState has two variants, and this arm is reachable only when the local selection_state (L366-372) is Unknown, which requires self.selection_state == Unknown already. So this copies all 11 fields and changes nothing.
Combined with dropping the payload, with_selection_state can go entirely and to_partitioned can return to its inline body. Caller counts today: to_partitioned 1, with_selection_state 2, one of which is to_partitioned itself.
| /// The configured limits that decide whether staging a join's build side on its | ||
| /// own is worth an extra round trip. See [`requires_build_staging`]. | ||
| #[derive(Debug, Clone, Copy)] | ||
| struct BuildStagingLimits { | ||
| /// `ballista.optimizer.broadcast_join_threshold_bytes`: the budget a build | ||
| /// side must come in under to be broadcast. `0` disables broadcast | ||
| /// promotion, and with it any reason to measure the build side again. | ||
| broadcast_threshold_bytes: usize, | ||
| /// `ballista.optimizer.stage_build_side_min_probe_ratio`: how many times | ||
| /// larger the probe side must be before staging pays off. | ||
| /// | ||
| /// Staging serialises the two shuffles that `Repartition` would otherwise | ||
| /// run concurrently, so the deferral costs at most the *smaller* side's | ||
| /// runtime. Requiring an order of magnitude keeps that cost well under the | ||
| /// probe-side shuffle it stands to avoid entirely. | ||
| min_probe_ratio: usize, | ||
| /// `ballista.optimizer.stage_build_side_max_estimate_multiple`: how far over | ||
| /// the broadcast budget an *estimated* build side may sit and still be worth | ||
| /// measuring. | ||
| /// | ||
| /// TPC-H q8's filtered `part` scan estimates roughly 10x over budget, | ||
| /// because the planner falls back to `default_filter_selectivity` for | ||
| /// `p_type = '...'`, and measures three orders of magnitude under it. A raw | ||
| /// fact-table scan sits far beyond this multiple and is shuffled without the | ||
| /// extra round trip. | ||
| max_estimate_multiple: usize, | ||
| } | ||
|
|
||
| impl BuildStagingLimits { | ||
| fn new(bc: &BallistaConfig, broadcast_threshold_bytes: usize) -> Self { | ||
| Self { | ||
| broadcast_threshold_bytes, | ||
| min_probe_ratio: bc.stage_build_side_min_probe_ratio(), | ||
| max_estimate_multiple: bc.stage_build_side_max_estimate_multiple(), | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
37 lines to bundle three usizes for one call site, and it diverges from the convention in this same file. supports_collect_by_thresholds(plan, threshold_byte_size, threshold_num_rows) (L509), hash_build_fits(..) (L766) and broadcast_build_side_pays_off(..) (join_selection.rs:176) all take plain params. I grepped ballista/scheduler and ballista/core: this is the only limits bundle in the tree.
It is also internally inconsistent. Two limits come from the &BallistaConfig it receives, the third is passed by hand even though it is just bc.broadcast_join_threshold_bytes() from L274.
Plain params drop ~31 lines and take the stage_limits test helper with them. Worth keeping: the q8 part-scan anecdote at L680-684, relocated as one sentence onto requires_build_staging. That one is measured evidence rather than prose.
| /// Each of the three conditions is necessary: | ||
| /// | ||
| /// * the build estimate must be **inexact**. An exact size over budget is a | ||
| /// fact rather than a guess, so measuring it again cannot change the outcome | ||
| /// and would only serialise two shuffles that could run concurrently. | ||
| /// * the estimate must be **plausibly wrong enough to flip**. Past | ||
| /// [`BuildStagingLimits::max_estimate_multiple`] the side is large on any | ||
| /// reading, and no measurement brings it under budget. | ||
| /// * the probe side must be **much larger**, per | ||
| /// [`BuildStagingLimits::min_probe_ratio`], which is what bounds the cost of | ||
| /// being wrong. |
There was a problem hiding this comment.
These three bullets map 1:1 onto the three early returns immediately below.
Worth keeping: L698-706 (what Repartition actually costs, which is the real why) and L724-725 (why a missing probe size declines, not derivable from else { return false }).
| fn join_schema() -> Arc<Schema> { | ||
| Arc::new(Schema::new(vec![ | ||
| Field::new("id", DataType::Int32, false), | ||
| Field::new("val", DataType::Int32, false), | ||
| ])) | ||
| } | ||
|
|
||
| /// A context carrying Ballista's shipped configuration, so these tests run | ||
| /// against the thresholds a deployment uses — including | ||
| /// `ballista.optimizer.stage_build_side`, which defaults to on. | ||
| fn ballista_ctx() -> SessionContext { | ||
| let config = SessionConfig::new_with_ballista() | ||
| .with_target_partitions(4) | ||
| .with_round_robin_repartition(false); | ||
| let state = SessionStateBuilder::new_with_default_features() | ||
| .with_config(config) | ||
| .build(); | ||
| SessionContext::new_with_state(state) | ||
| } | ||
|
|
||
| fn register(ctx: &SessionContext, name: &str, stats: Statistics) { | ||
| ctx.register_table(name, Arc::new(StatsTable::new(join_schema(), stats, 4))) | ||
| .unwrap(); | ||
| } |
There was a problem hiding this comment.
I diffed these against broadcast_thresholds.rs:
ballista_ctx()is byte-identical tobroadcast_thresholds.rs:80-88join_schema()is byte-identical to itsnarrow_schema()(L71-76)register()is its L90-93 with theschemaparam dropped, so strictly less capableconst MBis a third copy (L47 there, anddynamic_join.rs:911)
test/mod.rs is already the home for cross-file AQE fixtures (mock_context, mock_schema, mock_partitions_*). Hoisting these there and importing in both files nets ~25 lines, and more importantly stops "the shipped session config" from meaning two different things.
|
|
||
| /// A `Left` join can never be lowered to `CollectLeft` — the build side would | ||
| /// emit its unmatched rows once per probe task (#1055) — so measuring it cannot | ||
| /// change the decision, and staging would buy a serialised stage boundary for | ||
| /// nothing. Both sides must be shuffled on the first pass. | ||
| /// | ||
| /// Worth pinning separately: `test_left_join_not_collected_left` covers the | ||
| /// broadcast half of this, but passes today only because its statistics do not | ||
| /// match the staging shape. These ones do. | ||
| #[tokio::test] | ||
| async fn does_not_stage_a_left_join() { | ||
| let ctx = q8_shaped_ctx(); | ||
| let planner = planner_for( | ||
| &ctx, | ||
| "SELECT dim.val FROM dim LEFT JOIN fact ON dim.id = fact.id", | ||
| ) | ||
| .await; | ||
|
|
||
| assert_plan!(planner.current_plan(), @ " | ||
| AdaptiveDatafusionExec: is_final=false, plan_id=3, stage_id=pending, stage_resolved=false | ||
| ProjectionExec: expr=[val@1 as val] | ||
| DynamicJoinSelectionExec: plan_id=0, join_type=Left, on=[(id@0, id@0)] repartitioned=true | ||
| ExchangeExec: partitioning=Hash([id@0], 4), plan_id=1, stage_id=pending, stage_resolved=false | ||
| CooperativeExec | ||
| StatsExec: partitions=4, rows=Inexact(1000000), bytes=Inexact(209715200) | ||
| ExchangeExec: partitioning=Hash([id@0], 4), plan_id=2, stage_id=pending, stage_resolved=false | ||
| CooperativeExec | ||
| StatsExec: partitions=4, rows=Exact(1000000000), bytes=Exact(42949672960) | ||
| "); | ||
| } |
There was a problem hiding this comment.
The justification in the doc does not quite hold up. test_left_join_not_collected_left (test/join_selection.rs:190-198) already snapshots the identical two-exchange Left shape, and JoinType::Left is already a case in does_not_stage_a_join_type_that_can_never_broadcast. The genuinely unique content is "with q8-shaped statistics too", which is one row of the merged loop.
Judgment call rather than a clear cut, since broadcast_thresholds.rs does pair integration positives and negatives. But it is a ~30-line plan snapshot that will need re-blessing on every unrelated plan_id shift.
| /// Statistics for a table whose size is an *estimate* rather than a | ||
| /// measurement, as a filtered scan reports it before the filter has run. | ||
| /// | ||
| /// This is the shape that makes staging a join's build side worthwhile: the | ||
| /// planner falls back to `default_filter_selectivity` and produces a figure | ||
| /// that is over the broadcast budget but carries no evidence behind it. | ||
| pub(crate) fn estimated_statistics( | ||
| schema: &Schema, | ||
| num_rows: usize, | ||
| total_byte_size: usize, | ||
| ) -> Statistics { | ||
| use datafusion::common::{ColumnStatistics, stats::Precision}; | ||
|
|
||
| Statistics { | ||
| num_rows: Precision::Inexact(num_rows), | ||
| total_byte_size: Precision::Inexact(total_byte_size), | ||
| column_statistics: vec![ColumnStatistics::new_unknown(); schema.fields().len()], | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
This is sized_statistics with Exact swapped for Inexact, and DataFusion ships the conversion:
pub(crate) fn estimated_statistics(schema: &Schema, num_rows: usize, total_byte_size: usize) -> Statistics {
sized_statistics(schema, num_rows, total_byte_size).to_inexact()
}(Statistics::to_inexact, datafusion-common-55.0.0/src/stats.rs:485.)
Counterpoint: three sibling constructors is this file's existing shape, so low value either way.
| | Ballista | [`da92ba8d`](https://github.com/apache/datafusion-ballista/pull/2434/commits/da92ba8d1bce6e2fd76d01aa86a8c971fa34ac2f) (2026-09-08), Cargo pkg `54.0.0`, DataFusion `55.0.0` | | ||
| | Spark | 3.4 (vanilla, no acceleration plugin) | | ||
|
|
||
| ```{note} | ||
| `da92ba8d` is the scheduler commit of | ||
| [#2434](https://github.com/apache/datafusion-ballista/pull/2434) — `main` | ||
| as of 2026-09-08 plus that PR's change, which stages a join's build side | ||
| and reads its measured statistics before `DynamicJoinSelectionExec` | ||
| commits to a strategy. It accounts for almost all of the improvement over | ||
| the previous (`67b3a19b`) result set — Q8 96.02 → 40.68, Q9 107.89 → | ||
| 51.94, Q21 95.63 → 65.69 — so a run from `main` before #2434 lands will | ||
| not reproduce these numbers. Re-pin this row to the merge commit once it | ||
| does. |
There was a problem hiding this comment.
Checked-in docs pinned to an unmerged PR commit, carrying an unowned TODO ("Re-pin this row to the merge commit once it does"). This is also the only {note} admonition in the whole docs/source tree, so it is not an established pattern here.
Leaving the row on 67b3a19b and landing a separate docs commit after merge avoids the self-invalidating state.
| | 1 | 16.81 | 67.58 | | ||
| | 2 | 30.24 | 29.80 | | ||
| | 3 | 31.01 | 25.13 | | ||
| | 4 | 20.50 | 21.19 | | ||
| | 5 | 63.62 | 54.12 | | ||
| | 6 | 15.28 | 1.23 | | ||
| | 7 | 52.46 | 19.57 | | ||
| | 8 | 40.68 | 48.60 | | ||
| | 9 | 51.94 | 69.38 | | ||
| | 10 | 56.01 | 35.92 | | ||
| | 11 | 18.24 | 30.88 | | ||
| | 12 | 19.60 | 10.78 | | ||
| | 13 | 14.19 | 20.45 | | ||
| | 14 | 15.70 | 7.00 | | ||
| | 15 | 19.30 | 23.75 | | ||
| | 16 | 17.40 | 23.41 | | ||
| | 17 | 31.15 | 82.30 | | ||
| | 18 | 51.41 | 129.40 | | ||
| | 19 | 18.99 | 11.26 | | ||
| | 20 | 33.49 | 19.22 | | ||
| | 21 | 65.69 | 101.53 | | ||
| | 22 | 12.40 | 12.71 | | ||
| | **Total** | **696.09** | **845.21** | |
There was a problem hiding this comment.
Q6 is join-free (single-table scan, filter, aggregate on lineitem) and regressed 7% (14.25 to 15.28), while the equally join-free Q1 improved 4%. At one iteration per query that is a noise floor of roughly plus or minus 7% being published as measured signal, right next to a note saying this PR "accounts for almost all of the improvement".
The three headline wins are an order of magnitude above that band and survive it fine. The 13 small regressions do not, and the doc says nothing about them while the PR description does.
milenkovicm
left a comment
There was a problem hiding this comment.
thanks @andygrove & @comphead ,
this makes sense to me, when initial DJ implementation was created i had a question if scheduling both sides at the same time makes sense. i believe this is a good balance between cluster utilisation and plan adaptility.
maybe as a follow up we should consider work in #1916 and cardinality estimation, with hope it could bring better join decision
| } | ||
|
|
||
| impl BuildStagingLimits { | ||
| fn new(bc: &BallistaConfig, broadcast_threshold_bytes: usize) -> Self { |
There was a problem hiding this comment.
nit: consider adding max_probe_ratio & max_estimate_multiple, instead of BC, "law of demeter" principle. at the moment we have mix and match of paratemters as broadcast_threshold_bytes comes from BC as well
There was a problem hiding this comment.
Taken. BuildStagingLimits is gone: requires_build_staging now takes min_probe_ratio and max_estimate_multiple as plain usize params rather than reaching through &BallistaConfig, and broadcast_threshold_bytes is passed the same way — so the mix of "two read off BC, one passed by hand" is gone with it.
This also lines up with comphead's separate point that the three sibling predicates in this same file (supports_collect_by_thresholds, hash_build_fits, broadcast_build_side_pays_off) all take plain params, so the bundle was the odd one out.
| /// promotion, and with it any reason to measure the build side again. | ||
| broadcast_threshold_bytes: usize, | ||
| /// `ballista.optimizer.stage_build_side_min_probe_ratio`: how many times | ||
| /// larger the probe side must be before staging pays off. |
There was a problem hiding this comment.
is there a difference if statictics are exact or estimated ?
| /// | ||
| /// Each of the three conditions is necessary: | ||
| /// | ||
| /// * the build estimate must be **inexact**. An exact size over budget is a |
There was a problem hiding this comment.
this answers my question from, reasoning make sense to me.
| ConfigEntry::new(BALLISTA_STAGE_BUILD_SIDE.to_string(), | ||
| "Stages a join's prospective build side as its own shuffle before \ | ||
| choosing the join strategy, when that side's size is only an \ | ||
| estimate and the probe side is far larger. Lets AQE measure the \ |
There was a problem hiding this comment.
perhaps we should mention that we're working with estimated statistics only
There was a problem hiding this comment.
Taken. All three stage_build_side* entries now say it explicitly — staging applies only where the build side's size is an estimate rather than a measurement, because an exact size over the broadcast budget is a fact that no further measurement can change.
The code says the same thing in one place now too: requires_build_staging returns early unless total_byte_size is Precision::Inexact.
Config docs regenerated via ./dev/update_config_docs.sh.
Quality pass following comphead's reuse and simplification review, which found no correctness issues. Behaviour preserving: the only snapshots that changed are the four in stage_build_side.rs, and their plan shapes and plan_ids are identical to before. Production code: - Drop the `join` payload from `JoinSelectionAction::StageBuildSide`. It was a field-identical copy of the node `SelectJoinRule` is already visiting, so the arm now rebuilds from that node. - Replace `build_side: JoinSide` with `build_is_right: bool`. `JoinSide` is three variants for a two-state fact, which forced an unreachable `JoinSide::None` arm; the producer already computes the bool as `swap_inputs`. - Collapse the two `(Unknown, Partitioned)` arms into one. `!null_aware` was implied by matching `Partitioned`, and the join-type condition moves inside `requires_build_staging`. - Drop `with_selection_state`, a no-op at its only new call site, and restore `to_partitioned`'s inline body. - Replace `BuildStagingLimits` with plain params, matching the three sibling predicates in the same file. - Hoist `probe_stats` beside `build_stats` instead of a third swap ternary. - `join_key_partitioning` returns a tuple by value over the owned Vec, dropping a dead clone. `exchange_on` becomes `SelectJoinRule::ensure_exchange`, keeping its laziness through the receiver and documenting why it uses structural equality rather than `Partitioning::satisfaction`. Tests: - Fold six single-assert staging tests into two behind a `stages(..)` predicate helper. - Merge the two hand-partitioned join-type lists into one loop over all ten types, with `collect_left_broadcast_safe` as the oracle, so a join type added to DataFusion cannot slip through untested. - Un-fork `q8_shaped_action*` from `run_to_actual_join` by giving the latter a `stage_build_side` param. - Parameterise the fourth `StatisticsExec` builder; the others wrap it. - Hoist `ballista_ctx`, `narrow_schema`, `register_stats_table` and `MB` into `test/mod.rs` so "the shipped session config" means one thing. - `mock_partitions_with_statistics_no_data` delegates to `mock_partitions_with_size`; `estimated_statistics` uses DataFusion's `Statistics::to_inexact`. - Drop `does_not_stage_a_left_join` and `exchange_on_wraps_a_non_exchange_child`, both covered elsewhere. Config entries now state that staging applies only to estimated statistics. Reverts the benchmarking.md update: it pinned a row to an unmerged PR commit behind an unowned TODO, and those numbers predate the join-type guard. They will be re-measured and landed separately.
|
Thanks both. @milenkovicm for the approval, and @comphead for the second pass — I took essentially all of it. Net −232 lines (386 insertions, 618 deletions) across the branch, and 410 passed / 0 failed, clippy and rustfmt clean. The load-bearing check: the only snapshots insta accepted were the four in I verified your three structural claims rather than taking them on faith, and all three hold: TakenProduction. Dropped the On the naming: agreed Tests. Six single-assert staging tests folded into two behind a You were right that the fixtures in Dropped Also fixed the shadowed Not takenThe exchange-guard depth, deliberately. The fire-at-most-once bound is unaffected, because staging always produces a direct Threading one @milenkovicmLaw of Demeter: taken, and it lines up with comphead's item — Config wording: also taken. All three And yes on #1916 and cardinality estimation as the follow-up. Better cardinalities would shrink the window this heuristic exists to cover, which is the right direction. Benchmarks and docsI reverted the Those numbers also predate the join-type guard, so they are stale regardless. I will re-measure on the cluster and land the docs separately after merge, with the iteration count and the regression band stated rather than rounded away. CIThe S3 failures look like the MinIO issue you linked (#2446) rather than anything here. |
|
Merged. Thanks @milenkovicm and @comphead |
Merging main brought in the OpenAPI endpoint (apache#2397) and build-side staging for AQE joins (apache#2434). Neither was reflected in the docs this branch had already made complete, and auditing them turned up two older inaccuracies in the same sections. From the merged changes: - Document the new `utoipa` feature on ballista-core in the root and crate READMEs, and note that the scheduler's `rest-api` feature now also serves the OpenAPI spec. - Add build-side staging to the AQE optimization lists in the tuning and architecture guides, and to the 55.0.0 upgrade guide, since it is a new default that changes query plans. Pre-existing inaccuracies: - The tuning guide's Join Strategy section only described the static planner. With AQE on by default, `prefer_hash_join` is not consulted; the join is chosen at runtime from the broadcast threshold and `hash_join_max_build_partition_bytes`. The section now covers both paths, and notes that the static planner only promotes hash joins to broadcast. - Join reordering compares byte sizes first and falls back to row counts, not row counts alone. - The REST API was described as an optional feature to enable, but `rest-api` is on by default.
* docs: correct stale defaults, flags, and feature docs AQE was documented as experimental and off by default; it is on by default. The scheduler policy default is push-staged and the event-loop buffer is 1000. ballista-cli has no spark-compat feature. The REST table was missing endpoints and both health probes, the Prometheus list named a metric that is not exported, and every second-executor example moved only --bind-port. CLI usage is regenerated from the built binary. Also brings the docs up to date with #2397 (utoipa, rest-api default) and #2434 (build-side staging). Co-Authored-By: Claude Opus 5.5 <[email protected]> * docs: keep the Python SessionConfig limitation and name AQE join selection consistently A SessionConfig passed as config is still used only by the local context; only cluster_config reaches the cluster, so restore the limitation and point at cluster_config. The tuning guide listed join reordering and broadcast join selection as two optimizations, while the architecture guide calls them one. Both are the same AQE rule, so merge them into a single Join selection bullet, and say that the build side is chosen by byte size, falling back to row count. Co-Authored-By: Claude Opus 5.5 <[email protected]> * docs: fix the Spark-functions CLI claim, sample logs, and benchmark formats - ballista-cli does not get Spark functions from the cluster: SQL is planned in the client, so the CLI needs them compiled in. Say so, and give the source build that enables them (ballista-core's spark-compat, unified in). - Replace sample startup logs with what the current binaries print. The executor sample showed messages that no longer exist, and a pull-mode poll loop although push-staged is the default. - Keep --format tbl in the benchmark commands; tpch reads dbgen's .tbl files. - Note that the published images lack keda-scaler, that --disable-rest-api turns the REST API off at runtime, and tidy the Prometheus note. - Stop quoting rust_clippy.sh's exact command, which #2473 changes. Co-Authored-By: Claude Opus 5.5 <[email protected]> * docs: move Who uses Ballista from the README to the docs The README is the project's entry point, so keep the adopters list in the published docs instead, as a Community page. All three of its links were checked and resolve. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
Which issue does this PR close?
Closes #.
Follows up on the q8 analysis in #2419: #2419 (comment) (items 2 and 3). No issue filed yet, happy to open one if this direction looks right.
Rationale for this change
TPC-H q8 at SF1000 takes 164s, and 131s of that is a single stage:
SortShuffleWriterExec Hash([l_partkey], 256)over the raw lineitem scan. All 6B rows of lineitem are shuffled across the network in order to join against 1,332,379 rows of filteredpart, which is onep_partkeyi64 column, roughly 10.7 MB, about 12x under the 128 MiB broadcast budget. That shuffle should not exist.It exists because AQE decides the join before it can measure anything. The build side is
FilterExec(p_type = 'ECONOMY ANODIZED STEEL')overpart, and with no runtime statistics the planner falls back todefault_filter_selectivity(20%). The resulting estimate sits over the broadcast budget, soto_actual_joinpicksPartitionMode::Partitionedand emitsJoinSelectionAction::Repartition, which wraps both children in anExchangeExec. The 131s is committed at that point.Worse, it is unrecoverable. Stage 0 finishes in 1.4s and reveals the true size, but by then
selection_state == Repartitioned, so the only armsto_actual_joincan still reach are Hash and SortMerge. There is no path back toCollectLeft.The same shape shows up in q9 s1 (58s), and the estimate/measurement gap is not specific to q8: any filtered dimension scan feeding a fact-table join hits it.
What changes are included in this PR?
Stage the build side before deciding (
JoinSelectionAction::StageBuildSide)A new action that shuffles only the prospective build side, on the join key, and leaves the probe side untouched.
selection_statestaysUnknown, so whenupstream_resolved()goes true after that one cheap stage,to_actual_joinruns again — this time against a resolvedExchangeExec, whosepartition_statisticsreportsPrecision::Exactrow and byte counts read back from the shuffle'sPartitionStats. For q8 that is 1.33M rows and a few MB, which promotes the join toCollectLeft. The probe side is then scanned once inside the join stage against a broadcast build side, and stages 1 and 2 (142s combined) collapse into roughly one 12s stage.ExchangeExec::to_broadcastshares thestage_idandshuffle_partitionsArcs, so the staged shuffle is read as a broadcast rather than re-run.requires_build_staginggates this on four conditions, each necessary:collect_left_broadcast_safeon the post-swap join type.partition_modeisCollectLeftonly when that same predicate holds, so for aFulljoin, or a left-sided semi/anti/outer join, no measurement can change the outcome — staging would buy a serialised stage boundary and no decision.Inexact. An exact size over budget is a fact, not a guess, so measuring it again cannot flip the join and would only serialise two shuffles that could run concurrently.stage_build_side_max_estimate_multipleover the byte budget. Past that, no measurement brings it back under. q8'spartscan estimates ~10x over and measures three orders of magnitude under.stage_build_side_min_probe_ratiotimes the build side in bytes. Staging serialises the two shuffles, so the deferral costs at most the smaller side's runtime; the ratio is what bounds the cost of being wrong.It fires at most once per join, guarded on neither child already being an
ExchangeExec— once it fires, one child is, so the guard can never pass again. When the measured size then turns out to be too large to broadcast, the join falls through toRepartition, which reuses an exchange already shuffling on the join key (SelectJoinRule::ensure_exchange) instead of nesting a second stage boundary inside the first.The swap decision cannot rescue a non-broadcastable join type either:
supports_swap_join_orderisleft_bytes > right_bytes, so a build side that is already the left input is the smaller one, and measuring it only shrinks it further.Configuration
Three keys, all consulted only when staging is enabled, and only for build sides whose size is an estimate rather than a measurement:
ballista.optimizer.stage_build_side(Boolean, defaulttrue)ballista.optimizer.stage_build_side_min_probe_ratio(UInt64, default10)ballista.optimizer.stage_build_side_max_estimate_multiple(UInt64, default32)stage_build_side_ships_the_documented_defaultspins the shipped values.Why one-sided shuffling is safe
Worth spelling out, since a half-shuffled join is obviously wrong if it ever executes. It never does, for two independent reasons.
The intermediate plan is never executed.
DynamicJoinSelectionExec::executeis an error, and stages come only fromExchangeExecsubtrees (find_runnable_exchanges). In the staged state the sole unresolved exchange is the build side's, whose subtree is the filtered dimension scan, so that is the only stage created. The probe side contains no exchange and is never scheduled; the join node sits above the exchange, not below it, so it is in no stage either.Every executable outcome satisfies the join's distribution requirement. A
Partitionedhash join or aSortMergeJoinis reachable only through theJoinInputState::Repartitionedarms ofto_actual_join, and that state comes from exactly two places:to_partitioned(), which inserts both exchanges, orinputs_already_partitioned(), which verifies both children satisfyKeyPartitionedexactly. Staging sets neither. The two states it can reach areCollectLeft, which replicates the whole build side to every probe task and needs no co-partitioning, or a fallthrough toRepartition, which shuffles the probe side too. There is no arm that produces a co-partitioned join from a half-shuffled input.The
CollectLeftoutcome leaves the build side with the staged exchange's partition count, which DataFusion'sCollectLeftmode does not accept on its own.SelectJoinRuleconverts it withto_broadcastwhen the probe is partitioned, andEnsureRequirementsgathers it with aCoalescePartitionsExecwhen it is not.Tests
Unit coverage in
dynamic_joinforrequires_build_stagingacross every condition,to_actual_joinreturningStageBuildSidefor the q8 shape,Repartitionwhen the config is off, andRepartitionrather than a second staging once a child is an exchange.stages_exactly_the_join_types_that_can_broadcastloops all ten join types and compares againstcollect_left_broadcast_safeas the oracle, so a join type added to DataFusion cannot slip through untested.state/aqe/test/stage_build_side.rsdrivesAdaptivePlannerthrough both passes against declared statistics:stages_the_build_side_then_broadcasts_the_measured_result— pass one shows an exchange on the build side and a bare scan on the probe, the single stage is the build side alone, and after the shuffle reports its measured size pass two resolves toHashJoinExec: mode=CollectLeftoverbroadcast=true, with the fact table still never shuffled.reuses_the_staged_exchange_when_the_build_side_measures_too_large— the fallthrough, asserting exactly one exchange per side (the build's being the resolved staged one) rather than a nested pair.cargo test -p ballista-scheduler --lib: 410 passed, 0 failed. Clippy and rustfmt clean.Review rounds
Two review passes are folded in.
The first added the join-type guard above, which was a real gap:
Fullwas affected unconditionally (Full.swap() == Full), andLeft/LeftSemi/LeftAnti/LeftMarkwhenever the build side was already the left input.The second was a reuse and simplification pass with no correctness findings, taken essentially in full for a net −232 lines: the
StageBuildSidepayload dropped (the rule already visits that node) andJoinSidereplaced with abuild_is_right: boolthat makes the unreachable variant unrepresentable; the two(Unknown, Partitioned)arms collapsed into one;with_selection_stateremoved as a no-op;BuildStagingLimitsreplaced with plain params to match the sibling predicates; a dead clone dropped fromjoin_key_partitioning;exchange_onrenamed toensure_exchangeand made a method. On the test side, six single-assert staging tests folded into two, the two hand-partitioned join-type lists merged onto thecollect_left_broadcast_safeoracle,q8_shaped_action*un-forked fromrun_to_actual_join, and the duplicated session/schema/register fixtures hoisted intotest/mod.rs.Only the four
stage_build_side.rssnapshots changed across that pass, with identical plan shapes andplan_ids — which is the evidence it is behaviour-preserving.Split out of this PR
The
broadcast_join_threshold_rowssemantics change originally bundled here now lives in #2444. It is not what fixes q8 — at pre-execution q8's build side has anInexacttotal_byte_size, so the byte branch short-circuits and the row path is never reached — so the two are easier to review and revert independently.Benchmarks
Measured on a cluster at TPC-H SF1000, 32 executors on
r6i.24xlarge, Parquet on S3, AQE on: the 22-query suite went from 817.07s to 696.09s, with q8 2.36x, q9 2.08x and q21 1.46x.Those numbers are not being landed in the docs by this PR, and should not be read as final. They were measured before the join-type guard, so they include stages that were staged and discarded on anti and outer joins. They are also one iteration per query: Q1 and Q6 are both join-free, and one improved 4% while the other regressed 7%, so the noise floor is around ±7% — wide enough to explain every small delta in the set, though not the three headline wins, which are an order of magnitude larger.
benchmarking.mdis therefore untouched here. I will re-measure with the guard in place and land a docs commit separately after merge, stating the iteration count and the regression band rather than rounding them away.Known follow-ups, not addressed here
target_partitions, so the broadcast read pulls all of them per probe task. Correct, and cheap at 1.33M rows, but an unpartitioned write would be better.total_byte_sizeis compressed shuffle-file bytes, which understates the in-memory build size. Pre-existing:hash_join_max_build_partition_bytesalready reads the same figure.FilterExec(ExchangeExec(..))can still gain a boundary above an existing one. The fire-at-most-once bound is unaffected, since staging always produces a direct exchange child.to_actual_joinbuilds five independentStatisticsContexts per call, each with a fresh cache. Pre-existing; this PR adds no new statistics computation. Threading one through is worth its own PR.CoalescePartitionsRulehas no way to clear aCoalescePlan, and both bail paths return without clearing. Not introduced here and not reachable in the staged shape, sincefinalise_stage_internalreplans and re-decides the join beforeactionable_stagesinvokes the rule, but the invariant is order-dependent rather than structural.Are there any user-facing changes?
No API changes.
Three new config keys, listed above. With
stage_build_sideon, a plan may gain one extra stage boundary and lose a much larger one, so stage counts and the plan shape for affected joins change. Generated config docs regenerated via./dev/update_config_docs.sh.