Backends and Factorization Reuse
The Y-bus admittance matrix and Newton-Raphson Jacobian are stored and factored as sparse matrices, not dense ones. This page covers why that matters, how factorization work is reused across solves, and what the five interchangeable linear-solver backends are.
Why sparse
At 2,605 nodes the original dense solver was over 100,000x slower than power-grid-model; the sparse rewrite closed that to roughly an order of magnitude on a cold, single-shot solve. The underlying grid is sparse — each bus only connects to a handful of neighbors — so a dense representation was doing asymptotically unnecessary work regardless of how fast the constant-factor arithmetic was.
Two things make this work, rather than just "swap in a sparse matrix type":
- Sparse-aware assembly. Both the Jacobian build (
solver::build_jacobian_triplets) and the linear initial-guess warm start (network::linear_initial_guess) walk each bus's actual admittance neighbors (vianetwork::YBusSparse::row) instead of looping over every possible bus pair. The O(n²)/O(m²) assembly cost has to go too, or a sparse solve alone doesn't fix the bottleneck. - Symbolic factorization reuse. A Newton-Raphson Jacobian has the same sparsity pattern every
iteration — same bus topology, only numeric values change — so
solver::newton_raphsoncomputes the symbolic factorization (fill-reducing ordering) once and reuses it for a cheap numeric-only refactorization on each iteration (sparse::RealSparseSystem), mirroring what PGM's own solver does internally.
Inside KLU walks through exactly what "symbolic factorization" and "numeric-only refactorization" mean, step by step, on a real Jacobian.
Reusing factorization across repeated solves
A single newton_raphson/newton_raphson_with_backend call reuses its symbolic factorization
across its own NR iterations, but starts cold on every call — re-deriving the fill-reducing ordering
from scratch. That is fine for a genuinely one-off solve and wasteful for anything that solves the
same topology repeatedly: a time series, a batch of scenarios, contingency analysis. In those, only
bus values (p_spec, q_spec, voltage guess) change between calls, not the topology.
solver::PersistentSolver extends the reuse across calls. Construct one per topology, then solve
as many times as needed; only the first call pays for symbolic factorization.
#![allow(unused)] fn main() { use gridoxide::solver::{JacobianBackend, PersistentSolver}; let mut solver = PersistentSolver::new(JacobianBackend::Klu); for scenario in scenarios { apply_scenario(&mut buses, scenario); // changes p_spec/q_spec only solver.solve(&mut buses, &ybus, 1e-6, 20); } }
Call .reset() (or construct a new solver) if the topology itself changes between solves. This is a
meaningful win on real-world grids, since a cold solve otherwise redoes COLAMD/AMD/BTF ordering from
scratch every call. examples/bench_network.rs exposes it as an optional warm mode; its default
cold mode still measures "N independent flat-start solves with no shared state," a different and
also legitimate number. See Benchmarking for the measured
warm-vs-cold figures.
The backend interface
src/sparse.rs is the thin backend wrapper around faer, and
intentionally the only file that imports faer types directly, so a different sparse-solver backend
can be swapped in behind the same interface without touching the rest of the codebase.
solver::newton_raphson always uses the default Scalar (faer-backed) path.
newton_raphson_with_backend additionally accepts four alternatives via solver::JacobianBackend:
Block
src/block_sparse.rs, no extra build requirements. Groups each bus's own (angle,
voltage-magnitude) unknowns into one dense 2×2 block, mirroring power-grid-model's block-per-bus
matrix structure, with a hand-written Gilbert-Peierls sparse block LU (block_sparse::BlockLu).
Symmetric power flow only. Consistently faster than Scalar.
Klu
src/sparse_klu.rs, needs cargo build --features klu. The same scalar Jacobian as Scalar, solved
by SuiteSparse's KLU instead of faer,
vendored and compiled from source (vendor/suitesparse/) rather than depending on a third-party Rust
wrapper crate. Needs a C compiler and libclang (for bindgen) at build time.
KLU and BTF (one of KLU's own dependencies) are LGPL-2.1-or-later. See Provenance and Licensing.
KluNative
src/klu_native/, no extra build requirements. A from-scratch Rust translation of the same KLU
algorithm Klu links over FFI: BTF block-triangular preprocessing, per-block AMD ordering, a
partial-pivoting Gilbert-Peierls LU kernel with Eisenstat-Liu pruning, and cheap numeric-only
refactorization — all faithfully ported, not a simplified reimplementation. No C compiler or
libclang needed, so unlike Klu it is always built.
Validated end-to-end against real KLU on all 13 real MATPOWER benchmark cases (14 to 9,241 buses):
identical iteration counts and identical converged voltages on every case. One known, documented gap
— row scaling (klu_native::scale) is ported and independently tested but not yet wired into the
factor/refactor path (see src/klu_native/mod.rs's module doc comment). That is a
numerical-stability preconditioning step, not a correctness one, so it doesn't affect the results
above.
Inside KLU is a walkthrough of this port specifically.
Pardiso
src/sparse_pardiso.rs, needs cargo build --features pardiso and MKLROOT set at build time. The
same scalar Jacobian as Scalar, solved by
Intel oneMKL's PARDISO
direct solver. Unlike Klu, nothing is vendored — MKL is proprietary (Intel Simplified Software
License, not OSS), so this only dynamically links a locally-installed oneMKL (libmkl_rt.so,
discovered via MKLROOT, e.g. source /opt/intel/oneapi/setvars.sh) and generates FFI bindings via
bindgen against that install's own mkl_pardiso.h. No MKL header or source is copied into this
repo.
PARDISO's C API is one function called repeatedly with different phase values against a persistent
opaque handle, rather than KLU's separate analyze/factor/refactor/solve functions. mtype = 11
(real, nonsymmetric) and iparm[34] = 1 (0-based indexing) are the two settings that matter for
matching gridoxide's CSR/CSC conventions.
Not built or tested in CI — no CI runner has MKL installed — so this is a local/manual-verification-only backend.
How they compare
All four alternatives are strictly parallel to Scalar, not replacements. A bug in any of them
can't affect newton_raphson's default behavior, and every existing test keeps using Scalar unless
it explicitly opts into a different backend.
All five backends produce identical converged voltages at every scale — these are purely performance comparisons, not correctness trade-offs. In rough terms:
Block,Klu, andKluNativeare all meaningfully faster thanScalar.KluandKluNativeland close to each other, slightly ahead ofBlock.Pardisocarries a largely size-independent fixed setup cost from its default matching/scaling preprocessing, making it the slowest backend at small problem sizes — even behindScalar— though it scales better thanScalaras node count grows.
PGM is clearly faster than any gridoxide backend on synthetic radial-distribution/LV topology, a
real, standing gap this project hasn't closed. That gap doesn't hold universally, though: on
real-world transmission-topology grids, gridoxide's Klu backend is frequently faster than
lightsim2grid's own KLU-backed C++ solver. The comparison depends on topology, not just
implementation language.
Benchmarking points at the full measured numbers, exact ratios, and how to reproduce them.