# Copyright 2025 BrainX Ecosystem Limited. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
import contextlib
import ctypes
import importlib.util
import threading
import traceback
from ctypes import c_void_p, POINTER, CFUNCTYPE
from typing import Callable, Dict, Optional, Tuple, Union
import jax
import numpy as np
from brainevent._error import KernelRegistrationError
from .ffi_naming import kernel_content_fingerprint
from .numba_ffi import (
XLA_FFI_API_MAJOR,
XLA_FFI_API_MINOR,
XLA_FFI_Error_Code,
XLA_FFI_Extension_Type,
XLA_FFI_Metadata_Extension,
XLA_FFI_CallFrame,
XLA_FFI_Buffer,
make_ffi_error,
resolve_buffer_dtype,
get_xla_stream,
get_device_ordinal,
_normalize_shapes_and_dtypes,
_warn_if_untested_jax,
)
from .util import OutType, abstract_arguments
__all__ = [
'numba_cuda_kernel',
'numba_cuda_callable',
]
numba_cuda_installed = importlib.util.find_spec('numba') is not None
# Cached lazy import, initialized by import_numba_cuda() on first use.
cuda = None
def import_numba_cuda():
"""Import ``numba.cuda`` lazily and validate CUDA availability.
Returns
-------
module
The imported ``numba.cuda`` module.
Raises
------
ImportError
If numba is not importable, or if numba is installed but CUDA is not
currently available (device not present or the driver raised).
Notes
-----
Only a genuine :class:`ImportError` (numba itself is not importable) poisons
the module-level ``numba_cuda_installed`` flag; a *transient* CUDA failure
(e.g. ``CUDA_ERROR_NOT_INITIALIZED`` inside a forked worker, or
``cuda.is_available()`` raising) leaves the flag ``True`` so a later call in
a healthy context can succeed (F19). The flag therefore means "numba is
importable", never "CUDA worked once".
"""
global cuda, numba_cuda_installed
if cuda is not None:
return cuda
if not numba_cuda_installed:
raise ImportError(
'Numba with CUDA support is required. '
'Please install numba and ensure CUDA is available.'
)
try:
from numba import cuda as _cuda
except ImportError as exc:
# Genuine import failure: numba (or its CUDA target) is not installed.
# This is the *only* condition that poisons the availability flag.
numba_cuda_installed = False
raise ImportError(
'Numba with CUDA support is required. '
'Please install numba and ensure CUDA is available.'
) from exc
# numba imported cleanly; probe the runtime WITHOUT poisoning the flag so a
# transient CUDA error does not permanently disable the backend (F19).
try:
available = _cuda.is_available()
except Exception as exc: # noqa: BLE001 - transient CUDA/driver error
raise ImportError(
'Numba is installed but CUDA is not currently available: '
f'{type(exc).__name__}: {exc}. This may be transient (e.g. an '
'uninitialised CUDA context in a forked worker); retry in a '
'healthy CUDA context.'
) from exc
if not available:
raise ImportError(
'Numba is installed but no CUDA device is available on this machine.'
)
cuda = _cuda
return cuda
_NUMBA_CUDA_FFI_HANDLES: Dict[str, object] = {}
# Maps a kernel/dtype/launch-mode/out-size signature to an already-registered
# FFI target so repeated eager calls reuse one registration instead of leaking
# a fresh handler (and ctypes callback) per call (H1/F8).
_NUMBA_CUDA_FFI_TARGETS: Dict[tuple, str] = {}
# Maps a content-derived target *name* to the fingerprint it was registered
# under (F14). Two textually identical kernels (even freshly redefined after a
# module reload) reuse the same registration; a name whose stored fingerprint
# differs from a new one raises rather than silently rebinding the target.
_NUMBA_CUDA_FFI_NAME_FINGERPRINTS: Dict[str, Optional[str]] = {}
# Pins kernel objects memoized in ``_NUMBA_CUDA_FFI_TARGETS`` via the
# fingerprint-reuse path. Those kernels share the first registration's handler
# (which pins only the FIRST kernel), so without this pin such a kernel could
# be garbage-collected and its ``id`` recycled by a different kernel, which
# would then wrongly hit the memo and dispatch to the old handler.
_NUMBA_CUDA_FFI_KERNEL_PINS: Dict[int, object] = {}
_CUDA_FFI_CALLBACK_COUNTER = 0
# Serializes target registration (trace/lowering time). There is deliberately
# no per-launch lock: each callback operates only on its own call-local device
# arrays and on XLA's stream, so concurrent launches cannot race (L15).
_CUDA_REGISTRATION_LOCK = threading.Lock()
# The typed FFI callback signature: void* fn(XLA_FFI_CallFrame*)
_CUDA_FFI_CALLBACK_TYPE = CFUNCTYPE(c_void_p, POINTER(XLA_FFI_CallFrame))
# ---------------------------------------------------------------------------
# Device-context binding
#
# The XLA FFI ctypes structures, the stream getter and the device-ordinal
# getter live in ``numba_ffi`` (the single source of truth for the FFI ABI).
# This bridge imports ``get_xla_stream`` / ``get_device_ordinal`` from there
# and only adds the numba-CUDA-specific device-context helper below.
# ---------------------------------------------------------------------------
def _device_context(ordinal):
"""Return a context manager binding numba.cuda to device *ordinal*.
XLA may place an FFI call on any visible GPU; the device arrays and the
stream must be constructed on *that* device's context, not on whatever
device numba currently has selected (C3). Entering ``cuda.gpus[ordinal]``
pushes the matching device context for the duration of the launch.
Parameters
----------
ordinal : int or None
Device ordinal reported by ``XLA_FFI_DeviceOrdinal_Get``. ``None``
(older jaxlib that does not expose the ordinal) yields a
:class:`contextlib.nullcontext`, falling back to numba's current
device.
Returns
-------
context manager
Binds the requested device on ``__enter__`` and restores the previous
device on ``__exit__``.
"""
if ordinal is None:
return contextlib.nullcontext()
try:
return import_numba_cuda().gpus[ordinal]
except Exception: # noqa: BLE001 - unknown ordinal -> keep current device
return contextlib.nullcontext()
def _numba_stream_from_ptr(stream_ptr: int):
"""Create a Numba CUDA stream from a raw ``cudaStream_t`` pointer.
Parameters
----------
stream_ptr : int
The ``cudaStream_t`` pointer as a Python integer (e.g.,
obtained from :func:`brainevent._op.numba_ffi.get_xla_stream`).
Returns
-------
numba.cuda.cudadrv.driver.Stream
A Numba CUDA stream object wrapping the given pointer. Kernel
launches on this stream will execute on XLA's CUDA stream.
"""
return import_numba_cuda().external_stream(stream_ptr)
def _device_array_from_buffer(data_ptr: int, shape: Tuple[int, ...], dtype: np.dtype):
"""Create a Numba CUDA device array from a raw device memory pointer.
Uses the ``__cuda_array_interface__`` protocol for zero-copy access
to device memory owned by XLA.
Parameters
----------
data_ptr : int
The device memory pointer as a Python integer.
shape : tuple of int
The shape of the array.
dtype : numpy.dtype
The element data type.
Returns
-------
numba.cuda.cudadrv.devicearray.DeviceNDArray
A Numba CUDA device array that wraps the given device memory
without copying.
Notes
-----
The returned array does **not** own the underlying memory. The
caller must ensure that the memory remains valid for the lifetime
of the array.
A zero-element buffer is materialised as a fresh empty device array
rather than wrapped, because XLA may hand a null pointer for an empty
buffer and ``as_cuda_array`` rejects a null base pointer (M3).
"""
dtype = np.dtype(dtype)
shape = tuple(int(d) for d in shape)
size = 1
for d in shape:
size *= d
if size == 0:
return import_numba_cuda().device_array(shape, dtype=dtype)
class DevicePointerWrapper:
"""Wrapper class that implements __cuda_array_interface__ protocol."""
def __init__(self, ptr, arr_shape, arr_dtype):
self._ptr = ptr
self._shape = arr_shape
self._dtype = arr_dtype
@property
def __cuda_array_interface__(self):
# ``strides`` is ``None`` to declare the buffer C-contiguous; the
# ffi_call ``input_layouts``/``output_layouts`` make XLA honour
# this (M4), so the row-major reshape from ``shape`` is exact.
return {
'shape': self._shape,
'typestr': self._dtype.str,
'data': (self._ptr, False), # (ptr, read_only)
'strides': None,
'version': 3,
}
wrapper = DevicePointerWrapper(data_ptr, shape, dtype)
return import_numba_cuda().as_cuda_array(wrapper)
def _zero_fill_on_stream(device_array, stream) -> None:
"""Asynchronously zero every byte of *device_array* on *stream*.
Used when a kernel launch is skipped for a degenerate (zero) grid/block but
an output buffer is non-empty (F9): returning success without touching the
buffer would hand XLA uninitialised device memory. A byte-wise memset to
``0`` yields ``0`` for every fixed-width numeric dtype (IEEE ``+0.0``,
integer ``0``, boolean ``False``), so no per-dtype special-casing is needed.
Parameters
----------
device_array : numba.cuda.cudadrv.devicearray.DeviceNDArray
The output device array to clear. Must be C-contiguous (the bridge
only ever builds contiguous wrappers).
stream : numba.cuda.cudadrv.driver.Stream
The XLA-provided CUDA stream to enqueue the memset on, preserving the
async ordering XLA expects.
Notes
-----
``numba.cuda.cudadrv.driver.device_memset`` is the cheapest correct
mechanism numba exposes for a stream-ordered clear: it issues a single
``cuMemsetD8Async`` rather than compiling and launching a fill kernel.
"""
from numba.cuda.cudadrv.driver import device_memset
nbytes = int(device_array.size) * device_array.dtype.itemsize
if nbytes == 0:
return
device_memset(device_array, 0, nbytes, stream=stream)
def _compute_launch_config(
launch_dims: Union[int, Tuple[int, ...]],
threads_per_block: int = 256,
) -> Tuple[Tuple[int, ...], Tuple[int, ...]]:
"""Compute CUDA grid and block dimensions from total launch dimensions.
Automatically determines an appropriate grid/block decomposition
for 1-D, 2-D, or 3-D kernel launches given the total number of
threads desired along each axis.
Parameters
----------
launch_dims : int or tuple of int
Total number of threads to launch along each axis. An ``int``
is treated as a 1-D launch. Tuples of length 2 or 3 produce
2-D or 3-D launches respectively.
threads_per_block : int, optional
Maximum number of threads per block for 1-D launches. Default
is ``256``. For 2-D and 3-D launches, fixed block sizes are
used (16x16 and 8x8x4 respectively).
Returns
-------
grid : tuple of int
Grid dimensions (number of blocks per axis).
block : tuple of int
Block dimensions (number of threads per block per axis).
Raises
------
ValueError
If *launch_dims* has zero or more than 3 dimensions, contains a
negative extent, or if *threads_per_block* is not positive.
Notes
-----
A zero extent along an axis is allowed and yields a grid of ``0`` blocks
along that axis (an empty launch); the per-axis block size is clamped to a
minimum of ``1`` so the grid computation never divides by zero (M3).
Examples
--------
.. code-block:: python
>>> grid, block = _compute_launch_config(1024)
>>> grid
(4,)
>>> block
(256,)
>>> grid, block = _compute_launch_config((64, 64))
>>> grid
(4, 4)
>>> block
(16, 16)
"""
if isinstance(launch_dims, int):
launch_dims = (launch_dims,)
launch_dims = tuple(int(d) for d in launch_dims)
n = len(launch_dims)
if n < 1 or n > 3:
raise ValueError(f"launch_dims must have 1-3 dimensions, got {n}")
if any(d < 0 for d in launch_dims):
raise ValueError(f"launch_dims extents must be non-negative, got {launch_dims}")
if threads_per_block < 1:
raise ValueError(f"threads_per_block must be positive, got {threads_per_block}")
# Per-axis caps: 1-D uses the configurable budget, 2-D a 16x16 tile, 3-D an
# 8x8x4 tile. ``max(1, ...)`` guards a zero extent so the grid division
# below never divides by zero; a zero extent then produces a 0-block grid.
caps = {1: (threads_per_block,), 2: (16, 16), 3: (8, 8, 4)}[n]
block = tuple(max(1, min(cap, dim)) for cap, dim in zip(caps, launch_dims))
grid = tuple((dim + blk - 1) // blk for dim, blk in zip(launch_dims, block))
return grid, block
class NumbaCudaFfiHandler:
"""Typed FFI handler that bridges XLA's typed FFI protocol to a single Numba CUDA kernel.
This handler registers a single ``@cuda.jit`` kernel as an XLA FFI
target. When XLA invokes the FFI callback during execution, the
handler extracts input/output device arrays and the CUDA stream from
the call frame, computes the launch configuration from the stored
*launch policy*, and launches the kernel on that stream.
Rather than freezing a concrete grid/block at construction, the handler
stores the *launch policy* so it can adapt to ``jax.vmap`` (F5). Under vmap
the forwarded ``vmap_method`` hands the callback buffers with exactly one
extra leading batch axis. The callback detects that by *rank* (runtime rank
of output 0 equals its abstract rank + 1) and launches the unbatched kernel
**once per batch slice** on the XLA stream, passing a zero-copy view of each
slice. Per-slice launches (rather than a single rescaled launch over a
flattened buffer) are correct for *any* kernel, including ones whose rows are
coupled (stencils, reductions, atomics); a flattened launch would read/write
across batch boundaries. ``B`` slices enqueued on one stream run in order,
so no extra synchronisation is needed.
Parameters
----------
name : str
Unique FFI target name used for registration with
``jax.ffi.register_ffi_target``.
kernel : numba.cuda.compiler.CUDADispatcher
The compiled Numba CUDA kernel (from ``@cuda.jit``).
input_dtypes : tuple of numpy.dtype
Trace-time input dtypes, used only as the fallback for
:func:`resolve_buffer_dtype` (the runtime dtype code is authoritative).
output_dtypes : tuple of numpy.dtype
Trace-time output dtypes, used only as the resolver fallback.
abstract_out_shapes : tuple of tuple of int
The *unbatched* (abstract) shape of each output. vmap is detected by
comparing the runtime rank of output 0 against ``len(abstract_out_shapes[0])``.
launch_mode : tuple
The launch policy. Either ``('launch_dims', launch_dims, threads_per_block)``
(grid/block computed from the *unbatched* dims per launch) or
``('explicit', grid, block)`` (fixed grid/block; vmap forbidden).
shared_mem : int, optional
Dynamic shared memory size in bytes. Default is ``0``.
See Also
--------
numba_cuda_kernel : High-level API for creating a JAX-callable from
a single Numba CUDA kernel.
NumbaCudaCallableHandler : Handler for arbitrary multi-kernel Python
callables.
Notes
-----
The handler object must be kept alive (stored in a module-level
dictionary) to prevent garbage collection of the ctypes callback,
which would cause a segmentation fault when XLA tries to invoke it.
"""
def __init__(
self,
name: str,
kernel,
input_dtypes: Tuple[np.dtype, ...],
output_dtypes: Tuple[np.dtype, ...],
abstract_out_shapes: Tuple[Tuple[int, ...], ...],
launch_mode: tuple,
shared_mem: int = 0,
):
self.name = name
self.kernel = kernel
self.input_dtypes = input_dtypes
self.output_dtypes = output_dtypes
self.abstract_out_shapes = abstract_out_shapes
self.launch_mode = launch_mode
self.shared_mem = shared_mem
# Create the ctypes callback - must be stored as an attribute to prevent GC
self._callback = _CUDA_FFI_CALLBACK_TYPE(self._ffi_callback)
# Register as an FFI target for CUDA platform
_warn_if_untested_jax()
capsule = jax.ffi.pycapsule(ctypes.cast(self._callback, c_void_p).value)
jax.ffi.register_ffi_target(name, capsule, platform="CUDA")
# Self-pin (F7): XLA now holds a raw function pointer into
# ``self._callback``; ``self`` must never be collected while the
# registration is live, even for direct construction that bypasses
# the module-level factory. Re-pinning the same name is idempotent.
_NUMBA_CUDA_FFI_HANDLES[name] = self
def _ffi_callback(self, call_frame_ptr):
"""Typed FFI callback invoked by XLA during kernel execution.
Extracts input and output device arrays from the call frame, derives the
vmap batch factor and launch configuration from the stored launch
policy, obtains the CUDA stream, and launches the Numba CUDA kernel.
Also handles XLA metadata extension queries (API version and traits).
Parameters
----------
call_frame_ptr : ctypes.POINTER(XLA_FFI_CallFrame)
Pointer to the XLA FFI call frame.
Returns
-------
None or int
``None`` (XLA OkStatus) on success, or an ``XLA_FFI_Error*``
pointer (as an integer) when the launch raised or a batched call was
rejected, so the failure surfaces to the JAX caller instead of being
reported as success (C1/F5).
Notes
-----
Under ``jax.vmap`` the ``expand_dims`` / ``broadcast_all`` methods hand
every buffer exactly one extra leading axis (size ``B`` for a mapped
operand, size ``1`` for an operand broadcast in from ``in_axes=None``).
vmap is detected by *rank*: the runtime rank of output 0 equals its
abstract rank + 1. The callback then launches the *unbatched* kernel
once per batch slice ``b`` on the XLA stream, passing ``arr[b]`` for a
buffer whose leading dim is ``B`` and ``arr[0]`` for one whose leading
dim is ``1`` (both are zero-copy contiguous views). Per-slice launches
are correct for coupled-row kernels (stencils, reductions, atomics),
which a single flattened launch would corrupt across batch boundaries.
No vmap (equal ranks) is the pre-existing single launch, bit-identical.
When the (unbatched) launch config is degenerate (a zero grid/block) the
launch is skipped and every non-empty output is zero-filled once on the
stream so no uninitialised memory is returned (F9).
"""
try:
call_frame = call_frame_ptr.contents
# Metadata query: walk the whole extension chain (a future jaxlib may
# prepend other nodes before the metadata node) (F19).
ext_ptr = call_frame.extension_start
while ext_ptr:
ext = ext_ptr.contents
if ext.type == int(XLA_FFI_Extension_Type.Metadata):
metadata_ext = ctypes.cast(
ext_ptr, POINTER(XLA_FFI_Metadata_Extension)
).contents
metadata = metadata_ext.metadata.contents
metadata.api_version.major_version = XLA_FFI_API_MAJOR
metadata.api_version.minor_version = XLA_FFI_API_MINOR
metadata.traits = 0 # not command-buffer-compatible
return None # success
ext_ptr = ext.next
api_ptr = call_frame.api
ctx = call_frame.ctx
# Bind the GPU XLA placed this call on before building any device
# array or stream, so they reference the correct device (C3).
ordinal = get_device_ordinal(api_ptr, ctx)
with _device_context(ordinal):
# --- read raw output dims and detect vmap by RANK --------------
n_outputs = call_frame.rets.size
out_bufs = []
for i in range(n_outputs):
buf_ptr = ctypes.cast(
call_frame.rets.rets[i], POINTER(XLA_FFI_Buffer)
).contents
dims = tuple(buf_ptr.dims[d] for d in range(buf_ptr.rank))
out_bufs.append((buf_ptr, dims))
out0_dims = out_bufs[0][1]
abstract0_shape = self.abstract_out_shapes[0]
abstract0_rank = len(abstract0_shape)
# Each vmap level adds exactly one leading batch axis; rank-based
# detection (unlike a size ratio) correctly handles batch == 1.
# Only a single level is supported: with two or more extra axes
# the per-slice reconstruction below would treat the call as
# unbatched and return garbage for every slice but the first,
# so refuse loudly instead (nested-vmap users should wrap the
# outer level with vmap_method='sequential').
extra_axes = len(out0_dims) - abstract0_rank
if extra_axes not in (0, 1):
return make_ffi_error(
api_ptr,
XLA_FFI_Error_Code.INVALID_ARGUMENT,
f'Numba CUDA kernel {self.name!r}: runtime output rank '
f'{len(out0_dims)} differs from the abstract rank '
f'{abstract0_rank} by {extra_axes} leading axes; only one '
f'level of vmap is supported (nested vmap adds one axis '
f'per level). Apply outer levels with '
f"vmap_method='sequential' or flatten batch axes before "
f'calling.',
)
vmapped = (extra_axes == 1)
batch = out0_dims[0] if vmapped else 1
if vmapped:
# Defensive: the leading axis must account for the whole size
# difference between the runtime and abstract output.
abstract0_size = 1
for d in abstract0_shape:
abstract0_size *= d
runtime0_size = 1
for d in out0_dims:
runtime0_size *= d
if abstract0_size * batch != runtime0_size:
return make_ffi_error(
api_ptr,
XLA_FFI_Error_Code.INTERNAL,
f'Numba CUDA kernel {self.name!r}: output rank implies a '
f'vmap batch axis of {batch}, but runtime size {runtime0_size} '
f'!= batch * abstract size ({batch} * {abstract0_size}).',
)
# --- resolve the *unbatched* launch configuration --------------
mode = self.launch_mode[0]
if mode == 'explicit':
if vmapped:
return make_ffi_error(
api_ptr,
XLA_FFI_Error_Code.INTERNAL,
f'Numba CUDA kernel {self.name!r} was registered with an '
f'explicit grid/block and cannot be vmapped; register with '
f'launch_dims for batched execution.',
)
grid, block = self.launch_mode[1], self.launch_mode[2]
else: # 'launch_dims' -- unbatched config (no scaling)
launch_dims, threads_per_block = self.launch_mode[1], self.launch_mode[2]
grid, block = _compute_launch_config(launch_dims, threads_per_block)
degenerate = (0 in grid) or (0 in block)
# --- build the (possibly batched) device arrays ----------------
n_inputs = call_frame.args.size
input_arrays = []
for i in range(n_inputs):
buf_ptr = ctypes.cast(
call_frame.args.args[i], POINTER(XLA_FFI_Buffer)
).contents
dims = tuple(buf_ptr.dims[d] for d in range(buf_ptr.rank))
fallback = self.input_dtypes[i] if i < len(self.input_dtypes) else np.dtype(np.float32)
dtype = resolve_buffer_dtype(buf_ptr.dtype, fallback)
input_arrays.append(_device_array_from_buffer(buf_ptr.data, dims, dtype))
output_arrays = []
for i in range(n_outputs):
buf_ptr, dims = out_bufs[i]
fallback = self.output_dtypes[i] if i < len(self.output_dtypes) else np.dtype(np.float32)
dtype = resolve_buffer_dtype(buf_ptr.dtype, fallback)
output_arrays.append(_device_array_from_buffer(buf_ptr.data, dims, dtype))
# Extract XLA's CUDA stream (checked: a failed lookup raises
# rather than yielding a null/garbage stream) and launch on it.
stream_ptr = get_xla_stream(api_ptr, ctx)
stream = _numba_stream_from_ptr(stream_ptr)
if degenerate:
# Skip a degenerate launch (a zero grid/block dimension is a
# driver error, and an empty problem has no work); zero-fill
# every non-empty output once so nothing is returned
# uninitialised (F9/M3).
for arr in output_arrays:
if arr.size > 0:
_zero_fill_on_stream(arr, stream)
elif not vmapped:
# Pre-existing single-launch path (bit-identical).
self.kernel[grid, block, stream, self.shared_mem](*input_arrays, *output_arrays)
else:
# vmap: every buffer must carry a leading axis of size B or 1.
def _slice_selector(arrays, kind):
selectors = []
for j, arr in enumerate(arrays):
lead = arr.shape[0] if arr.ndim >= 1 else 1
if lead == batch:
selectors.append((arr, True)) # slice arr[b]
elif lead == 1:
selectors.append((arr, False)) # broadcast arr[0]
else:
return None, make_ffi_error(
api_ptr,
XLA_FFI_Error_Code.INTERNAL,
f'Numba CUDA kernel {self.name!r}: {kind} {j} has '
f'leading dim {lead}, which is neither the vmap batch '
f'{batch} nor 1. Retry with vmap_method="broadcast_all".',
)
return selectors, None
in_sel, err = _slice_selector(input_arrays, 'input')
if err is not None:
return err
out_sel, err = _slice_selector(output_arrays, 'output')
if err is not None:
return err
# B slices enqueued on one stream run in order; each slice is
# a zero-copy view, so the kernel sees unbatched-rank arrays.
for b in range(batch):
in_slices = [arr[b] if per_batch else arr[0] for arr, per_batch in in_sel]
out_slices = [arr[b] if per_batch else arr[0] for arr, per_batch in out_sel]
self.kernel[grid, block, stream, self.shared_mem](*in_slices, *out_slices)
except Exception as exc: # noqa: BLE001 - surfaced to XLA as an FFI error
traceback.print_exc()
try:
err_api_ptr = call_frame_ptr.contents.api
except Exception:
err_api_ptr = None
return make_ffi_error(
err_api_ptr,
XLA_FFI_Error_Code.INTERNAL,
f'Numba CUDA kernel {self.name!r} raised '
f'{type(exc).__name__}: {exc}',
)
return None # success
def _register_numba_cuda_ffi_target(
kernel,
input_dtypes: Tuple[np.dtype, ...],
output_shapes: Tuple[Tuple[int, ...], ...],
output_dtypes: Tuple[np.dtype, ...],
launch_mode: tuple,
shared_mem: int = 0,
):
"""Register (or reuse) a Numba CUDA kernel as an XLA typed FFI target.
Creates a :class:`NumbaCudaFfiHandler` that wraps the kernel and registers
it with ``jax.ffi.register_ffi_target``. The handler is stored in a
module-level dictionary to prevent garbage collection.
Parameters
----------
kernel : numba.cuda.compiler.CUDADispatcher
The compiled Numba CUDA kernel (from ``@cuda.jit``).
input_dtypes : tuple of numpy.dtype
Data types of the input buffers (resolver fallback only).
output_shapes : tuple of tuple of int
Abstract (unbatched) shapes of the output buffers.
output_dtypes : tuple of numpy.dtype
Data types of the output buffers.
launch_mode : tuple
The launch policy: ``('launch_dims', launch_dims, threads_per_block)``
or ``('explicit', grid, block)``.
shared_mem : int, optional
Dynamic shared memory size in bytes. Default is ``0``.
Returns
-------
target_name : str
The FFI target name assigned to this kernel.
out_types : tuple of jax.ShapeDtypeStruct
Output type specifications for use with ``jax.ffi.ffi_call``.
Raises
------
ImportError
If Numba with CUDA support is not available.
KernelRegistrationError
If a content-derived name is already registered under a *different*
fingerprint (an astronomically unlikely sha256 collision).
See Also
--------
NumbaCudaFfiHandler : The handler class created by this function.
numba_cuda_kernel : High-level user-facing API.
Notes
-----
Two caches are layered here (mirroring the CPU path in
:mod:`brainevent._op.numba_ffi`):
1. A fast in-process memo keyed on
``(id(kernel), input_dtypes, output_dtypes, abstract_out_shapes,
launch_mode, shared_mem)`` — per-call *input shapes* and the runtime
(batched) output shapes are excluded (the callback re-derives them from
``buf_ptr.dims``), so a kernel called with many distinct shapes registers
a single target instead of leaking one per shape (F8). The *abstract*
output shapes stay in the key because the callback needs their rank to
detect vmap, and because one kernel function may be wrapped twice with
different ``outs`` — those must map to distinct handlers.
2. A content-derived name (F14):
``brainevent_numba_cuda_ffi_{fingerprint}`` where *fingerprint* is
:func:`kernel_content_fingerprint` over the kernel content plus the same
discriminators as the key. This makes the name and the key one-to-one,
so two freshly redefined but byte-identical kernels (e.g. after a module
reload — different ``id`` yet same content) reuse the one registration.
``None`` (an unserialisable closure) falls back to the legacy per-process
counter for that kernel only (cross-process name stability lost, never
correctness).
"""
global _CUDA_FFI_CALLBACK_COUNTER
import_numba_cuda()
out_types = tuple(
jax.ShapeDtypeStruct(shape, dtype)
for shape, dtype in zip(output_shapes, output_dtypes)
)
abstract_out_shapes = tuple(
tuple(int(d) for d in shape) for shape in output_shapes
)
# Discriminators shared by BOTH the key and the fingerprint so a name maps
# one-to-one to a handler (see Notes). ``input_dtypes``/``output_dtypes`` are
# kept because they feed the resolver fallback; folding them into the
# fingerprint too keeps distinct-dtype kernels on distinct targets. The
# abstract output *shapes* (not just sizes) are stored so the callback can
# detect vmap by rank and so two wrappers with the same total size but
# different out shapes never share a handler holding the wrong shape.
discriminators = (input_dtypes, output_dtypes, abstract_out_shapes, launch_mode, shared_mem)
signature = (id(kernel),) + discriminators
with _CUDA_REGISTRATION_LOCK:
cached_name = _NUMBA_CUDA_FFI_TARGETS.get(signature)
if cached_name is not None:
return cached_name, out_types
fingerprint = kernel_content_fingerprint(kernel, extra=discriminators)
if fingerprint is not None:
target_name = f'brainevent_numba_cuda_ffi_{fingerprint}'
existing_fingerprint = _NUMBA_CUDA_FFI_NAME_FINGERPRINTS.get(target_name)
if existing_fingerprint is not None:
if existing_fingerprint == fingerprint:
# Same content already registered under this name (e.g. a
# module reload re-ran this call site with a freshly defined
# but byte-identical kernel) -- reuse without re-registering.
# Pin *this* kernel: the shared handler keeps only the FIRST
# kernel alive, and the memo entry is keyed on this one's id.
_NUMBA_CUDA_FFI_KERNEL_PINS[id(kernel)] = kernel
_NUMBA_CUDA_FFI_TARGETS[signature] = target_name
return target_name, out_types
raise KernelRegistrationError(
f'FFI target name {target_name!r} is already registered for a kernel '
f'with a different content fingerprint ({existing_fingerprint!r} != '
f'{fingerprint!r}). This is a sha256 collision between two distinct '
f'kernel contents and should be astronomically unlikely; if it happens, '
f'please report it at https://github.com/chaobrain/brainevent/issues.'
)
else:
# Unserializable closure/global: fall back to a per-process unique
# counter name for this kernel only (loses cross-process name
# stability, never correctness).
target_name = f'brainevent_numba_cuda_ffi_{_CUDA_FFI_CALLBACK_COUNTER}'
_CUDA_FFI_CALLBACK_COUNTER += 1
handler = NumbaCudaFfiHandler(
name=target_name,
kernel=kernel,
input_dtypes=input_dtypes,
output_dtypes=output_dtypes,
abstract_out_shapes=abstract_out_shapes,
launch_mode=launch_mode,
shared_mem=shared_mem,
)
# Keep the handler alive to prevent GC of ctypes callback
_NUMBA_CUDA_FFI_HANDLES[target_name] = handler
_NUMBA_CUDA_FFI_NAME_FINGERPRINTS[target_name] = fingerprint
_NUMBA_CUDA_FFI_TARGETS[signature] = target_name
return target_name, out_types
[docs]
def numba_cuda_kernel(
kernel: Callable,
outs: OutType,
*,
grid: Union[int, Tuple[int, ...], None] = None,
block: Union[int, Tuple[int, ...], None] = None,
launch_dims: Union[int, Tuple[int, ...], None] = None,
threads_per_block: int = 256,
shared_mem: int = 0,
vmap_method: str | None = None,
input_output_aliases: dict[int, int] | None = None,
) -> Callable:
"""Create a JAX-callable function from a single Numba CUDA kernel.
Wraps a Numba CUDA kernel (decorated with ``@cuda.jit``) so that it
can be called from JAX on GPU. The kernel operates on device memory
directly with zero-copy access via XLA's typed FFI protocol.
Either ``(grid, block)`` or ``launch_dims`` must be specified to
configure the CUDA launch. When ``launch_dims`` is used, the grid
and block dimensions are computed automatically.
Parameters
----------
kernel : numba.cuda.compiler.CUDADispatcher
A Numba CUDA kernel function decorated with ``@cuda.jit``.
outs : OutType
Output specification. A single ``jax.ShapeDtypeStruct`` or a
sequence/pytree of them for multiple outputs.
grid : int or tuple of int or None, optional
Grid dimensions for the kernel launch. Must be specified
together with *block*. Mutually exclusive with *launch_dims*.
block : int or tuple of int or None, optional
Block dimensions for the kernel launch. Must be specified
together with *grid*.
launch_dims : int or tuple of int or None, optional
Total number of threads to launch. Grid and block are computed
automatically. Mutually exclusive with *(grid, block)*.
threads_per_block : int, optional
Number of threads per block when using *launch_dims*. Default
is ``256``.
shared_mem : int, optional
Dynamic shared memory size in bytes. Default is ``0``.
vmap_method : str or None, optional
Method to use for ``jax.vmap``. Passed directly to
``jax.ffi.ffi_call``.
input_output_aliases : dict of int to int or None, optional
Mapping from input index to output index for in-place
operations. Passed directly to ``jax.ffi.ffi_call``.
Returns
-------
callable
A function that takes JAX arrays as inputs and returns JAX
arrays as outputs. The function can be used inside
``jax.jit``-compiled code.
Raises
------
ImportError
If Numba with CUDA support is not available.
ValueError
If the launch configuration is invalid: neither ``(grid, block)`` nor
``launch_dims`` given; only one of ``grid``/``block`` given; both
``(grid, block)`` and ``launch_dims`` given; ``vmap_method`` combined
with an explicit ``grid``/``block``; or an input/output dtype is
``bfloat16`` (which numba CUDA cannot launch).
TypeError
If *kernel* is not a ``numba.cuda.dispatcher.CUDADispatcher``.
See Also
--------
numba_cuda_callable : Wrap an arbitrary Python callable that
launches multiple Numba CUDA kernels.
XLACustomKernel.def_numba_cuda_kernel : Register a Numba CUDA
kernel with an ``XLACustomKernel``.
Notes
-----
``grid``/``block`` and ``launch_dims`` are mutually exclusive and one is
required; the two members of ``grid``/``block`` must be given together.
Under ``jax.vmap`` the ``launch_dims`` path is batch-aware: the callback
detects the added batch axis by rank and launches the unbatched kernel once
per batch slice on the stream, so every batched element is computed
correctly — including coupled-row kernels such as stencils or reductions
(F5). Explicit ``grid``/``block`` kernels cannot be sliced safely, so
combining them with ``vmap_method`` raises here at wrap time.
Registrations are memoised by ``(kernel, dtypes, abstract-out-shapes,
launch-mode, shared_mem)`` — per-call shapes are excluded, so repeated calls
with different input shapes reuse a single FFI target instead of leaking one
handler per shape (F8). Target names are content-derived (F14).
Examples
--------
.. code-block:: python
>>> from numba import cuda
>>> import jax
>>> import jax.numpy as jnp
>>>
>>> @cuda.jit
... def add_kernel(x, y, out):
... i = cuda.grid(1)
... if i < out.size:
... out[i] = x[i] + y[i]
>>>
>>> # Option 1: Explicit grid/block
>>> kernel_fn = numba_cuda_kernel(
... add_kernel,
... outs=jax.ShapeDtypeStruct((1024,), jnp.float32),
... grid=4,
... block=256,
... )
>>>
>>> # Option 2: Auto grid from launch_dims
>>> kernel_fn = numba_cuda_kernel(
... add_kernel,
... outs=jax.ShapeDtypeStruct((1024,), jnp.float32),
... launch_dims=1024,
... )
>>>
>>> @jax.jit
... def f(a, b):
... return kernel_fn(a, b)
"""
# --- validate the launch configuration (pure; before importing numba so
# obvious config errors fail fast even without a CUDA runtime) ---------
explicit = grid is not None or block is not None
if explicit and launch_dims is not None:
raise ValueError(
"Specify either (grid, block) or launch_dims for the kernel launch "
"configuration, not both."
)
if explicit and (grid is None or block is None):
raise ValueError(
"grid and block must be specified together; got "
f"grid={grid!r}, block={block!r}."
)
if not explicit and launch_dims is None:
raise ValueError(
"Either (grid, block) or launch_dims must be specified for kernel "
"launch configuration."
)
if explicit and vmap_method is not None:
raise ValueError(
"Explicit grid/block kernels cannot be vmapped: a fixed grid cannot "
"be adapted per batch slice. Use launch_dims together with "
"vmap_method for batched execution."
)
# Build the launch policy (F5): store the *policy*, not a frozen grid/block.
launch_mode: tuple
if grid is not None and block is not None:
grid_t = (grid,) if isinstance(grid, int) else tuple(int(g) for g in grid)
block_t = (block,) if isinstance(block, int) else tuple(int(b) for b in block)
launch_mode = ('explicit', grid_t, block_t)
else:
# The only remaining case after the validation above; the guard keeps
# the type-checker happy and is defensive (survives ``python -O``).
if launch_dims is None: # pragma: no cover - unreachable after validation
raise ValueError(
"Either (grid, block) or launch_dims must be specified for kernel "
"launch configuration."
)
dims_t = (launch_dims,) if isinstance(launch_dims, int) else tuple(int(d) for d in launch_dims)
# Validate the launch dims eagerly so bad dims raise at wrap time; the
# callback recomputes the same (unbatched) config per launch.
_compute_launch_config(dims_t, threads_per_block)
launch_mode = ('launch_dims', dims_t, int(threads_per_block))
# Output information
out_info, out_treedef = abstract_arguments(outs)
output_shapes, output_dtypes = _normalize_shapes_and_dtypes(
tuple(out.shape for out in out_info),
tuple(out.dtype for out in out_info),
'output',
)
# Reject bfloat16 outputs explicitly: numba CUDA cannot launch bf16 kernels,
# so make the rejection intentional rather than an accidental numba failure
# deep in the launch (F19).
for dt in output_dtypes:
if np.dtype(dt).name == 'bfloat16':
raise ValueError(
"numba CUDA cannot launch bfloat16 (bf16) kernels; output dtype "
"bfloat16 is not supported. Cast the output to float16 or float32."
)
import_numba_cuda()
from numba.cuda.dispatcher import CUDADispatcher
# Validate kernel type. Use an explicit ``raise`` rather than ``assert`` so
# the check survives ``python -O`` (which strips assertions) (L14).
if not isinstance(kernel, CUDADispatcher):
raise TypeError(
f'The kernel must be a Numba CUDA JIT-compiled function (from @cuda.jit), '
f'but got {type(kernel).__name__}.'
)
# Pin row-major layouts so XLA hands the handler C-contiguous device
# buffers; the callback wraps them by ``dims`` only and cannot recover a
# non-default layout from ``XLA_FFI_Buffer`` (M4). ``ffi_call`` takes
# layouts major-to-minor, so row-major is ``range(ndim)``.
output_layouts = tuple(tuple(range(len(out.shape))) for out in out_info)
def call(*ins):
"""Invoke the registered Numba CUDA kernel through XLA FFI.
Parameters
----------
*ins : jax.Array
Input arrays on GPU device.
Returns
-------
result
Output array(s) matching the ``outs`` specification.
"""
# Input information
in_info, _ = abstract_arguments(ins)
input_shapes, input_dtypes = _normalize_shapes_and_dtypes(
tuple(inp.shape for inp in in_info),
tuple(inp.dtype for inp in in_info),
'input',
)
# Reject 0-d (scalar) inputs at trace time with a clear error, mirroring
# numba_cuda_callable: numba CUDA cannot build device arrays from 0-d
# buffers, and the run-time failure would otherwise be an opaque INTERNAL
# FFI error (F19).
for i, shape in enumerate(input_shapes):
if len(shape) == 0:
raise ValueError(
f"numba_cuda_kernel does not support 0-d (scalar) array inputs, "
f"but input {i} has shape (). Wrap scalars in a 1-d array, "
f"e.g. jnp.array([value])."
)
# Reject bfloat16 inputs explicitly (numba CUDA cannot launch bf16) (F19).
for i, dt in enumerate(input_dtypes):
if np.dtype(dt).name == 'bfloat16':
raise ValueError(
f"numba CUDA cannot launch bfloat16 (bf16) kernels; input {i} "
f"has dtype bfloat16, which is not supported. Cast the input to "
f"float16 or float32."
)
input_layouts = tuple(tuple(range(len(shape))) for shape in input_shapes)
# Register FFI target
target_name, out_types = _register_numba_cuda_ffi_target(
kernel,
input_dtypes,
output_shapes,
output_dtypes,
launch_mode,
shared_mem,
)
# Call FFI with typed FFI protocol
result = jax.ffi.ffi_call(
target_name,
out_types,
input_output_aliases=input_output_aliases,
vmap_method=vmap_method,
input_layouts=list(input_layouts),
output_layouts=list(output_layouts),
)(*ins)
return jax.tree.unflatten(out_treedef, result)
return call
# ===========================================================================
# numba_cuda_callable: Multi-kernel callable wrapper
# ===========================================================================
_NUMBA_CUDA_CALLABLE_HANDLES: Dict[str, object] = {}
# Maps a func/io-count/shape/dtype signature to an already-registered target so
# repeated eager calls reuse one registration instead of leaking per call (H1).
_NUMBA_CUDA_CALLABLE_TARGETS: Dict[tuple, str] = {}
# Content-derived name -> fingerprint map (F14), see the kernel path for details.
_NUMBA_CUDA_CALLABLE_NAME_FINGERPRINTS: Dict[str, Optional[str]] = {}
# Pins callables memoized via the fingerprint-reuse path (see
# ``_NUMBA_CUDA_FFI_KERNEL_PINS`` for the id-recycling hazard this prevents).
_NUMBA_CUDA_CALLABLE_PINS: Dict[int, object] = {}
_CUDA_CALLABLE_CALLBACK_COUNTER = 0
# The typed FFI callback signature: void* fn(XLA_FFI_CallFrame*)
_CUDA_CALLABLE_CALLBACK_TYPE = CFUNCTYPE(c_void_p, POINTER(XLA_FFI_CallFrame))
class NumbaCudaCallableHandler:
"""Typed FFI handler for arbitrary Python callables that launch Numba CUDA kernels.
Unlike :class:`NumbaCudaFfiHandler` (which wraps a **single**
``@cuda.jit`` kernel with a fixed grid/block), this handler invokes
a plain Python function and passes it Numba device arrays together
with a Numba CUDA stream so the function can launch an arbitrary
number of kernels, allocate temporary device memory, and perform
multi-step GPU computations.
Parameters
----------
name : str
Unique FFI target name for registration with
``jax.ffi.register_ffi_target``.
func : callable
The Python function to invoke. Its signature must be
``func(in1, in2, ..., out1, out2, ..., stream)`` where each
``in*`` and ``out*`` is a Numba CUDA device array and ``stream``
is a Numba CUDA stream.
num_inputs : int
Number of input buffers expected.
num_outputs : int
Number of output buffers expected.
input_dtypes : tuple of numpy.dtype
Expected data types of the input buffers.
output_shapes : tuple of tuple of int
Expected shapes of the output buffers.
output_dtypes : tuple of numpy.dtype
Expected data types of the output buffers.
See Also
--------
numba_cuda_callable : High-level API for creating a JAX-callable
from an arbitrary Python function.
NumbaCudaFfiHandler : Handler for a single Numba CUDA kernel.
Notes
-----
The handler object must be kept alive (stored in a module-level
dictionary) to prevent garbage collection of the ctypes callback.
"""
def __init__(
self,
name: str,
func: Callable,
num_inputs: int,
num_outputs: int,
input_dtypes: Tuple[np.dtype, ...],
output_shapes: Tuple[Tuple[int, ...], ...],
output_dtypes: Tuple[np.dtype, ...],
):
self.name = name
self.func = func
self.num_inputs = num_inputs
self.num_outputs = num_outputs
self.input_dtypes = input_dtypes
self.output_shapes = output_shapes
self.output_dtypes = output_dtypes
# Create the ctypes callback -- must be kept alive to prevent GC
self._callback = _CUDA_CALLABLE_CALLBACK_TYPE(self._ffi_callback)
# Register as an FFI target for CUDA platform
_warn_if_untested_jax()
capsule = jax.ffi.pycapsule(ctypes.cast(self._callback, c_void_p).value)
jax.ffi.register_ffi_target(name, capsule, platform="CUDA")
# Self-pin (F7): XLA now holds a raw function pointer into
# ``self._callback``; ``self`` must never be collected while the
# registration is live, even for direct construction that bypasses
# the module-level factory. Re-pinning the same name is idempotent.
_NUMBA_CUDA_CALLABLE_HANDLES[name] = self
def _ffi_callback(self, call_frame_ptr):
"""Typed FFI callback invoked by XLA during execution.
Extracts input and output device arrays and the CUDA stream
from the call frame, then calls the user-provided Python
function. Also handles XLA metadata extension queries.
Parameters
----------
call_frame_ptr : ctypes.POINTER(XLA_FFI_CallFrame)
Pointer to the XLA FFI call frame.
Returns
-------
None or int
``None`` (XLA OkStatus) on success, or an ``XLA_FFI_Error*``
pointer (as an integer) when the user function raised, so the
failure surfaces to the JAX caller instead of being reported as
success (C1).
"""
try:
call_frame = call_frame_ptr.contents
# Handle metadata extension query (API version / traits). Walk the
# whole extension chain via ``ext.next`` (a future jaxlib may prepend
# other nodes before the metadata node) (F19).
ext_ptr = call_frame.extension_start
while ext_ptr:
ext = ext_ptr.contents
if ext.type == int(XLA_FFI_Extension_Type.Metadata):
metadata_ext = ctypes.cast(
ext_ptr, POINTER(XLA_FFI_Metadata_Extension)
).contents
metadata = metadata_ext.metadata.contents
metadata.api_version.major_version = XLA_FFI_API_MAJOR
metadata.api_version.minor_version = XLA_FFI_API_MINOR
metadata.traits = 0 # not command-buffer-compatible
return None # success
ext_ptr = ext.next
api_ptr = call_frame.api
ctx = call_frame.ctx
# Bind the GPU XLA placed this call on before building any device
# array or stream, so they reference the correct device (C3).
ordinal = get_device_ordinal(api_ptr, ctx)
with _device_context(ordinal):
# Extract input buffers. ``resolve_buffer_dtype`` raises on a
# known-but-unsupported dtype rather than silently mis-decoding
# it, and uses the abstract fallback for an unknown code (C2).
n_inputs = call_frame.args.size
input_arrays = []
for i in range(n_inputs):
buf_ptr = ctypes.cast(
call_frame.args.args[i], POINTER(XLA_FFI_Buffer)
).contents
shape = tuple(buf_ptr.dims[d] for d in range(buf_ptr.rank))
fallback = self.input_dtypes[i] if i < len(self.input_dtypes) else np.dtype(np.float32)
dtype = resolve_buffer_dtype(buf_ptr.dtype, fallback)
input_arrays.append(_device_array_from_buffer(buf_ptr.data, shape, dtype))
# Extract output buffers as Numba CUDA device arrays
n_outputs = call_frame.rets.size
output_arrays = []
for i in range(n_outputs):
buf_ptr = ctypes.cast(
call_frame.rets.rets[i], POINTER(XLA_FFI_Buffer)
).contents
shape = tuple(buf_ptr.dims[d] for d in range(buf_ptr.rank))
fallback = self.output_dtypes[i] if i < len(self.output_dtypes) else np.dtype(np.float32)
dtype = resolve_buffer_dtype(buf_ptr.dtype, fallback)
output_arrays.append(_device_array_from_buffer(buf_ptr.data, shape, dtype))
# Extract XLA's CUDA stream (checked) and create Numba wrapper.
stream_ptr = get_xla_stream(api_ptr, ctx)
stream = _numba_stream_from_ptr(stream_ptr)
# Call the user function
# Signature: func(in1, in2, ..., out1, out2, ..., stream)
self.func(*input_arrays, *output_arrays, stream)
except Exception as exc: # noqa: BLE001 - surfaced to XLA as an FFI error
traceback.print_exc()
try:
err_api_ptr = call_frame_ptr.contents.api
except Exception:
err_api_ptr = None
return make_ffi_error(
err_api_ptr,
XLA_FFI_Error_Code.INTERNAL,
f'Numba CUDA callable {self.name!r} raised '
f'{type(exc).__name__}: {exc}',
)
return None # success
def _register_numba_cuda_callable_target(
func: Callable,
num_inputs: int,
num_outputs: int,
input_dtypes: Tuple[np.dtype, ...],
output_shapes: Tuple[Tuple[int, ...], ...],
output_dtypes: Tuple[np.dtype, ...],
):
"""Register a Python callable as an XLA typed FFI target for CUDA.
Creates a :class:`NumbaCudaCallableHandler` and registers it with
``jax.ffi.register_ffi_target``. The handler is stored in a
module-level dictionary to prevent garbage collection.
Parameters
----------
func : callable
The Python function to wrap. Its signature must be
``func(in1, ..., out1, ..., stream)``.
num_inputs : int
Number of input buffers.
num_outputs : int
Number of output buffers.
input_dtypes : tuple of numpy.dtype
Data types of the input buffers.
output_shapes : tuple of tuple of int
Shapes of the output buffers.
output_dtypes : tuple of numpy.dtype
Data types of the output buffers.
Returns
-------
target_name : str
The unique FFI target name assigned to this callable.
out_types : tuple of jax.ShapeDtypeStruct
Output type specifications for use with ``jax.ffi.ffi_call``.
Raises
------
ImportError
If Numba with CUDA support is not available.
See Also
--------
NumbaCudaCallableHandler : The handler class created by this
function.
numba_cuda_callable : High-level user-facing API.
"""
global _CUDA_CALLABLE_CALLBACK_COUNTER
import_numba_cuda()
out_types = tuple(
jax.ShapeDtypeStruct(shape, dtype)
for shape, dtype in zip(output_shapes, output_dtypes)
)
# Reuse an existing registration for an identical func/signature so repeated
# eager calls do not each leak a handler and ctypes callback (H1). The
# cached handler keeps *func* alive, so ``id(func)`` cannot be recycled. A
# content-derived name (F14) additionally lets two freshly redefined but
# byte-identical callables share the one registration.
discriminators = (num_inputs, num_outputs, input_dtypes, output_shapes, output_dtypes)
signature = (id(func),) + discriminators
with _CUDA_REGISTRATION_LOCK:
cached_name = _NUMBA_CUDA_CALLABLE_TARGETS.get(signature)
if cached_name is not None:
return cached_name, out_types
fingerprint = kernel_content_fingerprint(func, extra=discriminators)
if fingerprint is not None:
target_name = f'brainevent_numba_cuda_callable_{fingerprint}'
existing_fingerprint = _NUMBA_CUDA_CALLABLE_NAME_FINGERPRINTS.get(target_name)
if existing_fingerprint is not None:
if existing_fingerprint == fingerprint:
# Pin *this* func: the shared handler keeps only the FIRST
# func alive, and the memo entry is keyed on this one's id.
_NUMBA_CUDA_CALLABLE_PINS[id(func)] = func
_NUMBA_CUDA_CALLABLE_TARGETS[signature] = target_name
return target_name, out_types
raise KernelRegistrationError(
f'FFI target name {target_name!r} is already registered for a callable '
f'with a different content fingerprint ({existing_fingerprint!r} != '
f'{fingerprint!r}). This is a sha256 collision between two distinct '
f'callable contents and should be astronomically unlikely; if it '
f'happens, please report it at '
f'https://github.com/chaobrain/brainevent/issues.'
)
else:
# Unserializable closure/global: per-process counter fallback for
# this callable only (loses cross-process name stability).
target_name = f'brainevent_numba_cuda_callable_{_CUDA_CALLABLE_CALLBACK_COUNTER}'
_CUDA_CALLABLE_CALLBACK_COUNTER += 1
handler = NumbaCudaCallableHandler(
name=target_name,
func=func,
num_inputs=num_inputs,
num_outputs=num_outputs,
input_dtypes=input_dtypes,
output_shapes=output_shapes,
output_dtypes=output_dtypes,
)
# Keep the handler alive to prevent GC of the ctypes callback
_NUMBA_CUDA_CALLABLE_HANDLES[target_name] = handler
_NUMBA_CUDA_CALLABLE_NAME_FINGERPRINTS[target_name] = fingerprint
_NUMBA_CUDA_CALLABLE_TARGETS[signature] = target_name
return target_name, out_types
[docs]
def numba_cuda_callable(
func: Callable,
outs: OutType,
*,
vmap_method: str | None = None,
input_output_aliases: dict[int, int] | None = None,
) -> Callable:
"""Create a JAX-callable from a Python function that launches Numba CUDA kernels.
Unlike :func:`numba_cuda_kernel` (which wraps a single
``@cuda.jit`` kernel), this function wraps an **arbitrary** Python
callable. The callable receives Numba CUDA device arrays for inputs
and outputs, plus a Numba CUDA stream, and may launch any number of
kernels, allocate temporary device memory, or perform multi-step GPU
computations.
The wrapped function must have the signature::
func(input_1, input_2, ..., output_1, output_2, ..., stream)
where every ``input_*`` and ``output_*`` is a Numba CUDA device
array and ``stream`` is a Numba CUDA stream obtained from XLA.
Parameters
----------
func : callable
A Python function with the signature described above.
outs : OutType
Output specification. A single ``jax.ShapeDtypeStruct`` or a
sequence/pytree of them for multiple outputs.
vmap_method : str or None, optional
How to handle ``jax.vmap``. Passed directly to
``jax.ffi.ffi_call``.
input_output_aliases : dict of int to int or None, optional
Mapping from input index to output index for in-place
operations. Passed directly to ``jax.ffi.ffi_call``.
Returns
-------
callable
A function that takes JAX arrays as inputs and returns JAX
arrays as outputs. The function can be used inside
``jax.jit``-compiled code.
Raises
------
ImportError
If Numba with CUDA support is not available.
TypeError
If *func* is not callable.
ValueError
If any input array is a 0-d (scalar) array, which is not
supported by Numba CUDA device arrays.
See Also
--------
numba_cuda_kernel : Wrap a single ``@cuda.jit`` kernel with fixed
grid/block configuration.
XLACustomKernel.def_numba_cuda_kernel : Register a Numba CUDA
kernel with an ``XLACustomKernel``.
Notes
-----
Registrations are memoised by ``(func, io-counts, shapes, dtypes)``:
repeated calls with an identical signature reuse a single FFI target
instead of leaking one handler per call (H1).
Scalar (0-d) inputs are not supported because Numba CUDA cannot
create device arrays from 0-d buffers. Wrap scalar values in 1-d
arrays (e.g., ``jnp.array([value])``) before passing them.
Examples
--------
.. code-block:: python
>>> from numba import cuda
>>> import jax
>>> import jax.numpy as jnp
>>>
>>> @cuda.jit
... def add_kernel(x, y, temp, n):
... i = cuda.grid(1)
... if i < n:
... temp[i] = x[i] + y[i]
>>>
>>> @cuda.jit
... def scale_kernel(temp, out, scale, n):
... i = cuda.grid(1)
... if i < n:
... out[i] = temp[i] * scale
>>>
>>> def my_op(x, y, out, stream):
... n = x.shape[0]
... temp = cuda.device_array(n, dtype=x.dtype)
... threads = 256
... blocks = (n + threads - 1) // threads
... add_kernel[blocks, threads, stream](x, y, temp, n)
... scale_kernel[blocks, threads, stream](temp, out, 2.0, n)
.. warning::
A ``temp`` array allocated with ``cuda.device_array(...)`` inside the
callable is managed by numba: it is *deallocated when the Python object
is garbage-collected*, which numba enqueues on numba's own default
stream, not on the XLA ``stream`` the kernels run on. Keep every
reference to such temporaries alive until all kernels that use them have
been enqueued (as above, ``temp`` stays in scope for the whole
function), and do not rely on the deallocation being ordered against the
XLA stream. For large or performance-critical temporaries, prefer
allocating them on ``stream`` and holding the reference for the callable's
lifetime.
>>>
>>> fn = numba_cuda_callable(
... my_op,
... outs=jax.ShapeDtypeStruct((1024,), jnp.float32),
... )
>>>
>>> @jax.jit
... def f(a, b):
... return fn(a, b)
"""
import_numba_cuda()
if not callable(func):
raise TypeError(
f'func must be callable, but got {type(func).__name__}.'
)
# Output information
out_info, out_treedef = abstract_arguments(outs)
output_shapes, output_dtypes = _normalize_shapes_and_dtypes(
tuple(out.shape for out in out_info),
tuple(out.dtype for out in out_info),
'output',
)
num_outputs = len(out_info)
# Pin row-major layouts so XLA hands C-contiguous device buffers (M4).
output_layouts = tuple(tuple(range(len(out.shape))) for out in out_info)
def call(*inputs):
"""Invoke the registered callable through XLA FFI.
Parameters
----------
*inputs : jax.Array
Input arrays on GPU device.
Returns
-------
result
Output array(s) matching the ``outs`` specification.
"""
# ``asarray`` (not ``array``) normalises dtype/container without forcing
# a copy, preserving any ``input_output_aliases`` donation (F13).
inputs = jax.tree.map(jax.numpy.asarray, inputs)
# Reject scalar (0-d) inputs — Numba CUDA kernels cannot operate on 0-d device arrays
for i, inp in enumerate(jax.tree.leaves(inputs)):
if jax.numpy.ndim(inp) == 0:
raise ValueError(
f"numba_cuda_callable does not support 0-d (scalar) array inputs, "
f"but input {i} has shape (). "
f"Wrap scalars in a 1-d array, e.g. jnp.array([value])."
)
# -- collect input metadata --------------------------------------------
in_info, _ = abstract_arguments(inputs)
input_dtypes = tuple(np.dtype(inp.dtype) for inp in in_info)
input_layouts = tuple(tuple(range(len(inp.shape))) for inp in in_info)
# -- register the FFI target -------------------------------------------
target_name, out_types = _register_numba_cuda_callable_target(
func,
num_inputs=len(inputs),
num_outputs=num_outputs,
input_dtypes=input_dtypes,
output_shapes=output_shapes,
output_dtypes=output_dtypes,
)
# -- invoke via jax.ffi.ffi_call ---------------------------------------
result = jax.ffi.ffi_call(
target_name,
out_types,
input_output_aliases=input_output_aliases,
vmap_method=vmap_method,
input_layouts=list(input_layouts),
output_layouts=list(output_layouts),
)(*inputs)
return jax.tree.unflatten(out_treedef, result)
return call