Stage: CUDA autodiff / Enzyme toolchain
Source language: C++20 and CUDA C++, built with Clang CUDA for the Enzyme target
Manuals to read first: PR #561 (VMECPP_USE_CUDA execution path), PR #585 (exact CPU Enzyme QS adjoint stack), docs/cuda_acceleration.md, src/vmecpp/cpp/vmecpp/common/enzyme/enzyme.h, src/vmecpp/cpp/vmecpp/vmec/ideal_mhd_model/local_force_composition.h, Enzyme README (https://github.com/EnzymeAD/Enzyme), Enzyme GPU paper (https://c.wsmoses.com/papers/EnzymeGPU.pdf), Clang CUDA docs (https://llvm.org/docs/CompileCudaWithLLVM.html)
Depends on: #561, #585
Goal
Add the first opt-in proof that a VMEC++ CUDA physics kernel can be differentiated by Enzyme on the GPU. The landed PR should compile one small CUDA device-kernel JVP or VJP target with ClangEnzyme, run it on device buffers, and validate the derivative against a trusted CPU or finite-difference oracle. It should not try to differentiate the full CUDA equilibrium loop.
Files to edit
CMakeLists.txt: add VMECPP_ENABLE_CUDA_ENZYME, default OFF; require VMECPP_USE_CUDA=ON, Clang as the CUDA compiler, and VMECPP_ENZYME_PLUGIN.
src/vmecpp/cpp/vmecpp/vmec/ideal_mhd_model/CMakeLists.txt: add the opt-in CUDA-Enzyme test target only when VMECPP_ENABLE_CUDA_ENZYME=ON.
src/vmecpp/cpp/vmecpp/vmec/ideal_mhd_model/jacobian_kernel.h: factor the per-point half-grid Jacobian arithmetic into a small __host__ __device__ helper without changing the existing CPU loop contract.
src/vmecpp/cpp/vmecpp/vmec/ideal_mhd_model/cuda_enzyme_jacobian_jvp.cu: NEW; launch a device-side Enzyme JVP for the half-grid Jacobian helper.
src/vmecpp/cpp/vmecpp/vmec/ideal_mhd_model/cuda_enzyme_jacobian_jvp_test.cc: NEW; deterministic GPU test, skipped or not built unless CUDA-Enzyme is enabled.
Syntax or behavior to implement
Add a build-only experimental switch:
-DVMECPP_USE_CUDA=ON \
-DVMECPP_ENABLE_CUDA_ENZYME=ON \
-DVMECPP_ENZYME_PLUGIN="${VMECPP_ENZYME_PLUGIN}" \
-DCMAKE_CXX_COMPILER=clang++-21 \
-DCMAKE_CUDA_COMPILER=clang++-21
The switch must not affect the default CPU build, the default Enzyme CPU build, or the default CUDA build from #561. When enabled, it builds a CUDA-Enzyme test target that differentiates one pure device function. The first target should be the half-grid Jacobian because the CPU branch already treats it as a small, allocation-free Enzyme kernel and because it avoids cuFFT, CUDA graphs, persistent CudaToroidalState, free-boundary NESTOR bridges, and solver control flow.
Behavioral contract:
- Inputs, tangents, outputs, and output tangents live in CUDA device buffers.
- The differentiated function is visible to Enzyme as CUDA device LLVM IR.
- The test compares the GPU-Enzyme tangent for
r12, ru12, zu12, rs, zs, and tau against central finite differences of the same helper or against the existing CPU Enzyme Jacobian test oracle.
- The test uses small deterministic dimensions, for example
nZnT = 8, three full-grid surfaces, and two half-grid surfaces.
- The test uses tolerances tight enough to catch missing activity markers, wrong shadow strides, and host/device layout drift.
Design notes
PR #561 is a primal CUDA execution backend. It adds an opt-in VMECPP_USE_CUDA path, device-resident iteration state, cuFFT transforms, CUDA graphs, custom reductions, batched configuration axes, device convergence and timestep logic, and host/device bridges for free-boundary NESTOR work. That path is not an Enzyme target today. The only Enzyme content on that branch is the existing CPU smoke-test infrastructure.
The current Enzyme stack is narrower by design. It differentiates flat, allocation-free CPU kernels such as the local force-density composition and QS harmonic maps, then wraps them with analytic linear transforms and adjoint identities. It does not differentiate the OpenMP phase-barrier solver or the whole nonlinear VMEC solve.
Enzyme can differentiate GPU kernels when the kernel computation is visible in LLVM device IR. That makes a Clang CUDA route plausible. It does not make the full CUDA backend automatically differentiable. The following #561 pieces need explicit derivative boundaries or follow-up issues:
- cuFFT and cuBLAS calls: treat transforms as linear maps with hand-coded forward and transpose wrappers.
- CUDA graph capture/replay: keep outside differentiated kernels until there is a proven derivative path.
- Host/device copies and pinned host caches: keep orchestration outside the differentiated region.
- Persistent
CudaToroidalState: add separate tangent or adjoint buffers only after one local kernel works.
- Reductions and atomics: define derivative accumulation order and numerical drift policy explicitly.
- Convergence, restart, timestep, and active-configuration masks: keep fixed for local derivative tests.
- Free-boundary NESTOR calls: not included until fixed-boundary GPU local derivatives are validated.
A good sequence after this first PR is:
- Prove one CUDA-Enzyme local kernel, covered here.
- Share more per-point physics helpers between the CPU flat kernels and CUDA kernels so the primal formula is not maintained twice.
- Add device tangent buffers to the CUDA state for fixed-boundary local JVPs.
- Add hand-coded adjoints for the spectral transforms, matching the CPU exact-HVP chain
T^T J_g T.
- Build a GPU local-force JVP/VJP API for fixed-boundary, symmetric inputs.
- Wire that API into an opt-in exact HVP or QS-gradient path and benchmark against the CPU Enzyme path.
- File separate issues for batched mode, free-boundary mode, and any reverse-mode full-solve adjoint.
OpenMP target and OpenACC should not be part of this path. LLVM documents OpenACC support as still under development for Flang and Clang (https://openmp.llvm.org/openacc/Overview.html). The existing VMEC++ OpenMP code is a host synchronization model, not a GPU kernel layout. The CUDA backend is already the project-local GPU execution design.
Scaffold
#if defined(__CUDACC__)
#define VMECPP_HD __host__ __device__
#else
#define VMECPP_HD
#endif
VMECPP_HD void HalfGridJacobianPoint(const JacobianPointInput& x,
JacobianPointOutput* y);
__global__ void k_jacobian_jvp(const JacobianPointInput* x,
const JacobianPointInput* dx,
JacobianPointOutput* y,
JacobianPointOutput* dy) {
const int i = blockIdx.x * blockDim.x + threadIdx.x;
__enzyme_fwddiff<void>((void*)HalfGridJacobianPoint,
enzyme_dup, x + i, dx + i,
enzyme_dup, y + i, dy + i);
}
The actual implementation can use a structure-of-arrays layout instead of JacobianPointInput if that better matches ComputeHalfGridJacobian. Keep the differentiated call pure: no cudaMemcpy, no cuFFT, no graph capture, no CudaToroidalState, no host-side Eigen object inside the differentiated device function.
Positive fixtures to add
cuda_enzyme_jacobian_jvp_test: deterministic small-shape device test. Seed all geometry arrays with nonzero smooth values, seed one tangent direction, launch the GPU-Enzyme JVP, and compare every output tangent to a central-difference oracle with relative error below 1e-8 for regular entries and an absolute floor for near-zero entries.
cuda_enzyme_jacobian_jvp_test second case: seed only one input block at a time (r1e, r1o, z1e, z1o, rue, ruo, zue, zuo) and verify that inactive input tangents produce zero output tangents where expected.
Negative fixtures to add
- Configure with
VMECPP_ENABLE_CUDA_ENZYME=ON and VMECPP_USE_CUDA=OFF: CMake must fail with a clear message.
- Configure with
VMECPP_ENABLE_CUDA_ENZYME=ON and a non-Clang CUDA compiler: CMake must fail with a clear message.
- Configure with
VMECPP_ENABLE_CUDA_ENZYME=ON and a missing VMECPP_ENZYME_PLUGIN: CMake must fail with the same plugin-path guidance used by the CPU Enzyme path.
Makefile target
No Makefile target. Add a CTest target named cuda_enzyme_jacobian_jvp and a build target named cuda_enzyme_jacobian_jvp_test.
Success criteria
cmake -S . -B build-cuda-enzyme -G Ninja \
-DVMECPP_USE_CUDA=ON \
-DVMECPP_ENABLE_CUDA_ENZYME=ON \
-DVMECPP_ENZYME_PLUGIN="${VMECPP_ENZYME_PLUGIN}" \
-DCMAKE_CXX_COMPILER=clang++-21 \
-DCMAKE_CUDA_COMPILER=clang++-21
cmake --build build-cuda-enzyme --target cuda_enzyme_jacobian_jvp_test -j"$(nproc)"
ctest --test-dir build-cuda-enzyme -R cuda_enzyme_jacobian_jvp --output-on-failure
Non-goals
- Do not differentiate the full CUDA iteration body.
- Do not add OpenMP target or OpenACC support.
- Do not differentiate cuFFT, cuBLAS, CUDA graph capture, host/device transfers, convergence gates, timestep control, restart logic, or NESTOR in this PR.
- Do not change the default
VMECPP_USE_CUDA behavior or performance path.
- Do not add a second copy of the Jacobian formula. Share or factor the existing kernel arithmetic.
- Do not require CUDA-Enzyme in CI unless a GPU runner with the matching ClangEnzyme plugin is available.
Verification
export VMECPP_ENZYME_PLUGIN="$(find /tmp/enzyme-build -name 'ClangEnzyme-21.so' | head -1)"
test -n "${VMECPP_ENZYME_PLUGIN}"
cmake -S . -B build-cuda-enzyme -G Ninja -DVMECPP_USE_CUDA=ON -DVMECPP_ENABLE_CUDA_ENZYME=ON -DVMECPP_ENZYME_PLUGIN="${VMECPP_ENZYME_PLUGIN}" -DCMAKE_CXX_COMPILER=clang++-21 -DCMAKE_CUDA_COMPILER=clang++-21
cmake --build build-cuda-enzyme --target cuda_enzyme_jacobian_jvp_test -j"$(nproc)"
ctest --test-dir build-cuda-enzyme -R cuda_enzyme_jacobian_jvp --output-on-failure
Stage: CUDA autodiff / Enzyme toolchain
Source language: C++20 and CUDA C++, built with Clang CUDA for the Enzyme target
Manuals to read first: PR #561 (
VMECPP_USE_CUDAexecution path), PR #585 (exact CPU Enzyme QS adjoint stack),docs/cuda_acceleration.md,src/vmecpp/cpp/vmecpp/common/enzyme/enzyme.h,src/vmecpp/cpp/vmecpp/vmec/ideal_mhd_model/local_force_composition.h, Enzyme README (https://github.com/EnzymeAD/Enzyme), Enzyme GPU paper (https://c.wsmoses.com/papers/EnzymeGPU.pdf), Clang CUDA docs (https://llvm.org/docs/CompileCudaWithLLVM.html)Depends on: #561, #585
Goal
Add the first opt-in proof that a VMEC++ CUDA physics kernel can be differentiated by Enzyme on the GPU. The landed PR should compile one small CUDA device-kernel JVP or VJP target with ClangEnzyme, run it on device buffers, and validate the derivative against a trusted CPU or finite-difference oracle. It should not try to differentiate the full CUDA equilibrium loop.
Files to edit
CMakeLists.txt: addVMECPP_ENABLE_CUDA_ENZYME, defaultOFF; requireVMECPP_USE_CUDA=ON, Clang as the CUDA compiler, andVMECPP_ENZYME_PLUGIN.src/vmecpp/cpp/vmecpp/vmec/ideal_mhd_model/CMakeLists.txt: add the opt-in CUDA-Enzyme test target only whenVMECPP_ENABLE_CUDA_ENZYME=ON.src/vmecpp/cpp/vmecpp/vmec/ideal_mhd_model/jacobian_kernel.h: factor the per-point half-grid Jacobian arithmetic into a small__host__ __device__helper without changing the existing CPU loop contract.src/vmecpp/cpp/vmecpp/vmec/ideal_mhd_model/cuda_enzyme_jacobian_jvp.cu: NEW; launch a device-side Enzyme JVP for the half-grid Jacobian helper.src/vmecpp/cpp/vmecpp/vmec/ideal_mhd_model/cuda_enzyme_jacobian_jvp_test.cc: NEW; deterministic GPU test, skipped or not built unless CUDA-Enzyme is enabled.Syntax or behavior to implement
Add a build-only experimental switch:
-DVMECPP_USE_CUDA=ON \ -DVMECPP_ENABLE_CUDA_ENZYME=ON \ -DVMECPP_ENZYME_PLUGIN="${VMECPP_ENZYME_PLUGIN}" \ -DCMAKE_CXX_COMPILER=clang++-21 \ -DCMAKE_CUDA_COMPILER=clang++-21The switch must not affect the default CPU build, the default Enzyme CPU build, or the default CUDA build from #561. When enabled, it builds a CUDA-Enzyme test target that differentiates one pure device function. The first target should be the half-grid Jacobian because the CPU branch already treats it as a small, allocation-free Enzyme kernel and because it avoids cuFFT, CUDA graphs, persistent
CudaToroidalState, free-boundary NESTOR bridges, and solver control flow.Behavioral contract:
r12,ru12,zu12,rs,zs, andtauagainst central finite differences of the same helper or against the existing CPU Enzyme Jacobian test oracle.nZnT = 8, three full-grid surfaces, and two half-grid surfaces.Design notes
PR #561 is a primal CUDA execution backend. It adds an opt-in
VMECPP_USE_CUDApath, device-resident iteration state, cuFFT transforms, CUDA graphs, custom reductions, batched configuration axes, device convergence and timestep logic, and host/device bridges for free-boundary NESTOR work. That path is not an Enzyme target today. The only Enzyme content on that branch is the existing CPU smoke-test infrastructure.The current Enzyme stack is narrower by design. It differentiates flat, allocation-free CPU kernels such as the local force-density composition and QS harmonic maps, then wraps them with analytic linear transforms and adjoint identities. It does not differentiate the OpenMP phase-barrier solver or the whole nonlinear VMEC solve.
Enzyme can differentiate GPU kernels when the kernel computation is visible in LLVM device IR. That makes a Clang CUDA route plausible. It does not make the full CUDA backend automatically differentiable. The following #561 pieces need explicit derivative boundaries or follow-up issues:
CudaToroidalState: add separate tangent or adjoint buffers only after one local kernel works.A good sequence after this first PR is:
T^T J_g T.OpenMP target and OpenACC should not be part of this path. LLVM documents OpenACC support as still under development for Flang and Clang (https://openmp.llvm.org/openacc/Overview.html). The existing VMEC++ OpenMP code is a host synchronization model, not a GPU kernel layout. The CUDA backend is already the project-local GPU execution design.
Scaffold
The actual implementation can use a structure-of-arrays layout instead of
JacobianPointInputif that better matchesComputeHalfGridJacobian. Keep the differentiated call pure: nocudaMemcpy, no cuFFT, no graph capture, noCudaToroidalState, no host-side Eigen object inside the differentiated device function.Positive fixtures to add
cuda_enzyme_jacobian_jvp_test: deterministic small-shape device test. Seed all geometry arrays with nonzero smooth values, seed one tangent direction, launch the GPU-Enzyme JVP, and compare every output tangent to a central-difference oracle with relative error below1e-8for regular entries and an absolute floor for near-zero entries.cuda_enzyme_jacobian_jvp_testsecond case: seed only one input block at a time (r1e,r1o,z1e,z1o,rue,ruo,zue,zuo) and verify that inactive input tangents produce zero output tangents where expected.Negative fixtures to add
VMECPP_ENABLE_CUDA_ENZYME=ONandVMECPP_USE_CUDA=OFF: CMake must fail with a clear message.VMECPP_ENABLE_CUDA_ENZYME=ONand a non-Clang CUDA compiler: CMake must fail with a clear message.VMECPP_ENABLE_CUDA_ENZYME=ONand a missingVMECPP_ENZYME_PLUGIN: CMake must fail with the same plugin-path guidance used by the CPU Enzyme path.Makefile target
No Makefile target. Add a CTest target named
cuda_enzyme_jacobian_jvpand a build target namedcuda_enzyme_jacobian_jvp_test.Success criteria
Non-goals
VMECPP_USE_CUDAbehavior or performance path.Verification