Skip to content

Cuda pr 6 particles on gpu - #651

Merged
max-models merged 61 commits into
develfrom
cuda-pr-6-particles-on-gpu
Oct 2, 2026
Merged

max-models merged 61 commits into
develfrom
cuda-pr-6-particles-on-gpu

Conversation

@max-models

@max-models max-models commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

Particles can now be initialized with the CuPy backend. The new cuda_args_markers property lazily builds and caches CudaMarkerArguments from the particle’s device arrays, validity mask, indices, and boundary-condition codes. Existing args_markers remains available for Pyccel calls.

The initialization path now converts Python process-count lists to cunumpy arrays before reductions. Scalar MPI gathers use small NumPy buffers, then copy the results to the active backend.

GPU particle creation and CUDA argument construction still need a GPU smoke test.

max-models and others added 30 commits September 30, 2026 15:04
The SPH linear smoothing kernels returned a non-zero gradient at zero
separation, so every particle pushed on itself. This PR makes all SPH
kernel gradients vanish at r=0, which matches the symmetric value the
trigonometric and gaussian kernels already give.

Closes #437

### What was wrong
- `grad_linear_uni(0, h)` returned `+1/h**2`, the left derivative. That
value carries into `grad_linear_1d`, `grad_linear_2d_{1,2}` and
`grad_linear_3d_{1,2,3}`, and it applies whenever the component's own
coordinate is 0, not only at the origin.
- `grad_linear_isotropic_3d_{1,2,3}` returned `-1/h / (C h^3)` at `r ==
0`.
- As a result the SPH gradient of a lattice-loaded constant density was
not zero: it came out at about 3.5 for 1d linear, 3.8 for 3d tensor
linear and -0.58 for isotropic in the check below.

### What changed
- `src/struphy/pic/sph_smoothing_kernels.py`: at the cusp the gradient
now returns 0 (`x == 0` in `grad_linear_uni`, `r == 0` in the isotropic
variants). Values away from 0 are unchanged. The trigonometric and
gaussian gradients were checked too and already give 0 there.
- `src/struphy/pic/tests/test_kernel_setup.py`:
- New test `test_sph_kernel_gradients_vanish_at_zero` covers every
gradient type in `smoothing_kernel`, at the origin and on the coordinate
planes.
- `test_sph_tensor_destinations` relied on the old self-gradient
`1/h**2`. It now uses a second neighbouring particle so the
viscosity-tensor destinations still get non-trivial values.

### Verification
- A standalone check calls all 21 gradient kernel types at the origin
and on the coordinate planes. Before the fix, 9 types were non-zero at
the origin and all 4 plane cases were non-zero. After the fix all of
them are 0. At random points away from 0 the values are bit-identical
before and after.
- The gradient of a lattice-loaded constant density is now ~1e-16 in 1d
linear, 3d tensor linear and 3d isotropic.
- `pytest src/struphy/pic/tests/test_kernel_setup.py`: 74 passed. The
new test fails without the fix.
- A subset of `test_sph.py::test_sph_evaluation_1d` (linear_1d,
periodic) passes.

### Risks / follow-ups
- SPH results that use the linear kernels change slightly, because the
self-contribution is gone. That removal is the intended correction.
- #436 (viscosity strain without DF^-T) is in the same area and is not
addressed here.
- This branch includes the commit bumping `feectools` to 0.1.11 so that
CI can run.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
CudaKernel no longer flattens argument objects or casts scalars at call
time: the arguments must already be in the format of a cupy.RawKernel
(flat tuple of CuPy arrays and NumPy scalars, built once at setup), and
the number of threads is passed as n_threads. Add docstrings to the
kernel and argument classes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Converting the scalars and joining the .values of the CUDA argument
classes costs about 1 us per call (the kernel launch alone about 70 us),
prevents silently misaligned arguments when a Python int is passed as a
64-bit value, and keeps the calls the same on both backends. Arrays are
still never converted or copied; n_threads stays an explicit argument.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sses

CUDA reads each kernel argument with the size declared in the signature,
so Python int/float already arrive correctly in int/double parameters;
the casts did not change the result, and did not prevent the actual
failure cases (e.g. an integer passed to a double parameter). Checking
scalars against the kernel signature is noted as a follow-up in
CUDA_STRATEGY.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add CudaKernel.from_file, which reads the CUDA source from a
<name>_cuda.cu file and takes the kernel name from the file name, and
ship .cu/.cuh files as package data. Step 2 of CUDA_STRATEGY.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
KernelCatalog.from_package pairs <name>/<name>_kernels.py (pyccel) with
<name>/<name>_cuda.cu (CUDA, optional). The CUDA kernel of a Kernel is
now optional: on the CuPy backend, a kernel without a CUDA version
raises NotImplementedError naming the expected .cu file.
Step 3 of CUDA_STRATEGY.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pusher now takes a Kernel or, as before, a PyccelKernel (wrapped into a
Kernel without CUDA version) and calls get_kernel() in its constructor.
On the CuPy backend, a pusher whose kernel has no CUDA version thus
fails when it is created instead of in the time loop. No changes to the
propagators, no behaviour change on the CPU.
Step 4 of CUDA_STRATEGY.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Test only analytic mappings on the CuPy backend: spline mappings such
  as IGAPolarCylinder cannot be created there yet (interp_mapping passes
  CuPy arrays to scipy.sparse.csc_matrix); correct CUDA_STRATEGY.md.
- Build the CUDA domain arguments with cupy instead of xp, so they can be
  built whichever backend is active (the arrays are on the device);
  test this.
- Move the new tests above the __main__ block of test_domain.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@max-models
max-models changed the base branch from devel to cuda-pr-5-domain October 1, 2026 13:41
This was referenced Oct 1, 2026
Base automatically changed from cuda-pr-5-domain to devel October 1, 2026 16:04
@max-models
max-models requested a review from spossann October 1, 2026 22:26
@max-models
max-models marked this pull request as ready for review October 1, 2026 22:26
@max-models
max-models enabled auto-merge (squash) October 1, 2026 22:26
Comment thread src/struphy/pic/base.py
@max-models
max-models merged commit cca3221 into devel Oct 2, 2026
30 checks passed
@max-models
max-models deleted the cuda-pr-6-particles-on-gpu branch October 2, 2026 12:07
max-models added a commit that referenced this pull request Oct 2, 2026
**Solves the following issue(s):**

PR 7 of the CUDA strategy (`CUDA_STRATEGY.md`). No issue closed.

Stack: #649 → #651 → this PR → #653. The commits `Derham on the GPU:
create Derham on CuPy, add Derham.cuda_args_derham` and `CUDA_STRATEGY:
PR 7 depends on feectools#85` are new relative to #651.

Depends on struphy-hub/feectools#85 (feectools on the CuPy backend).
Without it, feectools itself fails while the spline spaces are built on
CuPy.

**Core changes:**

`Derham` (`src/struphy/feec/psydac_derham.py`) can be created on the
CuPy backend. The rule: data that describes the spline spaces stays on
the host on every backend, because it goes to pyccel kernels, SciPy or
MPI. Only coefficients and stencil matrices live on the device.

- The projection and quadrature grids (`get_pts_and_wts`,
`get_pts_and_wts_quasi`, `get_span_and_basis`, the local-projector
weights) and `spline_types_pyccel` are NumPy arrays. This removes the
ad-hoc `cupy` conversions in them.
- `domain_array`, `index_array`, `index_array_N`, `index_array_D` and
`neighbours` are gathered with NumPy MPI buffers, so no CUDA-aware MPI
is needed. They are then converted with `xp.asarray`, so they are device
arrays on CuPy, like `Particles.domain_array`. The neighbour search runs
on the host because it builds an object array with `None` entries, which
CuPy cannot hold.
- `args_derham` is built from the host knots, degrees and starts
directly; `_to_numpy_for_kernel` is gone.
- New `Derham.cuda_args_derham`: `CudaDerhamArguments` (new in
`src/struphy/utils/cuda_arguments.py`) with one device copy of the
degrees, knots and start indices, built on first access. On the NumPy
backend it raises `TypeError`, since host arrays are never copied to the
device. The pyccel scratch arrays (`bn1`, ..., `bd3`) are not part of
it; they become per-thread local arrays in CUDA (PR 10).
- Local projectors (`DerhamOptions.local_projectors=True`) raise
`NotImplementedError` on CuPy when the `Derham` is created, instead of
failing later inside `CommutingProjectorLocal`.

Not in this PR:
- Local projectors on CuPy. `CommutingProjectorLocal` builds its data
with `xp` and calls pyccel kernels on it, as `Derham` did.
- Polar splines on CuPy. They need a spline mapping, which cannot be
created on CuPy yet (see PR 5).
- Field evaluation (`SplineFunction.__call__`, ...) on the device. It
still calls pyccel kernels with the coefficients, so it needs CUDA
evaluation kernels (PR 10+).

Tests:
- New `src/struphy/feec/tests/test_derham_gpu.py`:
- `test_cuda_args_derham_needs_device_arrays` (runs everywhere): raises
on the NumPy backend.
- `test_derham_on_cupy` (GPU only), with periodic and with
Dirichlet/free boundaries: the decomposition tables and kernel arguments
agree between the backends, and `cuda_args_derham` is built once and
holds device copies of the right values.
  - `test_local_projectors_not_supported_on_cupy` (GPU only).
- The GPU-only tests have not run on a GPU yet. On the CPU they were run
with a strict host stand-in for CuPy, which rejects host/device mixing
and cannot run kernels, on top of feectools#85. With it, a `Derham`
created on the "CuPy" backend matches the NumPy one on 1, 2 and 4 MPI
processes.
- `test_kernel_backends.py::test_pusher_accepts_kernel` fails with and
without this PR (`ValueError: array is too big` in `draw_markers`). It
is unrelated.

**Model-specific changes:**

None

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
max-models added a commit that referenced this pull request Oct 2, 2026
**Solves the following issue(s):**

PR 8 of the CUDA strategy (`CUDA_STRATEGY.md`). No issue closed.

Stack: #649 → #651 → #652 → this PR. Only the commit `Shared CUDA header
for the kernel argument classes` is new relative to #652 (plus a merge
of #652).

**Core changes:**

CUDA kernels used to repeat the full flat signature of the argument
classes (26 parameters for markers and domain alone). CuPy does not
check kernel signatures, so adding a field to `MarkerArguments` would
have shifted all following arguments of every CUDA kernel without an
error. Now each argument class is one C struct, defined once.

- New `src/struphy/kernel_arguments/pusher_args.cuh` with `struct
MarkerArgs`, `DerhamArgs` and `DomainArgs`. Kernels take them by value
in place of the pyccel argument classes, e.g. `void
push_eta_linear(double dt, int stage, MarkerArgs args_markers,
DomainArgs args_domain)`, and read `args_markers.markers`,
`args_markers.n_markers`, and so on.
- The member names are the attribute names of the pyccel classes. The
only CUDA-specific member is `MarkerArgs.n_cols`, since pyccel kernels
take it from `markers.shape[1]`. Accordingly,
`CudaMarkerArguments.first_pusher_idx` is now `first_init_idx`, as in
`MarkerArguments`.
- `src/struphy/utils/cuda_arguments.py`:
- Each `Argument` subclass lists its members once, e.g. `fields =
(("double*", "markers"), ...)`.
- `struct_dtype()` builds a NumPy structured dtype with `align=True`,
which gives C alignment and padding. Pointer members are `uint64` device
addresses (`array.data.ptr`).
- The struct is a `numpy.void`, which CuPy passes by value. It is packed
once in the constructor, so kernel calls do no extra work.
- Scalar members are checked when the struct is packed. A `float` for an
`int` member raises `TypeError`, and a value that does not fit raises
`OverflowError`. Before, they arrived in the kernel truncated or wrapped
around; plain NumPy assignment also truncates `1.7` to `1`. This solves
the "scalar types" open question for everything inside the argument
classes. Scalars passed directly to a kernel (`dt`, `stage`) are still
unchecked.
- Because the struct holds device addresses, it is repacked when an
argument object is deepcopied or unpickled
(`__getstate__`/`__setstate__`).
- `CudaKernel` (`src/struphy/utils/kernel_backends.py`) compiles with
the folder that contains the `struphy` package on the NVRTC include
path, so `.cu` files can `#include
"struphy/kernel_arguments/pusher_args.cuh"`. `.cuh` files are already
package data.

**Why structs, and why `_pack()` and the copy handling are needed:**

The underlying problem: CuPy passes kernel arguments positionally and
never checks them against the signature. With flat parameter lists, a
new field in an argument class shifts every later argument of every CUDA
kernel, silently and on the GPU only. Some single, checked definition of
each argument layout is therefore necessary. A C struct is the simplest
one that also works for device helper functions.

What each piece is for:
- `struct_dtype()` with `align=True`: CuPy passes a struct by value only
as a NumPy structured scalar, and `align=True` gives the C padding (the
`int` members are followed by pointers).
- Pointers as `uint64` (`data.ptr`): a struct cannot hold a CuPy array,
only its device address.
- Repacking in `__setstate__`: a direct consequence of storing
addresses. The arrays of a deepcopied or unpickled object have new
addresses, and without the repack a copy would silently read the
original's arrays.
- Packing once in the constructor: so kernel calls do no extra work.
Packing at every call would also be fine (well under 1 µs next to a
launch of about 70 µs).
- Scalar checks: the only optional part. They are cheap and turn
truncation or wrap-around into an exception.

For:
- Silent argument shifts cannot happen, and a test that needs no GPU
checks the header against `fields`.
- CUDA code mirrors the pyccel code: pyccel kernels and helpers take
`args_domain` and read `args_domain.kind_map`, and CUDA reads exactly
the same. This decides it for PR 10. The mapping evaluation, B-spline
and boundary helpers take these argument objects, so a `__device__`
helper takes one `const DomainArgs&` instead of 12+ parameters forwarded
at every call site.
- Short kernel signatures. Adding a field touches `fields` and the
header, not every kernel.
- No runtime cost.

Against:
- Raw pointers bypass CuPy's per-launch checks: a host array or an array
on another GPU is no longer rejected at launch. `_cupy_array()` checks
at construction that the array is a C-contiguous CuPy array of the right
dtype, but not which device it is on. That will matter with one GPU per
MPI rank; adding a device check is a small follow-up.
- If an owner reallocates an array without rebuilding its argument
object, the struct points at the old array. The pyccel argument classes
have the same issue, since they also hold references, so this is not
new.
- About 100 lines of infrastructure plus layout tests before any real
CUDA kernel exists. But this choice fixes the signatures of all device
helpers in PR 10, so changing it later would mean rewriting them.
- Alignment is subtle. The host `sizeof`/`offsetof` check and the NVRTC
layout test cover it.

Alternatives considered:
- Flat parameters generated from a C macro (`#define MARKER_ARGS double*
markers, ...`), with `get_cuda_args()` returning the arrays in `fields`
order. This also prevents silent shifts and keeps CuPy's checks, without
pointers or repacking. But every device helper then needs a long
parameter list, or a second macro that rebuilds a struct inside the
kernel. That is more macro machinery, and the CUDA code no longer
mirrors the pyccel helpers.
- Plain flat lists without a single definition: the state before this
PR, and the dangerous one.

Tests (`src/struphy/pic/tests/test_kernel_backends.py`):
- Run everywhere:
- `test_cuda_argument_structs_match_header`: parses `pusher_args.cuh`
and compares member names, C types and order with `fields`.
- `test_cuda_struct_members_are_pyccel_attributes`: the member names
exist as attributes in `pusher_args_kernels.py`, except `n_cols`.
- GPU only:
- `test_cuda_struct_layout`: an NVRTC-compiled kernel reports `sizeof`
and the member offsets and sizes, which are compared with the NumPy
dtype.
- `test_cuda_struct_scalars_are_checked` and
`test_cuda_struct_follows_copies`.
- The demo kernels `push_eta_linear` and `write_scalars` now use the
structs, and `write_scalars` also reads a pointer member of each struct.
- Fixed: several GPU-only tests here and in
`geometry/tests/test_domain.py` still used the `values` attribute, which
`get_cuda_args()` replaced. They would have failed on a GPU.

The GPU-only tests have not run on a GPU yet. On the host:
- Compiling the header with a C++ compiler gives the same
`sizeof`/`offsetof` as the NumPy dtypes. x86-64 and arm64 lay these
structs out like CUDA.
- The tests that do not launch kernels pass with a strict host stand-in
for CuPy.
- `test_pusher_accepts_kernel` fails with and without this PR (see
#652). It is unrelated.

**Model-specific changes:**

None

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants