Repository navigation
Reuse the init-time Jacobian on the first Newton step (stacked on #1365) - #1367
ChrisRackauckas-Claude wants to merge 5 commits into
Conversation
`construct_jacobian_cache` stored the solver's residual array as the `JacobianCache` output buffer, so every in-place AD Jacobian evaluation overwrote the solver's `fu` with the dual-number primal of f(u). That primal is not bitwise equal to a plain f call (e.g. `sin` of a Dual goes through `sincos`), so the residual used by the step and the termination check silently changed after each Jacobian evaluation. Use the cache's own `fu_cache`, which is already allocated for DI preparation. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Agent-Harness: Claude Code Agent-Model: claude-opus-5-5 Agent-Session: https://claude.ai/code/session_01LPHREnnonfLg1VcE1EJovv
`construct_jacobian_cache` evaluates the Jacobian at u0 to build the linear solver cache, and the first `step!` evaluated it again at the same point. Record in `JacobianCache` whether construction evaluated J, expose it to solver packages as `InternalAPI.jacobian_computed_at_construction`, and reuse that J on the first step of GeneralizedFirstOrderAlgorithm (NewtonRaphson, TrustRegion, LevenbergMarquardt, PseudoTransient, ...) and of QuasiNewton methods with a true-Jacobian initialization. Reuse is dropped on `reinit!`, after the first step, and when `u` no longer equals the construction state (polyalgorithms with `alias_u0 = true` share `u`). DampedNewtonDescent's `:simple` mode damped the aliased Jacobian cache storage in place during `init`; it now damps a copy so the reused first step Jacobian is not damped twice. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Agent-Harness: Claude Code Agent-Model: claude-opus-5-5 Agent-Session: https://claude.ai/code/session_01LPHREnnonfLg1VcE1EJovv
Independent review (Devin CLI 3000.11.3, model fusion-claude-opus-5-5-high-sidekick-swe-2-medium): MERGE, risk medium. Full reviewVERDICT: MERGE Merge only after #1365 (#1365), which is still open. The risk rating is medium because the change sits on the first iteration of every GeneralizedFirstOrderAlgorithm solve and every true-Jacobian QuasiNewton solve. No public API changes. Blocking findingsNone. Non-blocking findings
What I ranAll of this was in
Rerun of the PR's new tests (confirmed):
The tests fail before the change and pass after it. The assertions have real force:
Neighbouring tests on head (confirmed):
Independent bitwise A/B, base vs head (confirmed):
Results:
This reproduces the PR's "bitwise identical, only njacs changes" claim on an independent configuration set. Edge probes (
Formatting, spelling and QA (confirmed):
Code reading:
What I did not verify
🤖 Posted by an AI agent — harness: Devin CLI 3000.11.3 (review), Claude Code 2.1.285 (posting) · model: fusion-claude-opus-5-5-high-sidekick-swe-2-medium |
Define the helper as a top-level NonlinearSolveBase function, declared public next to reused_jacobian, with a docstring and a devdocs entry, instead of an undocumented InternalAPI function. NonlinearSolveFirstOrder and NonlinearSolveQuasiNewton import it like reused_jacobian, so their QA configs no longer exempt it from the all_qualified_accesses_are_public check. Add a NonlinearSolveBase unit test covering each construction path. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Harness: Claude Code 2.1.301 Agent-Model: claude-opus-5-5 Agent-Session: local session ID ff2c686d-08da-4517-80f8-8868d37c73f8 (headless claude -p, no URL)
Bump NonlinearSolveBase to 2.56.0 for its public Jacobian-construction predicate and raise the two consuming solver packages' compat floors. Record the sibling API compatibility requirement in the repository guidance. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Codex <noreply@openai.com> Agent-Harness: Codex CLI 0.163.0 Agent-Model: gpt-6.1-sol Agent-Session: local session, transcript /home/crackauc/sandbox/goals/performance/jobs/nls/pr-first-jac-reuse/log-codex.txt on amdci2
Please ignore until reviewed by @ChrisRackauckas.
Stacked on PR 1365. This branch includes that PR's Jacobian residual-buffer changes, first-iteration reuse, and the public API and compatibility corrections described below. The independent review below assesses the full stacked diff.
What changed and why
construct_jacobian_cacheevaluates the Jacobian atu0duringinit(it needs the matrix type to build the linear-solver cache). The firststep!then evaluated it again at the same point, becausemake_new_jacobianstarts outtrue. This PR reuses the init-time Jacobian on the first step when it is valid:JacobianCacherecords whether construction actually evaluated J. That is true only on the AD path with no analyticjac, nojac_prototypeand no operator Jacobian, and never for scalar problems. Solver packages read the flag through the publicNonlinearSolveBase.jacobian_computed_at_construction(cache), which defaults tofalsefor every other cache type, has a docstring and is rendered indocs/src/devdocs/jacobian.md. Both solvers import the helper directly from NonlinearSolveBase; their QA ignore lists have no additions for it. Base is versioned at 2.56.0 and both consumers require that minor.initial_jacobian_validis set at init and cleared byreinit!and after the first step. Step 1 reuses J only while the flag is set andu == u_cache. The second check matters because a polyalgorithm withalias_u0 = truesharesubetween its subcaches, so an earlier subalgorithm may have moved it. The descent still receivesnew_jacobian = true, so refactorization, damping and the Jacobian-reuse policy behave as before.TrueJacobianInitialization(Broyden/Klement withinit_jacobian = Val(:true_jacobian)orVal(:true_jacobian_diagonal)) is marked initialized when construction evaluated J. The same aliasing guard applies.:simplemode, used by PseudoTransient). Itsinitdamped the aliased Jacobian-cache storage in place. A reused first-step J would then have been damped twice: before this fix, PT took 90 steps instead of the base's 89.initnow damps a copy. This adds one n×n copy at PseudoTransient init.Not reused, by design: lazy/Krylov
JacobianOperator(J is never formed), an analyticjacand ajac_prototype(J is only allocated at init), and scalar problems.Bitwise identical to #1365
Comparison on the same 98 configurations as #1365:
jac, sparse 1000 with ajac_prototypeand withTracerSparsityDetector, SVector{4}, scalar.alias_u0 = true.solve, every iterate of astep!loop, andsolve!afterreinit!with a new u0 and p, with a new p, and with the original values.98 / 98 IDENTICAL: every iterate, residual, retcode and stat other than
njacs(nf, nfactors, nsolve, nsteps) is the same. Change innjacs:njacschange (solve, cache solve, three reinit solves)jac_prototype, Krylov operator, scalar, identity-init Broyden)alias_u0the second subcache's state has moved on the cache path, so the guard recomputes)u, so no aliasing)After
reinit!, the Jacobian is always re-evaluated (delta 0).Before → after
Pinned to one core with
taskset -c 100,@benchmark solve(prob, alg)minimum. Base is #1365 (7dd45902), and the two runs were done back to back on the same core:jac_prototype)Dense 100 is the only case where the effect is large and clearly above noise. There, one ForwardDiff Jacobian (100 columns at chunk size 1) dominates a Newton step. For the sparse Bratu problem, a colored Jacobian costs about three function evaluations, so dropping one is within run-to-run variation. The sparse LM row is identical in
njacsandnsteps, and its 1.2 s vs 1.4 s gap is load noise: the machine had a load average of about 70 during these runs. An earlier pair of runs against master on the same core gave 3808 → 2597 / 5699 → 4513 / 12517 → 11284 µs for dense NR / TR / LM.Verification
Regression tests that fail on the base (#1365, run in the same environment):
lib/NonlinearSolveFirstOrder/test/misc_tests__item11.jlcounts dual-number evaluations offand thenjacsdelta in the firststep!:jac_prototype, userjac, Krylov.ubefore the first step.reinit!with a new u0, p, or both, for NR/TR/LM/PT: the first step after reinit evaluates the Jacobian, and the result equals a freshsolve(u, retcode,njacs).lib/NonlinearSolveQuasiNewton/test/core_tests__item14.jlis the same for Broyden(true J), Klement(true J) and Klement(true diagonal).On the base:
On this branch, in the full Core groups (Julia 1.12):
The reinit assertions now pass for all four FirstOrder algorithms, including PseudoTransient after the double-damping fix. Runic (
Runic --check) andtyposare clean on every changed file.Not verified
native_bounded.jl) also evaluate J twice around init. That is a separate path.jacstill adds 1 tonjacsat init without evaluating anything (jacobian.jl).pin place betweeninitand the first step withoutreinit!. Mutatinguin place is caught by theu == u_cacheguard. On the base, such a mutation also leavesfustale, so it is not a supported path.For reviewers to push back on
🤖 Generated with Claude Code (model: claude-opus-5-5) https://claude.ai/code/session_01LPHREnnonfLg1VcE1EJovv
Review findings addressed
Findings from the independent review (codex-cli, gpt-6-astra, verdict CHANGES):
jacobian_computed_at_constructionis now a top-level NonlinearSolveBase function, declared public with@compat(public, ...)next toreused_jacobian. It has a docstring and an entry indocs/src/devdocs/jacobian.md. It is gone fromInternalAPIand from its module docstring. Both sublibraries import it the way they importreused_jacobian, and the two QA ignore-list additions are reverted, so bothtest/qa/qa.jlfiles match master again. New unit test:lib/NonlinearSolveBase/test/jacobian_computed_at_construction.jl. It checkstruefor in-place and out-of-place AD withreused_jacobian(cache, u0)equal to the true Jacobian. It checksfalsefor an analyticjac, ajac_prototype, a KrylovJacobianOperator, a scalar problem and a non-cache argument.This supersedes three statements in the description above. The helper is no longer reached as
InternalAPI.jacobian_computed_at_construction. It is no longer in the QA ignore lists. The PR is no longer free of public API changes: it adds one public, documented NonlinearSolveBase name.Evidence (Julia 1.13.0, 4 threads)
QA with the ignore entries removed but the helper still in
InternalAPI(the source before this commit):With this commit:
A strict Documenter build (
doctest = false) of a page with thedevdocs/jacobian.md@docsblock, plus the docstrings those entries cross-reference, renders the new docstring and resolves its@reflinks toreused_jacobianandconstruct_jacobian_cache.Runic (
--check) andtyposare clean on every changed file.Not run: the full
docs/make.jlbuild (it needs Sundials, HomotopyContinuation, SciPy through Python, and linkcheck), Julia pre, and the other sublibraries. On Julia 1.10, only FirstOrder QA was run, not the Core groups or the other QA groups.Release boundary
No breaking change. The new public function means NonlinearSolveBase needs a minor bump (2.55.0 → 2.56.0) when it is released. A release of NonlinearSolveFirstOrder or NonlinearSolveQuasiNewton cut from a tree that contains this PR must raise its floor to
NonlinearSolveBase = "2.56". The current floors are"2.54"and"2.43". Both admit Base releases withoutjacobian_computed_at_constructionor the newJacobianCachefield, and with those the packages fail to load. The reviewer reproduced that failure with Base 2.55.0, and so did I. Julia 1.10 ignores[sources], so a plainGROUP=QA Pkg.test()oflib/NonlinearSolveFirstOrderon 1.10 resolves the registered NonlinearSolveBase v2.55.0, and precompilation then fails withUndefVarError: jacobian_computed_at_construction not defined. CI does not hit this, because the sublibrary workflow develops the in-repo[sources]on 1.10. The repo already runsDowngradeSublibraries.yml. I did not check whether it resolves NonlinearSolveBase from the registry or from the in-repo path, so it is not established that it would catch this.🤖 Posted by an AI agent — harness: Claude Code 2.1.301 · model: claude-opus-5-5
Conversation: local session ID ff2c686d-08da-4517-80f8-8868d37c73f8 (headless, no URL)
Breaking change
These version bumps are required, not optional. This PR deliberately changes no version or compat entry: when and how to release is Chris's decision. This section restates the "Release boundary" section above as a hard release condition.
jacobian_computed_at_constructionand a newJacobianCachefield. No existing public NonlinearSolveBase API is removed or changed.NonlinearSolveBase = "2.56"in the first release that contains this code. Their current floors are"2.54"and"2.43". Both packages import the new function at load time. With any NonlinearSolveBase older than 2.56, they fail to precompile withUndefVarError: jacobian_computed_at_construction not defined; both independent reviewers and I reproduced this against Base 2.55.0. Neither package may be registered from a tree containing this PR until its floor is raised. Their own public API is unchanged.NonlinearSolveBase = "2.56"in FirstOrder and the in-repo Base still at 2.55.0,Pkg.resolve()fails:ERROR: empty intersection between NonlinearSolveBase@2.55.0 and project compatibility 2.56.0 - 2.[sources], so it always tests against the in-repo Base. The shared downgrade workflow (sublibrary-downgrade.yml) adds every in-repolib/*sublibrary to its skip list, so it never pins NonlinearSolveBase to a floor. An enforceable check would be a job that loads each sublibrary against the registered versions of its in-repo siblings. That would be a change to the shared SciML workflow, outside this PR.Review findings addressed (re-review)
No code changed in this round. The head is still
5f24a5421a11799c1efc0eed9fff186cddb5f7bf.🤖 Posted by an AI agent — harness: Claude Code 2.1.301 · model: claude-opus-5-5
Conversation: local session ID ff2c686d-08da-4517-80f8-8868d37c73f8 (headless, no URL)
Compat floors resolved
NonlinearSolveBase is bumped from 2.55.0 to 2.56.0, the next minor above upstream master at 8d5e163e. NonlinearSolveFirstOrder and NonlinearSolveQuasiNewton now require
NonlinearSolveBase = "2.56", so their dependency constraints exclude Base releases without the publicjacobian_computed_at_constructionhelper. A repository-wide search found no other packages importing or calling the helper; the top-level package and other sublibraries need no floor change. No fallback shim is added.Compatibility verification (Julia 1.12.7 and 1.10.12)
On Julia 1.12.7,
timeout 3600 julia +1.12 --startup-file=no --project=lib/<package> -e 'using Pkg; Pkg.resolve(); Pkg.instantiate()'succeeds for Base, FirstOrder and QuasiNewton. Both solvers select the in-repo NonlinearSolveBase 2.56.0.Julia 1.10.12 was checked with clean copies of each Project.toml, without manifests or developed siblings, so
[sources]is ignored. Before this fix, both solver projects resolve/instantiate with registered Base 2.55.0. With the raised floors, both reject that combination at resolution:QuasiNewton reports the same restriction from
NonlinearSolveQuasiNewton [9a2c21bd]. Base's own project resolves/instantiates on 1.10.12. Base 2.56.0 is not registered in the registry used for these checks; it must be registered before these solver projects can resolve on Julia 1.10 without developed siblings. No workaround was applied.Local tests and formatting
All test invocations use
GROUP=<group> NONLINEARSOLVE_TEST_GROUP=<group> timeout 3600 julia +1.12 --startup-file=no --project=lib/<package> -e 'using Pkg; Pkg.test()', four Julia threads, four precompile tasks and one OpenBLAS thread.Each complete invocation ends with
Testing <package> tests passed. The public predicate test passes 9/9, FirstOrder reuse 111/111, QuasiNewton reuse 66/66, native bounded methods 6001/6001, and operator Jacobians 14/14.The first FirstOrder Core attempt reached the 3600-second timeout during BandedMatrices precompilation for a sparsity test, after 9059 passing assertions and no failed assertions. After Base Core completed that dependency's precompilation, the identical full FirstOrder Core command passed. No tests or assertions were changed.
Real Runic 1.10.0
--checkpasses on all 15 Julia files in the full PR. The requested$HOME/sandbox/goals/performance/runic-envproject is absent; the command used a local copy of an existing Runic environment throughJULIA_LOAD_PATH, without modifying a global environment.typosandgit diff --checkpass on all four follow-up files (three Project.toml entries and the repository compatibility convention in AGENTS.md).Not rerun: full docs build, GPU, downstream tests, or Julia 1.10 runtime suites. The three existing-head CI failures (top-level LTS Core on Ubuntu/macOS and SCCNonlinearSolve Core) have not been compared with master, and this follow-up has not yet received independent review.
The independent review classified the full stacked diff as MH1 (medium-high); this follow-up updates three Project.toml entries and records the sibling-API compatibility convention in AGENTS.md. A fresh independent review and comparison of the outstanding CI failures with master are still pending.
🤖 Posted by an AI agent — harness: Claude Code 2.1.301 · model: claude-opus-5-5
Conversation: local session ID ff2c686d-08da-4517-80f8-8868d37c73f8 (headless, no URL)
Independent review (Codex CLI 0.162.1 gpt-6.1-sol, head 5f24a54)
VERDICT: CHANGES
Reviewed the full 16-file diff and supplied discussion; no linked issue was supplied. The Jacobian reuse regression tests have real failing-before/passing-after evidence, but the dependency compatibility blocker remains unresolved.
Local verification used Julia 1.12.7, JULIA_NUM_THREADS=4, JULIA_NUM_PRECOMPILE_TASKS=4 and timeout 3600. Against merge-base dd8a550, the new FirstOrder test has 61 passes/50 failures, QuasiNewton 39 passes/27 failures, and residual preservation 2 passes/2 failures. At head these pass 111/111, 66/66 and 4/4; the public predicate test passes 9/9. An independent analytic-root probe covering six algorithms, in-place/out-of-place residuals and parameter-changing reinit passes 48/48 assertions on each tree. All 12 first iterates are identical in that probe, with first-step njacs decreasing from 1 to 0.
G1: analytic Jacobian expectations and the independent root checks provide accuracy evidence. G2: the supplied rollup reports passing CUDA and Adjoint groups, and their test wiring covers the affected solvers; these were not rerun locally. G3: the new moved-state and reinit tests exercise cache invalidation and pass. The separate requirement to establish no CI regressions against master remains unresolved.
Matched criteria include L1 (documentation), L2 (stronger tests), ML7 (new public helper), and ML8 (cache validity state). The highest is MH1: the full diff includes the private-residual-buffer change at lib/NonlinearSolveBase/src/jacobian.jl:132, deliberately changing residual handling during Jacobian evaluation on the default Newton/trust-region lanes. The reuse optimization alone targets ML11, but this review includes its stacked prerequisite.
UndefVarError: jacobian_computed_at_construction not defined in NonlinearSolveFirstOrder; initializing Broyden with true-Jacobian initialization fails with the corresponding UndefVarError in NonlinearSolveQuasiNewton. These are declared-compatible combinations. The release notes acknowledge this but do not enforce it. Coordinate the Base version bump and both compat floors before shipping the dependent changes, and verify against permitted sibling versions. A future release reminder does not resolve this finding.Not verified: full Core/QA suites, timing benchmarks, docs, GPU/adjoint execution locally, Julia LTS/pre, or tests on the latest master tip (the before tests used merge-base dd8a550). The three failing supplied checks—Core on LTS Ubuntu, Core on LTS macOS, and SCCNonlinearSolve Core on Julia 1—could not be compared with master: no failure logs or master run evidence were supplied, and credential-free GitHub CLI access was unavailable. None is classified as pre-existing or as a confirmed regression. The supplied green affected-library, downstream, docs and accelerator checks do not settle those failures.
🤖 Posted by an AI agent — harness: Codex CLI 0.162.1 · model: gpt-6.1-sol
Conversation: Codex exec session 01a14b26-dcfc-7b23-964d-a8740ab171ea (no public URL)
Codex CLI gpt-6.1-sol rated it medium-high (MH1) at head 5f24a54
Risk assessment
reinit!paths, and the DampedNewtonDescent:simpleinit. One public, documented NonlinearSolveBase helper is added, with the minor version bumped to 2.56.0.🤖 Updated by an AI agent — harness: Codex CLI 0.163.0 · model: gpt-6.1-sol
Session: local session, transcript on a fleet worker host (headless, no public URL)
Independent executed findings at head 850fbf9 — session 01a15e30-7596-7a03-9c72-51d911618e85
The first-step cache reuse ignores explicit recompute_jacobian=true, violating the documented override. Raising the required Base floor also causes a reproduced Julia1.10 benchmark bootstrap resolution regression.
Preserve dependency floors while supplying complete local sibling graph to LTS benchmark setup; do not hide the red regression or release packages here. Add discriminating explicit-true first-step test. Original current commit session attribution still names path/host; no identity/history repair is inferred. Full stack and higher existing human/API risk hold remain.
Risk assessment
Links:
🤖 Posted by an AI agent — harness: Codex CLI0.163.0 · model:gpt-6.1-sol
Conversation: local session 01a154f0-8986-78e1-8af2-1119e8cdea4a (no session URL)