fix: Various fixes for AQE join selection#2124
Merged
Merged
Conversation
`DynamicJoinSelectionExec::to_actual_join` made two byte-size decisions against inputs that may not be the build side the executor runs. Broadcast eligibility was OR-ed across both sides, so the join was promoted to `CollectLeft` when either side passed the byte threshold, while the swap logic picked the build side separately and could fall back to row count. A wide-by-bytes side with fewer rows could therefore be broadcast even though it failed the byte check. The hash-join build-fit check measured `self.left`, but `SelectJoinRule` may swap the inputs, making `self.right` the real build side. Derive the swap decision once, up front, and key both checks on the input that actually becomes the build. Closes apache#2121
…join `datafusion.optimizer.prefer_hash_join` is a planner flag: DataFusion applies it when building the physical plan, and `DelayJoinSelectionRule` then folds both `HashJoinExec` and `SortMergeJoinExec` into `DynamicJoinSelectionExec`. Re-reading it in `to_actual_join` applied the same preference twice, and only to one of the three arms that can emit a hash join — both `CollectLeft` arms produced one unconditionally, so `prefer_hash_join=false` never meant "no hash joins" under AQE. Drop it from the `Partitioned` guard so the build-side fit check is the single lever that chooses hash versus sort-merge. That check was disabled by default (`hash_join_max_build_partition_bytes = 0` means "always fits"), which on its own would have left the arm unconditionally on hash join and made `SortMergeJoinExec` — the spillable strategy — unreachable. Default the budget to 64 MiB so the check is load-bearing; `0` still disables it. Also adds the `with_ballista_hash_join_max_build_partition_bytes` session-config setter, matching the existing broadcast-threshold setters.
…rking `datafusion.optimizer.enable_dynamic_filter_pushdown=false` is already applied by `new_with_ballista()`, so documenting it as a manual `-c` flag duplicated Ballista's own default. Remove the flag and its explanatory note. Drop the `ballista.shuffle.sort_based.memory_limit_per_task_bytes=0` override from the recorded run conditions, and remove the "Hash join with a per-partition build-size fallback" section, which was not meant to be checked in.
AQE does not use `datafusion.optimizer.prefer_hash_join` to select a join strategy: `DelayJoinSelectionRule` folds both join operators into `DynamicJoinSelectionExec`, and the strategy is then chosen from runtime statistics. Setting it to `false` did not hold the run to sort-merge joins; it only steered the plan down the `from_sort_join` conversion path, which discards the join's projection, so it measured a slower plan rather than a different join strategy. Drop it from the documented command and explain what actually selects the strategy. Also correct the Comet comparison note, which claimed the two engines were pinned to the same join strategy, and the results preamble's "all three engines plan SortMergeJoin" claim.
Running the whole suite minus one query previously meant driving 21 single-query jobs, which changes the measurement: each job starts a fresh process against a cold cache, so the numbers are not comparable to a continuous suite run. `--skip N` (repeatable) drops queries from the 1..=22 suite while keeping it a single run. An explicit `--query` still wins, so asking for a query directly always runs it. Skip values outside 1..=22, and a skip set that would exclude every query, are rejected rather than silently ignored. Both the DataFusion and Ballista paths now share one `select_queries` helper instead of duplicating the selection logic.
Refresh the Ballista column from one continuous 21-query run at 5290436, using the new `--skip 18` flag so the query that exhausts the memory pool cannot wedge the rest. Total (excluding Q18) goes 4661.0 -> 3936.8. Q18 is recorded as TBD rather than OOM: it was skipped in this run and has not been re-measured at this commit. An earlier attempt did OOM, but under a configuration that discards the join projection, so that result does not transfer. Also documents why the whole column should come from one run: driving queries as individual single-query jobs pays a cold-cache penalty large enough to swamp the effect being measured, and adds a note that single-iteration variance makes per-query differences under roughly 20% indistinguishable from noise.
andygrove
marked this pull request as ready for review
July 21, 2026 20:27
andygrove
marked this pull request as draft
July 21, 2026 20:32
andygrove
marked this pull request as ready for review
July 21, 2026 21:58
Member
Author
|
@phillipleblanc fyi |
phillipleblanc
approved these changes
Jul 22, 2026
phillipleblanc
left a comment
Contributor
There was a problem hiding this comment.
Thanks @andygrove!
avantgardnerio
approved these changes
Jul 22, 2026
avantgardnerio
left a comment
Contributor
There was a problem hiding this comment.
I lack the big picture here, but each individual part seems like an obvious win. I hope to learn more about joins in Ballista soon: I think I can improve SMJ using OrderedRepartitionExec, and broadcast hash join using MPTs.
andygrove
added a commit
to andygrove/datafusion-ballista
that referenced
this pull request
Jul 22, 2026
PR apache#2124 changed hash_join_max_build_partition_bytes from 0 to 64 MiB and reworded its description. The generated table now reflects that.
andygrove
added a commit
that referenced
this pull request
Jul 22, 2026
…2146) * feat(core): expose ConfigEntry metadata for doc generation * feat(core): render config registry as prettier-compatible tables * refactor: use expect instead of allow for dead_code lint Replace crate-level #![allow(dead_code)] with #![expect(dead_code)] to ensure the attribute becomes a hard error once main() is wired up and items become reachable, forcing cleanup rather than silently suppressing findings. * feat(core): splice generated config tables into marked doc regions * Remove dead_code expectation from update_config_docs The file's test module exercises every function, making the expect(dead_code) attribute unfulfilled under --all-targets. Dead code warnings in non-test builds are expected until main is wired up. * fix: scope dead_code expectation to non-test builds cargo clippy --all-targets compiles both the normal bin target, where the stub main leaves every item unreachable, and the test target, where the tests exercise all of them. An unconditional attribute fails one config or the other. Guarding on not(test) satisfies both, and expect keeps the attribute self-cleaning once main is wired up. * fix(config-docs): don't treat markers shown as examples as live splice() matched BEGIN/END markers on the trimmed line with no regard for fenced code blocks or indentation, so a marker documented as a syntax example (e.g. showing "how to add a config key" inside a fenced block, or indented in a list item) was indistinguishable from a real one. The generator would then silently overwrite or prematurely close a documented example as if it were a live region, corrupting the docs without any error. Require markers to start at column 0 (trailing whitespace still tolerated) and track fenced-code-block state while scanning, ignoring BEGIN/END while inside a fence. The region-body-consuming inner loop tracks fence state too, so an END shown inside a fenced example within a live region no longer closes that region early. * feat(core): add update_config_docs CLI with drift and coverage checks * docs: generate config reference tables from the config registry * docs: clean up two config entry descriptions Fix grammar and redundant wording in the descriptions for BALLISTA_PROPAGATE_EMPTY_ENABLED and BALLISTA_COALESCE_TARGET_PARTITION_BYTES, now that the config docs generator publishes them to the user-facing reference table. * docs: drop redundant join reference in propagate-empty description * ci: check generated config docs stay in sync with the registry * docs: warn readers not to hand-edit generated config tables * docs: use plain prose for the generated-tables notice GitHub alert syntax is not used anywhere else in docs/source and these pages render through myst-parser, where it may show the literal marker instead of an admonition. * ci: install protoc before config-docs-check job config-docs-check runs cargo run -p ballista-core, which triggers build.rs and requires protoc on PATH. The job was modelled on datafusion-proto-sync-check, which never compiles Rust and doesn't need protoc, so that template omitted the install step. Add it, matching the approach already used in docker.yml. * build: run update_config_docs with the ci profile Every other Rust-compiling CI job uses --profile ci to strip debuginfo and keep the rust-cache payload small. Bring update_config_docs.sh in line; the profile only changes build output, not behaviour. * fix: report written files even when the orphan-key check fails run() wrote changed files inside its main loop but only reported them afterwards, in a branch skipped by the early return from the orphan-key check. A contributor who rewrites a description and adds an undocumented key in the same commit would see only the orphan error, with no indication that configs.md had already been rewritten on disk. Print each "updated {path}" line as soon as that file is written so it always surfaces before any later failure. * fix: reject unrecognised arguments instead of defaulting to write mode main() only ever checked for the presence of --check, so any other argument (a typo like --chek, or --help) silently fell through to write mode. In CI that would turn the config-docs-check gate into a permanent no-op pass. Parse arguments explicitly: accept --check, print usage and exit 0 for --help/-h, and exit non-zero with usage for anything else. * fix: collapse newlines in escape_cell All descriptions are single-line today, but nothing stopped a future multi-line description from emitting a literal newline mid-row, silently corrupting the table while still leaving the generator idempotent. Collapse newlines and carriage returns to a single space alongside the existing pipe escaping, and add a test mirroring the existing pipe-escaping invariant test. * fix: point orphan-key error at the right section, not prefix widening The message's primary suggestion was to widen an existing region's prefix=, which a contributor could satisfy by widening all the way to prefix=ballista., dumping every key into one table and duplicating the others. Reword so the primary suggestion is adding the key to the marker region for its own section, and mention prefix widening only as the narrower fallback it is. * docs: document the config docs generator in CONTRIBUTING * docs: regenerate config tables after merging main PR #2124 changed hash_join_max_build_partition_bytes from 0 to 64 MiB and reworded its description. The generated table now reflects that.
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?
Closes #2121.
Performance summary
At TPC-H SF1000 on the reference cluster (AQE on), the 21-query total excluding Q18 drops from 4661.0 s to 3936.8 s, about 16% faster. That puts Ballista at roughly the same speed as vanilla Spark on the same cluster and data:
Rationale for this change
Two related corrections to AQE join selection, plus the documentation needed to measure the result.
1. The byte-size checks did not reflect the side that is actually built
DynamicJoinSelectionExec::to_actual_joinmade two byte-size decisions against inputs that may not be the build side the executor actually runs. Both were raised by @phillipleblanc as non-blocking suggestions on #2084.Broadcast eligibility was OR-ed across both sides.
under_thresholdwassupports_collect_by_thresholds(left) || supports_collect_by_thresholds(right), so the join was promoted toCollectLeftwhen either side passed the byte threshold. Which side actually gets broadcast is decided separately, bySelectJoinRule::supports_swap_join_order, which falls back to row count when byte statistics are absent. So if only the right side passed the byte estimate while the wider-by-bytes left side had fewer rows, AQE would still broadcast the left, replicating more bytes to every probe task than the threshold intended.The hash-join build-fit check measured the pre-swap input.
max_per_partition_build_bytes(&self.left)measuredself.left, butSelectJoinRulemay swap the inputs, makingself.rightthe real build side. The check could therefore validate a partition the build task never consumes, letting an oversized build side through.Both defects share one root cause: the function reasons about
self.left/self.rightwhile the executor runs a possibly-swapped build side. It already computed the swap (forbuild_side_join_type) and threw the result away.2. AQE was re-reading
prefer_hash_join, a planner flag it should not consultThe
(Repartitioned, Partitioned)arm was gated onconfig.optimizer.prefer_hash_join. That is a planner flag: DataFusion applies it when it builds the physical plan, andDelayJoinSelectionRulethen folds both the resultingHashJoinExecandSortMergeJoinExeconto a singleDynamicJoinSelectionExec. By the time AQE runs, the flag has already had its effect, so reading it again applies the same preference twice. It was also applied inconsistently: the twoCollectLeftarms emit a hash join unconditionally, so the gate only ever covered one of the three paths that can produce one.Setting
prefer_hash_join=falsetherefore did not do what a user would expect under AQE. It did not force sort-merge (broadcast joins ignored it), and where it did bite, it sent the plan down a conversion path that discards the join's projection, so it measured a slower plan rather than a different join strategy. That mattered in practice: it is what the benchmarking guide told people to set, and the SF1000 numbers on that page were collected with it.Removing the gate leaves
hash_join_max_build_partition_bytesas the only thing that can lower a Partitioned join to the spillableSortMergeJoin, so its default of0(check disabled) would have made sort-merge unreachable on that arm. The default moves to 64 MiB, the value #2084's own benchmark section had already tuned and validated across the full suite.What changes are included in this PR?
Join selection (
dynamic_join.rs)swap_inputsbinding and derivebuild_sidefrom it (self.rightwhen the swap holds,self.leftotherwise).build_side_join_typereuses it rather than callingsupports_swap_join_ordera second time.build_sidealone instead of OR-ing both sides. This is strictly a tightening: it can only reject broadcasts the OR would have allowed, never introduce one.build_sideinstead ofself.left.prefer_hash_joingate from the(Repartitioned, Partitioned)arm, with a comment recording why the flag is deliberately not consulted.AQE join decisiondebug line reports which side was selected as build.Configuration (
config.rs,extension.rs)ballista.optimizer.hash_join_max_build_partition_bytesnow defaults to 64 MiB instead of0.0still disables the check, and the doc comment and config description now spell out that disabling it leaves the Partitioned arm unconditionally on hash join.SessionConfigExt::with_ballista_hash_join_max_build_partition_bytes, the fluent setter this key was missing.Documentation (
docs/source/contributors-guide/benchmarking.md)prefer_hash_join=false, and explain what actually selects the join strategy under AQE.replaceSortMergeJoin=falsesetting no longer pins the two engines to the same join strategy, so a Ballista-vs-Comet gap may partly reflect a strategy difference.--skip 18, and record that per-query run-to-run spread reached ~20% so small differences should be read as noise. Total (excl. Q18) moves from 4661.0 s to 3936.8 s.prefer_hash_join=trueas a distinct configuration, which is no longer a thing AQE distinguishes, and its numbers came from a different cluster and commit.--skipover a loop of single-query runs when recording a result.Notes on what is deliberately not changed
Byte eligibility does not override the swap (i.e. "build the eligible side even though row count says otherwise").
SelectJoinRulere-derives the swap independently viasupports_swap_join_order, so overriding it here would need the decision threaded through both rules, and the two would silently diverge if either drifts.#2121 also asks for the budget to be applied post-coalesce. It is not, and the reason is ordering:
SelectJoinRuleruns insidereplan_stages(), whileCoalescePartitionsRulefires later inactionable_stages()on the stage the join was just placed into. At decision time there is noCoalescePlanto read, so a coalesce-aware check would have to re-derive the bin-pack over a leaf set that differs from the rule's own for chained joins. That is recorded as a known conservative gap in the doc comment onmax_per_partition_build_bytes;ballista.planner.coalesce.enabledisfalseby default, so it is inert unless coalescing is explicitly turned on. Happy to split it out into a follow-up if reviewers would rather see it addressed.Q18 is recorded as
TBDrather thanOOMin the results table. It was excluded from the suite run with--skip 18and has not since been run to completion at this commit. The earlier OOM observation on this branch was made underprefer_hash_join=false, which no longer describes any supported configuration, so that result does not transfer.Are there any user-facing changes?
Yes, though no breaking API change.
Behavior. Three changes, all narrowing toward what the existing thresholds already intended:
CollectLeft— only when the side that actually gets broadcast fits the byte threshold.hash_join_max_build_partition_bytesfallback to sort-merge now triggers on the swapped-in build side.datafusion.optimizer.prefer_hash_joinno longer affects join selection under AQE. Users who set it tofalseexpecting sort-merge throughout will now get AQE's runtime choice instead; the supported lever isballista.optimizer.hash_join_max_build_partition_bytes.Defaults.
ballista.optimizer.hash_join_max_build_partition_byteschanges from0to 64 MiB, so a Partitioned hash join whose largest build partition exceeds 64 MiB now falls back toSortMergeJoinwhere it previously did not. Setting the key back to0restores the old behavior, at the cost of making sort-merge unreachable on that path.API.
SessionConfigExtgainswith_ballista_hash_join_max_build_partition_bytes. Additive for callers; external implementors of the trait would need to add the method.Test notes
One pre-existing test,
build_over_threshold_falls_back_to_sort_merge_join, had to change its expectation. Its fixture paired a 500 MB leftExchangeExecwith a 16 MBStatisticsExecon the right, so under the fix the swap correctly moves the build onto that 16 MB side, which genuinely fits the 200 MB budget —HashJoinExecis now the right answer, and the oldSortMergeJoinExecassertion was pinning the exact bug being fixed. The other two build-fit tests had the same fixture problem and were passing only because the swap landed on a non-ExchangeExecwhose size is unknown, so nothing was measured at all. All three now use a two-ExchangeExechelper, which is the shape the(Repartitioned, Partitioned)arm always sees in practice.The build-fit tests' shared config now sets
prefer_hash_join = falsedeliberately, the hostile value: AQE must not consult it, so any test below asserting aHashJoinExecwould fail if that ever regressed.join_selection.rs'smake_ctx_without_collect_leftpreviously distinguished its hash-join and sort-merge cases by togglingprefer_hash_join. It now takes a build budget instead, above or below the 10 bytes per shuffle partition the mocks report, which is what actually selects the strategy.New tests: broadcast eligibility following the build side; the fit check measuring the post-swap build; a no-swap mirror case pinning that selection follows the swap decision rather than blanket-switching to the right; and
prefer_hash_joinnot affecting the decision under either setting. The existing default-value test is retargeted at 64 MiB, with a comment on why the default must stay non-zero.