adriangb opened a new pull request, #24235:
URL: https://github.com/apache/datafusion/pull/24235
## Which issue does this PR close?
- None. This is a prototype opened as a **draft, for evaluation only** — I
want the project's benchmark fleet to judge it, not to merge it as-is. See
"Honest summary" below: on TPC-H the bounds split does not pay for itself, and
I would rather have that measured on neutral hardware than argued about.
## Rationale for this change
A dynamic filter produced by a **hash-partitioned** join currently reaches
the scan as a single opaque conjunct:
```
DynamicFilter[ CASE hash_repartition % n
WHEN 0 THEN bounds_0 AND membership_0
WHEN 1 THEN bounds_1 AND membership_1
... END ]
```
Everything the join knows is sealed inside one expression.
`split_conjunction` splits only on a top-level `BinaryExpr(And)` and does not
descend into a `DynamicFilterPhysicalExpr`, so `row_filter.rs` builds exactly
one `ArrowPredicate`: every probe row must have its routing hash computed
before any row can be rejected, and row-group pruning can make nothing of a
`CASE`.
This PR pushes two filters instead:
```
DynamicFilter[ merged bounds ] AND DynamicFilter[ membership ]
```
Two **wrappers**, not two expressions inside one wrapper, is the
load-bearing detail. Split in two, each becomes its own `ArrowPredicate`, and
arrow-rs applies them in sequence against an accumulating `RowSelection` —
building a fresh array reader per predicate over only the still-selected rows.
The intent is that the cheap vectorized range check runs first and the routing
hash plus hash-table lookup only see the survivors.
### Honest summary: on TPC-H, the bounds half does not pay for itself
The idea above is sound but **the premise fails on this workload, for a
structural reason worth stating plainly.** Hash routing scatters keys uniformly
across partitions, so every partition's key range converges on the *global*
domain. The union of those ranges is then very close to the full column domain.
TPC-H q3's merged bound is `l_orderkey ∈ [96, 5999975]` against a ~6M-row
domain: it rejects roughly **0.002%** of rows. The cheap first predicate
compacts nothing, so all it can do is add cost.
The part that carries real value is the **`InList` collapse** (third
commit). When every non-empty build partition pushes an `InList`, the routing
`CASE` is redundant — routing is a deterministic function of the key columns,
so testing a key against the *union* of the lists accepts and rejects precisely
what the `CASE` does. That is an equality, not a relaxation, and it removes the
routing hash from the probe path entirely, leaving an `InListExpr` that
`datafusion/pruning` actually understands. That is where the measured wins come
from.
The catch is that it fires rarely: `hash_join_inlist_pushdown_max_size` is
128 KiB with a 150-distinct-value cap, **per partition**, so most joins never
produce an all-`InList` build.
### Measurements
Re-measured from scratch, because an earlier round of numbers was not
trustworthy. Protocol:
- **Both binaries built from the same worktree and the same target dir**
(build change → copy binary aside → revert to base → rebuild). An earlier
comparison across two worktrees at different upstream commits attributed
hundreds of unrelated commits to this change; its tell was a 6.7% delta on a
control query containing no joins.
- **Counterbalanced ordering within each round** — (branch, base) on odd
rounds, (base, branch) on even — reported separately as well as pooled.
- **Controls**: TPC-H q1 and q6 contain no joins, so this PR cannot affect
them. Their delta *is* the noise floor. Nothing smaller than it is reported as
a result.
- 12 rounds × `--iterations 5`, TPC-H SF=1 parquet. The machine was not idle
and was not quiesced.
Primary statistic is the **paired** per-round delta (median over 12 rounds
of `branch/base − 1`); pairing cancels round-level machine drift, which matters
because the unpaired cross-round spread exceeds 100% on some queries. Positive
= branch slower. `p` is an exact two-sided sign test over the 12 paired rounds.
**`pushdown_filters = false`** — control noise floor **5.09%**
| query | paired Δ% | branch-first Δ% | base-first Δ% | rounds branch faster
| sign p |
|---|---|---|---|---|---|
| q1 *(control)* | +5.09 | +10.83 | −0.96 | 5/12 | 0.774 |
| q6 *(control)* | +1.10 | −9.51 | +2.69 | 5/12 | 0.774 |
| q3 | +2.08 | +0.26 | +4.06 | 5/12 | 0.774 |
| q5 | −4.28 | −4.97 | −2.02 | 7/12 | 0.774 |
| q9 | +4.74 | +7.09 | +4.74 | 1/12 | 0.006 |
| q17 | +8.33 | +10.50 | +6.24 | 2/12 | 0.039 |
| q18 | +1.35 | +2.26 | +1.35 | 5/12 | 0.774 |
**`pushdown_filters = true`** — control noise floor **1.99%**
| query | paired Δ% | branch-first Δ% | base-first Δ% | rounds branch faster
| sign p |
|---|---|---|---|---|---|
| q1 *(control)* | +1.99 | −1.47 | +7.16 | 4/12 | 0.388 |
| q6 *(control)* | −0.38 | −8.19 | +3.78 | 6/12 | 1.000 |
| q3 | −0.72 | −1.19 | +1.58 | 7/12 | 0.774 |
| q5 | −1.14 | −5.66 | +7.93 | 6/12 | 1.000 |
| q9 | +5.34 | +10.16 | +2.63 | 3/12 | 0.146 |
| q17 | **−33.76** | −34.61 | −33.08 | 12/12 | 0.000 |
| q18 | −11.44 | −11.14 | −12.55 | 9/12 | 0.146 |
Reading these:
- **The previously claimed "~4% cost at `pushdown_filters=true`" did NOT
survive correct measurement.** It is not a smaller cost than reported — it is
not there. On q3 the paired delta is **−0.72%**, i.e. the sign flipped, with 7
of 12 rounds actually favouring the branch (p = 0.77); on q5 it is **−1.14%**,
6/12, p = 1.00. Both sit inside a **1.99%** control floor, so neither direction
is resolvable. That earlier ~4% figure was an artifact of the confounded
protocol described above (chiefly the cross-worktree build), and it should not
be carried forward.
- **q17 −33.8% at `pushdown_filters=true` is unambiguous**: 12/12 rounds,
both orderings agree to within 1.5 points, far outside any floor. q18 −11.4% is
consistent in direction across both orderings (9/12 rounds). Both are the
`InList` collapse, not the bounds split.
- **The residual cost instead shows up at `pushdown_filters=false`**, on q9
(+4.7%, only 1 of 12 rounds faster, p = 0.006) and q17 (+8.3%, 2/12, p =
0.039). To be explicit about how much weight these carry: **the control floor
in this mode is 5.09%, so both rest on the paired sign test — the consistency
of the direction across rounds and across both orderings — rather than on their
magnitude.** q9's +4.7% is *below* that floor outright; what makes it worth
reporting is that the branch lost 11 of 12 paired rounds, not that the number
is large. Read them as "there is probably a small real cost here", not as
calibrated cost estimates.
- **q17 flips from +8.3% at `pushdown_filters=false` to −33.8% at
`pushdown_filters=true`.** That is the expected shape rather than a
contradiction: the `InList` collapse only pays when the dynamic filter is
actually evaluated per row inside the scan, which is precisely what
`pushdown_filters=true` turns on. With pushdown off, the filter never reaches
the row-level evaluation path where removing the routing hash would help — but
the extra wrapper, the bounds union, and the second `DynamicFilter` are
constructed and charged either way. So the same change is a small cost in one
mode and a large win in the other.
- q1's control row at `pushdown_filters=true` (+1.99% pooled, but −10.18%
branch-first vs +10.02% base-first in the unpaired view) is a good
advertisement for reporting orderings separately — a query this change provably
cannot touch can still show a double-digit unpaired swing.
### The lever this branch did not pull
The obvious next move, which is **not** attempted here: gate the bounds
conjunct on a **degeneracy check** using probe-side column statistics from
`right_child.partition_statistics()`. When the merged union covers (or nearly
covers) the probe column's own min/max domain, the conjunct is known to be
useless before a single row is read, and it should simply not be pushed. That
is the most likely way to turn the bounds half from a cost into a neutral — it
keeps the win in the cases where per-partition ranges genuinely are narrow
(correlated or pre-clustered build sides) and stops paying for it on uniformly
hashed keys like TPC-H's. I would rather land that gate than tune anything here
to make the current numbers look better.
## What changes are included in this PR?
Three commits, each building green on its own:
1. **`refactor: let a hash join carry more than one self filter`** — routes
`handle_child_pushdown_result` and `dynamic_expressions_produced` through one
`HashJoinExecDynamicFilter::produced_expressions` helper and pops the driven
filter off the pushed list instead of indexing position zero. No behaviour
change; exactly one self filter is still pushed.
2. **`Split the partitioned hash join dynamic filter into bounds AND
membership`** — adds `bounds_union.rs`, which computes the set-theoretic union
of the per-partition ranges, and pushes it as a second, routing-free
`DynamicFilter` wrapper. The union is a *relaxation* (it admits keys that route
to a partition not holding them), which is sound because the membership half
behind it is exact. Multi-column keys are merged per column and emitted as a
product of ranges, a superset of the true union. The bounds stay inside the
`CASE` exactly as before whenever the union cannot describe the build side (a
cancelled partition), the merge is degenerate, or the second filter did not
survive pushdown — so no plan loses selectivity it previously had.
`CollectLeft` joins have no routing to hoist and are untouched.
3. **`Collapse an all-InList partitioned membership check into one InList`**
— when every non-empty build partition pushed an `InList`, replace the routing
`CASE` with a single `InListExpr` over the union. Capped at 1 MiB, since each
partition's list is independently limited and the concatenation grows with
partition count; past some size the routed `CASE` (where a probe row only ever
probes one list) is the cheaper shape. Partitions pushing a hash map, and
builds where a cancelled partition makes the union incomplete, keep the `CASE`.
## Are these changes tested?
Yes.
- `bounds_union.rs` ships unit tests for the union arithmetic, including the
multi-column product, degenerate merges, and the relaxation estimate.
- Existing hash-join and dynamic-filter coverage passes: `cargo test
--profile ci -p datafusion-physical-plan --lib` — 1675 passed, 0 failed.
- `cargo test --profile ci -p datafusion-sqllogictest --test sqllogictests`
— 502/502 files pass.
- Three `.slt` files have updated `EXPLAIN` output, showing the intended
`DynamicFilter [ empty ] AND DynamicFilter [ empty ]` shape on the probe-side
scan: `preserve_file_partitioning.slt`, `push_down_filter_parquet.slt`,
`statistics_registry.slt`. These are plan-shape changes only; every result set
is unchanged.
- `cargo fmt --all` clean; `cargo clippy --profile ci --all-targets
--features
avro,json,backtrace,extended_tests,recursive_protection,parquet_encryption
--workspace -- -D warnings` clean.
## Are there any user-facing changes?
No API changes and no changes to query results.
The one visible difference is in `EXPLAIN` output: the probe-side scan of a
partitioned hash join now shows two `DynamicFilter` conjuncts where it
previously showed one. Anything asserting on that exact string will need
updating.
No new configuration options. The existing
`hash_join_inlist_pushdown_max_size` (128 KiB) and
`hash_join_inlist_pushdown_max_distinct_values` (150), both per-partition,
continue to govern whether the `InList` path is reachable at all.
--
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]