Skip to content

LinearSolve 5.0: lightweight solutions — stop populating LinearSolution.cache in solve! returns#1083

Merged
ChrisRackauckas merged 6 commits into
SciML:mainfrom
ChrisRackauckas-Claude:lightweight-solution-5.0
Jul 11, 2026
Merged

LinearSolve 5.0: lightweight solutions — stop populating LinearSolution.cache in solve! returns#1083
ChrisRackauckas merged 6 commits into
SciML:mainfrom
ChrisRackauckas-Claude:lightweight-solution-5.0

Conversation

@ChrisRackauckas-Claude

@ChrisRackauckas-Claude ChrisRackauckas-Claude commented Jul 11, 2026

Copy link
Copy Markdown
Member

Note: This PR should be ignored until reviewed by @ChrisRackauckas.

Do not merge/release before the OrdinaryDiffEq migration PR (SciML/OrdinaryDiffEq.jl#3875) that moves OrdinaryDiffEq off the linres.cache pattern (link to be added once that PR is open — as of this writing no open PR on SciML/OrdinaryDiffEq.jl matches; searched open PRs by ChrisRackauckas-Claude and full-text linres.cache).

What this PR does

LinearSolve 5.0: solve!(cache) (and solve(prob, ...)) now return a lightweight LinearSolution whose cache field is always nothing. The LinearCache is no longer captured in the returned solution object.

The LinearSolution type itself lives in SciMLBase and keeps its cache::C field; this PR simply stops populating it (C === Nothing everywhere). All 118 SciMLBase.build_linear_solution(alg, u, resid, cache; ...) call sites across src/, ext/, and lib/ now pass nothing in the cache slot, plus the one direct SciMLBase.LinearSolution{...} construction in the HYPRE extension.

Semantics: anyone who needs the cache after solve!(cache) already holds it — that is the object they called solve! on.

Why

The solution wrapper returned from the warm (init once, solve! repeatedly) path should be free. Carrying the LinearCache in the wrapper makes the returned object heavier and keeps a reference chain from every returned solution to all solver internals (factorizations, Krylov workspaces, verbosity state), pinning them for GC as long as any solution object is alive.

Measured allocation numbers (warm path)

Harness: 51×51 dense Matrix{Float64}, matrix RHS (51×51), LUFactorization, alias = LinearAliasSpecifier(alias_A = true, alias_b = true), warm refactorize+solve loop (copyto!(Awork, A); cache.A = Awork; solve!(cache)), measured with @allocated over 100–1000 iterations and attributed with Profile.Allocs at sample_rate = 1.

Scenario (Julia 1.11.9 / 1.12.6) main (c4ebafea, v4.3.0) this PR (v5.0.0)
Warm loop, return consumed (sol.u read) or discarded 0 B/call 0 B/call
Warm loop, return escapes (stored into Ref{Any}) 48 B/call 32 B/call
Per-call @allocated solve!(cache) at global scope 48 B/call 32 B/call
  • The warm in-loop path is genuinely allocation-free (0 bytes) on Julia ≥ 1.11 when the caller reads sol.u/sol.retcode and moves on (the downstream NonlinearSolve/OrdinaryDiffEq pattern): the concretely-typed, nothing-cache immutable wrapper is fully elided by escape analysis. No further restructuring (function barriers / raw-tuple returns) was needed once the cache stopped being captured.
  • The residual 32 B: Profile.Allocs attributes it to exactly one allocation of LinearSolution{Float64, 2, Matrix{Float64}, Nothing, LUFactorization{RowMaximum}, Nothing, Nothing} (the wrapper box itself). It occurs only when the caller forces the returned solution to escape to the heap (global binding, Ref{Any} store). It is not an internal LinearSolve allocation — the suspected cache.cacheval = fact union store does not allocate on this path (cacheval is the concrete LU{Float64, Matrix{Float64}, Vector{Int64}}) — and it cannot be removed without changing solve! to not return a solution object at all.
  • The previously reported 80 B/call (48 wrapper + 32 residual) corresponds to a harness where the return escapes; in-loop non-escaping use was already elided to 0 on ≥ 1.11 before this PR. The PR's effect is 48 → 32 B in escaping contexts, Nothing-typed cache everywhere, and no GC pinning of solver internals via returned solutions.
  • Julia 1.10 (lts): the same warm loop measures 496 B/call on both main and this PR: 456 B is a fresh ipiv Vector{Int64} allocated inside LAPACK.getrf! (the getrf!(A, ipiv) reuse API only exists on Julia ≥ 1.11 — the reuse path is version-gated in src/factorization.jl), and the remaining ~40 B is the wrapper box that 1.10's weaker escape analysis does not elide. This is a pre-existing 1.10 floor, unchanged by this PR, and is asserted with justification in the new regression test.

Breaking changes (→ major version 5.0.0)

  1. Reading .cache off the return of solve!/solve no longer works — it is always nothing.
    • Affected downstream: OrdinaryDiffEq (threads linres.cache through chained solves in 6 files) — a parallel PR migrates it off the pattern and must merge before 5.0 releases. NonlinearSolve reads only .u/.retcode and is unaffected (verified below). LinearSolve internals never read sol.cache.
    • Migration: keep the LinearCache you called solve! on:
      # before                                   # after
      sol = solve!(cache)                        sol = solve!(cache)
      linsolve = sol.cache                       linsolve = cache
      For one-shot solve(prob, alg) where you previously continued via sol.cache, switch to cache = init(prob, alg); sol = solve!(cache).
  2. cleanup_petsc_cache!(sol::LinearSolution), cleanup_mumps_cache!(sol::LinearSolution), cleanup_superludist_cache!(sol::LinearSolution) methods are removed (they read sol.cache.cacheval). Use the LinearCache methods from an init/solve! cycle: cleanup_*_cache!(cache). GC finalizers remain as the safety net for one-shot solve(prob, alg). Docstrings, docs pages, and the extension test suites are migrated accordingly.
  3. Sublibrary/test compat bumps: LinearSolve = "5" in lib/LinearSolveAutotune (→ 1.13.0), lib/LinearSolvePyAMG (→ 1.3.0), docs/, and all test/*/Project.toml.

Follow-up (SciMLBase, separate PR): the LinearSolution docstring in SciMLBase still describes the cache field as populated; it should be updated to the 5.0 semantics once this releases.

Docs

  • docs/src/tutorials/caching_interface.md: no longer teaches retrieving the cache via sol.cache; documents the lightweight return.
  • docs/src/basics/Preconditioners.md: example migrated from cache = sol.cache to init/solve!.
  • docs/src/solvers/solvers.md + src/extension_algs.jl docstrings: PETSc/MUMPS memory-management examples migrated to cache-based cleanup.

Tests

New test/Core/lightweight_solution.jl (wired into the Core group):

  • solve!/solve returns have cache === nothing across default alg, LU/GenericLU/QR/SVD, KrylovJL_GMRES, KLU (sparse), the failure path, and the default algorithm's singular → column-pivoted-QR safety fallback.
  • Warm aliased refactorize+solve loop with matrix RHS asserts @allocated == 0 on Julia ≥ 1.11 (1.10 asserts the documented pre-existing getrf! ipiv floor).
  • Correctness across repeated refactorizations.
  • The existing test/Core/lu_refactorization.jl already covers pivot-buffer reuse and the singular → QR fallback surviving warm refactorization; those tests run unchanged.

Pre-existing master failure found and fixed en route

test/Core/lu_refactorization.jl's zero-allocation ceilings (introduced in #1082) fail on unmodified main under Julia 1.11 (alloc <= 0 sees 48 bytes: the returned solution wrapper is not elided at testset scope by 1.11's escape analysis; 1.12+ elides it). CI never runs 1.11 (lts/1/pre = 1.10/1.12/pre), which is why this was invisible. Reproduced on a clean main worktree; the introducing commit is dd826e5c (#1082, the file's only commit). Fixed here by allowing the boxed wrapper (<= 48 B) on 1.11 only — a leaked pivot vector would add >= n * sizeof(Int) on top, so the reuse assertion stays meaningful. Verified passing on 1.10.11, 1.11.9, and 1.12.6 after the fix.

Local test runs (Julia 1.11.9)

All local runs on the feature branch with Julia 1.11.9 via GROUP=<name> julia --project -e 'using Pkg; Pkg.test()' unless noted:

Group Result
Core (rebased tree, includes the new lightweight_solution.jl) pass on both 1.11.9 and 1.12.6 (Testing LinearSolve tests passed, exit 0; Lightweight Solution testset 39/39)
QA Quality Assurance 15 pass / 2 broken (pre-existing), JET 20 pass / 15 broken (pre-existing minus the KLU one promoted to passing) — Testing LinearSolve tests passed
AD Mooncake 41/41, Static Arrays 23 pass / 1 broken (pre-existing), Caching Allocation 50 pass / 24 broken (pre-existing), Enzyme 47/47 — Testing LinearSolve tests passed
DefaultsLoading 5/5 pass
Preferences 105/105 pass
LinearSolveSTRUMPACK 13/13 pass
LinearSolvePureUMFPACK 9/9 pass
LinearSolveHYPRE (group env, incl. embedded mpiexec -n 2 MPI file) 1454 pass / 2 broken (pre-existing ParaSails skips)
LinearSolvePETSc (serial + MPI file single-rank) all testsets pass (e.g. Serial: Cleanup 5/5, Matrix Rebuild 18/18, CSR suites green)
LinearSolveMUMPS 11/11 pass
LinearSolveSuperLUDIST 10/10 pass
New test/Core/lightweight_solution.jl standalone pass on 1.10.11, 1.11.9, and 1.12.6

Not run locally (external binaries/licenses/hardware): LinearSolvePardiso and LinearSolveHSL (proprietary licenses), LinearSolveGinkgo, LinearSolveElemental, LinearSolveParU, LinearSolvePartitionedSolvers, GPU (needs V100 runner), Trim. Their extension edits are the same mechanical 4th-arg change and are parse-checked; CI covers them.

Additionally, a broad algorithm smoke script (SimpleLU, Diagonal, SimpleGMRES, KrylovJL_CRAIGMR, NormalCholesky, Cholesky, LDLt, BunchKaufman, SVD, GenericFactorization, UMFPACK, KLU, LinearSolveFunction) verified sol.cache === nothing plus correct answers on Julia 1.12.6.

Downstream smoke check (NonlinearSolve)

Scratch env with this branch Pkg.developed (version field temporarily spoofed to 4.99.0 in a copied tree so the resolver accepts it against registered NonlinearSolve's LinearSolve = "..., 4" compat — code identical to this PR; the real compat widening happens when downstream bumps after 5.0 releases) + registered NonlinearSolve v4 and NonlinearSolveHomotopyContinuation, Julia 1.11.9. Observed output:

LinearSolve version: 4.99.0
sol.retcode = SciMLBase.ReturnCode.Success
sol.u = [1.4142135623730951, 1.7320508075688774, 2.0]
norm(f(sol.u, p)) = 6.280369834735101e-16     # NewtonRaphson(linsolve = LUFactorization())
roots = [2.000000000000001, 3.0]              # HomotopyContinuationJL on u^2 - 5u + 6
SMOKE OK

The NonlinearSolve LinearSolve extension path (linres.u / linres.retcode only) works unchanged. Note: registered NonlinearSolve (and other downstreams) will need a routine compat bump to LinearSolve = "5" when 5.0 releases.

🤖 Generated with Claude Code

ChrisRackauckas and others added 5 commits July 11, 2026 07:15
Every build_linear_solution call in src/, ext/, and lib/ now passes
nothing for the cache slot, and the direct LinearSolution construction
in the HYPRE extension does the same. The returned solution is a
lightweight value type: anyone needing the cache after solve!(cache)
already holds it.

The cleanup_petsc_cache!/cleanup_mumps_cache!/cleanup_superludist_cache!
methods taking a LinearSolution are removed since they read sol.cache;
use the LinearCache methods from an init/solve! cycle instead. Extension
docstrings are migrated accordingly.

With the cache no longer captured, the concretely-typed nothing-cache
wrapper is fully elided by escape analysis on Julia >= 1.11: the warm
aliased refactorize+solve loop is 0 bytes/call, and escaping returns
shrink from 48 to 32 bytes/call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Breaking: reading .cache off the return of solve!/solve no longer works
(always nothing). LinearSolveAutotune -> 1.13.0 and LinearSolvePyAMG ->
1.3.0 widen their LinearSolve compat to 5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
New test/Core/lightweight_solution.jl asserts cache === nothing across
solver paths (including failure and QR safety fallback returns), a
0-byte warm aliased refactorize+solve loop on Julia >= 1.11 (with the
documented pre-existing getrf! ipiv floor on 1.10), and correctness
across refactorizations.

Migrates remaining sol.cache readers to held-cache init/solve! cycles:
basictests Reuse precs, forwarddiff_overloads reinit!, and the PETSc/
MUMPS/SuperLUDIST/HYPRE extension suites (cleanup and reltol reads).
The KLU JET optimization test now passes with the cache dropped from
the solution, so its broken flag is removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
The caching interface tutorial no longer teaches retrieving the cache
via sol.cache, the preconditioner example keeps the cache from init,
and the PETSc/MUMPS memory-management examples clean up through the
held LinearCache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
The zero-allocation assertions introduced in SciML#1082 assume the returned
LinearSolution wrapper is elided at testset scope, which holds on 1.12+
but not on 1.11 (which CI never runs: lts/1/pre = 1.10/1.12/pre). The
failure reproduces identically on unmodified main under 1.11 (48 bytes
there; 32 with the 5.0 lightweight solution). Allow the boxed wrapper
(<= 48 bytes) on 1.11 only; a leaked pivot vector would add
>= n * sizeof(Int) on top, so the reuse assertions remain meaningful.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
The AD and qa test Project.tomls had absolute scratch paths spliced in
by a Pkg.develop during this PR's work; the GPU one carried an even
older leak already on main. All three restored to {path = "../.."}.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
@ChrisRackauckas
ChrisRackauckas marked this pull request as ready for review July 11, 2026 20:30
@ChrisRackauckas
ChrisRackauckas merged commit 4594459 into SciML:main Jul 11, 2026
13 of 15 checks passed
@j-fu

j-fu commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Yeah this is a much better pattern. Code-wise it seems it was possible since v2. Appreciate Fable to even update the Preconditioners.md accordingly!

@ChrisRackauckas

Copy link
Copy Markdown
Member

Yeah it's always been like this, it's more than an old bad pattern... I noticed that it was also causing allocations so it was like, okay it's time for this to go.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants