Kernels of real simulations take many arguments: the marker array, its shape, the grid spacing, spline degrees, knot vectors, a dozen parameters. Listing them at every call site is error-prone, and every new field means editing every kernel signature. CuNumpy groups them for CUDA kernels:
| Tool | CUDA kernel sees | Use when |
|---|---|---|
CudaArguments |
several parameters, flattened | grouping a few device arrays and scalars |
CudaStruct |
one C struct parameter | many fields; one definition shared by all CUDA kernels |
CudaStructArguments |
one C struct parameter | the struct as a class: an object with attributes, built once and reused |
CuNumpy never converts an argument object between a host form and a CUDA form. A host kernel gets its own argument object (e.g. a pyccel class of NumPy arrays), a CUDA kernel gets a CUDA one, and the code that owns the arrays decides which one to build; see Host and CUDA argument classes.
Any object with a __cuda_args__() method returning a tuple is replaced by
that tuple when passed to a CudaKernel. Subclassing CudaArguments gives you
the method for free:
import numpy as np
import cunumpy as xp
class DeviceParticles(xp.arguments.CudaArguments):
def __init__(self, positions, velocities):
self.positions = xp.as_device_array(
positions, np.float64, ndim=2, name="positions"
)
self.velocities = xp.as_device_array(
velocities, np.float64, ndim=2, name="velocities"
)
super().__init__(self.positions, self.velocities, self.positions.shape[0])
PUSH = r"""
extern "C" __global__
void push(double dt, double* x, const double* v, int n) { ... }
"""
push = xp.kernels.CudaKernel(PUSH, "push")
particles = DeviceParticles(x, v)
push(0.1, particles, n_threads=particles.positions.shape[0]) # -> push(0.1, x, v, n)Build the object once and reuse it. as_device_array() references existing
device arrays of the right dtype and copies everything else exactly once (see
Data movement, section "Build device arguments once").
A CudaStruct defines a C struct in Python: its C declaration for the kernel
source, its exact memory layout, and a packer for values. The kernel takes the
struct by value as one parameter:
Particles = xp.arguments.CudaStruct(
"Particles",
[("x", "double*"), ("v", "double*"), ("n", "long long"), ("charge", "double")],
)
PUSH = (
Particles.declaration
+ r"""
extern "C" __global__ void push(Particles p, double dt) {
long long i = blockDim.x * (long long)blockIdx.x + threadIdx.x;
if (i < p.n) p.x[i] += dt * p.charge * p.v[i];
}
"""
)
push = xp.kernels.CudaKernel(PUSH, "push", structs=[Particles])
value = Particles(x=x, v=v, n=x.size, charge=-1.0)
push(value, 0.1, n_threads=x.size)Fields may be scalars, pointers to scalar types (or void*), and array views
Array1D<T> to Array4D<T>. Packing checks every field like a kernel
argument: pointers need C-contiguous CuPy arrays of the declared dtype, scalars
are range-checked and cast. Adding a field means editing the one Python
definition; kernels that use the struct pick it up.
The packed value (CudaStructValue) holds only device addresses. It keeps
references to the arrays so they stay alive, but if you replace an array (for
example particles.x = new_array), pack a new value. value["charge"] reads a
field back; Particles.dtype is the NumPy structured dtype of the layout.
CudaKernel(..., structs=[Particles]) also checks that a struct definition in
the kernel source matches the Python definition, so a hand-edited header that
drifted raises a ValueError instead of reading fields at wrong offsets.
When the device arguments are an object of their own (built once per particle
species, domain or grid, and kept next to the host argument object), subclass
CudaStructArguments. The class declares the struct, the instance holds the
field values as attributes and is passed to kernels as it is:
class CudaMarkerArguments(xp.arguments.CudaStructArguments):
struct_name = "MarkerArgs"
fields = (
("markers", "double*"),
("valid_mks", "bool*"),
("n_markers", "int"),
("n_cols", "int"),
("weight_idx", "int"),
)
def __init__(self, markers, valid_mks, weight_idx):
self.markers = xp.as_device_array(markers, np.float64, ndim=2, name="markers")
self.valid_mks = xp.as_device_array(
valid_mks, np.bool_, ndim=1, name="valid_mks"
)
self.n_markers, self.n_cols = self.markers.shape
self.weight_idx = weight_idx
self.pack()
xp.arguments.write_cuda_header("kernels/marker_args.cuh", [CudaMarkerArguments.struct])
push = xp.kernels.CudaKernel.from_file(
"kernels/push_cuda.cu", structs=[CudaMarkerArguments.struct]
)
args = CudaMarkerArguments(markers, valid_mks, weight_idx=6)
push(args, dt, n_threads=args.n_markers)-
The
CudaStructis built when the class is defined (CudaMarkerArguments.struct), so a bad field type raises at import, not at the first launch. -
pack()checks every field likeCudaStructdoes. -
The struct is packed again automatically, at the next launch, when a field attribute changed: another array (address, shape or strides) or another scalar value. Make the fields properties when the arrays belong to another object that replaces them, e.g. a particle container that resizes its marker array:
class CudaMarkerArguments(xp.arguments.CudaStructArguments): struct_name = "MarkerArgs" fields = (("markers", "Array2D<double>"), ("n_markers", "int")) def __init__(self, particles): self._particles = particles self.pack() @property def markers(self): return self._particles.markers @property def n_markers(self): return self._particles.markers.shape[0]
Without the properties, the object keeps the old array alive and kernels keep working on it: assign the new array to the attribute instead.
-
Copies and unpickled objects are packed again from their own arrays, so a
deepcopynever points at the device memory of the original. -
For the host kernels, write the matching host class and let the owner choose, see Host and CUDA argument classes.
Values are packed with the NumPy dtype of the struct. If the compiler lays the
struct out differently (a hand-edited header, a #pragma pack, another
compiler such as hiprtc), kernels read fields at the wrong offsets without any
error. One GPU test per struct catches it:
def test_marker_args_layout():
CudaMarkerArguments.struct.verify_layout() # the Python declaration
CudaMarkerArguments.struct.verify_layout( # the committed header
"marker_args.cuh", include_dirs=["kernels"]
)If the host kernels already use an annotated argument class (Pyccel style), the struct can be derived from it, making the Python class the single definition of the arguments on host and device:
class MarkerArguments:
def __init__(self, markers: "float[:, :]", n_markers: int, valid: "bool[:]"): ...
MarkerArgs = xp.arguments.CudaStruct.from_signature(MarkerArguments.__init__, "MarkerArgs")
print(MarkerArgs.declaration)struct MarkerArgs {
Array2D<double> markers;
long long n_markers;
Array1D<bool> valid;
};Mappings: float to double, int to long long (Pyccel integers are
64-bit; change with int_type=), bool to bool, NumPy scalar types to their
C types, "float[:, :]" to Array2D<double> (1 to 3 dimensions). Final[...]
and const are ignored. scalar_names={"float": "float"} switches to single
precision. Parameters without a mappable annotation raise ValueError.
If the module of the argument class is compiled by pyccel, importing it gives
the compiled class, whose __init__ has no Python signature.
CudaStruct.from_pyccel_class(path, class_name, name) parses the .py source
with ast instead (never importing it), names each field after the attribute
the parameter is stored in (self.first_init_idx = first_pusher_idx gives a
field first_init_idx), and skips the parameters in exclude=:
MarkerArgs = xp.arguments.CudaStruct.from_pyccel_class(
"my_sim/kernel_arguments/pusher_args_kernels.py", "MarkerArguments", "MarkerArgs"
)Array view fields accept non-contiguous arrays, and the kernel indexes them like the host kernel does:
#include "marker_args.cuh"
#include <cunumpy/index.cuh>
extern "C" __global__ void push(MarkerArgs m, double dt) {
CUNUMPY_THREAD_1D(ip, m.n_markers);
if (m.valid(ip)) m.markers(ip, 0) += dt * m.markers(ip, 3);
}Array2D<T> takes any view, so its strides are only known at run time and a
kernel cannot tell which index is the fast one. When an array is always
C-contiguous (a marker array, a grid), declare it as CArray1D<T> to
CArray4D<T> instead. The view holds a pointer and the shape, no strides, and
m.markers(ip, 0) is data[ip * shape[1] + 0]: the last index is always the
fast one, as in the row-major memory the host code uses.
MarkerArgs = xp.arguments.CudaStruct.from_pyccel_class(
"my_sim/kernel_arguments/pusher_args_kernels.py",
"MarkerArguments",
"MarkerArgs",
contiguous=["markers"], # or True for every array field
)struct MarkerArgs {
CArray2D<double> markers;
long long n_markers;
Array1D<bool> valid;
};- A non-contiguous array (e.g.
markers[:, 0:3]) raisesTypeErrorat packing or launch. It is not copied withascontiguousarray, since the kernel would write into the copy and the result would be lost. UseArray2D<T>for arguments that are sometimes views. - A
CArrayND<T>converts to anArrayND<T>, so device helper functions written for strided views also take it. contiguous=works the same inCudaStruct.from_signature.
Kernels in .cu files include the struct from a header. Generate it from the
Python definition and commit it:
xp.arguments.write_cuda_header("kernels/marker_args.cuh", [MarkerArgs, DomainArgs])The header gets an include guard (MARKER_ARGS_CUH), the array_view.cuh
include when needed, and the definitions. MarkerArgs.to_header(path) does the
same for one struct. A test keeps the committed file in sync:
from pathlib import Path
def test_marker_args_header_is_up_to_date(tmp_path):
generated = xp.arguments.write_cuda_header(
tmp_path / "marker_args.cuh", [MarkerArgs, DomainArgs]
)
assert Path("kernels/marker_args.cuh").read_text() == generatedA host kernel compiled with Pyccel often receives a group of arrays as an
instance of an argument class. Write its CUDA counterpart as a
CudaStructArguments subclass with the same constructor and attributes, and
let the owner of the arrays create the one that matches the backend:
from my_sim.kernel_arguments.pusher_args_kernels import MarkerArguments # pyccel
class CudaMarkerArguments(xp.arguments.CudaStructArguments):
"""CUDA version of MarkerArguments: same constructor, same attributes."""
struct_name = "MarkerArgs"
fields = (("markers", "Array2D<double>"), ("n_markers", "int"))
def __init__(self, markers, n_markers):
self.markers = markers # a CuPy array
self.n_markers = n_markers
self.pack()
class Particles:
def __init__(self, markers):
self.markers = markers
args_class = CudaMarkerArguments if xp.is_gpu(markers) else MarkerArguments
self.args_markers = args_class(markers, markers.shape[0])
push = xp.kernels.Kernel.from_folder("my_sim.kernels.push")
push(particles.args_markers, dt) # the pyccel object on NumPy, the struct on CuPyKernelandPyccelKernelpass the host object to the host kernel as it is;CudaKernelflattens a CUDA argument object with__cuda_args__().- Pass the CUDA object to the host kernel, or the pyccel object to the CUDA kernel, and the kernel's own argument checks raise. Nothing is copied behind your back.
- A test can check that the two classes of a pair stay in sync, by comparing
CudaStruct.from_pyccel_class(...)(the pyccel constructor) withCudaMarkerArguments.struct.fields.
- Start with plain arguments. Group when the same set of five or more values appears in several kernels.
- Use a
CudaStructwhen many CUDA kernels take the same group, or when signatures become too long to read and keep in sync. - When the host kernels take an argument class, write a
CudaStructArgumentswith the same constructor and attributes, and build one of the two per backend. - Generate the struct with
from_signatureorfrom_pyccel_classwhen a host argument class exists, or test that the two field lists match, so the two cannot drift apart.