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

   ## Which issue does this PR close?
   
   - None. There is no separate issue; the problem is described below.
   
   ## Rationale for this change
   
   When every build key is unique, the hash join probe uses a faster loop that 
skips walking the duplicate-key chain. That loop was enabled by `map.len() == 
next.len()`, which means "every build row is in the map". Under 
`NullEqualsNothing`, build rows with a NULL key are not inserted, because they 
can never match. So a single NULL build key turns the fast path off, and every 
probe walks the chain even though every chain has length 1.
   
   This affects the hash-map path: string keys, multi-column keys and 
wide-range integer keys. It does not affect `ArrayMap`. A nullable unique key 
on the build side is enough to trigger it.
   
   ## What changes are included in this PR?
   
   - `update_from_iter` returns whether it chained a row onto an existing hash 
value. `JoinHashMapU32` and `JoinHashMapU64` keep that result in a `chained` 
flag across batches.
   - `get_matched_indices_with_limit_offset` now takes the uniqueness decision 
as a parameter instead of computing `map.len() == next_chain.len()` itself. The 
two join hash maps pass `!chained`. `PruningJoinHashMap` (symmetric hash join) 
still passes `map.len() == next.len()`, so its behavior is unchanged.
   - Nothing changes when no build rows are skipped, because `map.len() == 
next.len()` held exactly when no row was chained. `NullEqualsNull` is also 
unchanged: NULL rows are inserted and chain as before.
   
   ## What is the testing strategy for this PR?
   
   New unit tests:
   - `join_hash_map::tests::test_unique_fast_path_with_uninserted_row`: unique 
keys plus one row that was not inserted now take the fast path. The test checks 
the fast path's offset behavior with `limit = 2`; before this change it failed 
with `Some((1, Some(0)))`.
   - `join_hash_map::tests::test_chain_from_earlier_update_disables_fast_path`: 
a chain created by an earlier batch keeps the chain walk.
   - `utils::tests::update_hash_null_key_does_not_change_matches`: 
`update_hash` plus a chunked probe give the same matches with and without a 
NULL build key.
   
   Benchmark: `datafusion-cli` built with `release-nonlto`, base vs. this PR, 
on an Apple M4 Pro (12 cores, 24 GB) with default `target_partitions`. Each 
query is `SELECT count(*) FROM probe p JOIN build b ON <keys>`. The build side 
has 5M unique keys (`Utf8View`, or `(Int64 wide range, Utf8View)`) plus one 
NULL-key row. The probe side has 20M rows in shuffled order and matches every 
key. Runs were interleaved; each value is the median of 18 runs (6 rounds × 3), 
started only when the 1-minute load average was below 4. Under default 
thresholds the 5M-row build side runs in `Partitioned` mode. The `CollectLeft` 
rows raise `hash_join_single_partition_threshold{,_rows}` so that the whole 
build side is one map. `EXPLAIN ANALYZE` shows `array_map_created_count=0` in 
every case except where noted.
   
   | Case | Mode | base (s) | PR (s) | PR/base |
   |---|---|---|---|---|
   | Utf8View key, unique + 1 NULL | CollectLeft | 0.237 | 0.214 | **0.90** |
   | (Int64, Utf8View) key, unique + 1 NULL | CollectLeft | 0.284 | 0.259 | 
**0.91** |
   | Utf8View key, unique + 1 NULL | Partitioned | 0.144 | 0.143 | 0.99 |
   | (Int64, Utf8View) key, unique + 1 NULL | Partitioned | 0.192 | 0.189 | 
0.99 |
   | Control: Utf8View key, unique, no NULL | Partitioned | 0.134 | 0.137 | 
1.02 |
   | Control: Utf8View key, unique, no NULL | CollectLeft | 0.205 | 0.210 | 
1.03 |
   | Control: (Int64, Utf8View) key, unique, no NULL | CollectLeft | 0.253 | 
0.253 | 1.00 |
   | Control: 2 rows per key + 1 NULL | Partitioned | 0.163 | 0.161 | 0.99 |
   | Control: 2 rows per key + 1 NULL | CollectLeft | 0.260 | 0.264 | 1.02 |
   | Control: `hj` Q13 (TPC-H SF10, 1% density, hash map), `count(*)` | 
CollectLeft | 0.067 | 0.068 | 1.02 |
   
   `HashJoinExec` `join_time` from `EXPLAIN ANALYZE` (summed over partitions, 
median of 7 runs):
   
   | Case | base | PR |
   |---|---|---|
   | Utf8View key + 1 NULL, CollectLeft | 1.14 s | 0.84 s (-26%) |
   | (Int64, Utf8View) key + 1 NULL, CollectLeft | 1.31 s | 1.01 s (-23%) |
   | Utf8View key + 1 NULL, Partitioned | 729 ms | 715 ms |
   | Control: no NULL, CollectLeft | 802 ms | 809 ms |
   | Control: 2 rows per key, CollectLeft | 1.15 s | 1.12 s |
   
   With one NULL key, `join_time` now matches the no-NULL control. In 
`Partitioned` mode all NULL-key build rows hash to a single partition, so only 
that partition lost the fast path, and the end-to-end change there is within 
noise. Run-to-run spread on this shared machine is about ±3%, which covers 
every control.
   
   ## Are there any user-facing changes?
   
   No. `join_hash_map` is `#[doc(hidden)]` internal API, although the 
signatures of its free functions `update_from_iter` and 
`get_matched_indices_with_limit_offset` change.
   


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