Skip to content

Process only the columns that can hold markers, part 2 of #707 - #775

Merged
max-models merged 8 commits into
707-markers-soafrom
707-markers-rows-used
Oct 16, 2026
Merged

max-models merged 8 commits into
707-markers-soafrom
707-markers-rows-used

Conversation

@max-models

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

Copy link
Copy Markdown
Member

Stack: part 2 of 2, based on #774 (merge that first). This PR's diff includes the commits of #774 until it is merged. #774 changed the marker buffer to one column per marker ((n_cols, n_markers_max), markers[j, ip]), and this branch is merged with it, so "marker slot" below means a column of the buffer. Following #774, the names count markers, not rows: the bound is n_markers_used (was n_rows_used), counted by count_markers_used and tracked by _track_markers. Both branches are also merged with the current devel, including the kernel-based box sorting of #736.

Solves the following issue(s):

Closes #707 together with #774. This is part 2 of the issue: launches and per-quantity operations cover only the marker slots that can hold markers, not the trailing holes of the buffer.

Core changes:

The marker buffer has room for (1 + bufsize) * Np markers (n_markers_max slots), and every kernel and per-quantity operation ran over all of them. Particles.n_markers_used is now a bound with the invariant that every marker slot from it on is a hole, and the per-marker work stops there:

  • Kernels:
    • args_markers.n_markers holds the bound, so the pyccel kernels loop over it and the CUDA kernels return after it.
    • The 15 accumulation kernels that looped over the full marker count from the array shape now use args_markers.n_markers.
    • Pusher and the accumulators launch n_markers threads.
  • Python operations on markers[:, :n_markers_used]:
    • the pusher preamble (prepare_push gets the sliced Array2D view on the GPU);
    • the residual check of the iterative pushers;
    • update_holes;
    • the boundary conditions (_find_outside_particles and the snapshot of the positions);
    • the alpha-weighted sorting positions of mpi_sort_markers (the first n_markers_used columns of the (3, n_markers_max) helper arrays).

Keeping the bound without reading the device:

  • New markers always fill the lowest holes (nonzero of the hole mask, ascending). So k incoming markers end below (occupied slots) + k, where _n_occupied_bound is an upper bound on the occupied slots.
  • The sent, received and removed counts are already host integers: send_inds.size, the NumPy recv_info, and the lost-marker counts of the boundary conditions. So _track_markers updates the bound on the host. On the CuPy backend the time loop gets no new device-to-host transfer, which the transfer budget of the GPU end-to-end tests checks.
  • Where the bound is updated:
    • counted exactly with count_markers_used (reads row 0; one device read on CuPy): after loading (fresh, external or restart; restart files can have markers in any slot) and after box sorting (do_sort, also with the box kernels of Set up sorting boxes on the CuPy backend #736), which moves holes anywhere. Code that writes markers into holes itself, outside Particles, must call it too; it is public for that reason.
    • tracked from host counts: the MPI exchange, removal at the boundary, and the SPH ghost-box exchange (own copy and receives);
    • unchanged on purpose: holes created without a count (removed ghost particles) leave the bound too high, which is safe.

Fix included (also on #774): test_verif_LinearVlasovAmpereOneSpecies::test_delta_f_initial_condition checked the mean of the delta-f weights per rank, assuming every rank holds a random subset of the markers.

  • The markers are sorted into the domain of their rank, so on four ranks each rank holds a quarter of the cos(kx) perturbation: mean/rms = (2/π)/(1/√2) ≈ 0.9, exactly the measured 1.25e-7/1.39e-7.
  • The sums are now reduced over the clone's communicator.
  • The test fails on four ranks without any of these changes, but testmon had not selected it on recent PRs (0 of 3184 verification tests selected on 726-sorting-boxes-cupy). Touching pic/base.py in Store the marker buffer column-major (struct-of-arrays), part 1 of #707 #774 selected it in the domain cloning in Container job.

Tests:

  • test_markers_used (in test_marker_layout.py), run on NumPy and on the fake CuPy, on 1 and 2 ranks. It uses removing boundaries in eta1, pushes that move markers out, and MPI sorting. It checks:
    • after every step, that every marker slot after n_markers_used is a hole and that args_markers.n_markers matches;
    • that the final markers (by ID) and the number of lost markers are bit-for-bit those of the same run with every slot counted as used.
  • bench_marker_layout.py (from Store the marker buffer column-major (struct-of-arrays), part 1 of #707 #774): before the merge with the new layout, the valid markers after all timed operations were bit-for-bit those of devel. With 1M markers there are 1,251,008 slots, of which n_markers_used = 1,000,000.
  • Run locally: the new test and the verification test above, and the marker-layout tests after the renaming. The merges with Store the marker buffer column-major (struct-of-arrays), part 1 of #707 #774 and devel were resolved without running the suite locally; CI ran on the merged state, where test_markers_used called the wrong device-context helper after the merge (fixed).

Benchmark:

  • The work of every restricted operation scales with n_markers_used / n_markers_max. That is 0.8 for the default bufsize = 0.25 right after loading, and about 0.5 for bufsize = 1 (the 8M-slot buffer for 4M markers in the issue's trace).
  • I could not measure it here. bench_marker_layout.py --all-markers-used compares the same build with and without the bound. I ran it twice, alternating, but the machine had a load average of about 500 from other jobs, and operations this PR does not touch varied by up to 15× between runs. So I don't claim a number.
  • It needs a quiet node or the GPU (First GPU run of the CUDA tests (H100) #687):
    • bench_marker_layout.py --all-markers-used --json all.json
    • bench_marker_layout.py --json used.json
    • bench_marker_layout.py --compare all.json used.json

Model-specific changes:

None.

Documentation changes:

Particles.n_markers_used and the tracking (_track_markers, count_markers_used) are documented in their docstrings.

🤖 Generated with Claude Code

@max-models max-models added GPU Running Struphy on GPUs CUDA CUDA kernels and the CUDA porting (see #650) labels Oct 14, 2026
@max-models
max-models added this pull request to stack #776 October 14, 2026 21:04
Part 2 of #707. The marker buffer has room for (1 + bufsize) * Np rows, and
kernels and column operations ran over all of them, including the trailing
holes. Particles.n_rows_used is now a bound with the invariant that every row
from it on is a hole, and the per-row work stops there:

- args_markers.n_markers is the bound, so the pyccel kernels loop over it
  (the 15 accumulation kernels that looped over shape(markers)[0] use it now)
  and the CUDA kernels return after it; Pusher and the accumulators launch
  n_markers threads.
- The pusher preamble, the residual check of the iterative pushers,
  update_holes, the boundary conditions and the sorting positions of
  mpi_sort_markers work on markers[:n_rows_used].

The bound is kept on the host and never waits for the device: markers always
fill the lowest holes, so k incoming rows end below (occupied rows) + k, and
the sent, received and removed counts are already host integers. Loading
(including restart) and box sorting count it exactly; holes made without a
count (removed ghost particles) leave it too high, which is safe.

Tests: test_rows_used (NumPy and fake CuPy, 1 and 2 ranks) checks the
invariant through pushes with removing boundaries and MPI sorting, and that
the markers are bit-for-bit those of a run over all rows.
bench_marker_layout.py gets --all-rows for an A/B comparison in one build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@max-models
max-models force-pushed the 707-markers-rows-used branch from 66ad9e3 to e432350 Compare October 14, 2026 21:11
…-size sort buffers

- test_mpi_sort_markers_on_rank_boundary writes tagged markers into the lowest
  holes itself; these rows can lie beyond n_rows_used, so they were never
  sorted. count_rows_used() is public now and documented as the call to make
  after writing markers into holes outside the Particles methods; the test
  calls it.
- mpi_sort_markers wrote the domain check of the used rows into a new, shorter
  _is_on_proc_domain and a new _can_stay per call, so nbytes_local no longer
  matched the allocated buffers (test_estimate_mem). Both now fill the first
  rows of their preallocated full-size buffers.
- test_kernel_setup: the stand-in particles of the pusher test get
  n_rows_used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@max-models

Copy link
Copy Markdown
Member Author

Benchmark of this PR, on top of #774 (setup as in my comment there: M1 MacBook Air, NumPy, 1 rank, one thread, bench_marker_layout.py, 1M markers, 3 alternating rounds, median; another session was using three cores throughout, so about 10–20% noise).

The rows used line of the script confirms the bound: 1,000,000 of 1,251,008 rows (0.8, the default bufsize = 0.25). I compared the same build with and without the bound (--all-rows), and against #774:

Operation #774 #775 Speedup all rows → bound (same build)
mpi_sort_markers 77.5 ms 63.8 ms 1.22× 1.19×
update_holes 1.0 ms 0.8 ms 1.31× 1.18×
apply_kinetic_bc 10.1 ms 9.0 ms 1.11× 1.10×
preamble 4.3 ms 4.5 ms 0.97× 0.96×
push_eta_stage 282 ms 280 ms 1.01× 1.00×
push_vxb_analytic 733 ms 707 ms 1.04× 0.99×
charge_density_0form 481 ms 481 ms 1.00× 0.99×
binning 312 ms 307 ms 1.01× 0.98×
  • The gain is the expected one for the three column operations, bounded by 1 − 0.8 = 20% at this bufsize.
  • The pushers and the accumulation do not speed up. Their pyccel loops already skip a hole with one branch, so the 20% fewer rows cost almost nothing to visit.
  • The preamble differs by 0.03×, which is inside the noise (run-to-run spread 19%).
  • With bufsize = 1 the bound would be about 0.5. I did not measure that.

Against the old row-major layout (devel) the two PRs together give 16× on the preamble, 9.5× on update_holes, 3.5× on apply_kinetic_bc, 2.6× on mpi_sort_markers, and 1.1–1.3× on the pushers. Total of all ten operations: 2312 ms → 1909 ms (1.2×).

max-models and others added 6 commits October 15, 2026 17:32
The marker buffer is now a C-ordered (n_cols, n_rows) array, one column per
marker. n_rows_used counts the columns that can hold markers: the row
operations of the pushers and the boundary conditions, the hole/outside
masks and the sorting buffers work on markers[:, :n_rows_used] (and
[:, :n] of the (3, n_rows) helper arrays), count_rows_used reads row 0,
and the accumulation kernels keep looping over args_markers.n_markers.
test_rows_used and the bench are adapted to the layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The merge with the column-per-marker layout called gpu_e2e.device_context
with the backend; it takes no arguments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bound on the marker slots that can hold markers is named after what it
counts as well: n_rows_used -> n_markers_used, count_rows_used ->
count_markers_used, _track_rows -> _track_markers, test_rows_used ->
test_markers_used, and the bench flag --all-rows -> --all-markers-used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@max-models max-models changed the title Process only the rows that can hold markers (n_rows_used), part 2 of #707 Process only the columns that can hold markers, part 2 of #707 Oct 16, 2026
@max-models
max-models merged commit a207a9f into devel Oct 16, 2026
32 of 60 checks passed
@max-models
max-models deleted the 707-markers-rows-used branch October 16, 2026 09:45
max-models added a commit that referenced this pull request Oct 16, 2026
… (#774)

**Solves the following issue(s):**

Part 1 of #707: the marker buffer is stored as struct-of-arrays (one
contiguous block per quantity) on both backends. Part 2 of the issue
(tracking the range of marker slots that can hold valid markers, so that
launches skip trailing holes) is #775, stacked on this PR.

**Core changes:**

`Particles.markers` is now a plain C-ordered array of shape `(n_cols,
n_markers_max)`: **one column per marker**, indexed `markers[j, ip]`
(was `(n_rows, n_cols)`, `markers[ip, j]`). Every quantity `markers[j]`
is one contiguous block of `n_markers_max` values.

**Naming:** `n_cols` keeps its meaning, the number of quantities per
marker. The number of marker slots (holes included) no longer counts
rows, so it is renamed after what it counts: `Particles.n_rows` →
`n_markers_max`, `padded_n_rows` → `padded_n_markers`, `ROW_ALIGNMENT` →
`MARKER_ALIGNMENT`. The quantity indices are still called columns
(`first_pusher_idx` etc.).

An earlier version of this PR kept the shape and allocated the buffer
with `order="F"`. That gives the same bytes in memory, but every array
passed to a kernel then had to carry the order (pyccel `float[:,
:](order=F)`, conversion helpers at every entry point, checks in the
CUDA arguments). The buffer is now transposed and the indexing changed
instead, so no array needs an order flag.

- **`struphy.pic.marker_layout`:**
- `marker_array(n_cols, n_markers_max)`, plus
`padded_n_markers`/`MARKER_ALIGNMENT`: `n_markers_max` is padded to a
multiple of 32, so every row of the buffer starts on a 256-byte boundary
(point 3 of the issue's second comment).
- The module docstring documents the convention and its one pitfall.
Selecting markers (`markers[:, valid_mks]`) gives a Fortran-ordered copy
in NumPy, so convert it with `xp.ascontiguousarray` before passing it to
a kernel or to MPI.
- **pyccel kernels:**
- All kernels index `markers[j, ip]` and declare a plain `float[:,:]` (C
order).
- The marker count is `shape(markers)[1]`, and
`MarkerArguments.n_markers` is `markers.shape[1]`.
  - The sorting kernels copy whole markers as columns.
- **CUDA:**
- The kernels index `markers(j, ip)`. `CudaMarkerArguments` takes the
C-contiguous `(n_cols, n)` array again.
- cunumpy infers the launch size from the first axis of the first array,
which is now `n_cols`. So `Pusher` and the accumulators pass
`n_threads=args_markers.n_markers`, and the geometry and spline marker
kernels pass `markers.shape[1]`.
- `prepare_push` (the fused preamble from #708) takes an
`Array2D<double>` view (a raw `double*` would be made C-contiguous, i.e.
copied, by the launch). It numbers its threads row by row, so
neighbouring threads touch neighbouring markers.
- **`Particles`:**
- The properties follow the layout: `markers_wo_holes(_and_ghost)` are
`(n_cols, n)`, `positions` is `(3, n)`, `velocities` is `(vdim, n)`, and
`phasespace_coords`, `f_coords` and `f_jacobian_coords` are `(k, n)`.
The setters check these shapes.
- Holes are `markers[0] == -1`, ghosts are `markers[-1] == -2`, and
whole markers are selected with `markers[:, sel]`.
- The sorting etas, the bc buffer and `_is_on_domain_of_rank` work on
`(3, n)`.
- **MPI exchange:**
- The send lists, the receive buffers and the SPH ghost-box arrays are
`(n_cols, n)`.
- The send lists and ghost-box markers are made C-contiguous. A column
selection is Fortran-ordered, and MPI would otherwise send it marker by
marker into C-ordered receive buffers.
- The CuPy exchange receives into one buffer per source rank, because a
slice of a common `(n_cols, n_recv)` buffer is not contiguous.
- **Evaluation at markers takes `(k, n)` arrays, one column per point**
(eta1..3 in rows 0..2), so the buffer and its selections go in without a
transpose:
- `Domain` (metric coefficients, pull/push/transform, `show`),
`SplineFunction`, the GVEC equilibria, the kinetic-background moments
and `B0` callables, and the canonical Maxwellian (`coords[0]` is `(5,
n)`);
- user callbacks that are evaluated at markers (the example params files
read `etas[0][i]`).

Output shapes are unchanged. `Domain` and `SplineFunction` make the
input C-contiguous (no copy for the buffer).
- **Gather only the quantities that are used** (point 4 of the issue's
second comment): binning, the box count of the sorting boxes, the
magnetic energy of `DriftKineticElectrostaticAdiabatic`, and two
emptiness checks in the weight initialization.
- **Box sorting from `devel` (#736):** `devel` is merged into this
branch. Its box kernels (`box_particles`, `fill_boxes`,
`sort_and_fill_boxes`, `box_and_sort_particles`) call the sorting
kernels, and the two it adds there (`count_particles_in_boxes`,
`find_holes`) now read the box row and row 0 of each marker column, like
the other sorting kernels.
- **HDF5 output and restart use the buffer layout:**
  - restart data `restart/<species>` is `(nt, n_cols, n_markers_max)`;
- saved markers `kinetic/<species>/markers` are `(nt, n_cols,
n_to_save)`;
- external marker files have a `markers` dataset of shape `(n_cols, N)`.

**Restart and external files written before this change do not load.**
`_load_restart` copies the common markers when the padding differs and
asserts that any extra ones in the file are holes. The post-processed
orbits keep their `(n_markers, n_quantities)` layout.
- **Fix found along the way:** the `info` output of
`VlasovAmpereCoupling` read `particles.bufferindex`, which does not
exist, so it always failed. It now reads the velocities saved by the
preamble, gathering 6 rows instead of whole markers.

**Tests:**

- `pic/tests/test_marker_layout.py`, on NumPy and on the fake CuPy
(emulated CUDA launches), on 1 and 2 ranks. It checks:
  - the layout helpers;
- that the buffer is C-ordered `(n_cols, n_markers_max)` with
`n_markers_max` a multiple of 32, and stays the same object through
loading, RK4 and v×B pushes, MPI sorting, boundary conditions,
evaluation at markers and binning;
- that marker selections are Fortran-ordered and accepted by
`MarkerArguments` and a kernel after `ascontiguousarray`;
- that evaluation on selections gives the same values as on the buffer.
- All tests that build markers or evaluation points by hand use the new
layout. The CUDA parity cases pass `n_threads` explicitly.
- The box checks added by #736 in `test_sorting.py` and
`test_sorting_device.py` read the box row of the markers.
- `test_mhd_equils` checked its eta1 case with `kind="markers"` left
over from the previous case; fixed.
- Run locally before the full suite was left to CI: the marker-layout,
sorting, draw-parallel and load-external tests on 2 ranks, the emulated
CUDA `prepare_push`/parity cases, and the geometry, feec, equilibrium,
model-scalar, post-processing and output tests, plus
`test_verif_LinearVlasovAmpereOneSpecies` on 1 and 2 ranks. The MPI
tests caught the Fortran-ordered send lists described above. The SPH and
pusher tests were stopped partway (no failures until then), and pyccel
compilation and the GPU tests run only in CI. Nothing was run locally
after the merge of `devel` (including the new box kernels); CI covers
it.

**Benchmark (CPU):**

`pic/tests/bench_marker_layout.py` times the layout-dependent operations
on the active backend. The numbers below were measured with the
`order="F"` version of this PR. The buffer has the same bytes in memory
now, so they are expected to carry over, but they have not been measured
again. The setup is 1M markers (1.25M marker slots × 26 quantities), a
Cuboid, 8³ cells and cubic splines, with the median of 5 calls on NumPy,
1 rank, no OpenMP, on an M-series MacBook Air. Before: `devel` at
11adb7b.

| Operation | Before (row per marker) | After, run 1 | After, run 2 |
Speedup |
|---|---:|---:|---:|---:|
| preamble (row assignments of `Pusher._push`) | 527 ms | 14 ms | 34 ms
| 15–37× |
| `update_holes` | 53 ms | 1.1 ms | 0.9 ms | 47–61× |
| `apply_kinetic_bc` (periodic) | 264 ms | 70 ms | 71 ms | 3.7× |
| `mpi_sort_markers` (1 rank) | 1277 ms | 507 ms | 519 ms | 2.5× |
| weights of valid markers (`particles.weights`) | 134 ms | 40 ms | 43
ms | 3.1–3.3× |
| binning `e1_v1` | 2728 ms | 2057 ms | 2311 ms | 1.2–1.3× |
| `push_eta_stage` (RK4 pusher) | 2685 ms | 2021 ms | 1985 ms | 1.3× |
| `push_vxb_analytic` (pusher) | 6213 ms | 4977 ms | 4861 ms | 1.25–1.3×
|
| `charge_density_0form` (accumulation) | 4073 ms | 3087 ms | 3231 ms |
1.25–1.3× |
| all quantities of the valid markers (`markers_wo_holes_and_ghost`) |
225 ms | 385 ms | 392 ms | 0.6× |

Caveats:

- The machine was heavily loaded during all runs (load average 210–265
from other jobs), so the absolute numbers are inflated and noisy.
- The operations on single quantities gain an order of magnitude or
more, far beyond the noise.
- The 1.25–1.35× on the kernel-bound rows (pushers, accumulation) should
not be read as a kernel speedup until it is measured on a quiet node.
The issue's quiet single-thread microbenchmark found kernel time
unchanged within ±3%.
- Gathering all quantities of the valid markers is about 1.7× slower, as
expected: a marker now touches 26 separate rows. The hot paths above no
longer do it. It is still used by
`markers_wo_holes_and_ghost`/`markers_wo_holes` and by the saved-markers
output.
- With the `order="F"` version, the valid markers after all timed calls
were bit-for-bit equal to `devel` (`--save-markers`, which still writes
one row per marker).
- **Not measured yet:**
- The GPU, where most of the gain is expected (preamble, `update_holes`,
`valid_mks` masks, diagnostics). It waits for the first H100 run (#687):
`CUNUMPY_BACKEND=cupy python
src/struphy/pic/tests/bench_marker_layout.py`.
  - CPU with OpenMP on a compute node.
- SPH neighbour loops, whose per-marker access gets more expensive with
this layout (see the issue).

**Model-specific changes:**

None beyond indexing: every model uses the new layout through
`Particles`. User callbacks evaluated at markers (density, temperature
or perturbation functions in params files that accept a single 2D array)
now receive `(3, n)`; the example params files are updated.

**Documentation changes:**

- "Marker array layout" section in the developer docs (`dev-pic.rst`,
automodule of `struphy.pic.marker_layout`).
- The marker-layout question in `CUDA_STRATEGY.md` is marked as decided,
with the new convention. The kernel and argument docstrings describe the
layout.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
spossann pushed a commit that referenced this pull request Oct 21, 2026
… (#774)

**Solves the following issue(s):**

Part 1 of #707: the marker buffer is stored as struct-of-arrays (one
contiguous block per quantity) on both backends. Part 2 of the issue
(tracking the range of marker slots that can hold valid markers, so that
launches skip trailing holes) is #775, stacked on this PR.

**Core changes:**

`Particles.markers` is now a plain C-ordered array of shape `(n_cols,
n_markers_max)`: **one column per marker**, indexed `markers[j, ip]`
(was `(n_rows, n_cols)`, `markers[ip, j]`). Every quantity `markers[j]`
is one contiguous block of `n_markers_max` values.

**Naming:** `n_cols` keeps its meaning, the number of quantities per
marker. The number of marker slots (holes included) no longer counts
rows, so it is renamed after what it counts: `Particles.n_rows` →
`n_markers_max`, `padded_n_rows` → `padded_n_markers`, `ROW_ALIGNMENT` →
`MARKER_ALIGNMENT`. The quantity indices are still called columns
(`first_pusher_idx` etc.).

An earlier version of this PR kept the shape and allocated the buffer
with `order="F"`. That gives the same bytes in memory, but every array
passed to a kernel then had to carry the order (pyccel `float[:,
:](order=F)`, conversion helpers at every entry point, checks in the
CUDA arguments). The buffer is now transposed and the indexing changed
instead, so no array needs an order flag.

- **`struphy.pic.marker_layout`:**
- `marker_array(n_cols, n_markers_max)`, plus
`padded_n_markers`/`MARKER_ALIGNMENT`: `n_markers_max` is padded to a
multiple of 32, so every row of the buffer starts on a 256-byte boundary
(point 3 of the issue's second comment).
- The module docstring documents the convention and its one pitfall.
Selecting markers (`markers[:, valid_mks]`) gives a Fortran-ordered copy
in NumPy, so convert it with `xp.ascontiguousarray` before passing it to
a kernel or to MPI.
- **pyccel kernels:**
- All kernels index `markers[j, ip]` and declare a plain `float[:,:]` (C
order).
- The marker count is `shape(markers)[1]`, and
`MarkerArguments.n_markers` is `markers.shape[1]`.
  - The sorting kernels copy whole markers as columns.
- **CUDA:**
- The kernels index `markers(j, ip)`. `CudaMarkerArguments` takes the
C-contiguous `(n_cols, n)` array again.
- cunumpy infers the launch size from the first axis of the first array,
which is now `n_cols`. So `Pusher` and the accumulators pass
`n_threads=args_markers.n_markers`, and the geometry and spline marker
kernels pass `markers.shape[1]`.
- `prepare_push` (the fused preamble from #708) takes an
`Array2D<double>` view (a raw `double*` would be made C-contiguous, i.e.
copied, by the launch). It numbers its threads row by row, so
neighbouring threads touch neighbouring markers.
- **`Particles`:**
- The properties follow the layout: `markers_wo_holes(_and_ghost)` are
`(n_cols, n)`, `positions` is `(3, n)`, `velocities` is `(vdim, n)`, and
`phasespace_coords`, `f_coords` and `f_jacobian_coords` are `(k, n)`.
The setters check these shapes.
- Holes are `markers[0] == -1`, ghosts are `markers[-1] == -2`, and
whole markers are selected with `markers[:, sel]`.
- The sorting etas, the bc buffer and `_is_on_domain_of_rank` work on
`(3, n)`.
- **MPI exchange:**
- The send lists, the receive buffers and the SPH ghost-box arrays are
`(n_cols, n)`.
- The send lists and ghost-box markers are made C-contiguous. A column
selection is Fortran-ordered, and MPI would otherwise send it marker by
marker into C-ordered receive buffers.
- The CuPy exchange receives into one buffer per source rank, because a
slice of a common `(n_cols, n_recv)` buffer is not contiguous.
- **Evaluation at markers takes `(k, n)` arrays, one column per point**
(eta1..3 in rows 0..2), so the buffer and its selections go in without a
transpose:
- `Domain` (metric coefficients, pull/push/transform, `show`),
`SplineFunction`, the GVEC equilibria, the kinetic-background moments
and `B0` callables, and the canonical Maxwellian (`coords[0]` is `(5,
n)`);
- user callbacks that are evaluated at markers (the example params files
read `etas[0][i]`).

Output shapes are unchanged. `Domain` and `SplineFunction` make the
input C-contiguous (no copy for the buffer).
- **Gather only the quantities that are used** (point 4 of the issue's
second comment): binning, the box count of the sorting boxes, the
magnetic energy of `DriftKineticElectrostaticAdiabatic`, and two
emptiness checks in the weight initialization.
- **Box sorting from `devel` (#736):** `devel` is merged into this
branch. Its box kernels (`box_particles`, `fill_boxes`,
`sort_and_fill_boxes`, `box_and_sort_particles`) call the sorting
kernels, and the two it adds there (`count_particles_in_boxes`,
`find_holes`) now read the box row and row 0 of each marker column, like
the other sorting kernels.
- **HDF5 output and restart use the buffer layout:**
  - restart data `restart/<species>` is `(nt, n_cols, n_markers_max)`;
- saved markers `kinetic/<species>/markers` are `(nt, n_cols,
n_to_save)`;
- external marker files have a `markers` dataset of shape `(n_cols, N)`.

**Restart and external files written before this change do not load.**
`_load_restart` copies the common markers when the padding differs and
asserts that any extra ones in the file are holes. The post-processed
orbits keep their `(n_markers, n_quantities)` layout.
- **Fix found along the way:** the `info` output of
`VlasovAmpereCoupling` read `particles.bufferindex`, which does not
exist, so it always failed. It now reads the velocities saved by the
preamble, gathering 6 rows instead of whole markers.

**Tests:**

- `pic/tests/test_marker_layout.py`, on NumPy and on the fake CuPy
(emulated CUDA launches), on 1 and 2 ranks. It checks:
  - the layout helpers;
- that the buffer is C-ordered `(n_cols, n_markers_max)` with
`n_markers_max` a multiple of 32, and stays the same object through
loading, RK4 and v×B pushes, MPI sorting, boundary conditions,
evaluation at markers and binning;
- that marker selections are Fortran-ordered and accepted by
`MarkerArguments` and a kernel after `ascontiguousarray`;
- that evaluation on selections gives the same values as on the buffer.
- All tests that build markers or evaluation points by hand use the new
layout. The CUDA parity cases pass `n_threads` explicitly.
- The box checks added by #736 in `test_sorting.py` and
`test_sorting_device.py` read the box row of the markers.
- `test_mhd_equils` checked its eta1 case with `kind="markers"` left
over from the previous case; fixed.
- Run locally before the full suite was left to CI: the marker-layout,
sorting, draw-parallel and load-external tests on 2 ranks, the emulated
CUDA `prepare_push`/parity cases, and the geometry, feec, equilibrium,
model-scalar, post-processing and output tests, plus
`test_verif_LinearVlasovAmpereOneSpecies` on 1 and 2 ranks. The MPI
tests caught the Fortran-ordered send lists described above. The SPH and
pusher tests were stopped partway (no failures until then), and pyccel
compilation and the GPU tests run only in CI. Nothing was run locally
after the merge of `devel` (including the new box kernels); CI covers
it.

**Benchmark (CPU):**

`pic/tests/bench_marker_layout.py` times the layout-dependent operations
on the active backend. The numbers below were measured with the
`order="F"` version of this PR. The buffer has the same bytes in memory
now, so they are expected to carry over, but they have not been measured
again. The setup is 1M markers (1.25M marker slots × 26 quantities), a
Cuboid, 8³ cells and cubic splines, with the median of 5 calls on NumPy,
1 rank, no OpenMP, on an M-series MacBook Air. Before: `devel` at
11adb7b.

| Operation | Before (row per marker) | After, run 1 | After, run 2 |
Speedup |
|---|---:|---:|---:|---:|
| preamble (row assignments of `Pusher._push`) | 527 ms | 14 ms | 34 ms
| 15–37× |
| `update_holes` | 53 ms | 1.1 ms | 0.9 ms | 47–61× |
| `apply_kinetic_bc` (periodic) | 264 ms | 70 ms | 71 ms | 3.7× |
| `mpi_sort_markers` (1 rank) | 1277 ms | 507 ms | 519 ms | 2.5× |
| weights of valid markers (`particles.weights`) | 134 ms | 40 ms | 43
ms | 3.1–3.3× |
| binning `e1_v1` | 2728 ms | 2057 ms | 2311 ms | 1.2–1.3× |
| `push_eta_stage` (RK4 pusher) | 2685 ms | 2021 ms | 1985 ms | 1.3× |
| `push_vxb_analytic` (pusher) | 6213 ms | 4977 ms | 4861 ms | 1.25–1.3×
|
| `charge_density_0form` (accumulation) | 4073 ms | 3087 ms | 3231 ms |
1.25–1.3× |
| all quantities of the valid markers (`markers_wo_holes_and_ghost`) |
225 ms | 385 ms | 392 ms | 0.6× |

Caveats:

- The machine was heavily loaded during all runs (load average 210–265
from other jobs), so the absolute numbers are inflated and noisy.
- The operations on single quantities gain an order of magnitude or
more, far beyond the noise.
- The 1.25–1.35× on the kernel-bound rows (pushers, accumulation) should
not be read as a kernel speedup until it is measured on a quiet node.
The issue's quiet single-thread microbenchmark found kernel time
unchanged within ±3%.
- Gathering all quantities of the valid markers is about 1.7× slower, as
expected: a marker now touches 26 separate rows. The hot paths above no
longer do it. It is still used by
`markers_wo_holes_and_ghost`/`markers_wo_holes` and by the saved-markers
output.
- With the `order="F"` version, the valid markers after all timed calls
were bit-for-bit equal to `devel` (`--save-markers`, which still writes
one row per marker).
- **Not measured yet:**
- The GPU, where most of the gain is expected (preamble, `update_holes`,
`valid_mks` masks, diagnostics). It waits for the first H100 run (#687):
`CUNUMPY_BACKEND=cupy python
src/struphy/pic/tests/bench_marker_layout.py`.
  - CPU with OpenMP on a compute node.
- SPH neighbour loops, whose per-marker access gets more expensive with
this layout (see the issue).

**Model-specific changes:**

None beyond indexing: every model uses the new layout through
`Particles`. User callbacks evaluated at markers (density, temperature
or perturbation functions in params files that accept a single 2D array)
now receive `(3, n)`; the example params files are updated.

**Documentation changes:**

- "Marker array layout" section in the developer docs (`dev-pic.rst`,
automodule of `struphy.pic.marker_layout`).
- The marker-layout question in `CUDA_STRATEGY.md` is marked as decided,
with the new convention. The kernel and argument docstrings describe the
layout.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
spossann pushed a commit that referenced this pull request Oct 21, 2026
**Stack:** part 2 of 2, based on #774 (merge that first). This PR's diff
includes the commits of #774 until it is merged. #774 changed the marker
buffer to one column per marker (`(n_cols, n_markers_max)`, `markers[j,
ip]`), and this branch is merged with it, so "marker slot" below means a
column of the buffer. Following #774, the names count markers, not rows:
the bound is `n_markers_used` (was `n_rows_used`), counted by
`count_markers_used` and tracked by `_track_markers`. Both branches are
also merged with the current `devel`, including the kernel-based box
sorting of #736.

**Solves the following issue(s):**

Closes #707 together with #774. This is part 2 of the issue: launches
and per-quantity operations cover only the marker slots that can hold
markers, not the trailing holes of the buffer.

**Core changes:**

The marker buffer has room for `(1 + bufsize) * Np` markers
(`n_markers_max` slots), and every kernel and per-quantity operation ran
over all of them. `Particles.n_markers_used` is now a bound with the
invariant that **every marker slot from it on is a hole**, and the
per-marker work stops there:

- **Kernels:**
- `args_markers.n_markers` holds the bound, so the pyccel kernels loop
over it and the CUDA kernels return after it.
- The 15 accumulation kernels that looped over the full marker count
from the array shape now use `args_markers.n_markers`.
  - `Pusher` and the accumulators launch `n_markers` threads.
- **Python operations on `markers[:, :n_markers_used]`:**
- the pusher preamble (`prepare_push` gets the sliced `Array2D` view on
the GPU);
  - the residual check of the iterative pushers;
  - `update_holes`;
- the boundary conditions (`_find_outside_particles` and the snapshot of
the positions);
- the alpha-weighted sorting positions of `mpi_sort_markers` (the first
`n_markers_used` columns of the `(3, n_markers_max)` helper arrays).

**Keeping the bound without reading the device:**

- New markers always fill the lowest holes (`nonzero` of the hole mask,
ascending). So `k` incoming markers end below `(occupied slots) + k`,
where `_n_occupied_bound` is an upper bound on the occupied slots.
- The sent, received and removed counts are already host integers:
`send_inds.size`, the NumPy `recv_info`, and the lost-marker counts of
the boundary conditions. So `_track_markers` updates the bound on the
host. On the CuPy backend the time loop gets no new device-to-host
transfer, which the transfer budget of the GPU end-to-end tests checks.
- **Where the bound is updated:**
- **counted exactly** with `count_markers_used` (reads row 0; one device
read on CuPy): after loading (fresh, external or restart; restart files
can have markers in any slot) and after box sorting (`do_sort`, also
with the box kernels of #736), which moves holes anywhere. Code that
writes markers into holes itself, outside `Particles`, must call it too;
it is public for that reason.
- **tracked from host counts:** the MPI exchange, removal at the
boundary, and the SPH ghost-box exchange (own copy and receives);
- **unchanged on purpose:** holes created without a count (removed ghost
particles) leave the bound too high, which is safe.

**Fix included (also on #774):**
`test_verif_LinearVlasovAmpereOneSpecies::test_delta_f_initial_condition`
checked the mean of the delta-f weights per rank, assuming every rank
holds a random subset of the markers.

- The markers are sorted into the domain of their rank, so on four ranks
each rank holds a quarter of the `cos(kx)` perturbation: mean/rms =
(2/π)/(1/√2) ≈ 0.9, exactly the measured 1.25e-7/1.39e-7.
- The sums are now reduced over the clone's communicator.
- The test fails on four ranks without any of these changes, but testmon
had not selected it on recent PRs (0 of 3184 verification tests selected
on `726-sorting-boxes-cupy`). Touching `pic/base.py` in #774 selected it
in the `domain cloning in Container` job.

**Tests:**

- `test_markers_used` (in `test_marker_layout.py`), run on NumPy and on
the fake CuPy, on 1 and 2 ranks. It uses removing boundaries in eta1,
pushes that move markers out, and MPI sorting. It checks:
- after every step, that every marker slot after `n_markers_used` is a
hole and that `args_markers.n_markers` matches;
- that the final markers (by ID) and the number of lost markers are
bit-for-bit those of the same run with every slot counted as used.
- `bench_marker_layout.py` (from #774): before the merge with the new
layout, the valid markers after all timed operations were bit-for-bit
those of `devel`. With 1M markers there are 1,251,008 slots, of which
`n_markers_used` = 1,000,000.
- Run locally: the new test and the verification test above, and the
marker-layout tests after the renaming. The merges with #774 and `devel`
were resolved without running the suite locally; CI ran on the merged
state, where `test_markers_used` called the wrong device-context helper
after the merge (fixed).

**Benchmark:**

- The work of every restricted operation scales with `n_markers_used /
n_markers_max`. That is 0.8 for the default `bufsize = 0.25` right after
loading, and about 0.5 for `bufsize = 1` (the 8M-slot buffer for 4M
markers in the issue's trace).
- I could not measure it here. `bench_marker_layout.py
--all-markers-used` compares the same build with and without the bound.
I ran it twice, alternating, but the machine had a load average of about
500 from other jobs, and operations this PR does not touch varied by up
to 15× between runs. So I don't claim a number.
- It needs a quiet node or the GPU (#687):
  - `bench_marker_layout.py --all-markers-used --json all.json`
  - `bench_marker_layout.py --json used.json`
  - `bench_marker_layout.py --compare all.json used.json`

**Model-specific changes:**

None.

**Documentation changes:**

`Particles.n_markers_used` and the tracking (`_track_markers`,
`count_markers_used`) are documented in their docstrings.

🤖 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

CUDA CUDA kernels and the CUDA porting (see #650) GPU Running Struphy on GPUs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants