Repository navigation
perf: stop redoing per-chunk setup on the hash join probe path - #25658
Conversation
A probe batch is emitted in chunks of at most batch_size rows, and each chunk rebuilt the multi-column key comparator from scratch: fresh boxed closures plus a scan of every float key column, on both sides, for -0.0. The comparator is now created lazily once per probe batch and kept on the stream until the next batch arrives. The array map lookup cloned its index scratch vectors into every chunk's arrays. It now moves them in and takes the allocations back after the output batch is built. build_batch_from_indices and build_batch_empty_build_side accept a SchemaRef, so each output batch shares the schema instead of deep copying it. Float plus integer key join, 60K build rows, 17.1M output rows: query 150 ms to 108 ms, HashJoinExec elapsed_compute 147 ms to 104 ms. With low fanout (one chunk per probe batch) and on hj Q23-25 and TPC-H q7/q9 at SF1, timings stay within run-to-run noise.
15c86d0 to
8c064d7
Compare
There was a problem hiding this comment.
@dwsmith1983 Thank you for working on this.
Checked this against apache/datafusion#12131 and the earlier work in #23843. The comparator caching and buffer reuse cover the remaining repeated setup and allocations. All 564 hash-join tests passed locally, and the cache lifetime and buffer ownership look correct. I haven't verified the benchmark numbers.
@Dandandan could you please approve the workflow run?
Side note, apache/datafusion#12131 is still assigned to LeslieKid from 2024.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #25658 +/- ##
=======================================
Coverage 82.61% 82.61%
=======================================
Files 1147 1147
Lines 444733 444819 +86
Branches 444733 444819 +86
=======================================
+ Hits 367407 367495 +88
+ Misses 55010 55005 -5
- Partials 22316 22319 +3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Thanks @ShayanGho for checking it against #12131 and #23843. @jayzhan211 @adriangb could you take a look when you have time? It builds the key comparator once per probe batch and drops two per-chunk clones; the multi-column float join in the description goes from 150 ms to 108 ms. |
jayzhan211
left a comment
There was a problem hiding this comment.
Thanks @dwsmith1983 , here is a suggestion
| build_indices_buffer: Vec<u64>, | ||
| /// Key comparator for the current probe batch, built on first use and | ||
| /// reused by every chunk of that batch | ||
| probe_key_comparator: Option<JoinKeyComparator>, |
There was a problem hiding this comment.
The comparator is only valid for one probe batch, but it lives on the stream and relies on hand-placed = None resets (fetch_probe_batch, limit path). Any future path that installs a new ProcessProbeBatchState without a reset silently drops matches; removing the fetch_probe_batch reset fails join_inner_multi_key_across_probe_batches. Storing it on ProcessProbeBatchState ties its lifetime to the batch, removes both resets, and frees the probe arrays when the batch finishes instead of at the next fetch. I checked this locally: clippy is clean and the joins::hash_join tests pass.
-#[derive(Debug, Clone)]
+#[derive(Debug)]
pub(super) enum HashJoinStreamState {-#[derive(Debug, Clone)]
+#[derive(Debug)]
pub(super) struct ProcessProbeBatchState {
...
matched_probe_idx: Option<u32>,
+ /// Key comparator for this batch, built on first use and reused by every
+ /// chunk
+ key_comparator: Option<JoinKeyComparator>,
} matched_probe_idx: None,
+ key_comparator: None,
});- &mut self.probe_key_comparator,
+ &mut state.key_comparator,Then drop the probe_key_comparator field, its initializer, and both self.probe_key_comparator = None; lines. In utils.rs:
impl Debug for JoinKeyComparator {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("JoinKeyComparator").finish_non_exhaustive()
}
}There was a problem hiding this comment.
Thanks @jayzhan211, that is cleaner. Done in c6d7224: the comparator now lives on ProcessProbeBatchState, the stream field and both resets are gone, and JoinKeyComparator gets a manual Debug impl.
The comparator is only valid for one probe batch. Storing it on ProcessProbeBatchState ties its lifetime to the batch, so the manual resets in fetch_probe_batch and on the limit path go away and the probe arrays are released as soon as the batch finishes.
| offset, | ||
| probe_indices_buffer, | ||
| build_indices_buffer, | ||
| &mut None, |
There was a problem hiding this comment.
This loop calls lookup_join_hashmap once per chunk with the same build_scope_values and probe_scope_values. Because each call gets a new &mut None, the null-aware scope path still builds the comparator again for each chunk. Can you move the slot out of the loop so that this path also builds it once?
let mut offset = (0, None);
let mut key_comparator = None;
loop {
let (build_indices, probe_indices, next_offset) = lookup_join_hashmap(
// ...
&mut key_comparator,
)?;There was a problem hiding this comment.
Good catch, done in 31f5ae6: the slot now lives outside the loop, so each scope lookup builds the comparator once. None of the existing null-aware tests had a multi-column scope, so I added test_null_aware_left_anti_multi_column_scope, which splits both scope lookups into several chunks at the smaller batch sizes.
| next_offset, | ||
| ) | ||
| let build_indices: UInt64Array = | ||
| std::mem::take(&mut self.build_indices_buffer).into(); |
There was a problem hiding this comment.
Optional: this take-then-give-back of the index buffers repeats the pattern inside lookup_join_hashmap. A small helper that both paths call would keep the two in step if one of them changes later.
There was a problem hiding this comment.
I looked at sharing it, but the two paths hold the buffers for different spans. lookup_join_hashmap takes them and gives them straight back once the filtered arrays are built, while the array map path hands its arrays out as the chunk's indices and only reclaims the buffers after the output batch is done. A shared helper would wrap the two one-line conversions without making either path shorter, so I left them as they are. Happy to revisit if you feel strongly.
| /// | ||
| /// `comparator` caches the general-path [`JoinKeyComparator`] between calls | ||
| /// that share the same `left_arrays` and `right_arrays`, so its setup cost is | ||
| /// paid once rather than per call. Pass an empty slot whenever either side's |
There was a problem hiding this comment.
Nit: nothing enforces this rule, so a caller that keeps the slot across different arrays gets wrong matches with no error. Can you also say here that &mut None is always safe (it only costs the comparator setup on each call)? That explains the &mut None at the symmetric hash join call site.
There was a problem hiding this comment.
Added in 31f5ae6: the doc now says nothing checks the rule and that &mut None is always correct, at the cost of rebuilding the comparator on each call.
for_each_scope_match passed a fresh comparator slot to each chunk even though the build and probe scope arrays stay the same for the whole loop, so multi-column scopes rebuilt the comparator per chunk. Keep one slot for the loop. Spell out on equal_rows_arr that the same-arrays rule is not checked and that an empty slot is always safe. Add a null-aware anti join test with a two-column correlation scope that splits each scope lookup into several chunks.
jayzhan211
left a comment
There was a problem hiding this comment.
Thanks @dwsmith1983 , here is a suggestion
JoinKeyComparator::new still runs normalize_float_zero over the full build key column once per probe batch, so float-key joins pay O(build rows × probe batches). On TPC-H SF1 with a 1.5M-row build side (query below), join_time is ~91 ms on this PR. It drops to ~23 ms if build-side normalization is skipped, which matches the same join on a BIGINT key (~21 ms). Fine to handle in a follow-up.
SET datafusion.execution.target_partitions = 1;
EXPLAIN ANALYZE SELECT count(*)
FROM (SELECT l_orderkey AS k, CAST(l_quantity AS DOUBLE) AS q
FROM lineitem WHERE l_orderkey < 1500000) b
JOIN (SELECT l_orderkey AS k, CAST(l_quantity AS DOUBLE) AS q FROM lineitem) p
ON b.k = p.k AND b.q = p.q;Suggested shape: normalize float build keys once when the build side is collected and store them in JoinLeftData next to values(). Keep them separate because the in-list dynamic filter needs the original values. Then add a JoinKeyComparator constructor that takes already-normalized left arrays. That also removes the per-batch, untracked copy of the build float column when it contains -0.0.
|
@jayzhan211 thanks, nice find. You're right that the scan for |
|
@jayzhan211 the build-side normalization is up as #25972, a draft stacked on this PR. Your query goes from 96 ms to 26 ms of join time there, about the same as the BIGINT-key control. |
|
Thanks @dwsmith1983 @adriangb , I will review @25972 later on |
## Which issue does this PR close? - Follow-up to apache#25658, from @jayzhan211's review there. No separate issue. This branch is stacked on apache#25658, so until that merges the diff here includes its commits. Only the last commit is new. ## Rationale for this change Hash joins on float keys get much slower as the build side grows. The comparator that checks key equality is built once per probe batch, and building it rewrites `-0.0` to `+0.0` across the whole build key column so that `-0.0 = 0.0` holds. That scan repeats for every probe batch even though the build side never changes, so a float-key join costs build rows times probe batches, while the same join on integer keys does not. ## What changes are included in this PR? - `collect_left_input` normalizes the build key columns once and keeps the result as the join's build keys. The in-list and bounds dynamic filters and the build-side null check still read the original keys, which they already did before this point. - A rewritten copy is only made when a column actually holds `-0.0`; otherwise the stored key shares the original buffer. Any copy is charged to the build-side memory reservation. - `JoinKeyComparator::new_with_normalized_left` and `equal_rows_arr_with_normalized_left` skip the build-side pass. Hash join's lookup uses them; sort-merge, piecewise merge, ASOF and symmetric hash joins keep the existing behavior. The query from the review, TPC-H SF1, `target_partitions = 1`, `HashJoinExec` `join_time`, median of five interleaved runs: ```sql SELECT count(*) FROM (SELECT l_orderkey AS k, CAST(l_quantity AS DOUBLE) AS q FROM lineitem WHERE l_orderkey < 1500000) b JOIN (SELECT l_orderkey AS k, CAST(l_quantity AS DOUBLE) AS q FROM lineitem) p ON b.k = p.k AND b.q = p.q; ``` | keys | before | after | |---|---|---| | `(BIGINT, DOUBLE)` | 96.4 ms | 25.7 ms | | `(BIGINT, BIGINT)` control | 24.9 ms | 24.8 ms | ## What is the testing strategy for this PR? New tests: - `join_key_comparator_with_normalized_left_skips_left_normalization` in `joins/utils.rs` pins the new constructor's contract. - `collect_left_input_stores_normalized_float_keys` checks that Float64 and Float32 build keys are stored with `+0.0` while the batch keeps `-0.0`, that no copy is made without `-0.0`, and that the copy is charged to the reservation. - `join_inner_single_float_key_negative_zero` covers a single float key with `-0.0` and `+0.0` on both sides, over batch sizes and both null equality modes. - `test_null_aware_left_anti_float_scope_negative_zero` covers both null-aware scope lookups with float scope keys. `join_inner_multi_key_float_zero_and_nulls_across_chunks` and the existing join tests and `joins`, `null_aware` and `subquery` sqllogictest files pass unchanged. ## Are there any user-facing changes? No.
…e#25658) ## Which issue does this PR close? - Closes apache#12131. The fused index compare that issue asked for landed in apache#23843; this picks up the per-chunk allocations it left behind and removes the stale note that still pointed at the old code. ## Rationale for this change Three pieces of per-chunk work on the hash join probe path repeat work whose inputs do not change within a probe batch. The multi-column key comparator was rebuilt for every output chunk, and building it scans the entire build side for negative zero when a key is a float. The integer-map path cloned both index buffers per chunk. Every output batch deep-cloned the output schema, metadata included, to wrap it in a new `Arc`. ## What changes are included in this PR? - The key comparator is built once per probe batch, cached on the stream, and cleared when the next batch arrives or the stream stops. - The integer-map path takes and returns its index buffers instead of cloning them. - `build_batch_from_indices` and `build_batch_empty_build_side` accept a `SchemaRef` and clone the `Arc`. Two-column join on `(l_orderkey, CAST(l_quantity AS DOUBLE))` over TPC-H SF1 with a 60K row build side and 17M output rows, median of five: 150 ms before, 108 ms after; `HashJoinExec` `elapsed_compute` 147 ms to 104 ms. Low-fanout float joins, the `hj` benchmark queries 23 to 25, and TPC-H q7 and q9 stay within run-to-run noise. ## What is the testing strategy for this PR? New tests in `datafusion/physical-plan/src/joins/hash_join`: `join_inner_multi_key_float_zero_and_nulls_across_chunks`, `join_inner_multi_key_across_probe_batches` (fails if the per-batch comparator reset is removed), and `join_inner_array_map_across_chunks`. Existing join tests and the `join` sqllogictest files pass unchanged. ## Are there any user-facing changes? No.
## Which issue does this PR close? - Follow-up to apache#25658, from @jayzhan211's review there. No separate issue. This branch is stacked on apache#25658, so until that merges the diff here includes its commits. Only the last commit is new. ## Rationale for this change Hash joins on float keys get much slower as the build side grows. The comparator that checks key equality is built once per probe batch, and building it rewrites `-0.0` to `+0.0` across the whole build key column so that `-0.0 = 0.0` holds. That scan repeats for every probe batch even though the build side never changes, so a float-key join costs build rows times probe batches, while the same join on integer keys does not. ## What changes are included in this PR? - `collect_left_input` normalizes the build key columns once and keeps the result as the join's build keys. The in-list and bounds dynamic filters and the build-side null check still read the original keys, which they already did before this point. - A rewritten copy is only made when a column actually holds `-0.0`; otherwise the stored key shares the original buffer. Any copy is charged to the build-side memory reservation. - `JoinKeyComparator::new_with_normalized_left` and `equal_rows_arr_with_normalized_left` skip the build-side pass. Hash join's lookup uses them; sort-merge, piecewise merge, ASOF and symmetric hash joins keep the existing behavior. The query from the review, TPC-H SF1, `target_partitions = 1`, `HashJoinExec` `join_time`, median of five interleaved runs: ```sql SELECT count(*) FROM (SELECT l_orderkey AS k, CAST(l_quantity AS DOUBLE) AS q FROM lineitem WHERE l_orderkey < 1500000) b JOIN (SELECT l_orderkey AS k, CAST(l_quantity AS DOUBLE) AS q FROM lineitem) p ON b.k = p.k AND b.q = p.q; ``` | keys | before | after | |---|---|---| | `(BIGINT, DOUBLE)` | 96.4 ms | 25.7 ms | | `(BIGINT, BIGINT)` control | 24.9 ms | 24.8 ms | ## What is the testing strategy for this PR? New tests: - `join_key_comparator_with_normalized_left_skips_left_normalization` in `joins/utils.rs` pins the new constructor's contract. - `collect_left_input_stores_normalized_float_keys` checks that Float64 and Float32 build keys are stored with `+0.0` while the batch keeps `-0.0`, that no copy is made without `-0.0`, and that the copy is charged to the reservation. - `join_inner_single_float_key_negative_zero` covers a single float key with `-0.0` and `+0.0` on both sides, over batch sizes and both null equality modes. - `test_null_aware_left_anti_float_scope_negative_zero` covers both null-aware scope lookups with float scope keys. `join_inner_multi_key_float_zero_and_nulls_across_chunks` and the existing join tests and `joins`, `null_aware` and `subquery` sqllogictest files pass unchanged. ## Are there any user-facing changes? No.
Which issue does this PR close?
equal_rows_arr#12131. The fused index compare that issue asked for landed in refactor join-key equality filtering #23843; this picks up the per-chunk allocations it left behind and removes the stale note that still pointed at the old code.Rationale for this change
Three pieces of per-chunk work on the hash join probe path repeat work whose inputs do not change within a probe batch. The multi-column key comparator was rebuilt for every output chunk, and building it scans the entire build side for negative zero when a key is a float. The integer-map path cloned both index buffers per chunk. Every output batch deep-cloned the output schema, metadata included, to wrap it in a new
Arc.What changes are included in this PR?
build_batch_from_indicesandbuild_batch_empty_build_sideaccept aSchemaRefand clone theArc.Two-column join on
(l_orderkey, CAST(l_quantity AS DOUBLE))over TPC-H SF1 with a 60K row build side and 17M output rows, median of five: 150 ms before, 108 ms after;HashJoinExecelapsed_compute147 ms to 104 ms. Low-fanout float joins, thehjbenchmark queries 23 to 25, and TPC-H q7 and q9 stay within run-to-run noise.What is the testing strategy for this PR?
New tests in
datafusion/physical-plan/src/joins/hash_join:join_inner_multi_key_float_zero_and_nulls_across_chunks,join_inner_multi_key_across_probe_batches(fails if the per-batch comparator reset is removed), andjoin_inner_array_map_across_chunks. Existing join tests and thejoinsqllogictest files pass unchanged.Are there any user-facing changes?
No.