rml / Linear algebra

Linear algebra

rml/linalg holds the matrix product every layer runs on and the classic factorizations: LU, QR, Cholesky, a symmetric eigensolver, the SVD, and the norms built on it. The product is a SIMD, packed, threaded GEMM in f32 with its kernel chosen at run time. The factorizations copy into a plain double-precision Matrix.

On this page
  1. Matrix: the double-precision working type
  2. MatMul: NumPy semantics
  3. GEMM
  4. GemmRef
  5. Kernels and dispatch
  6. The blocked kernel
  7. PackedB: packing a weight once
  8. Threading
  9. The benchmark
  10. LU with partial pivoting
  11. QR and least squares
  12. Cholesky
  13. Symmetric eigendecomposition
  14. SVD
  15. Norms, condition number, rank, pseudo-inverse
  16. Tests

In short: MatMul follows NumPy's rules (batch axes broadcast, vectors gain and lose an axis) and calls Gemm, folding a batched x @ W into one product and spreading many small products across threads. Gemm picks its micro-kernel at run time (AVX-512 12×32, AVX2 6×16, or a portable 6×8 SSE / 8×12 NEON tile) and its path from the shape: one row of A streams B through an axpy, up to 32 rows stream B through register tiles, and anything larger runs the Goto/BLIS driver that packs panels of A and B and splits disjoint tiles of C across the thread pool. PackedB packs a constant weight once. GemmRef is the scalar reference every kernel is tested against. The decompositions work in double on a Matrix and convert back to f32 tensors. LU uses partial pivoting, QR uses Householder reflections, Cholesky needs no pivoting, and eigen and SVD use Jacobi rotations. Norms, condition number, rank and pseudo-inverse are all read off the SVD.

Each header links to the /ml-models page it builds on: matrix multiplication, matrix decompositions, the linear algebra toolkit and vectors and dot products. The tensors these functions take and return are described on Tensors and dtypes. The thread pool that runs Gemm is on Errors, memory and threads.

Which function to call A map from the kind of problem to the rml/linalg function that solves it. Products go through MatMul and Gemm. Square systems go through LU, symmetric positive definite systems through Cholesky, overdetermined systems through QR least squares, symmetric spectra through Jacobi eigen, and everything about size and rank through the SVD. a product A·B any batch shape solve A x = b A square A symmetric, PD covariance, normal eqns fit min ‖A x − b‖ A tall, full rank symmetric spectrum MatMul → Gemm f32, threaded LU, partial pivot Solve · Inverse · det Cholesky L Lᵀ half LU's work QR, Householder LeastSquares Jacobi eigen SVD (any shape) norms ‖·‖₂ ‖·‖* condition number rank pseudo-inverse rank-deficient or wide?
Every box on the right is a public function in rml/linalg. All of them take and return f32 tensors; everything but the product works in double internally.

Matrix: the double-precision working type

Tensors are f32, but factorizations lose digits to cancellation, so each decomposition copies its input into a Matrix, works in double, and converts the result back (matrix.h L4–11). Matrix is deliberately plain: row-major std::vector<double>, no views, no expression templates.

ItemMeaningSource
Matrix(rows, cols), Identity(n)Zeros, or the identity. The constructor checks the dimensions are non-negative.matrix.cc L14
FromTensorCopies an f32 tensor of rank 1 or 2, any strides. A vector becomes one column.matrix.cc L25
ToTensor, ColumnToTensorBack to a contiguous rank-2 tensor, or to rank 1 for a single column (checked).matrix.cc L37
operator()(r, c)Element access, bounds-checked by RML_DCHECK in debug builds.matrix.h L41
Transposed, MaxAbs, operator*A copied transpose, the largest |entry| (used to scale tolerances), and a plain i-k-j product.matrix.cc L51

MatMul: NumPy semantics

matmul.h follows NumPy's matmul. The last two axes are the matrix axes, and any leading batch axes broadcast against each other (see broadcasting). A 1-D left operand is treated as a row vector and a 1-D right operand as a column vector, and the added axis is removed from the result. Inputs may be strided, so multiplying by a Transpose view costs no copy.

FunctionResultSource
Dot(a, b)Inner product of two vectors, as a rank-0 tensor, through GemmRef.matmul.cc L122
MatMul(a, b)The batched product on the threaded Gemm and the default pool.matmul.cc L128
MatMulRef(a, b)The same, on GemmRef with double accumulation: the ground truth in tests.matmul.cc L130
MatMulPacked(a, b)a [..., m, k] times a PackedB weight [k, n], as one product over all of a's rows (a is made contiguous first). a must be f32 of rank ≥ 2.matmul.cc L132

The first three run through BatchedMatMul (matmul.cc L36–118). It checks f32 and the inner dimension, unsqueezes vector operands, broadcasts the batch shapes, and allocates the output. Then it picks one of three ways to run the batch:

  1. Fold the batch into the rows. When B is a single 2-D matrix shared by every batch (the x @ W of a linear layer) and A's rows are evenly spaced across all its batch axes, the batch is one tall matrix of batch·m rows, so one Gemm call does the whole product and B is packed once instead of once per batch (L67–82).
  2. Many small products in parallel. Otherwise internal::ForEachRow<3> collects every batch's offsets with broadcast strides, and a MatrixView is built for each operand straight from the tensor's strides, so a broadcast operand is read in place rather than copied. When there are several batches and each product has fewer than 2²² multiply-adds (attention heads), the batches are split across the default pool with a grain of about 2²⁰ multiply-adds per task, and each product then runs on one thread, because a parallel loop nested in a running chunk executes inline (L84–110).
  3. One product at a time, each on the threaded Gemm, for large products or a reference run (L111–113).

Finally the added vector axes are squeezed away.

GEMM

gemm.h computes C = α·A·B + β·C on raw f32 memory. Operands are MatrixViews: a pointer, a shape, and a row and column stride. Swapping the strides is a transpose, so Transposed() is free. When β is 0, C is overwritten without being read, so it may start as garbage or NaN. A mismatched inner dimension, or an ldc smaller than C's columns, is a RML_CHECK failure (gemm.cc L19–22).

GemmRef

The reference (gemm.cc L34–47) is the textbook triple loop. It accumulates each dot product in double and then applies α and β. It is slow and exact enough that the tolerance on every faster path reflects only that path's own rounding.

Kernels and dispatch

Gemm is internal::GemmWithKernel(kAuto, …) (gemm.cc L656–659). The kernel enum is GemmKernel { kAuto, kPortable, kAvx2, kAvx512 } (gemm.h L83–96). GemmKernelSupported asks the CPU through __builtin_cpu_supports (AVX2 needs avx2 and fma, AVX-512 needs avx512f; neither exists off x86-64), and BestGemmKernel caches the widest supported one in a function-local static (gemm.cc L577–603). The x86 kernels are compiled with __attribute__((target(...))), so the baseline build still runs on any x86-64. Tests and benchmarks can force a kernel through GemmWithKernel, which RML_CHECKs that the CPU can run it.

GemmWithKernel (gemm.cc L605–650) returns early when m or n is 0 and only scales C by β when k is 0. Then the shape of A picks the path:

How Gemm picks a path Gemm first resolves the kernel (AVX-512, AVX2 or portable) from the CPU. A single row of A with contiguous B columns goes to GemvRows, which streams B row by row. At most 32 rows with contiguous B goes to SmallM, which streams B through register tiles. Anything larger goes to the blocked Goto/BLIS driver, which packs A and B, or reads a PackedB packed ahead of time. Gemm(a, b, c) BestGemmKernel() m = 1 → GemvRows axpy each row of B into chunks of c; no packing m ≤ 32 → SmallM 8×48 / 4×24 register tiles stream B; no packing m > 32 → Blocked<Kernel> packs A and B, or reads a PackedB
The two streaming paths need B's columns to be contiguous (col_stride == 1); a transposed B always takes the blocked driver, whose packers read any strides.
PathWhenWhat it doesSource
GemvRowsm = 1, B's columns contiguousA matrix-vector product (the decode step of every layer). Each row of B is added, scaled by one element of a, into a chunk of c, so B is read once, in order. The axpy is one loop compiled three times, for the baseline, AVX2+FMA and AVX-512 targets. Chunks are 256 to 1024 columns, a multiple of 16, about two per thread, so a task's c stays in L1 (4 KB).gemm.cc L350–402
SmallMm ≤ kSmallM = 32, B's columns contiguousPacking B costs about as much as the m·k·n multiply-adds here, so B is read where it lies. A tile of up to 8 rows × 48 columns (AVX-512: 24 zmm accumulators, 3 B vectors, 1 broadcast) or 4 × 24 (AVX2: 12 ymm) stays in registers; partial tiles use masked loads and stores. Rows 8 steps of k ahead are prefetched by hand. The portable tile is 8 × 64, accumulated in memory. Row tiles vary fastest, so tiles sharing a column panel of B run back to back.gemm.cc L404–571
Blocked<Kernel>everything elseThe Goto/BLIS driver below, on the AVX-512, AVX2 or portable micro-kernel.gemm.cc L289–348

The blocked kernel

The blocked driver follows the Goto/BLIS scheme, which the comment at gemm.cc L49–74 lays out. C is cut into kMc×kNc blocks and the shared dimension k into kKc slices (L77–79). Each block of A is packed into Mr-row panels and each slice of B into Nr-column panels. Both are laid out in the order the micro-kernel reads them, and zero-padded at the edges so the kernel never branches on a partial tile.

How Gemm blocks the product B's k-slice sits above C and A's k-slice to the left of C. One kMc by kNc block of C is highlighted, with the A rows and B columns that feed it. Inside, one Mr by Nr micro-tile of C is held in registers, fed by one packed A panel of Mr rows and one packed B panel of Nr columns. B: one k-slice (kKc × n) kNc = 192 A: one k-slice (m × kKc) kMc = 96 kKc = 256 one C block C (m × n) Mr × Nr tile in registers outlined: the panels one micro-kernel call reads, and the tile it updates
One unit of work owns one C block for one k-slice. It packs its A block (96×256 floats, 96 KB, meant to stay in L2) and walks Mr×Nr tiles across it. Each micro-kernel call reads one B panel (at most 256×32 floats, 32 KB, meant for L1).

Each micro-kernel holds an Mr×Nr tile of C in vector registers. For each k step it does one rank-1 update: Nr/width vector loads of B, Mr broadcasts of A, and Mr·Nr/width multiply-adds. The tile size is chosen to fill the register file:

KernelMr × NrRegister budgetSource
AVX-512 (_mm512_fmadd_ps)12 × 3224 of the 32 zmm registers as accumulators, 2 B vectors, 1 broadcast: 768 flops per k step from 14 loads.gemm.cc L159
AVX2 + FMA6 × 1612 of the 16 ymm registers, 2 B vectors, 1 broadcast: two FMAs per broadcast.gemm.cc L129
Portable, x86-64 (SSE)6 × 8Four-float Vec4 (a GCC vector extension): 12 accumulators of 16 registers, 2 B vectors, 1 broadcast.gemm.cc L88
Portable, aarch64 (NEON)8 × 1224 accumulators of 32 registers, 3 B vectors, 1–2 broadcasts.

The packers (gemm.cc L190–224) copy through the views' strides, which is how transposed operands are handled. PackB takes a memcpy fast path when a panel is full and B's columns are contiguous. StoreTile (L226–242) writes α·tile plus β·C on the first k-slice, and adds α·tile on every later slice, so β is applied exactly once.

PackedB: packing a weight once

For more than 32 rows the blocked driver repacks B on every call. A model's constant weight never changes, so PackedB (gemm.h L55–81) packs it once for the kernel chosen when it is made: every k-slice's panels, laid out at offset p0·padded_n as the driver expects (PackAllB, gemm.cc L244–261). GemmPackedB (L683–701) passes the packed buffer to Blocked, or falls back to GemmWithKernel on the original view when A has 32 rows or fewer (where B is streamed, not packed) or k or n is 0. That is why the original B must stay alive and unchanged. GemmPacksB(rows) (L661) says whether packing is worth it.

Its caller is the graph executor's op compiler: a matmul node whose second input is a constant 2-D f32 weight, with enough rows that GemmPacksB holds, gets a kernel that calls MatMulPacked on a PackedB made at load (rml/graph/ops.cc L929–944). rml measured it in two interleaved runs on four threads: torch_lstm_b64 went from 53 / 45 ms to 33 / 37 ms and torch_mlp_b64 from 1.05 / 1.39 ms to 0.82 / 0.82 ms (kernels.md L75–78, same Xeon container).

Threading

For each k-slice the blocked driver (gemm.cc L307–347) runs up to two parallel loops:

  1. Unless B was packed ahead of time, B's slice is packed, split by column chunk. Packing is a copy, so it is threaded only when the slice has at least 2¹⁸ elements (kParallelPackElements); otherwise one task packs it all (L310–319). The packed buffer is a std::vector sized for one slice, allocated once per call.
  2. The product runs over (row block, column chunk) pairs, which own disjoint tiles of C, so no synchronization is needed (L320–346). Each task packs its A block into a 64-byte-aligned stack buffer, and re-packs only when the row block changes. The cost of re-packing A is 1/kNc of the pair's multiply-adds.

A task's column chunk need not be the kNc = 192 columns B was packed in, because packed panels are contiguous. UnitColumns keeps kNc when there are at least four pairs per thread, so a chunk's B stays in L2. With fewer, a fixed width leaves threads idle: 64 × 1024 is six kNc chunks on four threads, two rounds for 5.3 chunks of work. It then tries every width up to 4·kNc, in NR-column panels, and keeps the one whose slowest thread computes the fewest panels. On a packed 64 × 256 × 1024 weight (BM_GemmPackedB) that takes 104 µs on four threads against 286 on one, and in two interleaved runs on four threads torch_rnn_b64 went from 10.5 / 9.8 ms to 5.2 / 5.0 ms and torch_lstm_b64 from 17.5 / 16.3 ms to 15.6 / 13.0 ms (kernels.md L67–74).

The grain is max(1, kMinTaskMacs / unit_flops), where a unit is one block's multiply-adds for this slice and kMinTaskMacs is 2²⁰. Waking the pool's workers costs tens of microseconds, so every task gets about a million multiply-adds and small products run on the calling thread (L81–86). The streaming paths use the same floor. Because ParallelFor's chunk boundaries depend only on the range, grain and thread count (chunking), and each tile of C is summed by one thread in a fixed order, the result does not change from run to run.

The benchmark

gemm_benchmark.cc is a Google Benchmark target (rml_cc_benchmark in BUILD.bazel L56). It fills A and B from a fixed-seed mt19937 and reports GFLOP/s, counting 2·m·k·n flops per product (L25–29), or GB/s of B read for the matrix-vector case.

BenchmarkShapesThreadsSource
BM_GemmRefn³ for n = 64, 128, 2561L31
BM_Gemmn³ for n = 64, 256, 10241, 4 (wall-clock time)L42
BM_Gemv1 × n × n for n = 1024, 4096 (the decode shape)1, 4L59
BM_GemmFewRows8 × 256 × 1024, 8 × 512 × 784, 4 × 1024 × 1024 (1 and 4 threads); 16, 32 and 64 × 864 × 303 (1 thread)1, 4L78
BM_GemmPackedB64 × 256 × n against a weight packed ahead of time, n = 256, 1024, 4096 (a recurrent step); 4096 × 64 × 10241, 2, 4 (4096 rows: 1, 4)L104

rml's docs/kernels.md records these results on its Linux x86_64 development container (4 vCPUs, Xeon at 2.8 GHz with AVX-512, GCC 13 -O3), which is not the Apple Silicon target:

Benchmark1 thread4 threads
GemmRef 256³1.5 GFLOP/s—
Gemm 64³67 GFLOP/s67 GFLOP/s
Gemm 256³117 GFLOP/s152 GFLOP/s
Gemm 1024³113 GFLOP/s376 GFLOP/s
Gemm 1 × 1024 × 102426 GB/s33 GB/s
Gemm 1 × 4096 × 409619 GB/s60 GB/s

The same doc puts the AVX-512 kernel at 6–7× the SSE-only portable kernel it replaced (16.5 and 60 GFLOP/s for 1024³ on 1 and 4 threads). For the streaming path it gives 80 GFLOP/s for 8 × 256 × 1024 on one core, and 88–89 GFLOP/s at 16 and 32 rows of 864 × 303 against 41–60 packed; at 64 rows packing wins again (72 against 67) (kernels.md L48–63). Run it with bazel run --config=opt //rml/linalg:gemm_benchmark.

LU with partial pivoting

lu.h factors P·A = L·U. At each column, the row with the largest |entry| becomes the pivot, so every multiplier in L is at most 1 in magnitude, which keeps rounding error from growing. Once A is factored, each right-hand side costs two triangular solves, O(n²). The determinant is the product of U's diagonal times the sign of the permutation.

LuFactor (lu.cc L18–50) always succeeds. It stores the result packed into one matrix, with U on and above the diagonal and L's multipliers below it (LuFactors). A pivot no larger than MaxAbs · n · 64 · ε marks the matrix singular, but elimination carries on. LuSolve (L52–72) then returns InvalidArgument for a singular matrix, and Determinant returns 0.

Step on A = [[1, 2], [3, 4]]State
Column 0: the largest |entry| is 3, in row 1Swap rows: perm = [1, 0], sign = −1
Multiplier for row 1: 1 / 3Row 1 becomes [2 − 4/3] = [2/3], stored with 1/3 below the diagonal
ResultL = [[1, 0], [1/3, 1]], U = [[3, 4], [0, 2/3]]
Determinant−1 · 3 · 2/3 = −2, which matches 1·4 − 2·3

The tensor API (lu.cc L74–115) is LuDecompose (explicit L, U and the permutation), Determinant, Solve (b of shape [n] or [n, k]) and Inverse. The last two return Expected and propagate the singular error with RML_ASSIGN_OR_RETURN.

QR and least squares

qr.h computes A = Q·R by Householder reflections. Column j of R is made by one reflection H = I − 2vvᵀ, the mirror that sends the rest of column j onto the first axis without touching earlier columns. Reflections are orthogonal, so they never amplify rounding error. That is why QR, not the normal equations AᵀA·x = Aᵀb (which square the condition number), is the way to fit least squares.

  • QrFactor (qr.cc L34–77) returns the reduced form: Q is m×k with orthonormal columns and R is k×n, with k = min(m, n). Each reflection targets −sign(x₀)·‖x‖, so forming v = x − αe₁ never cancels (L50–52). A zero column is skipped.
  • Q is built by applying the stored reflectors to the first k columns of the identity, in reverse order (L62–68). Each column is then signed so R's diagonal is non-negative, which makes the factorization unique for a full-rank A (L70–76).
  • LeastSquares(a, b) (L85–108) returns the x minimizing ‖a·x − b‖₂ by back-substituting R·x = Qᵀb. It returns InvalidArgument when a is wider than tall, or when a diagonal entry of R is no larger than MaxAbs(R) · m · 64 · ε (rank deficient). For those cases, use PseudoInverse (below).

Cholesky

A symmetric positive definite matrix factors uniquely as A = L·Lᵀ, a kind of matrix square root (cholesky.h L4–12). It costs half as much as LU and needs no pivoting. It fails exactly when A is not positive definite, which makes it both the fastest solver for normal equations and covariance matrices and the standard test for definiteness. Only the lower triangle is read.

CholeskyFactor (cholesky.cc L13–29) fills L column by column. It returns InvalidArgument as soon as a diagonal value is not positive, and the check is written !(diagonal > 0) so a NaN fails it too. CholeskySolve (L31–49) solves L·y = b, then Lᵀ·x = y. The tensor wrappers Cholesky and CholeskySolve follow at L51–64.

Symmetric eigendecomposition

A symmetric A has real eigenvalues and an orthonormal basis of eigenvectors, A = V·diag(w)·Vᵀ. eigen.h finds them with the cyclic Jacobi method. Each step picks an off-diagonal pair (p, q) and rotates rows and columns p and q by the angle that zeroes apq. A rotation can refill entries zeroed earlier, but the off-diagonal sum of squares falls with every step, and convergence is quadratic at the end. The product of the rotations is V. The header notes it is slower than tridiagonal QR for large n, but simple and accurate to full relative precision even for tiny eigenvalues.

  • SymmetricEigenFactor (eigen.cc L32–95) reads only the lower triangle. It sweeps over every pair until the off-diagonal sum of squares is below ε² times the matrix's total, or 100 sweeps (kMaxSweeps) have run.
  • The rotation solves cot 2φ = θ with θ = (aqq − app) / 2apq, taking the smaller root for t = tan φ so |φ| ≤ π/4 (L51–56).
  • Eigenvalues come back ascending. Each eigenvector is signed so its largest-magnitude entry is positive, which makes the output deterministic (L79–93).

Worked example: for [[2, 1], [1, 2]], θ = 0, so t = 1 and φ = 45°. One rotation diagonalizes it, giving eigenvalues 1 and 3 with eigenvectors (1, −1)/√2 and (1, 1)/√2. This is the KnownSpectrum test.

SVD

Every m×n matrix factors as A = U·diag(s)·Vᵀ, with s ≥ 0 sorted descending. A rotates (Vᵀ), stretches along the axes (s), and rotates again (U). svd.h uses one-sided (Hestenes) Jacobi, which orthogonalizes A's columns directly. Rotating columns p and q until they are orthogonal is a Jacobi step on AᵀA without ever forming AᵀA, so no precision is lost to squaring. Once all columns are orthogonal, their norms are the singular values, the normalized columns are U, and the accumulated rotations are V.

  • TallSvd (svd.cc L61–118) sweeps until no pair needs rotating: a pair is skipped once |γ| ≤ ε·√(αβ), where α and β are the columns' squared norms and γ their dot product. It uses the same angle as the symmetric eigensolver.
  • A singular value no larger than s_max · m · ε counts as zero (L98). Its U column cannot be normalized, so CompleteColumn (L38–59) fills it with a unit vector orthogonal to the rest, by Gram-Schmidt on the standard basis. U stays orthonormal even for a rank-deficient A.
  • A wide matrix is handled by factoring its transpose and swapping U and V (L122–139). The result is the reduced SVD with k = min(m, n), and each pair is signed so V's largest-magnitude entry is positive.
  • SvdDecompose returns u [m, k], s [k] and vt [k, n]; SingularValues returns only s.

Norms, condition number, rank, pseudo-inverse

norms.h measures size. The induced matrix norms measure the most a matrix can stretch a vector. The condition number smax / smin bounds how much relative error in b can grow in the solution of A·x = b.

FunctionValueSource
VectorNorm(x, p)(Σ|xᵢ|ᵖ)1/p over every element, p ≥ 1; p = ∞ gives max |xᵢ|. It divides by the largest entry first, so |x|ᵖ cannot overflow or underflow.norms.cc L25
MatrixNorm(a, kFrobenius)√(Σ aij²), the vector 2-norm of the entries.norms.cc L43
kOne / kInfThe largest column sum / row sum of |aij|.
kSpectralThe largest singular value.
kNuclearThe sum of the singular values.
The kind is the enum MatrixNormKind (norms.h L20); Frobenius is the default.
ConditionNumber(a)smax / smin, or infinity when smin is 0.norms.cc L76
MatrixRank(a, tol)The count of singular values above tol. A negative tol means NumPy's default, smax · max(m, n) · εf32.norms.cc L83
PseudoInverse(a)V·diag(1/s)·Uᵀ, with singular values at or below the default rank tolerance treated as zero. pinv(A)·b is the minimum-norm least-squares solution for any shape and rank.norms.cc L91

The rank tolerance uses f32 epsilon even though the SVD runs in double, because the input was f32 and differences below its precision are noise (norms.cc L17–21).

Tests

FileTestsWhat they pin down
matrix_test.cc4Round trips through tensors, strided views and vectors, product, transpose and identity, and a mismatched product aborting.
gemm_test.cc6A known product; α, β and transposed views; β = 0 ignoring garbage in C; mismatched shapes aborting; BestGemmKernel never kAuto and always supported. Then every kernel (kAuto, portable, AVX2, AVX-512, each skipped when the CPU lacks it) against GemmRef, both through GemmWithKernel and through a PackedB, over 31 cases (L112–136): empty m, n or k, every transpose combination, single-row products, 2 to 25 rows through the streaming path with full and partial column tiles, more than 32 rows through the blocked driver, k = 513 across slice edges, few row blocks per thread so the column chunks are rebalanced, and 1 to 8 threads. The tolerance is 4e-6·(k + 1), and C has three padding columns that must be left untouched.
matmul_test.cc10Dot, matrix products, vector operands dropping their axis, a transposed view with no copy, broadcast batch axes, a matrix broadcast against a batch, a shared weight with contiguous, permuted and row-strided batches (folded and per-batch paths agreeing), many small attention-shaped products in parallel, a large product against MatMulRef, and mismatched shapes aborting.
lu_test.cc7P·A = L·U, and solve and inverse at sizes 1 to 33; a vector right-hand side; pivoting past a zero diagonal; known determinants; singular matrices detected; non-square input aborting.
qr_test.cc5Q orthonormal and R upper triangular for square, tall and wide shapes; a zero column; least squares recovering an exact linear model, leaving a residual orthogonal to the columns, and rejecting rank-deficient and wide inputs.
cholesky_test.cc4L·Lᵀ = A and solves at sizes 1 to 40, a known factor, and matrices that are not positive definite rejected.
eigen_test.cc4Reconstruction at sizes 1 to 25, a known spectrum, repeated eigenvalues, and an eigenvalue near 1e-9 resolved to relative precision.
svd_test.cc4Reconstruction for square, tall and wide shapes; known singular values; orthonormal factors for rank-deficient matrices; s² matching the eigenvalues of AᵀA.
norms_test.cc7Common vector norms and no overflow on large entries; known matrix norms; condition number; rank; pseudo-inverse on square and least-squares problems and the Penrose conditions when rank deficient.

How these run under sanitizers is on Testing.