Skip to content
Closed
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
247eaaf
initial pass at IR fusion
rjzamora May 13, 2025
2c882d7
add general task-fusion support
rjzamora May 13, 2025
7c986bc
Merge remote-tracking branch 'upstream/branch-25.06' into ir-fusion
rjzamora May 13, 2025
65bff68
experimental tests passing
rjzamora May 13, 2025
ecf0db5
deterministic partitioning
rjzamora May 13, 2025
21ec311
Merge branch 'branch-25.06' into ir-fusion
rjzamora May 13, 2025
3da460c
fix shuffle bug
rjzamora May 13, 2025
6c57bfa
Merge branch 'ir-fusion' of github.com:rjzamora/cudf into ir-fusion
rjzamora May 13, 2025
8695863
Merge remote-tracking branch 'upstream/branch-25.06' into ir-fusion
rjzamora May 13, 2025
08e8205
roll back breaking changes
rjzamora May 13, 2025
ddab700
fix spilling
rjzamora May 13, 2025
502ac9f
Merge branch 'branch-25.06' into ir-fusion
rjzamora May 13, 2025
ccb909f
avoid random in metadata sampling
rjzamora May 14, 2025
fddf093
avoid random in metadata sampling
rjzamora May 14, 2025
24e4ce5
Merge branch 'ir-fusion' of github.com:rjzamora/cudf into ir-fusion
rjzamora May 14, 2025
6db02ec
add test coverage for explain
rjzamora May 14, 2025
768e56c
Merge branch 'branch-25.06' into ir-fusion
rjzamora May 14, 2025
a857850
refactor
rjzamora May 14, 2025
981a25b
Merge branch 'ir-fusion' of github.com:rjzamora/cudf into ir-fusion
rjzamora May 14, 2025
c129bc3
avoid calling fuse_ir_graph unless task_fusion is True
rjzamora May 14, 2025
f9056ed
bump coverage
rjzamora May 14, 2025
0e9bd96
Merge branch 'branch-25.06' into ir-fusion
rjzamora May 14, 2025
d03fc03
drop unnecessary assertion
rjzamora May 14, 2025
5e014db
fix strict=False added by pre-commit
rjzamora May 14, 2025
c4751d2
address review
rjzamora May 14, 2025
deb549b
improve comments
rjzamora May 14, 2025
558aad8
Merge branch 'branch-25.06' into ir-fusion
rjzamora May 14, 2025
7cf61ef
Merge branch 'branch-25.06' into ir-fusion
rjzamora May 14, 2025
d07c350
avoid fusing Sort nodes for now
rjzamora May 14, 2025
00e8098
pre-commit
rjzamora May 14, 2025
e34530b
Merge branch 'branch-25.06' into ir-fusion
rjzamora May 15, 2025
e6636b3
Merge remote-tracking branch 'upstream/branch-25.06' into ir-fusion
rjzamora May 15, 2025
8c6efcf
cleanup
rjzamora May 15, 2025
9daf47b
Merge remote-tracking branch 'upstream/branch-25.06' into ir-fusion
rjzamora May 15, 2025
4fa356c
fix explain
rjzamora May 15, 2025
b5384f7
fix explain test
rjzamora May 15, 2025
15f2332
fix typing import
rjzamora May 15, 2025
772f584
add flag to pdsh.py
rjzamora May 16, 2025
7e26603
Merge remote-tracking branch 'upstream/branch-25.06' into ir-fusion
rjzamora May 16, 2025
b68a241
fix obvious mistake
rjzamora May 16, 2025
d21da69
fix misunderstanding
rjzamora May 16, 2025
c5807e7
Merge branch 'branch-25.06' into ir-fusion
rjzamora May 16, 2025
924b4e5
Merge remote-tracking branch 'upstream/branch-25.08' into ir-fusion
rjzamora May 23, 2025
517c081
Merge remote-tracking branch 'upstream/branch-25.08' into ir-fusion
rjzamora May 23, 2025
4da62d4
fix pdsh.py
rjzamora May 28, 2025
28a6f8b
Merge remote-tracking branch 'upstream/branch-25.08' into ir-fusion
rjzamora May 28, 2025
1c6b3a3
Merge branch 'branch-25.08' into ir-fusion
rjzamora May 29, 2025
2ae7e08
Merge branch 'ir-fusion' of github.com:rjzamora/cudf into ir-fusion
rjzamora May 30, 2025
5ad4b1a
Merge remote-tracking branch 'upstream/branch-25.08' into ir-fusion
rjzamora May 30, 2025
f60282f
address minor code-review comments
rjzamora May 30, 2025
19cbf46
Merge remote-tracking branch 'upstream/branch-25.10' into ir-fusion
rjzamora Aug 25, 2025
523f750
Merge branch 'branch-25.10' into ir-fusion
rjzamora Aug 27, 2025
519ba25
Merge branch 'branch-25.10' into ir-fusion
rjzamora Sep 4, 2025
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
14 changes: 13 additions & 1 deletion python/cudf_polars/cudf_polars/experimental/explain.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
Sort,
)
from cudf_polars.dsl.translate import Translator
from cudf_polars.experimental.fusion import Fused
from cudf_polars.experimental.parallel import lower_ir_graph
from cudf_polars.utils.config import ConfigOptions

Expand All @@ -30,7 +31,10 @@


def explain_query(
q: pl.LazyFrame, engine: pl.GPUEngine, *, physical: bool = True
q: pl.LazyFrame,
engine: pl.GPUEngine,
*,
physical: bool = True,
) -> str:
"""
Return a formatted string representation of the IR plan.
Expand Down Expand Up @@ -125,3 +129,11 @@ def _(ir: Sort, *, offset: str = "") -> str:
def _(ir: Scan, *, offset: str = "") -> str:
label = f"SCAN {ir.typ.upper()}"
return _repr_header(offset, label, ir.schema)


@_repr_ir.register
def _(ir: Fused, *, offset: str = "") -> str:
nodes = tuple(type(node).__name__.upper() for node in ir.subnodes)
if ir.fused_io:
nodes = (type(ir.fused_io.children[0]).__name__.upper(), *nodes)
return _repr_header(offset, f"FUSED {nodes}", ir.schema)
190 changes: 190 additions & 0 deletions python/cudf_polars/cudf_polars/experimental/fusion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
# SPDX-FileCopyrightText: Copyright (c) 2024-2025, NVIDIA CORPORATION & AFFILIATES.
# SPDX-License-Identifier: Apache-2.0
"""IR node fusion."""

from __future__ import annotations

import operator
from collections import defaultdict
from functools import reduce
from typing import TYPE_CHECKING, Any

from cudf_polars.dsl.ir import (
IR,
Cache,
Filter,
GroupBy,
HStack,
MapFunction,
Projection,
Select,
Sort,
Union,
)
from cudf_polars.dsl.traversal import CachingVisitor, traversal
from cudf_polars.experimental.dispatch import generate_ir_tasks
from cudf_polars.experimental.io import Scan, SplitScan

if TYPE_CHECKING:
from collections.abc import Callable, MutableMapping, Sequence
from typing import Any

from cudf_polars.containers import DataFrame
from cudf_polars.experimental.base import PartitionInfo
from cudf_polars.experimental.dispatch import LowerIRTransformer
from cudf_polars.typing import Schema
from cudf_polars.utils.config import ConfigOptions


class Fused(IR):
"""Fused IR node."""

__slots__ = ("fused_io", "subnodes")
_non_child = ("schema", "subnodes", "fused_io")
subnodes: tuple[IR, ...]
"""Fused sub-nodes."""
fused_io: Union | None
"""Fused IO node."""

def __init__(
self,
schema: Schema,
subnodes: tuple[IR, ...],
fused_io: Union | None,
*children: IR,
):
self.schema = schema
self.subnodes = subnodes
self.fused_io = fused_io

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.

question: It seems like fused_io is never used in the do_evaluate. How does this work? Ah, it's because the task generation does the right thing.

Does that suggest we should have separate Fused and FusedIO nodes? So we don't have this dichotomy?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Does that suggest we should have separate Fused and FusedIO nodes? So we don't have this dichotomy?

FYI: I refactored things with this goal in mind, and cleaned things up a bit in the process.

self._non_child_args = (
[node.do_evaluate for node in subnodes],
[tuple(node._non_child_args) for node in self.subnodes],
[],
[],

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Note: I needed to use lists rather than tuples or callable objects, because we run into issues when the scheduler or rapimdspf-spilling things these are tasks.

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.

😢

)
self.children = children

@classmethod
def do_evaluate(
cls,
funcs: Sequence[Callable],
subargs: Sequence[Sequence[Any]],
io_funcs: Sequence[Callable],
io_args: Sequence[Any],
*children: DataFrame,
) -> DataFrame:
"""Evaluate and return a dataframe."""
if io_funcs:
children = (io_funcs[0](*io_args),)

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.

For IO, we know that the passed in children are empty, I guess. Hence we can ignore them.

for func, args in zip(funcs, subargs, strict=False):
Comment thread
rjzamora marked this conversation as resolved.
Outdated
children = (func(*args, *children),)
return children[0]


def _fuse_ir_node(
ir: IR, rec: LowerIRTransformer
) -> tuple[IR, MutableMapping[IR, PartitionInfo]]:
partition_info: MutableMapping[IR, PartitionInfo] = {}
children = ()
if ir.children:
children, _partition_info = zip(*(rec(c) for c in ir.children), strict=True)
partition_info = reduce(operator.or_, _partition_info)

new_node: IR
if ir in rec.state["fusable"]:
if isinstance(ir, Union):
new_node = Fused(ir.schema, (), 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.

Because we've already marked this node as fusible, we know it must be an IO node?

@rjzamora rjzamora May 15, 2025

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yeah, I refactored things a bit so that the Union logic is all in one place.

elif len(children) == 1 and isinstance(children[0], Fused):
(child,) = children
grandchildren = child.children
new_node = Fused(
ir.schema, (*child.subnodes, ir), child.fused_io, *grandchildren
)

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.

So this is kind of "tricking" the task generation routine into doing partition-wise operations without generating new tasks/intermediates.

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.

Trying to understand how this works. I think this is trying to fuse things like:

HStack(
   Select(A, [1, 2]),
   Select(A, [3, 4]),
   Select(A, [5, 6]),
)

So we only generate a "task" for evaluating A and then for a "fused" expression.

But I think this rewrites to:

Fused(
  (Select12, Select34, Select56, HStack),
  A
)

But it seems like the evaluation rule would produce HStack(Select56(Select34(Select12(A)))). What am I missing?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We are actually targeting simple sequential IR-node strings. HStack is easy to fuse, because it can't be used in the way you describe here (maybe you're thinking of HConcat?).

else:
new_node = Fused(ir.schema, (ir,), None, *children)
else:
new_node = ir.reconstruct(children)

partition_info[new_node] = rec.state["partition_info"][ir]
return new_node, partition_info


def _fusable_ir_type(ir: IR) -> bool:
Comment thread
TomAugspurger marked this conversation as resolved.
Outdated
return (
# Basic fusion
isinstance(
ir, (Cache, Filter, GroupBy, HStack, MapFunction, Projection, Select, Sort)
)
or (
# IO Fusion
isinstance(ir, Union)
and all(isinstance(n, (Scan, SplitScan)) for n in ir.children)
)
)


@generate_ir_tasks.register(Fused)
def _(
ir: Fused, partition_info: MutableMapping[IR, PartitionInfo]
) -> MutableMapping[Any, Any]:
from cudf_polars.experimental.parallel import _default_generate_ir_tasks

if ir.fused_io is None:
return _default_generate_ir_tasks(ir, partition_info)

assert isinstance(ir.fused_io, Union)
Comment thread
rjzamora marked this conversation as resolved.
Outdated
graph: MutableMapping[Any, Any] = {}
for i, key in enumerate(partition_info[ir].keys(ir)):
io = ir.fused_io.children[i]
Comment thread
TomAugspurger marked this conversation as resolved.
assert isinstance(io, (Scan, SplitScan))
graph[key] = (
ir.do_evaluate,
ir._non_child_args[0],
ir._non_child_args[1],
[io.do_evaluate],
list(io._non_child_args),
)
return graph


def fuse_ir_graph(
ir: IR,
partition_info: MutableMapping[IR, PartitionInfo],
config_options: ConfigOptions,
) -> tuple[IR, MutableMapping[IR, PartitionInfo]]:
"""
Rewrite an IR graph with fused nodes.

Parameters
----------
ir
Root of the graph to rewrite.
Comment thread
rjzamora marked this conversation as resolved.
Outdated
partition_info
Initial partitioning information.
config_options
GPUEngine configuration options.

Returns
-------
new_ir, partition_info
The rewritten graph, and a mapping from unique nodes
in the new graph to associated partitioning information.
"""
assert config_options.executor.name == "streaming", (
"'in-memory' executor not supported in 'fuse_ir_graph'"
)
if not config_options.executor.task_fusion:
Comment thread
rjzamora marked this conversation as resolved.
Outdated
return ir, partition_info

parents: defaultdict[IR, int] = defaultdict(int)
if _fusable_ir_type(ir):
parents[ir] = 1
for node in traversal([ir]):
for child in node.children:
if _fusable_ir_type(child):
# Basic fusion
parents[child] += 1
fusable = {node for node, count in parents.items() if count == 1}
state = {"fusable": fusable, "partition_info": partition_info}
mapper = CachingVisitor(_fuse_ir_node, state=state)
return mapper(ir)
6 changes: 3 additions & 3 deletions python/cudf_polars/cudf_polars/experimental/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import dataclasses
import enum
import math
import random
from enum import IntEnum
from typing import TYPE_CHECKING, Any, TypeVar

Expand Down Expand Up @@ -263,9 +262,10 @@ def _sample_pq_statistics(ir: Scan) -> dict[str, np.floating[T]]:
import numpy as np

# Use average total_uncompressed_size of three files
n_sample = min(3, len(ir.paths))
n_sample = 5 # TODO: Make this configurable
stride = max(1, int(len(ir.paths) / n_sample))
metadata = plc.io.parquet_metadata.read_parquet_metadata(
plc.io.SourceInfo(random.sample(ir.paths, n_sample))
plc.io.SourceInfo(ir.paths[: stride * n_sample : stride])

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This "fix" is not related to the rest of the PR, but it made it easier to do A-B testing with the new fusion optimization.

)
column_sizes = {}
rowgroup_offsets_per_file = np.insert(
Expand Down
8 changes: 5 additions & 3 deletions python/cudf_polars/cudf_polars/experimental/parallel.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
generate_ir_tasks,
lower_ir_node,
)
from cudf_polars.experimental.fusion import fuse_ir_graph
from cudf_polars.experimental.utils import _concat, _lower_ir_fallback

if TYPE_CHECKING:
Expand Down Expand Up @@ -82,7 +83,8 @@ def lower_ir_graph(
lower_ir_node
"""
mapper = CachingVisitor(lower_ir_node, state={"config_options": config_options})
return mapper(ir)
lowered_ir, partition_info = mapper(ir)
return fuse_ir_graph(lowered_ir, partition_info, config_options)


def task_graph(
Expand Down Expand Up @@ -219,7 +221,7 @@ def evaluate_streaming(ir: IR, config_options: ConfigOptions) -> DataFrame:


@generate_ir_tasks.register(IR)
def _(
def _default_generate_ir_tasks(
ir: IR, partition_info: MutableMapping[IR, PartitionInfo]
) -> MutableMapping[Any, Any]:
# Generate pointwise (embarrassingly-parallel) tasks by default
Expand Down Expand Up @@ -319,8 +321,8 @@ def _lower_ir_pwise(


_lower_ir_pwise_preserve = partial(_lower_ir_pwise, preserve_partitioning=True)
lower_ir_node.register(Projection, _lower_ir_pwise_preserve)
lower_ir_node.register(Filter, _lower_ir_pwise_preserve)
lower_ir_node.register(Projection, _lower_ir_pwise_preserve)
lower_ir_node.register(Cache, _lower_ir_pwise)
lower_ir_node.register(HStack, _lower_ir_pwise)
lower_ir_node.register(HConcat, _lower_ir_pwise)
6 changes: 6 additions & 0 deletions python/cudf_polars/cudf_polars/utils/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ class StreamingExecutor:
rapidsmpf_spill
Whether to wrap task arguments and output in objects that are
spillable by 'rapidsmpf'.
task_fusion
Comment thread
TomAugspurger marked this conversation as resolved.
Whether to fuse sequential partition-wise IR nodes into
a single task per partition.
"""

name: Literal["streaming"] = dataclasses.field(default="streaming", init=False)
Expand All @@ -203,6 +206,7 @@ class StreamingExecutor:
broadcast_join_limit: int = 0
shuffle_method: ShuffleMethod | None = None
rapidsmpf_spill: bool = False
task_fusion: bool = True

def __post_init__(self) -> None:
if self.scheduler == "synchronous" and self.shuffle_method == "rapidsmpf":
Expand Down Expand Up @@ -244,6 +248,8 @@ def __post_init__(self) -> None:
raise TypeError("broadcast_join_limit must be an int")
if not isinstance(self.rapidsmpf_spill, bool):
raise TypeError("rapidsmpf_spill must be bool")
if not isinstance(self.task_fusion, bool):
raise TypeError("task_fusion must be bool")

def __hash__(self) -> int:
# cardinality factory, a dict, isn't natively hashable. We'll dump it
Expand Down
15 changes: 11 additions & 4 deletions python/cudf_polars/tests/experimental/test_explain.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ def test_explain_physical_plan(tmp_path, df):
executor_options={
"target_partition_size": 10_000,
"scheduler": DEFAULT_SCHEDULER,
"task_fusion": False,
},
)

Expand All @@ -73,7 +74,8 @@ def test_explain_physical_plan(tmp_path, df):
assert "SELECT ('sum', 'y')" in plan or "PROJECTION ('sum', 'y')" in plan


def test_explain_physical_plan_with_groupby(tmp_path, df):
@pytest.mark.parametrize("task_fusion", [True, False])
def test_explain_physical_plan_with_groupby(tmp_path, df, task_fusion):
make_partitioned_source(df, tmp_path, fmt="parquet", n_files=1)

q = (
Expand All @@ -89,12 +91,17 @@ def test_explain_physical_plan_with_groupby(tmp_path, df):
executor_options={
"target_partition_size": 10_000,
"scheduler": DEFAULT_SCHEDULER,
"task_fusion": task_fusion,
},
)

plan = explain_query(q, engine, physical=True)

assert "GROUPBY ('g',)" in plan
if task_fusion:
plan = explain_query(q, engine, physical=True)
assert "FUSED ('GROUPBY'" in plan
assert "FUSED ('SPLITSCAN'" in plan
else:
plan = explain_query(q, engine, physical=True)
assert "GROUPBY ('g',)" in plan


def test_explain_logical_plan_with_join(tmp_path, df):
Expand Down
2 changes: 1 addition & 1 deletion python/cudf_polars/tests/experimental/test_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def test_target_partition_size(tmp_path, df, blocksize, n_files):

# Check partitioning
qir = Translator(q._ldf.visit(), engine).translate_ir()
ir, info = lower_ir_graph(qir, ConfigOptions(engine.config))
ir, info = lower_ir_graph(qir, ConfigOptions.from_polars_engine(engine))
Comment thread
TomAugspurger marked this conversation as resolved.
Outdated
count = info[ir].count
if blocksize <= 12_000:
assert count > n_files
Expand Down