Skip to content

Commit 836f46e

Browse files
committed
perf(hash-join): union-bounds fast path for Partitioned dynamic filter (root fix)
Reverts the tactical fix from the previous commit and cures the same regression at its source. The prior commit skipped DynamicFilterPhysicalExpr-containing conjuncts from PostScanFilter for pushdown_filters=false; that recovered TPC-H but killed TPC-DS Q72 (4.91x faster -> no change) by removing row-level pruning for CollectLeft's cheap bounds too. Root cause: on PartitionMode::Partitioned, SharedBuildAccumulator emitted a per-partition 'CASE hash(col) % N WHEN pid THEN bounds ELSE lit(false) END' as the dynamic filter. On the probe scan this evaluates hash + modulo + CASE branch per row -- the profile hotspot (expressions::case::PartialResultIndex::merge_n at 1.11% self-time on TPC-H Q9 vs 0% on main). The routing existed to keep the per-partition bounds exact -- a probe row X with hash(X) % N == P would only be checked against partition P's bounds. That's exact but redundant with the downstream hash lookup (which is also per-partition and exact). The lookup filters exactly what CASE was filtering, at a lower per-row cost, so the CASE routing buys nothing on the probe scan. This commit, when the membership gate is off (the production default after the split-membership commit), emits the union of per-partition bounds instead: col >= min(min_0, ..., min_{N-1}) AND col <= max(max_0, ..., max_{N-1}) Same shape as PartitionMode::CollectLeft. A probe row can pass the union and still miss its build partition, but the exact hash lookup downstream drops it -- no wrong results. Empty partitions contribute nothing to the union; if every partition is empty the filter is lit(false); a canceled partition falls back to lit(true) (permissive, we lack the info to safely narrow). Membership-opt-in retains the historical CASE hash-routed form so InListExpr / HashTableLookupExpr can be applied to the correct partition's build values. With the expensive per-row form gone, the pushdown_filters=false PostScanFilter is cheap again, so opener/mod.rs no longer needs to filter dynamic conjuncts out of post_scan_conjuncts -- reverted. Local TPC-H SF1 (release-nonlto, 5 iters, warm): - baseline (HEAD~2, pre-#22384) Q9: 40 ms (avg 46) - PR before any fix Q9: 80 ms (avg 82) -- 2.09x regression - PR + this fix Q9: 35 ms (avg 52) -- back at baseline, no CASE hotspot - Full TPC-H avg: baseline 27.65, this fix 26.99 ms (net -2%) Snapshot in filter_pushdown.rs regenerated to reflect the union form (the old snapshot's 'CASE hash_repartition % 12 WHEN 5 ...' is gone; new form is 'a@0 >= aa AND a@0 <= ab AND b@1 >= ba AND b@1 <= bb').
1 parent 5a8ca02 commit 836f46e

3 files changed

Lines changed: 202 additions & 13 deletions

File tree

‎datafusion/core/tests/physical_optimizer/filter_pushdown.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1083,7 +1083,7 @@ async fn test_hashjoin_dynamic_filter_pushdown_partitioned() {
10831083
- RepartitionExec: partitioning=Hash([a@0, b@1], 12), input_partitions=1
10841084
- DataSourceExec: file_groups={1 group: [[test.parquet]]}, projection=[a, b, c], file_type=test, pushdown_supported=true
10851085
- RepartitionExec: partitioning=Hash([a@0, b@1], 12), input_partitions=1
1086-
- DataSourceExec: file_groups={1 group: [[test.parquet]]}, projection=[a, b, e], file_type=test, pushdown_supported=true, predicate=DynamicFilter [ CASE hash_repartition % 12 WHEN 5 THEN a@0 >= ab AND a@0 <= ab AND b@1 >= bb AND b@1 <= bb AND struct(a@0, b@1) IN (SET) ([{c0:ab,c1:bb}]) WHEN 8 THEN a@0 >= aa AND a@0 <= aa AND b@1 >= ba AND b@1 <= ba AND struct(a@0, b@1) IN (SET) ([{c0:aa,c1:ba}]) ELSE false END ]
1086+
- DataSourceExec: file_groups={1 group: [[test.parquet]]}, projection=[a, b, e], file_type=test, pushdown_supported=true, predicate=DynamicFilter [ a@0 >= aa AND a@0 <= ab AND b@1 >= ba AND b@1 <= bb ]
10871087
"
10881088
);
10891089

‎datafusion/datasource-parquet/src/opener/mod.rs‎

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1407,20 +1407,10 @@ impl RowGroupsPrunedParquetOpen {
14071407
// through to post-scan.
14081408
row_filter_generator.rejected_conjuncts().to_vec()
14091409
} else if let Some(predicate) = prepared.predicate.as_ref() {
1410-
// Pushdown disabled: static conjuncts run post-scan (in-scan
1411-
// equivalent of a FilterExec). Dynamic filters are *dropped* here
1412-
// — they stay in `prepared.predicate` for the RowGroupPruner
1413-
// below (#22450 dynamic RG-level pruning), so their contribution
1414-
// survives. Applying them per-batch here is redundant with the
1415-
// downstream operator that already exact-matches on the same
1416-
// key (HashJoin hash lookup / TopK sort heap) and, for
1417-
// Partitioned HashJoin's `CASE hash(col) % N WHEN pid THEN
1418-
// bounds ELSE lit(false) END`, expensive.
1410+
// Pushdown disabled: the whole predicate runs post-scan (in-scan
1411+
// equivalent of a FilterExec).
14191412
datafusion_physical_expr::split_conjunction(predicate)
14201413
.into_iter()
1421-
.filter(|conjunct| {
1422-
!DynamicFilterTracking::classify(conjunct).contains_dynamic_filter()
1423-
})
14241414
.cloned()
14251415
.collect()
14261416
} else {

‎datafusion/physical-plan/src/joins/hash_join/shared_bounds.rs‎

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,46 @@ fn create_bounds_predicate(
184184
}
185185
}
186186

187+
/// Compute the union of `PartitionBounds` across all non-empty partitions:
188+
/// per-column min-of-mins and max-of-maxes. Returns `None` if no partition
189+
/// reported bounds. `PartitionBounds::column_bounds` is aligned by join-key
190+
/// index across partitions (all report bounds for the same join-key columns
191+
/// in the same order), so union is a straight element-wise reduce.
192+
///
193+
/// Used by [`SharedBuildAccumulator::build_filter`] to emit a single
194+
/// `col >= min AND col <= max` predicate for `PartitionMode::Partitioned`
195+
/// (matching `PartitionMode::CollectLeft`'s shape) instead of a per-partition
196+
/// `CASE hash(col) % N WHEN pid THEN bounds ELSE lit(false) END`. The union
197+
/// is a strictly-weaker filter — a probe row may pass it and still miss its
198+
/// specific build partition's hash lookup — but the downstream `HashJoinExec`
199+
/// hash lookup is exact, so no wrong results. Wins: per-row cost drops from
200+
/// (hash + modulo + CASE branch + bounds) to a single range check.
201+
fn union_partition_bounds<'a>(
202+
partitions: impl IntoIterator<Item = &'a PartitionBounds>,
203+
) -> Option<PartitionBounds> {
204+
let mut acc: Option<Vec<ColumnBounds>> = None;
205+
for p in partitions {
206+
let cols = &p.column_bounds;
207+
acc = Some(match acc.take() {
208+
None => cols.clone(),
209+
Some(mut existing) => {
210+
// All partitions must agree on the join-key columns.
211+
debug_assert_eq!(existing.len(), cols.len());
212+
for (slot, other) in existing.iter_mut().zip(cols.iter()) {
213+
if other.min < slot.min {
214+
slot.min = other.min.clone();
215+
}
216+
if other.max > slot.max {
217+
slot.max = other.max.clone();
218+
}
219+
}
220+
existing
221+
}
222+
});
223+
}
224+
acc.map(PartitionBounds::new)
225+
}
226+
187227
/// Combines a membership predicate and a bounds predicate with logical AND.
188228
///
189229
/// Returns `None` when neither is available; callers decide the fallback (e.g.
@@ -614,6 +654,71 @@ impl SharedBuildAccumulator {
614654
}
615655
},
616656
FinalizeInput::Partitioned(partitions) => {
657+
// Fast path: when per-partition membership is disabled
658+
// (the default), emit a single union-of-bounds predicate
659+
// across all reported partitions instead of a
660+
// `CASE hash(col) % N WHEN pid THEN bounds ELSE lit(false) END`.
661+
// The CASE form is exact per partition but pays hash +
662+
// modulo + CASE-branch per row on the probe scan — that
663+
// dominated the #22384 regression on TPC-H / TPC-DS. The
664+
// union bounds are strictly weaker (a probe row may pass
665+
// the range and still miss its build partition's hash
666+
// lookup), but the downstream hash lookup is exact so
667+
// the join is correct. RG-level pruning is *not* weaker
668+
// — the pruning predicate uses the same min/max as the
669+
// union — so `#22450` dynamic RG-level pruning is
670+
// unaffected. See #23701.
671+
if !self.enable_membership_filter {
672+
let mut has_canceled_unknown = false;
673+
let mut reported_bounds: Vec<&PartitionBounds> = Vec::new();
674+
for partition in partitions.iter() {
675+
match partition {
676+
PartitionStatus::Reported(pd)
677+
if matches!(pd.pushdown, PushdownStrategy::Empty) =>
678+
{
679+
// Empty partition contributes nothing to
680+
// the union — omit it.
681+
}
682+
PartitionStatus::Reported(pd) => {
683+
reported_bounds.push(&pd.bounds);
684+
}
685+
PartitionStatus::CanceledUnknown => {
686+
has_canceled_unknown = true;
687+
}
688+
PartitionStatus::Pending => {
689+
return datafusion_common::internal_err!(
690+
"attempted to finalize dynamic filter with pending partition"
691+
);
692+
}
693+
}
694+
}
695+
696+
let filter_expr = if has_canceled_unknown {
697+
// Missing info from some partitions — fall back to
698+
// a no-op filter so we don't drop matching rows.
699+
lit(true)
700+
} else if reported_bounds.is_empty() {
701+
// Every partition reported empty build data → no
702+
// probe row can match, filter everything out.
703+
lit(false)
704+
} else {
705+
let union =
706+
union_partition_bounds(reported_bounds.iter().copied());
707+
union
708+
.as_ref()
709+
.and_then(|b| create_bounds_predicate(&self.on_right, b))
710+
.unwrap_or_else(|| lit(true))
711+
};
712+
713+
self.dynamic_filter
714+
.update(self.null_aware_filter(filter_expr))?;
715+
return Ok(());
716+
}
717+
718+
// Slow path: membership opt-in retains the historical
719+
// per-partition CASE hash-routed form so InListExpr /
720+
// HashTableLookupExpr can be applied to the correct
721+
// partition's build values.
617722
let num_partitions = partitions.len();
618723
let routing_hash_expr = Arc::new(HashExpr::new(
619724
self.on_right.clone(),
@@ -1191,4 +1296,98 @@ mod tests {
11911296
assert!(matches!(partitions[0], PartitionStatus::CanceledUnknown));
11921297
assert_eq!(completed, 1);
11931298
}
1299+
1300+
/// Build a Partitioned accumulator with the membership gate off — the
1301+
/// production default post #23701 (the "union bounds" path).
1302+
fn make_partitioned_accumulator_bounds_only(
1303+
num_partitions: usize,
1304+
) -> SharedBuildAccumulator {
1305+
let on_right = test_on_right();
1306+
let dynamic_filter = test_dynamic_filter(&on_right);
1307+
SharedBuildAccumulator {
1308+
inner: Mutex::new(AccumulatorState {
1309+
data: AccumulatedBuildData::Partitioned {
1310+
partitions: vec![PartitionStatus::Pending; num_partitions],
1311+
completed_partitions: 0,
1312+
},
1313+
completion: CompletionState::Pending,
1314+
}),
1315+
completion_notify: Notify::new(),
1316+
dynamic_filter,
1317+
on_right,
1318+
repartition_random_state: SeededRandomState::with_seed(1),
1319+
probe_schema: test_probe_schema(),
1320+
null_aware: false,
1321+
enable_membership_filter: false,
1322+
}
1323+
}
1324+
1325+
/// With the membership gate off, Partitioned mode publishes a single
1326+
/// union-of-bounds predicate — no `CaseExpr`, no `HashExpr`, no per-row
1327+
/// hash routing. This is the invariant that removes the #22384 CASE
1328+
/// hash-routed regression on TPC-H / TPC-DS.
1329+
#[test]
1330+
fn partitioned_with_gate_off_publishes_union_bounds_no_case() {
1331+
let acc = make_partitioned_accumulator_bounds_only(2);
1332+
// Non-`Empty` pushdown (each partition has build data). Bounds
1333+
// are what actually flow into the filter — the membership gate
1334+
// is off so the `in_list` is ignored.
1335+
acc.build_filter(FinalizeInput::Partitioned(vec![
1336+
reported(in_list(&[1]), bounds(1, 10)),
1337+
reported(in_list(&[5]), bounds(5, 20)),
1338+
]))
1339+
.unwrap();
1340+
1341+
let expr = current_expr(&acc);
1342+
// No CASE, no HashExpr — just a plain `col >= min AND col <= max`.
1343+
assert!(
1344+
expr.downcast_ref::<CaseExpr>().is_none(),
1345+
"gate-off Partitioned must not emit CaseExpr; got {expr:?}",
1346+
);
1347+
assert!(
1348+
!format!("{expr:?}").contains("hash_repartition"),
1349+
"gate-off Partitioned must not emit HashExpr; got {expr:?}",
1350+
);
1351+
// Union across [1,10] and [5,20] is [1,20]. The predicate shape is
1352+
// `col >= 1 AND col <= 20` — an `And` at the top.
1353+
assert_top_binary_op(&expr, Operator::And);
1354+
// No `InListExpr` reaches the filter — the gate is off.
1355+
assert!(
1356+
expr.downcast_ref::<InListExpr>().is_none(),
1357+
"gate-off must not emit InListExpr; got {expr:?}",
1358+
);
1359+
}
1360+
1361+
/// All-empty Partitioned build data → union yields no bounds → filter
1362+
/// resolves to `lit(false)` so no probe row can match. Matches the
1363+
/// historical CASE form's `real_branches.is_empty()` branch.
1364+
#[test]
1365+
fn partitioned_with_gate_off_all_empty_publishes_lit_false() {
1366+
let acc = make_partitioned_accumulator_bounds_only(2);
1367+
acc.build_filter(FinalizeInput::Partitioned(vec![
1368+
reported(PushdownStrategy::Empty, no_bounds()),
1369+
reported(PushdownStrategy::Empty, no_bounds()),
1370+
]))
1371+
.unwrap();
1372+
1373+
let expr = current_expr(&acc);
1374+
assert_literal_bool(&expr, false);
1375+
}
1376+
1377+
/// A canceled partition means we lack information from that partition,
1378+
/// so we must not narrow the filter — a probe row that would have
1379+
/// matched that partition's build side must still get through. The
1380+
/// gate-off path falls back to `lit(true)` in this case (permissive).
1381+
#[test]
1382+
fn partitioned_with_gate_off_canceled_partition_is_permissive() {
1383+
let acc = make_partitioned_accumulator_bounds_only(2);
1384+
acc.build_filter(FinalizeInput::Partitioned(vec![
1385+
reported(PushdownStrategy::Empty, bounds(1, 10)),
1386+
PartitionStatus::CanceledUnknown,
1387+
]))
1388+
.unwrap();
1389+
1390+
let expr = current_expr(&acc);
1391+
assert_literal_bool(&expr, true);
1392+
}
11941393
}

0 commit comments

Comments
 (0)