Skip to content

Store the marker buffer column-major (struct-of-arrays), part 1 of #707 - #774

Merged
max-models merged 13 commits into
develfrom
707-markers-soa
Oct 16, 2026
Merged

max-models merged 13 commits into
develfrom
707-markers-soa

Conversation

@max-models

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

Copy link
Copy Markdown
Member

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 Fuse the pusher preamble into one CUDA kernel #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 (Set up sorting boxes on the CuPy backend #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 Set up sorting boxes on the CuPy backend #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 (First GPU run of the CUDA tests (H100) #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

The marker buffer Particles.markers keeps its shape (n_rows, n_cols) and its
indexing, but is now allocated in Fortran order, so every column is one
contiguous block. Column operations (the pusher preamble, hole detection,
boundary conditions, masks in the diagnostics) touch only the columns
involved, and on the GPU neighbouring threads read neighbouring addresses.

- struphy.pic.marker_layout: MARKER_ORDER, marker_array, as_marker_array,
  check_marker_layout; n_rows is padded to a multiple of 32 so that every
  column starts on a 256-byte boundary.
- pyccel: MarkerArguments.markers and the 16 kernels that take the marker
  buffer declare float[:, :](order=F); pyccel rejects a C-ordered array.
- CUDA: CudaMarkerArguments takes the F-contiguous buffer; prepare_push takes
  an Array2D view (a raw pointer argument is made C-contiguous by the launch)
  and numbers its threads column by column.
- Geometry and spline evaluation at markers convert caller arrays (row
  gathers are C-ordered) to the marker layout.
- Binning, the box count of the sorting boxes, the DKEA magnetic energy and
  two emptiness checks gather only the columns they use instead of whole rows.
- Restart: a file with another number of rows (padding) is loaded row by row;
  extra rows must be holes. The HDF5 layout on disk is unchanged.
- VlasovAmpereCoupling: the info output used a non-existent
  particles.bufferindex; it reads the saved velocities now.
- Tests: test_marker_layout.py (NumPy, fake CuPy, 1 and 2 ranks), an emulation
  test of prepare_push, column-major markers in the test helpers;
  bench_marker_layout.py times the layout-dependent operations.

Part 1 of #707 (part 2, tracking the range of used rows, is a separate PR).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@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 and others added 2 commits October 14, 2026 21:39
… noise

pyccel checks order=F by the strides (strides[0] <= strides[1]), not by the
F-contiguous flag: a single row in C order is F-contiguous for NumPy but has
decreasing strides, and was passed through unchanged by as_marker_array
(domain evaluation at one point, SPH boundary mirroring). has_marker_layout
now checks the strides too, and as_marker_array copies in that case.

The noise of PushRandomDiffusion was a copy of markers[:, :3] and kept the
column-major order, but the kernel declares it C-ordered.
get_kinetic_energy_particles converts the markers it is given.

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

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 more than two ranks each rank
holds a part of the cos(kx) perturbation with a net charge of its own
(mean/rms = (2/pi)/(1/sqrt(2)) = 0.9 on four ranks). The sums are now reduced
over the clone's communicator; each clone holds a random subset of all
markers. The test failed on four ranks before this branch as well; testmon
had not selected it on recent PRs.

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

Copy link
Copy Markdown
Member Author

Note this very important caveat: "The machine was heavily loaded during all runs (load average 210–265 from other jobs), so the absolute numbers are inflated and noisy.". So the results in the table should not really be trusted.

@max-models
max-models requested a review from spossann October 15, 2026 08:28
@max-models

Copy link
Copy Markdown
Member Author

Benchmark of this PR against the old row-major layout, on a quiet-ish machine (the table in the description was measured at load average 200+).

Setup: M1 MacBook Air (8 cores), NumPy, 1 rank, OMP_NUM_THREADS=1, bench_marker_layout.py with 1M markers (1,251,000 rows × 26 columns), cubic splines, 8³ cells. Three builds in separate worktrees: devel at 11adb7b (before), this branch at db4a1d9, and #775. 3 rounds, alternating the builds, median of the 3 runs (each run is the median of 5 calls). Three Python processes from another session kept running at 100% CPU throughout, so expect about 10–20% noise; the round 1 (load 14) and round 3 (load 5) speedups agree to within a few percent.

Operation Before (C) This PR (F) Speedup
preamble (column assignments of Pusher._push) 72.6 ms 4.3 ms 16.7×
update_holes 7.2 ms 1.0 ms 7.2×
apply_kinetic_bc (periodic) 31.3 ms 10.1 ms 3.1×
mpi_sort_markers (1 rank) 165 ms 77.5 ms 2.1×
weights of valid markers 16.8 ms 7.4 ms 2.3×
push_eta_stage (RK4) 358 ms 282 ms 1.27×
push_vxb_analytic 806 ms 733 ms 1.1×
binning e1_v1 348 ms 312 ms 1.1×
charge_density_0form 478 ms 481 ms 1.0×
full rows of valid markers (markers_wo_holes_and_ghost) 29 ms 49 ms 0.6×

Summed over all ten operations: 2312 ms → 1957 ms (1.2×).

  • The big gains are on the operations that touch single columns. The pushers gain 10–30%, because their kernels spend most of the time in spline evaluation, not in memory traffic on the markers. The accumulation does not change.
  • The one regression is gathering whole rows (markers[valid_mks]), as expected for column-major storage. Row gathers that remain are in diagnostics, not in the time loop.

max-models and others added 6 commits October 15, 2026 17:18
Instead of storing the (n_rows, n_cols) marker buffer in Fortran order, the
buffer is now a plain C-ordered array of shape (n_cols, n_rows), one column
per marker, indexed markers[j, ip]. The memory layout (struct-of-arrays) is
the same, but no array needs an order flag: the pyccel kernels declare a
plain float[:,:] again and index markers[j, ip], with the marker count
shape(markers)[1]; the CUDA kernels index markers(j, ip).

marker_layout keeps ROW_ALIGNMENT/padded_n_rows and marker_array(n_cols,
n_rows); MARKER_ORDER, as_marker_array, has_marker_layout and
check_marker_layout are removed. MarkerArguments/CudaMarkerArguments take
n_markers = markers.shape[1] (C-contiguous again on the CUDA side).

cunumpy infers the CUDA launch size from the first axis of the first array,
which is now n_cols: pushers and accumulators pass
n_threads=args_markers.n_markers, the geometry and spline marker kernels
markers.shape[1]. The pusher preamble and prepare_push work on rows.

The random-diffusion noise is (3, n_rows); the symmetric sobol sampling
kernels fill (3 + vdim, n) arrays. Orbit post-processing keeps its (n, q)
arrays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Particles.markers is allocated as (n_cols, n_rows) and indexed
markers[j, ip]. The properties follow: markers_wo_holes(_and_ghost) are
(n_cols, n), positions (3, n), velocities (vdim, n), phasespace_coords,
f_coords and f_jacobian_coords (k, n); the setters check these shapes.

Hole/ghost masks read rows 0 and -1, marker selections are markers[:, sel],
loading writes columns (pseudo-random markers are still drawn particle by
particle, so the random stream is unchanged), and _is_on_domain_of_rank,
the sorting etas and the bc buffer are (3, n).

MPI: the send lists, received buffers and the SPH ghost-box arrays are
(n_cols, n). Selecting columns gives a Fortran-ordered copy, which MPI would
send marker by marker into C-ordered receive buffers, so the send lists and
the ghost-box markers are made C-contiguous. The device exchange receives
into one buffer per source rank (a slice of a common buffer would not be
contiguous).

External marker files and restart data have the layout of the buffer,
(n_cols, n).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Evaluation at markers (a single 2D array) now takes one column per point,
eta1, eta2, eta3 in rows 0 to 2, so the marker buffer and its selections go
in without a transpose: Domain (metric coefficients, pull/push/transform,
inside_logical_cube, show), SplineFunction and _flag_pts_not_on_proc, the
GVEC equilibria, the kinetic-background moments and B0 callables, and the
canonical Maxwellian (coords[0] is (5, n)). Output shapes are unchanged.

Domain and SplineFunction make the input C-contiguous for the kernels (no
copy for the buffer or its rows). The user callbacks of the examples read
etas[0][i]; orbit post-processing passes its (n, 3) arrays transposed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The propagators, model diagnostics and scalars index the marker buffer as
markers[j, ip] and reduce over axis 0 (velocities are (vdim, n)). The saved
markers are (n_cols, n_to_save), so the hdf5 marker output and the restart
data are (nt, n_cols, n); post-processing of the orbits reads them in that
layout and keeps its (n, q) orbit products.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tests build and index marker arrays with one column per marker and pass
(k, n) arrays to the marker evaluation. test_marker_layout checks the new
contract: the buffer is C-ordered (n_cols, n_rows) with n_rows a multiple
of 32, selections of markers are Fortran-ordered copies that kernels accept
after ascontiguousarray, and evaluation on the buffer and on selections
agrees. The CUDA parity cases pass n_threads explicitly. test_mhd_equils
checked the eta1 case with kind="markers" left over from the previous case.

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

@spossann spossann left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks really nice this way! Please update the description before merging.

max-models and others added 2 commits October 16, 2026 00:00
Conflict in Particles: devel counts the markers per box in the new box
kernels (#736) instead of a bincount in Python; taken from devel. The
sorting kernels it adds (count_particles_in_boxes, find_holes) and the
docstrings of the box kernels use the marker layout of this branch (one
column per marker), as do the new box checks in test_sorting and
test_sorting_device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The exact radial profile for the binning plots evaluated equil.absB0 at
an (n, 3) array of points; marker evaluation takes one column per point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
spossann
spossann previously approved these changes Oct 16, 2026
max-models and others added 2 commits October 16, 2026 10:08
With one column per marker, n_rows (the number of marker slots, the second
axis of the buffer) no longer counts rows. Renamed after its meaning:
Particles.n_rows -> n_markers_max, padded_n_rows -> padded_n_markers and
ROW_ALIGNMENT -> MARKER_ALIGNMENT; local marker counts are n_markers.
n_cols stays the number of quantities per marker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@max-models
max-models merged commit 1e19223 into devel Oct 16, 2026
28 checks passed
@max-models
max-models deleted the 707-markers-soa branch October 16, 2026 09:45
max-models added a commit that referenced this pull request Oct 16, 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>
max-models added a commit that referenced this pull request Oct 16, 2026
devel stores the marker buffer as (n_cols, n_markers) on both backends (#774), so the column-major
opt-in of the GPU stack is gone.

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.

2 participants