Sparse linear algebra
Overview
A finite-element simulation assembles a system $\mathbf{K}\mathbf{u} = \mathbf{f}$ whose matrix $\mathbf{K}$ has one important property: it is sparse. A tetrahedral mesh with a million nodes has a stiffness matrix with a trillion entries, but each node only interacts with its immediate neighbors, so only a handful of entries per row are non-zero — the vast majority are exactly zero. Storing all of them (a trillion numbers) is impossible; storing only the non-zeros (tens of millions) is routine.
Rasmah's sparse linear algebra exists to exploit that structure: it stores only the non-zeros, factorizes the matrix without filling in all the zeros, and solves the system — directly or iteratively — while keeping the whole operation differentiable (the factorizations and solves work on dual numbers, so a gradient can flow through a linear solve).
Why sparsity matters
A dense solve of an $n \times n$ system costs $O(n^3)$ work. A sparse direct solve of the same system costs roughly $O(\text{nnz} \cdot \text{fill})$ — and for a mesh matrix the fill stays modest with a good ordering, so the cost is close to $O(n^{1.5})$ or better, scaling to millions of unknowns that dense methods could never reach.
The idea behind a sparse matrix is a single storage trick. A matrix
\[\mathbf{A} = \begin{bmatrix} 2 & -1 & 0 \\ -1 & 2 & -1 \\ 0 & -1 & 2 \end{bmatrix}\]
is not stored as nine numbers but as three arrays: the non-zero values, their row indices, and a pointer to where each column begins. This is the compressed sparse column (CSC) format — columns are contiguous, which suits the column-oriented access of a factorization. The row-oriented twin is CSR, preferred by row-oriented algorithms like algebraic multigrid.
The sparse matrix
Rasmah.NativeLinearAlgebra.SparseMatrixCSC — Type
SparseMatrixCSC(I, J, V, m, n)Construct an m×n sparse matrix from triplet lists I (rows), J (columns) and V (values). Duplicate entries are summed.
SparseMatrixCSC(A::AbstractMatrix)Convert a dense (or otherwise indexable) matrix to a SparseMatrixCSC, dropping exact zeros.
Rasmah.NativeLinearAlgebra.SparseMatrixCSR — Type
SparseMatrixCSR(I, J, V, m, n)Construct an m×n CSR matrix from triplet lists I (rows), J (columns) and V (values). Duplicate entries are summed.
SparseMatrixCSR(A::AbstractMatrix)Convert a dense (or otherwise indexable) matrix to a SparseMatrixCSR, dropping exact zeros.
A sparse matrix is built from (row, column, value) triplets, with duplicate entries summed. Here is the standard discrete 1-D Laplacian (a tridiagonal matrix with $2$ on the diagonal and $-1$ on the two off-diagonals):
n = 6
I = Int[]; J = Int[]; V = Float64[]
for i in 1:n
push!(I, i); push!(J, i); push!(V, 2.0)
i > 1 && (push!(I, i); push!(J, i - 1); push!(V, -1.0))
i < n && (push!(I, i); push!(J, i + 1); push!(V, -1.0))
end
A = SparseMatrixCSC(I, J, V, n, n)
size(A)(6, 6)Direct factorizations
The direct way to solve $\mathbf{A}\mathbf{x} = \mathbf{b}$ is to factorize $\mathbf{A}$ once and then back-substitute. Rasmah extends the standard LinearAlgebra functions — cholesky, lu, ldlt, qr, and factorize — to the native sparse type, so cholesky(A) (for symmetric positive-definite $\mathbf{A}$), lu(A) (general square), ldlt(A) (symmetric indefinite), and qr(A) (least squares) all just work:
F = cholesky(A)
F \ ones(n)6-element Vector{Float64}:
3.000000000000001
5.000000000000003
6.000000000000002
6.000000000000001
5.000000000000001
3.000000000000001A \ b picks the factorization automatically from the matrix's structure, and each factorization accepts an ordering keyword that controls the fill-reducing column order. The ordering is where most of the sparse magic happens: reordering the columns of $\mathbf{A}$ does not change the solution, but a good ordering can reduce the factorization's fill-in — the new non-zeros introduced by the elimination — by orders of magnitude.
Rasmah.NativeLinearAlgebra.Direct.analyze — Function
analyze(A; ordering = :amd, perm = nothing) -> CholeskyAnalysisPerform the symbolic phase (ordering, permutation, elimination tree, symbolic factorization, supernode detection) once, for reuse across multiple numeric factorizations with the same sparsity pattern.
Rasmah.NativeLinearAlgebra.Direct.amd — Function
amd(A; dense = 10.0, aggressive = true, variant = :faithful) -> permCompute a fill-reducing ordering of the symmetric structure of A.
Keyword arguments
dense: dense-row threshold. Rows with more thanmax(dense·√n, 16)off-diagonal entries are removed and placed last (setdense < 0to only remove completely dense rows).aggressive: enable aggressive absorption (element absorption and supervariable amalgamation) in the:faithfulvariant.variant::faithful(port of the SuiteSparse AMD algorithm, the default),:classic(native quotient-graph minimum degree), or:hybrid(run both, plus the non-aggressive faithful variant, and return the ordering with the smallest estimated fill — ~3× more expensive than a single variant).
Rasmah.NativeLinearAlgebra.Direct.nested_dissection — Function
nested_dissection(A; min_size = 32) -> permNested-dissection fill-reducing ordering for the symmetric structure of A (via the recursive level-structure separator of George & Liu). Places a vertex separator last at each recursion level.
The native Refactorization caches the symbolic phase (the ordering and the factor sparsity pattern) so a sequence of matrices sharing one pattern — the Newton steps of a nonlinear solve, or the stiffness matrices of a topology optimization — pays only the cheap numeric phase on each iteration:
Rasmah.NativeLinearAlgebra.Direct.Refactorization — Type
Refactorization(A; method = :cholesky, ...) -> RCache the symbolic analysis (ordering + symbolic factor pattern) of the sparse matrix A once, so a sequence of matrices sharing A's sparsity pattern — the Newton steps of an optimization loop, the stiffness matrices across topology- optimization iterations, a time-stepping sequence — can be re-factorized paying only the numeric phase.
R = Refactorization(A; method = :lu, ordering = :amd)
u = R(A_new) \ b # factorize A_new reusing the ordering, then solve
F = R(A_new) # just the numeric factorizationmethod:
:cholesky/:direct(default) — SPD;analyze+factorize.:ldlt— symmetric indefinite;analyze+ldlt.:lu— unsymmetric;lu_analyze+lu_factorize.:klu— unsymmetric block-triangular form;klu_analyze+klu_factorize.
Keyword arguments passed on construction (ordering, perm, supernode_min, relax, relax_fill) are forwarded to the symbolic phase; keyword arguments passed on each call (check, tol, shift, parallel, scale, method, …) are forwarded to the numeric phase. A_new must have the same sparsity pattern as A (the standard analyze/factorize contract); only the values may differ.
R = Refactorization(A; method = :cholesky)
R(A) \ ones(n)6-element Vector{Float64}:
3.000000000000001
5.000000000000003
6.000000000000002
6.000000000000001
5.000000000000001
3.000000000000001Rasmah.NativeLinearAlgebra.Direct.perm — Function
perm(F) -> Vector{Int}The permutation associated with a factorization F (identity for a SparseTriangular factor, which carries no fill-reducing permutation).
Rasmah.NativeLinearAlgebra.Direct.factor_nnz — Function
factor_nnz(F) -> IntThe number of stored nonzeros in a factorization F (e.g. the sum of its factors).
Iterative solvers
For the largest systems — or when a good preconditioner is available — an iterative solver is faster than a direct factorization. Each Krylov solver takes the matrix (or a matrix-free operator) and an optional preconditioner M, and improves a guess until the residual is small:
Rasmah.NativeLinearAlgebra.Iterative.cg — Function
cg(A, b; M=nothing, atol=0.0, rtol=1e-8, maxiter=nothing) -> (; x, iters, history)Preconditioned conjugate gradient (Hestenes–Stiefel) for SPD A.
Rasmah.NativeLinearAlgebra.Iterative.gmres — Function
gmres(A, b; restart=min(20, n), M=nothing, Pr=nothing, atol=0.0, rtol=1e-8, maxiter=nothing)Restarted GMRES with modified Gram–Schmidt Arnoldi, least-squares solved via a QR of the Hessenberg matrix. M is the left preconditioner (M⁻¹ A x = M⁻¹ b) and Pr an optional right preconditioner (two-sided M⁻¹ A Pr⁻¹ (Pr x) = M⁻¹ b).
Rasmah.NativeLinearAlgebra.Iterative.bicgstab — Function
bicgstab(A, b; M=nothing, atol=0.0, rtol=1e-8, maxiter=nothing)BiCGStab (van der Vorst) for nonsymmetric A, with a left preconditioner M.
r = cg(A, ones(n); rtol=1e-10)
r.x6-element Vector{Float64}:
3.0
5.0
6.0
6.0
5.0
3.0r.iters3cg (conjugate gradients) is the workhorse for symmetric positive-definite systems; gmres and bicgstab cover the general non-symmetric case. Each returns the solution x, the number of iterations iters, and a residual history.
Preconditioners
An unpreconditioned iterative solve can take thousands of iterations. A preconditioner $\mathbf{M}$ is a cheap approximate inverse: solving $\mathbf{M}^{-1}\mathbf{A}\mathbf{x} = \mathbf{M}^{-1}\mathbf{b}$ converges far faster because $\mathbf{M}^{-1}\mathbf{A}$ is close to the identity. Rasmah provides the standard algebraic preconditioners, each applied with M \ b:
Rasmah.NativeLinearAlgebra.Preconditioners.jacobi — Function
jacobi(A; ω = 1.0, shift = 0.0) -> JacobiConstruct the (damped) Jacobi preconditioner M = D/ω from the diagonal D of the square matrix A. Applying it computes M \ b = ω D⁻¹ b elementwise. shift = ε adds ε to the diagonal first (D → D + εI), so the preconditioner stays well-defined even for a matrix with zero diagonal entries.
Rasmah.NativeLinearAlgebra.Preconditioners.ssor — Function
ssor(A; ω = 1.0, shift = 0.0) -> SSORConstruct the SSOR preconditioner M = 1/(ω(2-ω)) · (D + ωL) D⁻¹ (D + ωU) of the square matrix A (D diagonal, L strict lower, U strict upper). Applying it computes M \ b with two triangular solves. ω ∈ (0, 2) is the relaxation factor (default 1.0 = symmetric Gauss–Seidel). shift = ε adds ε to the diagonal first (D → D + εI), so the preconditioner stays well-defined even for a matrix with zero diagonal entries.
Rasmah.NativeLinearAlgebra.Preconditioners.ic — Function
ic(A; method = :ic0, τ = 0.0, k = 0, ordering = :amd, perm = nothing, check = true, shift = 0.0) -> preconditionerIncomplete Cholesky factorization of the symmetric positive-definite matrix A (A ≈ L Lᴴ), applied with M \ b or ldiv!(M, b).
method
:ic0— zero fill-in (IC(0)):LsharesA's lower pattern.:ict— threshold with the full symbolic fill pattern (ICT): AMD ordering + the complete Cholesky fill pattern, dropping entries belowτ.:ick— level-k fill (IC(k)): keep entries whose shortest-path distance (with intermediate vertices <min(i,j)) is ≤k + 1.
τ
Magnitude drop tolerance (off-diagonal entries of L with magnitude below τ are dropped; τ = 0 keeps the full pattern).
ordering (:ict only)
:amd(default) — approximate minimum degree.:natural— natural order.perm— an explicit symmetric permutation (overridesordering).
check
Mirrors cholesky: true throws PosDefException on a non-positive pivot.
shift
ε ≥ 0— factorA + εIinstead ofA(diagonal regularization, soMapproximates(A + εI)⁻¹);0(default) means no shift.
Rasmah.NativeLinearAlgebra.Preconditioners.ilu — Function
ilu(A; method = :ilu0, τ = 0.0, k = 0, ordering = :amd, perm = nothing, shift = 0.0) -> preconditionerIncomplete LU factorization of the square matrix A (A ≈ L U), applied with M \ b or ldiv!(M, b).
method
:ilu0— zero fill-in (ILU(0)):L/UshareA's pattern, no pivoting.:ilut— threshold with fill (ILUTP): a fill-reducing column ordering (ordering) is applied, then Gilbert–Peierls partial pivoting with magnitude dropping (τ).:iluk— level-k fill (ILU(k)): keep entries whose shortest directed-graph distance is ≤k + 1, no pivoting.
τ
Magnitude drop tolerance (off-diagonal entries below τ times the pivot are dropped). τ = 0 keeps the full pattern.
ordering (:ilut only)
:amd(default) — column approximate minimum degree (colamd).:natural— natural column order.perm— an explicit column permutation (overridesordering).
shift
ε ≥ 0— factorA + εIinstead ofA(diagonal regularization, soMapproximates(A + εI)⁻¹);0(default) means no shift.
jacobi(A)Rasmah.NativeLinearAlgebra.Preconditioners.Jacobi{Float64}([0.5, 0.5, 0.5, 0.5, 0.5, 0.5])Algebraic multigrid
The preconditioner that scales best for elliptic (mesh) problems is algebraic multigrid (AMG). AMG builds a hierarchy of coarser and coarser versions of $\mathbf{A}$ and smooths the error on every level, so the solver converges in a number of iterations that is essentially independent of the problem size:
Rasmah.NativeLinearAlgebra.AMG.amg — Function
amg(A; method = :classical, strength = :classical, coarsening = :rs,
interpolation = :modified, aggregation = :greedy,
prolongation = :smoothed, smoother = :gs, cycle = :v, presmooth = 1,
postsmooth = 1, θ = 0.25, max_levels = 20, coarse_size = 16,
coarse_solver = :auto, ω = 2/3, max_agg_size = 8, near_nullspace = nothing,
blocksize = 1, non_galerkin = false, ng_threshold = 0.0,
strength_iters = 4, candidates = 2, air_degree = 1,
aggressive = false, aggressive_levels = 1, trunc_factor = 0.0,
max_elements = 0, smoother_tol = 0.0, smoother_level = 0,
coordinates = nothing, shift = 0.0)Build an algebraic multigrid preconditioner for the square matrix A.
Families (method)
:classical— Ruge–Stüben AMG (C/F coarsening + interpolation).:smoothed_aggregation/:sa— smoothed aggregation.:rootnode— root-node aggregation AMG (MIS aggregation + energy-minimizing prolongation, near-nullspace aware).:air— approximate ideal restriction AMG (nonsymmetric; one-point interpolation + local ideal restriction).:mgr— multigrid reduction AMG.:adaptive/:alpha_sa— adaptive smoothed aggregation.:bootstrap— bootstrap AMG (adaptive near-nullspace via the hierarchy).
Components
strength::auto,:classical,:symmetric,:evolution,:algebraic_distance,:affinity.:autopicks:symmetricfor the aggregation families (SA/root-node/adaptive/bootstrap, matching PyAMG/MueLu) and:classicalfor the C/F families (classical/AIR/MGR, matching hypre).coarsening(classical)::rs,:pmis,:hmis,:cljp,:falgout,:cr,:cgc,:cgce.interpolation(classical)::direct,:classical(unmodified Ruge–Stüben),:modified(default; matches PyAMG/hypre "modified classical"),:extended,:extended_i.aggregation(SA/root-node)::greedy/:coupled,:mis,:pairwise,:uncoupled(block-respecting),:uncoupled_mis,:structured(coordinate bricks),:zoltan(recursive coordinate bisection).prolongation(SA/root-node)::tentative,:smoothed,:energy_minimizing.smoother::jacobi,:gs/:symmetric_gs,:forward_gs,:backward_gs,:l1_gs,:richardson,:chebyshev,:ilu,:ilut,:iluk,:block_jacobi,:block_gs/:block_sgs,:block_forward_gs,:block_backward_gs.cycle::v,:w,:f.
Other options
near_nullspace— ann×kmatrix (or length-nvector) of near-nullspace vectors consumed by SA/root-node; defaults to the block constant/unit modes implied byblocksize.blocksize— the number of consecutive unknowns per node (block AMG and the block/uncoupledaggregation and block smoothers).non_galerkin/ng_threshold— drop small coarse-operator entries.aggressive/aggressive_levels— apply hypre-style aggressive (multipass) coarsening — PMIS/HMIS over the strength-of-strength graph — to the firstaggressive_levelslevels, automatically pairing it with extended+i (distance-2) interpolation.trunc_factor/max_elements— interpolation truncation (drop entries belowtrunc_factor·row-max) and per-row element cap (keep the largestmax_elements), the hypreinterp_trunc_factor/interp_max_elementsknobs.smoother_tol/smoother_level— the drop tolerance / fill level for the:ilut/:iluk(Euclid-style ILU) smoothers.coordinates— ann×dmatrix of node coordinates for:structuredand:zoltanaggregation.shift— a nonnegative diagonal regularizationε: the hierarchy is built onA + εI(the shift propagates to every Galerkin coarse operator, so the coarse solves are well-posed even for semidefinite or mildly indefiniteA). Defaults to0.0(no shift).
Defaults
The defaults follow the established parameterizations of PyAMG, hypre (BoomerAMG), and MueLu: θ = 0.25 (classical strength threshold), ω = 2/3 (damped Jacobi), a V(1,1) cycle with a symmetric Gauss–Seidel smoother, 20 max levels, a 16-unknown direct coarse solve, and 8 as the aggregation size cap. coarse_solver = :auto uses Bunch–Kaufman LDLᵀ for self-adjoint operators and LU otherwise.
Rasmah.NativeLinearAlgebra.AMG.AMGPreconditioner — Type
AMGPreconditionerAn assembled algebraic multigrid preconditioner: an ordered list of levels (coarsest last), reusable scratch buffers, and (optionally) the rebuild settings for update!.
Fields
levels: theVector{AMGLevel{T}}, coarsest last.n: the matrix dimension.cycle::v,:w, or:f.ws: per-level transient buffers (levels1..L-1).config: rebuild settings forupdate!, ornothingif not reusable.
Rasmah.NativeLinearAlgebra.AMG.operator_complexity — Function
operator_complexity(M::AMGPreconditioner) -> numberOperator complexity: the total nonzeros across all levels relative to the fine operator (a standard AMG efficiency metric).
M = amg(A)
operator_complexity(M)1.0cg(A, ones(n); M=M, rtol=1e-10).iters1Using AMG as the preconditioner drops the iteration count, and — unlike Jacobi — the gain grows with the problem size.
Differentiability and parallelism
Every factorization and solve in the module is generic over the element type: Float32/Float64, complex numbers, and forward-mode dual numbers all work, so gradient(x -> sum(A(x) \ b), θ) differentiates through the linear solve — the engine behind Rasmah's differentiable FEM and topology optimization. The parallel keyword on the factorizations opts into multithreading, and parallel and serial runs produce bit-identical results.
Next steps
Sparse linear algebra is the engine underneath the finite-element solves — see the FEM & simulation chapter — and the reason a topology optimization loop can reuse its factorization every iteration (the Topology optimization chapter). The Optimization & nonlinear solvers chapter shows how Newton steps reuse the same Refactorization pattern.
