jayzhan211 opened a new pull request, #24779:
URL: https://github.com/apache/datafusion/pull/24779

   ## Which issue does this PR close?
   
   - Part of #24768 — the baseline the rest of that epic is measured against. 
Nothing to close yet.
   
   ## Rationale for this change
   
   A join whose build side does not fit the memory budget fails outright today:
   `HashJoinExec` cannot spill, so the query dies at the hash table build 
rather than
   slowing down. The only way to run such a join is `prefer_hash_join=false`, 
which hands
   the work to `SortMergeJoinExec`, whose sorts *can* spill — and which then 
also applies
   to 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. `SortMergeJoinExec` has 
memory-limit
   validation coverage; `HashJoinExec` has 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`) — one
   join 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 
exhausted`
   are the recorded baseline, not a broken run.
   
   **`memory_limit::budgeted_env`** — a `RuntimeEnv` on a budgeted fair pool, 
plus
   `run_under_budget()`, which reports what a query did under it: completed or 
exhausted,
   how long it took, and which operators spilled. The existing `TestCase` 
covers queries
   over 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.md` and an entry in `bench.sh` (help, 
data
   dispatch, runner). Not added to the `all` group, 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**:
   
   ```sql
   -- the join (rows 1, 2), and with the build side filtered (rows 3a, 3b, 4)
   SELECT count(*) FROM t_probe p JOIN t_build b ON p.k = b.k;
   SELECT count(*) FROM t_probe p JOIN (SELECT * FROM t_build WHERE k <= N) b 
ON p.k = b.k;
   
   -- the control (row 5)
   SELECT count(DISTINCT payload) FROM t_build;
   ```
   
   | # | configuration — what a user can actually set | outcome | mean | 
spilled |
   | --- | --- | --- | --- | --- |
   | 1 | default settings — the planner picks `HashJoinExec` | ❌ `Resources 
exhausted` at the hash-table build ¹ | 0.05s | — |
   | 2 | the only workaround — `prefer_hash_join=false`, so the sorts (which 
spill) carry the join | ✅ completes | 0.483s | `SortExec` 310.6 MB in 28 events 
|
   | 3a | where the ceiling is — build side filtered to `N = 10M` | ✅ completes 
| 0.106s | nothing |
   | 3b | just past it — the same filter at `N = 12M` | ❌ `Resources exhausted` 
at the hash-table build ² | 0.048s | — |
   | 4 | what the workaround costs when it isn't needed — row 3a forced through 
`SortMergeJoinExec` | ✅ completes | 0.340s | `SortExec` 233.0 MB in 24 events |
   | 5 | control, not a way to run the join — hash *aggregation* through the 
same budget | ✅ completes | 1.028s | `AggregateExec` 1079.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 *single* 
`try_grow`
   after 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
   
   ```bash
   # generates a 20M-row parquet file on the first run (~37MB, cached under 
--path)
   cd benchmarks && ./bench.sh run join_mem
   ```
   
   or directly, which is what produced the table above:
   
   ```bash
   cargo run --release --bin dfbench -- join-mem \
       --path /tmp/join_mem -o /tmp/join_mem.json
   ```
   
   Defaults, 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 3b` runs a single row; `-o` writes the usual benchmark JSON, which 
carries
   `pool_peak_bytes` for failed rows too (rows 1 and 3b peaked at 260MB and 
276MB of the
   300MB pool).
   
   The pool is a `TrackConsumersPool` over a `FairSpillPool`, the same one
   `datafusion-cli --mem-pool-type fair` installs, so the failure dumps are 
directly
   comparable 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:
   
   ```sql
   COPY (SELECT v AS k, concat('payload-', v, '-', repeat('x', 24)) AS payload
         FROM generate_series(1, 20000000) AS t(v))
   TO 'join_mem_20000000_rows.parquet' STORED AS PARQUET;
   ```
   
   Environment for the numbers above: Apple M4 Pro (12 cores), macOS, 
`--release`, DataFusion
   `main` @ 61bf6b96c.
   
   What is and isn't stable across machines: **the allocation sizes are** — 
`95.4 MB` above
   `38.2 MB`, `57.2 MB` above `22.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 available` value vary 
run to run with
   how many sibling partitions reached their hash-map grow first.
   
   To reproduce without the benchmark, the same matrix runs in `datafusion-cli`:
   
   ```bash
   datafusion-cli -m 300M --mem-pool-type fair --top-memory-consumers 8
   ```
   
   `--top-memory-consumers 8` matters: at the default of 3, the consumer that 
actually fails
   can be cut off from the dump.
   
   ## Are these changes tested?
   
   Yes — the tests are part of the change. `memory_limit::join_failure_matrix` 
asserts all six
   rows at test scale; the full `memory_limit` module passes (42 tests, ~11s).
   
   One note on the test-scale inputs: they are a generated parquet file rather 
than
   `generate_series` directly. A series is a sorted source with no statistics, 
which lets the
   sort-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.


-- 
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]

Reply via email to