Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
42 changes: 4 additions & 38 deletions python/cudf/cudf/core/_internals/aggregation.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,9 @@

from typing import TYPE_CHECKING, Literal

import numpy as np
from numba.np import numpy_support

import pylibcudf as plc

from cudf.api.types import is_scalar
from cudf.core.udf.utils import compile_udf
from cudf.utils.dtypes import SUPPORTED_NUMPY_TO_PYLIBCUDF_TYPES

if TYPE_CHECKING:
from collections.abc import Callable
Expand Down Expand Up @@ -238,28 +233,6 @@ def any(cls) -> Self:
def all(cls) -> Self:
return cls(plc.aggregation.all())

# Rolling aggregations
@classmethod
def from_udf(cls, op, *args, **kwargs) -> Self:
# Handling UDF type
nb_type = numpy_support.from_dtype(kwargs["dtype"])
type_signature = (nb_type[:],)
ptx_code, output_dtype = compile_udf(op, type_signature)
output_np_dtype = np.dtype(output_dtype)
if output_np_dtype not in SUPPORTED_NUMPY_TO_PYLIBCUDF_TYPES:
raise TypeError(
f"Result of window function has unsupported dtype {op[1]}"
)

return cls(
plc.aggregation.udf(
ptx_code,
plc.DataType(
SUPPORTED_NUMPY_TO_PYLIBCUDF_TYPES[output_np_dtype]
),
)
)


def make_aggregation(
op: str | Callable, kwargs: dict | None = None
Expand All @@ -268,15 +241,10 @@ def make_aggregation(
Parameters
----------
op : str or callable
If callable, must meet one of the following requirements:

* Is of the form lambda x: x.agg(*args, **kwargs), where
`agg` is the name of a supported aggregation. Used to
to specify aggregations that take arguments, e.g.,
`lambda x: x.quantile(0.5)`.
* Is a user defined aggregation function that operates on
group values. In this case, the output dtype must be
specified in the `kwargs` dictionary.
If callable, must be of the form lambda x: x.agg(*args, **kwargs),
where `agg` is the name of a supported aggregation. Used to
specify aggregations that take arguments, e.g.,
`lambda x: x.quantile(0.5)`.
\*\*kwargs : dict, optional
Any keyword arguments to be passed to the op.

Expand All @@ -292,8 +260,6 @@ def make_aggregation(
elif callable(op):
if op is list:
return Aggregation.collect()
elif "dtype" in kwargs:
return Aggregation.from_udf(op, **kwargs)
else:
return op(Aggregation)
raise TypeError(f"Unknown aggregation {op}")
121 changes: 121 additions & 0 deletions python/cudf/cudf/core/udf/rolling_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations

import functools
from typing import TYPE_CHECKING

import cupy as cp
import numpy as np
from numba_cuda_mlir import compiler, cuda
from numba_cuda_mlir.numba_cuda.core import config as _mlir_config
from numba_cuda_mlir.numba_cuda.np import numpy_support

from cudf.core.column.column import ColumnBase, as_column
from cudf.core.udf.utils import UDFError
from cudf.utils.performance_tracking import _performance_tracking

if TYPE_CHECKING:
from collections.abc import Callable


class _MLIRNumbaCudaConfig:
"""Silence numba_cuda_mlir low-occupancy warnings during launch."""

def __enter__(self) -> None:
self._low_occupancy_warnings = _mlir_config.CUDA_LOW_OCCUPANCY_WARNINGS
_mlir_config.CUDA_LOW_OCCUPANCY_WARNINGS = 0

def __exit__(self, exc_type, exc_value, traceback) -> None:
_mlir_config.CUDA_LOW_OCCUPANCY_WARNINGS = self._low_occupancy_warnings


def _get_udf_return_type(func: Callable, value_dtype: np.dtype) -> np.dtype:
"""Compile ``func`` for a 1D window of ``value_dtype`` to infer its
output dtype.
"""
nb_value_type = numpy_support.from_dtype(value_dtype)
signature = (nb_value_type[::1],)
try:
_, return_type = compiler.compile(
func, signature, device=True, output="ptx"
)
except Exception as e:
raise UDFError(str(e)) from e
return np.dtype(numpy_support.as_dtype(return_type))


def _make_rolling_kernel(device_func):
@cuda.jit
def _kernel(data, start, end, out, valid, min_periods):
i = cuda.grid(1)
if i < out.size:
begin = start[i]
stop = end[i]
count = stop - begin
if count >= min_periods and count > 0:
out[i] = device_func(data[begin:stop])
valid[i] = True
else:
valid[i] = False

return _kernel


@functools.lru_cache(maxsize=32)
def _compile_or_get_kernel(func: Callable, value_dtype: np.dtype):
return_dtype = _get_udf_return_type(func, value_dtype)
device_func = cuda.jit(device=True)(func)
kernel = _make_rolling_kernel(device_func)
return kernel, return_dtype


@_performance_tracking
def jit_rolling_apply(
source_column: ColumnBase,
start: cp.ndarray,
end: cp.ndarray,
min_periods: int,
func: Callable,
) -> ColumnBase:
"""Apply a user-defined function to each rolling window using a custom
CUDA kernel compiled with ``numba_cuda_mlir``.

Parameters
----------
source_column : ColumnBase
The (non-null) numeric column the windows are drawn from.
start, end : cupy.ndarray
``size_type`` arrays giving the absolute ``[start, end)`` row
indices of each row's window.
min_periods : int
Minimum number of observations in a window required to produce a
non-null result.
func : callable
The user-defined function. Receives a 1D array (the window) and
returns a scalar.
"""
value_dtype = source_column.dtype
kernel, return_dtype = _compile_or_get_kernel(func, value_dtype)

n = len(source_column)
if n == 0:
return as_column(cp.empty(0, dtype=return_dtype))

data = source_column.values
out = cp.empty(n, dtype=return_dtype)
valid = cp.zeros(n, dtype=np.bool_)

threads_per_block = 128
blocks = (n + threads_per_block - 1) // threads_per_block

with _MLIRNumbaCudaConfig():
kernel[blocks, threads_per_block](
data, start, end, out, valid, min_periods
)
cuda.synchronize()

result = as_column(out)
valid_col = as_column(valid)
mask, null_count = valid_col.as_mask()
return result.set_mask(mask, null_count)
47 changes: 38 additions & 9 deletions python/cudf/cudf/core/window/rolling.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,24 +356,55 @@ def _plc_windows(self) -> WindowTypePair:
f"not {type(self.window).__name__}"
)

def _window_start_end(self) -> tuple[cupy.ndarray, cupy.ndarray]:
"""
Return the absolute ``[start, end)`` row indices of each row's window
as ``size_type`` cupy arrays, used by the UDF (``apply``) kernel path.
"""
n = len(self.obj)
idx = cupy.arange(n, dtype=SIZE_TYPE_DTYPE)
pre, fwd = self._plc_windows
if isinstance(pre, int):
start = idx - (pre - 1)
end = idx + (fwd + 1)
else:
preceding = cupy.asarray(
ColumnBase.from_pylibcudf(pre).astype(SIZE_TYPE_DTYPE).values
)
following = cupy.asarray(
ColumnBase.from_pylibcudf(fwd).astype(SIZE_TYPE_DTYPE).values
)
start = idx - preceding + np.int32(1)
end = idx + following + np.int32(1)
start = cupy.clip(start, 0, n).astype(SIZE_TYPE_DTYPE)
end = cupy.clip(end, 0, n).astype(SIZE_TYPE_DTYPE)
return start, end

def _apply_agg_column(
self, source_column: ColumnBase, agg_name: str | Callable, **agg_kwargs
) -> ColumnBase:
if isinstance(source_column.dtype, CategoricalDtype):
# pandas window aggregations operate on the category values,
# not the codes
source_column = source_column._get_decategorized_column() # type: ignore[attr-defined]

min_periods = 1 if self.min_periods is None else self.min_periods

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- candidate files ---'
fd -i 'rolling|window|indexer' python/cudf tests 2>/dev/null | head -120 || true

printf '%s\n' '--- symbols and call sites ---'
rg -n --glob '*.py' --glob '*.pyx' \
  'class (BaseIndexer|FixedForwardWindowIndexer)|FixedForwardWindowIndexer|min_periods|jit_rolling_apply|_window_start_end' \
  python/cudf tests 2>/dev/null | head -300

printf '%s\n' '--- outline ---'
ast-grep outline python/cudf/cudf/core/window/rolling.py 2>/dev/null | head -160 || true

printf '%s\n' '--- implementation context ---'
sed -n '330,455p' python/cudf/cudf/core/window/rolling.py

printf '%s\n' '--- indexer definitions ---'
rg -l --glob '*.py' --glob '*.pyx' 'class (BaseIndexer|FixedForwardWindowIndexer)' python/cudf | while read -r f; do
  echo "FILE: $f"
  sed -n '1,240p' "$f"
done

Repository: rapidsai/cudf

Length of output: 19833


🏁 Script executed:

#!/bin/bash
set -eu
sed -n '330,455p' python/cudf/cudf/core/window/rolling.py
rg -n --glob '*.py' --glob '*.pyx' 'class (BaseIndexer|FixedForwardWindowIndexer)|FixedForwardWindowIndexer|min_periods|jit_rolling_apply|_window_start_end' python/cudf tests 2>/dev/null | head -300

Repository: rapidsai/cudf

Length of output: 18510


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- rolling.py relevant lines ---'
nl -ba python/cudf/cudf/core/window/rolling.py | sed -n '300,455p'

printf '%s\n' '--- indexer and test locations ---'
rg -n --glob '*.py' --glob '*.pyx' --glob '*.md' \
  'BaseIndexer|FixedForwardWindowIndexer|min_periods' . | head -400

printf '%s\n' '--- rolling tests ---'
fd -i 'rolling' tests python | head -100

Repository: rapidsai/cudf

Length of output: 22475


🏁 Script executed:

#!/bin/bash
set -eu
nl -ba python/cudf/cudf/core/window/rolling.py | sed -n '330,455p'
rg -n --glob '*.py' --glob '*.pyx' --glob '*.md' \
  'BaseIndexer|FixedForwardWindowIndexer|min_periods|jit_rolling_apply' . | head -400
fd -i 'rolling' tests python | head -100

Repository: rapidsai/cudf

Length of output: 22655


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- documented default and normalization ---'
sed -n '60,95p;640,695p' python/cudf/cudf/core/window/rolling.py

printf '%s\n' '--- existing BaseIndexer tests ---'
sed -n '385,425p;475,545p;620,650p' python/cudf/cudf/tests/window/test_rolling.py

printf '%s\n' '--- UDF min_periods behavior ---'
sed -n '35,125p' python/cudf/cudf/core/udf/rolling_utils.py

printf '%s\n' '--- pandas behavioral probe ---'
python3 - <<'PY'
import pandas as pd

indexer = pd.api.indexers.FixedForwardWindowIndexer(window_size=3)
s = pd.Series([10, 20, 30, 40])

for operation in ("sum", "apply"):
    rolling = s.rolling(window=indexer)
    result = getattr(rolling, operation)(lambda x: x.sum() if len(x) else 0, raw=True) if operation == "apply" else rolling.sum()
    print(operation, result.tolist(), result.isna().tolist())

print("rolling_min_periods", rolling.min_periods)
PY

Repository: rapidsai/cudf

Length of output: 10542


🌐 Web query:

pandas Rolling min_periods BaseIndexer FixedForwardWindowIndexer default window_size source

💡 Result:

In pandas, the BaseIndexer and its subclass FixedForwardWindowIndexer have a default window_size of 0 [1][2][3]. This default value indicates that, by default, the indexer is configured to consider all observations within the window [4][2][3]. Regarding min_periods in the context of rolling operations: 1. For standard integer-based windows, min_periods defaults to the size of the window [5][6]. 2. For offset-based windows, min_periods defaults to 1 [5][6]. 3. When using a BaseIndexer subclass like FixedForwardWindowIndexer as the window argument in rolling(), the min_periods parameter is explicitly passed to the indexer's get_window_bounds method [5][1]. If you do not specify min_periods in the rolling() call, it defaults to None, and the behavior of the indexer depends on the implementation of its get_window_bounds method [5][1][7]. The source code for BaseIndexer and FixedForwardWindowIndexer is located in the pandas repository within pandas/core/indexers/objects.py [1][7]. The logic for how rolling() handles these indexers can be found in pandas/core/window/rolling.py [5].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from urllib.request import urlopen

urls = [
    "https://raw.githubusercontent.com/pandas-dev/pandas/main/pandas/core/window/rolling.py",
    "https://raw.githubusercontent.com/pandas-dev/pandas/main/pandas/core/indexers/objects.py",
]

for url in urls:
    print(f"--- {url} ---")
    text = urlopen(url, timeout=15).read().decode()
    lines = text.splitlines()
    needles = (
        "min_periods =",
        "class FixedForwardWindowIndexer",
        "def get_window_bounds",
        "window_size",
    )
    for i, line in enumerate(lines):
        if any(needle in line for needle in needles):
            lo = max(0, i - 5)
            hi = min(len(lines), i + 18)
            print("\n".join(f"{j + 1}: {lines[j]}" for j in range(lo, hi)))
            print()
PY

Repository: rapidsai/cudf

Length of output: 2533


🏁 Script executed:

#!/bin/bash
set -eu

for spec in \
  "https://raw.githubusercontent.com/pandas-dev/pandas/main/pandas/core/window/rolling.py|rolling.py" \
  "https://raw.githubusercontent.com/pandas-dev/pandas/main/pandas/core/indexers/objects.py|objects.py"
do
  url=${spec%%|*}
  name=${spec##*|}
  curl -fsSLk "$url" -o "/tmp/$name"
  echo "--- $name ---"
  rg -n -A16 -B6 \
    'min_periods = \(|class FixedForwardWindowIndexer|def get_window_bounds|window_size' \
    "/tmp/$name" | head -260
done

Repository: rapidsai/cudf

Length of output: 16300


Preserve the BaseIndexer window-size default for min_periods.

When min_periods is omitted for FixedForwardWindowIndexer(window_size=3), _apply_agg_column passes 1 instead of 3. The final one- and two-row windows therefore produce values instead of nulls. Derive the default from self.window.window_size and add a forward-indexer test without an explicit min_periods.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/cudf/cudf/core/window/rolling.py` at line 391, The default assignment
in _apply_agg_column should use self.window.window_size when self.min_periods is
None, preserving explicit min_periods values. Add a test for
FixedForwardWindowIndexer(window_size=3) without min_periods that verifies the
final one- and two-row windows are null.

Source: Coding guidelines


if callable(agg_name):
from cudf.core.udf.rolling_utils import jit_rolling_apply

start, end = self._window_start_end()
return jit_rolling_apply(
source_column, start, end, min_periods, agg_name
)
Comment on lines +393 to +399

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Cast callable rolling results to float64.

The early return bypasses line 428. A UDF that returns an integer or float32 therefore exposes its inferred dtype, while this change requires rolling results to use float64.

Cast the jit_rolling_apply result before returning it. Add an integer-returning UDF assertion because both new tests return a Python float.

Proposed fix
-            return jit_rolling_apply(
-                source_column, start, end, min_periods, agg_name
-            )
+            return jit_rolling_apply(
+                source_column, start, end, min_periods, agg_name
+            ).astype(np.dtype("float64"))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if callable(agg_name):
from cudf.core.udf.rolling_utils import jit_rolling_apply
start, end = self._window_start_end()
return jit_rolling_apply(
source_column, start, end, min_periods, agg_name
)
if callable(agg_name):
from cudf.core.udf.rolling_utils import jit_rolling_apply
start, end = self._window_start_end()
return jit_rolling_apply(
source_column, start, end, min_periods, agg_name
).astype(np.dtype("float64"))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/cudf/cudf/core/window/rolling.py` around lines 393 - 399, Update the
callable branch in the rolling aggregation path to cast the result of
jit_rolling_apply to float64 before returning it, matching the dtype behavior
applied later in the method. Add or update the relevant assertion for
integer-returning UDFs while preserving the existing Python-float result
expectations.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- rolling.py outline ---'
ast-grep outline python/cudf/cudf/core/window/rolling.py
printf '%s\n' '--- relevant implementation and docstring references ---'
rg -n -C 8 'def apply|Rolling\\.apply|numba_cuda_mlir|libcudf|PTX|rolling_apply|jit_rolling_apply' python/cudf/cudf/core/window/rolling.py python/cudf/cudf/core/udf python/cudf -g '*.py' -g '*.pyx'

Repository: rapidsai/cudf

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- rolling.py outline ---'
ast-grep outline python/cudf/cudf/core/window/rolling.py
printf '%s\n' '--- relevant implementation and docstring references ---'
rg -n -C 8 'def apply|Rolling\.apply|numba_cuda_mlir|libcudf|PTX|rolling_apply|jit_rolling_apply' \
  python/cudf/cudf/core/window/rolling.py \
  python/cudf/cudf/core/udf \
  python/cudf -g '*.py' -g '*.pyx'

Repository: rapidsai/cudf

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- Rolling.apply docstring ---'
sed -n '560,635p' python/cudf/cudf/core/window/rolling.py
printf '%s\n' '--- rolling UDF implementation ---'
wc -l python/cudf/cudf/core/udf/rolling_utils.py
ast-grep outline python/cudf/cudf/core/udf/rolling_utils.py
sed -n '1,260p' python/cudf/cudf/core/udf/rolling_utils.py
printf '%s\n' '--- UDF backend and feature documentation references ---'
rg -n -C 4 'numba_cuda_mlir|supported.*feature|unsupported|Rolling\.apply|rolling.*apply|Masked|NA' \
  python/cudf/cudf/core/udf \
  python/cudf/cudf/core/window/rolling.py \
  docs -g '*.py' -g '*.rst' -g '*.md' -g '*.yaml' 2>/dev/null | head -n 500

Repository: rapidsai/cudf

Length of output: 44606


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- remainder of Rolling.apply ---'
sed -n '630,700p' python/cudf/cudf/core/window/rolling.py
printf '%s\n' '--- rolling apply tests and documentation ---'
rg -n -C 6 'rolling.*apply|apply\(.*count_if|jit_rolling_apply|cmath|math\.sin|global variables|PTX|numba_cuda_mlir' \
  python/cudf tests docs -g '*.py' -g '*.rst' -g '*.md' 2>/dev/null | head -n 800
printf '%s\n' '--- repository changes touching the relevant files ---'
git diff --stat -- python/cudf/cudf/core/window/rolling.py python/cudf/cudf/core/udf/rolling_utils.py
git status --short -- python/cudf/cudf/core/window/rolling.py python/cudf/cudf/core/udf/rolling_utils.py

Repository: rapidsai/cudf

Length of output: 50369


Update the Rolling.apply docstring. Describe the numba_cuda_mlir UDF path and its current supported features. Remove stale libcudf and PTX-specific restrictions, and document that null-containing inputs and args/kwargs are unsupported.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/cudf/cudf/core/window/rolling.py` around lines 393 - 399, Update the
Rolling.apply docstring to document the numba_cuda_mlir UDF execution path used
by the callable branch and its currently supported features. Remove outdated
libcudf and PTX-specific limitations, and explicitly state that inputs
containing nulls and passing args or kwargs are unsupported.

Source: Coding guidelines


pre, fwd = self._plc_windows

rolling_agg = aggregation.make_aggregation(
agg_name,
{"dtype": source_column.dtype}
if callable(agg_name)
else agg_kwargs,
agg_name, agg_kwargs
).plc_obj

min_periods = 1 if self.min_periods is None else self.min_periods
if self.min_periods == 0 and isinstance(agg_name, str):
if self.min_periods == 0:
# libcudf supports min_periods=0 and returns identity values for windows with
# insufficient observations: SUM and COUNT return 0, MIN returns the maximum
# value for the type, MAX returns the minimum value for the type. Only SUM and
Expand All @@ -394,9 +425,7 @@ def _apply_agg_column(
plc_result, dtype_from_pylibcudf_column(plc_result)
)

if isinstance(agg_name, str):
return col.astype(np.dtype("float64"))
return col
return col.astype(np.dtype("float64"))

def _reduce(
self,
Expand Down
42 changes: 42 additions & 0 deletions python/cudf/cudf/tests/window/test_rolling.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,48 @@ def some_func(A):
)


@pytest.mark.parametrize("window_size", [1, 2, 3])
@pytest.mark.parametrize("min_periods", [1, 2, 3])
def test_rolling_groupby_numba_udf(window_size, min_periods):
if min_periods > window_size:
pytest.skip("min_periods cannot exceed window_size")
pdf = pd.DataFrame(
{
"a": [1, 1, 1, 2, 2, 2, 2],
"b": [1.0, 2.0, 4.0, 8.0, 9.0, 4.0, 2.0],
}
)
gdf = cudf.from_pandas(pdf)

def some_func(A):
b = 0
for a in A:
b = b + a**2
return b / len(A)

assert_eq(
pdf.groupby("a").rolling(window_size, min_periods).apply(some_func),
gdf.groupby("a").rolling(window_size, min_periods).apply(some_func),
)


def test_rolling_numba_udf_base_indexer():
indexer = pd.api.indexers.FixedForwardWindowIndexer(window_size=3)
pdf = pd.DataFrame({"a": [1.0, 2.0, 4.0, 9.0, 9.0, 4.0]})
gdf = cudf.from_pandas(pdf)

def some_func(A):
b = 0
for a in A:
b = b + a
return b / len(A)

assert_eq(
pdf.rolling(window=indexer, min_periods=1).apply(some_func),
gdf.rolling(window=indexer, min_periods=1).apply(some_func),
)


def test_rolling_groupby_simple(supported_rolling_reductions):
pdf = pd.DataFrame(
{
Expand Down
Loading