Repository navigation
Kronecker solver on the device (no host copies in CuPy solves) - #96
Merged
Merged
Conversation
KroneckerLinearSolver copied the data of every direction to the host and back in every solve on the CuPy backend, because its 1D solvers (BandedSolver: LAPACK gbtrs, SparseSolver: SuperLU) are host-only. - direct_solvers.DenseInverse: the 1D matrix is inverted once on the host by the solver itself (M = solver.solve(I)) and applied to device data as rhs @ M (one GEMM per pass). - KroneckerSolverSerialPass (also used inside the distributed pass) uses it for device arrays; the inverses are copied to the device when a KroneckerLinearSolver with device temporaries is built. Works for any linear 1D solver (e.g. struphy's FFTSolver); the FFT "solvers" opt out with dense_on_device = False. - BandedSolver/SparseSolver.solve use it for device arrays (copied on the first device solve). Host path unchanged. - Tests: CPU (dense inverse vs host solvers, Kronecker solve vs global reference, serial and MPI), fake CuPy subprocess with assert_no_transfers, GPU tests (serial and MPI, skipped without GPU). - CUDA_STRATEGY.md: notes for this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
spossann
reviewed
Oct 7, 2026
…T, no symmetry assumed) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
max-models
added a commit
that referenced
this pull request
Oct 7, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 7, 2026
max-models
added this pull request to stack #99
October 8, 2026 05:42
max-models
marked this pull request as draft
October 8, 2026 05:44
Member
|
@max-models do you want to merge this before my Kronecker (I am still working on it)? |
Member
Author
Sure! |
max-models
marked this pull request as ready for review
October 8, 2026 09:26
Member
Author
This stack (and struphy-hub/struphy#716 on the struphy side) is ready for review. |
spossann
approved these changes
Oct 8, 2026
spossann
added a commit
to struphy-hub/struphy
that referenced
this pull request
Oct 9, 2026
**Stack:** part 3 of 3 — based on #706 (`feectools-stencil-6d-views`, merge that first, after #703), next: none (top of the stack). Corresponding update in feectools: struphy-hub/feectools#98. Mirrors the feectools stack struphy-hub/feectools#96 → struphy-hub/feectools#97 → struphy-hub/feectools#98. `feectools-stencil-6d-views` is merged into this branch (no rebase), and the `feectools` submodule now points at the stacked head of struphy-hub/feectools#98, `6d88806`, which contains feectools#96 and #97. Against its base (#706) this PR only moves the submodule `d16a6ad` → `6d88806`. After each feectools PR of the stack is merged into `devel-tiny`, the `feectools` submodule of the struphy stack must be moved to the corresponding merged `devel-tiny` commit (the `pr-feectools-submodule` check fails until then). --- **Solves the following issue(s):** Part of #689 (`VlasovAmpereOneSpecies` end to end on the GPU, no host/device transfers in the time loop), tracked in #650. This PR moves the `feectools` submodule to struphy-hub/feectools#98 ("Inner products stay on the device"). On the CuPy backend, every CG iteration of a struphy solve copied two scalars from the device to the host; after this change it copies one (the convergence test). **The `pr-feectools-submodule` check fails until struphy-hub/feectools#98 is merged** into `devel-tiny`. After that, the submodule should point at the merge commit. **Core changes:** - `feectools` submodule: `d16a6ad` (base #706) → `6d88806` (branch `inner-on-device` of struphy-hub/feectools#98, stacked on feectools#96 and #97; originally `a15a8e8`). No struphy code changes. - What changes in feectools, and only on the CuPy backend: - `StencilVector.inner`/`BlockVector.inner`/`dot_inner` return a 0-d device array, in serial and with MPI. - `axpy` accepts such a scalar without copying it to the host. - CG, PCG, BiCG, BiCGStab and PBiCGStab keep alpha/beta on the device and copy only the residual norm, once per iteration. On NumPy, results and types are unchanged. Copies to the host per iteration, counted under cunumpy's fake CuPy: | Solver | before | after | | --- | --- | --- | | CG | 2 | 1 | | PCG | 3 | 1 | | BiCG | 4 | 1 | | BiCGStab | 6 | 1 | | PBiCGStab | 5 | 1 | - struphy call sites of `.inner` that run on CuPy now get a 0-d device array. I checked them: - the scalars in `models/scalars.py` write it into `xp` buffers (`local_value[0] = ...`), which works on the device; - the model energy methods (`linear_mhd.py`, `shear_alfven.py`, ...) return it, and the scalar machinery handles it like the existing `dot_inner` results; - `PolarVector.dot` adds a NumPy scalar to it, which works, but polar splines are not on CuPy yet (#695); - the multigrid smoothers run their own PCG with `.inner`. It works with device scalars: the arithmetic stays on the device, and the comparisons with 0 copy to the host implicitly. - This removes the first blocker listed in #705 ("CG inner products ... The Schur solve is outside the transfer guard because of this"). The remaining copy per CG iteration still counts as a transfer for `assert_no_transfers`. The transfer guard could then cover the Schur solve with an allowance of one 8-byte copy per iteration. **Model-specific changes:** None. **Documentation changes:** None in struphy. The feectools PR documents the change in feectools' `CUDA_STRATEGY.md` (section "Inner products on the device"). **Testing** (macOS, no GPU; struphy kernels compiled with GNU/Fortran; **GPU tests not run**): - feectools (see struphy-hub/feectools#98): - serial suite: 9456 → 9480 passed, with the same 6 unrelated failures in `ddm/tests/test_cart_*d.py`; - `mpirun -n 2` linalg MPI suite: 738 → 739 passed; - the new fake-CuPy tests check the copy counts and that the results equal NumPy's. - struphy, solver tests on the NumPy backend, with feectools `devel-tiny` (before) and this branch (after): - `propagators/tests/test_poisson.py -m "not mpi" -k "not multigrid"`: 40 passed before, 40 passed after; - `linear_algebra/tests/test_saddlepoint_massmatrices.py`: the first case (`SaddlePointSolverUzawaNumpy`) passed before and after. I stopped the remaining saddle-point and multigrid tests: with the feectools kernels uncompiled in the checkouts, each test took more than 10 minutes. On NumPy, feectools' change is limited to identity helpers and an equivalent loop structure in BiCGStab, and the feectools solver tests check the same iteration counts and results. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Stefan Possanner <stefan.possanner@ipp.mpg.de>
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 1 of 3 — based on
devel-tiny, next: #97 (stencil-6d-views), then #98. Merge this one first.Companion struphy stack: struphy-hub/struphy#703 → struphy-hub/struphy#706 → struphy-hub/struphy#716; struphy-hub/struphy#703 points the
feectoolssubmodule at this branch's head (64b10fa).Corresponding PR in struphy: struphy-hub/struphy#703 (moves the submodule here)
Summary
KroneckerLinearSolvernow solves device data on the device. Its 1D solvers (BandedSolver: LAPACK?gbtrs;SparseSolver: SuperLU) are host-only, so on the CuPy backend every solve copied the data of each direction to the host and back. struphy'sMassMatrixPreconditionerapplies such a solve in every CG iteration, for example in the preconditioner ofEfieldWeightsCouplinginLinearVlasovAmpereOneSpecies(struphy-hub/struphy#689, part of struphy-hub/struphy#650).With this PR, a 3D solve on device data makes no host/device transfer. Before, a 3D solve on cunumpy's fake CuPy made 3 counted
to_hostcopies (one per direction) plus 3 uncounted copies back. The host path is unchanged.Design
Dense inverses of the 1D matrices, applied with one GEMM. The new
direct_solvers.DenseInverseinverts a 1D matrix once on the host, using the solver itself:M = solver.solve(I). On device data it then computesrhs @ M, a single matrix product (cuBLAS) over all right-hand sides of a pass. Why this approach:Mis cheap to store, and an n x n GEMM over m right-hand sides suits a GPU better than m banded triangular solves.Mcomes from the host solver itself. Pivoting, transposition (solver.transpose()) and complex dtypes therefore behave exactly as on the host path. Results agree with the host path to round-off: inverse-times-vector and LU solves are both backward stable, and the mass matrices are well conditioned. The tests use 1e-12 relative to the solution.cupyx.scipy.linalgoffers denselu_factor/lu_solvebut, as far as I know, nosolve_banded(not checked against a CuPy install here).lu_solvewould also need the dense matrix, and it runs two triangular solves instead of one GEMM.Works with any linear 1D solver. The Kronecker passes build
Mfromsolver.solveon host arrays. struphy'sFFTSolver(circulant mass matrices,scipy.linalg.solve_circulant) therefore works with no struphy change. I checked this in a local script with fake CuPy andassert_no_transfers. A 1D solver that should not be replaced by its dense matrix setsdense_on_device = False. The FFT "solvers" inlinalg/fft.pydo this, since an FFT is O(n log n); they still stage through the host as before.Decided by the array, as before.
xp.is_gpu(array)selects the dense path, and LAPACK/SuperLU are used otherwise.Copied to the device once, at setup. A
KroneckerLinearSolverwith device temporaries (CuPy backend) builds its inverses and copies them to the device in its constructor.transpose()builds a new solver, so the same applies there.solveitself therefore makes no transfer. A standaloneBandedSolver/SparseSolvercalled with device arrays copies its inverse on the first device solve (oneto_device) and caches it per dtype.Changes
linalg/direct_solvers.py: newDenseInverse.BandedSolver.solveandSparseSolver.solveuse it for device arrays instead of a host round trip; their host branches no longer callto_numpy/asarray.linalg/kron.py:KroneckerSolverSerialPass.solve_pass, also used between the twoAlltoallvof the distributed pass, applies the dense inverse to device data. Newprepare_deviceon both passes, called by theKroneckerLinearSolverconstructor when its temporaries are on the device.linalg/fft.py:DistributedFFTBase.OneDimSolver.dense_on_device = False.linalg/tests/test_kron_device_solve.py(new), see Testing.CUDA_STRATEGY.md: new section "Kronecker solver on the device"; open questions on FFT on the device and on large 1D matrices.Testing
Locally (macOS, Open MPI, cunumpy 0.6.1, pyccel kernels compiled; no GPU):
test_kron_device_solve.py:DenseInverseagainst the host solvers (banded/sparse, clamped/periodic, transposed, real/complex), and the Kronecker solve against a globalnp.kronreference (1D to 3D, mixed periodic/clamped, transposed). Serial: 36 passed.mpirun -n 2: 6 passed (distributed passes withAlltoallv).run_device_checksruns in a serial subprocess withCUNUMPY_FAKE_CUPY=1,MAYBEMPI=0and the MPI launcher variables removed. It compares device and host solves (Kronecker and 1D, with and without transposition) and wraps every device solve inassert_no_transfers. 1 test, passed. It fails on the old code with 3to_hosttransfers per 3D solve.requires_cupy): device against host solves underassert_no_transfers, serial (28) and MPI (6). Not run, skipped here (no GPU). They need a run by hand on the H100 before merging.mpirun -n 2with fake CuPy, so the distributed passes with device buffers ran with no transfers and matched the host results.feectools/linalg/tests, serial-m "not mpi and not petsc": before 7942 passed / 31 skipped, after 7979 passed / 59 skipped.mpirun -n 2 ... -m "mpi and not petsc" --with-mpi: before 738 passed, after 744 passed / 6 skipped.feectoolssuite, after: serial 9493 passed, 64 skipped, 6 failed. The 6 failures are the known ones inddm/tests/test_cart_2d.py/test_cart_3d.py, not caused by this PR.mpirun -n 2: 894 passed, 10 skipped.🤖 Generated with Claude Code