Files
tensor/linalg/bench_decomp_test.go
T

513 lines
13 KiB
Go
Raw Normal View History

2026-09-03 10:00:00 +02:00
// 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/core"
"sourcedock.dev/petrbalvin/tensor/internal/engine"
)
// Decomposition benchmarks for the shapes the solver benchmarks in
// perf_bench_test.go do not cover: the tall least-squares system, the
// pivoted QR sweep, the complex and matrix-function paths, and the SVD
// shapes whose cost sits in the reconstruction kernels rather than in
// the bidiagonalisation. Every input is a fixed literal formula, so the
// timings compare like with like across runs.
// benchDecompFloats fills an m×n row-major matrix with a deterministic
// literal pattern: a diagonally weighted band plus a bounded pseudo-random
// ripple.
func benchDecompFloats(m, n int, diagonal float64) []float64 {
v := make([]float64, m*n)
for i := range m {
for j := range n {
v[i*n+j] = 0.5*float64((i*11+j*7)%17) - 4 + 0.25*float64((i*j*5)%13)
}
v[i*n+i%n] += diagonal
}
return v
}
// benchDecompSPD builds a symmetric strictly diagonally dominant
// matrix, which is positive definite however the ripple lands.
func benchDecompSPD(n int) []float64 {
v := make([]float64, n*n)
for i := range n {
for j := range n {
v[i*n+j] = float64((i*5+j*3)%9) - 4
}
}
for i := range n {
for j := i + 1; j < n; j++ {
avg := (v[i*n+j] + v[j*n+i]) / 2
v[i*n+j], v[j*n+i] = avg, avg
}
row := 0.0
for j := range n {
if j != i {
row += v[i*n+j]
if v[i*n+j] < 0 {
row += 2 * -v[i*n+j]
}
}
}
v[i*n+i] += row + float64(n)
}
return v
}
// benchDecompHermitian builds a Hermitian matrix with a real diagonal
// whose entries are well separated, so the Jacobi sweep deflates
// quickly.
func benchDecompHermitian(n int) []complex128 {
v := make([]complex128, n*n)
for i := range n {
v[i*n+i] = complex(4*float64(i)+8, 0)
for j := i + 1; j < n; j++ {
re := 0.5*float64((i*3+j*5)%7) - 1.5
im := 0.25*float64((i+j)%5) - 0.5
v[i*n+j] = complex(re, im)
v[j*n+i] = complex(re, -im)
}
}
return v
}
func benchDecompArray(b *testing.B, v []float64, m, n int) *core.Array {
b.Helper()
a, err := core.FromFloats(v, m, n)
if err != nil {
b.Fatal(err)
}
return a
}
func benchDecompComplexArray(b *testing.B, v []complex128, m, n int) *core.Array {
b.Helper()
a, err := core.FromComplexes(v, m, n)
if err != nil {
b.Fatal(err)
}
return a
}
// BenchmarkCholesky512 measures the blocked Cholesky sweep above the
// size where the solver benchmark's 256 stops.
func BenchmarkCholesky512(b *testing.B) {
a := benchDecompArray(b, benchDecompSPD(512), 512, 512)
b.ReportAllocs()
for b.Loop() {
if _, err := Cholesky(a); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkLeastSquares512x32 measures the tall least-squares route,
// where the QR factor's orthogonal accumulation dominates.
func BenchmarkLeastSquares512x32(b *testing.B) {
const m, n = 512, 32
a := benchDecompArray(b, benchDecompFloats(m, n, 3), m, n)
rhs, err := core.FromFloats(benchDecompFloats(m, 1, 1), m)
if err != nil {
b.Fatal(err)
}
b.ReportAllocs()
for b.Loop() {
if _, err := LeastSquares(a, rhs); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkRRQR192x48 measures the pivoted QR sweep, whose cost is the
// per-step column-norm scan and the reflector application.
func BenchmarkRRQR192x48(b *testing.B) {
const m, n = 192, 48
a := benchDecompArray(b, benchDecompFloats(m, n, 3), m, n)
b.ReportAllocs()
for b.Loop() {
if _, _, _, _, err := RRQR(a); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkRRQR128x32 measures the pivoted QR sweep at the size where a
// single reflector's work is small enough that crew sizing decides it.
func BenchmarkRRQR128x32(b *testing.B) {
const m, n = 128, 32
a := benchDecompArray(b, benchDecompFloats(m, n, 3), m, n)
b.ReportAllocs()
for b.Loop() {
if _, _, _, _, err := RRQR(a); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkSVDReconstruct1024x16 measures the SVD shapes whose cost sits
// in the reconstruction kernels: the full QR factor is far larger than
// the bidiagonalisation.
func BenchmarkSVDReconstruct1024x16(b *testing.B) {
const m, n = 1024, 16
a := benchDecompArray(b, benchDecompFloats(m, n, 2), m, n)
b.ReportAllocs()
for b.Loop() {
if _, _, _, err := SVD(a); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkEigenComplex8 measures the complex Hermitian Jacobi sweep at
// the largest size its convergence floor admits: the sweep's off-mass
// threshold sits below the rounding floor the rotations leave behind
// above 8, so a bigger input errors out before it measures anything.
func BenchmarkEigenComplex8(b *testing.B) {
const n = 8
a := benchDecompComplexArray(b, benchDecompHermitian(n), n, n)
b.ReportAllocs()
for b.Loop() {
if _, _, err := EigenComplex(a); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkSVDComplex96x48 measures the direct complex bidiagonalisation
// and its Golub-Reinsch sweep.
func BenchmarkSVDComplex96x48(b *testing.B) {
const m, n = 96, 48
flat := benchDecompFloats(m, n, 2)
v := make([]complex128, m*n)
for i := range m * n {
v[i] = complex(flat[i], 0.5*float64((i*3)%7)-1.5)
}
a := benchDecompComplexArray(b, v, m, n)
b.ReportAllocs()
for b.Loop() {
if _, _, _, err := SVDComplex(a); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkEigenGeneral32 measures the Hessenberg reduction, the shifted
// QR sweep and the eigenvector back-substitution on a general matrix.
func BenchmarkEigenGeneral32(b *testing.B) {
const n = 32
v := benchDecompFloats(n, n, 4)
a := benchDecompArray(b, v, n, n)
b.ReportAllocs()
for b.Loop() {
if _, _, err := EigenGeneral(a); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkSchurComplex48 measures the complex Schur decomposition the
// matrix functions are built on.
func BenchmarkSchurComplex48(b *testing.B) {
const n = 48
flat := benchDecompFloats(n, n, 3)
v := make([]complex128, n*n)
for i := range n * n {
v[i] = complex(flat[i], 0.25*float64((i*5)%9)-1)
}
a := benchDecompComplexArray(b, v, n, n)
b.ReportAllocs()
for b.Loop() {
if _, _, err := SchurComplex(a); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkMatrixExp32 measures the Padé scaling-and-squaring kernel.
func BenchmarkMatrixExp32(b *testing.B) {
const n = 32
v := benchDecompFloats(n, n, 0)
v[0] = -0.5 // keep the 1-norm inside the unscaled degrees
a := benchDecompArray(b, v, n, n)
b.ReportAllocs()
for b.Loop() {
if _, err := MatrixExp(a); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkMatrixSqrt64 measures the symmetric eigen route of a matrix
// function, whose cost is the eigendecomposition plus two triple
// products.
func BenchmarkMatrixSqrt64(b *testing.B) {
const n = 64
a := benchDecompArray(b, benchDecompSPD(n), n, n)
b.ReportAllocs()
for b.Loop() {
if _, err := MatrixSqrt(a); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkMatrixLogSchur32 measures the Schur-Parlett logarithm, the
// square-root walk and the Mercator series included.
func BenchmarkMatrixLogSchur32(b *testing.B) {
const n = 32
flat := benchDecompSPD(n)
v := benchDecompComplexSPD(flat, n)
a := benchDecompComplexArray(b, v, n, n)
b.ReportAllocs()
for b.Loop() {
if _, err := MatrixLog(a); err != nil {
b.Fatal(err)
}
}
}
// benchDecompComplexSPD lifts a symmetric positive definite real matrix
// to complex with a small positive imaginary part on the strict upper
// triangle and its conjugate below, keeping the spectrum off the
// non-positive real axis.
func benchDecompComplexSPD(flat []float64, n int) []complex128 {
v := make([]complex128, n*n)
for i := range n {
for j := range n {
v[i*n+j] = complex(flat[i*n+j], 0)
}
}
for i := range n {
for j := i + 1; j < n; j++ {
im := 0.05 * float64((i+j)%4+1)
v[i*n+j] = complex(flat[i*n+j], im)
v[j*n+i] = complex(flat[j*n+i], -im)
}
}
return v
}
// BenchmarkTikhonov256x64 measures the SVD solve route, whose cost is a
// full SVD of the system matrix plus the Uᵀb and V·d products.
func BenchmarkTikhonov256x64(b *testing.B) {
const m, n = 256, 64
a := benchDecompArray(b, benchDecompFloats(m, n, 2), m, n)
rhs, err := core.FromFloats(benchDecompFloats(m, 1, 1), m)
if err != nil {
b.Fatal(err)
}
b.ReportAllocs()
for b.Loop() {
if _, err := SolveTikhonov(a, rhs, 0.5); err != nil {
b.Fatal(err)
}
}
}
// TestDecompWorkerCountBitIdentity pins the parallel splits: every
// kernel this file benchmarks must answer bit for bit the same whether
// the crew is one goroutine or the machine's full width. A split that
// moved an addend or a boundary shows up here before the oracle's
// smaller pinned shapes see it.
func TestDecompWorkerCountBitIdentity(t *testing.T) {
prev := engine.SetNumWorkers(1)
defer engine.SetNumWorkers(prev)
const (
cholN = 320
lsM = 384
lsN = 24
rrM = 160
rrN = 40
svdM = 384
svdN = 24
eigN = 192
cplxN = 8
)
spd := benchDecompSPD(cholN)
tall := benchDecompFloats(lsM, lsN, 3)
rr := benchDecompFloats(rrM, rrN, 3)
wide := benchDecompFloats(svdM, svdN, 2)
herm := benchDecompHermitian(cplxN)
square := benchDecompSPD(eigN)
complexSquare := make([]complex128, cplxN*cplxN)
flatComplex := benchDecompFloats(cplxN, cplxN, 3)
for i := range cplxN * cplxN {
complexSquare[i] = complex(flatComplex[i], 0.25*float64((i*5)%9)-1)
}
spdA := mustFromFloats(t, spd, cholN, cholN)
tallA := mustFromFloats(t, tall, lsM, lsN)
rrA := mustFromFloats(t, rr, rrM, rrN)
wideA := mustFromFloats(t, wide, svdM, svdN)
hermA := mustFromComplexes(t, herm, cplxN, cplxN)
squareA := mustFromFloats(t, square, eigN, eigN)
complexA := mustFromComplexes(t, complexSquare, cplxN, cplxN)
rhs := mustFromFloats(t, benchDecompFloats(lsM, 1, 1), lsM)
rrRHS := mustFromFloats(t, benchDecompFloats(rrM, 1, 1), rrM)
// A tall system wide enough that the blocked Qᵀb dispatches a crew,
// so the split is compared against the serial order too.
const lsWideN = 48
wideLS := mustFromFloats(t, benchDecompFloats(lsM, lsWideN, 3), lsM, lsWideN)
type snap struct {
name string
run func() []*core.Array
}
cases := []snap{
{"Cholesky", func() []*core.Array {
l, err := Cholesky(spdA)
if err != nil {
t.Fatal(err)
}
return []*core.Array{l}
}},
{"LeastSquares", func() []*core.Array {
x, err := LeastSquares(tallA, rhs)
if err != nil {
t.Fatal(err)
}
return []*core.Array{x}
}},
{"LeastSquaresBlockedQtb", func() []*core.Array {
x, err := LeastSquares(wideLS, rhs)
if err != nil {
t.Fatal(err)
}
return []*core.Array{x}
}},
{"RRQR", func() []*core.Array {
q, r, perm, rank, err := RRQR(rrA)
if err != nil {
t.Fatal(err)
}
pf := make([]float64, len(perm)+1)
for i, p := range perm {
pf[i] = float64(p)
}
pf[len(perm)] = float64(rank)
return []*core.Array{q, r, mustFromFloats(t, pf, len(pf), 1)}
}},
{"SolveRRQR", func() []*core.Array {
x, err := SolveRRQR(rrA, rrRHS)
if err != nil {
t.Fatal(err)
}
return []*core.Array{x}
}},
{"SVD", func() []*core.Array {
u, s, vt, err := SVD(wideA)
if err != nil {
t.Fatal(err)
}
return []*core.Array{u, s, vt}
}},
{"Eigen", func() []*core.Array {
v, q, err := Eigen(squareA)
if err != nil {
t.Fatal(err)
}
return []*core.Array{v, q}
}},
{"EigenComplex", func() []*core.Array {
v, q, err := EigenComplex(hermA)
if err != nil {
t.Fatal(err)
}
return []*core.Array{v, q}
}},
{"SVDComplex", func() []*core.Array {
u, s, vh, err := SVDComplex(complexA)
if err != nil {
t.Fatal(err)
}
return []*core.Array{u, s, vh}
}},
}
serial := make([][]*core.Array, len(cases))
for i, c := range cases {
serial[i] = c.run()
}
engine.SetNumWorkers(0) // the machine's full width
for i, c := range cases {
got := c.run()
for k := range got {
if got[k].Len() != serial[i][k].Len() {
t.Fatalf("%s: result %d length %d under the full crew, %d serial",
c.name, k, got[k].Len(), serial[i][k].Len())
}
if !rawBitsEqual(got[k], serial[i][k]) {
t.Fatalf("%s: result %d differs bitwise between the serial and parallel crew", c.name, k)
}
}
}
}
// rawBitsEqual compares two arrays' payloads bit for bit, complex
// payloads included.
func rawBitsEqual(a, b *core.Array) bool {
if a.Dtype() == core.Complex || b.Dtype() == core.Complex {
ac, bc := a.RawComplexes(), b.RawComplexes()
if len(ac) != len(bc) {
return false
}
for i := range ac {
if ac[i] != bc[i] {
return false
}
}
return true
}
af, bf := a.RawFloats(), b.RawFloats()
if len(af) != len(bf) {
return false
}
for i := range af {
if af[i] != bf[i] {
return false
}
}
return true
}
// BenchmarkLeastSquaresTall measures the solve at the shapes the
// reflector route exists for: many more rows than columns, where forming
// Q would dominate everything else.
func BenchmarkLeastSquaresTall(b *testing.B) {
for _, c := range []struct{ m, n int }{{512, 32}, {2048, 64}, {8192, 16}} {
a := make([]float64, c.m*c.n)
s := uint64(20260920)
for i := range a {
s = s*6364136223846793005 + 1442695040888963407
a[i] = float64((s>>40)%9+1) * 0.5
}
bm := make([]float64, c.m)
for i := range bm {
bm[i] = float64(i%11) - 5
}
am, err := core.FromFloats(a, c.m, c.n)
if err != nil {
b.Fatal(err)
}
bv, err := core.FromFloats(bm, c.m, 1)
if err != nil {
b.Fatal(err)
}
b.Run(fmt.Sprintf("%dx%d", c.m, c.n), func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
if _, err := LeastSquares(am, bv); err != nil {
b.Fatal(err)
}
}
})
}
}