Add plan optimizer pass that extracts join keys and uses them to construct pre-filters for inner joins - #22996
Conversation
Add generic dynamic-planning support for join key prefilters in the streaming actor graph. The planner evaluates join type, key compatibility, size estimates, and configured selectivity thresholds to decide when a small side can build a bloom/key prefilter for the larger side before shuffle. The implementation supports prefix key selection for multi-key joins, records structured trace metadata and skip reasons, preserves the original full join after the row-reduction stage, and exposes conservative dynamic-planning options for enabling, sizing, and tracing the prefilter path.
Add a streaming optimizer pass that inserts generic derived key-domain semi joins before actor-graph lowering. The pass uses existing dynamic-planning scan statistics and join metadata to reduce large join inputs from selective domains while preserving the original full join for correctness. The optimizer handles simple selective-domain filters and constrained multi-key domains, including cases where a selective key on one side can narrow the domain used to prefilter a larger source. It skips unsupported join shapes, non-column keys, unselective simple domains, and non-inner joins. Wire the pass into streaming execution behind dynamic-planning options and add focused tests plus config coverage.
|
Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually. Contributors can view more details about this message here. |
Reject boolean values for join_prefilter_max_key_columns instead of accepting them through Python's bool-is-int relationship. Add explicit config validation coverage so only None and positive integer limits remain valid.
Use a single typed helper for optional environment-value parsing and define the optional float and int converters in terms of it. This keeps the None/null handling in one place without changing accepted values.
Make join_prefilter_threshold the single dynamic-planning threshold for join prefilters, preserving the existing 0.5 default. Remove the obsolete bloom_filter_threshold option, its fallback/override tests, and the unused use_bloom_filter helper now replaced by _select_join_prefilter.
Allow numeric integer values such as 0 for join_prefilter_threshold and normalize the stored value to float during DynamicPlanningOptions validation. Reject booleans explicitly and add config coverage for the documented disable value.
Co-authored-by: Lawrence Mitchell <wence@gmx.li>
Clarify that join_prefilter_max_key_columns controls the size of the join-key prefix used by the prefilter, rather than selecting an arbitrary key subset.
Replace the hand-written JoinPrefilterDecision trace metadata mapping with dataclasses.asdict, so the trace output follows the dataclass fields without duplicating the field list.
Drop the always-true JoinPrefilterDecision.considered field since the presence of a decision already means the prefilter was considered. Inline skipped JoinPrefilterDecision construction at the return sites so the selector does not carry a helper that only forwards dataclass arguments.
Drop the no_join_keys branch from the join prefilter selector. Keyless cross joins are already unsupported by the selector, so cover that behavior directly instead of carrying a separate skip reason.
Replace the defensive mismatched-key skip reason with an assertion. A valid Join IR must provide the same number of left and right join keys, so reaching this state indicates malformed join metadata rather than a prefilter planning decision.
Remove the redundant build_side field from JoinPrefilterDecision. The bloom-filter build side is the inverse of filter_side, so task construction now derives that relationship from the side being filtered.
Drop apply_side_prepartitioned from join prefilter trace metadata. The prefilter does not make adaptive decisions from this value and the information is not consumed elsewhere, so keeping it in the filter trace adds noise without affecting planning.
Construct remaining prefilter tests using Polars queries
|
I have done another pass here and added docstrings and fixed a number of bugs around handling of node equality in DAGs. We now trace the full path from a join table to the nodes that produce the key as input. This allows us to target replacement only on one side of a self-join (this didn't work previously). |
| @dataclasses.dataclass | ||
| class LoweringInfo: | ||
| """Information produced by optimizing and lowering an IR graph.""" | ||
|
|
||
| optimized: IR # IR after optimization | ||
| lowered: IR # optimized IR after lowering | ||
| partition_info: MutableMapping[ | ||
| IR, PartitionInfo | ||
| ] # Partition mapping for nodes in the lowered IR. |
There was a problem hiding this comment.
Removing the nested-nested tuples.
| def remove_cache_nodes(ir: IR) -> IR: | ||
| """Remove logical cache nodes while preserving shared DAG structure.""" | ||
|
|
||
| def rewrite(node: IR, rec: GenericTransformer[IR, IR, None]) -> IR: | ||
| if isinstance(node, Cache): | ||
| return rec(node.children[0]) | ||
| return reuse_if_unchanged(node, rec) | ||
|
|
||
| mapper: GenericTransformer[IR, IR, None] = CachingVisitor(rewrite, state=None) | ||
| return mapper(ir) |
There was a problem hiding this comment.
Streaming executor doesn't treat Cache at all, and it just gets in the way of some rewrites, so remove it.
| """ | ||
| stats = allgather_stats(comm, ctx.br(), ir, config_options, py_executor) | ||
|
|
||
| lowering, node_map = lower_ir_graph_with_node_map( |
There was a problem hiding this comment.
I don't think any Quent things should be considered a blocker right now, but I'll need to check on this.
On main, we emit two plans, which show up in the DAG view as the Logical and Physical plans. With this change, I think we emit
- L755 emits a plan for
plan, which comes fromoptimized(lowering.optimized) - L765 emits a plan for
ir:lowering.lowered, with ties back tolowering.optimizedvialogical_op_by_id)
and we don't emit the original, unlowered plan anywhere. We can emit as many or few plans as we want here. Quent is flexible.
There was a problem hiding this comment.
OK, so optimized is the "logical" plan we now consider for execution. And lowered is the thing we actually execute. I suppose we want a stage from "input -> optimized" too?
There was a problem hiding this comment.
Possibly, if we think it'd be valuable to see what the optimizer changed. But we can handle that later.
| @dataclasses.dataclass(frozen=True) | ||
| class JoinDomainPrefilterOptions: | ||
| """ | ||
| Configuration for the logical join-domain prefilter rewrite. |
There was a problem hiding this comment.
I'd like to document the concept of "join domain prefilter" somewhere, possibly here in this docstring.
What's a "domain"? What do numerator (domain) and denominator (target) values in the threshold computation represent?
There was a problem hiding this comment.
Does the docstring at the top of join_domain_prefilter.py help? If so I can try and change the nouns here.
There was a problem hiding this comment.
I don't love this terminology so am open to other options
TomAugspurger
left a comment
There was a problem hiding this comment.
Docs / renaming look good.
I'm not worried about the Quent side currently, since that's all still in flux.
| """ | ||
| stats = allgather_stats(comm, ctx.br(), ir, config_options, py_executor) | ||
|
|
||
| lowering, node_map = lower_ir_graph_with_node_map( |
There was a problem hiding this comment.
Possibly, if we think it'd be valuable to see what the optimizer changed. But we can handle that later.
|
Question / future direction, definitely not blocking: Could we eventually split this into static candidate placement plus runtime activation? The planner could identify where a domain prefilter might be useful, but the actor graph could use sampled/runtime metadata to decide whether to actually run the semi-filter or just pass data through unchanged. |
Yes, I intend to replace these actual semijoins with "hints" |
|
I am concerned that occasionally the tests time out in, seemingly unrelated to this change, some tests |
wence-
left a comment
There was a problem hiding this comment.
test timeouts in some runs leave me concerned
|
Did you rerun a job that had a test timeout? I saw a timeout in yesterday's nightly and #23284 (review). I've started collecting notes in #23292. |
|
/merge |
This changes the cudf-polars default configuration to disable the join filter pushdown rewrite added in #22996. It can be re-enabled through an env var with CUDF_POLARS__EXECUTOR__JOIN_FILTER_PUSHDOWN=1, or through python by passing `join_filter_pushdown=JoinFilterPushdownOptions()` I'm proposing to disable this for the 26.08 release based on some slowdowns in our pds-h benchmarks at smaller scales (SF-1K on a single H100 GPU). <img width="1694" height="391" alt="image" src="https://github.com/user-attachments/assets/67766d4c-6d35-4afc-9751-29a98344943b" /> Eventually, I think that the join filter pushdown optimization should be on by default. But I'd like to understand a bit more about the slowdowns observed in that benchmark before turning it on by default. Authors: - Tom Augspurger (https://github.com/TomAugspurger) Approvers: - Vyas Ramasubramani (https://github.com/vyasr) URL: #23407
Teach the join filter pushdown planner to estimate the source-scan cost needed to build candidate domains. Candidate selection now prefers cheaper domain producers before smaller estimated output rows, and rejects pushdown when the domain-build cost is too large relative to the target being reduced. Also prevent a single-source domain from being stacked onto a target source that is already below the filtered side of a semi join. This heuristic does a better job of keeping profitable domain choices while avoiding cases where pushing down a filter would add an additional high-cardinality source onto a join target that is already significantly filtered. Additionally, extends trace metadata with estimated target, domain, and constraint costs. Material results of this change running NDSH SF30K on 8xNVL4 nodes are (previous results come from the change in #22996): * **Q9 improved runtime: 11.77s lukewarm, 7.45s hot** (previously 47.12s lukewarm, 32.68s hot) * Q5 unchaned: 9.55s lukewarm, 5.31s hot (previously 9.35s lukewarm, 5.14s hot) * **NDSH SF30K for all 22 queries: 118.87s lukewarm, 111.63s hot** Authors: - Peter Andreas Entschev (https://github.com/pentschev) - Lawrence Mitchell (https://github.com/wence-) Approvers: - Gil Forsyth (https://github.com/gforsyth) - Tom Augspurger (https://github.com/TomAugspurger) URL: #22997
Add a streaming optimizer pass that attempts to pre-filter one side of an input to inner joins before actor-graph lowering.
The pass uses existing dynamic-planning scan statistics and join metadata to determine where it is beneficial to push a semi-join against a join key onto the other side of a join.
The simplest example of such a rewrite is that we turn
into, assuming we somehow determine that
rightis selective,( left.join(right.select("key"), on="key", how="semi") .join(right, on="key", how="inner") )The optimization pass handles the case where a "domain" key, used to provide the right-hand side of the semi join, is "simple" and derived directly from some input node, as well as the more complex case where a domain key is already constrained by some other semi-join filter.
Only inner joins are rewritten, and only if all the keys are simple column keys. If heuristics determine that simple keys are not selective, we also don't perform the rewrite.
Material results of this change running NDSH SF30K on 8xNVL4 nodes are (previous results come from the change in #22995):