andygrove opened a new pull request, #26447:
URL: https://github.com/apache/datafusion/pull/26447
## Which issue does this PR close?
- Part of #17494.
## Rationale for this change
When a build-side key repeats, `HashJoinExec` keeps the build rows of each
key in a chain: the map holds the first
row and `next[row]` the one after it. A probe row follows the chain with one
load for every match, and each load
depends on the one before, so nothing overlaps them. Once the build side no
longer fits in the cache, most of those
loads are misses. That is what makes joins with many build rows per key,
such as the join of `catalog_sales` and
`inventory` in TPC-DS q72, several times slower than a sort-merge join: in
the reproduction posted on #17494,
85% of the hash join's samples are in
`ArrayMap::get_matched_indices_with_limit_offset`, with `traverse_chain`
inlined into it.
## What changes are included in this PR?
The build rows of each repeated key are stored next to each other, so that a
probe row reads its matches in order:
- `ArrayMap` groups the rows by key with a counting sort when some key
repeats: the rows of the key in `slot` are
`rows[starts[slot]..starts[slot + 1]]`. It uses the same memory as the
chain, one slot array and one index per
build row.
- `JoinHashMapU32` and `JoinHashMapU64` group the rows after the last build
batch is inserted, when the hash values
have at least two rows each on average. The map then holds the position of
each hash value's group, which starts
with its number of rows, and the chain is freed. The groups take one more
index per distinct hash value than the
chain. The build reserves that memory first, and keeps the chain if it
cannot.
- Grouping follows 16 chains at once, so that their loads overlap. Following
one chain at a time cost about 75 ns a
build row. It holds up to 4096 rows of each chain until the chain ends,
and measures longer chains to place them
in a second pass, so that it holds at most 16 x 4096 indices besides the
groups.
- The probe copies a probe row's matches with one slice copy
(`emit_grouped_rows`). `ProbeOffset::MidChain::next`
is then the position in the groups to resume from.
Matches come out in the same order as before, so the join's output does not
change.
## Benchmarks
`datafusion-cli` (`release-nonlto`) on an AMD 7950X3D (16 cores), two runs
each.
The reproduction from #17494: 64M build rows over 640K keys, about 100 rows
per key, 96M probe rows and a residual
filter that keeps 6% of the 9.6B candidate pairs, with 16 partitions:
| Keys | main | this PR |
Sort-merge join, for reference |
| ------------------------------ | -------------- | -------------- |
------------------------------ |
| Dense (`ArrayMap`) | 59.1 s, 60.0 s | 17.4 s, 17.6 s | 10.4 s
to 11.8 s |
| Sparse (`JoinHashMap`) | 76.4 s, 75.5 s | 33.5 s, 33.2 s | 9.4 s
to 13.4 s |
| Unique, dense, 64M build rows | 1.79 s, 1.61 s | 1.77 s, 1.63 s |
|
| Unique, sparse, 64M build rows | 1.82 s, 1.62 s | 1.78 s, 1.68 s |
|
What grouping costs when there is little to probe: the same 64M build rows,
and 16K probe rows:
| Keys | main | this PR |
| ---------------------- | --------------------- | --------------------- |
| Dense (`ArrayMap`) | 0.489, 0.373, 0.379 s | 0.574, 0.421, 0.478 s |
| Sparse (`JoinHashMap`) | 0.686, 0.458, 0.485 s | 0.737, 0.571, 0.566 s |
`dfbench hj` on TPC-H SF10, the median of 12 iterations over three runs that
alternate between main and this PR.
The benchmark turns off join reordering, so Q4 to Q15 build on `lineitem`,
about 600 rows per key, and probe
`supplier`. Where every probe row matches (Q5, Q7, Q9, Q11 and Q13) they are
15% to 34% faster, and the high fan-out
queries Q22 to Q25 are 11% to 22% faster. Where 10% of the probe rows match
(Q4 to Q14, even), the probe returns too
few matches to pay for grouping 60M build rows, and they are 1% to 10%
slower. The other queries are within 6%.
<details>
<summary>All 25 queries</summary>
| Query | Scenario
| main ms | this PR ms | Change |
| ----- |
------------------------------------------------------------------- | ------- |
---------- | ------ |
| Q1 | density=1 prob hit=1 25*1.5M
| 3.0 | 3.1 | +2% |
| Q2 | density=0.026 prob hit=1 25*1.5M
| 6.7 | 6.9 | +2% |
| Q3 | density=1 prob hit=1 100K*60M
| 53.4 | 52.2 | -2% |
| Q4 | density=1 prob hit=0.1 100K*60M
| 208.3 | 215.5 | +3% |
| Q5 | density=0.75 prob hit=1 100K*60M
| 407.8 | 267.1 | -34% |
| Q6 | density=0.75 prob hit=0.1 100K*60M
| 254.2 | 256.7 | +1% |
| Q7 | density=0.5 prob hit=1 100K*60M
| 398.8 | 261.5 | -34% |
| Q8 | density=0.5 prob hit=0.1 100K*60M
| 248.9 | 254.5 | +2% |
| Q9 | density=0.2 prob hit=1 100K*60M
| 398.6 | 266.6 | -33% |
| Q10 | density=0.2 prob hit=0.1 100K*60M
| 251.3 | 259.2 | +3% |
| Q11 | density=0.1 prob hit=1 100K*60M
| 402.1 | 277.3 | -31% |
| Q12 | density=0.1 prob hit=0.1 100K*60M
| 252.2 | 262.9 | +4% |
| Q13 | density=0.01 prob hit=1 100K*60M
| 479.7 | 407.4 | -15% |
| Q14 | density=0.01 prob hit=0.1 100K*60M
| 279.5 | 308.4 | +10% |
| Q15 | density=0.2 prob hit=0.1 100K (20% dups)*60M
| 221.0 | 215.8 | -2% |
| Q16 | density=1 prob hit=1 25*1.5M RightSemi
| 3.7 | 3.6 | -2% |
| Q17 | density=1 prob hit=1 100K*60M RightSemi
| 56.4 | 55.4 | -2% |
| Q18 | density=1 prob hit=0.1 100K*60M RightSemi
| 57.7 | 54.4 | -6% |
| Q19 | density=1 prob hit=1 25*1.5M RightAnti
| 3.0 | 3.0 | +2% |
| Q20 | density=1 prob hit=1 100K*60M RightAnti
| 43.1 | 43.9 | +2% |
| Q21 | density=1 prob hit=0.1 100K*60M RightAnti
| 67.5 | 64.9 | -4% |
| Q22 | density=1 prob hit=0.01 100K (fanout 100)*60M RightSemi
| 47.6 | 42.5 | -11% |
| Q23 | density=1 prob hit=1 32K (fanout~78)*2.3M long keys count
| 114.8 | 92.9 | -19% |
| Q24 | density=1 prob hit=1 3K (single hot bucket)*20K long keys count
| 308.7 | 241.2 | -22% |
| Q25 | density=1 prob hit=1 20K (fanout~78 multi key)*240K multi key
count | 176.0 | 152.7 | -13% |
</details>
The `hash_join_probe` criterion benchmark, whose build keys are unique, is
within 2% of main for 11 of its 13
cases, as close as two runs of main are to each other.
`right_semi_clustered_10pct` was 5% slower and
`right_semi_sparse_100k_all_hit` 6% to 8% slower, in a single run.
## What is the testing strategy for this PR?
- New unit tests compare grouped maps with the chain or with a brute-force
result: `JoinHashMapU32` and
`JoinHashMapU64` with rows inserted in batches from the last to the first
and one row left out, as for a NULL key,
probed with limits from 1 to 8192 and with NULL probe keys, and `ArrayMap`
with repeated, NULL and out of range
keys. Others cover chains long enough for the second pass of grouping, the
size of the groups, the memory freed,
and that short chains are not grouped.
- The existing hash join tests, the join fuzz tests (`--features
extended_tests`), the join and memory limit tests
of `core_integration`, and all sqllogictests pass, with grouping on
wherever keys repeat.
## Are there any user-facing changes?
No. `JoinHashMapType`, which is public for internal use only, gains
`grouped_rows_size` and `group_rows`, both with
default implementations.
## Notes for reviewers
- Most of what is left of the gap to the sort-merge join in the reproduction
is gathering build columns at the
matched rows, which are scattered across the build batch. Ordering the
build batch by key would make that
sequential too, but it changes the order of build-side output for outer,
semi and anti joins, so it is not part
of this PR.
- Grouping is decided at build time. Deciding it from how much the probe has
followed chains would avoid the cost
when the probe side is small, but the map is shared across probe streams
in `CollectLeft` mode, and would need
interior mutability and memory accounting during the probe.
--
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]