Skip to content

[gpu-1] End-to-end GPU test of VlasovAmpereOneSpecies on 1 and 2 ranks - #734

Merged
max-models merged 37 commits into
cuda-namespacesfrom
gpu-1-vlasov-ampere-e2e
Oct 13, 2026
Merged

max-models merged 37 commits into
cuda-namespacesfrom
gpu-1-vlasov-ampere-e2e

Conversation

@max-models

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

Copy link
Copy Markdown
Member

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:

  • New test 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 an e1_v1 binning plot so that the diagnostics run on the device too. It compares the energies, the e_field and phi coefficients, and the markers by ID (tolerances explained in compare_runs), and checks the transfer budget of the time loop.
  • Shared helpers in 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.py now uses them.
  • The GPU tests also run without a GPU. On cunumpy's fake CuPy (CUNUMPY_FAKE_CUPY=1), every CUDA launch is emulated on the CPU and CudaKernel.compile is skipped. Simulation.compile_cuda_kernels still checks that every kernel of the time loop has a CUDA version. Without a GPU and without the fake CuPy, test_fake_cupy_matches_numpy runs the comparison in a serial child process.
  • Transfer budget of 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.
  • Bug fix (every backend): VlasovAmpereCoupling updates the ghost regions of the mid-point field before the push. The pusher kernel reads _e_sum._data including its ghost regions, and _e_sum is 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 1 rank 2 ranks
test_gpu_VlasovAmpereOneSpecies.py (NumPy reference + CuPy vs NumPy + transfer budget) passed passed
test_gpu_LinearVlasovAmpereOneSpecies.py::test_cupy_matches_numpy_without_transfers passed not run

Transfers in the time loop of the CuPy run:

  • integrate: one 8-byte residual norm per pcg iteration.
  • Diagnostics: one 24-byte copy of the three scalars per update.
  • Output: one copy per saved dataset.

Diagnostics: no new CUDA kernels are needed for this model. The scalars use xp reductions and feectools' device inner products, and binning uses xp.histogramdd.

Found along the way (not changed here): now #766 (Poisson stabilization) and #767 (energy diagnostic with the control variate).

  • The initial Poisson solve with the default stab_eps=1e-14 puts (Monte-Carlo net charge) / 1e-14 into the constant mode of phi. That makes phi about 1e14, so E = -grad(phi) loses about 5% to cancellation on either backend. The verification tests use this default. The new test uses stab_eps=1e-6, as Fix sobol random numbers on cupy #719 does.
  • With control_variate=True, kinetic_energy at t=0 is computed with the full-f weights and afterwards with the delta-f weights, so total_energy jumps 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):

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

max-models and others added 14 commits October 7, 2026 18:43
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>
Stack #703 -> #706 -> #716: feectools submodule points to the head of
struphy-hub/feectools#98, which now contains #96 and #97.

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>
max-models and others added 13 commits October 9, 2026 12:35
…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>
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>
@max-models
max-models added this pull request to stack #729 October 12, 2026 12:10

@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.

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

  1. The ghost-region update in VlasovAmpereCoupling is a bug fix on every backend, not only on CuPy. The pusher reads _e_sum._data including the ghost regions, and += does not sync them. Please list it under bug fixes in the description and changelog.
  2. Particles._allocate_marker_array now runs a collective probe (_mpi_cuda_aware). That is safe for the current callers (__init__ and the lazy n_rows), but please add a comment that all ranks of mpi_comm must call it, so that a future per-rank reallocation doesn't hang.
  3. feec/tests/test_preconditioner_cupy.py re-implements cunumpy.kernel_testing.emulated_launches (_emulated_launches, _host_buffer, _HostFields, which read fake-CuPy internals via object.__getattribute__(value, "_a")). gpu_e2e.device_context already uses the cunumpy version. That file also starts its subprocess on every rank under mpirun, instead of run_fake_cupy_child, which runs only on rank 0 and was added to avoid exactly the concurrent-children crash.

Minor

  1. FFTSolver is host-only now (_space = np.ndarray); an assert isinstance(rhs, np.ndarray) in solve would make a device caller fail loudly instead of crashing deep inside.
  2. 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 in CUDA_STRATEGY.md.
  3. The two findings in the description (stab_eps=1e-14 puts phi ≈ 1e14; total_energy jumps with the control variate because t=0 uses full-f weights) deserve their own issues.

🤖 Generated with Claude Code

Comment thread src/struphy/feec/preconditioner.py Outdated
spossann and others added 2 commits October 13, 2026 09:09
…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>
@spossann

Copy link
Copy Markdown
Member

@max-models I pushed two commits to this branch to address my review:

Verified locally on Linux (Open MPI 4.1.6):

  • e2e: serial, and 2 ranks on NumPy, pass (3 passed, 2 skipped); fake CuPy on 1 and 2 ranks passes (4 passed, 1 skipped);
  • full shard-2 on 2 ranks: 396 passed;
  • -m mpi_pic pic/tests on 2 ranks: 41 passed;
  • preconditioner and mass-matrix tests: 66 serial, 100 on 2 ranks.

I also updated the description: the ghost-region update in VlasovAmpereCoupling is listed as a bug fix on every backend, and the two side findings are now #766 and #767.

Still yours: please check that the changes fit your plans for gpu-2.

🤖 Generated with Claude Code

@max-models
max-models merged commit 137a8e5 into devel Oct 13, 2026
28 checks passed
@max-models
max-models deleted the gpu-1-vlasov-ampere-e2e branch October 13, 2026 12:43
spossann pushed a commit that referenced this pull request Oct 13, 2026
…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>
spossann pushed a commit that referenced this pull request Oct 13, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Run VlasovAmpereOneSpecies end to end on the GPU

2 participants