Skip to content

Fix singular handling in the StaticArrays square fast path with SVD rescue for the default#1085

Merged
ChrisRackauckas merged 4 commits into
SciML:mainfrom
ChrisRackauckas-Claude:staticarrays-singular-svd-fallback
Jul 11, 2026
Merged

Fix singular handling in the StaticArrays square fast path with SVD rescue for the default#1085
ChrisRackauckas merged 4 commits into
SciML:mainfrom
ChrisRackauckas-Claude:staticarrays-singular-svd-fallback

Conversation

@ChrisRackauckas-Claude

Copy link
Copy Markdown
Member

Fixes #1084

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

Problem

The StaticArrays fast path in solve had no singular handling for square matrices:

using LinearSolve, StaticArrays, LinearAlgebra
As = SA[0.0 0.0; 1.0 -1.0]
sol = solve(LinearProblem(As, SMatrix{2,2}(1.0I)))
sol.retcode  # Success   ← wrong
sol.u        # [Inf NaN; Inf NaN]

For N ≤ 3, StaticArrays' \ uses direct inverse formulas that silently produce Inf/NaN on singular input, so the fast path returned Success with a non-finite solution. For N ≥ 4, \ is lu(A) \ b with check = true, so the default path threw SingularException instead. Explicit LUFactorization() (lu(prob.A) \ prob.b) also threw. (The init/solve! cache route was already fine — the generic LU machinery reports Failure there.)

Fix

Two parts, both confined to the static fast path in src/common.jl:

  1. Explicit static LUFactorization reports failure. The LUFactorization branch now factors with lu(A, check = false) and checks LinearAlgebra.issuccess (which StaticArrays defines for its LU by checking the U diagonal — the same criterion as LAPACK's info). On failure it returns retcode = ReturnCode.Failure with the zero-initialized u, exactly matching the dense LUFactorization behavior on singular input (verified: dense returns Failure with u = [0.0, 0.0]), instead of throwing. issuccess is used rather than a finiteness check on u because it tests the factorization itself, matching dense semantics (a nonsingular A with non-finite b still reports Success with the non-finite u, on both paths), and it is also cheaper (N diagonal checks, and no wasted triangular solves on failure — StaticArrays' triangular \ throws on a zero diagonal, so solving first isn't even an option here).

  2. Static square default gets an SVD rescue. solve(::StaticLinearProblem, ::Nothing) — the static default — now detects LU failure the same way and rescues with svd(A) \ b, returning the finite min-norm least-squares pseudo-solution with retcode = Success. This mirrors the dense default's singular-LU → pivoted-QR safetyfallback, which likewise returns the rescue solve's retcode (Success, verified on the dense reproducer).

Why SVD and not QR

qr(::SMatrix) \ b least-squares is not defined in StaticArrays — the same reason defaultalg(::SMatrix) already uses SVDFactorization() for the non-square branch ("QR(...) \ b is not defined currently"). StaticArrays' svd \ is tolerance-thresholded, so it gives the min-norm pseudo-solution: the rescue result matches pinv(Matrix(A)) * b to the last ulp, consistent with the intent of the dense pivoted-QR rescue.

Design choice

The check lives directly in the static fast-path methods rather than in a new static default-algorithm struct or the dense DefaultLinearSolver machinery. Default provenance is already explicit in the fast path's dispatch: solve(prob, nothing) is the static default, and solve(prob, alg::SciMLLinearSolveAlgorithm) is the explicit-algorithm path, so no provenance flag is needed. The DefaultLinearSolver cacheval union machinery is built around mutable caches and in-place factorization reuse and is not static-friendly; forcing static arrays through it would sacrifice the zero-allocation fast path for no benefit.

Two implementation subtleties, both commented in the source:

  • For N ≤ 3 the nonsingular solve stays A \ b (direct inverse formulas) so results remain bit-identical to \; for N ≥ 4 it becomes lu(A, check = false) \ b, which is the identical computation to A \ b minus the throw.
  • The plain \ kernel sits behind a @noinline barrier: StaticArrays' small-size formulas contain muladds whose FMA contraction is inlining-context dependent, and inlining them next to the new finiteness check changed the result in the last bit. The barrier restores bit-identity (verified === for N = 1…5, vector and matrix RHS, default and explicit LU).

Measured behavior

Before (main, c4ebafe):

case result
singular 2×2, default Success, u = [Inf NaN; Inf NaN]
singular 4×4, default throws SingularException
singular static, LUFactorization() throws SingularException

After:

case result
singular 2×2/4×4, default, SVector & SMatrix RHS Success, finite u ≈ pinv(Matrix(A)) * b (min-norm)
singular static, LUFactorization() Failure, zero u, no throw
nonsingular, N = 1…5, both RHS kinds bit-identical (===) to A \ b / lu(A) \ b
non-square static unchanged

Performance (BenchmarkTools minimum, @inferred passes, AllocCheck @check_allocs passes — the whole path including the rescue branch is statically allocation-free):

case main this PR
default 2×2 5.6 ns, 0 allocs 11.8 ns, 0 allocs (@noinline barrier)
default 5×5 205 ns, 0 allocs 248 ns, 0 allocs
LUFactorization() 2×2 / 5×5 20.2 / 252 ns, 0 allocs 20.4 / 250 ns, 0 allocs
singular 2×2 rescue — (was wrong/threw) 1.47 µs, 0 allocs

Tests

New "StaticArray Singular" testset in test/Core/retcodes.jl: singular 2×2 and 4×4 with SVector and SMatrix RHS (default → finite min-norm ≈ pinv, correct retcode; explicit LU → Failure, finite u, no throw), nonsingular bit-identity (===) for both sizes and both RHS kinds against \ and lu(A) \, and a non-square problem unchanged. Ran locally: GROUP=Core Pkg.test() and test/AD/static_arrays.jl (AllocCheck/@inferred static tests, 23 pass / 1 pre-existing broken).

Notes

🤖 Generated with Claude Code

ChrisRackauckas and others added 2 commits July 11, 2026 09:50
The StaticArrays fast path in `solve` returned `retcode = Success` with
`u = [Inf NaN; ...]` (N <= 3, direct inverse formulas) or threw a
`SingularException` (N >= 4, checked `lu`) when the square matrix was
singular (SciML#1084).

Now the static square default detects LU failure via
`lu(A, check = false)` + `issuccess` and rescues with an SVD
least-squares solve, returning the finite min-norm pseudo-solution with
`Success` — mirroring the dense default's LU -> pivoted-QR
`safetyfallback`. SVD is used instead of QR because
`qr(::SMatrix) \ b` least-squares is not defined in StaticArrays (the
same reason `defaultalg(::SMatrix)` uses `SVDFactorization()` for
non-square).

Explicit `LUFactorization()` on singular static input now returns
`retcode = Failure` with the zero-initialized `u` instead of throwing,
matching the dense `LUFactorization` behavior.

Nonsingular solves stay bit-identical to `\` and the fast path remains
allocation-free (AllocCheck passes); non-square static problems are
unchanged.

Fixes SciML#1084

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Drops the @noinline bit-identity barrier: StaticArrays small-size
formulas have inlining-context-dependent FMA contraction, so
nonsingular N <= 3 results may differ from a bare A \ b in the last
bit; the test asserts a 4-ulp match instead. Measured 2x2 default
solve: 13.2 ns inlined vs 11.8 ns with the barrier — the singularity
check dominates, not the call, so the barrier bought nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
@ChrisRackauckas-Claude

Copy link
Copy Markdown
Member Author

Done — the barrier is removed and the small-N path inlines (4d5001f2 → new head). The nonsingular test now asserts a 4-ulp isapprox against A \ b instead of bit-identity (the explicit-LU assertion stays exact — it is lu(A) \ b either way). Honest perf note: 2×2 default measured 13.2 ns inlined vs 11.8 ns with the barrier vs 5.6 ns on main — the singularity check dominates, not the function call, so the barrier was buying nothing. Retcodes testset re-run post-change: 61 pass / 1 pre-existing broken.

@ChrisRackauckas-Claude

Copy link
Copy Markdown
Member Author

Correction on the perf numbers + answers to the review questions, with a fair same-session A/B (the earlier "5.6 ns on main" was measured in a different session/machine state — my error propagating it):

2×2 default solve (API)
main 12.8 ns
this PR (inlined, 7f7fdee6) 13.2 ns

True cost of the singular handling: ~0.4 ns — the LinearProblem/solution wrapper (~9 ns) dominates at this size regardless.

Component profile (raw kernels, N=2/4/8): all(isfinite, u) is free (fuses with the solve: 4.0 vs 4.4 ns bare); issuccess on an already-computed static LU is free (13.6 vs 13.7); the rescue branch costs ~2 ns at N=2 at kernel level and vanishes by N=8 — invisible through the API.

So: no check-skipping LUFactorization dispatch is warranted — the check costs nothing measurable, and explicit static LUFactorization is slower than the default for a different reason entirely (static lu() costs 3× the direct inverse formulas at N=2: 13.7 vs 4.4 ns raw). Users wanting maximal small-N speed should use the default. Also tried @inline on the kernel (worse: 21.2 ns — drags the cold rescue into the wrapper) and outlining the rescue tail (no change: 13.2) — the committed fully-inline version stands.

…r (review)

GESVFactorization previously fell through to the generic init path,
whose dense-only strided requirement rejects SMatrix. The static
dispatch mirrors the dense gesv driver semantics: a one-shot
factorize-and-solve whose singular failures are reported through the
return code — no SVD rescue (that is the default's behavior, which
gesv semantics do not include). N <= 3 uses the direct small-size
formulas with the (measured-free) finiteness/issuccess check; larger N
uses lu(check = false), never touching the throwing triangular solve
on failure. Non-square input gets an informative ArgumentError.

Measured 2x2 API: gesv 16.4 ns, default 12.8 ns, LUFactorization
21.7 ns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
@ChrisRackauckas-Claude

Copy link
Copy Markdown
Member Author

Added the GESVFactorization static dispatch per review. It previously fell through to the generic init path (dense-strided-only → rejected SMatrix outright). Semantics mirror the dense gesv driver exactly: one-shot factorize-and-solve, singular input reported as Failure through the retcode with zeroed u, no SVD rescue (that's the default algorithm's behavior, which gesv semantics don't include). N ≤ 3 uses the direct small-size formulas + the measured-free checks; larger N uses lu(check = false) with the failure branch never touching StaticArrays' throwing triangular solve. Non-square → informative ArgumentError. @inferred-stable on both branches.

Measured 2×2 through the API: gesv 16.4 ns vs default 12.8 vs explicit LU 21.7 — faster than LU (skips the static lu() at small N), slightly above the default (elseif-chain + tuple plumbing); its value is the semantics, not raw speed. Retcodes testset: 71 pass / 1 pre-existing broken (10 new assertions: nonsingular 2×2/4×4 vector+matrix RHS, singular → Failure both size regimes, non-square throw).

@ChrisRackauckas-Claude

Copy link
Copy Markdown
Member Author

Overhead decomposition for the static path (2×2, prebuilt LinearProblem, all 0 allocations):

algorithm ns semantics on singular
bare A \ b 4.4 Inf/NaN (N≤3) or throw (N≥4)
DirectLdiv!() 5.6 same as \ — the unchecked zero-overhead path (~1 ns = one alg-type branch)
GESVFactorization() ~8-9 Failure retcode, no rescue
default (nothing) 9.2 Failure-safe + SVD min-norm rescue

(LinearProblem construction itself costs 4.4 ns — earlier quoted numbers included it; per-solve marginal cost on a prebuilt problem is the column above.) So the tiering is: DirectLdiv! when you know your matrix is fine and want raw \ speed, gesv for failure reporting, default for full safety — all allocation-free.

Add a StaticArrays tutorial page documenting the non-caching static
fast path (0-allocation direct solve, immutable so no init!/solve!),
the algorithm tier (DirectLdiv! / GESVFactorization / LUFactorization /
default) with their singular-input behavior and measured overhead, and
non-square SVD routing. Note the static GESV dispatch in the
GESVFactorization docstring.

Also raise the HTML size_threshold to 500 KiB: solvers.md (a long
auto-listed solver catalog) exceeds the default 200 KiB limit, which
has been failing the Documentation CI on main independently of this PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
@ChrisRackauckas-Claude

Copy link
Copy Markdown
Member Author

Documented the StaticArrays behavior (1e13cb1c): new docs/src/tutorials/static_arrays.md covering the non-caching static fast path (0-allocation direct solve; immutable ⇒ no init!/solve! interface), the algorithm tier with singular-input behavior and measured overhead —

algorithm ~2×2 singular A
DirectLdiv! 5.6 ns unchecked (Inf/NaN or throw)
GESVFactorization 8–9 ns Failure, no rescue
LUFactorization 22 ns Failure
default 9.2 ns SVD min-norm rescue, Success

— plus non-square SVD routing, and a note in the GESVFactorization docstring about its static dispatch. The @example blocks execute during the build (they render correctly).

While there I also fixed a pre-existing Documentation CI failure unrelated to this PR: solvers/solvers.md (a ~970-line auto-listed solver catalog) exceeds Documenter's default 200 KiB per-page HTML limit, which has been failing the docs build on main (confirmed on c4ebafe and 571d0a8). Raised size_threshold to 500 KiB — full docs build is now green locally (exit 0, no cross-ref/threshold errors).

@ChrisRackauckas
ChrisRackauckas marked this pull request as ready for review July 11, 2026 19:48
@ChrisRackauckas
ChrisRackauckas merged commit 86d0ef9 into SciML:main Jul 11, 2026
51 of 55 checks passed
ChrisRackauckas-Claude pushed a commit to ChrisRackauckas-Claude/NonlinearSolve.jl that referenced this pull request Jul 11, 2026
…ty solve

Replace the bespoke pinv machinery in the quasi-Newton inverse-Jacobian
initialization with a plain A·X = I solve through the shared LinearSolve
stack. Utils.linsolve_workspace / linsolve_identity!! build a
default-algorithm linear-solve cache once and solve against an identity
RHS; no matrix is inverted. Sparse A routes through the same cache
(pinv has no sparse method). Number/Diagonal use a zero-guarded
safe_inv (bit-identical to the old pinv values).

SMatrix solves A·X = I through LinearSolve's static fast path directly,
whose SVD rescue (SciML/LinearSolve.jl#1085, LinearSolve ≥ 4.3) returns
the finite min-norm generalized inverse on a singular initial Jacobian —
the behavior pinv provided — allocation-free. Compat: LinearSolve
"4.3, 5".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
ChrisRackauckas-Claude pushed a commit to ChrisRackauckas-Claude/NonlinearSolve.jl that referenced this pull request Jul 12, 2026
…ty solve

Replace the bespoke pinv machinery in the quasi-Newton inverse-Jacobian
initialization with a plain A·X = I solve through the shared LinearSolve
stack. Utils.linsolve_workspace / linsolve_identity!! build a
default-algorithm linear-solve cache once and solve against an identity
RHS; no matrix is inverted. Sparse A routes through the same cache
(pinv has no sparse method). Number/Diagonal use a zero-guarded
safe_inv (bit-identical to the old pinv values).

SMatrix solves A·X = I through LinearSolve's static fast path directly,
whose SVD rescue (SciML/LinearSolve.jl#1085, LinearSolve ≥ 4.3) returns
the finite min-norm generalized inverse on a singular initial Jacobian —
the behavior pinv provided — allocation-free. Compat: LinearSolve
"4.3, 5".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
ChrisRackauckas added a commit to SciML/NonlinearSolve.jl that referenced this pull request Jul 12, 2026
…ty solve (#1039)

Replace the bespoke pinv machinery in the quasi-Newton inverse-Jacobian
initialization with a plain A·X = I solve through the shared LinearSolve
stack. Utils.linsolve_workspace / linsolve_identity!! build a
default-algorithm linear-solve cache once and solve against an identity
RHS; no matrix is inverted. Sparse A routes through the same cache
(pinv has no sparse method). Number/Diagonal use a zero-guarded
safe_inv (bit-identical to the old pinv values).

SMatrix solves A·X = I through LinearSolve's static fast path directly,
whose SVD rescue (SciML/LinearSolve.jl#1085, LinearSolve ≥ 4.3) returns
the finite min-norm generalized inverse on a singular initial Jacobian —
the behavior pinv provided — allocation-free. Compat: LinearSolve
"4.3, 5".

Co-authored-by: ChrisRackauckas-Claude <accounts@chrisrackauckas.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

Static LU path returns retcode Success with Inf/NaN solution on singular SMatrix

2 participants