You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[EPIC] Spilling Hash Join — run any join in a bounded memory budget
TL;DR. When the build side of a hash join exceeds the memory budget, DataFusion fails the query. This epic makes HashJoinExec spill to disk instead — behind a default-off flag (enable_hash_join_spilling), with zero change to joins that fit in memory and zero change for anyone running without a memory limit.
📄 Full design doc (step-by-step mechanics, join-type × mechanism matrix, figures): Google Doc (Consolidates #1599, #12952, and the design direction of #17267 into one epic with current status and a PR-sized plan.)
Two terms used throughout. A hash join loads one input entirely into an in-memory hash table (the build side — DataFusion uses the left input) and streams the other input past it (the probe side). Also: DataFusion already splits query execution into target_partitionsoutput partitions; this design further splits each join's build data into 16 buckets — "partition" below always means the former, "bucket" always the latter.
What is going on (symptoms)
On DataFusion 54.0.0, two 20M-row Parquet tables (~1.1 GB raw each), 300 MB FairSpillPool, target_partitions=4:
query (same data, same 300 MB budget)
result
hash join t_probe ⋈ t_build ON k
❌ fails in 0.2 s — Resources exhausted: Failed to allocate additional 95.4 MB for HashJoinInput[3] with 38.2 MB already allocated for this reservation - 51.9 MB remain available for the total memory pool: fair(pool_size: 300.0 MB)
same join via SMJ (prefer_hash_join=false)
✅ 3.7 s — the SortExecs spill (spill_count=16, 155 MB)
hash join, build side shrunk to 10M rows (fits)
✅ 0.7 s
that same fitting join forced through SMJ
✅ 2.5 s — the workaround's tax: 3.5× on a join that fits
count(DISTINCT payload) hash aggregate
✅ 11.1 s (spills)
hash join, build side 12M rows
❌ the fatal allocation is the hash-table build (+57.2 MB with 22.9 MB held)
So today users choose between two bad options: keep prefer_hash_join=true and lose big joins outright, or set it to false and pay ~3.5× on every join that would have fit. Sort, aggregation, sort-merge join, repartition, and (since DF 54) nested-loop join all spill; hash join — the default join — is the last major operator that fails instead. It also makes memory-constrained CI impossible: TPC-H/TPC-DS can't run under a stepped budget because plans die at the first big join.
What is causing the problem
collect_left_input() (hash_join/exec.rs)
batches: Vec<RecordBatch> ◄── every build batch appended; reservation.try_grow(batch)
... per batch — on failure the query dies (no reaction path)
concat_batches(...) ◄── then ONE contiguous batch
JoinHashMapU32/U64 ◄── then ONE hash table sized for all rows
(the +57 MB allocation that actually fails in practice)
The build phase has no reaction to memory pressure, and the consumer isn't registered with_can_spill(true), so FairSpillPool can't even treat it fairly. Meanwhile the surrounding infrastructure is ready: SpillManager (IPC spill files, compression, disk quotas, metrics), SpillPool, ReplayableStreamSource, and an in-tree precedent of a join reacting to ResourcesExhausted by switching into a spill mode (DF 54's NLJ). Only the hash-join algorithm on top is missing.
The high-level solution sketch
First, the two modes DataFusion runs hash joins in, because the design leans on one of them. CollectLeft (small builds): build the table once, share it read-only across all probe streams. Partitioned (large builds — the mode that hits memory limits): both inputs are hash-redistributed across the target_partitions output partitions, and each output partition builds and probes its own private table over its own disjoint slice of keys, in a single thread. That privacy is the key insight: each output partition can spill and recover independently. The cross-thread coordination that DuckDB and Velox spend most of their spill machinery on — and the main blocker named in #17267 — does not arise, because DataFusion's plan already did the dividing. Fairness between partitions is the memory pool's existing job.
Within one output partition, the join becomes a hybrid hash join — "hybrid" meaning it keeps as much of the build side in memory as fits and spills only the remainder (vs. Grace-style, which partitions everything to disk up front):
BUILD route rows into 16 buckets by high bits of the join hash
┌────┬────┬────┬────┬───┐ on memory pressure: destage the largest
│ B0 │ B1 │ B2 │ B3 │ … │ bucket (move its buffered rows to disk)
└────┴────┴────┴────┴───┘ + build a Bloom filter of that bucket's hashes
mem mem disk mem
end of input: ONE JoinHashMap over the resident buckets
PROBE hash once, route: resident rows → probe the map now (unchanged code path)
rows for a spilled bucket → Bloom test:
negative → resolved immediately (provably matches nothing:
drop, or emit null-padded for Right/Full)
positive → append to probe.Bi.spill (only rows that might match)
CLEANUP for each spilled pair (build.Bi, probe.Bi):
restore build.Bi → fits? build its map, stream probe.Bi through
the normal probe path
→ too big? re-split with the next window of hash
bits (≤ 4 levels; capacity ≈ share × 16^level)
→ won't shrink (all-equal keys)? chunked build +
probe replay (the DF 54 NLJ pattern)
role reversal: if probe.Bi ≪ build.Bi, build the map from the
probe file instead and stream the build file through it
What runs before the first spill — exactly today's code. This is the zero-regression guarantee, and it is structural, not aspirational: while memory suffices, batches append to a single list — no routing, no hashing, no extra copies, no files. The first time try_grow fails (or an optional watermark trips), the stream computes the join hashes for the batches buffered so far, splits them into 16 buckets, destages the largest, and only from that moment routes incoming batches. With no memory limit configured (the default), that transition is unreachable and the operator is bit-for-bit unchanged.
Disk footprint. Only spilled buckets create files — one build + one probe file each — so a worst-case level is 32 files per output partition (128 at target_partitions=4; a few thousand at most on a 32-core machine with everything spilling). A pair's files are deleted as soon as it is processed, recursion drains a parent before creating children, and total bytes are already bounded by DiskManager's max_temp_directory_size.
Scope decision: CollectLeft mode
The title says any join; phase 1 implements spilling for Partitioned mode only — handled in the open rather than buried in non-goals: when enable_hash_join_spilling=true, the physical planner steers joins to Partitioned unless the build side is provably below the CollectLeft threshold (part of T8). With the flag on, the joins that can hit the limit are exactly the joins that can spill. A statistics misestimate can still land an oversized build in CollectLeft; that keeps today's failure behavior, is called out in the config docs, and coordinated CollectLeft spilling is the first follow-up issue. Relatedly, this epic makes no change to prefer_hash_join — but once spilling is default-on, prefer_hash_join=false stops being the memory-safety workaround (the symptoms table shows why you don't want it to be).
Grace hash join: bucket 0 in memory, all other buckets spill both sides up front
Sequential bucket processing — plus two warnings: it is not adaptive, and it shipped INNER/LEFT-only for years (join-type completeness must be day-one)
Revocable memory; a subset of build partitions spills with matching probe rows
The hybrid "spill some, keep the rest hot" discipline
No engine that solved this well made spilling a plan-time decision, and none used sort-fallback as the primary design — runtime-adaptive partitioned hybrid hash join is the consensus.
Alternatives considered
Alternative
Why not
Sort-based fallback (Spark-style)
Gives up O(1) probes; SMJ already exists as the manual fallback — and costs 3.5× on fitting joins (measured above)
Grace up-front (ClickHouse-style)
Pays partitioning + spill even when memory would have sufficed — violates zero-regression
Whole-build spill + probe replay (NLJ-style) as primary
Replay cost multiplies with the build/memory ratio; kept only as the skew fallback where re-splitting provably cannot help
Symmetric / early probing (start probing before build completes)
Must retain probe rows for late-arriving matches ⇒ buffers both sides — strictly worse memory; also forfeits build-side dynamic-filter pushdown
Probe re-scan instead of probe spill
The probe child is an arbitrary subtree — re-running it once per restore group is unbounded, and unsound if non-deterministic; kept as an opt-in follow-up for re-scannable leaf scans
Deeper treatment of the three closest calls (sparse-match economics via Bloom/role-reversal, why probe starts after build, spill vs. re-scan) is in the design doc §6.11–6.13.
What it costs (rule of thumb)
Nothing spilled → nothing paid. Same code path as today; and with no memory limit configured, the spill machinery is structurally unreachable.
Spilling: pay only for what didn't fit. Every spilled build byte is written once and read back once (≈ 2× the spilled bytes); probe-side spill is only the rows that might match a spilled bucket — with the Bloom filters, close to the true match volume. CPU stays linear; peak memory stays under the budget by construction. (Spilled rows carry their 8-byte join hash, ~10–15% extra spill volume, so restore/routing/Bloom never re-hash.)
Capacity: ×16 per recursion level. A 75 MB share covers a multi-TB build side within 3–4 levels; all-equal-key skew falls back to chunked build + probe replay — slower but unbounded (an equal-key join's output is inherently quadratic anyway).
Versus today's workaround: SMJ sorts and spills both complete sides no matter how close the build was to fitting; hybrid hash pays proportionally to the miss — ~3 GB of sequential spill traffic in the experiment above instead of query failure, and zero when it fits.
Why this is hard to fix (and how each part is handled)
Join-type completeness is where the bodies are buried. Unmatched-build emission needs per-bucket visited bitmaps; Right/Full need Bloom-negative early emission; null-aware anti needs build-side null knowledge over all rows including spilled ones. ClickHouse shipping INNER/LEFT-only for years is the cautionary tale. The full join-type × mechanism table is in the design doc (§6.6); the test matrix is this epic's definition of done, not a fast-follow.
The fatal allocation is the hash table, not the batches (measured above) — so the design reserves explicit table-construction headroom (hash_join_spill_reservation_bytes, the sort_spill_reservation_bytes lesson).
Ordering property.HashJoinExec currently advertises probe-side order preservation for Inner/Right joins; cleanup emission breaks that, so with the flag on the operator stops advertising it — which can make the planner insert an explicit sort downstream even for queries that never spill. That is why the property change is tied to the flag (off ⇒ zero plan changes), T8 includes a TPC-H plan-diff audit with the flag on, and the config docs call it out.
Dynamic-filter interplay, precisely. Dynamic filters are installed at plan time but populated once, at build completion — which is also exactly when we know what spilled, so the decision is made at a single well-defined moment, never mid-scan. Min/max bounds and InList filters are unaffected (accumulated over all rows, spilled included); only the full-map membership variant is unavailable when something spilled, and a per-bucket Bloom membership filter is its natural follow-up (T7 builds the Blooms anyway). Worst case: queries that previously failed outright lose one pushdown variant.
Batch-splitting cost (PROPOSAL Hash Join Spilling Proposal #17267's "dual take") — only paid after the first destage, and measured by a dedicated benchmark PR (T2) before the core lands.
T4 (L) — core: INNER equi-join end-to-end, single level. Acceptance: the failing join from the symptoms table completes under 300 MB in ≤ 2× the SMJ-workaround time (≤ ~7.5 s), and no-spill join benchmarks stay within 2% of main
T5 (L) — full join-type matrix (per-bucket bitmaps; Right/Full early emission; null-aware anti) + spill-forcing mode for the join fuzzer
T7 (M) — Bloom filters: negative-row early resolution in pass 1 + role reversal for Inner/Semi
T8 (S/M) — planner + property integration: CollectLeft-steering rule when the flag is on; drop probe-side maintains_input_order under the flag; membership→bounds/InList downgrade; TPC-H plan-diff audit with the flag on
T10 (S) — metrics (SpillMetrics + spilled_buckets, spill_recursion_depth) in EXPLAIN ANALYZE; user-guide memory section; degradation-curve write-up
T11 (M) — CI: TPC-H SF1 under stepped budgets (4 GB → 256 MB). Path to default-on, stated up front: flip enable_hash_join_spilling when (a) the force-spill suite has been green for two consecutive releases, (b) the fuzzer is clean, (c) spilled hybrid ≤ the SMJ workaround on the benchmark matrix, and (d) no-spill regression ≤ 2% — proposed and decided on this issue
Bucket count: 16 per level, or larger fan-out? (Routing cost and file count vs. recursion depth — T2/T4 benchmarks decide.)
When to start spilling: only when an allocation is actually refused (try_grow fails), or slightly earlier — e.g. once the join has used ~80% of the memory it can expect to get — so there is still headroom for the split work and the hash-table build? (The early trigger is only meaningful under FairSpillPool, where each consumer has a defined share; under GreedyMemoryPool the failure reaction is the trigger either way.)
Probe spill files: plain per-bucket InProgressSpillFile (phase 1) vs. SpillPool rotation for very large probes.
[EPIC] Spilling Hash Join — run any join in a bounded memory budget
TL;DR. When the build side of a hash join exceeds the memory budget, DataFusion fails the query. This epic makes
HashJoinExecspill to disk instead — behind a default-off flag (enable_hash_join_spilling), with zero change to joins that fit in memory and zero change for anyone running without a memory limit.📄 Full design doc (step-by-step mechanics, join-type × mechanism matrix, figures): Google Doc
(Consolidates #1599, #12952, and the design direction of #17267 into one epic with current status and a PR-sized plan.)
Two terms used throughout. A hash join loads one input entirely into an in-memory hash table (the build side — DataFusion uses the left input) and streams the other input past it (the probe side). Also: DataFusion already splits query execution into
target_partitionsoutput partitions; this design further splits each join's build data into 16 buckets — "partition" below always means the former, "bucket" always the latter.What is going on (symptoms)
On DataFusion 54.0.0, two 20M-row Parquet tables (~1.1 GB raw each), 300 MB
FairSpillPool,target_partitions=4:t_probe ⋈ t_build ON kResources exhausted: Failed to allocate additional 95.4 MB for HashJoinInput[3] with 38.2 MB already allocated for this reservation - 51.9 MB remain available for the total memory pool: fair(pool_size: 300.0 MB)prefer_hash_join=false)SortExecs spill (spill_count=16, 155 MB)count(DISTINCT payload)hash aggregate+57.2 MBwith22.9 MBheld)So today users choose between two bad options: keep
prefer_hash_join=trueand lose big joins outright, or set it tofalseand pay ~3.5× on every join that would have fit. Sort, aggregation, sort-merge join, repartition, and (since DF 54) nested-loop join all spill; hash join — the default join — is the last major operator that fails instead. It also makes memory-constrained CI impossible: TPC-H/TPC-DS can't run under a stepped budget because plans die at the first big join.What is causing the problem
The build phase has no reaction to memory pressure, and the consumer isn't registered
with_can_spill(true), soFairSpillPoolcan't even treat it fairly. Meanwhile the surrounding infrastructure is ready:SpillManager(IPC spill files, compression, disk quotas, metrics),SpillPool,ReplayableStreamSource, and an in-tree precedent of a join reacting toResourcesExhaustedby switching into a spill mode (DF 54's NLJ). Only the hash-join algorithm on top is missing.The high-level solution sketch
First, the two modes DataFusion runs hash joins in, because the design leans on one of them. CollectLeft (small builds): build the table once, share it read-only across all probe streams. Partitioned (large builds — the mode that hits memory limits): both inputs are hash-redistributed across the
target_partitionsoutput partitions, and each output partition builds and probes its own private table over its own disjoint slice of keys, in a single thread. That privacy is the key insight: each output partition can spill and recover independently. The cross-thread coordination that DuckDB and Velox spend most of their spill machinery on — and the main blocker named in #17267 — does not arise, because DataFusion's plan already did the dividing. Fairness between partitions is the memory pool's existing job.Within one output partition, the join becomes a hybrid hash join — "hybrid" meaning it keeps as much of the build side in memory as fits and spills only the remainder (vs. Grace-style, which partitions everything to disk up front):
What runs before the first spill — exactly today's code. This is the zero-regression guarantee, and it is structural, not aspirational: while memory suffices, batches append to a single list — no routing, no hashing, no extra copies, no files. The first time
try_growfails (or an optional watermark trips), the stream computes the join hashes for the batches buffered so far, splits them into 16 buckets, destages the largest, and only from that moment routes incoming batches. With no memory limit configured (the default), that transition is unreachable and the operator is bit-for-bit unchanged.Disk footprint. Only spilled buckets create files — one build + one probe file each — so a worst-case level is 32 files per output partition (128 at
target_partitions=4; a few thousand at most on a 32-core machine with everything spilling). A pair's files are deleted as soon as it is processed, recursion drains a parent before creating children, and total bytes are already bounded byDiskManager'smax_temp_directory_size.Scope decision: CollectLeft mode
The title says any join; phase 1 implements spilling for
Partitionedmode only — handled in the open rather than buried in non-goals: whenenable_hash_join_spilling=true, the physical planner steers joins toPartitionedunless the build side is provably below the CollectLeft threshold (part of T8). With the flag on, the joins that can hit the limit are exactly the joins that can spill. A statistics misestimate can still land an oversized build in CollectLeft; that keeps today's failure behavior, is called out in the config docs, and coordinated CollectLeft spilling is the first follow-up issue. Relatedly, this epic makes no change toprefer_hash_join— but once spilling is default-on,prefer_hash_join=falsestops being the memory-safety workaround (the symptoms table shows why you don't want it to be).How other engines solve it
force_external)No engine that solved this well made spilling a plan-time decision, and none used sort-fallback as the primary design — runtime-adaptive partitioned hybrid hash join is the consensus.
Alternatives considered
Deeper treatment of the three closest calls (sparse-match economics via Bloom/role-reversal, why probe starts after build, spill vs. re-scan) is in the design doc §6.11–6.13.
What it costs (rule of thumb)
Why this is hard to fix (and how each part is handled)
hash_join_spill_reservation_bytes, thesort_spill_reservation_byteslesson).HashJoinExeccurrently advertises probe-side order preservation for Inner/Right joins; cleanup emission breaks that, so with the flag on the operator stops advertising it — which can make the planner insert an explicit sort downstream even for queries that never spill. That is why the property change is tied to the flag (off ⇒ zero plan changes), T8 includes a TPC-H plan-diff audit with the flag on, and the config docs call it out.Past attempts and prior work
SpillManagercompression,SpillPoolrotation,ReplayableStreamSource, disk quotasProposed next steps (one reviewable PR each)
collect_left_input→BuildSideBuffer(no behavior change; bench-verified ≤ noise)bucket_indices(hashes, bit_window)+take) with criterion benchmarksBucketedBuildBuffer: 16 buckets, Phase-A→Phase-B transition, destage-largest,SpillManagerfiles,with_can_spill(true)(flag-gated)mainmaintains_input_orderunder the flag; membership→bounds/InList downgrade; TPC-H plan-diff audit with the flag onSpillMetrics+spilled_buckets,spill_recursion_depth) inEXPLAIN ANALYZE; user-guide memory section; degradation-curve write-upenable_hash_join_spillingwhen (a) the force-spill suite has been green for two consecutive releases, (b) the fuzzer is clean, (c) spilled hybrid ≤ the SMJ workaround on the benchmark matrix, and (d) no-spill regression ≤ 2% — proposed and decided on this issueDependency order: T0–T2 parallel; T3 → T4 → {T5, T6, T7} → T8–T11.
Related issues
Open questions
try_growfails), or slightly earlier — e.g. once the join has used ~80% of the memory it can expect to get — so there is still headroom for the split work and the hash-table build? (The early trigger is only meaningful underFairSpillPool, where each consumer has a defined share; underGreedyMemoryPoolthe failure reaction is the trigger either way.)InProgressSpillFile(phase 1) vs.SpillPoolrotation for very large probes.References
DuckDB PR #4189 · "Saving Private Hash Join" VLDB '25 · Velox spilling · ClickHouse grace_hash · SPARK-32634 · Trino spill · Kitsuregawa '83 (GRACE); DeWitt '84 / Shapiro '86 (hybrid hash); Graefe et al. VLDB '98 (dynamic destaging, role reversal). Experiment scripts: design doc, Appendix A.