Skip to content

fix: Various fixes for AQE join selection#2124

Merged
andygrove merged 7 commits into
apache:mainfrom
andygrove:aqe-join-build-side
Jul 22, 2026
Merged

fix: Various fixes for AQE join selection#2124
andygrove merged 7 commits into
apache:mainfrom
andygrove:aqe-join-build-side

Conversation

@andygrove

@andygrove andygrove commented Jul 21, 2026

Copy link
Copy Markdown
Member

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:

Engine Total (excl. Q18)
Spark 3.5.3 (vanilla) 3914.7 s
Comet 3705.0 s
Ballista (AQE on) 3936.8 s

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_join made 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_threshold was supports_collect_by_thresholds(left) || supports_collect_by_thresholds(right), so the join was promoted to CollectLeft when either side passed the byte threshold. Which side actually gets broadcast is decided separately, by SelectJoinRule::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) measured self.left, but SelectJoinRule may swap the inputs, making self.right the 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.right while the executor runs a possibly-swapped build side. It already computed the swap (for build_side_join_type) and threw the result away.

2. AQE was re-reading prefer_hash_join, a planner flag it should not consult

The (Repartitioned, Partitioned) arm was gated on config.optimizer.prefer_hash_join. That is a planner flag: DataFusion applies it when it builds the physical plan, and DelayJoinSelectionRule then folds both the resulting HashJoinExec and SortMergeJoinExec onto a single DynamicJoinSelectionExec. 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 two CollectLeft arms emit a hash join unconditionally, so the gate only ever covered one of the three paths that can produce one.

Setting prefer_hash_join=false therefore 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_bytes as the only thing that can lower a Partitioned join to the spillable SortMergeJoin, so its default of 0 (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)

  • Hoist the swap decision into a swap_inputs binding and derive build_side from it (self.right when the swap holds, self.left otherwise). build_side_join_type reuses it rather than calling supports_swap_join_order a second time.
  • Broadcast eligibility now tests build_side alone instead of OR-ing both sides. This is strictly a tightening: it can only reject broadcasts the OR would have allowed, never introduce one.
  • The build-fit check measures build_side instead of self.left.
  • Drop the prefer_hash_join gate from the (Repartitioned, Partitioned) arm, with a comment recording why the flag is deliberately not consulted.
  • The AQE join decision debug line reports which side was selected as build.

Configuration (config.rs, extension.rs)

  • ballista.optimizer.hash_join_max_build_partition_bytes now defaults to 64 MiB instead of 0. 0 still disables the check, and the doc comment and config description now spell out that disabling it leaves the Partitioned arm unconditionally on hash join.
  • Add SessionConfigExt::with_ballista_hash_join_max_build_partition_bytes, the fluent setter this key was missing.

Documentation (docs/source/contributors-guide/benchmarking.md)

  • Stop instructing readers to pass prefer_hash_join=false, and explain what actually selects the join strategy under AQE.
  • Note that the Comet replaceSortMergeJoin=false setting no longer pins the two engines to the same join strategy, so a Ballista-vs-Comet gap may partly reflect a strategy difference.
  • Replace the SF1000 result column with numbers from a single 21-query suite run using --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.
  • Drop the separate "hash join with a per-partition build-size fallback" section: it described prefer_hash_join=true as a distinct configuration, which is no longer a thing AQE distinguishes, and its numbers came from a different cluster and commit.
  • Recommend --skip over 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"). SelectJoinRule re-derives the swap independently via supports_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: SelectJoinRule runs inside replan_stages(), while CoalescePartitionsRule fires later in actionable_stages() on the stage the join was just placed into. At decision time there is no CoalescePlan to 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 on max_per_partition_build_bytes; ballista.planner.coalesce.enabled is false by 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 TBD rather than OOM in the results table. It was excluded from the suite run with --skip 18 and has not since been run to completion at this commit. The earlier OOM observation on this branch was made under prefer_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:

  • Fewer joins are promoted to CollectLeft — only when the side that actually gets broadcast fits the byte threshold.
  • The hash_join_max_build_partition_bytes fallback to sort-merge now triggers on the swapped-in build side.
  • datafusion.optimizer.prefer_hash_join no longer affects join selection under AQE. Users who set it to false expecting sort-merge throughout will now get AQE's runtime choice instead; the supported lever is ballista.optimizer.hash_join_max_build_partition_bytes.

Defaults. ballista.optimizer.hash_join_max_build_partition_bytes changes from 0 to 64 MiB, so a Partitioned hash join whose largest build partition exceeds 64 MiB now falls back to SortMergeJoin where it previously did not. Setting the key back to 0 restores the old behavior, at the cost of making sort-merge unreachable on that path.

API. SessionConfigExt gains with_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 left ExchangeExec with a 16 MB StatisticsExec on the right, so under the fix the swap correctly moves the build onto that 16 MB side, which genuinely fits the 200 MB budget — HashJoinExec is now the right answer, and the old SortMergeJoinExec assertion 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-ExchangeExec whose size is unknown, so nothing was measured at all. All three now use a two-ExchangeExec helper, which is the shape the (Repartitioned, Partitioned) arm always sees in practice.

The build-fit tests' shared config now sets prefer_hash_join = false deliberately, the hostile value: AQE must not consult it, so any test below asserting a HashJoinExec would fail if that ever regressed.

join_selection.rs's make_ctx_without_collect_left previously distinguished its hash-join and sort-merge cases by toggling prefer_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_join not 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.

`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
@andygrove andygrove changed the title fix: measure the actual build side in AQE join selection fix: measure the actual build side in AQE join selection [WIP] Jul 21, 2026
@andygrove andygrove changed the title fix: measure the actual build side in AQE join selection [WIP] fix: Various fixes for AQE join selection [WIP] Jul 21, 2026
…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.
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Jul 21, 2026
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
andygrove marked this pull request as ready for review July 21, 2026 20:27
@andygrove
andygrove marked this pull request as draft July 21, 2026 20:32
@andygrove
andygrove marked this pull request as ready for review July 21, 2026 21:58
@andygrove andygrove changed the title fix: Various fixes for AQE join selection [WIP] fix: Various fixes for AQE join selection Jul 21, 2026
@andygrove

Copy link
Copy Markdown
Member Author

@phillipleblanc fyi

@phillipleblanc phillipleblanc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @andygrove!

@avantgardnerio avantgardnerio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
andygrove merged commit 2c93670 into apache:main Jul 22, 2026
23 checks passed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AQE join selection: byte-size checks don't reflect the post-swap / post-coalesce build side

3 participants