Skip to content

Specialize split dual AD path on sparse matrix in ForwardDiff extension#1048

Merged
ChrisRackauckas merged 5 commits into
SciML:mainfrom
hersle:fix/sparse-update-partials-list
Jun 18, 2026
Merged

Specialize split dual AD path on sparse matrix in ForwardDiff extension#1048
ChrisRackauckas merged 5 commits into
SciML:mainfrom
hersle:fix/sparse-update-partials-list

Conversation

@hersle

@hersle hersle commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Checklist

  • Appropriate tests were added
  • Any code changes were done in a way that does not break public API
  • All documentation related to code changes were updated
  • The new code follows the
    contributor guidelines, in particular the SciML Style Guide and
    COLPRAC.
  • Any new documentation only uses public API

Additional context

In my package I need to forward-differentiate stiff ODEs with sparse Jacobians. Before this PR, the generic dual AD path through linear solves in the ODE has been a big bottleneck, as it does not specialize properly on the sparse matrix, but iterates through all dense $m \times n$ entries (including zeros) and goes through slow binary searches to look up the sparse matrix for each $(i, j)$.

With this PR, I try to specialize the expensive partials_to_list and update_partials_list functions for sparse matrices in order to remove this bottleneck.

hersle added 5 commits June 17, 2026 20:55
…sion

SparseArrays is a stdlib and has essentially zero installation cost.
Making it a direct dependency (rather than a weakdep co-trigger) lets
LinearSolveForwardDiffExt use `using SparseArrays` without SparseArrays
needing to be loaded by the caller, so the ForwardDiff extension loads
correctly for users who do not otherwise use SparseArrays.
…xCSC

The generic update_partials_list! iterates over all m*n matrix elements and
calls sparse_matrix[i,j] (a binary search into the column pointers) for each
one. For a typical ODE Jacobian with low fill (e.g. 304 nnz out of 47*47=2209
entries) this is ~7x more work than necessary and uses O(log nnz) lookup per
entry.

Add SparseMatrixCSC specializations that iterate directly over the nzval array:

- update_partials_list! reads nzval once and scatters each partial into the
  corresponding nzval of the pre-allocated output sparse matrices.
- partials_to_list allocates output SparseMatrixCSC objects sharing the same
  sparsity pattern (copied colptr/rowval) with only the nzval varying per
  partial index.

Both methods skip all structural zeros and avoid any index search, reducing the
per-call cost from O(m*n * log(nnz)) to O(nnz).
…atrixCSC

If the ODE Jacobian sparsity changes between factorization events, the
cached list of partial matrices has different nnz than the new partial
matrix, causing a DimensionMismatch in eachindex. Rebuild the cached
matrices in-place when nnz changes.
At ODE init time the Jacobian may be structurally all-zeros, so
nonzeros(partial_matrix) is empty. Use ForwardDiff.npartials/valtype
on the element type instead of first(nz) to determine p and V.
@hersle

hersle commented Jun 17, 2026

Copy link
Copy Markdown
Contributor Author

Example timings from my package:

using SymBoltz, ForwardDiff
M = ΛCDM()
p= parameters_Planck18(M)
prob = CosmologyProblem(M, p; jac = true, sparse = true) # stiff ODE with 82 unknowns and sparse+analytical Jacobian

vary = [M.c.Ω₀, M.b.Ω₀]
probgen = parameter_updater(prob, vary)
k = logrange(1e0, 1e3; length = 100)
P(θ) = spectrum_matter(probgen(θ), k; thread = false) # depends on solution of 100x of the above ODEs
θ₀ = [p[par] for par in vary]
@time ∂P_∂θ = ForwardDiff.jacobian(P, θ₀)
@profview ∂P_∂θ = ForwardDiff.jacobian(P, θ₀)
Before this PR: 18.477147 seconds (379.61 k allocations: 113.941 MiB, 0.60% gc time)
After this PR:   2.796430 seconds (383.78 k allocations: 102.552 MiB, 0.48% gc time)

Before this PR (note the big update_partials_list!):
bilde

After this PR (note the small update_partials_list!):
bilde

@hersle hersle changed the title Specialize dual AD path on sparse matrix in ForwardDiff extension Specialize split dual AD path on sparse matrix in ForwardDiff extension Jun 17, 2026
@hersle

hersle commented Jun 17, 2026

Copy link
Copy Markdown
Contributor Author

Will fix formating.

Is it OK to move SparseArrays to deps? That is the cleanest solution I could think of. Or do you see another way?

@ChrisRackauckas

Copy link
Copy Markdown
Member

Which linear solver? It shouldn't make a difference on PureKLU, and that would likely be pretty fast in a lot cases like this.

@ChrisRackauckas

Copy link
Copy Markdown
Member

Can do an extension that is SparseArrays+ForwardDiff for these.

@hersle

hersle commented Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

Which linear solver? It shouldn't make a difference on PureKLU, and that would likely be pretty fast in a lot cases like this.

Using KLUFactorization for now. I saw PureKLUFactorization and tried it, but did not see any immediate improvement yet. I could compare the two after getting this improvement in, to make the comparison fair.

Can do an extension that is SparseArrays+ForwardDiff for these.

Won't that end up duplicating a lot of the machinery in ext/LinearSolveForwardDiffExt.jl? I went with making SparseArray a dep to avoid that mess, and since it is in the stdlib. Is that not an option?

@ChrisRackauckas

Copy link
Copy Markdown
Member

Oh wait, you want to do it with #1023

@hersle

hersle commented Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

Wasn't #1041 merged in favor of #1023? Should not #1023 be closed?

@ChrisRackauckas

Copy link
Copy Markdown
Member

Oh yes, I was confused why I still saw that PR around. So wait, with the forwarddiff opt out you still don't see an advantage to PureKLU with forwarddiff? That's wild because it's a huge difference.

@hersle

hersle commented Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

No, I don't see any improvement with it. I tried the same code as above, now with PureKLUFactorization (on LinearSolve v3.85.2), and I get:

 16.977270 seconds (88.85 M allocations: 35.636 GiB, 22.30% gc time)
bilde

This is after warm-up. Seems like its doing a lot of allocations, that could perhaps explain parts of it.

@ChrisRackauckas

Copy link
Copy Markdown
Member

The A matrix is dual?

@hersle

hersle commented Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

Yes it should be, its the $W = I - \gamma J$ matrix inside Rodas5P, and $J$ should be dual.

@ChrisRackauckas ChrisRackauckas merged commit 1a18f54 into SciML:main Jun 18, 2026
48 of 55 checks passed
@ChrisRackauckas

Copy link
Copy Markdown
Member

That's really odd.

@hersle

hersle commented Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

Yeah I was also expecting it to perform well. Here is @profview_allocs on the same code with PureKLUFactorization. Maybe throwing Claude at it could eliminate the allocations?

bilde

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