Repository navigation
Conversation
SpillingHashJoinExec wraps the HashJoinExec that Comet plans for a shuffled hash join. While the build side fits in memory it runs that join unchanged. When the memory pool refuses, it routes both sides to 16 buckets by a hash of the join keys, destages the largest buckets to disk, joins the resident buckets with the probe input, and then joins each spilled bucket from its files. A bucket that does not fit is split on the next bits of the hash, and one that splitting cannot shrink is joined in chunks. Each bucket is joined by a stock HashJoinExec, so the operator implements no join semantics of its own. Everything the operator and its inner joins hold is reserved under one memory consumer, including what HashJoinExec allocates without reserving.
spark.comet.exec.hashJoin.spill.enabled, on by default, runs a native shuffled hash join with SpillingHashJoinExec. The serde enables it only for shuffled hash joins that are not null-aware, do not use the join runtime filter, and whose output order Spark does not rely on, because rows of spilled buckets are emitted after the others. spark.comet.exec.hashJoin.spill.maxBuildBufferBytes makes a join spill once its build side holds that many bytes, for tests. CometHashJoinExec declares the join's spill metrics, so its spills count in the task's spill totals.
It runs one task's join of CometHashJoinMemoryBenchmark over in-memory batches, so that the cost of spilling is not diluted by scans and shuffles: a build side of unique keys with a string payload, and a probe side five times as long, half of whose rows find a match. The cases are the plain join and the spilling join with no memory limit, the spilling join under pools of 64, 40, 20 and 10 MiB with lz4 spill files, and at 20 MiB with zstd and with uncompressed files. Each case prints what it spilled before it is timed.
Routing splits every input batch into one part per bucket, a sixteenth of a batch, and a split bucket's parts are split again. Each part was written to its file as a batch of its own, so spill files held slivers of about 512 rows, or 32 once split. Every one cost an IPC message and an lz4 frame per buffer, and on reading back a decode and a probe batch of the inner join. A spill file's writer now holds its parts, reserved, until they make a batch of the session's batch size or 256 KiB, and writes them as one. When the pool refuses the memory, it writes what it holds at once. A batch read back from a spill file can hold up to twice the memory it held when written: DataFusion reads spill files in chunks of 128 KiB, and arrow assembles a message that spans chunks in a buffer that grows by doubling. With full batches nearly every message spans chunks. The build side of a spilled bucket is now read back with every batch copied into buffers of its own size, dictionary values and view data included, and is reserved at what it held when written whatever the codec. A compressed file's build side was reserved at its in-memory size plus the file's size before. In the native benchmark a join under a 20 MiB pool takes 19% less time, and one under 10 MiB, which splits every bucket again, 67% less. split_again's limit is lowered, because one of its buckets now fits the old one.
The differential tests joined only an integer or string key with integer and string payloads. key_types joins keys of decimal, date, timestamp, string view, binary and dictionary type through every path, with a hot key so that a bucket is joined in chunks. payload_types carries list, struct, large string, string view, dictionary, wide decimal, fixed-size binary and boolean payloads through routing, the writers' concatenated parts and the copy of a build side read back. It is what showed that a dictionary's values have to be copied too. wide_rows writes batches larger than the chunks DataFusion reads spill files in.
A probe row of a spilled bucket was written to the bucket's probe file even when no build row of the bucket had its key. Such a row matches nothing anywhere, so the join of the resident buckets, where no build row has its key either, emits it exactly as the bucket's own join would, for every join type. Each spilled bucket now gets a Bloom filter of its build keys when it is destaged, and a probe row the filter rejects goes to the resident join instead of to disk. The filter is checked for every probe row of a spilled bucket, so a lookup has to cost far less than writing and reading the row back. A key sets four bits in one 64-bit word, from bits of the routing hash that routing at the first level does not use, and a lookup is one load and one comparison. A first version with Parquet's split block filter cost about 9 ns a lookup and was slower overall. The filter grows as a scalable Bloom filter does, since a bucket's final size is not known when it is destaged, its memory is reserved, and a filter that rejects fewer than one in eight of its first 64K lookups is dropped. The new metric bloom_filtered_rows counts the rows it kept off disk. In the native benchmark, where half of the probe rows match, the filter keeps 1.4 to 2.3 million of 5 million probe rows off disk. Pinned to one CCD, a join under a 40 MiB pool takes 7% less time, one under 10 MiB 20% less, and one with zstd spill files 12% less. With uncompressed files, where spilling a narrow row costs little, it takes 2% more.
CometSpillingHashJoinSuite was not listed in the PR workflows, so the missing-suite check failed preflight and no PR build ran. CometJoinSpillingSuite, which runs CometJoinSuite with every native shuffled hash join spilling, is declared in CometJoinSuite.scala, so the check does not require it, but suites run only when they are listed.
Under greedy_unified the join has no share of its own and takes memory from Spark's off-heap pool until Spark refuses. CometSpillingHashJoinGreedyPoolSuite runs with 32 MiB of off-heap memory, so that Spark refuses before a task's build side fits, and checks that the join spills and matches Spark. It forces the hash join on the Comet side only, so that Spark's own run keeps a sort-merge join it can spill.
The design now describes what the operator does: routed parts kept as they are for resident buckets and held by spill files' writers until they make a full batch, a spilled bucket's build side read back compacted, and the Bloom filters that keep probe rows matching nothing off disk. The status section has CometHashJoinMemoryBenchmark and the native benchmark on an idle machine, before and after, and the remaining work is updated with what the profiles showed.
HashJoinExec concatenates its build batches into one copy and holds the batches and the copy at once while it builds its table, so a spilled bucket needed twice its build side in memory to be joined, and had to be split again when it did not fit. Given a single batch, HashJoinExec keeps it as it is, because arrow's concat returns a lone array unchanged. A spill file's writer now records the value bytes of each column, and a spilled bucket whose columns are all flat, fixed-width, boolean, string or binary, is read back one batch at a time into buffers allocated once at their final size. Its inner join gets that one batch, and no concatenated copy is reserved for it. A build side with nested, dictionary or view columns is still read back compacted. In the native benchmark under a 10 MiB pool, no bucket has to be split again any more, the spilled bytes halve, and the join takes 38% less time. Larger pools, where no bucket was split, are unchanged.
The design describes how a spilled bucket with flat columns is read back into one batch that its inner join keeps without a copy. The status tables are rerun on the final code, the native benchmark's 10 MiB case now needs no second split, and the remaining work records that TPC-H matches its golden answers with spilling forced, and that arrow-rs already tried reusing the lz4 encoder.
TPC-DS at scale factor 1 matches its golden answers with spilling forced, as TPC-H does. With forceShuffledHashJoin, 10 of the 611 native shuffled hash joins in the TPC-DS plans cannot spill because Spark relies on their output order. And in CometHashJoinMemoryBenchmark at 64 MiB, zstd makes the spilling join 14% slower than lz4 for 39% fewer bytes spilled, and uncompressed files 4% faster for twice the bytes.
apache/datafusion#24592 fixed the inflated read-back of spill files on DataFusion's main branch after 55.2, closing apache/datafusion#17340, and was not backported to branch-55. The design now cites it rather than listing the finding as one to report.
A shuffled hash join reports its streamed side's order, for example when it probes the sorted output of a sort-merge join below it, and the serde kept every such join off the spilling join, because rows of spilled buckets come out after the others. But the order matters only if an operator above uses it. In TPC-H q21 at scale factor 100 with forceShuffledHashJoin, the NOT EXISTS anti join probes the output of the EXISTS sort-merge join and feeds a shuffle, which discards any order. It could not spill and failed when its build side did not fit. CometExecRule now tags each shuffled hash join whose order no operator above relies on: none requires a child ordering the order could satisfy, and the order does not reach the root, whose output order a query relies on once RemoveRedundantSorts has dropped a sort. An operator that reports no order or sorts its input itself ends the order. A tagged join can spill, and a spilling join reports no output order.
mbutrovich
pushed a commit
to apache/datafusion
that referenced
this pull request
Oct 16, 2026
…es pin only their own bytes (#24592) (#26435) ## Which issue does this PR close? - Fixes #17340 on the `branch-55` release line. - Backports the merged fix in #24592. ## Rationale for this change In 55.x, a batch read back from a spill file can hold far more memory than was recorded for it when it was written: up to twice as much when its IPC message spans two of the 128 KiB chunks that `SpillReaderStream` reads, and the whole 128 KiB chunk when a small batch fits inside one. Operators that read spills back budget memory from the size recorded at spill time, among them `SortExec`'s multi-level merge and the spilling aggregate, so under a memory limit they hold memory the pool does not know about. The `Record batch memory usage (...) exceeds the expected limit` warnings reported on #17340 come from this. #24592 fixed it on `main`, but it is not in 55.1.0 or 55.2.0, and it first ships in 56.0.0 (#24461). This backport is for a 55.3.0 patch release, if one is made. DataFusion Comet runs on 55.2.0 and hits this. In its spilling shuffled hash join (apache/datafusion-comet#7057), a spilled build side read back in batches of 256 KiB held up to twice what it held when written, so the join now copies every batch it reads back into buffers of their own size. Comet's sorts and aggregates spill through the same reader. ## Past backports of spill fixes Fixes to spilling have been backported to earlier release lines: | Release line | Backport | Fix | | ------------ | -------- | -------------------------------------------------------------------------------- | | `branch-54` | #23654 | Lost wakeup in `SpillPoolReader` with multiple concurrent readers (#23522) | | `branch-53` | #21451 | Use the spill writer's schema instead of the first batch's for spill files (#21293) | | `branch-53` | #20918 | Duplicate group keys after hash aggregation spill (#20724, #20858) | | `branch-53` | #20792 | Repartition dropping data when spilling (#20672) | | `branch-52` | #21403 | Use the spill writer's schema instead of the first batch's for spill files (#21293) | | `branch-52` | #20917 | Duplicate group keys after hash aggregation spill (#20724, #20858) | | `branch-52` | #20777 | Repartition dropping data when spilling (#20672) | | `branch-52` | #20684 | Arrow spill underrun when reading spill files back (#20159) | #20684 is the closest: like this one, it fixed how spill files are read back. ## What changes are included in this PR? - A cherry-pick with `git cherry-pick -x` of the commit that #24592 merged (cf083e0). It applies to `branch-55` without conflicts, and this PR changes nothing else. - `SpillReaderStream` reassembles each IPC message into allocations sized from the message's own headers before decoding it, so a decoded batch pins only its own bytes. The change is confined to `datafusion/physical-plan/src/spill/mod.rs`, with the tests from #24592. ## Are these changes tested? Yes, with the tests that #24592 added, on `branch-55`: - `cargo fmt --all -- --check` - `cargo clippy -p datafusion-physical-plan --all-targets --all-features -- -D warnings` - `cargo test -p datafusion-physical-plan --lib spill`: 93 passed, including the five tests #24592 added (`test_read_back_does_not_inflate_batch_memory`, `test_stringview_spill_read_back_memory_accounting`, `test_message_framer_across_chunk_boundaries`, `test_message_framer_reports_truncation_like_the_decoder` and `test_truncated_spill_file_errors`) - `cargo test -p datafusion-physical-plan --lib sorts`: 81 passed - `cargo test -p datafusion-physical-plan --lib aggregates`: 179 passed - `cargo test -p datafusion --test core_integration memory_limit`: 31 passed ## Are there any user-facing changes? No API changes. Queries that spill hold less memory when they read their spills back, and the warning from #17340 no longer fires for this cause. Co-authored-by: Jay Zhan <jayzhan211@gmail.com>
Describe the eligibility rule that walks up from each shuffled hash join, add the TPC-H results at three off-heap sizes, and record that q21 fails at 1 GiB because the join takes the memory a sort below it needs. Lead the remaining work with that, with what the fair pool and a build buffer cap showed.
…ototype Add TPC-DS at scale factor 100 with 2 GiB, where every query completes with spilling and q72 shows that a forced hash join can lose badly to the sort-merge join on a high fan-out join, spilling or not. Record that a pool limiting only the consumers that can spill, over the task's share, removes the q21 failure and every retried task in TPC-H for up to 3% more time.
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?
Part of #2545. This is a research draft for discussion, not a change to merge as it stands.
Rationale for this change
Comet's native shuffled hash join is DataFusion's
HashJoinExec, whose build side has to fit in the task's shareof the memory pool. When it does not, the task fails. That is why
spark.comet.exec.forceShuffledHashJoinis off bydefault and has a build-size guard (#6384), even though a hash join is faster than a sort-merge join when it fits.
DataFusion has an open design for spilling hash joins (apache/datafusion#24768), but no agreed approach yet. The
sort-merge fallback (apache/datafusion#25217) is waiting on review, and a refactor of the hash join stream is in
flight. Comet's case is much simpler than DataFusion's general one. Every native shuffled hash join runs in
Partitionedmode, on one partition per task, and the output order contract lives on the Spark side, not inDataFusion's optimizer. This draft explores a Comet-only design for that case, to find out what it costs and what
breaks. Parts of it could be offered upstream later.
What changes are included in this PR?
The design is written up in
docs/source/contributor-guide/spilling-hash-join-design.md. In short:SpillingHashJoinExec(native/operators/src/spilling_hash_join/) wraps theHashJoinExecthat Comet alreadyplans. An equi-join splits exactly by a hash of its keys, for every join type, so each bucket is joined by a stock
HashJoinExec, and the operator implements no join semantics of its own.the buckets left in memory with the probe input, and then joins each spilled bucket from its files. A bucket
that does not fit is split on the next bits of the hash. A bucket that splitting cannot shrink, because most of
its rows share one key or have a null key, is joined in chunks, with a replay of its probe rows for each chunk.
reserved, until they make a batch of the session's batch size or 256 KiB.
anywhere, so it goes to the join of the buckets left in memory instead of to disk.
HashJoinExecallocates without reserving it: its concatenated copy of the build batches, and its chain of rowindices. A spilled bucket's build side is read back into one batch, which its inner join keeps without a
copy, so that the bucket needs about its own size in memory. A build side with nested, dictionary or view
columns is read back compacted instead.
spark.comet.exec.hashJoin.spill.enabledis on by default, so that CI runs the new join.spark.comet.exec.hashJoin.spill.maxBuildBufferBytesforces spilling, for tests. The serde enables spilling onlyfor shuffled hash joins that are not null-aware, do not use the join runtime filter, and whose output order no
operator above relies on.
CometExecRulewalks up the plan from each shuffled hash join to find that out, and aspilling join reports no output order.
CometHashJoinExec, and the native operator displays as theHashJoinExecit runs, so plansand golden files do not change. New metrics:
spilled_buckets,split_buckets,chunked_buckets,bloom_filtered_rowsandbudget_overflow_bytes, plus the spill metrics.CometHashJoinMemoryBenchmarkcompares Spark's sort-merge join with Comet's sort-merge join, its hash join withspilling disabled, and its hash join with spilling, across native memory budgets. A native benchmark,
cargo bench --bench spilling_hash_join, runs one task's join over in-memory batches.It reviews best commit by commit. The first four commits are the first version, and the rest are a second round
that made spilling cheaper and let more joins spill.
Findings so far
CometHashJoinMemoryBenchmarkjoins 4M build rows with 20M probe rows onlocal[4], with a native pool per taskfrom 1024 MiB down to 64 MiB. These runs were on an otherwise idle AMD 7950X3D, pinned to its eight V-cache cores,
median of three. Before is the first version, after is the second round.
fails. The shapes are inner, left, right and full outer, left anti, and an inner join with a hot key.
for every join shape. At 64 MiB the second round is 5 to 19% faster than the first and spills 38 to 50% fewer
bytes.
The first draft's numbers, which showed it no faster, came from a machine that another benchmark was also using.
spills, mostly because it counts the concatenated copy of the build side that
HashJoinExecmakes withoutreserving it.
writing full batches: spill files held slivers of 512 rows, or 32 once split, and every one cost an IPC message
and an lz4 frame per buffer. Under 10 MiB the first version split every bucket again, and the join now takes 84%
less time, because a spilled bucket's inner join gets one build batch and needs half the memory, so no bucket is
split.
the probe rows match. A first version with Parquet's split block filter cost about 9 ns a lookup and lost
overall. The one here sets four bits in one 64-bit word.
CometHashJoinMemoryBenchmark's inner join at 64 MiB, zstd makes the spilling join 14%slower than lz4 and spills 39% fewer bytes, and uncompressed files make it 4% faster and spill twice as many. The
join uses
spark.comet.exec.spill.compression.codec, the setting it shares with sorts and aggregates, and whichis best depends on the disks.
it held when written: DataFusion 55.2 reads spill files in chunks of 128 KiB, and arrow assembles a message that
spans chunks in a buffer that grows by doubling. fix: frame IPC messages when reading spill files so batches pin only their own bytes datafusion#24592 fixed that on main after 55.2 (it closed
Memory should not blow up after Arrow IPC write-read round trip during spilling datafusion#17340) and was not backported, so this PR reads a spilled build side back compacted. The
backport to branch-55 is proposed in [branch-55] fix: frame IPC messages when reading spill files so batches pin only their own bytes (#24592) datafusion#26435. And
HashJoinExeccounts its final pass over the build rows as probe input ininput_rows.TPC-H at scale factor 100
On the same machine, as a Spark 4.1.1 standalone cluster of two executors with 8 cores each, AQE on, one run of
each query. The hash join arms force it with
spark.comet.exec.forceShuffledHashJoinandmaxBuildSize=-1. Totaltime of the 22 queries:
sorted rows. That includes every query that spilled.
spilling it fails. Its
NOT EXISTSanti join probes the output of the sort-merge join ofEXISTS, so Sparkreports an order for it, and until
CometExecRulelooked at the operators above, it could not spill.235.6 s with the sort-merge join.
side, and then a sort below its probe side, for the sort-merge join of
EXISTS, cannot get the 10 MiB it needsto merge. The join takes whatever the pool grants, and nothing makes it give memory back.
Capping the join's build buffer at 64 MiB with
maxBuildBufferByteslets q21 complete in 33.9 s. MakingComet's fair pool divide the task's share instead of the executor's pool, with
spark.comet.exec.memoryPool.fraction=0.125, lets it complete in 34.9 s, but then consumers that cannot spillhit their smaller limits: q16's broadcast hash join fails in both arms, and so does a sort's merge in q2 with
the sort-merge join.
FairSpillPooldoes, overthe task's share, fixes both. Every TPC-H query completes in both arms at 1, 2 and 16 GiB with no task
retried, in up to 3% more total time, and the spilling join completes all 22 at 1 GiB in 248.1 s. It is not
part of this PR, because it changes when every Comet operator spills.
another sort of its task held most of the task's share. Those retries succeed, because a sort's need is
bounded and a retry finds a larger share. A retried spilling join takes the larger share as well.
TPC-DS at scale factor 100
On the same cluster, with 2 GiB and 32 shuffle partitions:
differ in their last digits from run to run: every row agrees within a relative 4e-16, and two runs of the
sort-merge join differ from each other the same way.
catalog_saleswithinventory, on theitem alone with a residual filter, produces 16.8 billion rows, and the tasks of that stage take 179 s on average
with the sort-merge join, 678 s with the spilling join and over 1,000 s without spilling. Forcing the hash join by
default would need a guard on fan-out, not only on build size.
Open questions
concatenated as it arrives, so the copy is smaller? A spilled bucket now avoids the copy by handing its inner
join one build batch, but the join that fits in memory still makes it.
forceShuffledHashJoinbe on by default, after measuring TPC-H and TPC-DS at realistic memory sizes?forceShuffledHashJoin.maxBuildSizeonce joins can spill?below its probe side? Comet's
fair_unifiedpool divides the executor's whole pool among a task's consumers,so its limit does not bind while Spark gives each task an even share. Limiting only the consumers that can
spill, to a share of what the task's share leaves after the ones that cannot, as DataFusion's
FairSpillPooldoes, helps every operator, the sort-merge join included, as the prototype above shows, but changes when all
of them spill. Or the join could keep resident only part of what the pool grants when its probe side has
operators that need memory.
forceShuffledHashJoin, there are noneamong the 611 native shuffled hash joins in the TPC-DS plans at scale factor 1. Before
CometExecRulelooked atthe operators above, there were 10, in six queries, for which Spark reported an order that nothing used. Should a join whose order is used keep
the sort-merge join?
The remaining work, in priority order, and how to pick it up are in the design doc's Remaining work and
Working on it sections.
How are these changes tested?
HashJoinExec, row for row, for all 10 DataFusion join types, withand without a residual filter. They cover each path: in memory, bucketed, split again, chunked for a hot key and
for null keys, real pool refusals, every bucket spilled, and empty sides, each with uncompressed and lz4 spill
files. They also check that no inner join reserves beyond its estimate, that the operator's row counts are right,
that a failed acquire fails the join instead of spilling, and that dropping the join deletes its spill files.
Four deliberate breakages of the operator were each caught.
list, struct, string view, dictionary, wide decimal, fixed-size binary and boolean payloads, and write rows wide
enough that spilled batches span DataFusion's read chunks. The payload test is what showed that a dictionary's
values have to be copied when a build side is read back. A unit test checks the one-batch build against
concat_batches.swap_inputsadds, and that a joinwith the runtime filter is left alone.
CometSpillingHashJoinSuitechecks that under a native pool too small for the build side the join fails withoutspilling, and completes and matches Spark with it. It also checks that every Spark join type matches Spark while
spilling, and which joins are eligible.
CometSpillingHashJoinGreedyPoolSuitechecks that under thegreedy_unifiedpool, with a small off-heap pool,the join spills when Spark refuses it memory, and matches Spark.
CometJoinSpillingSuiteruns all ofCometJoinSuitewith every native shuffled hash join spilling all of itsbuild side.
CometSpillingHashJoinSuitewas not, which failed preflight, sono PR build ran for the first version.
CometJoinSuite,CometJoinSpillingSuiteand both spilling suites pass on Spark 4.1, and every Sparkprofile, 3.4, 3.5 with strict warnings, 4.0, 4.1 and 4.2, compiles with its tests.
CometTPCHQuerySuiteandCometTPCDSQuerySuite,all 22 and all 134 queries, with the suites' shuffled hash join configuration also setting
spark.comet.exec.hashJoin.spill.maxBuildBufferBytes, to 1 MiB and 256 KiB, so that every larger build sidespills.
return the same rows with the spilling join as with the sort-merge join for every query that completed, but for
floating point noise in TPC-DS q39, see the findings above.
run-spark-4.1-testslabel. They run Comet on-heap,where the native pool is unbounded and nothing spills, so they show that the wrapper is transparent. Not yet
run: the Spark SQL suites on the other Spark versions, and the Iceberg suites.