Skip to content

Commit e3af7ef

Browse files
SupernodalLU: sparse block-cache storage, AllocCheck/JET coverage, threaded race fix
Four changes to the vendored solver, found by auditing it against PureKLU's optimization history and by writing the QA coverage. 1. Block caches are stored sparsely. Only blocks at or above dense_threshold get a LinearSolve cache; everything narrower uses the built-in kernel, which is the overwhelming majority - 0.6 % of supernodes are cached on poisson2d_256 and 1.3 % on poisson3d_28, where the median supernode is 5 and 1 columns wide. `bcaches` now holds only the blocks that have one, with `bcacheidx[s]` indexing into it (0 = built-in kernel), instead of a 99 %-empty full-length vector with a nullability check on every lookup. Caching every supernode instead was considered and rejected on the numbers: a cache costs ~1476 B even for a 4x4 block, so poisson2d_256 alone would spend ~8 MB to make small blocks slower than the built-in kernel - the same size effect behind PANEL_BLAS_CUTOFF. The element type stays `Any`, and this is load-bearing rather than laziness: a LinearCache type carries 13 parameters, and putting it in SupernodalLUFactor's parameters pushes the sparse default solver's cacheval - which holds a SupernodalLUFactor in one of its 30 slots - past Julia's type-complexity limit, so inference widens it and `@inferred init(prob, nothing)` fails for sparse matrices (test/Core/default_algs.jl). Verified that a smaller concrete cache type does not help either. Keeping the caches out of the type leaves SupernodalLUFactor{Tv,Ti} fully concrete and inferable, at the cost of one dispatch per cached supernode per factorization - tens of calls against hundreds of ms of GEMM. The solve path never touches the field. 2. A threaded data race, found by the new JET test. The per-worker perturbation accumulator shared one variable with the serial phase, so Julia boxed it and every spawned task incremented the same counter; nperturbed could come out wrong, and it drives 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. 3. The solve sweeps are now provably allocation-free. AllocCheck counts the grow-on-demand resize! calls as possible allocations even when they never fire, so the scratch size - derivable from the symbolic analysis - is now recorded as SymbolicAnalysis.maxnu and applied once at solve entry. The sweeps carry no growth branch at all, which also removes a branch from the hot loop. 4. QA coverage. test/qa/allocations.jl gets a @check_allocs proof for the sweeps (tridiagonal and 2D-mesh cases, the latter with maxnu=29 so panels are real) plus runtime zero-allocation assertions for single- and multi-RHS solve!. test/qa/jet.jl asserts zero runtime dispatch for solve! and _solve_panels! and that the factorization type is concrete; factorization is marked broken with the reason, since it carries the one deliberate dispatch described above. Block factorization goes through LinearSolve's own default solver rather than an algorithm resolved here, so it follows whatever the dense default resolves to. src/SupernodalLU is included after src/default.jl for that reason. GROUP=Core and GROUP=QA both pass. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017rr42T1vFRRT8D7DMAmJzf
1 parent a39171d commit e3af7ef

9 files changed

Lines changed: 267 additions & 56 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/LinearSolve.jl

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,6 @@ function defaultalg_symbol end
435435

436436
include("verbosity.jl")
437437
include("blas_logging.jl")
438-
include("SupernodalLU/SupernodalLU.jl")
439438
include("generic_lufact.jl")
440439
include("eigenvalue.jl")
441440
include("common.jl")
@@ -453,6 +452,9 @@ include("preconditioners.jl")
453452
include("preferences.jl")
454453
include("solve_function.jl")
455454
include("default.jl")
455+
# after default.jl: the vendored solver caches its dense diagonal blocks
456+
# with LinearSolve's own default solver, so it needs DefaultLinearSolver{,Init}
457+
include("SupernodalLU/SupernodalLU.jl")
456458
include("init.jl")
457459
include("adjoint.jl") # LinearSolveAdjoint struct definition only; rrules are in ChainRulesCore ext
458460

src/SupernodalLU/interface.jl

Lines changed: 44 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -110,36 +110,40 @@ 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+
# One dense block cache over an `np x np` buffer. `dense_alg === nothing`
114+
# means LinearSolve's default solver, which is the point: its cache type is
115+
# `LinearCache{..., DefaultLinearSolver, DefaultLinearSolverInit{...}}`, which
116+
# holds a slot for every candidate algorithm and picks between them with a
117+
# runtime enum. The type therefore depends only on the element type - not on
118+
# which algorithm the default happens to select on this machine - so the whole
119+
# factorization type is inferable from its inputs and `init_cacheval`'s empty
120+
# prototype matches every real factorization by construction. The extra slots
121+
# cost nothing measurable: the block buffer dominates (35 796 B vs 34 264 B at
122+
# np = 64, 2 112 724 B vs 2 105 816 B at np = 512).
123+
function _block_cache(::Type{Tv}, alg, np::Int) where {Tv}
124+
return SciMLBase.init(
125+
LinearSolve.LinearProblem(Matrix{Tv}(undef, np, np), zeros(Tv, np)),
126+
alg;
127+
alias = SciMLBase.LinearAliasSpecifier(alias_A = true, alias_b = true),
128+
assumptions = LinearSolve.OperatorAssumptions(true)
129+
)
130+
end
131+
132+
113133
function _dense_block_caches(
114-
W::Vector{Matrix{Tv}}, sym::SymbolicAnalysis, dense_alg, dense_threshold::Int
134+
::Type{Tv}, sym::SymbolicAnalysis, alg, dense_threshold::Int
115135
) where {Tv}
116136
nsuper = length(sym.sstart) - 1
117-
bcaches = Vector{Any}(nothing, nsuper)
118-
Tv <: LinearAlgebra.BlasFloat || return bcaches
119-
assump = LinearSolve.OperatorAssumptions(true)
137+
bcaches = Any[]
138+
bcacheidx = zeros(Int, nsuper)
139+
Tv <: LinearAlgebra.BlasFloat || return bcaches, bcacheidx
120140
for s in 1:nsuper
121141
np = sym.sstart[s + 1] - sym.sstart[s]
122142
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-
)
143+
push!(bcaches, _block_cache(Tv, alg, np))
144+
bcacheidx[s] = length(bcaches)
141145
end
142-
return bcaches
146+
return bcaches, bcacheidx
143147
end
144148

145149
# Allocate all numeric state (panels, V/Vt + position maps, every workspace)
@@ -166,6 +170,22 @@ function _snlu_build(
166170
end
167171
V = M[sym.qf, sym.qf]
168172
Vt, Tpos = _transpose_map(V)
173+
return _snlu_assemble(
174+
sym, A, M, ms, W, Z, rowsfac, V, Vt, Tpos, dense_alg, dense_threshold,
175+
eps_pivot, threaded, check
176+
)
177+
end
178+
179+
function _snlu_assemble(
180+
sym::SymbolicAnalysis, A::SparseMatrixCSC{Tv, Ti},
181+
M::SparseMatrixCSC{Tv, Ti}, ms::Union{MatchScale, Nothing},
182+
W::Vector{Matrix{Tv}}, Z::Vector{Matrix{Tv}}, rowsfac::Vector{Vector{Int}},
183+
V::SparseMatrixCSC{Tv, Ti}, Vt::SparseMatrixCSC{Tv, Ti}, Tpos::Vector{Int},
184+
dense_alg, dense_threshold::Int, eps_pivot::Float64, threaded::Bool,
185+
check::Bool
186+
) where {Tv, Ti <: Integer}
187+
n = sym.n
188+
bcaches, bcacheidx = _dense_block_caches(Tv, sym, dense_alg, dense_threshold)
169189
F = SupernodalLUFactor{Tv, Ti}(
170190
sym, W, Z, collect(1:n), collect(1:n), rowsfac, 0, NaN, eps_pivot,
171191
A, Vector{Tv}(undef, n), Vector{Tv}(undef, 64),
@@ -178,7 +198,7 @@ function _snlu_build(
178198
Matrix{Tv}(undef, n, 0),
179199
Vector{Tv}(undef, n), Vector{Tv}(undef, n), Vector{Tv}(undef, n),
180200
threaded,
181-
_dense_block_caches(W, sym, dense_alg, dense_threshold)
201+
bcaches, bcacheidx
182202
)
183203
_build_vpos!(F, A)
184204
_factor_core!(F)

src/SupernodalLU/numeric.jl

Lines changed: 61 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -77,16 +77,29 @@ 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). Deliberately `Vector{Any}`: the solve path never touches it and
82-
# `_cache_lu!` is a function barrier, so the only dynamic dispatch is one
83-
# call per cached supernode per factorization (tens of calls against
84-
# hundreds of ms of GEMM). Narrowing it to `Vector{Union{Nothing, C}}` for
85-
# union-splitting was tried and measured 2.3-2.7 % *slower* on
86-
# poisson3d_28/poisson2d_256, and it breaks `init_cacheval`'s cacheval
87-
# type-pinning (the empty prototype has no caches, so its element type
88-
# cannot match a real factorization's). Do not retry.
80+
# Dense LinearSolve caches for the diagonal blocks at or above
81+
# `dense_threshold`; everything narrower uses the built-in kernel, which is
82+
# the overwhelming majority (0.6 % of supernodes are cached on
83+
# poisson2d_256, 1.3 % on poisson3d_28 - the median supernode is 1-5
84+
# columns wide). Stored sparsely: `bcaches` holds only the blocks that
85+
# have one and `bcacheidx[s]` indexes into it, 0 meaning "no cache", so
86+
# there is no mostly-empty full-length vector and no nullability check on
87+
# the lookup.
88+
#
89+
# The element type is deliberately `Any`. Each entry is a `LinearCache`
90+
# over the block, and a `LinearCache` type carries 13 parameters; putting
91+
# it in `SupernodalLUFactor`'s own parameters pushes the *sparse default
92+
# solver's* cacheval - which holds a `SupernodalLUFactor` in one of its 30
93+
# slots - past Julia's type-complexity limit, so inference widens it and
94+
# `@inferred init(prob, nothing)` fails for sparse matrices
95+
# (test/Core/default_algs.jl). Keeping the caches out of the type leaves
96+
# `SupernodalLUFactor{Tv, Ti}` fully concrete and inferable from its
97+
# inputs, which matters far more: the solve path never touches this field,
98+
# and `_cache_lu!` is a function barrier, so the only dynamic dispatch is
99+
# one call per cached supernode per factorization - tens of calls against
100+
# hundreds of ms of GEMM.
89101
bcaches::Vector{Any}
102+
bcacheidx::Vector{Int}
90103
end
91104

92105
nperturbed(F::SupernodalLUFactor) = F.nperturbed
@@ -237,9 +250,9 @@ function _dense_block_factor!(
237250
F, s::Int, np::Int, epsv, ipiv::AbstractVector{Int}, scratch
238251
)
239252
Ws = F.W[s]
240-
bc = F.bcaches[s]
241-
bc === nothing && return _block_lu!(Ws, np, epsv, ipiv)
242-
ok = _cache_lu!(bc, Ws, np, epsv, ipiv)
253+
i = F.bcacheidx[s]
254+
i == 0 && return _block_lu!(Ws, np, epsv, ipiv)
255+
ok = _cache_lu!(F.bcaches[i], Ws, np, epsv, ipiv)
243256
ok && return 0
244257
@inbounds for j in 1:np
245258
ipiv[j] = j
@@ -259,7 +272,7 @@ function _cache_lu!(
259272
end
260273
bc.isfresh = true
261274
SciMLBase.solve!(bc)
262-
fact = _lu_from_cacheval(bc.cacheval)
275+
fact = _block_lu_of(bc)
263276
fac = fact.factors
264277
@inbounds for j in 1:np
265278
abs(fac[j, j]) >= epsv || return false
@@ -294,6 +307,35 @@ end
294307
_lu_from_cacheval(cv::LinearAlgebra.LU) = cv
295308
_lu_from_cacheval(cv::Tuple) = _lu_from_cacheval(first(cv))
296309

310+
# The default solver keeps one slot per candidate algorithm and records the
311+
# choice in a runtime enum, so pull the factorization out of the slot that was
312+
# actually used. Written as a literal-symbol chain rather than
313+
# `getfield(cv, Symbol(choice))` so every branch stays concrete; the result is
314+
# a small union of `LU` types, which Julia union-splits.
315+
function _lu_from_cacheval(cv::LinearSolve.DefaultLinearSolverInit, choice)
316+
DAC = LinearSolve.DefaultAlgorithmChoice
317+
if choice === DAC.LUFactorization
318+
return _lu_from_cacheval(cv.LUFactorization)
319+
elseif choice === DAC.RFLUFactorization
320+
return _lu_from_cacheval(cv.RFLUFactorization)
321+
elseif choice === DAC.MKLLUFactorization
322+
return _lu_from_cacheval(cv.MKLLUFactorization)
323+
elseif choice === DAC.AppleAccelerateLUFactorization
324+
return _lu_from_cacheval(cv.AppleAccelerateLUFactorization)
325+
elseif choice === DAC.GenericLUFactorization
326+
return _lu_from_cacheval(cv.GenericLUFactorization)
327+
elseif choice === DAC.BLISLUFactorization
328+
return _lu_from_cacheval(cv.BLISLUFactorization)
329+
end
330+
throw(ArgumentError("SupernodalLU: dense block solver resolved to $choice, which does not expose an LU factorization; pass `dense_alg` explicitly"))
331+
end
332+
333+
# Factorization behind a block cache, whichever solver the cache is using.
334+
@inline _block_lu_of(bc) = _lu_from_cacheval(bc.cacheval)
335+
@inline function _block_lu_of(bc::LinearSolve.LinearCache{<:Any, <:Any, <:Any, <:Any, <:LinearSolve.DefaultLinearSolver})
336+
return _lu_from_cacheval(bc.cacheval, bc.alg.alg)
337+
end
338+
297339
# Transpose of V with a position map: Vt.nzval[q] == V.nzval[Tpos[q]] forever
298340
# (structure is static), so refactorization refreshes Vt by one gather pass.
299341
function _transpose_map(V::SparseMatrixCSC{Tv, Ti}) where {Tv, Ti}
@@ -677,13 +719,16 @@ function _core_threaded!(F::SupernodalLUFactor{Tv}, epsv) where {Tv}
677719
relmap = zeros(Int, sym.n)
678720
ipiv = Vector{Int}(undef, sym.n)
679721
buf = Vector{Tv}(undef, 64)
680-
acc = 0
722+
# `local`: without it this shares the enclosing function's
723+
# `acc`, which boxes it (killing inference) and races every
724+
# worker against the others' perturbation counts.
725+
local nloc = 0
681726
for (lo, hi) in q
682727
for s in lo:hi
683-
acc += _process_supernode!(F, s, epsv, relmap, ipiv, buf)
728+
nloc += _process_supernode!(F, s, epsv, relmap, ipiv, buf)
684729
end
685730
end
686-
Threads.atomic_add!(npert_par, acc)
731+
Threads.atomic_add!(npert_par, nloc)
687732
end
688733
end
689734
end

src/SupernodalLU/solve.jl

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,17 @@ end
8585
return nothing
8686
end
8787

88+
# Ensure the panel scratch can hold the widest update-row set times `nrhs`.
89+
# Called once per solve so the sweeps below carry no growth branch and are
90+
# provably allocation-free (see the AllocCheck test in test/qa/allocations.jl).
91+
@inline function _ensure_panel_scratch!(F::SupernodalLUFactor, nrhs::Int)
92+
need = F.sym.maxnu * nrhs
93+
length(F.gbuf) < need && resize!(F.gbuf, need)
94+
return nothing
95+
end
96+
8897
# y := U \ (L \ (P * y)) in permuted space; y enters as V-row-ordered rhs.
98+
# Precondition: `_ensure_panel_scratch!(F, 1)` has been called.
8999
function _solve_panels!(y::AbstractVector{Tv}, F::SupernodalLUFactor{Tv}) where {Tv}
90100
sym = F.sym
91101
sstart = sym.sstart
@@ -105,7 +115,6 @@ function _solve_panels!(y::AbstractVector{Tv}, F::SupernodalLUFactor{Tv}) where
105115
ldiv!(UnitLowerTriangular(view(Ws, 1:np, 1:np)), xb)
106116
end
107117
if nu > 0
108-
length(buf) < nu && resize!(buf, max(nu, 2 * length(buf)))
109118
t = view(buf, 1:nu)
110119
if np < PANEL_BLAS_CUTOFF
111120
_panel_gemv!(t, Ws, xb, np, nu)
@@ -126,7 +135,6 @@ function _solve_panels!(y::AbstractVector{Tv}, F::SupernodalLUFactor{Tv}) where
126135
Ws = F.W[s]
127136
xb = view(y, c1:c2)
128137
if nu > 0
129-
length(buf) < nu && resize!(buf, max(nu, 2 * length(buf)))
130138
t = view(buf, 1:nu)
131139
@simd for k in 1:nu
132140
t[k] = y[R[k]]
@@ -147,6 +155,7 @@ function _solve_panels!(y::AbstractVector{Tv}, F::SupernodalLUFactor{Tv}) where
147155
end
148156

149157
# Multi-RHS variant: same sweeps with gemm-shaped updates on an n×nrhs block.
158+
# Precondition: `_ensure_panel_scratch!(F, nrhs)` has been called.
150159
function _solve_panels!(Y::AbstractMatrix{Tv}, F::SupernodalLUFactor{Tv}) where {Tv}
151160
sym = F.sym
152161
sstart = sym.sstart
@@ -163,7 +172,6 @@ function _solve_panels!(Y::AbstractMatrix{Tv}, F::SupernodalLUFactor{Tv}) where
163172
Yb = view(Y, c1:c2, :)
164173
ldiv!(UnitLowerTriangular(view(Ws, 1:np, 1:np)), Yb)
165174
if nu > 0
166-
length(buf) < nu * nrhs && resize!(buf, max(nu * nrhs, 2 * length(buf)))
167175
T = reshape(view(buf, 1:(nu * nrhs)), nu, nrhs)
168176
mul!(T, view(Ws, (np + 1):(np + nu), 1:np), Yb)
169177
for r in 1:nrhs, k in 1:nu
@@ -180,7 +188,6 @@ function _solve_panels!(Y::AbstractMatrix{Tv}, F::SupernodalLUFactor{Tv}) where
180188
Ws = F.W[s]
181189
Yb = view(Y, c1:c2, :)
182190
if nu > 0
183-
length(buf) < nu * nrhs && resize!(buf, max(nu * nrhs, 2 * length(buf)))
184191
T = reshape(view(buf, 1:(nu * nrhs)), nu, nrhs)
185192
for r in 1:nrhs, k in 1:nu
186193
T[k, r] = Y[R[k], r]
@@ -203,6 +210,7 @@ function _solve_once!(x::AbstractVector{Tv}, F::SupernodalLUFactor{Tv}, b::Abstr
203210
rp = F.rowperm
204211
Rs = F.Rs
205212
Cs = F.Cs
213+
_ensure_panel_scratch!(F, 1)
206214
@inbounds for k in 1:n
207215
i = rp[p[k]]
208216
y[k] = Rs[i] * b[i]
@@ -229,6 +237,7 @@ function _solve_once!(X::AbstractMatrix{Tv}, F::SupernodalLUFactor{Tv}, B::Abstr
229237
n = F.sym.n
230238
nrhs = size(B, 2)
231239
Y = _scratch_mat!(F, nrhs)
240+
_ensure_panel_scratch!(F, nrhs)
232241
p = F.p
233242
qf = F.sym.qf
234243
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

0 commit comments

Comments
 (0)