Repository navigation
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Builds on #10; review that first.
What and why
With
iceberg.dynamic_filter_pruning_enabledon, several TPC-H SF10 Iceberg queries got slower, not faster. Before this change (median of 3 alternating rounds, 10 iterations each):The cause is the shape of the predicate the Iceberg reader was handed. Its row filter costs O(rows × predicate size): every IN literal becomes one equality and one OR, and AND/OR don't short-circuit. The partitioned-join rewrite from #9 made that predicate an OR of every build partition's filter:
Design
Before either consumer uses a dynamic filter, its current value is normalized into a domain for each probe column:
[min, max], or an exact value set, which then implies its own range;The domain is then rebuilt as a small expression. For each constrained column:
col >= min AND col <= max [AND col IN (set)] [OR col IS NULL]A single value is written
col = v, and the per-column constraints are AND-ed together.Rules:
AND intersects domains: ranges intersect, sets intersect, and a set is filtered by a range. When a value set exists, the range is derived from the set's min and max, so the output is as tight as the domain allows. An empty intersection becomes literal
false.Unconvertible conjuncts constrain nothing and are dropped. This covers hash lookups, casts, NOT, columns compared with columns, and literals of another type.
OR and
CASEbranches are unioned:false(or NULL) branch adds nothing. Atruebranch, e.g. theELSEof a canceled build partition, removes the constraint.CASE, the THEN and ELSE branches are unioned and the WHEN and base expressions are ignored. A missing ELSE yields NULL, so it adds nothing.These rules also cover the merged distributed shape, an OR of per-task CASEs.
Multi-column keys become one constraint per column. That is wider than the tuples but still holds all of them.
The cap reuses
datafusion.optimizer.hash_join_inlist_pushdown_max_distinct_values(default 150), so no new configuration is added. It applies to unions and to IN lists.Literals follow the same rules as the existing predicate converter:
Why it's conservative
Every rule only keeps or widens the set of rows that pass, so the domain contains every row the filter keeps. Widening is only safe where the filter appears in a monotonic position, so normalization is applied:
CASEbranches. Anything else constrains nothing.The
FilterExecabove the scan still evaluates the original filters, so this only changes what is read. Results are identical with the setting on and off.Where it's used, and caching
CompactedDynamicFilters(the newdynamic_domain.rs) is built once per scan stream and shared by both consumers:PruningPredicatefrom it.It is recomputed only when the filters' generation changes, so per-file work is a cache hit. It replaces the OR-of-branches rewrite:
case_as_or.rsand the predicate converter'sCASEhandling are removed, because they are now dead code.Design note: comparison with runtime-filter domains
This is the same idea as compacting runtime filters into per-column domains (as Trino does):
CASEof partitioned joins, rather than raw build-side values. So the domain is derived structurally (intersect under AND, union under OR/CASE), and anything it can't read is treated as unconstrained rather than failing.PruningPredicateconsume it unchanged.Tests
dynamic_domain.rs) snapshot the compacted expression for:false;runtime_predicate.rs). It runs a forced partitioned join over the taxi fixture through the normal planning path. The join's filter is a 2-branchCASEcarrying 50,000 IN-list items; the reader receivespickup_date >= 2024-01-08 AND pickup_date <= 2024-01-14 AND pickup_date IN (2024-01-14, 2024-01-08).dynamic_filter_pruning.rsintegration tests pass with unchanged snapshots, so rows and files read are the same as before this change. That includes the partitioned-join and file-skipping cases, and the 2-worker distributed correctness run.cargo fmt --all;cargo clippy -p datafusion-distributed-iceberg -p datafusion-distributed-iceberg-test-tools --all-targets, with no warnings;cargo test -p datafusion-distributed-iceberg;cargo test --features integration --test dynamic_filtering, with 18 passed.Benchmarks
Setup:
baseis this PR's base andheadis this PR.Deterministic metrics from one
--debugrun each:How to read the rows rows:
FilterExec. Range-based row-group, page and file pruning still applies.One alternating full-suite pass at head (single node, 2 rounds × 3 iterations, order reversed in round 2). The total of per-query medians was:
q8 and q9 were slower with the setting on in both rounds (q8 576 → 750 ms, q9 896 → 981 ms), which matches the focused rounds above: their remaining cost with the setting on is the reader evaluating the compacted ranges. No other query was slower in both rounds. The per-query noise under this load is large (±30–40%).
With the setting off, base and head run the same code path (the shared filter state is never created, so no provider or pruner is installed). A dedicated re-run of q8 and q9 (single node, 4 alternating rounds × 10 iterations, 1-minute load 28–37) confirms that the setting-off difference above is noise: