Skip to content

Auto nonstructural zeros: decide on filled values, deactivate when nothing is droppable#1110

Merged
ChrisRackauckas merged 1 commit into
SciML:mainfrom
ChrisRackauckas-Claude:auto-nsz-defer-and-deactivate
Jul 25, 2026
Merged

Auto nonstructural zeros: decide on filled values, deactivate when nothing is droppable#1110
ChrisRackauckas merged 1 commit into
SciML:mainfrom
ChrisRackauckas-Claude:auto-nsz-defer-and-deactivate

Conversation

@ChrisRackauckas-Claude

Copy link
Copy Markdown
Member

What

Two fixes to the NonstructuralZeros.Auto state machine. Both target the same failure: the sparse 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. An all-zero sample is indistinguishable from "100% nonstructural zeros", so Auto activated on it with an all-false mask. The first filled solve then flipped every entry, making activated == nstart_zeros unconditionally trip the non-persistence guard and set cache_union = false permanently. From then on every solve allocated a fresh dropzeros copy — while dropping nothing, because the matrix has no nonstructural zeros at all. 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 ever grows, so at that point no future matrix can have anything dropped — the reduction is provably dead weight, not just heuristically unpromising. This is also structure-preserving: reduced equals the full pattern exactly when this triggers, so the operand handed to the factorization keeps the structure it just had. Previously the only escape from a bloated union was "drop per solve forever", which is the wrong conclusion when nothing is droppable.

Effect

Prototype-initialized cache, n=200, nnz=598, KLUFactorization:

before after
alloc/solve 17.7 kB (nnz(reduced) == nnz(A)) 0 B

The reduction is unchanged where it pays, and becomes reachable where it previously self-destructed:

case init after first filled solve reduced_nnz alloc/solve
genuine nonstructural zeros active active, union caching 359/598 0 B
prototype → zeros-heavy fill pending active, union caching 359/598 0 B
prototype → no droppable zeros pending inactive 0 B
filled at init, no zeros inactive inactive 0 B

Row 2 is the point: a prototype-initialized caller that should benefit now does. Before, that exact case activated on the all-zero prototype and latched into the degraded path.

Downstream

This removes a 30× sparse allocation gap in OrdinaryDiffEq's NonlinearSolveAlg (11.8 MB → 0.42 MB per solve, ~2.3× faster on a 32-state Brusselator), whose inner Jacobian buffer is exactly such a prototype — with no change needed on the OrdinaryDiffEq side.

Testing

Two regression testsets added to test/Core/nonstructural_zeros.jl (deferral + decide-on-fill in both directions; dead-weight deactivation with continued correct solves). Core group run locally.

Draft — please ignore until reviewed by @ChrisRackauckas.

🤖 Generated with Claude Code

…thing is droppable

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.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NB3oFTNzGW79UtDt8AyjsM
@ChrisRackauckas
ChrisRackauckas marked this pull request as ready for review July 25, 2026 13:45
@ChrisRackauckas
ChrisRackauckas merged commit 02e7a5e into SciML:main Jul 25, 2026
61 of 62 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants