Skip to content

Commit 671e11b

Browse files
authored
Support equi joins that Polars plans with a fused predicate (NVIDIA#24858)
### What Polars gives us Polars plans an equi join with an extra, non-equality condition as a single join with a "fused predicate". For example `SQL ... WHERE a.key = b.key AND x <> y`, `join_where` with an equality and an inequality, and correlated `EXISTS` / `NOT EXISTS` subqueries such as the ones in TPC-H queries 7, 17, 19, 20 and 21. With Polars 2.0 the visitor did not expose that condition, so cudf-polars fell back to the CPU. Polars 2.1 reports the join as `("Inner", "fused_predicate")`, with the equality keys in `left_on`/`right_on` and the extra condition in `node.predicate`. ```python import polars as pl orders = pl.LazyFrame({"key": [1, 2, 3], "supp_nation": ["FRANCE", "GERMANY", "FRANCE"]}) customers = pl.LazyFrame({"key": [1, 2, 3], "cust_nation": ["GERMANY", "FRANCE", "FRANCE"]}) q = pl.SQLContext(orders=orders, customers=customers).execute( "SELECT * FROM orders, customers WHERE orders.key = customers.key AND supp_nation <> cust_nation" ) q.collect(engine=pl.GPUEngine(raise_on_fail=True)) # before: NotImplementedError, now: runs on the GPU ``` ### The obvious approach does not scale The first version of this PR translated the join as an equi join followed by a filter. That is simple, but the join writes out every pair of rows that match on the keys before the filter sees any of them. If 30,000 rows on each side share one key, that is 900 million pairs, even when the predicate rejects all of them. ```python n = 30_000 left = pl.LazyFrame({"k": [1] * n, "a": range(n)}) right = pl.LazyFrame({"k": [1] * n, "b": range(n)}) q = pl.SQLContext(left=left, right=right).execute( "SELECT * FROM left, right WHERE left.k = right.k AND left.a + right.b < 0" ) q.collect(engine=pl.GPUEngine(raise_on_fail=True)) # join then filter: out of memory (a 7.2 GB allocation) on a 32 GB GPU # this PR: 0 rows ``` ### What this PR does libcudf already has a mixed join. It builds a hash table on the keys, finds the rows with the same key, and tests the extra condition on the spot. It only writes out the pairs that pass, so memory scales with the answer, not with the number of key matches. `Join` now takes an optional predicate and evaluates it with `plc.join.mixed_inner_join`. The catch is that the condition must be written in libcudf's AST. The AST is strict: no implicit casts, both operands of an operation must have the same type, decimal128 only supports comparisons, and there are no string functions. Polars conditions are arbitrary expressions, such as `price > 1.2 * avg_price` (TPC-DS query 6) or `lower(a) <> lower(b)`. So the condition is split. Any part that uses only one input is computed once per row on that input, and added as an extra column to the table the AST looks at. The condition is rewritten so the AST only compares columns of the two inputs. That is 60,000 evaluations in the example above, not 900 million, and it should also be cheaper than evaluating the same expression for every pair (I have not benchmarked that). Conditions that cannot be split this way fall back to the CPU: a non-pointwise expression on one input, or operands that still differ in type. ## Changes - `dsl/ir.py`: `Join` takes an optional predicate (inner joins only) and calls `plc.join.mixed_inner_join` when there is one. Gather maps, row ordering and the streaming paths are unchanged. - `dsl/to_ast.py`: `split_join_predicate` does the split described above. - `dsl/translate.py`: `_translate_fused_predicate` builds that `Join`. Other join kinds fall back to the CPU (I only saw a predicate fused into an inner join, including when the original query used an outer join and Polars rewrote it). - `streaming/join.py`, `streaming/filter_hint.py`, `streaming/actor_graph/join.py`: pass the predicate along when a join is rebuilt. Prefilters are not used for a join with a predicate. - `FUSED_PREDICATE_XFAIL` in `tests/test_join.py` is now conditional on Polars 2.0, where the visitor still hides the predicate (uses `POLARS_VERSION_LT_21` from NVIDIA#24825). - Tests, all skipped on Polars 2.0: - `tests/test_join_fused_predicate.py`: the SQL query above, an inner join followed by a filter (`nulls_equal` both ways, with NULL keys that pass the predicate so the two settings differ), `join_where`, suffixed columns, correlated `EXISTS` and `NOT EXISTS`, an empty result, a decimal compared with a computed float (the TPC-DS query 6 shape), expressions of each input, a repeated expression, a string function, mismatched integer widths, a check that no `Filter` is left in the plan, and the 30,000 row case above. - `tests/dsl/test_join_predicate.py`: unit tests for the split and for the cases that fall back. ## Notes for reviewers - This is partly a workaround for limits of libcudf's AST. The hoisting of one-sided work is worth doing anyway, but doing it for type and string reasons is not ideal. From reading the libcudf source (not tested), it has AST casts to int64, uint64 and float64 and the float64 cast accepts decimal sources, but pylibcudf does not expose them and they do not cover narrower types, strings or decimal128 arithmetic. Exposing them, and better type promotion in the AST, would be worth a follow-up. It would shrink the split but not replace it. `ConditionalJoin` already works around the same limits by pre-casting decimal and float columns. - `Join` gains a positional `predicate` argument, so every `Join(...)` call site changed. - The 30,000 row test is skipped on spmd because it takes 36 s with many small partitions. ## Verification | Check | Result | | --- | --- | | `tests/test_join_fused_predicate.py` on Polars 2.1.0, in-memory, dask and spmd | 59 passed, 2 skipped | | `tests/test_join_fused_predicate.py`, `tests/dsl/test_join_predicate.py`, `tests/test_join.py` | 633 passed, 173 skipped, 5 xfailed | | New tests against the join-then-filter version | `test_predicate_is_evaluated_inside_the_join` and the 30,000 row test fail (out of memory, 7.2 GB) | | 30,000 rows per side, one shared key, predicate rejects all (V100, 32 GB) | before: out of memory. now: 0 rows on the GPU | | `test_join.py`, `test_join_fused_predicate.py`, `test_sql.py`, `tests/dsl`, `tests/streaming` on this branch and on the join-then-filter version | the same 318 failures in both, with identical test ids. They are environment failures on my host (TPC-DS and TPC-H data, hive scans, metadata, a libcudf JIT cache directory). 2,683 passed here, 2,616 before | | TPC-DS query 6 (it broke with a first version of the predicate join, fixed by the split) | passes | | Coverage of the added lines | all covered except `JoinWithPrefilter.predicate = None`, which only runs with dynamic planning (rapidsmpf) | | ruff and mypy | `check-style` passed in CI after the mypy fix; later commits are small refactors, CI re-running | | Polars 2.0, upstream Polars tests, TPC-H 7, 17, 19, 20, 21 and 22, dynamic planning, speed against join then filter | not run after this change | Authors: - Matthew Murray (https://github.com/Matt711) Approvers: - Tom Augspurger (https://github.com/TomAugspurger) - Matthew Roeschke (https://github.com/mroeschke) URL: NVIDIA#24858
1 parent 427634a commit 671e11b

12 files changed

Lines changed: 638 additions & 13 deletions

File tree

‎python/cudf_polars/cudf_polars/dsl/ir.py‎

Lines changed: 93 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
from cudf_polars.dsl.nodebase import Node
5151
from cudf_polars.dsl.to_ast import _DECIMAL_IDS, to_ast, to_parquet_filter
5252
from cudf_polars.dsl.tracing import log_do_evaluate, nvtx_annotate_cudf_polars
53+
from cudf_polars.dsl.traversal import traversal
5354
from cudf_polars.dsl.utils.naming import unique_names
5455
from cudf_polars.dsl.utils.per_path import PerPathValues
5556
from cudf_polars.dsl.utils.reshape import broadcast
@@ -80,6 +81,7 @@
8081
from rmm.pylibrmm.stream import Stream
8182

8283
from cudf_polars.containers.dataframe import NamedColumn
84+
from cudf_polars.dsl.to_ast import JoinPredicate
8385
from cudf_polars.dsl.utils.io import CachedParquetInfo
8486
from cudf_polars.quent._context import (
8587
ActorBoundQuentIRExecutionState,
@@ -2979,6 +2981,37 @@ def find(key: _ColumnKey) -> _ColumnKey:
29792981
return left_casts, right_casts
29802982

29812983

2984+
def _check_ast_operand_types(predicate: expr.Expr) -> None:
2985+
"""
2986+
Check that libcudf's AST can evaluate a predicate.
2987+
2988+
The AST requires both operands of a binary operation to share a type id,
2989+
and it only fails once the join runs, so reject a predicate whose operands
2990+
differ in type (after the decimal and float casts) when translating.
2991+
"""
2992+
left_casts, right_casts = _collect_decimal_binop_casts(predicate)
2993+
2994+
def type_id(node: expr.Expr) -> plc.TypeId:
2995+
if isinstance(node, expr.ColRef):
2996+
(col,) = node.children
2997+
assert isinstance(col, expr.Col)
2998+
casts = (
2999+
left_casts
3000+
if node.table_ref == plc_expr.TableReference.LEFT
3001+
else right_casts
3002+
)
3003+
if col.name in casts:
3004+
return casts[col.name].plc_type.id()
3005+
return node.dtype.plc_type.id()
3006+
3007+
for node in traversal([predicate]):
3008+
if (
3009+
isinstance(node, expr.BinOp)
3010+
and len({type_id(c) for c in node.children}) > 1
3011+
):
3012+
raise NotImplementedError("Join predicate with operands of different types")
3013+
3014+
29823015
def _apply_casts(df: DataFrame, casts: dict[str, DataType]) -> DataFrame:
29833016
if not casts:
29843017
return df
@@ -3127,18 +3160,24 @@ def do_evaluate(
31273160
class Join(IR):
31283161
"""A join of two dataframes."""
31293162

3130-
__slots__ = ("left_on", "options", "right_on")
3163+
__slots__ = ("left_on", "options", "predicate", "right_on")
31313164
_non_child: ClassVar[tuple[str, ...]] = (
31323165
"schema",
31333166
"left_on",
31343167
"right_on",
31353168
"options",
3169+
"predicate",
31363170
)
3137-
_n_non_child_args = 3
3171+
_n_non_child_args = 4
31383172
left_on: tuple[expr.NamedExpr, ...]
31393173
"""List of expressions used as keys in the left frame."""
31403174
right_on: tuple[expr.NamedExpr, ...]
31413175
"""List of expressions used as keys in the right frame."""
3176+
predicate: JoinPredicate | None
3177+
"""
3178+
Optional extra condition (inner joins only) that a pair of rows must
3179+
satisfy to match, evaluated while joining.
3180+
"""
31423181
options: tuple[
31433182
Literal["Inner", "Left", "Right", "Full", "Semi", "Anti", "Cross"],
31443183
bool,
@@ -3176,6 +3215,7 @@ def __init__(
31763215
left_on: Sequence[expr.NamedExpr],
31773216
right_on: Sequence[expr.NamedExpr],
31783217
options: Any,
3218+
predicate: JoinPredicate | None,
31793219
left: IR,
31803220
right: IR,
31813221
):
@@ -3184,7 +3224,24 @@ def __init__(
31843224
self.right_on = tuple(right_on)
31853225
self.options = options
31863226
self.children = (left, right)
3187-
self._non_child_args = (self.left_on, self.right_on, self.options)
3227+
predicate_args = None
3228+
if predicate is not None:
3229+
if options[0] != "Inner":
3230+
raise NotImplementedError(f"Join predicate with join type {options[0]}")
3231+
ast_predicate = _strip_predicate_casts(predicate.predicate)
3232+
_check_ast_operand_types(ast_predicate)
3233+
predicate_args = (
3234+
ConditionalJoin.Predicate(ast_predicate),
3235+
predicate.left_exprs,
3236+
predicate.right_exprs,
3237+
)
3238+
self.predicate = predicate
3239+
self._non_child_args = (
3240+
self.left_on,
3241+
self.right_on,
3242+
self.options,
3243+
predicate_args,
3244+
)
31883245

31893246
@staticmethod
31903247
@cache
@@ -3370,6 +3427,12 @@ def do_evaluate(
33703427
bool,
33713428
Literal["none", "left", "right", "left_right", "right_left"],
33723429
],
3430+
predicate_args: tuple[
3431+
ConditionalJoin.Predicate,
3432+
tuple[expr.NamedExpr, ...],
3433+
tuple[expr.NamedExpr, ...],
3434+
]
3435+
| None,
33733436
left: DataFrame,
33743437
right: DataFrame,
33753438
*,
@@ -3442,6 +3505,19 @@ def do_evaluate(
34423505
),
34433506
stream=right.stream,
34443507
)
3508+
if predicate_args is not None:
3509+
predicate_wrapper, left_exprs, right_exprs = predicate_args
3510+
left_casts, right_casts = _collect_decimal_binop_casts(
3511+
predicate_wrapper.predicate
3512+
)
3513+
left_conditional = _apply_casts(
3514+
HStack.do_evaluate(left_exprs, True, left, context=context), # noqa: FBT003
3515+
left_casts,
3516+
)
3517+
right_conditional = _apply_casts(
3518+
HStack.do_evaluate(right_exprs, True, right, context=context), # noqa: FBT003
3519+
right_casts,
3520+
)
34453521
with context.stream_ordered_after(left, right) as stream:
34463522
null_equality = (
34473523
plc.types.NullEquality.EQUAL
@@ -3464,9 +3540,20 @@ def do_evaluate(
34643540
left, right = right, left
34653541
left_on, right_on = right_on, left_on
34663542
maintain_order = Join.SWAPPED_ORDER[maintain_order]
3467-
lg, rg = join_fn(
3468-
left_on.table, right_on.table, null_equality, stream=stream
3469-
)
3543+
if predicate_args is None:
3544+
lg, rg = join_fn(
3545+
left_on.table, right_on.table, null_equality, stream=stream
3546+
)
3547+
else:
3548+
lg, rg = plc.join.mixed_inner_join(
3549+
left_on.table,
3550+
right_on.table,
3551+
left_conditional.table,
3552+
right_conditional.table,
3553+
predicate_wrapper.ast,
3554+
null_equality,
3555+
stream=stream,
3556+
)
34703557
if (
34713558
how in ("Inner", "Left", "Right", "Full")
34723559
and maintain_order != "none"

‎python/cudf_polars/cudf_polars/dsl/to_ast.py‎

Lines changed: 136 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from __future__ import annotations
77

88
from functools import partial, reduce, singledispatch
9-
from typing import TYPE_CHECKING, TypedDict, cast
9+
from typing import TYPE_CHECKING, NamedTuple, TypedDict, cast
1010

1111
import polars as pl
1212

@@ -16,10 +16,12 @@
1616
from cudf_polars.containers import DataType
1717
from cudf_polars.dsl import expr
1818
from cudf_polars.dsl.traversal import CachingVisitor, reuse_if_unchanged, traversal
19+
from cudf_polars.dsl.utils.naming import unique_names
20+
from cudf_polars.dsl.utils.replace import replace
1921
from cudf_polars.typing import GenericTransformer
2022

2123
if TYPE_CHECKING:
22-
from collections.abc import Mapping
24+
from collections.abc import Generator, Mapping, Sequence
2325

2426
from rmm.pylibrmm.stream import Stream
2527

@@ -112,9 +114,35 @@ class ExprTransformerState(TypedDict):
112114
table_ref: plc.expressions.TableReference
113115

114116

117+
class JoinPredicateState(TypedDict):
118+
"""
119+
State used for splitting the extra condition of a join.
120+
121+
Parameters
122+
----------
123+
columns
124+
For each output column of the join, the table it comes from and its
125+
name there.
126+
indices
127+
For each table, the index of each of its columns.
128+
names
129+
For each table, a generator of names for columns to append to it.
130+
extras
131+
For each table, the expressions to append to it and their names.
132+
"""
133+
134+
columns: Mapping[str, tuple[plc_expr.TableReference, str]]
135+
indices: Mapping[plc_expr.TableReference, Mapping[str, int]]
136+
names: Mapping[plc_expr.TableReference, Generator[str, None, None]]
137+
extras: Mapping[plc_expr.TableReference, dict[expr.Expr, str]]
138+
139+
115140
type Transformer = GenericTransformer[expr.Expr, plc_expr.Expression, ASTState]
116141
type ExprTransformer = GenericTransformer[expr.Expr, expr.Expr, ExprTransformerState]
117142
"""Protocol for transformation of Expr nodes."""
143+
type JoinPredicateTransformer = GenericTransformer[
144+
expr.Expr, expr.Expr, JoinPredicateState
145+
]
118146

119147

120148
@singledispatch
@@ -441,3 +469,109 @@ def insert_colrefs(
441469
state={"name_to_index": name_to_index, "table_ref": table_ref},
442470
)
443471
return mapper(node)
472+
473+
474+
class JoinPredicate(NamedTuple):
475+
"""
476+
An extra condition of an equi join, split for libcudf's mixed join.
477+
478+
Parts of the condition that use columns of one input only are evaluated
479+
on that input before the join. Only the remaining comparison between the
480+
inputs is left to the AST.
481+
"""
482+
483+
predicate: expr.Expr
484+
"""Expression with column references into the left and right tables."""
485+
left_exprs: tuple[expr.NamedExpr, ...]
486+
"""Columns appended to the left table, evaluated on the left input."""
487+
right_exprs: tuple[expr.NamedExpr, ...]
488+
"""Columns appended to the right table, evaluated on the right input."""
489+
490+
491+
def _split_join_predicate(node: expr.Expr, rec: JoinPredicateTransformer) -> expr.Expr:
492+
state = rec.state
493+
if isinstance(node, expr.Col):
494+
table_ref, name = state["columns"][node.name]
495+
return expr.ColRef(
496+
node.dtype,
497+
state["indices"][table_ref][name],
498+
table_ref,
499+
expr.Col(node.dtype, name),
500+
)
501+
sides = {
502+
state["columns"][col.name][0]
503+
for col in traversal([node])
504+
if isinstance(col, expr.Col)
505+
}
506+
if len(sides) == 1:
507+
(table_ref,) = sides
508+
if not node.all_pointwise():
509+
raise NotImplementedError("Join predicate with a non-pointwise expression")
510+
renames: dict[expr.Expr, expr.Expr] = {
511+
col: expr.Col(col.dtype, state["columns"][col.name][1])
512+
for col in traversal([node])
513+
if isinstance(col, expr.Col)
514+
}
515+
(local,) = replace([node], renames)
516+
extras = state["extras"][table_ref]
517+
if local not in extras:
518+
extras[local] = next(state["names"][table_ref])
519+
index = len(state["indices"][table_ref]) + list(extras).index(local)
520+
return expr.ColRef(
521+
node.dtype, index, table_ref, expr.Col(node.dtype, extras[local])
522+
)
523+
return reuse_if_unchanged(node, rec)
524+
525+
526+
def split_join_predicate(
527+
node: expr.Expr,
528+
*,
529+
columns: Mapping[str, tuple[plc_expr.TableReference, str]],
530+
left_names: Sequence[str],
531+
right_names: Sequence[str],
532+
) -> JoinPredicate:
533+
"""
534+
Split an equi join's extra condition for libcudf's mixed join.
535+
536+
Parameters
537+
----------
538+
node
539+
Condition over the columns of the join's output.
540+
columns
541+
For each output column, the table it comes from and its name there.
542+
left_names, right_names
543+
Column names of the left and right inputs.
544+
545+
Returns
546+
-------
547+
The condition with column references inserted, and the columns to add
548+
to each input so that every comparison in the condition is between
549+
columns of the two inputs.
550+
551+
Raises
552+
------
553+
NotImplementedError
554+
If a part of the condition that uses one input cannot be evaluated
555+
on that input.
556+
"""
557+
left = plc_expr.TableReference.LEFT
558+
right = plc_expr.TableReference.RIGHT
559+
extras: dict[plc_expr.TableReference, dict[expr.Expr, str]] = {left: {}, right: {}}
560+
mapper: JoinPredicateTransformer = CachingVisitor(
561+
_split_join_predicate,
562+
state={
563+
"columns": columns,
564+
"indices": {
565+
left: {name: i for i, name in enumerate(left_names)},
566+
right: {name: i for i, name in enumerate(right_names)},
567+
},
568+
"names": {left: unique_names(left_names), right: unique_names(right_names)},
569+
"extras": extras,
570+
},
571+
)
572+
predicate = mapper(node)
573+
return JoinPredicate(
574+
predicate,
575+
tuple(expr.NamedExpr(name, e) for e, name in extras[left].items()),
576+
tuple(expr.NamedExpr(name, e) for e, name in extras[right].items()),
577+
)

0 commit comments

Comments
 (0)