Skip to content

Commit 02e7a5e

Browse files
ChrisRackauckas-ClaudeChrisRackauckasclaude
authored
Auto nonstructural zeros: decide on filled values, deactivate when nothing is droppable (#1110)
Two fixes to the `NonstructuralZeros.Auto` state machine, both aimed at the same failure: the reduction latching into per-solve `dropzeros` that drops nothing. 1. Defer the activation decision when the starting matrix is entirely zero. A cache is commonly initialized from a *prototype* — the intended sparsity pattern with values not filled in yet — and an all-zero sample is indistinguishable from "100% nonstructural zeros". `Auto` activated on it with an all-`false` mask, and the first filled solve then flipped every entry, so `activated == nstart_zeros` unconditionally tripped the non-persistence guard and set `cache_union = false` permanently. Every later solve then allocated a fresh `dropzeros` copy while dropping nothing. Deferring to the first matrix that carries a nonzero decides on real values instead. 2. Deactivate once the union covers every stored entry. The union only grows, so at that point no future matrix can have anything dropped — the reduction is provably dead weight. Switching it off is also structure-preserving, since `reduced` equals the full pattern exactly when this triggers, so the operand handed to the factorization keeps the structure it just had. Measured on a prototype-initialized cache (n=200, nnz=598, KLU): 17.7 kB per solve with `nnz(reduced) == nnz(A)` before, 0 B after. The reduction itself is unchanged where it pays: a matrix with genuine nonstructural zeros still reduces (359/598 stored entries kept) at 0 B per solve, and prototype-initialized callers now *gain* that reduction on the first filled solve instead of latching into the degraded path. This also removes a 30x sparse allocation gap in OrdinaryDiffEq's `NonlinearSolveAlg`, whose inner Jacobian buffer is exactly such a prototype. Claude-Session: https://claude.ai/code/session_01NB3oFTNzGW79UtDt8AyjsM Co-authored-by: ChrisRackauckas-Claude <accounts@chrisrackauckas.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent cbfd6b7 commit 02e7a5e

3 files changed

Lines changed: 110 additions & 7 deletions

File tree

Project.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name = "LinearSolve"
22
uuid = "7ed4a6bd-45f5-4d41-b270-4a48e9bafcae"
3-
version = "5.1.1"
3+
version = "5.1.2"
44
authors = ["SciML"]
55

66
[deps]

ext/LinearSolveSparseArraysExt.jl

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1200,6 +1200,13 @@ end
12001200
# so `init_sparse_reduction` returns a concrete type either way — type-stable.
12011201
mutable struct SparseReduction{Tv, Ti}
12021202
active::Bool
1203+
# `Auto` only: the activation decision is deferred until the first solve whose matrix
1204+
# carries a nonzero. A cache is commonly initialized from a *prototype* — the intended
1205+
# sparsity pattern with values not filled in yet (all zero) — which says nothing about
1206+
# nonstructural zeros. Taking it at face value activates the reduction on an all-zero
1207+
# mask, and the first real solve then bloats the union and degrades to per-solve
1208+
# dropzeros for the rest of the run. Deciding on the first filled matrix sees real values.
1209+
pending::Bool
12031210
cache_union::Bool # true: union caching; false: per-solve dropzeros
12041211
auto::Bool # may switch union -> per-solve on bloat
12051212
nstart_zeros::Int # # stored entries zero on the starting matrix
@@ -1255,15 +1262,18 @@ function LinearSolve.init_sparse_reduction(
12551262
nsz = LinearSolve.__nonstructural_zeros(assumptions)
12561263
NZ = LinearSolve.NonstructuralZeros
12571264
nz = nonzeros(A)
1265+
auto = nsz == NZ.Auto
1266+
# An all-zero starting matrix is a prototype whose values are not filled in yet: its zero
1267+
# fraction carries no information, so defer the `Auto` decision to the first filled solve.
1268+
pending = auto && !isempty(nz) && all(iszero, nz)
12581269
active = if nsz == NZ.Persistent || nsz == NZ.Present
12591270
true
1260-
elseif nsz == NZ.None
1271+
elseif nsz == NZ.None || pending
12611272
false
1262-
else # Auto
1273+
else # Auto, on a matrix that carries values
12631274
!isempty(nz) &&
12641275
count(iszero, nz) / length(nz) >= LinearSolve.PERSISTENT_ZERO_FRACTION_THRESHOLD
12651276
end
1266-
auto = nsz == NZ.Auto
12671277
cache_union = nsz != NZ.Present # Present => per-solve dropzeros from the start
12681278
colptr = copy(getcolptr(A))
12691279
rowval = copy(rowvals(A))
@@ -1272,19 +1282,43 @@ function LinearSolve.init_sparse_reduction(
12721282
nstart_zeros = count(iszero, nz)
12731283
keep, reduced = _persistent_reduced(A, colptr, rowval, mask, nz)
12741284
return SparseReduction{Tv, Ti}(
1275-
true, cache_union, auto, nstart_zeros, colptr, rowval, mask, keep, reduced,
1276-
1, 0, nnz(reduced), false
1285+
true, false, cache_union, auto, nstart_zeros, colptr, rowval, mask, keep,
1286+
reduced, 1, 0, nnz(reduced), false
12771287
)
12781288
else
12791289
reduced = LinearSolve.make_SparseMatrixCSC(A)
12801290
return SparseReduction{Tv, Ti}(
1281-
false, cache_union, auto, 0, colptr, rowval, Bool[], Int[], reduced,
1291+
false, pending, cache_union, auto, 0, colptr, rowval, Bool[], Int[], reduced,
12821292
0, 0, 0, false
12831293
)
12841294
end
12851295
end
12861296

1297+
# Resolve a deferred `Auto` activation (see `SparseReduction.pending`) against the first
1298+
# matrix that carries values. A still-all-zero matrix remains uninformative, so keep waiting;
1299+
# there is nothing worth reducing in it either way.
1300+
function _resolve_pending!(red::SparseReduction, A)
1301+
nz = nonzeros(A)
1302+
(isempty(nz) || all(iszero, nz)) && return red
1303+
red.pending = false
1304+
count(iszero, nz) / length(nz) >=
1305+
LinearSolve.PERSISTENT_ZERO_FRACTION_THRESHOLD || return red
1306+
red.active = true
1307+
red.nstart_zeros = count(iszero, nz)
1308+
red.colptr = copy(getcolptr(A))
1309+
red.rowval = copy(rowvals(A))
1310+
red.mask = Bool[!iszero(v) for v in nz]
1311+
red.keep, red.reduced = _persistent_reduced(A, red.colptr, red.rowval, red.mask, nz)
1312+
red.nanalyze += 1
1313+
red.reduced_nnz = nnz(red.reduced)
1314+
# The operand handed to the factorization now has a different structure than the one the
1315+
# cache was initialized with, so cached symbolic factorizations must be redone.
1316+
red.structure_changed = true
1317+
return red
1318+
end
1319+
12871320
function LinearSolve.reduce_operand!(red::SparseReduction, A)
1321+
red.pending && _resolve_pending!(red, A)
12881322
red.active || return A
12891323
nz = nonzeros(A)
12901324
# A change in the stored nnz breaks union caching: an explicit `Persistent`
@@ -1319,6 +1353,15 @@ function LinearSolve.reduce_operand!(red::SparseReduction, A)
13191353
A, red.colptr, red.rowval, red.mask, nz
13201354
)
13211355
red.nanalyze += 1
1356+
# The union only ever grows, so once it covers every stored entry no future matrix
1357+
# can have anything dropped: the reduction is dead weight from here on. Deactivating
1358+
# is safe rather than merely cheap — `reduced` currently equals the full pattern, so
1359+
# the operand handed to the factorization keeps the same structure it just had.
1360+
if length(red.keep) == length(red.mask)
1361+
red.active = false
1362+
red.structure_changed = false
1363+
return A
1364+
end
13221365
# auto mode: if more than NONPERSISTENT_ZERO_FRACTION of the starting zeros
13231366
# have activated, the zeros are not persistent — stop caching the union and
13241367
# drop per solve instead (this matrix's own zeros only). `activated` is

test/Core/nonstructural_zeros.jl

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,4 +282,64 @@ reduction(cache) = cache.cacheval.sparse_reduction
282282
@test sol2.u Matrix(A_new2) \ b rtol = 1.0e-8
283283
@test !reduction(cache).cache_union
284284
end
285+
286+
@testset "auto defers the decision on an all-zero starting matrix" begin
287+
# A cache is often initialized from a *prototype*: the intended sparsity pattern
288+
# with values not filled in yet. Its zero fraction says nothing about
289+
# nonstructural zeros, so `auto` must not decide from it -- taking it at face
290+
# value activates on an all-zero mask, and the first filled solve then bloats the
291+
# union and latches per-solve dropzeros while dropping nothing.
292+
proto = copy(mats[1])
293+
nonzeros(proto) .= 0.0
294+
295+
cache = init(LinearProblem(copy(proto), copy(b))) # auto
296+
@test !reduction(cache).active
297+
@test reduction(cache).pending
298+
299+
# filled with a matrix that genuinely carries nonstructural zeros: activate
300+
filled = copy(mats[1])
301+
cache.A = copy(filled)
302+
sol = solve!(cache)
303+
@test sol.retcode == ReturnCode.Success
304+
@test sol.u Matrix(filled) \ b rtol = 1.0e-8
305+
@test !reduction(cache).pending
306+
@test reduction(cache).active && reduction(cache).cache_union
307+
@test reduction(cache).reduced_nnz < nnz(filled)
308+
309+
# filled with a matrix that has no droppable zeros: stay inactive
310+
dense_valued = copy(mats[1])
311+
nonzeros(dense_valued) .= 1.5
312+
c2 = init(LinearProblem(copy(proto), copy(b)))
313+
@test c2.cacheval.sparse_reduction.pending
314+
c2.A = copy(dense_valued)
315+
sol2 = solve!(c2)
316+
@test sol2.retcode == ReturnCode.Success
317+
@test sol2.u Matrix(dense_valued) \ b rtol = 1.0e-8
318+
@test !reduction(c2).pending
319+
@test !reduction(c2).active
320+
end
321+
322+
@testset "auto deactivates once the union covers every stored entry" begin
323+
# The union only grows, so when it covers the whole stored pattern nothing can
324+
# ever be dropped again: the reduction is dead weight and must switch off rather
325+
# than fall back to allocating a per-solve dropzeros copy that drops nothing.
326+
start = copy(mats[1]) # has stored zeros -> auto activates
327+
cache = init(LinearProblem(copy(start), copy(b)))
328+
@test reduction(cache).active
329+
full = copy(mats[1])
330+
nonzeros(full) .= 2.0 # every stored entry becomes nonzero
331+
cache.A = copy(full)
332+
sol = solve!(cache)
333+
@test sol.retcode == ReturnCode.Success
334+
@test sol.u Matrix(full) \ b rtol = 1.0e-8
335+
@test !reduction(cache).active
336+
# and it keeps solving correctly afterwards, with no reduction overhead
337+
full2 = copy(full)
338+
nonzeros(full2) .= 3.0
339+
cache.A = copy(full2)
340+
sol2 = solve!(cache)
341+
@test sol2.retcode == ReturnCode.Success
342+
@test sol2.u Matrix(full2) \ b rtol = 1.0e-8
343+
@test !reduction(cache).active
344+
end
285345
end

0 commit comments

Comments
 (0)