Always evaluate the Jacobian implicitly, and add an AD option - #154
Open
oameye wants to merge 1 commit into
Open
Always evaluate the Jacobian implicitly, and add an AD option#154oameye wants to merge 1 commit into
oameye wants to merge 1 commit into
Conversation
Compiling `eom.jacobian`, the Jacobian of the rearranged system, was preferred whenever the field was filled. The rearrangement inverts the mass matrix symbolically, so the expression it produces grows explosively with the number of harmonics: a two-tone Duffing with nonlinear damping reaches 366698 nodes, and LLVM spends about ten minutes compiling it on the first call. That is the whole of issue #20; the homotopy solve itself takes 0.07s. The implicit route reaches the same matrix at a steady state by solving `J1 x = -J0` once the parameters have values, and stays at ~1000 nodes on the same system. It is now used unconditionally, so the rearranged Jacobian is never compiled. Issue #20 goes from 626s to 0.23s warm, with the same 9 branches, 3 real and 2 stable. Also: - `ldiv!(lu!(J1; check=false), J0)` instead of `-inv(J1) * J0`, which is a sevenfold cut in the per-call cost. `check=false` matters: solution arrays are NaN-padded and a Jacobian at a padded entry has to come back NaN, not throw. - `cse=true` when building the compiled matrices. Jacobian entries share most of their subexpressions, so this cuts the first-call LLVM by 2-3x on larger systems (18.6s to 6.7s at 12 variables) and about a tenth off the per-call cost, to the last bit of the same result. - `jacobian_backend` on `get_steady_states` and `HomotopyContinuationProblem`, which differentiates the harmonic equations with any ADTypes backend instead of symbolically. Off by default.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #20.
The problem
Compiling
eom.jacobian, the Jacobian of the rearranged system, was preferred whenever that field was filled. The rearrangement inverts the mass matrix symbolically, so the expression grows explosively with the number of harmonics. Staged timings for the system in #20 (two drive tones,η*x^2*d(x,t)):The 583s is LLVM compiling a single 366,698-node expression. Nothing was wrong with the solve; it was all in a matrix that gets used only to check stability.
The fix
The implicit route reaches the same matrix by solving
J1 x = -J0once the parameters have values, and stays at ~1000 nodes on the same system. It is now used unconditionally, so the rearranged Jacobian is never compiled.The two agree only at a steady state, which is the only place the Jacobian is ever evaluated: away from one they differ by
∂(M⁻¹)/∂u ⋅ f(u), which vanishes whenf(u) = 0. Verified across five systems: max|ΔJ|and max eigenvalue difference both ≤ 2.5e-15 at every real steady state.Node counts, rearranged vs implicit:
ηIssue #20 goes from 626s to 0.23s warm, same 9 branches, 3 real, 2 stable. The van der Pol first call goes from 865s to 0.2s. End-to-end on 100-point sweeps of the small systems, where the rearranged Jacobian was cheap, the difference is in the noise (-4.3% to +2.6%).
Also in here
ldiv!(lu!(J1; check=false), J0)instead of-inv(J1) * J0, a sevenfold cut in per-call cost (3.9µs to 0.56µs).check=falseis load-bearing:lu!defaults tocheck=trueand throws on NaN, whereas solution arrays are NaN-padded bypad_solutionsand a Jacobian at a padded entry has to come back NaN. There is a regression test for this.cse=truewhen building the compiled matrices. Jacobian entries share most of their subexpressions, so this cuts the first call by 2-3x on larger systems and about a tenth off per-call, bit-identical results:This is also what ModelingToolkit does by default when generating a Jacobian.
jacobian_backend, opt-in, differentiates the harmonic equations with any ADTypes backend rather than symbolically:Defaults to
nothing, so the implicit path is unchanged unless asked for.DifferentiationInterfaceis a dependency; the AD package itself is the caller's to load.DI backends take real inputs, but the Jacobian must accept
ComplexF64. The harmonic equations are polynomial, hence holomorphic, so a perturbation along the real axis already carries the full complex derivative: differentiatingx -> [Re f; Im f]at fixed imaginary part and recombining gives the exact complex Jacobian. Agreement with the implicit route is 1.2e-16 including at genuinely complex solutions, so no complex-dual machinery is needed and stock backends work.Worth knowing before reaching for it: on this workload AD costs far more to set up than it saves per call, and the gap widens with system size (at 12 variables, 77-205s of setup against 4.3s, to save tens of microseconds per call across ~10^3 calls). It is useful at small sizes and for differentiating through a sweep, not as a default.
AutoEnzymesegfaults on RuntimeGeneratedFunctions and is not usable here.A trap worth flagging for review
The first version of the AD path called
prepare_jacobianonce and reused the preparation object, which is the obvious performance move. It produced 25 stable solutions instead of 26 on a Duffing sweep. The Jacobians agreed to 1.2e-16 and the eigenvalues were solidly negative, so the matrix was never wrong:classify_solutionsevaluates the Jacobian underThreads.@threads, and a DI preparation object holds buffers the backend writes into, so sharing one across threads races and silently misclassifies the odd solution.The shared preparation is gone, and there is a test that runs the classification repeatedly and compares against the symbolic result, since this reproduces intermittently rather than always.
Testing
Full suite passes. New
test/steady_states/jacobian.jlcovers: the implicit and rearranged Jacobians agreeing at steady states on three systems where the rearranged one is small enough to compile; NaN padding returning NaN rather than throwing; #20 solving and classifying without the rearranged Jacobian; and the AD backend matching, including the threading case above.