Add adaptive Jacobian reuse to first-order solvers - #1072
Conversation
|
Implementation scratchpad for commit
Benchmarking showed wins for expensive dense/sparse factorizations but overhead and policy sensitivity on small/problem-library cases, so the solver default remains exact Newton. |
6dd2157 to
99348eb
Compare
|
Published final stacked head Safety and history checks:
Final local outputs include:
CI is now running on the combined stack, which includes both focused prerequisite fixes before the unchanged feature commit. |
99348eb to
63e043b
Compare
|
Final-stack update at
The PR description now records the actual stack, benchmark/default decision, baseline-failure investigations, and observed validation. Refreshed combined-stack CI is running. |
63e043b to
ed4d653
Compare
|
Published rebuilt combined head Before replacing the old
New local evidence includes:
The superseded combined CI run finished 90 successful, 3 skipped, and 4 failed. Two Ubuntu failures were the already-audited Brown unexpected-pass behavior. The two macOS failures were the allocation issue addressed by the cached destination. Fresh CI is now running; Apple ARM must pass before the cross-platform fix is claimed. |
ed4d653 to
5b9e07d
Compare
|
Published rebuilt combined head Safety audit immediately before publication:
The prior Apple prerelease job is important negative evidence: it still measured exactly 772.0/400.0 B per sweep/arclength step after the cached Jacobian destination. The remaining allocation came from constructing a complete solution for every interior corrector. The new helper runs those correctors to completion in the reused cache; ordinary solve results, anchors, landings, and failure originals are unchanged. Final local evidence includes cache-only 0 B with same-cache identity, end-to-end 0.0/0.0 B per step, Base Core on Julia 1.12 and 1.10, all 39 continuation files, Base QA 18/18, ForwardDiff cache reinit 1/1, Runic 1.7, and |
|
Final-tree local integration update for
Fresh Apple ARM Core checks remain queued and are still required for the cross-architecture allocation claim. |
|
Apple ARM prerelease result on feature head
|
|
Diagnostic rerun correction at
|
|
Apple allocation profile root cause and upstream follow-up:
|
|
Upstream Apple-silicon validation update for LinearSolve #1099:
These are direct LinearSolve macOS tests of the fixed Apple Accelerate path. They do not replace the required NonlinearSolve end-to-end continuation rerun. The combined PR therefore still makes no downstream cross-platform allocation claim until #1099 is merged/released and this branch is retested against that dependency. |
|
Fresh exact-graph validation of the stacked #1057 prerequisite:
|
cfb8e2d to
6ffbd6f
Compare
|
Allocation regression update: the GenericLU workspace fix has been reviewed and merged upstream in LinearSolve at SciML/LinearSolve.jl#1195. The blocked-LU packing buffer and pivot storage are now allocated during cache initialization/resize and reused by solve!, with AllocCheck.jl plus runtime zero-allocation QA coverage. On the exact local NonlinearSolve sweep, the before value was 61.76 bytes/step and the fixed LinearSolve checkout measured sweep_per_step=0.0. LinearSolve main has been bumped to 5.9.0, but no v5.9.0 tag/release is published yet, so these existing checks cannot pick up the fix until that release is available or the workflow is rerun against the merged commit. |
|
Claude Code review — 2 finding(s) normal — lib/NonlinearSolveFirstOrder/src/solve.jl:376-380The new line-search stale-Jacobian retry at nit — lib/NonlinearSolveBase/test/linsolve_workspace.jl:66-71The new testset at lib/NonlinearSolveBase/test/linsolve_workspace.jl:66-71 ("arrays without fast scalar indexing use pinv") is a verbatim duplicate of the pre-existing testset with the same name and identical body at lines 42-47. Remove the duplicate — it wastes CI time and clutters the file, though it does not affect correctness. Generated by Claude Code |
Review findings addressed (2)Ran an automated deep review over this PR; two findings, both fixed on this branch. 1.
|
|
Pushed as a9849a5.
Zero errors, zero failures across the group. Base branch check: 🤖 Generated with Claude Code (model: claude-opus-5[1m]) |
a9849a5 to
3663b74
Compare
|
Force-pushed a rebase onto master What changed in the rebase
Verification on the pushed tree (Julia 1.12.4): FirstOrder 🤖 Generated with Claude Code (model: claude-opus-5[1m]) |
|
CI on Everything this PR touches is green: all 🤖 Generated with Claude Code (model: claude-opus-5[1m]) |
Add an opt-in `JacobianReuse(; max_age, max_residual_ratio)` policy for the first-order solvers. While the residual keeps contracting and the Jacobian is younger than `max_age` accepted steps, the current Jacobian (and, for an unchanged concrete linear system, its factorization) is reused. A rejected trust-region step or a failed line search or linear solve on a stale Jacobian requests a fresh one. `reinit!` resets the policy state. `step!(cache; recompute_jacobian = true)` now always recomputes, matching the documented `step!` contract; previously it also required the cache to want a new Jacobian. Rebased over the deferred-residual `step!` (`evaluate_residual = false`): a deferred step has no residual for the policy to look at, so it schedules a fresh Jacobian. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Agent-Harness: Claude Code 2.1.251 Agent-Model: claude-opus-5[1m] Agent-Session: https://claude.ai/code/session_013vbihgEfx7VCEa4W3ccccG
The line-search stale-Jacobian retry splatted `cache.kwargs` into `InternalAPI.step!`, whose `GeneralizedFirstOrderAlgorithmCache` method takes only `recompute_jacobian` and `evaluate_residual`, so any user keyword retained in `cache.kwargs` (`alias_u0`, `callback`, `saveat`, ...) turned the retry into a `MethodError`. Match the linear-solve retry and pass only `recompute_jacobian`. The existing testset could not catch this because every keyword it passed was consumed by name in `__init`, leaving `cache.kwargs` empty; it now also passes `alias_u0 = false`, which is retained. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Agent-Harness: Claude Code 2.1.251 Agent-Model: claude-opus-5[1m] Agent-Session: https://claude.ai/code/session_013vbihgEfx7VCEa4W3ccccG
- Run the policy where the residual at the new iterate actually becomes available, so a step taken with `evaluate_residual = false` no longer forces a refresh and then never re-arms the policy. - Skip building the reuse cache for matrix-free Jacobians: the stateful operator is rebound to the current iterate on every step, so nothing is reused and nothing goes stale. Previously the policy counted age anyway and a failed line search recursed once for nothing. - Store the policy in the reuse cache instead of threading it alongside, drop the `mark_jacobian_refresh!` alias, and drop the `!new_jacobian` conjunct that `jacobian_is_stale` already implies. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Agent-Harness: Claude Code 2.1.251 Agent-Model: claude-opus-5[1m] Agent-Session: https://claude.ai/code/session_013vbihgEfx7VCEa4W3ccccG
NonlinearSolveFirstOrder gains a public `JacobianReuse` and a `jacobian_reuse` keyword, so it goes to 2.5.0. The root polyalgorithm now passes that keyword through unconditionally, so the root requires NonlinearSolveFirstOrder 2.5 and goes to 4.29.0. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Agent-Harness: Claude Code 2.1.251 Agent-Model: claude-opus-5[1m] Agent-Session: https://claude.ai/code/session_013vbihgEfx7VCEa4W3ccccG
`jacobian_reuse` is a field of the `@concrete` algorithm struct, so a policy chosen from a runtime property of the problem — the obvious one being `length(u0)` — would give `NewtonRaphson()` two types, and with it two algorithm types, two solver cache types, and a `Union` return from `solve`. Measured before this commit: `@inferred solve(prob, NewtonRaphson(jacobian_reuse = length(u0) > 25))` fails, and the inferred cache type grows from 5131 to 10386 characters. Disabled reuse is now spelled `max_age = 1`, which already means "recompute after every accepted step", so the whole policy fits in one concrete type and the reuse cache is always built. `resolve_jacobian_reuse` is where a future default can consult the problem; it still returns the disabled policy, so behavior is unchanged. Two guards keep `max_age = 1` exactly equal to the previous `nothing`: `reuses_jacobian` gates the stale-Jacobian tests that `age > 0` alone would now answer differently on the manual `step!(cache; recompute_jacobian = false)` path, and the reuse cache skips its residual norm when the policy is off. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude <noreply@anthropic.com> Agent-Harness: Claude Code 2.0.14 Agent-Model: claude-opus-5[1m] Agent-Session: https://claude.ai/code/session_01CKHupdFDQQCFcEgqThh6Mz
`resolve_jacobian_reuse` now enables reuse when `length(u0) >= 16`, and the default `max_residual_ratio` drops from 1 to 0.1. Both numbers come from a sweep over dense-AD, dense-analytic-Jacobian, sparse-Brusselator and sparse-Bratu families with `NewtonRaphson` and `TrustRegion`, min-of-N wall time. The cutoff is where the cheap-Jacobian families stop losing: below 16 an analytic dense Jacobian runs at 0.90-1.06x and 1D Bratu at 0.99-1.01x, while at 16 and above every family measured is at or above baseline and dense AD Jacobians are already at 1.32-1.37x. `max_residual_ratio = 1` reuses while the residual improves at all, which is almost always true, so it runs to `max_age` every time. Across 30 problem-and- algorithm cases at or above the cutoff it has the best geometric mean (1.51x) and the worst tail (0.77x on a sparse Brusselator at n = 8192, and a `Success` turned `Stalled` at n = 512 with `max_age = 100`). A ratio of 0.1 gives up little of the mean (1.38x) and never fell below 1.02x. `step!` no longer takes the stale-Jacobian retry when the caller passed `recompute_jacobian` explicitly. OrdinaryDiffEqNonlinearSolve always does, so turning the default on cannot change how an ODE solver's Newton iteration behaves on a failed line search. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude <noreply@anthropic.com> Agent-Harness: Claude Code 2.0.14 Agent-Model: claude-opus-5[1m] Agent-Session: https://claude.ai/code/session_01CKHupdFDQQCFcEgqThh6Mz
3663b74 to
f3c9c38
Compare
|
Two commits since the last revision, both about the default rather than the mechanism.
Rebased onto Follow-up on replacing the size cutoff with a cost-based heuristic: #1216 |
The previous commit left two type-level distinctions behind. `jacobian_reuse = nothing` reached the algorithm struct as `Nothing`, so `NewtonRaphson()` and `NewtonRaphson(jacobian_reuse = false)` were different types; and a matrix-free Jacobian got a `nothing` reuse cache, so `reset_jacobian_reuse!`, `jacobian_is_stale` and `prepare_next_jacobian!` each carried a second method for it. `nothing` now normalizes to `JacobianReuse(JACOBIAN_REUSE_AUTO, ...)` before it reaches the struct, and a matrix-free Jacobian gets the same cache with the policy switched off rather than no cache. Every spelling of the keyword lands on one concrete type and every hot-path function has one method. `max_age = 0` is the canonical "off"; `1` still parses and means the same thing, since a Jacobian serving one step is an exact Newton step. Validation moves from `max_age > 0` to `max_age >= JACOBIAN_REUSE_AUTO`. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude <noreply@anthropic.com> Agent-Harness: Claude Code 2.0.14 Agent-Model: claude-opus-5[1m] Agent-Session: https://claude.ai/code/session_01CKHupdFDQQCFcEgqThh6Mz
|
Both are gone. so every spelling of the keyword is one concrete type and every hot-path function has one method. The testset grew by three assertions that check exactly this and would have failed before the commit: @test typeof(alg()) === typeof(alg(jacobian_reuse = false)) ===
typeof(alg(jacobian_reuse = true)) === typeof(alg(jacobian_reuse = JacobianReuse()))Verified on the pushed tip: Worth a look from a reviewer: the encoding puts a negative sentinel in the same field as a count, so validation is |
The `JacobianReuse` docstring is rendered by a `@docs` block, and it had picked up a `[`JACOBIAN_REUSE_AUTO`](@ref)` link to a constant that has a docstring but no `@docs` entry. With `checkdocs = :exports` and no `warnonly` that terminates the build: Error: Cannot resolve @ref for md"[`INTERNAL_KNOB`](@ref)" in docs/src/index.md. ERROR: `makedocs` encountered an error [:cross_references] reproduced on a two-file Documenter project of the same shape. The cutoff and the auto sentinel are tuning constants whose values move with the benchmarks, so the fix is to describe the behavior in prose with the numbers interpolated rather than to promote them to public API. Also brings the surrounding prose in line with the one-type policy: `GaussNewton` documents `jacobian_reuse` as a keyword like the other solvers instead of in a paragraph, and the manual explains `max_age` and that every spelling of the keyword yields the same algorithm type. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude <noreply@anthropic.com> Agent-Harness: Claude Code 2.0.14 Agent-Model: claude-opus-5[1m] Agent-Session: https://claude.ai/code/session_01CKHupdFDQQCFcEgqThh6Mz
|
The one-type commit put a
Swept the rest of the feature's docs for the same class of problem while I was there:
All five solver kwarg docstrings interpolate the cutoff from the constant, so they cannot drift from it. The paragraph claiming an explicit No functional change since |
- Require all components to stall before resetting Broyden (#1184) - Add adaptive Jacobian reuse to first-order solvers (#1072) - Release 4.29.1 (#1220) - Release 2.49.1 (#1221) - Release 1.15.3 (#1222) - Fix @static_timeit for TimerOutputs ≥ 0.5.27 (#1224) (#1225) - NonlinearSolveBase: rebuild a pre-wrapped function when its signatures do not match u0/p (#1226) - Fix reverse AD through despecialized parameters (#1228) - Preserve Jacobian cache parameter representation (#1227) - Preserve TrustRegion cache parameter representation on reinit (#1229) Agent-Harness: Claude Code Agent-Model: claude-opus-5[1m] Claude-Session: https://claude.ai/code/session_014FEzNTLFutCmTEAZ3zBg5R Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Remove name duplicates in keyword arguments/named tuples (#1238) - Add 32-bit Core CI lane via test_groups.toml (#1239) - Add a native bounded trust-region solver (#1219) Agent-Harness: Claude Code Agent-Model: claude-opus-5[1m] Claude-Session: https://claude.ai/code/session_014FEzNTLFutCmTEAZ3zBg5R Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
What
Adds a
JacobianReuse(; max_age = 10, max_residual_ratio = 0.1)policy to the first-order solvers (NewtonRaphson,TrustRegion,GaussNewton,LevenbergMarquardt,PseudoTransient, and the first-order polyalgorithms) via ajacobian_reusekeyword. While the residual keeps contracting fast enough and the Jacobian is younger thanmax_ageaccepted steps, the Jacobian — and, for an unchanged concrete linear system, its factorization — is reused. A rejected trust-region step, or a failed line search or linear solve on a stale Jacobian, requests a fresh one.The default is on for
length(u0) ≥ 16and off below it.jacobian_reuse = falseforces exact Newton steps and reproduces the previous behavior exactly;jacobian_reuse = JacobianReuse()forces reuse on at any size.Rebased onto current master (
38b61b13).Design: the policy is a value, not a type
jacobian_reuseis a field of the@concretealgorithm struct, so a policy chosen from a runtime property of the problem would giveNewtonRaphson()two types. Measured on the earlier revision of this branch, which encoded "off" asnothing:The whole policy now fits in one concrete type, and
max_agecarries the decision:nothing,false,trueand an explicitJacobianReuseall normalize toJacobianReuse{Float64}before reaching the algorithm struct, soNewtonRaphson(),NewtonRaphson(jacobian_reuse = false)andNewtonRaphson(jacobian_reuse = JacobianReuse())are one type;resolve_jacobian_reusethen varies only theInt. This mirrorsFastShortcutNonlinearPolyalg, whereu0_lenalready picks astart_index::Intrather than a type. A matrix-free Jacobian gets the same cache with the policy switched off rather than no cache, soreset_jacobian_reuse!,jacobian_is_staleandprepare_next_jacobian!each have exactly one method.Two guards make
max_age = 0exactly equal to the oldnothingrather than approximately:reuses_jacobiangates the stale-Jacobian tests that a bareage > 0would now answer differently on the manualstep!(cache; recompute_jacobian = false)path, and the reuse cache skips its residual norm when the policy is off. Themax_age = 0 reproduces exact Newtontestset asserts identicalnjacs,nfactors,nstepsandu.The one leaky part of the encoding is that a negative sentinel shares a field with a count, so validation is
max_age >= JACOBIAN_REUSE_AUTOrather thanmax_age >= 0. The alternative — a separateBoolfield — reads worse at the use sites.Where the cutoff of 16 comes from
Speedup of the default policy over
jacobian_reuse = false, min-of-N wall time, three problem families × two algorithms:dense_adis a dense system with an AD Jacobian (C_J ≈ nresidual evaluations),analyticJis the same system withf.jacsupplied,bratu1dis a sparse tridiagonal Bratu problem — the cheapest Jacobian of the three. 16 is the smallest size at which nothing measured is below baseline while the expensive-Jacobian family is already at 1.32–1.37x. Below it the cheap-Jacobian families run at 0.90–1.06x.This is a noise-band crossing, not a sharp one: from n = 12 to n = 24 the cheap-Jacobian families move about ±4% run to run.
Why
max_residual_ratiodropped from 1 to 0.1max_residual_ratio = 1reuses while the residual improves at all, which is almost always true, so it runs tomax_ageevery time. Over 32 problem-and-algorithm cases at or above the cutoff (dense AD, dense analytic, expensive residual, Brusselator 2D sparse at n = 128…8192, Bratu 1D sparse at n = 100…5000),max_age = 10throughout:max_residual_ratioA permissive ratio has the better mean and the worse tail — 0.767x on the sparse Brusselator at n = 8192, and with
max_age = 100it turns aSuccessinto aStalledat n = 512. 0.1 gives up almost none of the mean and never fell below 1.016x.Other behavior to review
step!no longer takes the stale-Jacobian retry when the caller passedrecompute_jacobianexplicitly.OrdinaryDiffEqNonlinearSolvealways does (recompute_jacobian = nlsolver.iter == 1 && (cache.W === nothing || cache.new_W)), so turning the default on cannot change how an ODE solver's Newton iteration behaves on a failed line search.step!(cache; recompute_jacobian = true)now always recomputes. Previously it also required the cache to want a new Jacobian. This is what thestep!docstring in NonlinearSolveBase already promises.JacobianCache{<:JacobianOperator}rebinds aStatefulJacobianOperatorto the current iterate on every call, so nothing is cached and nothing goes stale; the reuse cache is not built for them.evaluate_residual = false, from Let a driver defer the residual evaluation that ends a step #1167) drive the policy fromrefresh_residual!, where the residual at the new iterate actually becomes available.NonlinearSolveFirstOrder = "2.5"compat — the root polyalgorithm passesjacobian_reusethrough unconditionally, so a root resolved against FirstOrder 2.4.x would throw fromFastShortcutNonlinearPolyalg().:linsolve_failed_noncurrentverbosity key rather than adding a new one.Verification (Julia 1.12, Linux x86_64)
Against master at
c498eaff,jacobian_reuse = falseon this branch reproduces master exactly — identicalnf,njacsandnstepson all 22 (problem × algorithm) pairs (scalar,SVector, dense n = 2…200, sparse Bratu, Brusselator;NewtonRaphsonandTrustRegion), with wall times within noise on a loaded machine.Not verified
JULIA_CONDAPKG_BACKEND=Nullwith the system Python:SciMLBasePythonCallExtcannot precompile on this machine because CondaPkg has no PyPI access, anddocs/make.jlloadsNonlinearSolveSciPy. That failure is environmental and predates this branch.CoreandPolyAlgorithmsruns above predate the last two commits;lib/NonlinearSolveFirstOrderGROUP=CoreandGROUP=QAwere re-run on the pushed tip.lib/NonlinearSolveFirstOrderGROUP=CoreandGROUP=QAwere re-run on the pushed commitf3c9c38aand pass there too (Adaptive Jacobian reuse 125/125, TrustRegion 1176/1176, General NLLS 480/480, QA 28/28, exit 0). The three commits the rebase brought in touch only the NLsolveJL extension and two sublibrary version bumps.What a reviewer should push back on
NonlinearProblemLibrarycases, a permissive policy landed on a different solution than exact Newton on 4 of 23; atmax_residual_ratio = 0.1that is 1 of 23. All of those problems are below the cutoff so the shipped default does not hit them, but a user above the cutoff on a multi-root problem can get a different (still valid) root than before. This is a defaults change with a visible output change, shipping under a minor bump.length(u0)is knowingly a proxy for the wrong thing. What decides the payoff is the cost of a Jacobian relative to the cost of a nonlinear step. A 10,000-unknown sparse tridiagonal system gains ~1.0x; a 25-unknown dense system with an AD Jacobian gains 1.9x. Both sit above the cutoff. Follow-up with the measured cost model and the static predictors that could replace it: JacobianReuse default should key off Jacobian cost, not length(u0) #1216linsolve = KrylovJL_GMRES()and noconcrete_jac,njacs == 0and the policy is inert; every setting timed within 0.2% of baseline at n = 512 and n = 2048. The size default enables a policy that cannot act there.🤖 Generated with Claude Code (model: claude-opus-5[1m])
https://claude.ai/code/session_01CKHupdFDQQCFcEgqThh6Mz