Auto nonstructural zeros: decide on filled values, deactivate when nothing is droppable#1110
Merged
ChrisRackauckas merged 1 commit intoJul 25, 2026
Conversation
…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
marked this pull request as ready for review
July 25, 2026 13:45
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Two fixes to the
NonstructuralZeros.Autostate machine. Both target the same failure: the sparse reduction latching into per-solvedropzerosthat 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
Autoactivated on it with an all-falsemask. The first filled solve then flipped every entry, makingactivated == nstart_zerosunconditionally trip the non-persistence guard and setcache_union = falsepermanently. From then on every solve allocated a freshdropzeroscopy — 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:
reducedequals 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:nnz(reduced) == nnz(A))The reduction is unchanged where it pays, and becomes reachable where it previously self-destructed:
activeactive, union cachingpendingactive, union cachingpendingRow 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