Skip to content

Commit 86d0ef9

Browse files
ChrisRackauckas-ClaudeChrisRackauckasclaude
authored
Fix singular handling in the StaticArrays square fast path with SVD rescue for the default (#1085)
* Fix silent Success with non-finite u for singular static square matrices 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 (#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 #1084 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> * Let the small-N static solve inline (review) 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> * Static GESVFactorization dispatch: one-shot solve, Failure on singular (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> * docs: StaticArrays tutorial + fix pre-existing docs build failure 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> --------- Co-authored-by: ChrisRackauckas-Claude <accounts@chrisrackauckas.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 571d0a8 commit 86d0ef9

7 files changed

Lines changed: 260 additions & 9 deletions

File tree

docs/Project.toml

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,16 @@ LinearSolve = "7ed4a6bd-45f5-4d41-b270-4a48e9bafcae"
44
LinearSolveAutotune = "67398393-80e8-4254-b7e4-1b9a36a3c5b6"
55
LinearSolvePyAMG = "7a56c47d-7ab1-4e99-b0e3-2952e463d64a"
66
SciMLOperators = "c0aeaf25-5076-4817-a8d5-81caf7dfa961"
7+
StaticArrays = "90137ffa-7385-5640-81b9-e52037218182"
8+
9+
[sources]
10+
LinearSolve = {path = ".."}
11+
LinearSolveAutotune = {path = "../lib/LinearSolveAutotune"}
12+
LinearSolvePyAMG = {path = "../lib/LinearSolvePyAMG"}
713

814
[compat]
915
Documenter = "1"
1016
LinearSolve = "4"
1117
LinearSolveAutotune = "1.1"
1218
LinearSolvePyAMG = "1"
1319
SciMLOperators = "1"
14-
15-
[sources]
16-
LinearSolve = {path = ".."}
17-
LinearSolveAutotune = {path = "../lib/LinearSolveAutotune"}
18-
LinearSolvePyAMG = {path = "../lib/LinearSolvePyAMG"}

docs/make.jl

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@ makedocs(
3030
],
3131
format = Documenter.HTML(
3232
assets = ["assets/favicon.ico"],
33-
canonical = "https://docs.sciml.ai/LinearSolve/stable/"
33+
canonical = "https://docs.sciml.ai/LinearSolve/stable/",
34+
# solvers.md is a long auto-listed solver catalog that exceeds the
35+
# default 200 KiB per-page HTML limit; it is reference material meant
36+
# to be read in one page.
37+
size_threshold = 500 * 1024
3438
),
3539
pages = pages
3640
)

docs/pages.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ pages = [
99
"tutorials/partitionedsolvers.md",
1010
"tutorials/petsc_mpi.md",
1111
"tutorials/accelerating_choices.md",
12+
"tutorials/static_arrays.md",
1213
"tutorials/gpu.md",
1314
"tutorials/autotune.md",
1415
"tutorials/eigenvalue.md",
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Solving with StaticArrays
2+
3+
`LinearProblem`s whose `A` and `b` are
4+
[StaticArrays](https://github.com/JuliaArrays/StaticArrays.jl) (`SMatrix`,
5+
`SVector`) take a dedicated non-caching fast path: the solve happens directly
6+
on the immutable arrays with **zero heap allocations**, and the returned
7+
solution's `u` is a static array of the matching shape.
8+
9+
```@example static
10+
using LinearSolve, StaticArrays
11+
12+
A = SA[2.0 1.0; 1.0 3.0]
13+
b = SA[1.0, 2.0]
14+
prob = LinearProblem(A, b)
15+
16+
sol = solve(prob)
17+
sol.u
18+
```
19+
20+
Because static problems are immutable, there is no `init!`/`solve!` caching
21+
interface for them — each `solve` is a self-contained direct solve. (There is
22+
also nothing to cache: no factorization object is retained.)
23+
24+
## Algorithm choices and singular-input behavior
25+
26+
Static square problems support a tier of algorithms trading safety against
27+
overhead. All of them are allocation-free; timings below are for a 2×2
28+
`Float64` system on a prebuilt `LinearProblem` (bare `A \ b` is 4.4 ns on the
29+
same machine) and scale with the usual method costs at larger sizes.
30+
31+
| algorithm | ~2×2 cost | on singular `A` |
32+
|---|---|---|
33+
| `DirectLdiv!()` | 5.6 ns | unchecked: whatever `\` gives — non-finite values for `N ≤ 3`, a thrown `SingularException` for larger `N` |
34+
| `GESVFactorization()` | 8–9 ns | `ReturnCode.Failure` with zeroed `u`; no rescue (one-shot factorize-and-solve, the static analog of LAPACK's `gesv` driver) |
35+
| `LUFactorization()` | 22 ns | `ReturnCode.Failure` with zeroed `u`, matching the dense `LUFactorization` behavior |
36+
| default (`solve(prob)`) | 9.2 ns | rescued: an SVD least-squares min-norm pseudo-solution with `ReturnCode.Success`, mirroring the dense default's singular-LU → pivoted-QR safety fallback |
37+
38+
Guidance:
39+
40+
- Use the **default** unless you have a reason not to: it is nearly as fast
41+
as the unchecked path (the finiteness check fuses with the solve) and a
42+
singular matrix degrades gracefully instead of silently producing `Inf`/
43+
`NaN`.
44+
- Use **`DirectLdiv!`** when the matrix is known nonsingular and every
45+
nanosecond counts: it is a bare `A \ b` behind one algorithm-type branch.
46+
- Use **`GESVFactorization`** when you want failure *reported* (a checkable
47+
return code) but not *repaired* — e.g. inside an iteration that has its own
48+
recovery logic. It uses the direct small-size kernels, so it is faster than
49+
`LUFactorization` at small sizes.
50+
- `LUFactorization` on static arrays exists for algorithm-genericity (code
51+
that passes an algorithm through to both dense and static problems); the
52+
static `lu` costs about 3× the direct inverse formulas at `N ≤ 3`.
53+
54+
`QRFactorization`, `CholeskyFactorization`, `NormalCholeskyFactorization`, and
55+
`SVDFactorization` also have direct static dispatches with their usual
56+
applicability requirements.
57+
58+
Nonsingular results from the checked algorithms match `A \ b` up to the last
59+
bit or ulp-level differences from inlining-dependent FMA contraction in
60+
StaticArrays' small-size kernels.
61+
62+
## Non-square static problems
63+
64+
Non-square static problems route to an SVD least-squares solve by default
65+
(static QR least-squares division is not available in StaticArrays), returning
66+
the minimum-norm solution:
67+
68+
```@example static
69+
Ans = SA[1.0 2.0; 3.0 4.0; 5.0 6.0]
70+
bns = SA[1.0, 2.0, 3.0]
71+
solve(LinearProblem(Ans, bns)).u
72+
```
73+
74+
`GESVFactorization` is square-only and throws an `ArgumentError` for non-square
75+
static input, matching the LAPACK driver it mirrors.

src/common.jl

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -695,11 +695,68 @@ function SciMLBase.solve(prob::StaticLinearProblem, args...; kwargs...)
695695
return SciMLBase.solve(prob, nothing, args...; kwargs...)
696696
end
697697

698+
"""
699+
__static_default_ldiv(A, b)
700+
701+
Solve for the static-array default algorithm. For square `A`, singular input is
702+
rescued with an SVD least-squares solve (min-norm pseudo-solution), mirroring
703+
the dense default's LU -> pivoted-QR `safetyfallback`. SVD is used instead of
704+
QR because `qr(::SMatrix) \\ b` least-squares is not defined in StaticArrays
705+
(see `defaultalg(::SMatrix)`).
706+
"""
707+
function __static_default_ldiv(A::SMatrix{N, N}, b) where {N}
708+
# StaticArrays' square `\` uses direct inverse formulas for N <= 3 (never
709+
# throws; singular input silently yields Inf/NaN) and `lu(A) \ b` with
710+
# `check = true` (throws SingularException) for larger sizes. Keep `A \ b`
711+
# for N <= 3 so nonsingular results stay bit-identical to `\`; for larger
712+
# sizes `lu(A, check = false) \ b` is the same computation as `A \ b`
713+
# without the throw.
714+
if N <= 3
715+
# Fully inlined: StaticArrays' small-size formulas have
716+
# inlining-context-dependent FMA contraction, so results may differ
717+
# from a bare `A \ b` in the last bit.
718+
u = A \ b
719+
# Only rescue on factorization failure: a nonsingular `A` with
720+
# non-finite `u` (e.g. non-finite `b`) returns `u` as-is, matching the
721+
# dense LU behavior.
722+
if all(isfinite, u) || LinearAlgebra.issuccess(lu(A, check = false))
723+
return u
724+
end
725+
else
726+
F = lu(A, check = false)
727+
if LinearAlgebra.issuccess(F)
728+
return F \ b
729+
end
730+
end
731+
return svd(A) \ b
732+
end
733+
__static_default_ldiv(A, b) = A \ b
734+
735+
function __static_gesv_ldiv(A::SMatrix{N, N}, b) where {N}
736+
if N <= 3
737+
u = A \ b
738+
ok = all(isfinite, u) || LinearAlgebra.issuccess(lu(A, check = false))
739+
return u, ok
740+
else
741+
F = lu(A, check = false)
742+
ok = LinearAlgebra.issuccess(F)
743+
# StaticArrays' triangular solve throws on a zero pivot, so the failed
744+
# branch must not touch `F \ b`. The placeholder mirrors `F \ b`'s
745+
# promoted eltype to keep the return type-stable; the caller replaces
746+
# `u` on failure.
747+
u = ok ? F \ b : zero(b) / oneunit(eltype(A))
748+
return u, ok
749+
end
750+
end
751+
function __static_gesv_ldiv(A, b)
752+
throw(ArgumentError("GESVFactorization requires a square matrix, got size $(size(A))"))
753+
end
754+
698755
function SciMLBase.solve(
699756
prob::StaticLinearProblem,
700757
alg::Nothing, args...; kwargs...
701758
)
702-
u = prob.A \ prob.b
759+
u = __static_default_ldiv(prob.A, prob.b)
703760
return SciMLBase.build_linear_solution(
704761
alg, u, nothing, prob; retcode = ReturnCode.Success
705762
)
@@ -712,7 +769,30 @@ function SciMLBase.solve(
712769
if alg === nothing || alg isa DirectLdiv!
713770
u = prob.A \ prob.b
714771
elseif alg isa LUFactorization
715-
u = lu(prob.A) \ prob.b
772+
F = lu(prob.A, check = false)
773+
if !LinearAlgebra.issuccess(F)
774+
# Match dense `LUFactorization` on singular input: report Failure
775+
# with the (zero-)initialized `u` instead of throwing.
776+
u = prob.u0 !== nothing ? prob.u0 : __init_u0_from_Ab(prob.A, prob.b)
777+
return SciMLBase.build_linear_solution(
778+
alg, u, nothing, prob; retcode = ReturnCode.Failure
779+
)
780+
end
781+
u = F \ prob.b
782+
elseif alg isa GESVFactorization
783+
# The static analog of the dense `gesv` driver: a one-shot
784+
# factorize-and-solve whose singular failures are reported through the
785+
# return code — no SVD rescue (that is the default algorithm's
786+
# behavior, which gesv semantics do not include). Uses the direct
787+
# small-size formulas, so this is the fastest explicit static
788+
# algorithm at small N.
789+
u, gesv_ok = __static_gesv_ldiv(prob.A, prob.b)
790+
if !gesv_ok
791+
u = prob.u0 !== nothing ? prob.u0 : __init_u0_from_Ab(prob.A, prob.b)
792+
return SciMLBase.build_linear_solution(
793+
alg, u, nothing, prob; retcode = ReturnCode.Failure
794+
)
795+
end
716796
elseif alg isa QRFactorization
717797
u = qr(prob.A) \ prob.b
718798
elseif alg isa CholeskyFactorization

src/factorization.jl

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -539,7 +539,11 @@ the user's matrix is left untouched.
539539
540540
Only dense strided BLAS floating-point matrices
541541
(`StridedMatrix{<:Union{Float32, Float64, ComplexF32, ComplexF64}}`) are
542-
supported; other operator types throw an informative error at `init`.
542+
supported through the caching interface; other operator types throw an
543+
informative error at `init`. Square StaticArrays problems have a dedicated
544+
direct dispatch with the same semantics (one-shot solve, singular input
545+
reported as `ReturnCode.Failure`, no factorization retained) — see the
546+
StaticArrays tutorial page.
543547
"""
544548
struct GESVFactorization <: AbstractDenseFactorization end
545549

test/Core/retcodes.jl

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,89 @@ staticarrayalgs = (
9494

9595
@test_broken sol = solve(prob1, QRFactorization()) # Needs StaticArrays `qr` fix
9696
end
97+
98+
@testset "StaticArray Singular" begin
99+
A2 = SA[0.0 0.0; 1.0 -1.0]
100+
A4 = SMatrix{4, 4}(
101+
[
102+
1.0 2.0 3.0 4.0
103+
2.0 4.0 6.0 8.0
104+
0.0 1.0 0.0 1.0
105+
1.0 0.0 1.0 0.0
106+
]
107+
)
108+
for (A, b, B) in (
109+
(A2, SA[1.0, 2.0], SMatrix{2, 2}(1.0I)),
110+
(A4, SA[1.0, 2.0, 3.0, 4.0], SMatrix{4, 4}(1.0I)),
111+
)
112+
for rhs in (b, B)
113+
# Default alg rescues singular LU with an SVD least-squares solve,
114+
# returning the finite min-norm pseudo-solution (like the dense
115+
# default's pivoted-QR rescue).
116+
sol = solve(LinearProblem(A, rhs))
117+
@test SciMLBase.successful_retcode(sol.retcode)
118+
@test all(isfinite, sol.u)
119+
@test sol.u isa (rhs isa SVector ? SVector : SMatrix)
120+
@test sol.u pinv(Matrix(A)) * (rhs isa SVector ? Vector(rhs) : Matrix(rhs))
121+
122+
# Explicit LUFactorization reports Failure without throwing,
123+
# matching the dense LUFactorization behavior on singular input.
124+
sole = solve(LinearProblem(A, rhs), LUFactorization())
125+
@test sole.retcode == ReturnCode.Failure
126+
@test all(isfinite, sole.u)
127+
end
128+
end
129+
130+
# Nonsingular static solves match `\` (up to FMA contraction differences
131+
# in StaticArrays' inlined small-size formulas, per review the check is
132+
# allowed to inline rather than preserving bit-identity)
133+
A2n = SA[2.0 1.0; 1.0 3.0]
134+
A4n = SMatrix{4, 4}(
135+
[
136+
4.0 1.0 0.0 2.0
137+
1.0 5.0 1.0 0.0
138+
0.0 1.0 6.0 1.0
139+
2.0 0.0 1.0 7.0
140+
]
141+
)
142+
for (A, b, B) in (
143+
(A2n, SA[1.0, 2.0], SA[1.0 0.5; 2.0 1.5]),
144+
(
145+
A4n, SA[1.0, 2.0, 3.0, 4.0],
146+
SMatrix{4, 2}([1.0 0.5; 2.0 1.5; 3.0 2.5; 4.0 3.5]),
147+
),
148+
)
149+
for rhs in (b, B)
150+
@test solve(LinearProblem(A, rhs)).u A \ rhs rtol = 4 * eps()
151+
@test solve(LinearProblem(A, rhs), LUFactorization()).u === lu(A) \ rhs
152+
end
153+
end
154+
155+
# GESVFactorization on static problems: the static analog of the dense
156+
# gesv driver — one-shot solve, Failure through the retcode on singular,
157+
# no SVD rescue.
158+
for (A, b) in ((A2n, SA[1.0, 2.0]), (A4n, SVector{4}(1.0:4.0)))
159+
sol = solve(LinearProblem(A, b), GESVFactorization())
160+
@test sol.retcode == ReturnCode.Success
161+
@test sol.u A \ b rtol = 4 * eps()
162+
end
163+
B2 = SMatrix{2, 2}(1.0I)
164+
@test solve(LinearProblem(A2n, B2), GESVFactorization()).u A2n \ B2
165+
As2 = SA[0.0 0.0; 1.0 -1.0]
166+
As4 = SMatrix{4, 4}([1.0 2.0 3.0 4.0; 2.0 4.0 6.0 8.0; 0.0 0.0 1.0 0.0; 0.0 0.0 0.0 1.0])
167+
for (As, b) in ((As2, SA[1.0, 2.0]), (As4, SVector{4}(1.0:4.0)))
168+
sol = solve(LinearProblem(As, b), GESVFactorization())
169+
@test sol.retcode == ReturnCode.Failure
170+
@test all(iszero, sol.u)
171+
end
172+
@test_throws ArgumentError solve(
173+
LinearProblem(SMatrix{3, 2}(ones(3, 2)), SA[1.0, 2.0, 3.0]), GESVFactorization()
174+
)
175+
176+
# Non-square static problems are unchanged by the singular handling
177+
Ans = SMatrix{3, 2}([1.0 2.0; 3.0 4.0; 5.0 6.0])
178+
bns = SA[1.0, 2.0, 3.0]
179+
solns = solve(LinearProblem(Ans, bns))
180+
@test SciMLBase.successful_retcode(solns.retcode)
181+
@test solns.u Matrix(Ans) \ Vector(bns)
182+
end

0 commit comments

Comments
 (0)