Skip to content

Add plan optimizer pass that extracts join keys and uses them to construct pre-filters for inner joins - #22996

Merged
rapids-bot[bot] merged 87 commits into
rapidsai:release/26.08from
pentschev:cudf-polars/join-domain-prefilter
Jul 22, 2026
Merged

Add plan optimizer pass that extracts join keys and uses them to construct pre-filters for inner joins#22996
rapids-bot[bot] merged 87 commits into
rapidsai:release/26.08from
pentschev:cudf-polars/join-domain-prefilter

Conversation

@pentschev

@pentschev pentschev commented Jun 25, 2026

Copy link
Copy Markdown
Member

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

left.join(right, on="key", how="inner")

into, assuming we somehow determine that right is 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):

  • Q5 doesn't OOM on 8 nodes anymore and improved runtime performance: 9.35s lukewarm, 5.14s hot (previously 40.25s lukewarm, OOM on hot)
  • Q9 unchanged performance or slight regression: 47.12s lukewarm, 32.68s hot (previously 43.63s lukewarm, 30.56s hot)

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.
@pentschev pentschev self-assigned this Jun 25, 2026
@pentschev pentschev added feature request New feature or request 0 - Blocked Cannot progress due to external reasons non-breaking Non-breaking change cudf.polars labels Jun 25, 2026
@copy-pr-bot

copy-pr-bot Bot commented Jun 25, 2026

Copy link
Copy Markdown

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.

@github-actions github-actions Bot added Python Affects Python cuDF API. cudf-polars Issues specific to cudf-polars labels Jun 25, 2026
@GPUtester GPUtester moved this to In Progress in cuDF Python Jun 25, 2026
@wence-
wence- changed the base branch from main to pull-request/22995 June 26, 2026 08:03
@wence-
wence- changed the base branch from pull-request/22995 to main June 26, 2026 08:03
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.

@wence- wence- left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First pass

Comment thread python/cudf_polars/cudf_polars/streaming/join_domain_prefilter.py Outdated
Comment thread python/cudf_polars/cudf_polars/streaming/join_domain_prefilter.py Outdated
Comment thread python/cudf_polars/cudf_polars/streaming/join_filter_pushdown.py
Comment thread python/cudf_polars/cudf_polars/streaming/join_domain_prefilter.py Outdated
Comment thread python/cudf_polars/cudf_polars/streaming/join_domain_prefilter.py Outdated
Comment thread python/cudf_polars/cudf_polars/streaming/join_domain_prefilter.py Outdated
Comment thread python/cudf_polars/cudf_polars/streaming/join_domain_prefilter.py Outdated
Comment thread python/cudf_polars/cudf_polars/streaming/join_domain_prefilter.py Outdated
Comment thread python/cudf_polars/cudf_polars/streaming/join_domain_prefilter.py Outdated
pentschev and others added 8 commits June 26, 2026 19:30
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.
@wence-

wence- commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

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).

Comment on lines +77 to +85
@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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing the nested-nested tuples.

Comment on lines +88 to +97
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Streaming executor doesn't treat Cache at all, and it just gets in the way of some rewrites, so remove it.

@madsbk madsbk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Comment thread python/cudf_polars/cudf_polars/streaming/join_domain_prefilter.py Outdated
"""
stats = allgather_stats(comm, ctx.br(), ir, config_options, py_executor)

lowering, node_map = lower_ir_graph_with_node_map(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. L755 emits a plan for plan, which comes from optimized (lowering.optimized)
  2. L765 emits a plan for ir: lowering.lowered, with ties back to lowering.optimized via logical_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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the docstring at the top of join_domain_prefilter.py help? If so I can try and change the nouns here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love this terminology so am open to other options

@wence- wence- changed the title Add generic derived join-domain prefilters Add plan optimizer pass that extracts join keys and uses them to construct pre-filters for inner joins Jul 15, 2026

@TomAugspurger TomAugspurger left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possibly, if we think it'd be valuable to see what the optimizer changed. But we can handle that later.

Comment thread docs/cudf/source/cudf_polars/api.md Outdated
@rjzamora

Copy link
Copy Markdown
Member

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.

@wence-

wence- commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

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"

@wence-

wence- commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

I am concerned that occasionally the tests time out in, seemingly unrelated to this change, some tests

@wence- wence- left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test timeouts in some runs leave me concerned

@TomAugspurger

TomAugspurger commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

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.

@wence- wence- removed the 0 - Blocked Cannot progress due to external reasons label Jul 20, 2026
@wence-
wence- changed the base branch from main to release/26.08 July 20, 2026 11:17
@wence-

wence- commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

/merge

@rapids-bot
rapids-bot Bot merged commit fa5bcff into rapidsai:release/26.08 Jul 22, 2026
130 checks passed
@github-project-automation github-project-automation Bot moved this from In Progress to Done in cuDF Python Jul 22, 2026
rapids-bot Bot pushed a commit that referenced this pull request Jul 23, 2026
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
rapids-bot Bot pushed a commit that referenced this pull request Jul 23, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cudf.polars cudf-polars Issues specific to cudf-polars feature request New feature or request non-breaking Non-breaking change Python Affects Python cuDF API.

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

6 participants