Repository navigation
GPU: row-major marker buffer and hole rows make column operations bandwidth-bound #707
Description
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:
- Row-major (array-of-structs) layout.
markershas shape(n_rows, n_cols)in C order, so a column
(or a narrow range of columns) is strided byn_cols * 8bytes. 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. - Hole rows are processed like valid ones. The buffer has room for
n_rows ≈ (1 + bufsize) * Nprows
(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 alln_rowsrows.
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.0Each 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-1is 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_idxuntil 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
- Column-wise marker storage on the GPU. Every column operation (preamble,
markers[:, 0] == -1in
update_holes,valid_mksmasks, 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 accessargs_markers.markers(ip, j)throughArray2D, so the
accessor could absorb the layout change; the Python side (markers[:, slice],markers[valid_mks],
MPI send buffers, HDF5 output) needs auditing. - 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-1marker and a zero residual. - 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
-
pyccel checks the memory order at every call. An F-ordered array passed to a
float[:,:]argument raisesTypeError: Wrong argument type for argument markers : argument does not have the expected ordering, and the reverse fails too. Declaringfloat[:,:](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
markersdirectly: sorting, sampling, utilities,reflect, the diagnostics,eval_spline_mpi_markers,kernel_evaluate_pic,kernel_pullpush_picand 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. -
CudaMarkerArgumentsrejects it._device_arrayraises "must be a C-contiguous array". The CUDA kernels themselves already handle any layout, becauseArray2Dcarries strides, so only this check needs relaxing. -
Hand-written row-major CUDA.
prepare_push_cuda.cu(Fuse the pusher preamble into one CUDA kernel #708) indexesmarkers + ip * n_cols.
What silently creates C-ordered arrays
a.copy(): NumPy and CuPy default toorder="C", unlikenp.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._markersis only assigned inpic/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.markersand the 20 directmarkers: "float[:, :]"arguments tofloat[:, :](order=F). - Convert other 2D per-marker arrays that reach kernels (callers of
lost_markersandmarkers_wo_holes), or usexp.asfortranarray(...)at those call sites. - Recompile, then run the PIC tests. The ordering
TypeErrorpoints to every caller that was missed.
- Change
- 2. CUDA.
- Relax
_device_arrayformarkersto 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.
- Relax
- 3. Python-side audit (
pic/base.py,pic/sorting.py, sampling, diagnostics,post_processing, marker evaluation ingeometry/base.py)..copy(),.reshape()and.ravel()on marker arrays: useorder="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_maskrow moves: each moved row now meansn_colsstrided 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_mksmasks, 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
max-models commentedon Oct 8, 2026
@spossann Re: SoA
max-models commentedon Oct 8, 2026
order="F"vs. refactoring to a transposed(n_cols, n_rows)arrayRecommendation: 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 atj * n_rows + ipin 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:
(n, 26), current AoS(n, 26)(26, n), transposed refactorThe 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:
Array2Dreads 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:
.copy(), masks andconcatenatekeep 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.CArray2Dinstead ofArray2D. On the GPU the transposed array could useCArray2D, 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_usedso that launches skip trailing holes, which roughly halves the work in the benchmark above.max-models commentedon Oct 8, 2026
More things to take into account for the SoA (
order="F") change1. cunumpy:
xp.to_numpyloses F order (needs a cunumpy fix)cunumpy.xp._to_numpycallsarray.get(). Both the real CuPy and the fake CuPy defaultget()toorder="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 itxp.host_call/xp.evaluate_on_host.PyccelKernelconversion on the CuPy backend (_kernel.py:_device_to_host = _to_numpy). A kernel without a CUDA version would get a C-ordered copy ofmarkers, and pyccel then raises its orderingTypeError.Fix (cunumpy PR):
array.get(order="A")in_to_numpy, plus a test thatto_numpy,host_calland thePyccelKernelconversion 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 blocksa[:, i:j]stay F-contiguous.xp.to_cupyof an F-ordered NumPy array keeps F order.kernel_testing.host_bufferkeeps F order.Array2D<double>gives the same result for C- and F-ordered inputs, because the view carries the strides.xp.algorithms.compact_by_maskis 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, useorder="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:
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.mpi_sort_markers, and scattering received rows into holes. Each moved row is nown_colsseparate accesses. SPH sorts every step, so measure it there.3. Pad
n_rowsto a multiple of 32Column
jstarts at bytej * n_rows * 8. Ifn_rowsis 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. Roundingn_rowsup 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
markersthemselves (e.g.marker_argumentsinpic/tests/kernel_test_args.py, the parity cases, andnp.random.rand(n, n_cols)in tests) need F order, or pyccel rejects them. Expect some test churn.SortingBoxes) touches the same code.max-models commentedon Oct 8, 2026
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.(n_cols, n_rows)array would give exactly the same speed, because the memory layout is identical. Rewriting all the indices buys nothing.TypeErrormakes 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".
model.integrateon CuPy.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.
Recommended order
to_numpyorder bug and release cunumpy. That release also unblocks Port GVEC to GPU #715 (GVEC).n_rowsto a multiple of 32 and gathering only the needed columns in the diagnostics.n_rows_used.Doing SoA before step 2 would mean optimizing without a real GPU profile, and fighting merge conflicts with the open stack.
Fuse the pusher preamble into one CUDA kernel (#708)
Process only the rows that can hold markers (n_rows_used)
Store the marker buffer column-major (struct-of-arrays), part 1 of #707…
Process only the columns that can hold markers, part 2 of #707 (#775)
Fuse the pusher preamble into one CUDA kernel (#708)
Store the marker buffer column-major (struct-of-arrays), part 1 of #707…
Process only the columns that can hold markers, part 2 of #707 (#775)