LinearSolve 5.0: lightweight solutions — stop populating LinearSolution.cache in solve! returns#1083
Merged
ChrisRackauckas merged 6 commits intoJul 11, 2026
Conversation
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>
This was referenced Jul 11, 2026
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
marked this pull request as ready for review
July 11, 2026 20:30
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! |
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. |
This was referenced Jul 17, 2026
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.
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.cachepattern (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-textlinres.cache).What this PR does
LinearSolve 5.0:
solve!(cache)(andsolve(prob, ...)) now return a lightweightLinearSolutionwhosecachefield is alwaysnothing. TheLinearCacheis no longer captured in the returned solution object.The
LinearSolutiontype itself lives in SciMLBase and keeps itscache::Cfield; this PR simply stops populating it (C === Nothingeverywhere). All 118SciMLBase.build_linear_solution(alg, u, resid, cache; ...)call sites acrosssrc/,ext/, andlib/now passnothingin the cache slot, plus the one directSciMLBase.LinearSolution{...}construction in the HYPRE extension.Semantics: anyone who needs the cache after
solve!(cache)already holds it — that is the object they calledsolve!on.Why
The solution wrapper returned from the warm (
initonce,solve!repeatedly) path should be free. Carrying theLinearCachein 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@allocatedover 100–1000 iterations and attributed withProfile.Allocsatsample_rate = 1.c4ebafea, v4.3.0)sol.uread) or discardedRef{Any})@allocated solve!(cache)at global scopesol.u/sol.retcodeand 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.Profile.Allocsattributes it to exactly one allocation ofLinearSolution{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 suspectedcache.cacheval = factunion store does not allocate on this path (cacheval is the concreteLU{Float64, Matrix{Float64}, Vector{Int64}}) — and it cannot be removed without changingsolve!to not return a solution object at all.Nothing-typedcacheeverywhere, and no GC pinning of solver internals via returned solutions.ipivVector{Int64}allocated insideLAPACK.getrf!(thegetrf!(A, ipiv)reuse API only exists on Julia ≥ 1.11 — the reuse path is version-gated insrc/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)
.cacheoff the return ofsolve!/solveno longer works — it is alwaysnothing.linres.cachethrough chained solves in 6 files) — a parallel PR migrates it off the pattern and must merge before 5.0 releases. NonlinearSolve reads only.u/.retcodeand is unaffected (verified below). LinearSolve internals never readsol.cache.LinearCacheyou calledsolve!on:solve(prob, alg)where you previously continued viasol.cache, switch tocache = init(prob, alg); sol = solve!(cache).cleanup_petsc_cache!(sol::LinearSolution),cleanup_mumps_cache!(sol::LinearSolution),cleanup_superludist_cache!(sol::LinearSolution)methods are removed (they readsol.cache.cacheval). Use theLinearCachemethods from aninit/solve!cycle:cleanup_*_cache!(cache). GC finalizers remain as the safety net for one-shotsolve(prob, alg). Docstrings, docs pages, and the extension test suites are migrated accordingly.LinearSolve = "5"inlib/LinearSolveAutotune(→ 1.13.0),lib/LinearSolvePyAMG(→ 1.3.0),docs/, and alltest/*/Project.toml.Follow-up (SciMLBase, separate PR): the
LinearSolutiondocstring in SciMLBase still describes thecachefield 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 viasol.cache; documents the lightweight return.docs/src/basics/Preconditioners.md: example migrated fromcache = sol.cachetoinit/solve!.docs/src/solvers/solvers.md+src/extension_algs.jldocstrings: PETSc/MUMPS memory-management examples migrated to cache-based cleanup.Tests
New
test/Core/lightweight_solution.jl(wired into the Core group):solve!/solvereturns havecache === nothingacross default alg, LU/GenericLU/QR/SVD, KrylovJL_GMRES, KLU (sparse), the failure path, and the default algorithm's singular → column-pivoted-QR safety fallback.@allocated == 0on Julia ≥ 1.11 (1.10 asserts the documented pre-existinggetrf!ipiv floor).test/Core/lu_refactorization.jlalready 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 <= 0sees 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 isdd826e5c(#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:lightweight_solution.jl)Testing LinearSolve tests passed, exit 0; Lightweight Solution testset 39/39)Testing LinearSolve tests passedTesting LinearSolve tests passedmpiexec -n 2MPI file)test/Core/lightweight_solution.jlstandaloneNot 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 === nothingplus 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'sLinearSolve = "..., 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:The NonlinearSolve LinearSolve extension path (
linres.u/linres.retcodeonly) works unchanged. Note: registered NonlinearSolve (and other downstreams) will need a routine compat bump toLinearSolve = "5"when 5.0 releases.🤖 Generated with Claude Code