Motivation
The JAX port pyrabe (https://github.com/itpplasma/pyrabe) is differentiable end-to-end only with respect to Boozer Fourier coefficients, and only on a fixed branch of the well topology: same surface, same field-line label grid, same selected pair of maxima per line, same active global B_max. The VMEC-to-Boozer step in pyrabe wraps the legacy booz_xform extension and is not part of the JAX trace, so the chain
SIMSOPT DOFs -> VMEC -> Boozer -> Lambda_A, Lambda_B, Lambda_S
has no exact gradient today. Inside pyrabe, the discrete pieces (find_two_maxima, jnp.max(line_max_B), the max(1 - B*eta_b, 0) clamp, the rational xi_0 grid) are correct piecewise but jump at topology changes: a minimum-maximum pair born or annihilated, two maxima exchanging which is largest, a split well, an active global maximum switching from one field line to another. Reverse-mode AD through those events gives the gradient of the branch immediately before, not through, the event.
Fortran rabe is the canonical numeric kernel. If we get reverse-mode AD on rabe itself via Enzyme, we obtain:
- exact gradient
d Lambda_{A,B,S} / d boozer_modes without a parallel JAX reimplementation that has to be kept in sync;
- a differentiable VMEC-to-Boozer step through the existing
boozer_converter Fortran source, closing the SIMSOPT chain to whatever extent boozer_converter itself is differentiable;
- adjoint cost roughly independent of the number of inputs, which is the right cost model for stellarator shape optimization.
The build/llvm-fortran-compilers branch already moves the build toward an LLVM-based Fortran frontend (flang-new / LLVM flang), which is what Enzyme needs. This issue tracks the autodiff work that builds on top.
Concrete request
Enable reverse-mode autodiff of rabe.x (or of a rabe_lib entry point) with respect to a chosen input vector, using Enzyme as the AD pass on the LLVM IR produced by the flang-new build.
Reference inputs to differentiate against, ranked by usefulness:
- Boozer Fourier coefficients on one surface (matches the
pyrabe Boozer-coefficient gradient and lets us cross-check against JAX on the same physics).
- VMEC
wout quantities consumed by src/vmec/boozer_converter.F90, propagating through the Boozer transform.
- Eventually SIMSOPT-side DOFs, via a chain
simsopt -> VMEC -> rabe.
Reference outputs: Lambda_A, Lambda_B, Lambda_S, nu_star_crit per surface, as written to rabe.nc.
Known obstacles in the current sources
These are the spots where Enzyme will either need help or where we should replace the algorithm with a differentiable equivalent. File paths are on main / build/llvm-fortran-compilers.
-
src/utils/find_extrema.f90 does a coarse 1001-point scan followed by repeated subdivision with maxloc and an if ladder. Discrete index selection is not differentiable directly. Replacement: fix the selected extremum by index (treat the coarse argmax as a piecewise-constant choice), then differentiate the Newton refinement via the implicit-function theorem at B_phi(phi*, p) = 0, giving d phi* / d p = -B_{phi,p} / B_{phi,phi}. pyrabe/maxima.py already does this; we want the same trick inside rabe.
-
src/fieldline/make_fieldline.f90:get_global_B_max is a hard maxval over field lines, and calc_nu_star_crit uses a hard minval over B_max(:,1). The gradient flows only through the currently active line and jumps when the active line switches. Decision needed: keep the hard max and accept that the optimizer sees a kink, or expose a smooth surrogate (logsumexp with a documented temperature) behind a switch.
-
src/fieldline/make_fieldline.f90:pick_maximum_on_each_side and the split_maxima warning branch encode a topology change. Enzyme cannot differentiate across the event; we should produce a gradient only when split_maxima == 0, and surface the flag in the output the way the namelist already does, so SIMSOPT-side code can treat it as a constraint.
-
src/utils/integrate.f90 calls QUADPACK dqag, which is adaptive: the subdivision pattern depends on the integrand values. Enzyme can in principle differentiate through it, but adaptive control flow with internal error_stop paths is the hardest case. Two cleaner options:
- keep
dqag for the forward computation, and use the substitution + fixed Gauss-Legendre rule that pyrabe.quadrature uses for the reverse pass on the same integrand; or
- port the four well integrals (
wrapper_lambda_over_B_squared, wrapper_local_radial_drift, wrapper_one_over_B_squared, wrapper_nabla_s_over_B_squared) to a fixed-order Gauss-Legendre rule with the same t**2 = x - a / t**2 = b - x substitution that the Fortran code already applies, then differentiate the fixed rule with Enzyme directly. This matches pyrabe and removes the adaptive branch.
-
error stop on integration or validation failure aborts the process. For AD we need those branches reachable as ordinary error returns or as NaN propagation so the autodiff machinery does not get terminated mid-derivative.
-
src/vmec/boozer_converter.F90 and the libneo field interface: these are the SIMSOPT-side payoff. Differentiability here is a separate workstream; we should first land Enzyme on the rabe-internal path with the Boozer field as input and treat boozer-converter as a later milestone.
Suggested first milestone
One surface, one quantity, fixed maximum topology:
- input: Boozer Fourier coefficients on a fixed
s_tor;
- output:
Lambda_A for that surface;
- check: Enzyme gradient agrees with central finite differences on
rabe.x to ~rel_error_tol_quadkind (currently 1e-6), and agrees with the jax.grad of pyrabe.coefficients.Lambda_A on the same Boozer input to within the documented quadrature tolerance.
Once that holds, extend to Lambda_B, Lambda_S, then to multiple surfaces, then push the input boundary upstream into boozer_converter.
Build prerequisites
- LLVM flang (work in progress on
build/llvm-fortran-compilers).
- Enzyme built against the same LLVM version as flang.
cmake/compiler.cmake already branches on CMAKE_Fortran_COMPILER_ID; add a Flang / LLVMFlang branch with -fno-fortran-main-style flags as needed and a -fpass-plugin=…/LLVMEnzyme-*.so option.
- QUADPACK is BSD-3; either keep as-is for the forward pass or replace the four well integrals as in obstacle 4.
Excluded
- Forward-mode AD: cost model is wrong for shape optimization.
- Differentiable rewrite of
libneo field I/O.
- Smoothing the rational
xi_0 grid in src/fieldline/fieldline_labels.f90. Freezing this grid per optimization segment is acceptable for a first version.
References
- pyrabe analysis of the same numerics: https://github.com/itpplasma/pyrabe (see
src/pyrabe/maxima.py, src/pyrabe/surface_pipeline.py, src/pyrabe/__init__.py for the JAX-side limits this issue is meant to lift on the Fortran side).
- Enzyme: https://enzyme.mit.edu/ and Moses & Churavy, NeurIPS 2020.
Motivation
The JAX port
pyrabe(https://github.com/itpplasma/pyrabe) is differentiable end-to-end only with respect to Boozer Fourier coefficients, and only on a fixed branch of the well topology: same surface, same field-line label grid, same selected pair of maxima per line, same active globalB_max. The VMEC-to-Boozer step inpyrabewraps the legacybooz_xformextension and is not part of the JAX trace, so the chainhas no exact gradient today. Inside
pyrabe, the discrete pieces (find_two_maxima,jnp.max(line_max_B), themax(1 - B*eta_b, 0)clamp, the rationalxi_0grid) are correct piecewise but jump at topology changes: a minimum-maximum pair born or annihilated, two maxima exchanging which is largest, a split well, an active global maximum switching from one field line to another. Reverse-mode AD through those events gives the gradient of the branch immediately before, not through, the event.Fortran
rabeis the canonical numeric kernel. If we get reverse-mode AD onrabeitself via Enzyme, we obtain:d Lambda_{A,B,S} / d boozer_modeswithout a parallel JAX reimplementation that has to be kept in sync;boozer_converterFortran source, closing the SIMSOPT chain to whatever extentboozer_converteritself is differentiable;The
build/llvm-fortran-compilersbranch already moves the build toward an LLVM-based Fortran frontend (flang-new / LLVM flang), which is what Enzyme needs. This issue tracks the autodiff work that builds on top.Concrete request
Enable reverse-mode autodiff of
rabe.x(or of arabe_libentry point) with respect to a chosen input vector, using Enzyme as the AD pass on the LLVM IR produced by the flang-new build.Reference inputs to differentiate against, ranked by usefulness:
pyrabeBoozer-coefficient gradient and lets us cross-check against JAX on the same physics).woutquantities consumed bysrc/vmec/boozer_converter.F90, propagating through the Boozer transform.simsopt -> VMEC -> rabe.Reference outputs:
Lambda_A,Lambda_B,Lambda_S,nu_star_critper surface, as written torabe.nc.Known obstacles in the current sources
These are the spots where Enzyme will either need help or where we should replace the algorithm with a differentiable equivalent. File paths are on
main/build/llvm-fortran-compilers.src/utils/find_extrema.f90does a coarse 1001-point scan followed by repeated subdivision withmaxlocand anifladder. Discrete index selection is not differentiable directly. Replacement: fix the selected extremum by index (treat the coarse argmax as a piecewise-constant choice), then differentiate the Newton refinement via the implicit-function theorem atB_phi(phi*, p) = 0, givingd phi* / d p = -B_{phi,p} / B_{phi,phi}.pyrabe/maxima.pyalready does this; we want the same trick insiderabe.src/fieldline/make_fieldline.f90:get_global_B_maxis a hardmaxvalover field lines, andcalc_nu_star_crituses a hardminvaloverB_max(:,1). The gradient flows only through the currently active line and jumps when the active line switches. Decision needed: keep the hard max and accept that the optimizer sees a kink, or expose a smooth surrogate (logsumexpwith a documented temperature) behind a switch.src/fieldline/make_fieldline.f90:pick_maximum_on_each_sideand thesplit_maximawarning branch encode a topology change. Enzyme cannot differentiate across the event; we should produce a gradient only whensplit_maxima == 0, and surface the flag in the output the way the namelist already does, so SIMSOPT-side code can treat it as a constraint.src/utils/integrate.f90calls QUADPACKdqag, which is adaptive: the subdivision pattern depends on the integrand values. Enzyme can in principle differentiate through it, but adaptive control flow with internalerror_stoppaths is the hardest case. Two cleaner options:dqagfor the forward computation, and use the substitution + fixed Gauss-Legendre rule thatpyrabe.quadratureuses for the reverse pass on the same integrand; orwrapper_lambda_over_B_squared,wrapper_local_radial_drift,wrapper_one_over_B_squared,wrapper_nabla_s_over_B_squared) to a fixed-order Gauss-Legendre rule with the samet**2 = x - a/t**2 = b - xsubstitution that the Fortran code already applies, then differentiate the fixed rule with Enzyme directly. This matchespyrabeand removes the adaptive branch.error stopon integration or validation failure aborts the process. For AD we need those branches reachable as ordinary error returns or asNaNpropagation so the autodiff machinery does not get terminated mid-derivative.src/vmec/boozer_converter.F90and thelibneofield interface: these are the SIMSOPT-side payoff. Differentiability here is a separate workstream; we should first land Enzyme on the rabe-internal path with the Boozer field as input and treat boozer-converter as a later milestone.Suggested first milestone
One surface, one quantity, fixed maximum topology:
s_tor;Lambda_Afor that surface;rabe.xto ~rel_error_tol_quadkind(currently 1e-6), and agrees with thejax.gradofpyrabe.coefficients.Lambda_Aon the same Boozer input to within the documented quadrature tolerance.Once that holds, extend to
Lambda_B,Lambda_S, then to multiple surfaces, then push the input boundary upstream intoboozer_converter.Build prerequisites
build/llvm-fortran-compilers).cmake/compiler.cmakealready branches onCMAKE_Fortran_COMPILER_ID; add aFlang/LLVMFlangbranch with-fno-fortran-main-style flags as needed and a-fpass-plugin=…/LLVMEnzyme-*.sooption.Excluded
libneofield I/O.xi_0grid insrc/fieldline/fieldline_labels.f90. Freezing this grid per optimization segment is acceptable for a first version.References
src/pyrabe/maxima.py,src/pyrabe/surface_pipeline.py,src/pyrabe/__init__.pyfor the JAX-side limits this issue is meant to lift on the Fortran side).