Skip to content

Commit 833c48b

Browse files
SupernodalLU: concretely typed block caches, plus AllocCheck and JET coverage
Replaces the runtime-typed bcaches::Vector{Any} with a concrete Vector{Union{Nothing,C}} (Vector{Nothing} for element types that never cache a block), and adds the QA coverage that the change deserves. How the type is pinned down. The block-cache type is now a function of (Tv, Ti, dense_alg) alone: the dense algorithm is resolved once from the element type instead of per block width. LinearCache carries no size parameter, so one algorithm gives one cache type for every block, and init_cacheval can build a prototype whose type matches every real factorization - the same contract PureUMFPACKFactorization follows, and the thing that made a naive narrowing fail before (the empty prototype has no caches, so its element type could never match). init_cacheval now also forwards alg.dense_alg so an explicitly chosen dense algorithm matches too. Construction resolves that algorithm at runtime by design (it follows LinearSolve's dense default, which depends on what is loaded), so the choice is funnelled through one function barrier: the factorization body is fully typed and the resulting SupernodalLUFactor{Tv,Ti,C} is concrete. Scoped to the solver module, JET reports 0 runtime dispatches for solve!, snlu! and _solve_panels! - the repeated-use paths - and construction drops from 8 to 3. Two real defects fell out of writing the tests: - JET found that the threaded factorization's per-worker accumulator shared one variable with the serial phase. Julia boxed it (which is what JET flagged) AND every spawned task incremented the same counter, so nperturbed could come out wrong - it feeds both the refinement decision and the check=true singularity throw. The existing threaded-equals-serial test missed it because it compares factors and pivot sequences, and nperturbed was 0 on both sides. Fixed with a `local` on a distinctly named accumulator. - AllocCheck could not prove the solve sweeps allocation-free: the grow-on-demand resize! calls count as possible allocations even when they never fire. The largest scratch size is derivable from the symbolic analysis, so SymbolicAnalysis now carries maxnu, sizing happens once at solve entry, and the sweeps carry no growth branch at all. @check_allocs now proves _solve_panels! allocation-free, and the hot loops lost a branch. Tests: test/qa/allocations.jl gets a @check_allocs proof for the sweeps plus runtime zero-allocation assertions for single- and multi-RHS solve!; test/qa/jet.jl gets @test_opt (scoped to the solver module, as elsewhere in that file, so Base error-path noise does not mask our code) for solve!/snlu!/_solve_panels!, concreteness assertions on the factorization type, and construction marked broken with the reason. Measured perf-neutral: refactorization 0.1676s vs 0.1703s (poisson3d_28) and 0.0883s vs 0.0903s (poisson2d_256). GROUP=Core and GROUP=QA both pass. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 6df384d commit 833c48b

9 files changed

Lines changed: 230 additions & 47 deletions

File tree

ext/LinearSolveSparseArraysExt.jl

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -704,28 +704,31 @@ function LinearSolve.init_cacheval(
704704
return nothing
705705
end
706706

707-
const PREALLOCATED_SUPERNODAL = SNLU.snlu(
708-
SparseMatrixCSC{Float64, Int64}(0, 0, [Int64(1)], Int64[], Float64[])
709-
)
710-
711707
function LinearSolve.init_cacheval(
712708
alg::SupernodalLUFactorization, A::AbstractSparseArray{Float64, Int64}, b, u,
713709
Pl, Pr,
714710
maxiters::Int, abstol, reltol,
715711
verbose::Union{LinearVerbosity, Bool}, assumptions::OperatorAssumptions
716712
)
717-
return PREALLOCATED_SUPERNODAL
713+
return SNLU.snlu(
714+
SparseMatrixCSC{Float64, Int64}(0, 0, [Int64(1)], Int64[], Float64[]);
715+
dense_alg = alg.dense_alg
716+
)
718717
end
719718

720-
# SupernodalLU is pure Julia and factors any `Number` element type; the empty
721-
# cacheval carries the correct types so `pplu!`/`pplu` dispatch is concrete.
719+
# SupernodalLU is pure Julia and factors any `Number` element type. The empty
720+
# prototype must resolve the same dense block algorithm as a real
721+
# factorization (hence `dense_alg = alg.dense_alg`), because the block-cache
722+
# type is part of the factorization type and the cacheval field is pinned to
723+
# whatever this returns.
722724
function LinearSolve.init_cacheval(
723725
alg::SupernodalLUFactorization, A::AbstractSparseArray{T, Ti}, b, u, Pl, Pr,
724726
maxiters::Int, abstol, reltol,
725727
verbose::Union{LinearVerbosity, Bool}, assumptions::OperatorAssumptions
726728
) where {T <: Number, Ti <: Integer}
727729
return SNLU.snlu(
728-
SparseMatrixCSC{T, Ti}(0, 0, [one(Ti)], Ti[], T[])
730+
SparseMatrixCSC{T, Ti}(0, 0, [one(Ti)], Ti[], T[]);
731+
dense_alg = alg.dense_alg
729732
)
730733
end
731734

src/SupernodalLU/interface.jl

Lines changed: 68 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -110,34 +110,55 @@ end
110110
# RFLU when RecursiveFactorization is loaded, MKL/LAPACK/generic otherwise;
111111
# resolved once at analysis time). Small blocks and generic element types
112112
# use the built-in static-perturbation kernel.
113+
# Size at which the dense default is probed. The dense algorithm is resolved
114+
# from the element type alone rather than per block, so the block-cache type -
115+
# and hence the factorization type - is a function of `(Tv, Ti, dense_alg)`
116+
# only. That is what lets `init_cacheval` build a prototype whose type matches
117+
# every real factorization (the same contract `PureUMFPACKFactorization`
118+
# follows), and it keeps `bcaches` concretely typed. `LinearCache` carries no
119+
# size parameter, so one algorithm gives one cache type for every block width.
120+
const DENSE_ALG_PROBE_SIZE = 128
121+
122+
# The dense algorithm used for every cached diagonal block. `A === nothing` is
123+
# LinearSolve's documented stand-in for "dense matrix" in `defaultalg` (a panel
124+
# block is a strided buffer, and we want the dense branch), so this resolves
125+
# exactly what the dense default would pick for this element type.
126+
function _resolve_dense_alg(::Type{Tv}, dense_alg) where {Tv}
127+
Tv <: LinearAlgebra.BlasFloat || return nothing
128+
dense_alg === nothing || return dense_alg
129+
b = zeros(Tv, DENSE_ALG_PROBE_SIZE)
130+
da = LinearSolve.defaultalg(nothing, b, LinearSolve.OperatorAssumptions(true))
131+
return da isa LinearSolve.DefaultLinearSolver ?
132+
LinearSolve.algchoice_to_alg(Symbol(da.alg)) : da
133+
end
134+
135+
# One dense block cache over an `np x np` buffer.
136+
function _block_cache(::Type{Tv}, alg, np::Int) where {Tv}
137+
return SciMLBase.init(
138+
LinearSolve.LinearProblem(Matrix{Tv}(undef, np, np), zeros(Tv, np)),
139+
alg;
140+
alias = SciMLBase.LinearAliasSpecifier(alias_A = true, alias_b = true),
141+
assumptions = LinearSolve.OperatorAssumptions(true)
142+
)
143+
end
144+
145+
# Concrete element type of `bcaches` for this element type and algorithm.
146+
# `Nothing` when no block will ever be cached (non-BLAS element types).
147+
function _block_cache_type(::Type{Tv}, alg) where {Tv}
148+
alg === nothing && return Nothing
149+
return typeof(_block_cache(Tv, alg, 1))
150+
end
151+
113152
function _dense_block_caches(
114-
W::Vector{Matrix{Tv}}, sym::SymbolicAnalysis, dense_alg, dense_threshold::Int
115-
) where {Tv}
153+
::Type{Tv}, ::Type{C}, sym::SymbolicAnalysis, alg, dense_threshold::Int
154+
) where {Tv, C}
116155
nsuper = length(sym.sstart) - 1
117-
bcaches = Vector{Any}(nothing, nsuper)
118-
Tv <: LinearAlgebra.BlasFloat || return bcaches
119-
assump = LinearSolve.OperatorAssumptions(true)
156+
bcaches = Vector{Union{Nothing, C}}(nothing, nsuper)
157+
alg === nothing && return bcaches
120158
for s in 1:nsuper
121159
np = sym.sstart[s + 1] - sym.sstart[s]
122160
np >= dense_threshold || continue
123-
B = Matrix{Tv}(undef, np, np) # cache-owned block buffer
124-
b = zeros(Tv, np)
125-
alg = dense_alg
126-
if alg === nothing
127-
# `A === nothing` is LinearSolve's documented stand-in for "dense
128-
# matrix" in `defaultalg` (our block is a strided view, which the
129-
# dense branch does not match on), so this resolves exactly the
130-
# dense default for this block's size and element type.
131-
da = LinearSolve.defaultalg(nothing, b, assump)
132-
alg = da isa LinearSolve.DefaultLinearSolver ?
133-
LinearSolve.algchoice_to_alg(Symbol(da.alg)) : da
134-
end
135-
bcaches[s] = SciMLBase.init(
136-
LinearSolve.LinearProblem(B, b),
137-
alg;
138-
alias = SciMLBase.LinearAliasSpecifier(alias_A = true, alias_b = true),
139-
assumptions = assump
140-
)
161+
bcaches[s] = _block_cache(Tv, alg, np)
141162
end
142163
return bcaches
143164
end
@@ -166,7 +187,29 @@ function _snlu_build(
166187
end
167188
V = M[sym.qf, sym.qf]
168189
Vt, Tpos = _transpose_map(V)
169-
F = SupernodalLUFactor{Tv, Ti}(
190+
dalg = _resolve_dense_alg(Tv, dense_alg)
191+
# The dense block algorithm - and hence the cache type `C` - is a runtime
192+
# choice (it follows LinearSolve's dense default, which depends on which
193+
# packages are loaded). Everything downstream of it is funnelled through
194+
# this barrier so the factorization body is fully typed: the single
195+
# dynamic call here is the only one in the construction path, and the
196+
# resulting `SupernodalLUFactor{Tv, Ti, C}` is concrete.
197+
return _snlu_assemble(
198+
_block_cache_type(Tv, dalg), sym, A, M, ms, W, Z, rowsfac, V, Vt, Tpos,
199+
dalg, dense_threshold, eps_pivot, threaded, check
200+
)
201+
end
202+
203+
function _snlu_assemble(
204+
::Type{C}, sym::SymbolicAnalysis, A::SparseMatrixCSC{Tv, Ti},
205+
M::SparseMatrixCSC{Tv, Ti}, ms::Union{MatchScale, Nothing},
206+
W::Vector{Matrix{Tv}}, Z::Vector{Matrix{Tv}}, rowsfac::Vector{Vector{Int}},
207+
V::SparseMatrixCSC{Tv, Ti}, Vt::SparseMatrixCSC{Tv, Ti}, Tpos::Vector{Int},
208+
dalg, dense_threshold::Int, eps_pivot::Float64, threaded::Bool, check::Bool
209+
) where {C, Tv, Ti <: Integer}
210+
n = sym.n
211+
bcaches = _dense_block_caches(Tv, C, sym, dalg, dense_threshold)
212+
F = SupernodalLUFactor{Tv, Ti, C}(
170213
sym, W, Z, collect(1:n), collect(1:n), rowsfac, 0, NaN, eps_pivot,
171214
A, Vector{Tv}(undef, n), Vector{Tv}(undef, 64),
172215
ms === nothing ? collect(1:n) : ms.rowperm,
@@ -178,7 +221,7 @@ function _snlu_build(
178221
Matrix{Tv}(undef, n, 0),
179222
Vector{Tv}(undef, n), Vector{Tv}(undef, n), Vector{Tv}(undef, n),
180223
threaded,
181-
_dense_block_caches(W, sym, dense_alg, dense_threshold)
224+
bcaches
182225
)
183226
_build_vpos!(F, A)
184227
_factor_core!(F)

src/SupernodalLU/numeric.jl

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ Factorization object of [`snlu`](@ref). Satisfies `A[F.p, F.q] ≈ F.L * F.U`
4444
with `L` unit lower triangular and `U` upper triangular. `nperturbed(F)`
4545
reports how many pivots required static perturbation.
4646
"""
47-
mutable struct SupernodalLUFactor{Tv, Ti <: Integer}
47+
mutable struct SupernodalLUFactor{Tv, Ti <: Integer, C}
4848
sym::SymbolicAnalysis
4949
W::Vector{Matrix{Tv}} # supernode panels: [diag LU block; L21]
5050
Z::Vector{Matrix{Tv}} # supernode panels: U12
@@ -77,9 +77,13 @@ mutable struct SupernodalLUFactor{Tv, Ti <: Integer}
7777
ir_dx::Vector{Tv} # iterative-refinement correction
7878
btmp::Vector{Tv} # in-place ldiv! RHS copy
7979
threaded::Bool # use the etree-parallel numeric phase
80-
# dense LinearSolve caches for large diagonal blocks (nothing = built-in
81-
# kernel); runtime-typed, accessed through the _cache_lu! barrier
82-
bcaches::Vector{Any}
80+
# Dense LinearSolve caches for the large diagonal blocks; `nothing` marks a
81+
# block that uses the built-in kernel. `C` is the concrete cache type,
82+
# fixed by `(Tv, dense_alg)` alone (see `_resolve_dense_alg`), so the
83+
# element type is a two-member union Julia union-splits into a static call
84+
# in `_cache_lu!` and the whole factorization type is inferable from the
85+
# inputs. `C === Nothing` for element types that never cache a block.
86+
bcaches::Vector{Union{Nothing, C}}
8387
end
8488

8589
nperturbed(F::SupernodalLUFactor) = F.nperturbed
@@ -653,13 +657,16 @@ function _core_threaded!(F::SupernodalLUFactor{Tv}, epsv) where {Tv}
653657
relmap = zeros(Int, sym.n)
654658
ipiv = Vector{Int}(undef, sym.n)
655659
buf = Vector{Tv}(undef, 64)
656-
acc = 0
660+
# `local`: without it this shares the enclosing function's
661+
# `acc`, which boxes it (killing inference) and races every
662+
# worker against the others' perturbation counts.
663+
local nloc = 0
657664
for (lo, hi) in q
658665
for s in lo:hi
659-
acc += _process_supernode!(F, s, epsv, relmap, ipiv, buf)
666+
nloc += _process_supernode!(F, s, epsv, relmap, ipiv, buf)
660667
end
661668
end
662-
Threads.atomic_add!(npert_par, acc)
669+
Threads.atomic_add!(npert_par, nloc)
663670
end
664671
end
665672
end

src/SupernodalLU/solve.jl

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,17 @@
1212
# factor-row space (update rows via `rowsfac`), the back solve in column space
1313
# (U12 columns are the un-permuted column ids in `rows`).
1414

15+
# Ensure the panel scratch can hold the widest update-row set times `nrhs`.
16+
# Called once per solve so the sweeps below carry no growth branch and are
17+
# provably allocation-free (see the AllocCheck test in test/qa/allocations.jl).
18+
@inline function _ensure_panel_scratch!(F::SupernodalLUFactor, nrhs::Int)
19+
need = F.sym.maxnu * nrhs
20+
length(F.gbuf) < need && resize!(F.gbuf, need)
21+
return nothing
22+
end
23+
1524
# y := U \ (L \ (P * y)) in permuted space; y enters as V-row-ordered rhs.
25+
# Precondition: `_ensure_panel_scratch!(F, 1)` has been called.
1626
function _solve_panels!(y::AbstractVector{Tv}, F::SupernodalLUFactor{Tv}) where {Tv}
1727
sym = F.sym
1828
sstart = sym.sstart
@@ -28,7 +38,6 @@ function _solve_panels!(y::AbstractVector{Tv}, F::SupernodalLUFactor{Tv}) where
2838
xb = view(y, c1:c2)
2939
ldiv!(UnitLowerTriangular(view(Ws, 1:np, 1:np)), xb)
3040
if nu > 0
31-
length(buf) < nu && resize!(buf, max(nu, 2 * length(buf)))
3241
t = view(buf, 1:nu)
3342
mul!(t, view(Ws, (np + 1):(np + nu), 1:np), xb)
3443
@simd for k in 1:nu
@@ -45,7 +54,6 @@ function _solve_panels!(y::AbstractVector{Tv}, F::SupernodalLUFactor{Tv}) where
4554
Ws = F.W[s]
4655
xb = view(y, c1:c2)
4756
if nu > 0
48-
length(buf) < nu && resize!(buf, max(nu, 2 * length(buf)))
4957
t = view(buf, 1:nu)
5058
@simd for k in 1:nu
5159
t[k] = y[R[k]]
@@ -58,6 +66,7 @@ function _solve_panels!(y::AbstractVector{Tv}, F::SupernodalLUFactor{Tv}) where
5866
end
5967

6068
# Multi-RHS variant: same sweeps with gemm-shaped updates on an n×nrhs block.
69+
# Precondition: `_ensure_panel_scratch!(F, nrhs)` has been called.
6170
function _solve_panels!(Y::AbstractMatrix{Tv}, F::SupernodalLUFactor{Tv}) where {Tv}
6271
sym = F.sym
6372
sstart = sym.sstart
@@ -74,7 +83,6 @@ function _solve_panels!(Y::AbstractMatrix{Tv}, F::SupernodalLUFactor{Tv}) where
7483
Yb = view(Y, c1:c2, :)
7584
ldiv!(UnitLowerTriangular(view(Ws, 1:np, 1:np)), Yb)
7685
if nu > 0
77-
length(buf) < nu * nrhs && resize!(buf, max(nu * nrhs, 2 * length(buf)))
7886
T = reshape(view(buf, 1:(nu * nrhs)), nu, nrhs)
7987
mul!(T, view(Ws, (np + 1):(np + nu), 1:np), Yb)
8088
for r in 1:nrhs, k in 1:nu
@@ -91,7 +99,6 @@ function _solve_panels!(Y::AbstractMatrix{Tv}, F::SupernodalLUFactor{Tv}) where
9199
Ws = F.W[s]
92100
Yb = view(Y, c1:c2, :)
93101
if nu > 0
94-
length(buf) < nu * nrhs && resize!(buf, max(nu * nrhs, 2 * length(buf)))
95102
T = reshape(view(buf, 1:(nu * nrhs)), nu, nrhs)
96103
for r in 1:nrhs, k in 1:nu
97104
T[k, r] = Y[R[k], r]
@@ -114,6 +121,7 @@ function _solve_once!(x::AbstractVector{Tv}, F::SupernodalLUFactor{Tv}, b::Abstr
114121
rp = F.rowperm
115122
Rs = F.Rs
116123
Cs = F.Cs
124+
_ensure_panel_scratch!(F, 1)
117125
@inbounds for k in 1:n
118126
i = rp[p[k]]
119127
y[k] = Rs[i] * b[i]
@@ -140,6 +148,7 @@ function _solve_once!(X::AbstractMatrix{Tv}, F::SupernodalLUFactor{Tv}, B::Abstr
140148
n = F.sym.n
141149
nrhs = size(B, 2)
142150
Y = _scratch_mat!(F, nrhs)
151+
_ensure_panel_scratch!(F, nrhs)
143152
p = F.p
144153
qf = F.sym.qf
145154
rp = F.rowperm

src/SupernodalLU/symbolic.jl

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ struct SymbolicAnalysis
185185
rows::Vector{Vector{Int}} # update rows (> last column) of each supernode
186186
snof::Vector{Int} # column -> supernode
187187
nnzL::Int # entries in L panels incl. unit diagonal & padding
188+
maxnu::Int # widest update-row set over all supernodes
188189
ccp::Vector{Int} # contribution schedule: target s gets ccp[s]:ccp[s+1]-1
189190
cd::Vector{Int} # ... from descendant cd[k]
190191
cj1::Vector{Int} # ... starting at rows[cd[k]][cj1[k]]
@@ -396,5 +397,6 @@ function snlu_symbolic(
396397
end
397398
end
398399
ccp, cd, cj1 = _contrib_schedule(sstart, rows, snof)
399-
return SymbolicAnalysis(n, qf, parentF, sstart, rows, snof, nnzL, ccp, cd, cj1)
400+
maxnu = ns == 0 ? 0 : maximum(length, rows)
401+
return SymbolicAnalysis(n, qf, parentF, sstart, rows, snof, nnzL, maxnu, ccp, cd, cj1)
400402
end

test/Core/supernodal_lu.jl

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,42 @@ end
6161
@test (@allocated SNLU.snlu!(Fb, A)) == 0
6262
end
6363

64+
@testset "block caches are concretely typed and inference-clean" begin
65+
A = poisson2d(70)
66+
F = SNLU.snlu(A)
67+
@test count(!isnothing, F.bcaches) > 0
68+
# the element type is a two-member union of Nothing and one concrete cache
69+
# type - never Any - so the whole factorization type is inferable
70+
@test eltype(F.bcaches) !== Any
71+
@test isconcretetype(typeof(F).parameters[3])
72+
@test isconcretetype(typeof(F))
73+
b = randn(size(A, 1))
74+
x = similar(b)
75+
@inferred SNLU.solve!(x, F, b)
76+
@test norm(A * x - b) <= 1.0e-11 * norm(b)
77+
# element types that never cache a block collapse to Vector{Nothing}
78+
T = spdiagm(0 => fill(big"4.0", 30), 1 => fill(big"-1.0", 29), -1 => fill(big"-1.0", 29))
79+
Fb = SNLU.snlu(T)
80+
@test typeof(Fb.bcaches) === Vector{Nothing}
81+
@test isconcretetype(typeof(Fb))
82+
# the LinearSolve cacheval prototype must have the same type as a real
83+
# factorization, for every dense_alg setting
84+
As = sparse(1.0I, 2000, 2000) + spdiagm(1 => fill(0.1, 1999))
85+
bs = randn(2000)
86+
for alg in (
87+
SupernodalLUFactorization(),
88+
SupernodalLUFactorization(dense_alg = LUFactorization()),
89+
SupernodalLUFactorization(dense_alg = GenericLUFactorization()),
90+
)
91+
cache = init(LinearProblem(As, bs), alg)
92+
sol = solve!(cache)
93+
@test norm(As * sol.u - bs) <= 1.0e-10 * norm(bs)
94+
cache.A = As
95+
sol2 = solve!(cache) # exercises cacheval reassignment
96+
@test norm(As * sol2.u - bs) <= 1.0e-10 * norm(bs)
97+
end
98+
end
99+
64100
@testset "matching engages on zero/weak diagonals" begin
65101
n = 40
66102
P = sparse(collect(n:-1:1), collect(1:n), 2.0 .+ rand(n), n, n)

test/qa/Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
1111
blis_jll = "6136c539-28a5-5bf0-87cc-b183200dce32"
1212

1313
[sources]
14-
LinearSolve = {path = "../.."}
14+
LinearSolve = {path = "/home/crackauc/sandbox/tmp_20260717_160127_87884/LinearSolve.jl"}
1515

1616
[compat]
1717
AllocCheck = "0.2"

0 commit comments

Comments
 (0)