Files
tensor/linalg/bench_factor_test.go
T
petrbalvin af4ee19703
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s
feat: initial release
Assisted-by: GLM 5.3 Flash
2026-09-03 10:00:00 +02:00

422 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package linalg
import (
"fmt"
"testing"
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// Benchmarks for the dense LU kernel, the sparse factorisations and the
// sparse rank-one sweep. Every input is built from fixed literals, so
// the patterns, the pivots and the orderings are the same on every run.
// denseLUInput builds an n×n row-major matrix whose rows view one flat
// buffer, with a pristine copy the benchmark restores from: the
// factorisation consumes its argument, and rebuilding the matrix inside
// the timed region would measure the rebuild.
func denseLUInput(n int) (rows [][]float64, pristine []float64) {
flat := make([]float64, n*n)
for i := range n {
for j := range n {
flat[i*n+j] = float64((i*5+j*11)%13) - 6
}
// Diagonal dominance keeps the elimination on the
// well-conditioned side, so the timing measures the kernel.
flat[i*n+i] += float64(2 * n)
}
rows = make([][]float64, n)
for i := range n {
rows[i] = flat[i*n : (i+1)*n]
}
pristine = make([]float64, len(flat))
copy(pristine, flat)
return rows, pristine
}
func restoreDenseRows(rows [][]float64, pristine []float64) {
off := 0
for _, row := range rows {
copy(row, pristine[off:off+len(row)])
off += len(row)
}
}
// BenchmarkDenseLUFactor measures the LU elimination alone at the sizes
// the solvers reach.
func BenchmarkDenseLUFactor(b *testing.B) {
for _, n := range []int{128, 256, 512} {
b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
rows, pristine := denseLUInput(n)
b.ReportAllocs()
for b.Loop() {
restoreDenseRows(rows, pristine)
base.Factor(rows)
}
})
}
}
// denseLUSolveInput builds the same matrix as a core array, for the
// end-to-end solve path.
func denseLUSolveInput(b *testing.B, n int) (*core.Array, *core.Array) {
b.Helper()
flat := make([]float64, n*n)
for i := range n {
for j := range n {
flat[i*n+j] = float64((i*5+j*11)%13) - 6
}
flat[i*n+i] += float64(2 * n)
}
a, err := core.FromFloats(flat, n, n)
if err != nil {
b.Fatal(err)
}
rhs := make([]float64, n)
for i := range rhs {
rhs[i] = float64(i%7) - 3
}
x, err := core.FromFloats(rhs, n)
if err != nil {
b.Fatal(err)
}
return a, x
}
// BenchmarkDenseLUSolve measures Solve end to end: the copy of the
// matrix, the factorisation, the permutation and the substitution.
func BenchmarkDenseLUSolve(b *testing.B) {
for _, n := range []int{128, 256, 512} {
b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
a, x := denseLUSolveInput(b, n)
b.ReportAllocs()
for b.Loop() {
if _, err := Solve(a, x); err != nil {
b.Fatal(err)
}
}
})
}
}
// sparseCOOFrom assembles a COO matrix from the triplets a builder
// appended, refusing nothing: the builders below emit canonical,
// duplicate-free triplets.
func sparseCOOFrom(b *testing.B, n int, idx []int64, vals []float64) *core.SparseCOO {
b.Helper()
indices, err := core.FromInts(idx, len(vals), 2)
if err != nil {
b.Fatal(err)
}
coo, err := core.NewSparseCOO(indices, floatsToArray(vals, []int{len(vals)}), []int{n, n})
if err != nil {
b.Fatal(err)
}
return coo
}
// gridLaplacian builds the 5-point Laplacian on a w×h grid in row-major
// order: symmetric positive definite, banded, and the pattern every
// sparse direct solver is measured on.
func gridLaplacian(b *testing.B, w, h int) *core.SparseCOO {
b.Helper()
n := w * h
idx := make([]int64, 0, 5*n)
vals := make([]float64, 0, 5*n)
add := func(r, c int, v float64) {
idx = append(idx, int64(r), int64(c))
vals = append(vals, v)
}
at := func(x, y int) int { return y*w + x }
for y := range h {
for x := range w {
add(at(x, y), at(x, y), 4)
if x+1 < w {
add(at(x, y), at(x+1, y), -1)
add(at(x+1, y), at(x, y), -1)
}
if y+1 < h {
add(at(x, y), at(x, y+1), -1)
add(at(x, y+1), at(x, y), -1)
}
}
}
return sparseCOOFrom(b, n, idx, vals)
}
// arrowHead builds the arrowhead matrix of order n: a diagonal plus a
// dense first row and column. The diagonal dominates the first arrow
// (n+1 against n−1), so the matrix is positive definite, and the
// ordering has a genuine choice to make on the arrow.
func arrowHead(b *testing.B, n int) *core.SparseCOO {
b.Helper()
idx := make([]int64, 0, 3*n)
vals := make([]float64, 0, 3*n)
add := func(r, c int, v float64) {
idx = append(idx, int64(r), int64(c))
vals = append(vals, v)
}
add(0, 0, float64(n+1))
for i := 1; i < n; i++ {
add(i, i, float64(i+2))
add(0, i, 1)
add(i, 0, 1)
}
return sparseCOOFrom(b, n, idx, vals)
}
// bandedNonsymmetric builds a banded, diagonally dominant matrix with a
// periodic spike below the diagonal: every tenth column has a
// subdiagonal entry larger than its diagonal, so the partial pivoting
// of the LU factorisation swaps rows and its label bookkeeping is
// exercised rather than measured cold.
func bandedNonsymmetric(b *testing.B, n int) *core.SparseCOO {
b.Helper()
idx := make([]int64, 0, 6*n)
vals := make([]float64, 0, 6*n)
add := func(r, c int, v float64) {
idx = append(idx, int64(r), int64(c))
vals = append(vals, v)
}
for i := range n {
d := 6.0
if i%10 == 3 {
d = 0.5 // the spike below takes this column's pivot
}
add(i, i, d)
if i+1 < n {
add(i, i+1, 1+0.25*float64(i%3))
add(i+1, i, 1.5+0.5*float64(i%5))
}
if i+3 < n {
add(i, i+3, 0.5)
}
}
return sparseCOOFrom(b, n, idx, vals)
}
// swapHeavyBanded builds a tridiagonal matrix that pivots on every
// column: a small diagonal against a large subdiagonal, so the largest
// entry at or below the diagonal is always the row below. The factor
// stays banded, so the measurement is the pivot bookkeeping rather
// than the elimination arithmetic.
func swapHeavyBanded(b *testing.B, n int) *core.SparseCOO {
b.Helper()
idx := make([]int64, 0, 3*n)
vals := make([]float64, 0, 3*n)
add := func(r, c int, v float64) {
idx = append(idx, int64(r), int64(c))
vals = append(vals, v)
}
for i := range n {
add(i, i, 0.125)
if i+1 < n {
add(i, i+1, 1)
add(i+1, i, 16)
}
}
return sparseCOOFrom(b, n, idx, vals)
}
// BenchmarkSparseLUFactorSwapped measures the elimination of a system
// that pivots at every column, so the cost of relabelling L's stored
// rows is part of the number.
func BenchmarkSparseLUFactorSwapped(b *testing.B) {
for _, n := range []int{100, 200, 400} {
coo := swapHeavyBanded(b, n)
b.Run(fmt.Sprintf("banded-%d", n), func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
if _, err := NewSparseLU(coo); err != nil {
b.Fatal(err)
}
}
})
}
}
// denseRHS builds a deterministic right hand side of length n.
func denseRHS(b *testing.B, n int) *core.Array {
b.Helper()
v := make([]float64, n)
for i := range v {
v[i] = float64(i%11) - 5 + 0.5*float64(i%3)
}
x, err := core.FromFloats(v, n)
if err != nil {
b.Fatal(err)
}
return x
}
// BenchmarkSparseCholeskyFactor measures the symbolic and numeric
// elimination of a few hundred unknowns: the Laplacian grid with the
// natural order, then the same grid through the reverse Cuthill-McKee
// order, which meets a much smaller factor.
func BenchmarkSparseCholeskyFactor(b *testing.B) {
coo := gridLaplacian(b, 19, 19)
b.Run("grid-natural", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
if _, err := NewSparseCholesky(coo, SparseOrderingNatural); err != nil {
b.Fatal(err)
}
}
})
b.Run("grid-rcm", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
if _, err := NewSparseCholesky(coo, SparseOrderingReverseCuthillMcKee); err != nil {
b.Fatal(err)
}
}
})
coo = arrowHead(b, 300)
b.Run("arrow-natural", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
if _, err := NewSparseCholesky(coo, SparseOrderingNatural); err != nil {
b.Fatal(err)
}
}
})
}
// BenchmarkSparseCholeskySolve measures the solve on a factor built
// once outside the loop: the gather, the two substitutions and the
// scatter.
func BenchmarkSparseCholeskySolve(b *testing.B) {
for _, size := range []struct {
name string
w, h int
ordering SparseOrdering
}{
{"grid-natural", 19, 19, SparseOrderingNatural},
{"grid-rcm", 19, 19, SparseOrderingReverseCuthillMcKee},
} {
coo := gridLaplacian(b, size.w, size.h)
f, err := NewSparseCholesky(coo, size.ordering)
if err != nil {
b.Fatal(err)
}
rhs := denseRHS(b, size.w*size.h)
b.Run(size.name, func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
if _, err := f.Solve(rhs); err != nil {
b.Fatal(err)
}
}
})
}
}
// BenchmarkSparseLUFactor measures the left-looking elimination of a
// banded nonsymmetric system with pivoting.
func BenchmarkSparseLUFactor(b *testing.B) {
for _, n := range []int{200, 400} {
coo := bandedNonsymmetric(b, n)
b.Run(fmt.Sprintf("banded-%d", n), func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
if _, err := NewSparseLU(coo); err != nil {
b.Fatal(err)
}
}
})
}
}
// BenchmarkSparseLUSolve measures the forward and backward substitution
// on a factor built once outside the loop.
func BenchmarkSparseLUSolve(b *testing.B) {
const n = 400
coo := bandedNonsymmetric(b, n)
f, err := NewSparseLU(coo)
if err != nil {
b.Fatal(err)
}
rhs := denseRHS(b, n)
b.Run("banded-400", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
if _, err := f.Solve(rhs); err != nil {
b.Fatal(err)
}
}
})
}
// sparseCholRankOneInput factors the tridiagonal Laplacian and returns
// the factor with a two-entry vector whose support sits on the stored
// pattern, so both the update and the downdate are accepted.
func sparseCholRankOneInput(b *testing.B, n int) (*SparseCholesky, *core.Array) {
b.Helper()
idx := make([]int64, 0, 3*n)
vals := make([]float64, 0, 3*n)
add := func(r, c int, v float64) {
idx = append(idx, int64(r), int64(c))
vals = append(vals, v)
}
for i := range n {
add(i, i, 4)
if i+1 < n {
add(i, i+1, -1)
add(i+1, i, -1)
}
}
coo := sparseCOOFrom(b, n, idx, vals)
f, err := NewSparseCholesky(coo, SparseOrderingNatural)
if err != nil {
b.Fatal(err)
}
x := core.New(core.Float, []int{n}...)
x.RawFloats()[0] = 0.5
x.RawFloats()[1] = 0.25
return f, x
}
// BenchmarkSparseCholRankOnePair measures one rank-one update followed
// by the matching downdate: each iteration leaves the factor where it
// found it, so the sweep is timed rather than the refactorisation the
// modification replaces.
func BenchmarkSparseCholRankOnePair(b *testing.B) {
for _, n := range []int{150, 300} {
f, x := sparseCholRankOneInput(b, n)
b.Run(fmt.Sprintf("tridiag-%d", n), func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
if err := f.Update(x); err != nil {
b.Fatal(err)
}
if err := f.Downdate(x); err != nil {
b.Fatal(err)
}
}
})
}
}
// BenchmarkSparseCholUpdateRefusal measures the refusal path: a vector
// whose support reaches outside the stored pattern, so every call
// returns the pattern error after the fill check has walked the support.
// It is the cost the check pays before it can refuse, and the error
// construction is deliberately part of it.
func BenchmarkSparseCholUpdateRefusal(b *testing.B) {
const n = 300
f, _ := sparseCholRankOneInput(b, n)
x := core.New(core.Float, []int{n}...)
x.RawFloats()[0] = 0.5
x.RawFloats()[n-1] = 0.25
b.ReportAllocs()
for b.Loop() {
if err := f.Update(x); err == nil {
b.Fatal("expected the update to need fill the pattern does not hold")
}
}
}