perf: use jax.linearize when chunking jacobians in forward-mode - #2286
perf: use jax.linearize when chunking jacobians in forward-mode#2286jpbrodrick89 wants to merge 11 commits into
Conversation
…alProjection ProximalProjection._jvp differentiates the equilibrium constraint w.r.t. many perturbation directions at a single fixed equilibrium point, chunked via batched_vectorize for memory. Since dF/dx (restricted to feasible directions) does not depend on the perturbation direction, only on the converged equilibrium state, the previous code's use of raw jax.jvp inside the chunked vmap re-evaluated the nonlinear force-balance residual and re-factorized its SVD once per chunk instead of once per call, so cost grew with the number of chunks rather than staying roughly constant. Use jax.linearize once per xf to get a cheap linear map, and factor its pseudoinverse once, reusing both across every chunk. Scoped to the "batched" deriv_mode path (the one that chunks via jac_chunk_size); the "blocked" fallback is untouched. Verified bitwise-identical jac/jvp output across chunk sizes against the existing test_proximal_jacobian regression test, and measured a 2.7-3x speedup on a HELIOTRON equilibrium at chunk counts that evenly divide the design space. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgWTyAewaigF78JQ6gH6Ry
…acobians _jvp_batched builds Jacobians/JVPs by chunking a batch of tangent directions through batched_vectorize for memory. Since fun's primal does not depend on the tangent direction, a fresh Derivative.compute_jvp (raw jax.jvp) call inside every chunk re-evaluates fun's nonlinear primal once per chunk rather than once per call. This generalizes the fix already applied to ProximalProjection: add Derivative.linearize (linearize once, get a cheap reusable jvp function) and use it in _jvp_batched whenever there is more than one chunk, falling back to the previous behavior otherwise so the common single-chunk case is untouched. Second/third-order JVPs (compute_jvp2/compute_jvp3, used by perturbation theory) are intentionally left as-is for now. Verified against test_derivatives.py, test_objective_funs.py::test_jvp_scaled, and test_optimizer.py::test_proximal_jacobian, plus a direct chunk-invariance check comparing jac_scaled across chunk sizes on a ForceBalance objective. Note the real-world speedup is problem-dependent: it was substantial for ProximalProjection's SVD-based tangent construction, but negligible for a plain ForceBalance Jacobian at the resolution tested here, likely because that computation is dominated by spectral transform matmuls (linear in the tangent direction, so their cost is unavoidable either way) rather than by the nonlinear residual evaluation this fix targets. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgWTyAewaigF78JQ6gH6Ry
…earize Comment wording: describe the mechanism as jax.linearize's partial evaluation (compute once, reuse the resulting linear map for many tangents -- like factorizing a matrix once and reusing it across right-hand sides), rather than as avoiding a redundant primal recompute, which reads as something a compiler's DCE should already handle. Point to jax.linearize's own docs instead of re-explaining it inline, and trim the comments generally. _constraint_wrappers.py now calls the new Derivative.linearize instead of jax.linearize directly, so there is one implementation of "linearize once" rather than two. It is kept rather than reverted in favor of the general _jvp_batched fix: the two address different redundancies. _jvp_batched's fast path only helps the inner jvp_<op> call inside _get_tangent if that call itself chunks; it cannot reach in and hoist Fxh/its SVD construction out of ProximalProjection's own outer chunking loop over v, since that construction lives in _constraint_wrappers.py, not in _jvp_batched. Recorded that reasoning as a comment at the call site so the "why not just rely on the general fix" question is answered in the code, not just in review discussion. Re-verified: test_optimizer.py::test_proximal_jacobian, test_derivatives.py, test_objective_funs.py::test_jvp_scaled, and an explicit chunk-invariance check on jac_scaled/jvp_scaled all still pass after the refactor. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgWTyAewaigF78JQ6gH6Ry
…ry gap Found while auditing all batched_vectorize(<jvp-producing-fn>) call sites for the same pattern already fixed in _jvp_batched/ProximalProjection: - _Objective._jvp's "fwd" branch (used per sub-objective by ObjectiveFunction._jvp_blocked, e.g. when a "blocked"-mode objective's jac_scaled is built via jvp_scaled) had the identical unfixed pattern. Generalized Derivative.linearize to accept a tuple of argnums (matching compute_jvp's interface) since this call site can differentiate w.r.t. multiple "things" at once, and applied the same chunk_size-gated fast path used in _jvp_batched. - ProximalProjection.grad() was missed when _jvp was fixed -- it has its own separate batched_vectorize(_get_tangent, ...) call and was still using the unfixed _get_tangent. Extracted the hoisting logic (build Fxh/its pseudoinverse once) into a shared _get_tangent_fun helper used by both _jvp and grad, so this can't drift out of sync again. - Fxh's construction was using plain jax.vmap (desc.backend.vmap, no chunking) rather than respecting jac_chunk_size the way the old constraint.jvp_<op> call it replaced did. For a large equilibrium this drops the memory bound chunking exists for. Switched to batched_vectorize with the same chunk_size; f_lin being cheap means chunking it now only bounds memory rather than repeating expensive work. Verified: test_optimizer.py::test_proximal_jacobian, test_proximal_grad, test_derivatives.py, test_objective_funs.py::test_jvp_scaled all pass. Added explicit chunk-invariance checks for grad() and for a "blocked"-mode ObjectiveFunction combining fwd/rev sub-objectives, confirming the new _Objective._jvp path fires (instrumented) and matches the unchunked reference. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgWTyAewaigF78JQ6gH6Ry
jacfwd_chunked (DESC's local reimplementation of jax.jacfwd, used by _Objective.jac_scaled/.grad/.hess for any individual objective with deriv_mode="fwd") had the same raw-jvp-under-chunked-vmap pattern already fixed elsewhere: pushfwd = partial(_jvp, f_partial, dyn_args) re-evaluates fun's primal once per scan chunk instead of once overall. This is likely the most-exercised instance of the four found, since "fwd" is a common default deriv_mode. Uses jax.linearize directly (not Derivative.linearize) since desc.batching is imported by desc.derivatives, so importing back would be circular. Verified in isolation against jax.jacfwd across chunk sizes for both single- and multi-argnum cases (the multi-arg case needed a different calling convention than Derivative.linearize: jax.linearize(fun, *primals) unpacks primals as separate positional args, but the existing _jvp-based code here treats dyn_args as one packed pytree argument matching vmap's in_axes=0 -- wrapping in a single-argument closure keeps that convention). has_aux=True is left on the old path, matching the precedent set for higher-order derivatives elsewhere in this series of fixes. Confirmed via instrumentation the fast path fires exactly once when chunking occurs and is skipped entirely for a single chunk. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgWTyAewaigF78JQ6gH6Ry
jacfwd_chunked, add regression tests, and simplify ProximalProjection Diff-minimization fixes (no behavior change): - objective_funs.py: drop the local chunk_size variable in two places (accessing self._jac_chunk_size is a free attribute read) and collapse the branch-then-duplicate-return pattern into build-jvpfun-then-return-once, matching the original structure more closely. ProximalProjection.grad(): was calling compute_scaled_error(xg) then vjp_scaled_error(g, xg), and vjp_scaled_error's jax.grad internally re-evaluates the primal to build the backward pass -- so the (possibly expensive) objective was evaluated twice. Replaced with one jax.vjp call that returns both the value and a reusable pullback. jacfwd_chunked (desc/batching.py): restructured so the common no-chunking case just defers to jax.jacfwd directly instead of reimplementing it, removing the has_aux special-casing entirely (has_aux now works in the chunked path too via jax.linearize's own has_aux support, verified against jax.jacfwd). Also stopped materializing _std_basis just to read its size for the chunking decision -- the direction count is now computed directly from the input arguments' sizes. jacrev_chunked: checked per request -- it already calls jax.vjp exactly once and only chunks the cheap resulting pullback, so it doesn't have the redundant-primal bug and wasn't changed. Applying the same "defer when unchunked" simplification here would need an extra jax.eval_shape pass just to decide (chunking there depends on output size, which isn't known until after the forward+vjp), so isn't a clean win the way it was for jacfwd_chunked. Regression tests (previously this was only checked with throwaway scripts, not committed): tests/test_batching.py (new), plus additions to test_derivatives.py, test_objective_funs.py, and test_optimizer.py. These assert both numerical chunk-invariance and, via monkeypatching, that linearize is actually called once rather than once per chunk -- the property the whole fix series is about, not just "the numbers match". ProximalProjection simplification: _get_tangent_fun no longer branches on constraint deriv_mode. It always linearizes the constraint's compute_<op> as one function now, rather than falling back to differentiating via constraint.jvp_<op> (dispatching per sub-objective) for "blocked" mode. This turns out not to lose anything: the only objectives ProximalProjection accepts as constraints (ForceBalance, CurrentDensity, RadialForceBalance, HelicalForceBalance) are all dense many-output residuals, so reverse mode is never actually cheaper for them, and Fxh's SVD downstream was already computed on one combined matrix regardless of "batched" vs "blocked" -- there was nothing left for "blocked" mode to win by branching. Verified numerically identical to the old branching behavior for a genuine multi-sub-objective, mixed fwd/rev "blocked" constraint (RadialForceBalance + HelicalForceBalance, which together are equivalent to ForceBalance), both by direct comparison during development and in the new test_proximal_always_linearizes_multi_objective_blocked_constraint test. Old _get_tangent/_proximal_jvp_f_pure (the non-linearized versions) and _get_tangent_linearized/_proximal_jvp_f_linearized_pure are consolidated into single _get_tangent/_proximal_jvp_f_pure implementations. Caught and fixed two test bugs while writing the above: passing jac_chunk_size to a sub-objective (rather than the outer ObjectiveFunction) forces "blocked" deriv_mode per ObjectiveFunction's auto-deriv_mode selection, not "batched" -- so an earlier draft of the batched-mode regression test was silently exercising the blocked-mode code path instead of the one it claimed to test. Fixed by asserting the expected deriv_mode in each test and using the construction that actually produces it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgWTyAewaigF78JQ6gH6Ry
cross-reference vmap_chunked/batched_vectorize docstrings - Restored the op= keyword-argument calling convention at both _get_tangent_fun call sites (grad, _jvp) -- the original code always passed op as a keyword there; an earlier edit had switched to positional. - Inlined _get_tangent as a nested closure inside _get_tangent_fun (now named tangent_fun), and moved the merged method to _get_tangent's original file position (after _jvp) rather than _get_tangent_fun's. Removes the need to thread f_lin/uf/sfi/vtf through as explicit parameters on a separate method -- they're just captured directly now. - Cross-referenced vmap_chunked and batched_vectorize's docstrings: batched_vectorize is built on top of vmap_chunked (calling it once per broadcast dimension implied by a gufunc signature) to add jax.numpy.vectorize-style broadcasting; vmap_chunked is the lower-level primitive, used directly elsewhere in the codebase (e.g. singularities.py) for its reduction/chunk_reduction support that batched_vectorize doesn't expose. They're not duplicative, just layered -- documented that explicitly since the relationship wasn't otherwise obvious from the code. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgWTyAewaigF78JQ6gH6Ry
…nking fix PR PlasmaControl#2239 (upstream) restructures ProximalProjection's tangent computation to form the reduced constraint Jacobian densely once, which is a strictly better fix for the SVD-per-chunk problem than linearizing the per-direction tangent function. Revert _constraint_wrappers.py to its pre-branch state so there's no overlap/conflict with that PR, and remove the two tests that exercised the now-dropped consolidation. The general Derivative.linearize fix in objective_funs.py/batching.py is unrelated and still valuable on its own (and is load-bearing for PlasmaControl#2239's own single jvp_op call once chunked), so it stays. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgWTyAewaigF78JQ6gH6Ry
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Memory benchmark result| Test Name | %Δ | Master (MB) | PR (MB) | Δ (MB) | Time PR (s) | Time Master (s) |
| -------------------------------------- | ------------ | ------------------ | ------------------ | ------------ | ------------------ | ------------------ |
test_objective_jac_w7x | 0.03 % | 3.985e+03 | 3.986e+03 | 1.00 | 31.07 | 29.63 |
test_proximal_jac_w7x_with_eq_update | 3.47 % | 6.534e+03 | 6.761e+03 | 227.04 | 155.08 | 152.65 |
test_proximal_freeb_jac | -0.35 % | 1.341e+04 | 1.336e+04 | -46.28 | 80.32 | 82.21 |
test_proximal_freeb_jac_blocked | -8.77 % | 7.727e+03 | 7.049e+03 | -677.75 | 73.27 | 69.00 |
test_proximal_freeb_jac_batched | -7.53 % | 7.634e+03 | 7.059e+03 | -574.68 | 72.57 | 69.56 |
test_proximal_jac_ripple | -1.43 % | 3.597e+03 | 3.546e+03 | -51.41 | 54.33 | 54.83 |
test_proximal_jac_ripple_bounce1d | 1.59 % | 3.695e+03 | 3.754e+03 | 58.73 | 68.20 | 67.38 |
test_eq_solve | -6.87 % | 2.125e+03 | 1.979e+03 | -145.95 | 54.14 | 53.14 |
test_objective_quadratic_flux_jac | -0.83 % | 2.537e+03 | 2.516e+03 | -21.16 | 53.95 | 53.90 |For the memory plots, go to the summary of |
| benchmark_name | dt(%) | dt(s) | t_new(s) | t_old(s) |
| -------------------------------------- | ---------------------- | ---------------------- | ---------------------- | ---------------------- |
test_build_transform_fft_lowres | +0.51 +/- 4.95 | +4.23e-03 +/- 4.13e-02 | 8.39e-01 +/- 3.1e-02 | 8.35e-01 +/- 2.7e-02 |
test_equilibrium_init_lowres | -1.98 +/- 3.20 | -1.30e-01 +/- 2.10e-01 | 6.42e+00 +/- 1.6e-01 | 6.55e+00 +/- 1.4e-01 |
test_objective_compile_atf | -2.65 +/- 3.52 | -1.55e-01 +/- 2.06e-01 | 5.69e+00 +/- 1.4e-01 | 5.85e+00 +/- 1.5e-01 |
test_objective_compute_atf | -7.21 +/- 9.50 | -2.00e-04 +/- 2.64e-04 | 2.58e-03 +/- 1.3e-04 | 2.78e-03 +/- 2.3e-04 |
test_objective_jac_atf | -3.32 +/- 4.93 | -6.02e-02 +/- 8.94e-02 | 1.75e+00 +/- 7.0e-02 | 1.81e+00 +/- 5.6e-02 |
test_perturb_1 | -3.62 +/- 2.09 | -4.31e-01 +/- 2.49e-01 | 1.15e+01 +/- 1.8e-01 | 1.19e+01 +/- 1.7e-01 |
test_proximal_jac_atf | -3.77 +/- 2.15 | -2.06e-01 +/- 1.18e-01 | 5.27e+00 +/- 4.8e-02 | 5.47e+00 +/- 1.1e-01 |
test_proximal_freeb_compute | -3.49 +/- 4.20 | -5.08e-03 +/- 6.11e-03 | 1.41e-01 +/- 4.4e-03 | 1.46e-01 +/- 4.3e-03 |
test_solve_fixed_iter | -2.06 +/- 2.92 | -5.28e-01 +/- 7.48e-01 | 2.51e+01 +/- 5.2e-01 | 2.56e+01 +/- 5.4e-01 |
test_LinearConstraintProjection_build | -2.60 +/- 3.25 | -1.72e-01 +/- 2.15e-01 | 6.43e+00 +/- 1.0e-01 | 6.60e+00 +/- 1.9e-01 |
test_objective_compute_ripple | +2.65 +/- 6.70 | +5.64e-03 +/- 1.43e-02 | 2.19e-01 +/- 1.3e-02 | 2.13e-01 +/- 6.7e-03 |
test_objective_grad_ripple | -1.43 +/- 3.64 | -1.41e-02 +/- 3.60e-02 | 9.75e-01 +/- 3.0e-02 | 9.89e-01 +/- 2.0e-02 |
test_objective_quadratic_flux_compute | -5.54 +/- 10.47 | -3.24e-03 +/- 6.12e-03 | 5.52e-02 +/- 2.5e-03 | 5.85e-02 +/- 5.6e-03 |
test_build_transform_fft_midres | -0.72 +/- 3.31 | -6.23e-03 +/- 2.86e-02 | 8.59e-01 +/- 1.6e-02 | 8.65e-01 +/- 2.4e-02 |
test_build_transform_fft_highres | +0.54 +/- 3.33 | +6.14e-03 +/- 3.81e-02 | 1.15e+00 +/- 3.1e-02 | 1.14e+00 +/- 2.3e-02 |
test_equilibrium_init_medres | +0.14 +/- 3.80 | +9.79e-03 +/- 2.59e-01 | 6.81e+00 +/- 1.9e-01 | 6.80e+00 +/- 1.7e-01 |
test_objective_compile_dshape_current | -0.30 +/- 3.24 | -1.18e-02 +/- 1.25e-01 | 3.86e+00 +/- 1.2e-01 | 3.87e+00 +/- 4.6e-02 |
test_objective_compute_dshape_current | +10.81 +/- 14.07 | +6.47e-05 +/- 8.42e-05 | 6.63e-04 +/- 7.8e-05 | 5.99e-04 +/- 3.2e-05 |
test_objective_jac_dshape_current | +14.35 +/- 28.13 | +3.29e-03 +/- 6.46e-03 | 2.63e-02 +/- 4.8e-03 | 2.30e-02 +/- 4.3e-03 |
test_perturb_2 | +0.11 +/- 1.39 | +1.69e-02 +/- 2.06e-01 | 1.48e+01 +/- 1.5e-01 | 1.48e+01 +/- 1.5e-01 |
test_proximal_jac_atf_with_eq_update | +0.56 +/- 0.97 | +6.93e-02 +/- 1.21e-01 | 1.26e+01 +/- 8.9e-02 | 1.25e+01 +/- 8.2e-02 |
test_proximal_freeb_jac | +0.26 +/- 4.12 | +1.23e-02 +/- 1.93e-01 | 4.70e+00 +/- 1.5e-01 | 4.69e+00 +/- 1.2e-01 |
test_solve_fixed_iter_compiled | +0.96 +/- 1.85 | +5.91e-02 +/- 1.14e-01 | 6.21e+00 +/- 9.7e-02 | 6.15e+00 +/- 6.0e-02 |
test_objective_compute_ripple_bounce1d | +1.05 +/- 5.50 | +2.91e-03 +/- 1.52e-02 | 2.80e-01 +/- 1.0e-02 | 2.77e-01 +/- 1.1e-02 |
test_objective_grad_ripple_bounce1d | -0.89 +/- 2.14 | -8.73e-03 +/- 2.09e-02 | 9.71e-01 +/- 1.9e-02 | 9.79e-01 +/- 7.7e-03 |
test_objective_quadratic_flux_jac | +0.30 +/- 0.86 | +2.56e-02 +/- 7.34e-02 | 8.58e+00 +/- 2.0e-02 | 8.55e+00 +/- 7.1e-02 |Github CI performance can be noisy. When evaluating the benchmarks, developers should take this into account. |
|
The failing unit test is related to |
|
Ok, #2287 will resolve the test issue. I ran this script with different The above plots show the memory usage for chunk sizes of The memory usage for very low chunk sizes increases quite a bit, whereas the evaluation is 2 times faster. They converge to the same speed and the same memory as the chunk size decreases. I checked these on my laptop RTX4080 12GB, having similar thing on cluster A100 would help. I think we need to test this a bit more. We usually accept the slowdown for less memory usage. |
|
This is exactly what I'd expect to see, there is essentially a constant memory overhead equal to keeping the residuals in memory. For a chunk size of one this will be noticeable and its proportional controbution will decrease proportional to chunk size. I think it all depends what chunk size you need to run in production. If its typically O(1) then maybe we should gate linearising to skip when chunk size is small (i think even 1-2 will be reasonable). As i said though if you are running with chunk sizes of 1-2 already my gut feel is youd be better off with iterative Krylov solvers in those cases. |
|
Yeah, one weird thing tho (which I realised before but worth mentioning here), sometimes doing the operation in chunks is faster than no chunking or bigger chunks. I guess this depends on the hardware you have and the vectorization capabilities. I mean, sometimes I choose a lower chunk size not for memory purposes but for speed too. I will try to check different types of problems on A100. DESC has many objectives and many options, so it is hard to say whether this is the production run. In the meantime, people are welcome to test it using my repo; it should be quite straightforward when you have a script. |
|
Im surprised that some medium chunk sizes seemed to show a slowdown but I'm guessing this is noise. Also note that for smaller chunk sizes you need more chunks so the speedup might actually be more worth it. I think the interesting thing to check is if you are running at the largest chunk size that fits on an A100 and this PR forces you to shrink that chunk size slightly whether the linearisation speedup compensates for the extra chunks required. |

This should provide a runtime and compile time improvement at the possible cost of a very slight peak memory increase. I think if this pushes you over the limit you are probably in the region where you should be switching to iterative rather than direct solvers.
jax.linearizecaches the residuals from the primal pass just likejax.vjpdoes which means sequentially evaluating (chunks of) jvp's it will be a lot faster. For example, if Proximal was fully jittable and went through this route this would have allowed the reuse of the SVD decomposition automatically. As it stands Proximal does not directly benefit but the improvement @YigitElma is making in #2239 should chain nicely with this (it would be interesting to see the benchmarks for these two PR's combined).@unalmis this should probably be upstreamed in adv-jax-math, lmk if you'd like me to make a PR there.