Skip to content

feat: autodiff via Enzyme on LLVM Fortran toolchain #75

Description

@krystophny

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:

  1. Boozer Fourier coefficients on one surface (matches the pyrabe Boozer-coefficient gradient and lets us cross-check against JAX on the same physics).
  2. VMEC wout quantities consumed by src/vmec/boozer_converter.F90, propagating through the Boozer transform.
  3. 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.

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

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

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

  4. 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.
  5. 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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions