Repository navigation
[gpu-1] End-to-end GPU test of VlasovAmpereOneSpecies on 1 and 2 ranks - #734
Conversation
Moves the feectools submodule to struphy-hub/feectools#96 (branch device-kronecker-solve): KroneckerLinearSolver solves device data with dense inverses of the 1D matrices (one GEMM per direction) instead of copying it to the host for LAPACK/SuperLU, so MassMatrixPreconditioner solves on the CuPy backend make no host/device transfers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Point the feectools submodule at struphy-hub/feectools#97 (branch stencil-6d-views): the 3D stencil dot/transpose kernels take the matrix data as 6D views, and StencilMatrix.dot/vdot/transpose no longer raise for matrices with fewer than 2p+1 diagonals. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Move the feectools submodule to struphy-hub/feectools#98: on the CuPy backend inner products return 0-d device arrays and the Krylov solvers copy one scalar per iteration to the host (the convergence test). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The mass-matrix preconditioners could not be created on the CuPy backend: the assembled 1d mass matrices come back from StencilMatrix.toarray() as host (NumPy) arrays, and is_circulant/FFTSolver asserted xp.ndarray (cupy.ndarray on CuPy). The stencil-matrix fill also mixed xp.nonzero with host arrays. The 1d matrices and solvers are now host setup data on every backend (what KroneckerLinearSolver expects, and what feectools#96 builds its device inverses from); only the process-local 1d stencil matrices of the KroneckerStencilMatrix are copied to the device once. The duplicated 1d setup of both preconditioners moves into _solver_and_local_matrix_1d. FFTSolver accepts host or device matrices and solves device right-hand sides on the host. The NumPy path computes the same as before. Adds a fake-CuPy test (subprocess, CUDA launches emulated on the CPU) comparing both preconditioners for M0/M1/M2 (periodic and clamped) with NumPy, and the same check on a GPU. Part of #689 and #650. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run a small LinearVlasovAmpereOneSpecies case (periodic Cuboid, 8 elements, 320 Sobol markers, 3 steps) on NumPy and CuPy, compare energies, E-field coefficients and markers, and count the host/device transfers of the time loop per phase (none in model.integrate, scalar-sized downloads in the diagnostics, one download per saved dataset in the output). GPU-only part is marked requires_cupy; the NumPy reference runs everywhere and under MPI. Sobol marker loading now generates on the host and copies the result to the active backend: sobol_seq is a scalar loop and passed lists to cupy.transpose. Part of #689. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…6d-views Stack #703 -> #706: feectools submodule points to the head of struphy-hub/feectools#97, which now contains #96. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- New test_gpu_VlasovAmpereOneSpecies.py: a small Landau-damping run (full-f weights, e1_v1 binning plot) on NumPy and on CuPy; compares energies, E and phi coefficients and markers, and checks the transfer budget of the time loop. Passes on the fake CuPy with emulated CUDA launches on 1 and 2 ranks. - Shared helpers in models/tests/gpu_e2e.py (transfer budget, instrumentation, gathering the markers, device context); test_gpu_LinearVlasovAmpereOneSpecies.py uses them. On the fake CuPy every CUDA launch is emulated and CudaKernel.compile is skipped, so both tests now also run without a GPU (CUNUMPY_FAKE_CUPY=1, or a serial child process). - Transfer budget of model.integrate: only scalar downloads (the residual norm of each Krylov iteration, which feectools copies for its convergence test), no arrays or host kernels. - Particles: probe whether MPI is CUDA-aware when the marker array is allocated (setup), not on the first marker exchange inside the first time step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ss-preconditioner-cupy Conflict in src/struphy/feec/preconditioner.py: #731's refactor (KroneckerPreconditioner base class, module-level helpers, StiffnessPreconditioner) is kept; the CuPy changes of this branch are re-applied on top of it: - _kronecker_approximation, _solver_1d, is_circulant, FFTSolver: the dense 1d matrices and the 1d solvers are host (NumPy) setup data on every backend. KroneckerLinearSolver (feectools#96, in 0.7.0) solves device data with dense inverses built from these host solvers, so FFTSolver no longer needs the host round trip of the previous version of this branch. - _process_local_matrix_1d: band filled on the host, copied to the factor's data in one step; StiffnessPreconditioner passes its host 1d matrices directly. - _local_diagonal: outer product by broadcasting instead of ufunc.outer (not available on every backend), so diagonal scaling (MassMatrixDiagonalPreconditioner) works on CuPy. test_preconditioner_cupy.py also covers weight_reduction="average" with diagonal_scaling and checks that applying the preconditioners makes no host/device transfers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # feectools
MassMatrixDiagonalPreconditioner was removed on devel (#739); the CuPy test now checks MassMatrixPreconditioner with and without diagonal scaling and with dim_reduce=None instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In a periodic direction with fewer points than the band width (2p + 1 > n, e.g. a single element in a 1D run), StencilMatrix.diagonal() returns only the entry at offset 0, while the entries at offsets k = m n also lie on the main diagonal. The diagonal D of the mass matrix was therefore wrong, and MassMatrixPreconditioner with diagonal_scaling=True (the default since #739) was no longer exact on the logical cube; in VlasovAmpereCoupling this broke energy conservation in 1D runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… before the push The pusher kernel reads the ghost regions of e_sum directly. They were only correct if the Schur solver happened to return its solution with valid ghost regions, which is no longer the case with the diagonal scaling of MassMatrixPreconditioner (default since #739): its last step is a StencilDiagonalMatrix.dot that writes only the owned entries. In 1D runs (one element in e2, e3) every evaluation reads ghost values, and the total energy was no longer conserved (drift ~1e-4 instead of ~1e-12). The other couplings already update the ghost regions before pushing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
spossann
left a comment
There was a problem hiding this comment.
Thanks, the design is solid: the transfer budget is well specified, and the tolerances in compare_runs are explained. I reran the e2e tests locally at the PR head (727083eb), in a fresh env with all kernels compiled. Linux, Open MPI 4.1.6, no GPU:
| run | result |
|---|---|
serial, no fake CuPy (test_fake_cupy_matches_numpy child) |
passed |
serial, CUNUMPY_FAKE_CUPY=1 |
passed (both models, budget included) |
mpirun -n 2, fake CuPy |
segfault on rank 1, in every test, including test_numpy_reference |
mpirun -n 2, plain NumPy |
segfault |
mpirun -n 2, fake CuPy, with an mpi_sort_markers() after draw_markers (patched in the test run only) |
passed |
Blocking
1. The 2-rank run crashes: markers are never MPI-sorted after loading when there are no sorting boxes. Particles.draw_markers calls mpi_sort_markers() only if self._initialized_sorting and sort, i.e. only with sorting boxes. Both e2e tests use SortingParameters(), so after sobol_standard loading, 80 of the 160 markers of each rank lie outside the rank's domain. The initial Poisson accumulation in post_allocate runs before the first PushEta sorts them, and writes them outside the local stencil arrays. The crash then shows in exchange_assembly_data. On macOS this may have corrupted memory without crashing, so I would not rely on the 2-rank result in the description. The bug is already on devel; see #764. It needs its own fix in draw_markers, and this PR should be rebased on that fix.
2. CI does not run the new tests. models/tests/test_gpu_*.py are top-level files in models/tests/, and no workflow collects that folder: the model workflows run only default_params/ and verification/, and the unit shards don't include models/. The green CI therefore says nothing about this PR's main deliverable. Please add them like the existing fake-CuPy lines in test-PR-unit-mpi.yml (mpirun -n 2 env CUNUMPY_FAKE_CUPY=1 pytest --with-mpi models/tests/test_gpu_*.py), plus a serial shard and console/test.py. A 2-rank job would have caught point 1.
3. MassMatrixPreconditioner._core_diagonal breaks its contract. The base class documents it as "diagonal D of the core operator as diagonal operator", and StiffnessPreconditioner still returns one. It now returns the full mass matrix. That works today only because the single caller passes it to _local_diagonal; the next caller who trusts the name and calls .dot gets M instead of D, with no error. Keep the wrap-around fix and the contract:
def _core_diagonal(self):
# diagonal of the mass matrix, including the band entries that wrap onto the main diagonal
# (StencilMatrix.diagonal() keeps only the entry at offset 0)
M = self._mass_operator.matrix
return _diagonal_operator(M.domain, _local_diagonal(M))_stencil_local_diagonal itself looks right to me: the band centre is at p for shift 1, and the sum over wrapped offsets is correct for a tensor product. The underlying gap is in feectools' StencilMatrix.diagonal(), which affects the Jacobi preconditioners there as well; that is worth a feectools issue.
Should fix
- The ghost-region update in
VlasovAmpereCouplingis a bug fix on every backend, not only on CuPy. The pusher reads_e_sum._dataincluding the ghost regions, and+=does not sync them. Please list it under bug fixes in the description and changelog. Particles._allocate_marker_arraynow runs a collective probe (_mpi_cuda_aware). That is safe for the current callers (__init__and the lazyn_rows), but please add a comment that all ranks ofmpi_commmust call it, so that a future per-rank reallocation doesn't hang.feec/tests/test_preconditioner_cupy.pyre-implementscunumpy.kernel_testing.emulated_launches(_emulated_launches,_host_buffer,_HostFields, which read fake-CuPy internals viaobject.__getattribute__(value, "_a")).gpu_e2e.device_contextalready uses the cunumpy version. That file also starts its subprocess on every rank undermpirun, instead ofrun_fake_cupy_child, which runs only on rank 0 and was added to avoid exactly the concurrent-children crash.
Minor
FFTSolveris host-only now (_space = np.ndarray); anassert isinstance(rhs, np.ndarray)insolvewould make a device caller fail loudly instead of crashing deep inside.- On the fake CuPy, the setup falls back to host kernels for the mass-matrix assembly (
kernel_1d_mat,kernel_3d_vec,RuntimeWarning: No CUDA version of kernel). This is outside the time loop, so the budget is unaffected, but worth listing as open work inCUDA_STRATEGY.md. - The two findings in the description (
stab_eps=1e-14putsphi≈ 1e14;total_energyjumps with the control variate because t=0 uses full-f weights) deserve their own issues.
🤖 Generated with Claude Code
…g boxes (#764) On more than one process, draw_markers MPI-sorted the markers only if sorting boxes were set up. Without boxes, the markers drawn on the whole domain stayed on the wrong process until the first push, and accumulations before it (e.g. the initial Poisson solve of VlasovAmpereOneSpecies) wrote outside the local stencil arrays (segfault on 2 ranks). Now draw_markers always MPI-sorts on several processes; sorting into boxes is unchanged. On one process the behaviour is unchanged. New MPI test test_draw_without_boxes_on_rank_domain (pseudo_random, sobol_standard, sobol_antithetic; sort=True/False): fails on 2 ranks without the fix. Closes #764. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…al contract - CI: the end-to-end GPU tests run in unit shard-2 (serial and 2 ranks, both workflows and `struphy test unit`) and on the fake CuPy on 1 and 2 ranks in test-PR-unit-mpi.yml (8 elements of degree 3 allow at most 2 ranks). - MassMatrixPreconditioner._core_diagonal returns a diagonal operator again (with the band entries that wrap onto the diagonal), as documented in the base class. - test_preconditioner_cupy: use cunumpy's emulated_launches and run_fake_cupy_child (rank 0 only) instead of local copies; test_mass_preconditioners_on_cupy emulates its launches on the fake CuPy instead of trying to compile CUDA. - FFTSolver.solve asserts host input; comment on the collective probe in _allocate_marker_array. - CUDA_STRATEGY.md: CI coverage, #764, host kernels left in the setup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The end-to-end tests run on 2 ranks without sorting boxes and need the markers MPI-sorted after drawing (#764). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@max-models I pushed two commits to this branch to address my review:
Verified locally on Linux (Open MPI 4.1.6):
I also updated the description: the ghost-region update in Still yours: please check that the changes fit your plans for gpu-2. 🤖 Generated with Claude Code |
…s, control variate) (#738) ## Problem Found while writing the GPU end-to-end test of `VlasovAmpereOneSpecies` (#734). Setup: `VlasovAmpereOneSpecies(alpha=1, epsilon=-1, with_B0=False)`, Cuboid `r1=12.56`, 8x1x1 elements, degree (3,1,1), `ppc=40` `sobol_standard`, Maxwellian background, `ModesCos` density perturbation of amplitude 0.1, `dt=0.1`, 3 steps, solver tolerances 1e-12, initial Poisson with `stab_eps=1e-6`. `total_energy` (`electric_energy + kinetic_energy`): | | t=0 | step 1 | step 2 | step 3 | |---|---|---|---|---| | full-f, before | 18.96461097 | 18.96766028 | 18.96766028 | 18.96766028 | | full-f, after | 18.96461097 | 18.96461097 | 18.96461097 | 18.96461097 | | control variate, before | **18.96461097** | 0.11536371 | 0.09705431 | 0.08242210 | | control variate, after | 0.12553637 | 0.10389888 | 0.08562030 | 0.07105039 | Full-f: the Crank-Nicolson coupling conserved the total to round-off from step 1 on, but step 1 itself jumped by 1.6e-4 relative. Control variate: the t=0 value was a different quantity than the later values. ## Root causes and fix 1. **Ghost regions of the initial E field.** `post_allocate` sets `e = -grad(phi)` with `derham.grad.dot(-phi, out=e)`, which does not fill the ghost regions. The pusher in `VlasovAmpereCoupling` evaluates `e_mid = (e^n + e^{n+1})/2` at the markers, ghost cells included, so in the **first** step it pushed with a wrong field near the periodic boundary. The field solve itself does not read ghost cells, so the field and the velocities were no longer consistent and energy was not conserved. From step 2 on, `e` comes from `update_feec_variables`, which syncs the ghost regions. This changes the physics of the first step, not only the diagnostic. **Fix:** `e.update_ghost_regions()` after the initial Poisson solve in `VlasovAmpereOneSpecies`, `VlasovMaxwellOneSpecies`, `ColdPlasmaVlasov` and `LinearVlasovAmpereOneSpecies`. These are all the models that write `-grad(phi)` into `e_field` this way. 2. **Control-variate weights reset after the initial Poisson solve.** `initialize_weights` already sets delta-f weights when `control_variate=True`. `post_allocate` calls `update_weights()` so that the Poisson right-hand side is the charge of `f - f0`, and then set `weights = weights0` without checking the control variate. That left full-f weights for the t=0 diagnostic. The first `PushEta` then called `update_weights()`, so from step 1 on the weights were delta-f again. **Fix:** reset to `weights0` only without the control variate. The same change is applied to the `VlasovMaxwellOneSpecies` Gauss-law diagnostic, which restored full-f weights at every scalar update. `ColdPlasmaVlasov` had the opposite problem: it never reset, so its full-f runs used delta-f weights for the whole run. It now follows the same rule. 3. **Sobol-loaded markers were not sent to their MPI process** (`pic/base.py`). With `loading="sobol_standard"`/`"sobol_antithetic"` every process draws its markers on the whole unit cube, but `mpi_sort_markers` was only called when box sorting was enabled. Without it, the first accumulation (the initial Poisson solve) wrote outside the local stencil arrays and corrupted the heap. The new `test_energy_conservation` is the only MPI test that loads Sobol markers without box sorting, and it made the next test abort the 2-rank verification job on Linux (`Fatal Python error: Aborted`). On 2 ranks, half of each rank's 3200 markers were outside its domain before the fix and none after. **Fix:** call `mpi_sort_markers()` after a Sobol draw when there is an MPI communicator and box sorting is off. With the control variate, `kinetic_energy` is now the kinetic energy of `f - f0` at all times, including t=0. This is now stated in the `KineticEnergyPIC` docstring, and it matches what `LinearMHDVlasovCC` and `ToyDrift` already did. ### Expected behaviour (no code change) With the control variate, the delta-f total energy is **not** conserved to round-off, even after this fix. The Crank-Nicolson step conserves `E_E + α²/2 Σ w_δ |v|²` with the weights frozen during the step. Afterwards `update_weights` changes `w_δ` by `-Δf0(v)/(s0 N)`, which adds two things per step: - a Monte-Carlo estimate of zero (`∝ Σ f0 (v·E)|v|²`), and - an `O(Δt²)` term: the energy the field gives to the background markers. Measured on 16 elements with `ppc=4000` up to T=0.3, the drift is 3.0e-3, 1.2e-3 and 2.7e-4 for dt = 0.1, 0.05 and 0.025. So it converges with dt, and it is a property of the scheme, not of the diagnostic. ## Affected models `VlasovAmpereOneSpecies`, `VlasovMaxwellOneSpecies` and `ColdPlasmaVlasov` (ghost regions + weights), `LinearVlasovAmpereOneSpecies` (ghost regions only; its markers are delta-f by construction). ## Tests - New `test_energy_conservation[False/True]` in `models/tests/verification/test_verif_VlasovAmpereOneSpecies.py`. It uses the setup above with 16 elements, so that the CI run on 4 ranks with clones gets at least p=3 elements per rank. ~2 s each. - Full-f, `ppc=40`: drift from t=0 must be `< 1e-10`. On devel it is 9.4e-7; after the fix it is round-off. - Control variate, `ppc=400`: drift must be `< 0.1`, for the reason above. On devel it is 0.99; after the fix it is 3.7e-3. - Both fail on devel and pass with the fix: serial, `mpirun -n 2 --with-mpi`, `mpirun -n 4 --with-mpi` and `mpirun -n 4 --with-mpi --nclones 2`. - Passing: `test_weak_Landau` (control variate), `test_verif_LinearVlasovAmpereOneSpecies.py`, `test_verif_VlasovMaxwellOneSpecies.py`, and the default-parameter runs (`test_single_model`) of `VlasovAmpereOneSpecies`, `VlasovMaxwellOneSpecies` and `LinearVlasovAmpereOneSpecies`. - Not run to completion locally: the default-parameter `ColdPlasmaVlasov` run takes more than 5 minutes on devel as well. I stopped it, so CI covers it. ## Note for #734 The GPU e2e test of `VlasovAmpereOneSpecies` in #734 currently uses full-f weights and only a loose t=0 check (`|en_tot[1] - en_tot[0]| < 1e-3`) because of these two bugs. Once this is merged, it can check `total_energy` from t=0 at solver tolerance, and it can also use the control variate. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…737) ## Problem On a domain without Dirichlet boundary conditions (e.g. fully periodic), the Poisson operator `G^T M1 G` is singular. Its kernel is the constant functions, which have coefficient vector `1` (all ones, by partition of unity). `PoissonSolve` only regularized this with `stab_eps * stab_mat`, and the default `stab_eps = 0` is bumped to `1e-14` in `allocate`. When the source has a nonzero net charge `1^T b`, the constant mode of `phi` becomes `(net charge) / stab_eps`. In PIC this always happens, because the Monte-Carlo charge density is noisy. That constant is around 1e9 to 1e13, so `E = -grad phi` loses most of its digits to cancellation. This affects the `initial_poisson` of `VlasovAmpereOneSpecies`, `VlasovMaxwellOneSpecies`, `LinearVlasovAmpereOneSpecies`, `LinearVlasovMaxwellOneSpecies` and `ColdPlasmaVlasov`. It also affects every other `PoissonSolve` user on periodic or Neumann domains (`Poisson`, `HasegawaWakatani`, `IncompressibleNavierStokesSPH`, `ToyDrift`). The verification tests use the default `stab_eps`, so they are affected too. I found this while writing the GPU end-to-end test for `VlasovAmpereOneSpecies` (#734). That test currently sets `stab_eps=1e-6` as a workaround, which can be removed once this PR is merged. ### Numbers before the fix All runs use 1D periodic `Cuboid(r1=12.56)`, degree (3,1,1) and default options. | setup | max\|phi\| | rel. error of E | |---|---|---| | analytic source `0.3 + 0.1 cos(kx)`, 16 cells, `stab_mat="Id"` | 2.4e13 | **4.6e-2** | | same, `stab_mat="M0"` | 3.0e13 | **2.7e-2** | | same, `stab_eps=1e-6` (workaround) | 3.0e5 | 4.8e-4 | | PIC, control variate, `pseudo_random`, ppc 40, 8 cells (net charge 7.9e-2) | 6.3e11 | differs by 2.7e-4 from the `stab_eps=1e-6` result | | `test_verif_VlasovAmpereOneSpecies` initial solve | 3.8e8 (coefficients) | | ### After the fix | setup | max\|phi\| | rel. error of E | |---|---|---| | analytic source, `Id` or `M0`, default `stab_eps` | 0.40 (exact: 0.40) | 4.8e-4 (discretization error) | | PIC case above | 0.48 | differs by 3.9e-6 from `stab_eps=1e-6` (that difference is the O(stab_eps) error of the workaround itself) | | `test_verif_VlasovAmpereOneSpecies` initial solve | 4.0e-3 (coefficients) | | ## Fix New option `enforce_compatibility`. It is `True` for `PoissonSolve` and `False` for `ImplicitDiffusion` and `PoissonAdiabaticGyrokinetic`, where the mass term is physical. When it is on and the operator is singular (no Dirichlet BCs and no polar splines, see `ImplicitDiffusion.operator_is_singular`), the rhs is made to satisfy the discrete compatibility condition `1^T b = 0` before the solve: ``` b <- b - (1^T b) / (1^T M0 1) * M0 1 ``` - **Physics:** `M0 1` is the weak form of a uniform density, so this adds a uniform neutralizing background. A periodic plasma is quasi-neutral, and the net Monte-Carlo charge is noise that must be discarded. - **Effect on phi:** `phi` stays O(1). `stab_eps` now only fixes the constant of `phi`, which is harmless, so its default is unchanged. With `stab_mat="M0"` the projection changes `phi` only by a constant for any `stab_eps`, so `E` is the same; the new test checks this. - **Implementation:** it uses only feectools vector operations (`inner`, which is a global MPI reduction, and `mul_iadd`), with `1` and `M0 1` precomputed in `allocate`. There are no host copies, so it should work on both cunumpy backends. The CuPy path is untested. - **Out of scope:** polar splines are conservatively treated as regular and left unchanged, because the polar coefficient vector of the constant function is not simply all ones. - **Side effect:** CG converges faster on the singular problems. In `test_poisson_2d_multigrid` (periodic, unpreconditioned) it now takes 97 iterations instead of 142. ## Tests - New `test_poisson_net_charge_periodic_1d[Id|M0]` (about 1 s). It uses a non-neutral analytic source with default options and checks: - `phi` is O(1) and matches the exact solution up to a constant; - the E error is below 1e-3; - with `enforce_compatibility=False`, `|phi| > 1e10`; - for `M0` with finite `stab_eps`, the projection only shifts `phi` by a constant. - New `test_poisson_dirichlet_not_singular`: with Dirichlet BCs the rhs is left unchanged. - The new tests also pass with `mpirun -n 2`. - Locally passing: the 40 serial tests of `test_poisson.py` before the multigrid ones; `test_poisson_2d_multigrid[periodic-degree0]` (about 3 min per run, so the rest of the multigrid set is left to CI); `test_implicit_diffusion_multigrid_dt`; the 24 `test_poisson_M1perp_1d` cases in `test_gyrokinetic_poisson.py`; `test_verif_VlasovAmpereOneSpecies.py` (48 s). - The 2D/3D gyrokinetic Poisson tests and the remaining verification tests are left to CI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Solves the following issue(s):
Part of #689, tracked in #650. This PR covers the items of #689 that are left for
VlasovAmpereOneSpecies: running on 1 and 2 ranks, no host/device transfers in the time loop, and the diagnostics it needs. It does not close #689, because these runs use the CPU emulation; the first run on a real GPU is #687.Stack: this is gpu-1 of a new stack. Merge it after #722 (
cuda-namespaces), #716, #717, #719 and #765 (MPI sort of the markers after drawing, needed for the 2-rank runs). It merges those branches in, so until they are merged, this diff also shows their commits. gpu-2 (the benchmark script) is based on this branch.Core changes:
models/tests/test_gpu_VlasovAmpereOneSpecies.py. A small Landau-damping run on NumPy and on CuPy: 8 cells, 320 Sobol markers, 3 steps, full-f weights, and ane1_v1binning plot so that the diagnostics run on the device too. It compares the energies, thee_fieldandphicoefficients, and the markers by ID (tolerances explained incompare_runs), and checks the transfer budget of the time loop.models/tests/gpu_e2e.py. The transfer budget, the instrumentation, gathering markers across ranks and the device context, moved out of Fix sobol random numbers on cupy #719's test.test_gpu_LinearVlasovAmpereOneSpecies.pynow uses them.CUNUMPY_FAKE_CUPY=1), every CUDA launch is emulated on the CPU andCudaKernel.compileis skipped.Simulation.compile_cuda_kernelsstill checks that every kernel of the time loop has a CUDA version. Without a GPU and without the fake CuPy,test_fake_cupy_matches_numpyruns the comparison in a serial child process.model.integrate. Only scalar downloads are allowed: the residual norm of each Krylov iteration, which feectools copies for its convergence test (feectools#98 keeps everything else of the solve on the device). No arrays, no host-to-device copies, no host kernels.VlasovAmpereCouplingupdates the ghost regions of the mid-point field before the push. The pusher kernel reads_e_sum._dataincluding its ghost regions, and_e_sumis built with+=from the solver output, which does not sync them. Stale ghost regions would give a wrong field near process (and periodic) boundaries.Particles. Checking whether MPI is CUDA-aware now happens when the marker array is allocated (in the setup), not on the first marker exchange. Before, that check was the only array transfer inside the time loop on 2 ranks.Results (fake CuPy, every CUDA launch emulated on the CPU; macOS; not run on a GPU):
test_gpu_VlasovAmpereOneSpecies.py(NumPy reference + CuPy vs NumPy + transfer budget)test_gpu_LinearVlasovAmpereOneSpecies.py::test_cupy_matches_numpy_without_transfersTransfers in the time loop of the CuPy run:
integrate: one 8-byte residual norm per pcg iteration.Diagnostics: no new CUDA kernels are needed for this model. The scalars use
xpreductions and feectools' device inner products, and binning usesxp.histogramdd.Found along the way (not changed here): now #766 (Poisson stabilization) and #767 (energy diagnostic with the control variate).
stab_eps=1e-14puts (Monte-Carlo net charge) / 1e-14 into the constant mode ofphi. That makesphiabout 1e14, so E = -grad(phi) loses about 5% to cancellation on either backend. The verification tests use this default. The new test usesstab_eps=1e-6, as Fix sobol random numbers on cupy #719 does.control_variate=True,kinetic_energyat t=0 is computed with the full-f weights and afterwards with the delta-f weights, sototal_energyjumps after the first step. With full-f weights the total is conserved from step to step to 1e-9, but the t=0 value still differs by about 2e-4 (not investigated).Review fixes (pushed by @spossann, see the review):
test-PR-unit-mpi.yml); merged draw_markers: send the markers to their processes also without sorting boxes #765, without which they segfault on 2 ranks (draw_markers does not MPI-sort the markers without sorting boxes (off-rank markers in setup accumulations, segfault on 2 ranks) #764);MassMatrixPreconditioner._core_diagonalreturns a diagonal operator again (with the wrapped band entries);test_preconditioner_cupy.pyuses cunumpy'semulated_launchesandrun_fake_cupy_child, and its GPU test emulates its launches on the fake CuPy;FFTSolver.solveasserts host input.Documentation changes:
CUDA_STRATEGY.md: the end-to-end entry now covers both models, the CPU emulation and the transfer budget.🤖 Generated with Claude Code