Homotopy drivers: read the interior corrector solution through typed barriers (drop last_sol union boxing) - #1045
Draft
ChrisRackauckas-Claude wants to merge 1 commit into
Conversation
…barriers (drop last_sol union boxing) The HomotopySweep and ArcLengthContinuation drivers build one inner-solver cache and re-drive it every continuation step. In the interior accept/quality block, `last_sol` is union-typed across the concrete interior `solve!(cache)` and the fresh `solve(inner_prob, inner)` of the exempt anchor/landing solves (`_sweep_exempt_solve` / `_arclength_fixed_solve`), whose two `NonlinearSolution` members differ in their `resid`/function specialization type parameters. Reading the converged iterate as a bare `last_sol.u` (four times per step in the sweep, once per step in arclength) boxed a fresh getproperty on every step, because the two-member union is not split at the non-inlined `norm_op`/`_sweep_accept!` / chord-arithmetic call sites the value flows into. Read the needed fields (`.u`, `.stats.nsteps`) once through `@noinline` type-specializing field barriers so the union-split happens at the barrier call (a two-member union is always split) and each specialization returns a concrete value the downstream non-inlined calls consume allocation-free. A plain same-scope `solu = last_sol.u` local does NOT suffice — it stays inferred as the union `.u` and still boxes downstream (this is why the read-once collapse in the closed PR SciML#1042 did not remove the box). Measured per-step allocation (SLOPE method inside a function, n = 50 coupled cubic, adaptive = false fixed-step, nsteps 50 vs 250, NewtonRaphson inner): Julia 1.10.11, LinearSolve 5.0.0: HomotopySweep 1629.28 -> 1245.28 B/step (-384.0 = exactly the four boxes) ArcLengthContinuation 816.00 -> 720.00 B/step (-96.0 = exactly the one box) Julia 1.11.9, LinearSolve 5.0.0: ArcLengthContinuation 320.00 -> 224.00 B/step (-96.0) Timing (ns/step) is unchanged-to-improved. Behavior is byte-identical: the adaptive sweep and arclength still land on u = ones(50) (max err 6.66e-16). Part of the homotopy allocation-reduction effort; gets stepping toward zero. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
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.
Part of the homotopy allocation-reduction effort; gets stepping toward zero.
Root causes
Two independent per-step allocation sources were profiled for the continuation drivers (
HomotopySweep,ArcLengthContinuation), measured with the SLOPE method inside a function (n = 50 coupled cubic,adaptive = falsefixed-step,nsteps/maxsteps50 vs 250,NewtonRaphsoninner, per-step = (allocated@250 − allocated@50)/200):(1)
last_solunion boxing (this PR). The drivers build one inner-solver cache and re-drive it every continuation step. In the interior accept/quality block,last_solis union-typed: it is assigned from both the concrete interiorsolve!(cache)and the freshsolve(inner_prob, inner)of the exempt anchor/landing solves (_sweep_exempt_solve/_arclength_fixed_solve), whose twoNonlinearSolutionmembers differ in type parameters. Reading the converged iterate as a barelast_sol.u— four times per step in the sweep (the correction/disp/scale norms and_sweep_accept!), once per step in arclength (xnew = last_sol.u) — boxed a fresh SciMLBasegetpropertyon every step (solution_interface.jlgetproperty, ~384 B/step sweep, ~96 B/step arclength), because the two-member union is not split at the non-inlinednorm_op/_sweep_accept!/ chord-arithmetic call sites the value flows into.The fix reads the needed fields (
.u,.stats.nsteps) once through@noinlinetype-specializing field barriers (_sweep_sol_u/_sweep_sol_nsteps,_arclength_sol_u/_arclength_sol_nsteps) so the union-split happens at the barrier call (a two-member union is always split) and each specialization returns a concrete value the downstream non-inlined calls consume allocation-free. A plain same-scopesolu = last_sol.ulocal does not suffice — it stays inferred as the union.uand still boxes downstream (this is why the read-once collapse in the closed #1042 did not remove the box; #1044 fixed the returned-solution reads at the source but not these interior-loop reads).(2) LinearSolve refactorization ipiv (out of scope here — upstream LinearSolve, Julia 1.10 only). On Julia 1.10 each interior
lu!allocates a freshipivpivot vector (~900 B/step) because LinearSolve's in-place ipiv-reuse path (_reusable_lu_cacheval/_lu_reusing_ipiv!,factorization.jl) isVERSION >= v"1.11"-gated — the preallocated-ipivLAPACK.getrf!(A, ipiv)overload only exists in Julia 1.11+. On Julia 1.11 this is already 0 B/step; standaloneNewtonRaphsonand a directreinit!/solve!loop both allocate 0 there. This is a LinearSolve.jl concern (no in-tree NonlinearSolve lever) and is reported separately; it is the entire ~900 B/step gap between the Julia-1.10 and Julia-1.11 numbers below.Before / after (per-step)
Default polyalgorithm inner also improves (the same interior union reads boxed there): sweep 4901.76 → 2917.76 B/step and arclength secant 2988.0 → 2492.0 B/step on Julia 1.10.
The −384.0 (sweep) / −96.0 (arclength) deltas are exactly the count of boxed getproperty reads removed (four vs one per step), confirmed by Profile.Allocs attribution to
SciMLBase .../solution_interface.jlgetproperty at the sweep lines (correction/disp/scale/accept) and the arclengthxnew = last_sol.uline: 384.0 → 0.77 B/step (sweep, now one-time) and 96.0 → 0.0 B/step (arclength).Timing (ns/step, same config): unchanged-to-improved — HomotopySweep 72.4 → 70.4 µs/step and ArcLengthContinuation secant 48.1 → 47.8 µs/step on Julia 1.10; no regression on 1.11.
Irreducible floor after this PR
On Julia 1.11 the remaining per-step allocation is 96 B/step (sweep) / 224 B/step (arclength secant), attributed by Profile.Allocs to a single site:
last_sol = solve!(cache)(the interior corrector solve). The cache is union-typed becauseiniton a fully-concreteNonlinearProblem{true, FullSpecialize, …}withNewtonRaphsonhas a return type that widens to a two-member{FullSpecialize, AutoSpecialize}cache union (verified:Base.return_types(init, …)returns a 2-member union even with no kwargs and a concrete FullSpecialize input). Callingsolve!on that union boxes the returnedNonlinearSolution(whose two members differ in size) once per step. This is a NonlinearSolveFirstOrder/SciMLBaseinitreturn-type-inference limitation upstream of these drivers — the driver already constructs a concrete FullSpecialize problem (per #1044) and does everything right; there is no driver-level lever that removes it without changingoriginal/failure-path semantics. On Julia 1.10 this same box (~96 B) plus LinearSolve's ~900 B ipiv (cause 2) make up the residual 1245 B/step.Behavior
Byte-identical: the adaptive sweep and arclength both still land on
u = ones(50)with max error 6.66e-16 (identical to master). The failure-path reads (last_sol.resid/.retcode,original = last_sol) are left as-is — they are O(1) per solve, not per interior step, and are already gated concretely by #1044'sstore_original.Tests
test/Core/homotopy_sweep_tests*.jl+arclength_tests*.jl+*_jac_tests*.jlpass unmodified: 273/273 on Julia 1.10.11 and 273/273 on Julia 1.11.9.arclength_tests__item8.jl: the default-inner bounds drop from 128 KB/160 KB (a pre-cache-driver artifact) to 6 KB/7 KB, and a NewtonRaphson-inner guard (< 1.1 KB secant / < 1.7 KB tangent) directly bounds the union-boxing regression.homotopy_sweep_tests__item23.jl: the sweep equivalent (default-inner < 7 KB, NewtonRaphson-inner < 1.5 KB, plus the land-on-ones correctness check).Both regression files pass on Julia 1.10 and 1.11. Runic
--checkis clean on all changed files.Note: This PR should be ignored until reviewed by @ChrisRackauckas.
🤖 Generated with Claude Code