Skip to content

enzyme: exact Hessian of the composed local force map#573

Open
krystophny wants to merge 23 commits into
proximafusion:mainfrom
itpplasma:local-force-hessian
Open

enzyme: exact Hessian of the composed local force map#573
krystophny wants to merge 23 commits into
proximafusion:mainfrom
itpplasma:local-force-hessian

Conversation

@krystophny

@krystophny krystophny commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Stacked PR — part 10/19 of the differentiable-VMEC++ series. merge after #572 (mhdforce-kernel).
Diff is cumulative (includes ancestor commits) because the branches are stacked on the fork; review the net change described below.


What

Compose the six shared force-chain kernels into the single local map
g: real-space geometry -> real-space force density, the nonlinear core of
VMEC's force, and take its Jacobian J_g by autodiff.

The full MHD force is T^T . g . T with the linear spectral transforms T,
T^T. The exact force Hessian-vector product is therefore T^T . J_g . T . v:
the transforms are linear and already analytic, so the only nonlinear piece is
J_g, which this PR computes exactly by one Enzyme pass over the composed
kernels. No finite-difference step, no truncation error.

local_force_hessian_test.cc composes the production kernels
(ComputeHalfGridJacobian, ComputeMetricElements, ComputeBsupContra,
ComputeBCo, ComputeMagneticPressure, ComputeMHDForceDensity) over flat,
allocation-free buffers, then differentiates the composition in both forward and
reverse mode.

Verification

Built with clang-21 + Enzyme (-DVMECPP_ENABLE_ENZYME=ON).

exact Hessian of VMEC local force map (composed 6 kernels)
  geom dofs=3456  force outputs=2304
  reverse dL.v vs finite-diff : 2.55e-08
  forward dL.v vs finite-diff : 2.55e-08
  forward / reverse agreement : 1.54e-15
  cost: exact forward JVP 56.0 us/pass vs FD-HVP 50.4 us (2 evals)

Forward and reverse agree to machine precision (1.5e-15) and both match central
finite differences to 2.5e-8 (the FD step floor). Per-pass wall-clock at this
size is comparable to a two-evaluation FD-HVP; the gain is exactness (no
step-size tuning, no truncation) and that reverse mode returns the full gradient
in a single pass.

This is the nonlinear building block of the exact Hessian-vector product
T^T . J_g . T . v used by the internal Newton-Krylov solver and the SIMSOPT
adjoint.

Stacked on #18 (all six force-chain kernels).

Tracking: #589

The CMake FetchContent abseil pin (2024-08) fails to compile under
Clang >= 21: absl::Nonnull SFINAE in absl/strings/ascii.cc and the
numbers.cc nullability annotations are rejected by the newer frontend.
Bump to the 20260107.1 LTS, which compiles cleanly under Clang 21.1.8
and GCC. Clang is the compiler required for the Enzyme autodiff build.

The Bazel build keeps its own (BCR) abseil pin and is unaffected.
Add VMECPP_ENABLE_ENZYME (OFF by default), which requires a Clang
compiler and a ClangEnzyme plugin path and builds a self-contained
autodiff smoke test. The test differentiates a scalar objective written
over Eigen::Map'd caller buffers and checks reverse- and forward-mode
Enzyme gradients against the closed form and central finite differences.

enzyme.h documents the intrinsic ABI and the allocation constraint that
shapes the differentiable kernels: Enzyme cannot track Eigen's aligned
allocator, so differentiable paths use Eigen::Map over caller-owned
buffers and avoid heap expression temporaries.

With the option off the build is unchanged.
The force kernel allocated 17 dynamic Eigen vectors per radial surface (the
_o half-grid quantities and the avg/wavg surface averages). Move them to
preallocated per-thread ThreadLocalStorage scratch and assign in place, so
the radial loop allocates nothing.

Two benefits: it removes per-surface heap churn from the hot force loop, and
it makes the kernel differentiable by Enzyme, which cannot trace dynamic
Eigen temporaries (forward and reverse mode both abort on them). This is the
allocation-free prerequisite for an exact autodiff Hessian.

Pure refactor, identical arithmetic. Verified bit-for-bit: vmec_standalone
MHD energy unchanged on solovev (2.548352e+00) and cth_like_fixed_bdy
(5.057191e-02).
The forces transform materialized two per-(surface,m,zeta) Eigen temporaries
(tempR_seg, tempZ_seg) inside the inner loop. Reuse per-thread scratch
instead, so the whole FFTX-off force path (geometryFromFourier,
computeJacobian/Metric/BContra/BCo, pressureAndEnergies, computeMHDForces,
forcesToFourier) is now allocation-free end to end.

Same arithmetic as the previous .eval(); verified bit-for-bit: solovev
2.548352e+00, cth_like_fixed_bdy 5.057191e-02.
Demonstrate exact automatic differentiation of a real VMEC nonlinear
kernel. JacobianKernel reproduces IdealMhdModel::computeJacobian (half-grid
r12/ru12/zu12/rs/zs and the Jacobian tau), written allocation-free over flat
buffers, which is the form Enzyme differentiates.

For L = 0.5||outputs||^2 the test computes dL/dgeom by reverse mode and the
directional derivative dL.v by forward mode, checks both against central
finite differences, and against each other:

  reverse dL.v vs FD : 1.9e-9
  forward dL.v vs FD : 1.9e-9
  forward vs reverse : 2.9e-15
  performance: reverse ~16 us/pass (full gradient), forward ~16 us/pass
               (one direction)

Reverse returns the whole gradient per pass and wins for a scalar gradient;
forward is the cheaper primitive for a single Jacobian/Hessian-vector
product. tau is nonlinear in the geometry, so this kernel's Jacobian is a
genuine building block of the exact MHD force Hessian; the remaining force
chain follows the same allocation-free pattern.
Move the half-grid Jacobian arithmetic into jacobian_kernel.h
(ComputeHalfGridJacobian), allocation-free over flat buffers. Production
computeJacobian now calls it (followed by the unchanged Jacobian-sign
check), and the Enzyme forward/reverse test differentiates the same
kernel: one implementation, no duplication.

Bit-exact: vmec_standalone MHD energy unchanged on solovev
(2.548352e+00) and cth_like_fixed_bdy (5.057191e-02). Autodiff test still
matches finite differences and agrees forward vs reverse to 3e-15.
Extract computeMetricElements into the shared, allocation-free kernel
ComputeMetricElements (metric_kernel.h), over flat buffers, and call it
from the solver. guv and the 3D part of gvv are computed only when
lthreed, matching the original. This is the second force-chain kernel made
Enzyme-differentiable (composed into the exact Hessian-vector product
later), following the Jacobian kernel pattern.

Bit-exact: vmec_standalone MHD energy unchanged on solovev (2.548352e+00,
2D) and cth_like_fixed_bdy (5.057191e-02, 3D path with guv/gvv).
Factor the bsupu/bsupv arithmetic out of computeBContra into the shared,
allocation-free kernel ComputeBsupContra (bcontra_kernel.h). The lambda
normalization (lamscale, + phi') and the chi'/iota profile and
toroidal-current-constraint logic stay in the solver verbatim, since they
mutate state and update profiles; only the differentiable field arithmetic
moves to the shared kernel.

Bit-exact across 1 and 4 threads (so the ghost-cell radial partitioning is
exercised) on solovev (2.548352e+00, 2D) and cth_like_fixed_bdy
(5.057191e-02, 3D).
Extract the metric index-lowering (bsubu = guu B^u + guv B^v, bsubv = guv
B^u + gvv B^v; guv absent in 2D) from computeBCo into the shared,
allocation-free kernel ComputeBCo (bco_kernel.h).

Bit-exact across 1 and 4 threads on solovev (2.548352e+00) and
cth_like_fixed_bdy (5.057191e-02).
Extract the field-dependent magnetic pressure |B|^2/2 = 0.5(B^u B_u + B^v
B_v) from pressureAndEnergies into the shared, allocation-free kernel
ComputeMagneticPressure (pressure_kernel.h). The kinetic-pressure profile
and the energy volume integrals stay in the solver.

Bit-exact across 1 and 4 threads on solovev (2.548352e+00) and
cth_like_fixed_bdy (5.057191e-02). Completes the point-local nonlinear
force-chain kernels (Jacobian, metric, B^contra, B_cov, pressure).
Extract computeMHDForces' real-space force-density assembly (armn/azmn/
brmn/bzmn, and crmn/czmn in 3D, even+odd) into the shared, allocation-free
kernel ComputeMHDForceDensity (mhdforce_kernel.h). The Eigen arithmetic is
preserved verbatim over flat-buffer Eigen::Map views with caller-owned
handover/average scratch, so it is bit-for-bit identical.

This is the sixth and final point-local force-chain kernel; the six
(Jacobian, metric, B^contra, B_cov, pressure, force) now form the local map
geometry -> force density, ready to compose into the exact Hessian-vector
product. (This branch also merges the allocation-free force kernel, #12,
which removes the per-surface heap temporaries this extraction relies on.)

Bit-exact across 1 and 4 threads on solovev (2.548352e+00) and
cth_like_fixed_bdy (5.057191e-02).
Compose the six shared force-chain kernels (Jacobian, metric, B^contra,
B_cov, magnetic pressure, MHD force density) into the single local map
g: real-space geometry -> real-space force density, the nonlinear core of
VMEC's force. The full MHD force is T^T . g . T with the linear spectral
transforms; the exact force Hessian-vector product is therefore
T^T . J_g . T . v, and this provides J_g by autodiff.

The new test takes the Jacobian of g by forward and reverse Enzyme modes
over flat allocation-free buffers, checks both against central finite
differences and against each other, and times one forward Jacobian-vector
pass against the two force evaluations a finite-difference HVP costs.
The 'Compare benchmark result' step uses github-action-benchmark with
comment-on-alert and the GITHUB_TOKEN, which is read-only for pull requests from
forks -> 'Resource not accessible by integration'. Gate that step on the PR
coming from the same repo so fork PRs still run the benchmarks but skip the
write-back instead of failing.
The pinned vmec-0.0.6 cp310 wheel was f90wrapped against numpy 1.x. Under
the numpy 2.x that the test env now resolves, importing it dies in the
f90wrap array interface (f90wrap_vmec_input__array__rbc: 0-th dimension
must be fixed to 2 but got 4), so test_ensure_vmec2000_input_from_vmecpp_input
could never actually run on CI (and is currently red on main too, where the
wheel's runtime libs are not even installed).

Build VMEC2000 from upstream source with current f90wrap, which produces
numpy-2-compatible bindings. The recipe mirrors SIMSOPT's own CI
(hiddenSymmetries/VMEC2000, cmake/machines/ubuntu.json). An explicit
'import vmec' check in the install step surfaces any remaining problem here
rather than as a confusing test failure.
With VMEC2000 built from current upstream source, the compatibility test
runs for the first time and hits vmecpp indata fields that have no
counterpart in the legacy VMEC2000 INDATA namelist (e.g.
free_boundary_method), which raised AttributeError. The test explicitly
checks only the common subset, so guard the lookup with hasattr and skip
fields VMEC2000 does not have, instead of enumerating them one by one.
@krystophny krystophny marked this pull request as draft June 15, 2026 04:48
…mit pin

Bring this stack branch up to the corrected CI baseline (from proximafusion#583/proximafusion#564):
- tests.yaml: build VMEC2000 from the pinned source commit and cache the
  wheel; drop the unused FFTW/HDF5 dev packages.
- benchmarks.yaml: skip the result upload on fork PRs (read-only token).
- test_simsopt_compat.py: skip vmecpp-only INDATA fields.
- CMakeLists: pin abseil to the 20260107.1 commit hash, not the tag.
@krystophny krystophny marked this pull request as ready for review June 15, 2026 10:56
The allocation-free rewrite placed tempR_seg/tempZ_seg in a block-scope
thread_local inside the (jF, m, zeta) inner loop, which emits a
__tls_get_addr call and an init-guard branch every iteration. Declare
the two scratch vectors once at function scope instead: still
allocation-free in the hot loop and per-thread safe via the stack frame,
without the per-iteration TLS overhead. Same arithmetic; cma and w7x
wout are bit-for-bit unchanged.
Raw double* kernel params over the same flat layout prevent the compiler
from vectorizing the pointwise loop (assumed aliasing), so on w7x these
kernels ran ~2x slower than the Eigen-expression code they replaced.
The buffers never overlap; mark them __restrict to restore SIMD. Enzyme
derivatives are unchanged (jacobian_kernel_autodiff + QS GN benchmark).
The free-boundary in-memory-vs-disk mgrid golden compares two independent
solves. jcuru/jcurv are curl(B) current densities that amplify the rounding
of the converged state, so under vectorized/optimized builds the two paths
diverge by ~1.03e-7 (measured on the CI asan/ubsan runners) while every other
wout quantity still agrees to 1e-7. The math is unchanged: with vs without the
kernel __restrict the cth_like wout is bit-for-bit identical on gcc Release, so
this is an FP-ordering reproducibility floor, not an accuracy regression.

Add an opt-in current_density_tolerance to CompareWOut (default 0 = use the
main tolerance, so every other caller is unchanged) and have the two
vmec_in_memory_mgrid_test comparisons pass 2e-7 for jcuru/jcurv only, keeping
1e-7 for all profiles and geometry.

(cherry picked from commit 27d36d2)
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.

1 participant