Automatic Sparse Differentiation & Assembled Solver Paths
This tutorial covers feax.asd, FEAX's automatic sparse differentiation (ASD) module built on asdex (jaxpr-level sparsity detection + graph coloring), and the two assembled solver paths it unlocks in fe.create_solver:
- Assembled
extra_residual_fn— arbitrary coupling terms outside the mesh connectivity (springs, follower loads, penalty terms) solved with direct factorization instead of hybrid Krylov. - Assembled reduced operator — periodic problems solved with direct or AMG-preconditioned solvers instead of matrix-free CG.
Overview
FEAX's standard volume assembly does not use this module: the element-dense jacfwd + slot-map scatter is already optimal when the sparsity is the mesh connectivity (a global coloring would need "max row nnz" colors — more kernel evaluations than the element dimension). feax.asd covers the places where a sparse operator is needed but its pattern is not the plain connectivity:
- Extra residual terms — arbitrary user coupling with unknown sparsity. The pattern is detected from the function itself.
- Reduced periodic operators — the pattern is a boolean triple product of known patterns; the values come from colored probes of the matrix-free action.
- Design-space Hessians — symmetric (star) coloring + HVPs for second-order design optimization.
- Verification — checking FEAX's hand-built CSR pattern against detection on the actual residual.
All factories return functions with a fixed sparsity structure (jit-safe) that produce feax.csr.CSRMatrix — the operator type the FEAX solver stack consumes.
from feax import asd
The ASD Toolbox
Sparse Jacobians and Hessians
asd.sparse_jacobian_fn(f, x_sample) detects the global sparsity of f by abstract interpretation of its jaxpr (no derivative evaluation), colors the pattern, and returns a fixed-structure producer:
import jax
import jax.numpy as jnp
from feax import asd
def f(x):
return jnp.sin(x[1:] * x[:-1]) + x[1:] ** 2
x = jnp.linspace(0.3, 1.2, 30)
jac_fn, pattern = asd.sparse_jacobian_fn(f, x) # pattern: boolean scipy CSR
J = jax.jit(jac_fn)(x) # J: feax.csr.CSRMatrix
Each call costs num_colors AD passes of f — for a banded or local coupling this is a small constant, independent of the problem size. An explicit pattern= superset can be passed instead of x_sample to skip detection.
asd.sparse_hessian_fn(g, x_sample) is the second-order analogue for scalar g: star (symmetric) coloring + one HVP per color:
def g(x):
return jnp.sum((x[1:] - x[:-1]) ** 2 * x[1:]) + jnp.sum(x ** 3)
hess_fn, pattern = asd.sparse_hessian_fn(g, x)
H = jax.jit(hess_fn)(x) # CSRMatrix, matches jax.hessian(g)(x)
This is intended e.g. for design-space Hessians in second-order topology optimization, where the pattern is the filter-stencil overlap.
Materializing a Linear Operator from Its Matvec
asd.operator_assembler(pattern) colors a pattern once and returns an assemble(matvec) -> CSRMatrix function that materializes any linear operator with that sparsity, using num_colors matvec probes (colored JVPs at zero):
assemble = asd.operator_assembler(pattern) # color once
K = assemble(lambda v: A @ v) # num_colors matvecs
This is how the reduced periodic operator gets assembled: its action exists matrix-free, but its assembled form is needed for direct factorization or AMG hierarchy construction.
Pattern Algebra
asd.merge_csr_patterns(pattern_a, pattern_b) computes the union of two CSR patterns plus everything needed to assemble and transpose on it: the merged indptr/indices, data-slot maps slots_a/slots_b (aligned with each input's CSR order), and transpose maps T_perm/T_indptr/T_indices for feax.csr.transpose_with_maps:
from feax.csr import CSRMatrix, transpose_with_maps
m = asd.merge_csr_patterns(bulk_pattern, extra_pattern)
data = (jnp.zeros(m["nnz"])
.at[m["slots_a"]].add(bulk_values)
.at[m["slots_b"]].add(extra_values))
M = CSRMatrix(data, m["indptr"], m["indices"], m["shape"])
MT = transpose_with_maps(M, m["T_perm"], m["T_indptr"], m["T_indices"])
Two more pattern helpers round out the algebra:
asd.connectivity_pattern(problem)— FEAX's assembled CSR pattern (mesh connectivity) as boolean CSR. RequiresMatrixView.FULL.asd.reduced_operator_pattern(P, K_pattern)— the sparsity of the Galerkin product by boolean triple product. Exact for boolean algebra, i.e. a superset of the numerical pattern — which is what coloring and decompression need.
Verifying the Hand-Built Pattern
asd.verify_jacobian_pattern(problem, traced_params) detects the true Jacobian sparsity of the assembled (bulk, no-BC) residual and compares it with the connectivity pattern. Soundness requires the detected pattern to be contained in the connectivity pattern:
report = asd.verify_jacobian_pattern(problem, traced_params)
assert report["ok"] # detected ⊆ connectivity
print(report["coverage"]) # fraction of the pattern actually used
This is a useful sanity check when developing custom kernels or new element types: a False result means the residual couples DOFs the assembler does not account for.
Path 1: Assembled extra_residual_fn (Direct Solvers)
fe.create_solver(..., extra_residual_fn=...) adds an arbitrary residual contribution extra_residual_fn(sol_flat) -> residual_flat on top of the FEM residual — discrete springs, ground springs, follower loads, penalty couplings. These terms can couple DOFs that share no element, so their Jacobian lies outside the mesh CSR pattern.
Previously only the hybrid path worked: with KrylovSolverOptions, the bulk Jacobian is assembled and the extra contribution enters as a jax.jvp matvec — restricting the linear solve to Krylov methods. With DirectSolverOptions, FEAX now takes the assembled path instead:
- The extra term's Jacobian sparsity is detected once via
asd.sparse_jacobian_fn(asdex jaxpr analysis + coloring), on a sample solution. - The extra pattern is merged with the bulk CSR pattern via
asd.merge_csr_patterns. - Every Newton step assembles