perf(vlasov1d): halve reverse-mode residuals in the spectral velocity push - #340
perf(vlasov1d): halve reverse-mode residuals in the spectral velocity push#340joglekara wants to merge 1 commit into
Conversation
… push The velocity push is `irfft(exp(-i*kv*dt*accel) * rfft(f))`. Because `accel` comes from the field solve it is traced, so reverse-mode has to keep two `(nx, nv//2+1)` complex128 arrays alive per call: the spectrum, needed by the phase gradient, and the phase factor, needed by the spectrum gradient. That is two residual arrays for every one distribution function advected, and the sixth-order Hamiltonian integrator issues six pushes per timestep. Route the phase multiply through a `custom_vjp` that saves only the spectrum and the small generators (`kv`, `dt`, `accel`) and rebuilds the phase factor on the backward pass. Measured on nx=64, nv=4096: 4.23 MB -> 2.11 MB of residual per push, exactly the phase factor dropped. The wrapper covers only the phase multiply, not the surrounding transforms. `rfft`/`irfft` are linear so autodiff already stores nothing for them, and leaving them untouched keeps the FFT conventions (including the Nyquist bin) exactly as they were. The backward rule differentiates a recomputation of the same primal with `jax.vjp` rather than hand-deriving the complex-multiply adjoint, so the primal and both gradients come out bit-identical to the expression they replace -- the tests assert bit-identity, not a tolerance. `SpaceExponential` and the Hou-Li filter are deliberately left alone: their phase and filter factors are built from constants, so XLA hoists them out of the step and there is no per-step residual to save. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wjo7qNpJAiCWtEMmhEiStA
There was a problem hiding this comment.
Review summary
Verdict: Changes requested
The reverse-mode memory optimization and its reverse-mode/shard_map coverage are well motivated, and the relevant CI checks pass. However, introducing custom_vjp in the unconditional velocity-push path removes forward-mode autodiff support. That is a blocking compatibility regression for this solver because the repository documentation explicitly identifies jax.jvp as the supported differentiation mode for the parallel: ["v"] configuration. See the inline comment for the requested fix and regression test.
| return jnp.exp(-1j * k[None, :] * dt * shift[:, None]) * spectrum | ||
|
|
||
|
|
||
| @jax.custom_vjp |
There was a problem hiding this comment.
Blocking: jax.custom_vjp makes _shift_spectrum unavailable to forward-mode autodiff (jax.jvp/jacfwd), so routing every VelocityExponential.push through it regresses a previously supported differentiation path. This matters here because docs/source/solvers/vlasov1d/overview.md explicitly directs gradient users to forward mode for the parallel: ["v"] configuration where reverse mode is unsupported. Please preserve a JVP-capable path (for example, use a custom primitive/custom derivative strategy that supports both modes, or conditionally retain the original expression for forward mode) and add an end-to-end jax.jvp regression test through VelocityExponential.
What
The spectral velocity push is
accelcomes from the field solve, so it is traced, and reverse-mode has to keep two(nx, nv//2+1)complex128 arrays alive per call: the spectrum (needed by the phase gradient) and the phase factor (needed by the spectrum gradient). That is two residual arrays for every one distribution function advected, andSixthOrderHamIntegratorissues six pushes per timestep.This routes the phase multiply through a
custom_vjpthat saves only the spectrum and the small generators (kv,dt,accel), and rebuilds the phase factor on the backward pass.Measured
Residual bytes read straight off the linearized jaxpr, so the numbers are a property of JAX's partial evaluation rather than of any one backend. At
nx=64, nv=4096(thebump-on-tail.yamlvelocity resolution):Exactly the phase factor dropped — 50.0%. Under the sixth-order integrator that is ~25 MB/step of
edfdvresidual against a 2.1 MB state, so the per-step figure goes to ~12.7 MB.Correctness
The primal and both gradients come out bit-identical to the expression they replace. The tests assert equality, not a tolerance — a silent gradient change in an optimization codebase is the failure mode worth guarding against, and bit-identity means no downstream behaviour can shift.
Two choices make that guarantee cheap to hold:
rfft/irfftare linear, so autodiff already stores nothing for them; leaving them untouched keeps the FFT conventions (including the Nyquist bin, which a spectral shift does not treat as a pure rotation) exactly as they were.jax.vjprather than hand-deriving the complex-multiply adjoint. Exact by construction rather than by argument.Scope
SpaceExponentialandHouLiFilterare deliberately left alone. Their factors are built from constants — thev-grid timesdt, and the fixed filter kernel — so XLA hoists them out of the step and there is no per-step residual to save. OnlyVelocityExponentialhas a traced phase. (Ifdtever becomes traced, e.g. adaptive stepping,SpaceExponentialwould become a candidate too.)Tests
New
tests/test_vlasov1d/test_pusher_spectral_vjp.py— 6 tests:fandaccelpathsVelocityExponentialclassshard_map—parallel: [x](used by the large IAW configs) wrapspush, so the custom VJP has to survive that. Runs in a subprocess since faking devices needs XLA flags set before JAX initializes; there was no prior sharding coverage here.Full suite:
113 passed, 7 deselected, 1 xpassed. The xpass is the pre-existing self-marked flakytest_landau_damping("Fails mysteriously with float64"), unrelated.CI needs no change — the existing paths filter already covers
adept/_vlasov1d/**andtests/test_vlasov1d/**.Tradeoff worth knowing
This buys memory with compute: one extra elementwise
expon the reverse pass. On CPU that measures ~7% slower backward for the 50% memory. On GPU these kernels are bandwidth-bound, so recomputing anexpshould beat the 2 MB round trip it replaces — but that wants confirming on real hardware before anyone leans on it. A GPU A/B benchmark script (peak memory + backward time, monkeypatching the helper back to the plain expression) is available separately; it was kept out of the tree since there is nobenchmarks/dir. If it comes back negative for production grid sizes, the change is one line to revert at the call site.The payoff is not the 2 MB by itself — it is that halving per-step residuals lets
RecursiveCheckpointAdjointafford more checkpoints at the same memory ceiling, which is the dominant backward cost on the long IAW runs.Generated by Claude Code