Home › Numerics & scientific computing › Linear algebra
algorithm

Sparse matrices (CSR)

Problem Matrices from meshes, graphs and PDEs are almost entirely zeros. This is how to store them compactly and multiply them by vectors fast, as needed by iterative solvers.

Compressed sparse row stores only nonzeros as row pointers, column indices and values; SpMV streams them once, so it is memory-bandwidth bound.

memory 12·nnz + 8·(n+1) bytes (f64 values, u32 columns, usize pointers); SpMV O(nnz), about 2 flops per 12-20 bytes moved
linear-algebrasparsecsrcsccoospmvmemory-bandwidthstorage-format
Overview

Matrices from meshes, graphs and PDEs have a handful of nonzeros per row. CSR stores three arrays: row_ptr[n+1], col[nnz], val[nnz]; row i is col/val[row_ptr[i]..row_ptr[i+1]]. Matrix-vector product reads each nonzero once and gathers x by column, which is all iterative solvers like CG need. Assemble as COO triplets, convert once.

Key facts

Use when

Avoid when

  • Density above roughly 10-30 %: dense BLAS wins.
  • Structure changes every step: keep COO or a hash map.
  • Structured-grid stencils: matrix-free code reads no indices.

Deep dive

How it works

In: COO triplets (row, col, value) and later a vector x · Out: the matrix as row_ptr/col/val, then y = A·x · State: the three arrays (read-only after the build) · Cost: build O(nnz + n); SpMV O(nnz), about 12 bytes read per nonzero for 2 flops

COO → CSR in O(nnz + n) with a counting sort:

  1. Count entries per row into row_ptr[r+1].
  2. Prefix-sum: row_ptr[i+1] += row_ptr[i].
  3. Scatter each triplet to next[r]++ (a copy of row_ptr).
  4. Sort each row's columns (short rows: insertion sort) and merge duplicates by summing, as FEM assembly needs.

SpMV: for each row i, y[i] = Σ val[k]·x[col[k]] for k in row_ptr[i]..row_ptr[i+1]. val and col stream sequentially; x is gathered, so cache hits on x depend on how well-banded the matrix is (reorder with reverse Cuthill-McKee to improve). Aᵀ·x with the same CSR scatters instead: y[col[k]] += val[k]·x[i], which is a race when parallel; store CSC (the transpose) for parallel transpose products.

A = [10  0  0  2]     row_ptr = [0, 2, 3, 5, 6]
    [ 0  3  0  0]     col     = [0, 3, 1, 0, 2, 3]
    [ 1  0  4  0]     val     = [10, 2, 3, 1, 4, 7]
    [ 0  0  0  7]

Code

pub struct Csr { pub nrows: usize, pub ncols: usize, pub row_ptr: Vec<usize>, pub col: Vec<u32>, pub val: Vec<f64> }

impl Csr {
    /// Build from COO triplets; duplicates are summed, columns sorted within each row.
    pub fn from_coo(nrows: usize, ncols: usize, t: &[(u32, u32, f64)]) -> Csr {
        let mut row_ptr = vec![0usize; nrows + 1];
        for &(r, _, _) in t { row_ptr[r as usize + 1] += 1; }
        for i in 0..nrows { row_ptr[i + 1] += row_ptr[i]; }
        let mut next = row_ptr.clone();
        let (mut col, mut val) = (vec![0u32; t.len()], vec![0.0; t.len()]);
        for &(r, c, v) in t {
            let k = next[r as usize];
            col[k] = c; val[k] = v; next[r as usize] += 1;
        }
        // sort each row by column and merge duplicates, compacting in place
        let mut out = 0;
        let mut new_ptr = vec![0usize; nrows + 1];
        for i in 0..nrows {
            let (s, e) = (row_ptr[i], row_ptr[i + 1]);
            let mut row: Vec<(u32, f64)> = (s..e).map(|k| (col[k], val[k])).collect();
            row.sort_unstable_by_key(|p| p.0);
            for (c, v) in row {
                if out > new_ptr[i] && col[out - 1] == c { val[out - 1] += v; } else { col[out] = c; val[out] = v; out += 1; }
            }
            new_ptr[i + 1] = out;
        }
        col.truncate(out); val.truncate(out);
        Csr { nrows, ncols, row_ptr: new_ptr, col, val }
    }

    pub fn nnz(&self) -> usize { self.val.len() }

    /// y = A x
    pub fn spmv(&self, x: &[f64], y: &mut [f64]) {
        assert_eq!(x.len(), self.ncols);
        for i in 0..self.nrows {
            let (s, e) = (self.row_ptr[i], self.row_ptr[i + 1]);
            let mut acc = 0.0;
            for (v, &c) in self.val[s..e].iter().zip(&self.col[s..e]) { acc += v * x[c as usize]; }
            y[i] = acc;
        }
    }

    /// y = A^T x (scatter; sequential only)
    pub fn spmv_t(&self, x: &[f64], y: &mut [f64]) {
        y.iter_mut().for_each(|v| *v = 0.0);
        for i in 0..self.nrows {
            for k in self.row_ptr[i]..self.row_ptr[i + 1] { y[self.col[k] as usize] += self.val[k] * x[i]; }
        }
    }

    /// Explicit transpose (= CSC of A), O(nnz + n); columns come out sorted.
    pub fn transpose(&self) -> Csr {
        let mut ptr = vec![0usize; self.ncols + 1];
        for &c in &self.col { ptr[c as usize + 1] += 1; }
        for j in 0..self.ncols { ptr[j + 1] += ptr[j]; }
        let mut next = ptr.clone();
        let (mut col, mut val) = (vec![0u32; self.nnz()], vec![0.0; self.nnz()]);
        for i in 0..self.nrows {
            for k in self.row_ptr[i]..self.row_ptr[i + 1] {
                let j = self.col[k] as usize;
                col[next[j]] = i as u32; val[next[j]] = self.val[k]; next[j] += 1;
            }
        }
        Csr { nrows: self.ncols, ncols: self.nrows, row_ptr: ptr, col, val }
    }

    /// Parallel y = A x: rows split so each thread gets ~nnz/threads nonzeros.
    pub fn spmv_par(&self, x: &[f64], y: &mut [f64], threads: usize) {
        let nnz = self.nnz();
        let mut bounds = vec![0usize];
        for t in 1..threads {
            let target = nnz * t / threads;
            bounds.push(self.row_ptr.partition_point(|&p| p < target).min(self.nrows));
        }
        bounds.push(self.nrows);
        std::thread::scope(|s| {
            let mut rest = &mut y[..];
            for w in bounds.windows(2) {
                let (r0, r1) = (w[0], w[1].max(w[0]));
                let (mine, tail) = std::mem::take(&mut rest).split_at_mut(r1 - r0);
                rest = tail;
                s.spawn(move || {
                    for (yi, i) in mine.iter_mut().zip(r0..r1) {
                        let (a, b) = (self.row_ptr[i], self.row_ptr[i + 1]);
                        *yi = self.val[a..b].iter().zip(&self.col[a..b]).map(|(v, &c)| v * x[c as usize]).sum();
                    }
                });
            }
        });
    }
}

Complexity and constants

  • Memory: f64 values + u32 columns = 12 bytes per nonzero, plus 8 bytes per row pointer. A 5-point Laplacian on 1000 × 1000 has n = 10⁶ rows and about 5·10⁶ nonzeros: 68 MB.
  • SpMV traffic per call: 12·nnz + 8·n (pointers) + 8·n (y) + x reads (≥ 8·n): about 84 MB for that matrix.
  • Measured with the code above on that Laplacian (Apple M4 Pro, -O): COO → CSR build 46 ms; serial SpMV 2.1 ms (about 40 GB/s effective, 4.8 GFLOP/s); spmv_par 1.27 ms on 2 threads, 0.71 ms on 4, 0.68 ms on 8 (about 120 GB/s): it saturates memory bandwidth at a few cores, more threads do not help.
  • Conversion COO → CSR: O(nnz) plus per-row sorting; the triplet array is 16 bytes per entry, so assembly peaks at about 2.3× the final CSR size.

Efficiency

Cost: one SpMV on the 5-point Laplacian with 10⁶ rows and about 5·10⁶ nonzeros moves about 84 MB. Measured with this page's code on an Apple M4 Pro: 2.1 ms serial (about 40 GB/s effective, 4.8 GFLOP/s), 0.68 ms on 8 threads (about 120 GB/s). Bound: memory bandwidth, about 2 flops per 12 bytes.

Levers:

  • Threads — 3.1× at 4 threads, no gain beyond (measured above) — saturates bandwidth within a few cores.
  • u32 instead of u64 column indices — 12 instead of 16 bytes per nonzero, a quarter less traffic — fewer than 2³² columns.
  • Equal-nnz row split — removes the one-thread-does-most case — power-law rows.
  • Reverse Cuthill-McKee reordering — better cache hits on x gathers — unstructured meshes.
  • BSR or matrix-free — one index per block, or none — multi-component PDEs, stencils.

Naive → standard → state of the art:

  • COO triplets, 16 bytes per entry, scatter per entry · slower, about 1.3× the bytes of CSR (16 vs 12)
  • CSR, u32 columns, nnz-balanced threads · bandwidth-bound, 12 B/nnz
  • Blocked or SELL-C-σ layouts, matrix-free stencils · fewer index bytes, SIMD/GPU friendly

Implementation notes

  • Index width: u32 columns and u32 (or u64 when nnz ≥ 2³²) row pointers; the code above uses usize pointers for simplicity.
  • Block CSR (BSR): for elasticity or multi-component PDEs, store dense 3×3 or 4×4 blocks; one column index per block cuts index traffic and enables SIMD.
  • SELL-C-σ / ELLPACK: pad rows to equal length within chunks of C rows for SIMD and GPUs; sorting rows by length within windows of σ limits padding.
  • Symmetric storage (upper triangle only) halves memory but makes SpMV a scatter; usually not worth it for parallel code.
  • Reordering (RCM, METIS nested dissection) improves locality of x gathers and is mandatory for sparse direct solvers.
  • Matrix-free: for stencils and high-order FEM, compute A·x on the fly; no index traffic at all.
  • Compressed indices: delta-encode column indices within rows (bit packing) when bandwidth is the bottleneck and decode is cheap; for 0/1 matrices, bitmaps (roaring) or pattern-only CSR.

Pitfalls

  • Splitting rows evenly across threads when a few rows are dense (power-law graphs): one thread does most of the work.
  • Parallel Aᵀx on CSR by scattering without atomics: data races; build the transpose once.
  • Assuming sorted, duplicate-free columns from external data: validate or canonicalize, many kernels (merging, triangular solves) depend on it.
  • Storing explicit zeros from assembly (cancellations) and then counting them as structural nonzeros: harmless for SpMV, wasteful for direct solvers; prune after assembly if they are structural.
  • Using u32 for row pointers with more than 4 billion nonzeros: silent overflow.

Variants and alternatives

CSCcolumn-major; natural for sparse direct solvers (CHOLMOD, UMFPACK) and column slicing.
COOassembly and format conversion; also used by GPU kernels for very irregular matrices.
BSR, ELLPACK, SELL-C-σ, DIAblocked, padded or diagonal layouts for SIMD/GPU and banded structure.
Hash map / DOKdynamic insertion; convert before computing.
Matrix-free operatorscompute products from the stencil or element data.

In production

SciPy scipy.sparse.csr_matrix, PETSc MATAIJ (CSR) and MATBAIJ (BSR), Intel MKL sparse BLAS, cuSPARSE (CSR, COO, BSR, SELL), Eigen SparseMatrix (CSC by default), SuiteSparse (CSC), Rust sprs and faer, PyTorch torch.sparse_csr_tensor.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
sprsRustsparsemat/sprscargo add sprs————CSR/CSC/COO, products
faerRustsarah-quinones/faercargo add faer————sparse module, CSC/CSR
SciPy scipy.sparsePython/C++scipy/scipypip install scipy————csr_array, csc_array, coo_array
PETScCpetsc/petscbrew install petsc————MATAIJ (CSR), MATBAIJ (BSR)
EigenC++libeigen/eigenbrew install eigen————SparseMatrix (CSC default)

Sources

  • Yousef Saad, Iterative Methods for Sparse Linear Systems, 2nd ed., SIAM, 2003, chapter 3. book
  • Timothy A. Davis, Direct Methods for Sparse Linear Systems, SIAM, 2006. book
  • Moritz Kreutzer, Georg Hager, Gerhard Wellein, Holger Fehske, Alan R. Bishop, A Unified Sparse Matrix Data Format for Efficient General Sparse Matrix-Vector Multiplication on Modern Processors with Wide SIMD Units, SIAM Journal on Scientific Computing 36(5), 2014. paper
  • Samuel Williams, Leonid Oliker, Richard Vuduc, John Shalf, Katherine Yelick, James Demmel, Optimization of Sparse Matrix-Vector Multiplication on Emerging Multicore Platforms, SC 2007. paper

Used by

ac power flow · plannedNewton-Raphson AC power flow on the sparse bus admittance matrix to get voltages and line flows
analytical placement · plannedQuadratic or nonlinear wirelength minimization with density penalties to place millions of cells, then legalize
belief propagation · plannedMessage passing on factor graphs (sum-product, min-sum) for decoding and inference
BFS and DFSAny program working with a graph must answer basic structural questions: what is reachable, how many hops apart things are, whether there are cycles, what the connected pieces are, and in what order to process nodes.
bundle adjustment · plannedJointly refine camera poses and 3D points by sparse nonlinear least squares on reprojection error
Matrix factorization for collaborative filteringA service holds a huge, mostly empty table of who interacted with what (ratings, plays, clicks, applications) and must predict the missing cells to suggest items or fill gaps, without any item descriptions.
complementarity contact solver · plannedContact as LCP or convex optimization (PGS, Newton) with friction cones; soft contacts
conservative regridding · plannedRemap fields between grids preserving integrals, using overlap areas (first and second order)
constraint solvers shake lincs · plannedHold bond lengths fixed in MD: SHAKE iteration, LINCS matrix expansion, SETTLE for water
contingency analysis · plannedN-1 screening: simulate outages with fast DC/AC approximations and flag violations
Fill-reducing orderingSolving a large sparse linear system exactly (meshes, circuits, constrained optimization) where factoring it in the order the unknowns happen to be numbered fills memory with new nonzeros and takes far too long.Finite elementsSolving a PDE (structures, heat, electrostatics) on an irregular geometry with mixed materials and local refinement, where a regular grid cannot follow the boundary.
finite volume method · plannedConservative cell-averaged discretization with numerical fluxes on structured and unstructured meshes
gmres · plannedGMRES with restarts: Arnoldi, Givens rotations, for nonsymmetric sparse systems
graph community detection · plannedLouvain/label propagation community detection for rings, fraud clusters and campaigns
graph neural networks · plannedMessage-passing neural networks over graphs (GCN, GAT, equivariant) for molecules and relations
Graph partitioningDistributing a large graph (social network, mesh, database shards) across machines needs balanced parts with few links between them, because every cross-part edge costs network traffic.
groebner bases · plannedBuchberger and F4 algorithms for polynomial ideals; solving polynomial systems
Incomplete factorization preconditionersIterative solvers for large sparse systems need many iterations when the matrix is ill-conditioned, and an exact factorization is too big. This builds a cheap sparse approximation to speed them up.
ldpc codes · plannedLow-density parity-check codes with soft-decision min-sum decoding, used in NAND flash
Simplex methodAnyone optimizing a linear objective under linear constraints (planning, blending, allocation) needs an exact optimum, sensitivity information and infeasibility proofs, and the engine that integer-program solvers re-solve repeatedly.
modified nodal analysis · plannedBuild circuit equations from netlists by element stamps (MNA), including voltage sources
pagerank · plannedPower-iteration PageRank on a sparse graph with damping and dangling nodes
pipe network hydraulics · plannedSolve pipe-network flows and heads (Hazen-Williams, gradient method) for water systems
Presolve reductionsOptimization models generated by software are full of redundant rows, fixed variables and loose bounds that slow linear and integer solvers. This shrinks and tightens the model before the solver starts.
primal dual hybrid gradient · plannedFirst-order LP solving (PDLP): restarted PDHG with preconditioning for very large LPs
quadratic programming · plannedConvex QP solvers (active-set, interior-point, ADMM) for constrained optimization
random graph models · plannedErdos-Renyi, configuration model, small-world and stochastic block models for synthetic networks
Sparse direct solverSolving sparse linear systems exactly and reliably (FEM, circuits, many repeated solves with one matrix) without worrying about iterative convergence, up to the point where memory for the factors runs out.
wls state estimation · plannedWeighted-least-squares grid state estimation with bad-data detection

Systems (26):

Alternatives and related