Repository navigation
Store the marker buffer column-major (struct-of-arrays), part 1 of #707 - #774
Conversation
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>
… 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>
|
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. |
|
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,
Summed over all ten operations: 2312 ms → 1957 ms (1.2×).
|
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
left a comment
There was a problem hiding this comment.
Looks really nice this way! Please update the description before merging.
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>
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>
**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>
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>
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.markersis now a plain C-ordered array of shape(n_cols, n_markers_max): one column per marker, indexedmarkers[j, ip](was(n_rows, n_cols),markers[ip, j]). Every quantitymarkers[j]is one contiguous block ofn_markers_maxvalues.Naming:
n_colskeeps 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_idxetc.).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 (pyccelfloat[:, :](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), pluspadded_n_markers/MARKER_ALIGNMENT:n_markers_maxis 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).markers[:, valid_mks]) gives a Fortran-ordered copy in NumPy, so convert it withxp.ascontiguousarraybefore passing it to a kernel or to MPI.pyccel kernels:
markers[j, ip]and declare a plainfloat[:,:](C order).shape(markers)[1], andMarkerArguments.n_markersismarkers.shape[1].CUDA:
markers(j, ip).CudaMarkerArgumentstakes the C-contiguous(n_cols, n)array again.n_cols. SoPusherand the accumulators passn_threads=args_markers.n_markers, and the geometry and spline marker kernels passmarkers.shape[1].prepare_push(the fused preamble from Fuse the pusher preamble into one CUDA kernel #708) takes anArray2D<double>view (a rawdouble*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:markers_wo_holes(_and_ghost)are(n_cols, n),positionsis(3, n),velocitiesis(vdim, n), andphasespace_coords,f_coordsandf_jacobian_coordsare(k, n). The setters check these shapes.markers[0] == -1, ghosts aremarkers[-1] == -2, and whole markers are selected withmarkers[:, sel]._is_on_domain_of_rankwork on(3, n).MPI exchange:
(n_cols, n).(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 andB0callables, and the canonical Maxwellian (coords[0]is(5, n));etas[0][i]).Output shapes are unchanged.
DomainandSplineFunctionmake 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):develis 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/<species>is(nt, n_cols, n_markers_max);kinetic/<species>/markersare(nt, n_cols, n_to_save);markersdataset of shape(n_cols, N).Restart and external files written before this change do not load.
_load_restartcopies 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
infooutput ofVlasovAmpereCouplingreadparticles.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:(n_cols, n_markers_max)withn_markers_maxa multiple of 32, and stays the same object through loading, RK4 and v×B pushes, MPI sorting, boundary conditions, evaluation at markers and binning;MarkerArgumentsand a kernel afterascontiguousarray;n_threadsexplicitly.test_sorting.pyandtest_sorting_device.pyread the box row of the markers.test_mhd_equilschecked its eta1 case withkind="markers"left over from the previous case; fixed.prepare_push/parity cases, and the geometry, feec, equilibrium, model-scalar, post-processing and output tests, plustest_verif_LinearVlasovAmpereOneSpecieson 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 ofdevel(including the new box kernels); CI covers it.Benchmark (CPU):
pic/tests/bench_marker_layout.pytimes the layout-dependent operations on the active backend. The numbers below were measured with theorder="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:develat 11adb7b.Pusher._push)update_holesapply_kinetic_bc(periodic)mpi_sort_markers(1 rank)particles.weights)e1_v1push_eta_stage(RK4 pusher)push_vxb_analytic(pusher)charge_density_0form(accumulation)markers_wo_holes_and_ghost)Caveats:
markers_wo_holes_and_ghost/markers_wo_holesand by the saved-markers output.order="F"version, the valid markers after all timed calls were bit-for-bit equal todevel(--save-markers, which still writes one row per marker).update_holes,valid_mksmasks, 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.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:
dev-pic.rst, automodule ofstruphy.pic.marker_layout).CUDA_STRATEGY.mdis marked as decided, with the new convention. The kernel and argument docstrings describe the layout.🤖 Generated with Claude Code