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

   ## Which issue does this PR close?
   
   - Related to #23194 (this does not close it).
   
   ## Rationale for this change
   
   DataFusion picks the mode of each hash join at planning time from estimated 
statistics. It only collects the build side into a single hash table 
(`CollectLeft`) when the estimate is under 
`hash_join_single_partition_threshold` (4 MiB / 128K rows). Otherwise it 
hash-repartitions both inputs (`Partitioned`). At TPC-H SF100 almost every join 
comes out `Partitioned`, because estimates for filtered scans, joins and 
aggregates are rough. Many of those joins have tiny build sides, so the query 
hash-repartitions hundreds of millions of probe rows for nothing. For example:
   
   - Q9 repartitions all 600M `lineitem` rows to join 1.09M parts.
   - Q17 repartitions all 600M `lineitem` rows for a 20K-row part filter.
   - Q11 repartitions 80M `partsupp` rows to join 1M suppliers.
   
   This draft is a prototype to show what adaptive query execution (AQE) could 
gain here, and to feed the design discussion in #23194. It is not proposed for 
merging as is.
   
   ## What changes are included in this PR?
   
   Everything is in the benchmarks crate and uses only public APIs:
   
   - `benchmarks/src/util/adaptive.rs`: `make_adaptive(plan)` runs after normal 
physical planning. It wraps each `Partitioned` hash join whose probe side is 
hash-repartitioned in an `AdaptiveHashJoinExec`. On first execution that 
operator collects the build side, which a hash join must do anyway, up to a 
threshold:
     - If the build side fits, it runs the same join as `CollectLeft`, probing 
the probe input as it is, without the repartition.
     - If it does not fit, it stops collecting and runs `Partitioned` as the 
static plan would. The rest of the build side streams in behind the batches 
already collected.
   - `dfbench tpch --adaptive`, plus `--adaptive-broadcast-rows` (default 4M), 
`--adaptive-broadcast-rows-tracking` (default 128K) and 
`--adaptive-broadcast-probe-ratio` (default 0.05).
   
   The plan is otherwise unchanged:
   
   - **Execution order** is unchanged, so a parent join's dynamic filter still 
reaches the build sides below it.
   - **Dynamic filter:** the wrapped join keeps the one the optimizer already 
pushed into the probe scans.
   - **Plan properties** are unchanged, so ancestors are not re-derived and 
their TopK filters stay wired.
   
   Points that turned out to matter:
   
   1. **Re-planning from materialized stages** is the Spark-style approach. I 
tried it first: run build sides bottom-up as stages, then re-optimize with 
exact statistics. It made Q18 2x slower. DataFusion builds top-down along the 
probe spine, so the semi-join's dynamic filter (with its `KeyRangeBitmap`) 
prunes the build side of the join below it. A standalone stage loses that 
filter and builds its hash table on all 150M orders instead of 85.7M.
   2. **Joins that track matched build rows** (`Left`, `LeftSemi`, `LeftAnti`, 
`LeftMark`, `Full`) share one mutex-protected bitmap across probe threads in 
`CollectLeft`, which made Q22's anti join 20% slower. They now only broadcast 
build sides of up to 128K rows.
   3. **Waiting for the whole build side** before deciding cost 6-11% on joins 
that end up partitioned anyway, so collection stops as soon as the threshold is 
exceeded.
   4. **Ancestors that rely on hash partitioning:** where an ancestor relies on 
the join's output being hash partitioned, a broadcast join repartitions its 
usually much smaller output instead of its probe input (for example, the inner 
join under Q17's semi join).
   5. **Dynamic filter barrier:** a `Partitioned` hash join waits for every 
partition to report its build side before it publishes its dynamic filter. 
Partitions a parent never polls, for example because the parent's own build 
partition is empty, must still be created and dropped so that they report as 
canceled. Otherwise the query hangs.
   
   ## Results
   
   TPC-H SF100 parquet, `release-nonlto` build, same binary with and without 
`--adaptive`, median of 3 runs. The machine is a Ryzen 9 7950X3D (16 cores / 32 
threads, 124 GB):
   
   | Query | main (ms) | AQE (ms) | Speedup |
   |---|---:|---:|---:|
   | Q1 | 2,499 | 2,458 | 1.02x |
   | Q2 | 1,421 | 963 | 1.48x |
   | Q3 | 3,390 | 3,334 | 1.02x |
   | Q4 | 1,217 | 1,250 | 0.97x |
   | Q5 | 6,252 | 5,816 | 1.07x |
   | Q6 | 1,154 | 1,174 | 0.98x |
   | Q7 | 10,510 | 8,289 | 1.27x |
   | Q8 | 6,484 | 4,049 | 1.60x |
   | Q9 | 11,952 | 8,169 | 1.46x |
   | Q10 | 4,942 | 5,039 | 0.98x |
   | Q11 | 1,294 | 627 | 2.07x |
   | Q12 | 1,500 | 1,389 | 1.08x |
   | Q13 | 3,420 | 3,425 | 1.00x |
   | Q14 | 1,932 | 1,916 | 1.01x |
   | Q15 | 3,938 | 3,983 | 0.99x |
   | Q16 | 844 | 775 | 1.09x |
   | Q17 | 11,625 | 9,853 | 1.18x |
   | Q18 | 15,838 | 15,886 | 1.00x |
   | Q19 | 2,147 | 2,036 | 1.05x |
   | Q20 | 3,836 | 3,699 | 1.04x |
   | Q21 | 11,744 | 10,721 | 1.10x |
   | Q22 | 533 | 539 | 0.99x |
   | **Total** | **108,472** | **95,390** | **1.14x** |
   
   All the gains come from switching to `CollectLeft` once the real build-side 
size is known. `dfbench tpch --debug --adaptive` prints each join's decision. 
On an M3 Ultra (28 cores, 96 GB, median of 6) the total is 1.08x, with the same 
wins. There, Q21 is 0.92x and Q10 0.93x. Q21 loses because it broadcasts the 
1M-row `supplier` table against a narrow 379M-row probe: on that machine 
repartitioning is cheap and a 1M-entry hash table misses L2, while the 
7950X3D's 128 MB L3 holds it. With every join forced to `Partitioned`, Q21 
matches main, so the adaptive machinery itself costs nothing there. A real 
implementation would need a cost model that knows probe width and cache size, 
not a row threshold.
   
   For comparison, main with the static thresholds simply raised to about the 
same level (4M rows / 128 MiB) reaches 1.07x on the M3 Ultra, against AQE's 
1.08x. It gets the same big wins, because TPC-H's estimates for filtered 
dimension tables are good enough. It also broadcasts on estimates where it 
should not: Q22 0.83x, Q21 0.88x, Q13 0.94x and Q18 0.96x, against AQE's 1.02x, 
0.92x, 0.98x and 0.99x. It wins Q10 (1.09x) where AQE does not.
   
   SF1000 runs are in progress and will be added here.
   
   ## What is the testing strategy for this PR?
   
   - Unit tests in `adaptive.rs` cover three cases:
     - a small build side is collected, with results equal to the static plan;
     - a build side over the threshold stays partitioned after the early stop;
     - partitions a parent never polls do not hang the query. This test hangs 
and fails at its timeout without the fix.
   - All 22 TPC-H queries return the same results as main at SF1 under five 
settings: defaults, everything partitioned, everything collected, batch size 
97, and 3 partitions. They also match at SF100 on two different datasets.
   
   ## Are there any user-facing changes?
   
   No. The only additions are the new `dfbench tpch` flags.
   


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