feat(benchmarks): Record the join failure matrix under a fixed memory budget - #24779
feat(benchmarks): Record the join failure matrix under a fixed memory budget#24779jayzhan211 wants to merge 1 commit into
Conversation
…test utility Records what one join workload does under a fixed memory budget in each configuration a user can pick today. `HashJoinExec` cannot spill its build side, so several of these rows fail; the point is to keep that matrix reproducible while external hash join is built, and to show when a row flips. - `dfbench join-mem` (`./bench.sh run join_mem`): runs the six matrix rows under a 300 MiB fair pool by default, generating and caching its own 20M-row parquet file. Reports each row's outcome next to the recorded baseline, the allocation that failed, per-operator spill metrics, and the "SMJ tax" (the fitting join forced through `SortMergeJoinExec`, divided by the same join on the hash join). - `memory_limit::budgeted_env`: builds a `RuntimeEnv` on a budgeted fair pool and reports what a query did under it — completed or exhausted, how long it took, and which operators spilled — for tests where the outcome, not the error text, is the subject. - `memory_limit::join_failure_matrix`: the same six rows as assertions at test scale. The inputs are a generated parquet file rather than `generate_series`, because a sorted, statistics-free source lets the sort-merge rows skip their sorts and stops the planner from choosing the smaller build side. Claude-Session: https://claude.ai/code/session_01BR4uF8mNBC5K3oKv9XjgLt
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #24779 +/- ##
==========================================
- Coverage 81.52% 81.47% -0.05%
==========================================
Files 1123 1124 +1
Lines 405970 406220 +250
Branches 405970 406220 +250
==========================================
+ Hits 330978 330980 +2
- Misses 55627 55876 +249
+ Partials 19365 19364 -1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
I ran the benchmark locally using the PR's default configuration. The results are Environment for the numbers below: Apple M5 (10 cores), macOS 26.5.2, Command: cd benchmarks
./bench.sh run join_memThe benchmark was run with the default configuration: a 300MB fair memory pool, 4 The recorded allocation failures were:
SMJ tax (row 4 / row 3a): 3.7x on a join that would have fit in memory. Every row matched the recorded baseline. |
Which issue does this PR close?
Rationale for this change
A join whose build side does not fit the memory budget fails outright today:
HashJoinExeccannot spill, so the query dies at the hash table build rather thanslowing down. The only way to run such a join is
prefer_hash_join=false, which handsthe work to
SortMergeJoinExec, whose sorts can spill — and which then also appliesto every join that would have fit in memory, at a measurable cost.
That behaviour is well known, but nothing in-tree measures it. There is no baseline to
hold external hash join work against, no way to see a row of it change, and no shared
artifact for the numbers quoted in discussions.
SortMergeJoinExechas memory-limitvalidation coverage;
HashJoinExechas none.This PR adds that baseline as a benchmark and as tests. It does not change any execution
behaviour.
What changes are included in this PR?
dfbench join-mem/./bench.sh run join_mem(benchmarks/src/join_mem.rs) — onejoin workload run through a fixed budget in each configuration a user can choose today.
Both inputs read the same generated relation, so there is no smaller side for the planner
to swap in: the failure is not one a better build side avoids.
For each row it reports the outcome next to the recorded baseline, the allocation that
failed, per-operator spill metrics, and the "SMJ tax" — row 4 divided by row 3a, i.e.
what the workaround costs on a join that would have fit. A row whose outcome differs from
the recorded baseline is called out by name; rows that fail with
Resources exhaustedare the recorded baseline, not a broken run.
memory_limit::budgeted_env— aRuntimeEnvon a budgeted fair pool, plusrun_under_budget(), which reports what a query did under it: completed or exhausted,how long it took, and which operators spilled. The existing
TestCasecovers queriesover the built-in scenario tables and asserts on error text; this covers the other shape,
where whether a query finished, and what it spilled to get there, is the subject.
memory_limit::join_failure_matrix— the same six rows as assertions at test scale(16MB budget, 2M rows, 2 partitions), one test per row, so a flip names the row that
flipped. Rows 1 and 3b are the ones external hash join is meant to turn into
completes;when that lands, these are the tests to update.
Docs: a section in
benchmarks/README.mdand an entry inbench.sh(help, datadispatch, runner). Not added to the
allgroup, since a run generates a 20M-row file.The baseline
Two queries, run six ways through a 300MB fair pool at 4 partitions over 20M rows:
HashJoinExecResources exhaustedat the hash-table build ¹prefer_hash_join=false, so the sorts (which spill) carry the joinSortExec310.6 MB in 28 eventsN = 10MN = 12MResources exhaustedat the hash-table build ²SortMergeJoinExecSortExec233.0 MB in 24 eventsAggregateExec1079.4 MB in 84 events¹
Failed to allocate additional 95.4 MB for HashJoinInput[1] 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)²
Failed to allocate additional 57.2 MB for HashJoinInput[0] with 22.9 MB already allocated for this reservation - 36.6 MB remain available for the total memory pool: fair(pool_size: 300.0 MB)SMJ tax (row 4 / row 3a): 3.2x on a join that would have fit in memory.
Reading the rows: the fatal allocation is the hash map itself, not the batches.
count(*)projects the key alone (~8 B/row), and the map on top of it is sized by
estimate_memory_size::<(u32, u64)>at ~19 B/row — asked for as a singletry_growafter every build batch has already been admitted. That is the 95.4 MB above 38.2 MB in
row 1, and the 57.2 MB above 22.9 MB in row 3b. Per-batch backpressure alone cannot
avoid it. Row 5 is a control, not a way to run the join: it shows the budget itself is
workable, pushing 1079MB of aggregation spill through the same 300MB pool.
How to reproduce these numbers
or directly, which is what produced the table above:
cargo run --release --bin dfbench -- join-mem \ --path /tmp/join_mem -o /tmp/join_mem.jsonDefaults, all overridable:
--memory-limit 300M,--mem-pool-type fair,--partitions 4,--iterations 3,--rows 20000000,--fit-rows 10000000(rows 3a/4),--over-rows 12000000(row 3b), and DataFusion's default 10MB sort spill reservation.-q 3bruns a single row;-owrites the usual benchmark JSON, which carriespool_peak_bytesfor failed rows too (rows 1 and 3b peaked at 260MB and 276MB of the300MB pool).
The pool is a
TrackConsumersPoolover aFairSpillPool, the same onedatafusion-cli --mem-pool-type fairinstalls, so the failure dumps are directlycomparable with a CLI reproduction. Each row gets a fresh runtime, so no row inherits
another's pool state.
The data is generated once, with no memory limit — building the file under a 300MB pool
is its own fight and not the thing under test:
Environment for the numbers above: Apple M4 Pro (12 cores), macOS,
--release, DataFusionmain@ 61bf6b9.What is and isn't stable across machines: the allocation sizes are —
95.4 MBabove38.2 MB,57.2 MBabove22.9 MB, and 1079MB of aggregation spill reproduce exactly,because they are functions of row count, partition count and key width, not of hardware.
Wall-clock is not — it varies with cores, disk and page cache, so the SMJ tax (a ratio
of two rows from the same run) is the number to compare, not the individual times. The
failing partition index and the trailing
... remain availablevalue vary run to run withhow many sibling partitions reached their hash-map grow first.
To reproduce without the benchmark, the same matrix runs in
datafusion-cli:--top-memory-consumers 8matters: at the default of 3, the consumer that actually failscan be cut off from the dump.
Are these changes tested?
Yes — the tests are part of the change.
memory_limit::join_failure_matrixasserts all sixrows at test scale; the full
memory_limitmodule passes (42 tests, ~11s).One note on the test-scale inputs: they are a generated parquet file rather than
generate_seriesdirectly. A series is a sorted source with no statistics, which lets thesort-merge rows skip their sorts entirely and stops the planner from putting the smaller
side on the build side — both of which quietly invalidate the matrix. That cost me a
debugging round; the file says so, so the next person does not repeat it.
Are there any user-facing changes?
No API or execution changes. New benchmark subcommand (
dfbench join-mem,./bench.sh run join_mem) and its documentation.