jayzhan211 opened a new pull request, #25217:
URL: https://github.com/apache/datafusion/pull/25217
## Which issue does this PR close?
- Part of #24768. Related to #1599 and #12952.
This does not close them. It is the interim sort-based path, not the
dynamically
partitioned hybrid hash join that #24768 proposes, and it deliberately leaves
`CollectLeft` mode out of scope.
## Rationale for this change
A hash join whose build side exceeds the memory budget fails the query with
`Resources exhausted`, even though the same join completes when the planner
is
told to use a sort-merge join instead, because its sorts spill. Users are
left
choosing between two bad options: keep `prefer_hash_join = true` and lose
large
joins outright, or set it to `false` and pay for a sort on every join,
including
the ones that would have fit in memory comfortably.
Sort, aggregation, sort-merge join, repartition, and nested-loop join all
spill.
Hash join, the default join, is the last major operator that fails instead.
This makes the decision at runtime rather than at plan time. A join stays a
hash
join while its build side fits, and becomes a sort-merge join only in the
partitions where it does not.
On a 240k x 160k row join through a 512 KB pool, median of five runs each:
| variant | time |
|---|---|
| hash join, no memory limit | 107 ms |
| hash join under the limit, falls back | 376 ms |
| `prefer_hash_join = false`, planned sort-merge, same limit | 380 ms |
| planned sort-merge, no limit | 301 ms |
The fallback costs about what today's workaround costs, roughly 3.5x the
in-memory join, but it is paid only by the partitions that actually run out
of
memory, and without having to predict that in advance.
## Before and after, and what to set
`EXPLAIN ANALYZE` shows which case you are in: a partitioned hash join now
reports `sort_merge_fallback_count` next to its `spill_count`.
| Situation | Before | After | Action |
|---|---|---|---|
| Memory limit set, build side fits | works | identical | none |
| Memory limit set, build side too big, temp files enabled | `Resources
exhausted` | completes as a sort-merge join | none, automatic |
| Memory limit set, budget too small for the sorts | fails in the join |
still fails, now in the sort | raise the limit, or lower
`sort_spill_reservation_bytes` |
| No memory limit configured | build side grows unbounded | unchanged, no
reservation ever fails | `hash_join_max_build_size` |
| Join lands in `CollectLeft` mode over budget | `Resources exhausted` |
unchanged | keep `prefer_hash_join = false` |
## Which cases can fall back, and which are covered here
A hash join can be asked to switch to a sort-merge join in more situations
than
one PR should try to handle. The cases, and where each one stands:
| Case | In this PR | Why, or what it still needs |
|---|---|---|
| `PartitionMode::Partitioned`, all ten join types | ✅ | Each partition owns
a disjoint slice of the key space on both sides, so it sorts and merges alone
and every join type stays correct |
| Trigger: reservation fails while buffering build batches | ✅ | |
| Trigger: reservation fails building the hash table or bitmaps, on batches
that did fit | ✅ | |
| Trigger: build side grows past `hash_join_max_build_size` | ✅ | The only
trigger available under the default, unbounded memory pool |
| Join filter, embedded projection, `fetch` | ✅ | Applied to the merge
output exactly as the hash join applies them to its own |
| Dynamic filter pushdown active on the join | ✅ | The partition reports
bounds only, through the new `PushdownStrategy::Unknown` |
| `CollectLeft`, probe-preserving types: Inner, Right, RightSemi, RightAnti,
RightMark | ❌ | Follow-up 1. The single shared build side has to be sorted once
and materialized so every probe partition can re-read it |
| `CollectLeft`, build-preserving types: Left, Full, LeftSemi, LeftAnti,
LeftMark | ❌ | Follow-up 2. Unmatched build rows require all probe partitions
to agree on what matched, so restore ownership has to be coordinated |
| Join advertises a probe-side output ordering | ❌ | Follow-up 3. A merge
emits in join-key order, so the promise needs either an extra sort or to be
dropped when the fallback is reachable |
| Null-aware (`NOT IN`) joins | ❌ | Not planned. The sort-merge streams have
no three-valued `NOT IN` semantics |
| Join keys the row format cannot sort | ❌ | Not planned. Bounded by what
arrow-rs `RowConverter` supports |
| Spilling unavailable, `DiskManager` disabled | ❌ | By design. There is
nothing to fall back to, so today's behavior is kept |
## What changes are included in this PR?
Runtime fallback in `HashJoinExec`:
- Collecting the build side now yields either an in-memory hash table or a
sorted build side. When the join's own reservation fails with
`ResourcesExhausted`, that partition sorts its build side with
`ExternalSorter`, sorts the probe side, and finishes by merging them.
- A new `HashJoinStreamState::SortMergeFallback` forwards that output. Both
OOM
sites are covered: mid-stream while buffering batches, and afterwards when
the
hash table or a bitmap does not fit on top of batches that did.
- `SortMergeJoinExec::execute` is factored into a shared
`sort_merge_join_stream`, so the fallback runs the real sort-merge code
path
rather than a copy of it. Projection and `fetch` are applied to its output
exactly as the hash join applies them to its own.
- The fallback engages only when disk spilling is available, so
`DiskManagerMode::Disabled` keeps today's behavior.
New option `datafusion.execution.hash_join_max_build_size`
(`Option<usize>`, default `NULL` meaning no limit):
- Triggers the same switch once a partition's build side grows past the given
size, whether or not a memory limit is configured. Under the default
unbounded
memory pool no reservation ever fails, so this is the only available
trigger.
- It is not required for memory safety. With
`datafusion.runtime.memory_limit`
set, the fallback already happens on its own.
- Documented as a short-term measure that may be deprecated and removed once
hash joins spill natively.
Supporting changes:
- New metric `sort_merge_fallback_count`, plus the fallback's spills reported
through the join's own spill metrics, both visible in `EXPLAIN ANALYZE`.
- `PushdownStrategy::Unknown`, so a build side that holds rows but has no
hash
table to test membership against pushes down bounds only. Reporting `Empty`
there would give the partition a `false` branch in the routed dynamic
filter
and silently drop every match it owns.
- `ExternalSorter` and the sort-merge join stream constructors are now
`pub(crate)`. No public API changes.
Also fixes a latent sort-merge join bug found while building this:
`get_filter_columns` assembled filter columns as all-left-then-all-right,
which
is wrong for the column order `JoinFilter::swap` produces. Only this
fallback can
reach it today, because the planner never swaps a `SortMergeJoinExec`.
### Scope
Only `PartitionMode::Partitioned` joins fall back. There each partition owns
a
disjoint slice of the key space on both sides, so it can sort and merge
independently of its siblings and every join type stays correct.
`CollectLeft`
joins, null-aware joins, and joins that promise to preserve their probe-side
ordering are untouched and still fail under memory pressure.
### Known limitation
The fallback replaces a build side that must fit entirely in memory with a
much
smaller requirement, but not with none. Each sort pre-reserves
`sort_spill_reservation_bytes` so its merge phase can always run, and a
falling-back partition runs two sorts. A budget that cannot cover those
reservations plus the sorts' working set still fails, and it now fails
inside the
sort rather than in the hash join.
Measured on a join with roughly 12 MB of build side per partition, two
partitions: with the default 10 MB reservation no budget worked, because any
budget small enough to trigger the fallback was too small to pay for the
reservations. At a 1 MB reservation the same query succeeded from 16 MB
upward.
The fixed reservation matters proportionally less as the build side grows,
which
is the case this feature targets, but the floor is real. Sizing the
fallback's
sorts from the budget actually available, rather than inheriting the global
default, is follow-up 8.
Partitions also share one pool without coordinating, so a partition that
falls
back does not make a sibling that still fits release its hash table. That
narrows
the usable range further, though whether one or both partitions fall back is
not
by itself decisive: a single falling-back partition completes when the budget
covers its sorts.
## Follow-up PRs
Each is independent of the others and of #24768's hybrid hash join work.
1. **`CollectLeft`, probe-preserving join types.** Sort the shared build side
once and materialize it to a spill file that every probe partition reads.
No cross-partition coordination needed.
2. **`CollectLeft`, build-preserving join types.** Coordinate which partition
restores and merges, so unmatched build rows are emitted once. Carries the
same distributed-execution caveat as `enable_nlj_coordinated_fallback`.
3. **Keep the advertised probe-side ordering.** Re-sort the merge output on
the
promised ordering instead of declining the fallback for those joins.
4. **Make the fallback contagious within one join,** so a partition that
gives up
its hash table is not competing with a resident sibling.
5. **Residual sort-merge OOM.** The streamed side and the join-key arrays of
a
sort-merge join still never spill, so a very tight budget fails even after
falling back. See #17429 and #19013.
6. **`SMJStream` registers `can_spill = false`** although it spills. One-line
fix, affects `FairSpillPool` accounting.
7. **Memory-limited join benchmarks.** A profile that runs joins under fixed
budgets and reports spill metrics, so the behaviour above is tracked in CI
rather than measured by hand. Overlaps #24768's first task.
8. **Size the fallback's sorts from the available budget.** They currently
inherit `sort_spill_reservation_bytes`, so with the 10 MB default a small
pool
is consumed by reservations before the sorts can run.
## What is the testing strategy for this PR?
- `datafusion/physical-plan/src/joins/hash_join/exec.rs`: all ten join types,
with and without a join filter, under a 6 KB pool, each asserted to return
the
same rows as the in-memory join and to have actually fallen back and
spilled.
Plus projection and `fetch`, the disk-disabled path, a test that falls back
purely on `hash_join_max_build_size` with no memory limit, and a guard
that a
fallen-back partition leaves its pushed-down dynamic filter permissive.
That
last one was written after mutation testing showed that reporting `Empty`
instead of `Unknown` passed every other test; `shared_bounds` carries two
matching tests for the pruning semantics of the two reports.
- `datafusion/core/tests/memory_limit/hash_join_sort_merge_fallback.rs`: a
single table-driven `config_matrix` test covering the five configurations
that
decide between a plain hash join, the fallback, and outright failure,
asserting
both the answer and which algorithm ran. Alongside it, end-to-end SQL
coverage
of inner, filtered outer, semi, anti and `IN` subqueries, plus `LIMIT`. The
memory-limited budget and `sort_spill_reservation_bytes` were chosen by
measurement: the committed pair ran 12 consecutive times with both
partitions
falling back and correct results every time.
- `datafusion/core/tests/fuzz_cases/join_fuzz.rs`: a fuzz test comparing the
fallback against the in-memory hash join across join types, filters and
batch
sizes under a 16 KB pool.
- `datafusion/physical-plan/src/joins/sort_merge_join/tests.rs`: a regression
test for the filter column order fix.
Performance: benchmarked with `hash_join_semi_anti` (criterion) against the
parent commit, median +0.36% with a range of -1.43% to +3.08%. Re-running the
*parent* commit against its own baseline gives median +0.34% and a range of
-0.67% to +2.80%, so that spread is this machine's run-to-run variance rather
than an effect of the change. The three build-heavy cases, which cover the
rewritten build loop, were flat to slightly faster.
The full `sqllogictest` suite passes, all 516 files, including the one case
that
asserts partitioned hash join metrics.
## Are there any user-facing changes?
Yes, two.
Queries that previously failed with `Resources exhausted` in a partitioned
hash
join now complete by falling back, provided disk spilling is enabled and the
budget covers the sorts. Nothing changes for joins that fit in memory, or for
anyone running without a memory limit and without the new option set.
One new configuration option,
`datafusion.execution.hash_join_max_build_size`,
documented in the configuration guide and marked as possibly temporary. No
breaking changes to public APIs.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]