Skip to content

GPU: row-major marker buffer and hole rows make column operations bandwidth-bound #707

Closed

Description

@max-models

Summary

On the CuPy backend, a large share of the time in model.integrate is not spent in the pusher kernels but in
column operations on the marker buffer (Particles.markers). Two properties of the buffer cause this:

  1. Row-major (array-of-structs) layout. markers has shape (n_rows, n_cols) in C order, so a column
    (or a narrow range of columns) is strided by n_cols * 8 bytes. Any operation on a few columns touches the
    cache lines of every row, i.e. it costs about as much memory traffic as sweeping the whole buffer.
  2. Hole rows are processed like valid ones. The buffer has room for n_rows ≈ (1 + bufsize) * Np rows
    (in the benchmark below: 8,002,000 rows for 4,000,000 markers). Holes can sit anywhere in it and nothing
    tracks a high-water mark, so column operations and kernels always run over all n_rows rows.

Proposal: store the markers column-wise (struct-of-arrays, e.g. shape (n_cols, n_rows) in C order or
(n_rows, n_cols) in F order) on the GPU, and keep valid markers compact (or track the range of rows that
can hold valid markers) so that launches can be limited to it.


The preamble in Pusher._push is

markers[:, first_pusher_idx:first_shift_idx] = markers[:, : 3 + vdim]
markers[:, first_shift_idx:residual_idx] = 0.0
markers[:, residual_idx:-2] = 0.0

Each of these is one strided pass over the full 1.66 GB buffer, this takes longer than the kernel call itself.

The preamble can be fused into one CUDA kernel (src/struphy/pic/pushing/prepare_push_cuda.cu), with
one thread per entry of the contiguous column range so that writes are coalesced. See #708

Skipping holes inside the kernel gains only ~10%, since every thread still has to read column 0 of its row
to know whether it is a hole, which touches the same cache lines.

Why holes cannot simply be skipped today

  • The CUDA pushers detect a hole by markers[ip, first_init_idx] == -1; that -1 is written by the preamble
    copying column 0. A marker that became a hole in the previous push still has its old position in
    first_init_idx until the next preamble.
  • Iterative pushers take xp.max(markers[:, residual_idx]) and count non-converged markers over all rows,
    relying on hole rows having a zero residual.
  • There is no high-water mark of valid rows; holes are created anywhere by boundary conditions and MPI sorting.

Suggested direction

  1. Column-wise marker storage on the GPU. Every column operation (preamble, markers[:, 0] == -1 in
    update_holes, valid_mks masks, diagnostics) becomes a contiguous, fully coalesced sweep over only the
    columns involved. For the preamble that is 15 of 26 columns written and 6 read, i.e. well below the
    whole-buffer floor above. The kernels access args_markers.markers(ip, j) through Array2D, so the
    accessor could absorb the layout change; the Python side (markers[:, slice], markers[valid_mks],
    MPI send buffers, HDF5 output) needs auditing.
  2. Keep valid markers compact, or track a high-water mark (n_rows_used) updated wherever markers are
    created, removed or moved (loading, boundary conditions, mpi_sort_markers, refilling), so that launches
    cover [0, n_rows_used) instead of [0, n_rows). In the benchmark this halves the work of every
    full-buffer operation. Hole rows inside that range would still need the -1 marker and a zero residual.
  3. Outside model.integrate, the same trace shows ~6 ms per step of boolean-mask work over all rows in the
    diagnostics (cupy_scan_naive, cupy_getitem_mask, cupy_copy__bool_bool), which would benefit from 1. and 2. as well.

Plan for part 1: struct-of-arrays via order="F"

Allocating the buffer as xp.zeros((n_rows, n_cols), order="F") gives exactly the struct-of-arrays memory layout, and every markers[ip, j] and markers[:, a:b] keeps working unchanged. The alternative, storing the transpose with shape (n_cols, n_rows), would mean rewriting every index expression. So F order is the way to do it, but it can't just be a flag. Findings below are on cuda-namespaces (6c2a772, the top of the CUDA stack), with struphy's pyccel 2.2.1.

What enforces the layout explicitly

  1. pyccel checks the memory order at every call. An F-ordered array passed to a float[:,:] argument raises TypeError: Wrong argument type for argument markers : argument does not have the expected ordering, and the reverse fails too. Declaring float[:,:](order=F) fixes it, and that also works on a pyccel class attribute (tested). Places to change:

    • MarkerArguments.markers (kernel_arguments/pusher_args_kernels.py), which ~110 kernel files receive;
    • 20 kernels that take markers directly: sorting, sampling, utilities, reflect, the diagnostics, eval_spline_mpi_markers, kernel_evaluate_pic, kernel_pullpush_pic and the orbits post-processing.

    Every caller must then pass F-ordered arrays. That includes copies such as markers_wo_holes (markers[~holes]), which come out in C order.

  2. CudaMarkerArguments rejects it. _device_array raises "must be a C-contiguous array". The CUDA kernels themselves already handle any layout, because Array2D carries strides, so only this check needs relaxing.

  3. Hand-written row-major CUDA. prepare_push_cuda.cu (Fuse the pusher preamble into one CUDA kernel #708) indexes markers + ip * n_cols.

What silently creates C-ordered arrays

  • a.copy(): NumPy and CuPy default to order="C", unlike np.copy, which defaults to "K".
  • Boolean and fancy row indexing (markers[valid_mks]), xp.concatenate (the send lists), MPI receive buffers and h5py reads.

These are harmless as long as the result is only written back into the F buffer with markers[idx] = .... They break when the result is passed to a kernel. The MPI exchange (mpi_sort_markers) already builds temporary buffers and scatters them back, so it doesn't depend on the layout. Only its row gathers become strided.

Quick CPU benchmark

One thread, Fortran backend, 2M markers × 26 columns. The kernel is pusher-like: it reads 9 columns and writes 6. The preamble is the three column assignments from Pusher._push, run as NumPy operations.

Layout Kernel Preamble as NumPy column ops
C (AoS, current) 20.8 ms 66.1 ms
F (SoA) 22.4 ms 8.3 ms

So the kernel is about 8% slower and the column work is 8× faster, even on the CPU. This argues for one layout on both backends rather than SoA on the GPU only. Splitting by backend would also break the pyccel fallback on CuPy, because converting a device array to NumPy keeps its F order, and the pyccel signature would reject it. This was a toy kernel; real kernels with spline evaluation and OpenMP still need measuring (step 5).

Steps

  • 0. One place that creates the buffer.
    • Particles._markers is only assigned in pic/base.py (xp.zeros((self.n_rows, self.n_cols))). Add a layout constant used there and for _lost_markers, the receive buffers and the send lists.
    • Add a _check_layout() assert that runs in debug mode and in the tests.
  • 1. pyccel signatures.
    • Change MarkerArguments.markers and the 20 direct markers: "float[:, :]" arguments to float[:, :](order=F).
    • Convert other 2D per-marker arrays that reach kernels (callers of lost_markers and markers_wo_holes), or use xp.asfortranarray(...) at those call sites.
    • Recompile, then run the PIC tests. The ordering TypeError points to every caller that was missed.
  • 2. CUDA.
    • Relax _device_array for markers to accept F-contiguous arrays.
    • Rewrite prepare_push_cuda.cu (Fuse the pusher preamble into one CUDA kernel #708) for column-major, or drop it. With F order the preamble is three contiguous column-block copies (markers[:, a:b] = markers[:, :k]), which CuPy runs as coalesced copies. Measure before deciding.
    • Rerun the parity tests (pic/tests/test_cuda_parity.py) with F-ordered marker inputs: in CPU emulation now, and on the H100 in First GPU run of the CUDA tests (H100) #687.
  • 3. Python-side audit (pic/base.py, pic/sorting.py, sampling, diagnostics, post_processing, marker evaluation in geometry/base.py).
    • .copy(), .reshape() and .ravel() on marker arrays: use order="K", or make sure the result never reaches a kernel.
    • Restart and HDF5 output: datasets stay (n_markers, n_cols) on disk, so files don't change. Writing a strided row subset gets slower; measure it for large outputs.
    • Box sorting and compact_by_mask row moves: each moved row now means n_cols strided accesses. Measure; this happens once per sort, not once per push.
  • 4. Layout test. Allocate, load, push, sort, exchange (2 ranks, fake CuPy), save and restart. Check that the buffer stays F-ordered and that results match the C-ordered baseline bit-for-bit.
  • 5. Benchmarks before merging.
    • CPU: a few real models (VlasovAmpere, guiding centre, SPH) with OpenMP, C vs F.
    • GPU: the trace above (preamble, update_holes, valid_mks masks, diagnostics).
    • If a CPU kernel regresses badly, fix that kernel (e.g. read a marker's columns into locals once) rather than splitting the layout by backend.
  • 6. Part 2, separate PR: track the range of rows that can hold valid markers (n_rows_used) so that launches skip trailing holes. This is independent of the layout and roughly halves the work again in the benchmark above.

Steps 0–4 are mostly mechanical: ~20 annotations, one pyccel class, one CUDA check, one CUDA kernel, plus the audit and tests. The risk is in the audit (step 3) and in CPU performance (step 5).

Activity

  1. max-models commented on Oct 8, 2026

    @max-models
    MemberAuthor

    @spossann Re: SoA

  2. max-models commented on Oct 8, 2026

    @max-models
    MemberAuthor

    order="F" vs. refactoring to a transposed (n_cols, n_rows) array

    Recommendation: use order="F". A refactor to a transposed (n_cols, n_rows) C-ordered array would give no speedup, because the two layouts store every element at the same address. Element (ip, j) sits at j * n_rows + ip in both, and every column is one contiguous block in both. The only difference is which way round the indices are written.

    Benchmark: the same pusher-like kernel as in the description (2M markers × 26 columns, reads 9 columns and writes 6, one thread, pyccel 2.2.1 with the Fortran backend). Each number is the minimum of 7 runs, and the whole benchmark was run twice:

    Layout Kernel Preamble (column ops)
    C (n, 26), current AoS 21.0 ms 64 ms
    F (n, 26) 21.5 ms 5.4 ms
    C (26, n), transposed refactor 21.6 ms 5.3 ms

    The two SoA rows are identical within noise. The ~8% kernel slowdown in the description was mostly noise; with min-of-7 timing it is ~2–3%. The same holds on the GPU: Array2D reads the strides at runtime, so the CUDA kernels make the same memory accesses for both layouts. CuPy also handles F-contiguous and C-contiguous arrays the same way for copies and elementwise operations.

    The transposed version has two speed-related advantages, both small:

    • No accidental C-order copies. With a transposed C array, .copy(), masks and concatenate keep the layout by default. With F order, an accidental C-order copy in a hot path costs time (one passed to a kernel at least fails loudly in pyccel). This is a matter of code discipline rather than speed, and the layout check in step 0 catches it.
    • CArray2D instead of Array2D. On the GPU the transposed array could use CArray2D, which has no runtime strides. For memory-bound kernels like these, the difference is not expected to be measurable.

    Beyond SoA, the next speedup doesn't come from the layout choice. It comes from part 2 (step 6): tracking n_rows_used so that launches skip trailing holes, which roughly halves the work in the benchmark above.

  3. max-models commented on Oct 8, 2026

    @max-models
    MemberAuthor

    More things to take into account for the SoA (order="F") change

    1. cunumpy: xp.to_numpy loses F order (needs a cunumpy fix)

    cunumpy.xp._to_numpy calls array.get(). Both the real CuPy and the fake CuPy default get() to order="C", so an F-ordered device array arrives on the host C-ordered. I checked this on the fake CuPy. It affects:

    • xp.to_numpy, and with it xp.host_call / xp.evaluate_on_host.
    • The PyccelKernel conversion on the CuPy backend (_kernel.py: _device_to_host = _to_numpy). A kernel without a CUDA version would get a C-ordered copy of markers, and pyccel then raises its ordering TypeError.
    • Tests that compare device results on the host.

    Fix (cunumpy PR): array.get(order="A") in _to_numpy, plus a test that to_numpy, host_call and the PyccelKernel conversion keep F order. This has to land in a cunumpy release before the struphy change.

    These parts of cunumpy already work with F-ordered arrays (checked on the fake CuPy):

    • xp.zeros(..., order="F") on the CuPy backend; column blocks a[:, i:j] stay F-contiguous.
    • xp.to_cupy of an F-ordered NumPy array keeps F order.
    • kernel_testing.host_buffer keeps F order.
    • Emulated launches: a CUDA kernel taking Array2D<double> gives the same result for C- and F-ordered inputs, because the view carries the strides.
    • xp.algorithms.compact_by_mask is correct on F-ordered arrays, since it uses fancy indexing.

    a.copy() and boolean row masks return C order, but that matches the real CuPy, so it is not a cunumpy bug. In struphy, use order="K" or write the result back into the F buffer (step 3 of the plan).

    2. Operations on whole markers get more expensive

    SoA speeds up column operations and slows down operations that need all of one marker's data:

    • SPH neighbour loops read positions of other markers (markers[jp, 0:3]). That is 3 cache lines per neighbour instead of 1, so these are the kernels most likely to regress. This is fine as long as markers are kept box-sorted, so that neighbours sit next to each other in memory.
    • Row moves: box sorting, boundary conditions, gathering send rows in mpi_sort_markers, and scattering received rows into holes. Each moved row is now n_cols separate accesses. SPH sorts every step, so measure it there.

    3. Pad n_rows to a multiple of 32

    Column j starts at byte j * n_rows * 8. If n_rows is not a multiple of 32, columns start misaligned on the GPU, and a warp's 256-byte read spans 3 cache lines instead of 2. Rounding n_rows up when allocating fixes this at no cost.

    4. Gather only the columns you need

    Diagnostics do markers[valid_mks], which copies all 26 columns. With SoA, markers[valid_mks, j] costs only the columns used; with AoS it costs nearly as much as the full rows. Going through the diagnostics for this is where most of the ~6 ms/step of mask work in the description would go.

    5. CPU caveats the benchmark didn't cover

    The benchmark was single-threaded and touched 15 columns. Kernels that touch 20+ columns use one memory stream per column, which can exceed the hardware prefetcher's capacity. Benchmark a few real kernels with OpenMP on a compute node (step 5 of the plan), not on a laptop.

    6. Tests and timing

  4. max-models commented on Oct 8, 2026

    @max-models
    MemberAuthor

    Conclusions

    Summary of the plan in the description and the two comments above.

    1. Struct-of-arrays via order="F" is clearly worth doing, and it's the right way to do it.

    • Column operations get much faster, even on the CPU (preamble: 64 ms → 5.4 ms), and the CUDA kernels need no changes to their indexing.
    • A refactor to a transposed (n_cols, n_rows) array would give exactly the same speed, because the memory layout is identical. Rewriting all the indices buys nothing.
    • The change is mostly mechanical: about 20 pyccel annotations, one pyccel class, one CUDA check and one CUDA kernel. Pyccel's ordering TypeError makes every missed call site fail loudly instead of silently.

    2. The speedup is on the GPU, and the gain is narrower than "everything gets faster".

    • Faster: whole-buffer column work: the preamble, hole detection, masks and diagnostics. Per the trace in the description, that is a large share of model.integrate on CuPy.
    • Unchanged: kernel time itself, which on the CPU stays within ±3%.
    • Possibly slower: SPH neighbour access and row moves (sorting, boundary conditions, the MPI exchange).

    So the net effect depends on the model. Particle pushers and Vlasov models gain the most, while SPH could lose unless markers stay box-sorted. This needs measuring rather than assuming.

    3. Part 2 (tracking n_rows_used) is probably worth as much as SoA, and is independent of it. Skipping trailing holes roughly halves every full-buffer operation, whatever the layout. The two gains multiply.

    4. Nothing here has been measured on a real GPU yet, and that is the biggest gap.

    • So far the evidence is the trace in the description, a CPU microbenchmark and CPU emulation.
    • The CUDA work as a whole has not run on an H100 yet (First GPU run of the CUDA tests (H100) #687).
    • The GPU speedup is expected, not proven.

    Recommended order

    1. Merge the CUDA stack (CUDA version of the linear_vlasov_ampere accumulation #705–Move FEEC assembly kernels into kernel folders #725).
    2. First H100 run (First GPU run of the CUDA tests (H100) #687), with a baseline profile of a particle model.
    3. Fix the cunumpy to_numpy order bug and release cunumpy. That release also unblocks Port GVEC to GPU #715 (GVEC).
    4. SoA change (steps 0–4 of the plan), plus padding n_rows to a multiple of 32 and gathering only the needed columns in the diagnostics.
    5. Benchmarks: CPU with OpenMP and GPU against the baseline, including SPH.
    6. Part 2: track n_rows_used.

    Doing SoA before step 2 would mean optimizing without a real GPU profile, and fighting merge conflicts with the open stack.

  5. added
    CUDACUDA kernels and the CUDA porting (see #650)
    on Oct 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    CUDACUDA kernels and the CUDA porting (see #650)GPURunning Struphy on GPUs

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions