Repository navigation
Process only the columns that can hold markers, part 2 of #707 - #775
Merged
Merged
Conversation
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
force-pushed
the
707-markers-rows-used
branch
from
October 14, 2026 21:11
66ad9e3 to
e432350
Compare
…-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>
Member
Author
|
Benchmark of this PR, on top of #774 (setup as in my comment there: M1 MacBook Air, NumPy, 1 rank, one thread, The
Against the old row-major layout (devel) the two PRs together give 16× on the preamble, 9.5× on |
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>
spossann
approved these changes
Oct 16, 2026
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>
This was referenced Oct 16, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 isn_markers_used(wasn_rows_used), counted bycount_markers_usedand tracked by_track_markers. Both branches are also merged with the currentdevel, 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) * Npmarkers (n_markers_maxslots), and every kernel and per-quantity operation ran over all of them.Particles.n_markers_usedis now a bound with the invariant that every marker slot from it on is a hole, and the per-marker work stops there:args_markers.n_markersholds the bound, so the pyccel kernels loop over it and the CUDA kernels return after it.args_markers.n_markers.Pusherand the accumulators launchn_markersthreads.markers[:, :n_markers_used]:prepare_pushgets the slicedArray2Dview on the GPU);update_holes;_find_outside_particlesand the snapshot of the positions);mpi_sort_markers(the firstn_markers_usedcolumns of the(3, n_markers_max)helper arrays).Keeping the bound without reading the device:
nonzeroof the hole mask, ascending). Sokincoming markers end below(occupied slots) + k, where_n_occupied_boundis an upper bound on the occupied slots.send_inds.size, the NumPyrecv_info, and the lost-marker counts of the boundary conditions. So_track_markersupdates 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.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, outsideParticles, must call it too; it is public for that reason.Fix included (also on #774):
test_verif_LinearVlasovAmpereOneSpecies::test_delta_f_initial_conditionchecked the mean of the delta-f weights per rank, assuming every rank holds a random subset of the markers.cos(kx)perturbation: mean/rms = (2/π)/(1/√2) ≈ 0.9, exactly the measured 1.25e-7/1.39e-7.726-sorting-boxes-cupy). Touchingpic/base.pyin Store the marker buffer column-major (struct-of-arrays), part 1 of #707 #774 selected it in thedomain cloning in Containerjob.Tests:
test_markers_used(intest_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:n_markers_usedis a hole and thatargs_markers.n_markersmatches;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 ofdevel. With 1M markers there are 1,251,008 slots, of whichn_markers_used= 1,000,000.develwere resolved without running the suite locally; CI ran on the merged state, wheretest_markers_usedcalled the wrong device-context helper after the merge (fixed).Benchmark:
n_markers_used / n_markers_max. That is 0.8 for the defaultbufsize = 0.25right after loading, and about 0.5 forbufsize = 1(the 8M-slot buffer for 4M markers in the issue's trace).bench_marker_layout.py --all-markers-usedcompares 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.bench_marker_layout.py --all-markers-used --json all.jsonbench_marker_layout.py --json used.jsonbench_marker_layout.py --compare all.json used.jsonModel-specific changes:
None.
Documentation changes:
Particles.n_markers_usedand the tracking (_track_markers,count_markers_used) are documented in their docstrings.🤖 Generated with Claude Code