Fix singular handling in the StaticArrays square fast path with SVD rescue for the default#1085
Conversation
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>
|
Done — the barrier is removed and the small-N path inlines ( |
|
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):
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): So: no check-skipping |
…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>
|
Added the 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 |
|
Overhead decomposition for the static path (2×2, prebuilt
( |
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>
|
Documented the StaticArrays behavior (
— plus non-square SVD routing, and a note in the While there I also fixed a pre-existing Documentation CI failure unrelated to this PR: |
…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>
…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>
…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>
Fixes #1084
Note: This PR should be ignored until reviewed by @ChrisRackauckas.
Problem
The StaticArrays fast path in
solvehad no singular handling for square matrices:For N ≤ 3, StaticArrays'
\uses direct inverse formulas that silently produceInf/NaNon singular input, so the fast path returnedSuccesswith a non-finite solution. For N ≥ 4,\islu(A) \ bwithcheck = true, so the default path threwSingularExceptioninstead. ExplicitLUFactorization()(lu(prob.A) \ prob.b) also threw. (Theinit/solve!cache route was already fine — the generic LU machinery reportsFailurethere.)Fix
Two parts, both confined to the static fast path in
src/common.jl:Explicit static
LUFactorizationreports failure. TheLUFactorizationbranch now factors withlu(A, check = false)and checksLinearAlgebra.issuccess(which StaticArrays defines for itsLUby checking theUdiagonal — the same criterion as LAPACK'sinfo). On failure it returnsretcode = ReturnCode.Failurewith the zero-initializedu, exactly matching the denseLUFactorizationbehavior on singular input (verified: dense returnsFailurewithu = [0.0, 0.0]), instead of throwing.issuccessis used rather than a finiteness check onubecause it tests the factorization itself, matching dense semantics (a nonsingularAwith non-finitebstill reportsSuccesswith the non-finiteu, 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).Static square default gets an SVD rescue.
solve(::StaticLinearProblem, ::Nothing)— the static default — now detects LU failure the same way and rescues withsvd(A) \ b, returning the finite min-norm least-squares pseudo-solution withretcode = Success. This mirrors the dense default's singular-LU → pivoted-QRsafetyfallback, which likewise returns the rescue solve's retcode (Success, verified on the dense reproducer).Why SVD and not QR
qr(::SMatrix) \ bleast-squares is not defined in StaticArrays — the same reasondefaultalg(::SMatrix)already usesSVDFactorization()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 matchespinv(Matrix(A)) * bto 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
DefaultLinearSolvermachinery. Default provenance is already explicit in the fast path's dispatch:solve(prob, nothing)is the static default, andsolve(prob, alg::SciMLLinearSolveAlgorithm)is the explicit-algorithm path, so no provenance flag is needed. TheDefaultLinearSolvercacheval 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:
A \ b(direct inverse formulas) so results remain bit-identical to\; for N ≥ 4 it becomeslu(A, check = false) \ b, which is the identical computation toA \ bminus the throw.\kernel sits behind a@noinlinebarrier: StaticArrays' small-size formulas containmuladds 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):
Success,u = [Inf NaN; Inf NaN]SingularExceptionLUFactorization()SingularExceptionAfter:
Success, finiteu ≈ pinv(Matrix(A)) * b(min-norm)LUFactorization()Failure, zerou, no throw===) toA \ b/lu(A) \ bPerformance (BenchmarkTools minimum,
@inferredpasses, AllocCheck@check_allocspasses — the whole path including the rescue branch is statically allocation-free):@noinlinebarrier)LUFactorization()2×2 / 5×5Tests
New
"StaticArray Singular"testset intest/Core/retcodes.jl: singular 2×2 and 4×4 with SVector and SMatrix RHS (default → finite min-norm ≈pinv, correct retcode; explicit LU →Failure, finiteu, no throw), nonsingular bit-identity (===) for both sizes and both RHS kinds against\andlu(A) \, and a non-square problem unchanged. Ran locally:GROUP=CorePkg.test()andtest/AD/static_arrays.jl(AllocCheck/@inferredstatic tests, 23 pass / 1 pre-existing broken).Notes
src/common.jl(plus tests), touching only howu/retcodeare computed beforebuild_linear_solution, so whichever of the two merges second rebases trivially.pinvspecial case forSMatrixprecisely because of this bug; once this merges, that case can route through the LinearSolve static path (follow-up, not done here).🤖 Generated with Claude Code