Nonlinear reverse: single adjoint solve instead of dense full-parameter sensitivity#369
Nonlinear reverse: single adjoint solve instead of dense full-parameter sensitivity#369yeabbratz wants to merge 3 commits into
Conversation
For NonLinearProgram models, reverse_differentiate! formed the dense parameter sensitivity ds = -K \ N ((kkt_dim x P) columns) and then contracted it with the seed. A reverse request is a vector-Jacobian product, so one adjoint solve suffices: dp = -N' * (K' \ dw), with the JuMP-convention sign adjustments applied to the seed entries instead of the rows of ds, and the objective seed folded into the same right-hand side. Same factorization, same inertia-correction/singular fallback, same ReverseCache contract; forward mode still uses _compute_sensitivity unchanged. Results are numerically equivalent (solve-order change only, ~1e-11 relative); a reverse-vs-forward consistency test is added.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #369 +/- ##
==========================================
+ Coverage 91.88% 91.94% +0.06%
==========================================
Files 17 17
Lines 2722 2745 +23
==========================================
+ Hits 2501 2524 +23
Misses 221 221 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Updated for review: applied Note on the formatting commit: the files it touches are all under |
Motivation
For
NonLinearProgrammodels,reverse_differentiate!currently forms the dense parametersensitivity
∂s = -K⁻¹N(kkt_dim × P— one forward solve per parameter) and only then contractsit with the seed. A reverse request is a vector–Jacobian product, so only one adjoint vector is
needed: the dense solve is O(P) wasted time and memory. On a downstream differentiable-ACOPF
project this dominated the backward pass (12 s / 20 GiB per backward at 3633 parameters) and
blocked large instances.
Change
reverse_differentiate!(::NonLinearProgram.Model)now computesvia a new
_compute_sensitivity_adjointinnlp_utilities.jl, instead of materializing∂s.Details that keep it exactly the quantity the dense path contracted:
_compute_sensitivityapplies to the dual rowsof
∂sare applied to the corresponding entries of the seed (algebraically identical);df_dp = ∇ₓfᵀ ∂s[1:n,:] + ∇ₚfᵀ, soits
∂s-dependent part rides along as∇ₓf · dobjadded to the primal seed entries, and thedirect
∇ₚf · dobjterm is added after the solve) — mixeddx/dy/dobjrequests still takeone adjoint solve;
K === nothing⇒ zero solve component);forward_differentiate!and_compute_sensitivityare unchanged; no public API change, nonew flag — the reverse path is a VJP by definition, so it always benefits.
Results
In-tree reproducer (script below; sparse parameterized NLP, chain-coupled quartic objective + one
nonlinear inequality per variable; Ipopt; median of 5, M4 Pro):
(The "dense" column runs the pre-PR contraction via the still-shipped
_compute_sensitivity, sothe comparison is exact; at small P the two are within noise.)
Downstream benchmark that motivated this (parameterized ACOPF, PGLib cases, Ipopt, DiffOpt 0.6.0
with this change applied as a local override): per backward at
case2000_goc__api(P = 3633)9.6 s / 20.2 GiB → 0.94 s / 0.70 GiB (×10.2 time, ×29 allocation); the speedup grows with P
(×1.1 / ×1.2 / ×2.3 / ×3.6 / ×10.2 across case14/30/179/500/2000). Reverse allocation now tracks
KKT assembly, not KKT×P.
Correctness
Same quantity,
Δp = -Nᵀ K⁻ᵀ Δw; the only difference from the dense path is floating-point solveorder (one transposed solve instead of P forward columns), so results are numerically
equivalent but not bitwise — ≤1.5e-15 relative on the reproducer above, ~1e-11 relative on the
ill-conditioned ACOPF KKT systems downstream (≈4 orders below Ipopt's 1e-8 tolerance, and
validated there against central finite differences at two operating points). In-tree:
dx,dyand
dobjseeds and the FiniteDiff-validated ones, which now exercise the adjoint path;test_reverse_adjoint_matches_forward_jvp) cross-checks reverse (VJP) againstforward (JVP) component-wise,
⟨Δw, ∂x/∂pᵢ⟩ + dobj·∂f/∂pᵢ == Δpᵢ, across parameter counts andwith simultaneous primal + objective seeds.
Reproducer
bench_reverse_adjoint.jl(standalone; needs JuMP + Ipopt + this branch)