-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Replace rolling.apply implementation with numba-cuda-mlir #23598
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Cast callable rolling results to The early return bypasses line 428. A UDF that returns an integer or Cast the 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
Suggested change
🤖 Prompt for AI Agents📐 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 500Repository: 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.pyRepository: rapidsai/cudf Length of output: 50369 Update the 🤖 Prompt for AI AgentsSource: 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 | ||||||||||||||||||||||||||||||
|
|
@@ -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, | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
There was a problem hiding this comment.
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:
Repository: rapidsai/cudf
Length of output: 19833
🏁 Script executed:
Repository: rapidsai/cudf
Length of output: 18510
🏁 Script executed:
Repository: rapidsai/cudf
Length of output: 22475
🏁 Script executed:
Repository: rapidsai/cudf
Length of output: 22655
🏁 Script executed:
Repository: rapidsai/cudf
Length of output: 10542
🌐 Web query:
pandas Rolling min_periods BaseIndexer FixedForwardWindowIndexer default window_size source💡 Result:
In pandas, the
BaseIndexerand its subclassFixedForwardWindowIndexerhave a defaultwindow_sizeof 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]. Regardingmin_periodsin the context of rolling operations: 1. For standard integer-based windows,min_periodsdefaults to the size of the window [5][6]. 2. For offset-based windows,min_periodsdefaults to 1 [5][6]. 3. When using aBaseIndexersubclass likeFixedForwardWindowIndexeras thewindowargument inrolling(), themin_periodsparameter is explicitly passed to the indexer'sget_window_boundsmethod [5][1]. If you do not specifymin_periodsin therolling()call, it defaults toNone, and the behavior of the indexer depends on the implementation of itsget_window_boundsmethod [5][1][7]. The source code forBaseIndexerandFixedForwardWindowIndexeris located in the pandas repository withinpandas/core/indexers/objects.py[1][7]. The logic for howrolling()handles these indexers can be found inpandas/core/window/rolling.py[5].Citations:
🏁 Script executed:
Repository: rapidsai/cudf
Length of output: 2533
🏁 Script executed:
Repository: rapidsai/cudf
Length of output: 16300
Preserve the
BaseIndexerwindow-size default formin_periods.When
min_periodsis omitted forFixedForwardWindowIndexer(window_size=3),_apply_agg_columnpasses1instead of3. The final one- and two-row windows therefore produce values instead of nulls. Derive the default fromself.window.window_sizeand add a forward-indexer test without an explicitmin_periods.🤖 Prompt for AI Agents
Source: Coding guidelines