Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
87 commits
Select commit Hold shift + click to select a range
03ef8ad
Add dynamic join key prefilter planning
pentschev Jun 25, 2026
6b71a95
Add generic derived join-domain prefilters
pentschev Jun 25, 2026
09f0474
Reject boolean join prefilter key limits
pentschev Jun 26, 2026
6f95d33
Consolidate optional config converters
pentschev Jun 26, 2026
519cd1d
Remove legacy bloom prefilter threshold path
pentschev Jun 26, 2026
d1cd0a1
Merge remote-tracking branch 'upstream/main' into cudf-polars/join-pr…
pentschev Jun 26, 2026
09a744e
Fix linting
pentschev Jun 26, 2026
7da769c
Accept integer join prefilter thresholds
pentschev Jun 26, 2026
fd6ba23
Implement default in-place without additional variable
pentschev Jun 26, 2026
fbe886b
Document join prefilter key prefix limit
pentschev Jun 26, 2026
f01e62e
Use dataclass conversion for join prefilter trace
pentschev Jun 26, 2026
ffd5e8f
Remove redundant join prefilter decision state
pentschev Jun 26, 2026
6bee038
Remove unused keyless prefilter skip
pentschev Jun 26, 2026
47f2ce4
Assert matching join prefilter key counts
pentschev Jun 26, 2026
97466a3
Track only join prefilter apply side
pentschev Jun 26, 2026
543a73d
Remove unused prefilter partition trace
pentschev Jun 26, 2026
0dfc80d
Clarify dynamic join collective IDs
pentschev Jun 26, 2026
94e132c
Document actor trace extra metadata
pentschev Jun 26, 2026
918417d
Fix docstring linting
pentschev Jun 26, 2026
29b950e
Merge remote-tracking branch 'upstream/main' into cudf-polars/join-pr…
pentschev Jun 26, 2026
c75249d
Fix optional join prefilter default typing
pentschev Jun 26, 2026
42871f0
Merge remote-tracking branch 'upstream/main' into cudf-polars/join-pr…
pentschev Jun 26, 2026
dc1ffb2
Merge branch 'cudf-polars/join-prefilter-core' into cudf-polars/join-…
pentschev Jun 29, 2026
0dafc3e
Merge remote-tracking branch 'upstream/main' into cudf-polars/join-pr…
pentschev Jun 29, 2026
d81c198
Merge remote-tracking branch 'upstream/main' into cudf-polars/join-do…
pentschev Jun 29, 2026
4cc6213
Use column expressions for join domain keys
pentschev Jun 29, 2026
c148219
Use shared DAG utilities for domain prefilter planning
pentschev Jun 29, 2026
fe17507
Restore optional float config conversion
pentschev Jun 29, 2026
1c0e3df
Reanalyze rewritten join-domain subtrees
pentschev Jun 29, 2026
702c6ad
Generalize default factory result typing
pentschev Jun 29, 2026
a37608d
Document join prefilter selection inputs
pentschev Jun 29, 2026
6bc7559
Simplify join prefilter decision flow
pentschev Jun 29, 2026
5ac7e79
Merge remote-tracking branch 'upstream/main' into cudf-polars/join-pr…
pentschev Jun 29, 2026
903084d
Fix missing coverage
pentschev Jun 29, 2026
4538aef
Fix one more missing coverage
pentschev Jun 29, 2026
c55aa6c
Merge remote-tracking branch 'upstream/main' into cudf-polars/join-pr…
pentschev Jun 29, 2026
4a938fc
Merge branch 'cudf-polars/join-prefilter-core' into cudf-polars/join-…
pentschev Jun 29, 2026
d1936aa
Merge remote-tracking branch 'upstream/main' into cudf-polars/join-pr…
pentschev Jun 29, 2026
eab9726
Merge branch 'cudf-polars/join-prefilter-core' into cudf-polars/join-…
pentschev Jun 29, 2026
47ac908
Merge remote-tracking branch 'upstream/main' into cudf-polars/join-do…
pentschev Jun 29, 2026
b3049c2
Add missing coverage for JOIN_DOMAIN_PREFILTER_TRACE
pentschev Jun 29, 2026
1a0d1f5
Merge remote-tracking branch 'upstream/main' into cudf-polars/join-do…
pentschev Jun 30, 2026
0b93f55
Simplify join-domain candidate selection
pentschev Jun 30, 2026
0226a83
Track column bindings through domain subplans
pentschev Jun 30, 2026
8bd9023
Keep domain prefilter replacement side-scoped
pentschev Jun 30, 2026
9511c94
Format join-domain review updates
pentschev Jun 30, 2026
ae443dd
Trace bindings through filtering joins
pentschev Jun 30, 2026
571181b
Check cheap domain guards first
pentschev Jul 1, 2026
cf59505
Inline target prefilter construction
pentschev Jul 1, 2026
3a57f14
Clarify bound key projection
pentschev Jul 1, 2026
4261279
Decouple join-domain prefilter configuration
pentschev Jul 1, 2026
ff32f43
Test nullable join-domain prefilters
pentschev Jul 1, 2026
c21cc13
Merge remote-tracking branch 'upstream/main' into cudf-polars/join-do…
pentschev Jul 1, 2026
011ba87
Validate join-domain prefilter configuration
pentschev Jul 1, 2026
a5041ec
Merge remote-tracking branch 'upstream/main' into cudf-polars/join-do…
pentschev Jul 2, 2026
7188a61
Use optional join-domain prefilter options
pentschev Jul 2, 2026
eabbdba
Merge remote-tracking branch 'upstream/main' into cudf-polars/join-do…
pentschev Jul 2, 2026
9cf3029
Fix join-domain prefilter CI regressions
pentschev Jul 2, 2026
c07ca0b
Merge remote-tracking branch 'upstream/main' into cudf-polars/join-do…
pentschev Jul 2, 2026
fbc56ec
Merge remote-tracking branch 'upstream/main' into cudf-polars/join-do…
pentschev Jul 3, 2026
920fdd9
Merge remote-tracking branch 'upstream/main' into pull-request/22996
wence- Jul 13, 2026
a5f919b
New optimize_with_stats stage in lowering
wence- Jul 13, 2026
3c6adf8
Introduce utility to map value domains of columns
wence- Jul 13, 2026
135af45
Use column_domain_bindings in select_column_targets
wence- Jul 13, 2026
7e58320
WIP: gather plan facts for join prefilter in one place
wence- Jul 13, 2026
4ae2b15
Refactor semijoin pushdown candidates
wence- Jul 14, 2026
4c2e988
docstring
wence- Jul 14, 2026
6da9b87
fixup
wence- Jul 14, 2026
ccc9503
More refactoring
wence- Jul 14, 2026
7c8393a
Disambiguate column lineage with edges
wence- Jul 14, 2026
f9926f8
Remove Cache nodes when lowering to streaming engine
wence- Jul 14, 2026
408619f
Merge branch 'main' into pull-request/22996
wence- Jul 14, 2026
e26e1ac
Remove treatment of Cache nodes in lowering and raise if we see one
wence- Jul 14, 2026
97e693a
Correctly handle pushdown through self-joins
wence- Jul 14, 2026
3a46fcf
Add high-level description of what is going on
wence- Jul 14, 2026
7904b20
Actually test we get the right answers
wence- Jul 14, 2026
8817181
Assert optimised query in way independent of Polars version
wence- Jul 15, 2026
6f16f9f
Assert more correctness in tests
wence- Jul 15, 2026
1ce2e59
Remove inappropriate intimacy in tests
wence- Jul 15, 2026
f69f2f2
Update docstring with better explanation
wence- Jul 15, 2026
68d01bd
Remove double checking of source being None
wence- Jul 15, 2026
7df367b
Rename to join filter pushdown, provide more explanation
wence- Jul 15, 2026
5c24361
Rename module
wence- Jul 15, 2026
9638a58
Finish renaming, add more explanatory comments
wence- Jul 15, 2026
3d9bfe4
Final renaming
wence- Jul 16, 2026
641832f
Merge branch 'release/26.08' into cudf-polars/join-domain-prefilter
wence- Jul 20, 2026
51303be
Merge branch 'release/26.08' into cudf-polars/join-domain-prefilter
wence- Jul 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/cudf/source/cudf_polars/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ Most users interact with them through `StreamingOptions` fields rather than dire
.. automodule:: cudf_polars.utils.config
:members:
DynamicPlanningOptions,
JoinFilterPushdownOptions,
MemoryResourceConfig,
ParquetOptions,
StreamingExecutor,
Expand Down
1 change: 1 addition & 0 deletions docs/cudf/source/cudf_polars/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ Environment variables follow these patterns:
| `broadcast_limit` | Maximum number of bytes for broadcast joins. | auto |
| `target_partition_size` | Target partition size in bytes. Used for IO and dynamic planning. `0` means auto. | auto |
| `dynamic_planning` | Dynamic planning configuration, dict or {class}`~cudf_polars.utils.config.DynamicPlanningOptions`. `None` disables. | enabled |
| `join_filter_pushdown` | Configuration for join filter pushdown plan rewrites, dict or {class}`~cudf_polars.utils.config.JoinFilterPushdownOptions`. `None` disables. | enabled |
| `sink_to_directory` | Whether `.sink_*()` writes its output as a directory. The `spmd`, `ray`, and `dask` engines always use `True`; passing `False` raises `ValueError`. | `True` |

### Category: `engine`
Expand Down
143 changes: 143 additions & 0 deletions python/cudf_polars/cudf_polars/dsl/utils/column_domain.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Utilities for tracking column value domains between IR nodes."""

from __future__ import annotations

from dataclasses import dataclass
from functools import singledispatch
from typing import TYPE_CHECKING

from cudf_polars.dsl import expr
from cudf_polars.dsl.ir import (
Distinct,
Filter,
GroupBy,
HStack,
Join,
Projection,
Select,
Slice,
Sort,
)

if TYPE_CHECKING:
from collections.abc import Mapping

from cudf_polars.dsl.ir import IR

__all__ = [
"ColumnBinding",
"ColumnLineage",
"ColumnRef",
"column_domain_bindings",
]


@dataclass(frozen=True)
class ColumnBinding:
"""A direct binding to a named column on a specific child edge."""

child_index: int
name: str


@dataclass(frozen=True)
class ColumnRef:
"""A named column produced by an IR node."""

node: IR
name: str


@dataclass(frozen=True)
class ColumnLineage:
"""Persistent value-domain lineage, sharing suffixes across DAG branches."""

column: ColumnRef
source: ColumnLineage | None = None
source_child_index: int | None = None
"""Child edge leading to ``source``, or None if there is no source."""


@singledispatch
def column_domain_bindings(node: IR) -> Mapping[str, ColumnBinding]:
"""
Map output columns to child columns containing their value domains.

For every ``output_name -> ColumnBinding(child_index, input_name)`` binding,
every value appearing in ``node[output_name]`` is guaranteed to appear in
``node.children[child_index][input_name]``. Row order, multiplicity, and
cardinality are not preserved.

If a name in ``node.schema`` does not appear in the mapping it means
that it was not possible to derive a relationship between the domain of
the output and input values for that column.
"""
return {}


@column_domain_bindings.register(Select)
def _(node: Select) -> Mapping[str, ColumnBinding]:
return {
item.name: ColumnBinding(0, item.value.name)
for item in node.exprs
if isinstance(item.value, expr.Col)
}


@column_domain_bindings.register(HStack)
def _(node: HStack) -> Mapping[str, ColumnBinding]:
child = node.children[0]
replaced = {item.name for item in node.columns}
return {
name: ColumnBinding(0, name) for name in child.schema if name not in replaced
} | {
item.name: ColumnBinding(0, item.value.name)
for item in node.columns
if isinstance(item.value, expr.Col)
}


@column_domain_bindings.register(GroupBy)
def _(node: GroupBy) -> Mapping[str, ColumnBinding]:
return {
key.name: ColumnBinding(0, key.value.name)
for key in node.keys
if isinstance(key.value, expr.Col)
}


@column_domain_bindings.register(Join)
def _(node: Join) -> Mapping[str, ColumnBinding]:
left, right = node.children
how = node.options[0]
if how in ("Semi", "Anti"):
return {
name: ColumnBinding(0, name) for name in node.schema if name in left.schema
}
if how != "Inner":
return {}

bindings = {name: ColumnBinding(0, name) for name in left.schema}
suffix = node.options[3]
for name in right.schema:
output_name = f"{name}{suffix}" if name in left.schema else name
if output_name in node.schema:
bindings[output_name] = ColumnBinding(1, name)
return bindings


@column_domain_bindings.register(Distinct)
@column_domain_bindings.register(Filter)
@column_domain_bindings.register(Projection)
@column_domain_bindings.register(Slice)
@column_domain_bindings.register(Sort)
def _(
node: Distinct | Filter | Projection | Slice | Sort,
) -> Mapping[str, ColumnBinding]:
child = node.children[0]
return {
name: ColumnBinding(0, name) for name in node.schema if name in child.schema
}
14 changes: 8 additions & 6 deletions python/cudf_polars/cudf_polars/engine/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -734,11 +734,17 @@ def evaluate_on_rank(
"""
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.

ir, config_options, stats, rank=comm.rank, nranks=comm.nranks
)
optimized = lowering.optimized
ir = lowering.lowered
partition_info = lowering.partition_info
if config_options.executor.quent_context is not None:
assert local_quent_context is not None
logical_plan_id = ir.get_stable_plan_id()
logical_plan_id = optimized.get_stable_plan_id()
plan, ops, ports, logical_op_by_id = build_plan(
ir,
optimized,
config_options,
query=local_quent_context.context.query,
plan_id=logical_plan_id,
Expand All @@ -752,10 +758,6 @@ def evaluate_on_rank(
local_quent_context.logger, plan, ops, ports
)

ir, partition_info, node_map = lower_ir_graph_with_node_map(
ir, config_options, stats, rank=comm.rank, nranks=comm.nranks
)

if comm.rank == 0:
log_query_plan(ir, config_options)

Expand Down
12 changes: 12 additions & 0 deletions python/cudf_polars/cudf_polars/engine/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from cudf_polars.quent import QuentContext
from cudf_polars.utils.config import (
DynamicPlanningOptions,
JoinFilterPushdownOptions,
ParquetOptions,
)

Expand Down Expand Up @@ -248,6 +249,14 @@ class StreamingOptions:
Env: ``CUDF_POLARS__EXECUTOR__DYNAMIC_PLANNING``.
Default: enabled.
Category: executor.
join_filter_pushdown
Config for join filter pushdown optimizations, dict or
:class:`~cudf_polars.utils.config.JoinFilterPushdownOptions`. ``None``
disables the rewrite.
Env: ``CUDF_POLARS__EXECUTOR__JOIN_FILTER_PUSHDOWN`` and
``CUDF_POLARS__EXECUTOR__JOIN_FILTER_PUSHDOWN__*``.
Default: enabled.
Category: executor.
sink_to_directory
Whether multi-partition sink operations should write to a directory
rather than a single file. The ``spmd``/``ray``/``dask`` engines
Expand Down Expand Up @@ -346,6 +355,9 @@ class StreamingOptions:
dynamic_planning: dict[str, Any] | DynamicPlanningOptions | None | Unspecified = (
_opt("executor")
)
join_filter_pushdown: (
dict[str, Any] | JoinFilterPushdownOptions | None | Unspecified
) = _opt("executor")
sink_to_directory: bool | Unspecified = _opt(
"executor", "CUDF_POLARS__EXECUTOR__SINK_TO_DIRECTORY", parse_boolean
)
Expand Down
10 changes: 5 additions & 5 deletions python/cudf_polars/cudf_polars/streaming/actor_graph/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@
import cudf_polars.dsl.tracing
from cudf_polars.containers import DataFrame
from cudf_polars.dsl.expr import Cast, Col, NamedExpr, TemporalFunction
from cudf_polars.dsl.ir import Cache, Filter, GroupBy, HStack, Join, Projection, Select
from cudf_polars.dsl.ir import Filter, GroupBy, HStack, Join, Projection, Select
from cudf_polars.dsl.tracing import Scope
from cudf_polars.dsl.utils.column_domain import column_domain_bindings
from cudf_polars.dsl.utils.naming import names_to_indices
from cudf_polars.streaming.actor_graph.collectives.allgather import AllGatherManager
from cudf_polars.streaming.actor_graph.tracing import ActorTracer, send_chunk
Expand Down Expand Up @@ -446,9 +447,8 @@ def _derived_ordering(

def _select_column_targets(select: Select) -> dict[str, dict[str, None]]:
old_to_new_names: defaultdict[str, dict[str, None]] = defaultdict(dict)
for ne in select.exprs:
if isinstance(ne.value, Col):
old_to_new_names[ne.value.name][ne.name] = None
for output_name, source in column_domain_bindings(select).items():
old_to_new_names[source.name][output_name] = None
return dict(old_to_new_names)


Expand Down Expand Up @@ -603,7 +603,7 @@ def maybe_remap_partitioning(
),
local=_remap_scheme_simple(ir, partitioning.local, ir.children[0]),
)
if isinstance(ir, (Cache, Join, Projection, Filter)):
if isinstance(ir, (Join, Projection, Filter)):
child = child_ir if child_ir is not None else ir.children[0]
return Partitioning(
inter_rank=_remap_scheme_simple(ir, partitioning.inter_rank, child),
Expand Down
14 changes: 10 additions & 4 deletions python/cudf_polars/cudf_polars/streaming/explain.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,10 @@ def explain_query(
if physical:
with cm:
stats = collect_statistics(ir, config, executor)
lowered_ir, partition_info = lower_ir_graph(ir, config, stats)
return _repr_ir_tree(lowered_ir, partition_info, stats=stats, config=config)
lowered = lower_ir_graph(ir, config, stats)
return _repr_ir_tree(
lowered.lowered, lowered.partition_info, stats=stats, config=config
)
else:
if config.executor.name == "streaming":
# Include row-count statistics for the logical plan
Expand All @@ -150,7 +152,9 @@ def collect_partition_plan(

with concurrent.futures.ThreadPoolExecutor() as executor:
stats = collect_statistics(ir, config, executor)
lowered_ir, partition_info = lower_ir_graph(ir, config, stats)
lowered = lower_ir_graph(ir, config, stats)
lowered_ir = lowered.lowered
partition_info = lowered.partition_info

seen: set[tuple] = set()
rows: list[PartitionPlanRow] = []
Expand Down Expand Up @@ -755,7 +759,9 @@ def from_ir(
if lowered:
with cm:
stats = collect_statistics(ir, config_options, executor)
ir, partition_info_d = lower_ir_graph(ir, config_options, stats)
lowering = lower_ir_graph(ir, config_options, stats)
ir = lowering.lowered
partition_info_d = lowering.partition_info
partition_info_dict = {}

nodes: dict[str, SerializableIRNode] = {}
Expand Down
2 changes: 1 addition & 1 deletion python/cudf_polars/cudf_polars/streaming/join.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ def _make_hash_join(
partition_info,
output_count,
)
# Always reconstruct in case children contain Cache nodes
# Reconstruct with the lowered and possibly shuffled children.
ir = ir.reconstruct([left, right])

# Record new partitioning info
Expand Down
Loading
Loading