Files

432 lines
12 KiB
Go
Raw Permalink 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 base
import (
"fmt"
"sync"
"testing"
"time"
"sourcedock.dev/petrbalvin/tensor/internal/engine"
)
// The dispatch of Factor is the one place where a crew size, a chunk
// boundary and a spawn style can silently change the arithmetic, so the
// candidates live here beside a bit-identity test and the measurement
// that picks the constants. Every candidate must reproduce factorSerial
// byte for byte, and the production Factor must reproduce the
// parameterised mirror at the shipped constants.
func benchFactorRows(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*7+j*13)%11) - 5
}
flat[i*n+i] += float64(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 resetFactorRows(rows [][]float64, pristine []float64) {
off := 0
for _, row := range rows {
copy(row, pristine[off:off+len(row)])
off += len(row)
}
}
// factorSerial is Factor with the dispatch removed: the reference every
// candidate must match byte for byte.
func factorSerial(m [][]float64) ([]int, int) {
n := len(m)
perm := make([]int, n)
for i := range perm {
perm[i] = i
}
parity := 1
for k := range n {
pivot := k
for i := k + 1; i < n; i++ {
if absOf(m[i][k]) > absOf(m[pivot][k]) {
pivot = i
}
}
if m[pivot][k] == 0 {
continue
}
if pivot != k {
m[pivot], m[k] = m[k], m[pivot]
perm[pivot], perm[k] = perm[k], perm[pivot]
parity = -parity
}
factorRows(m[k+1:], k, n, m[k])
}
return perm, parity
}
// factorRetired is the dispatch Factor used before the crew moved onto
// preallocated slots: one goroutine per chunk through
// sync.WaitGroup.Go, so every chunk allocates a closure. It is the
// reference the current dispatch is measured against.
func factorRetired(m [][]float64, quantum int) ([]int, int) {
n := len(m)
perm := make([]int, n)
for i := range perm {
perm[i] = i
}
parity := 1
for k := range n {
pivot := k
for i := k + 1; i < n; i++ {
if absOf(m[i][k]) > absOf(m[pivot][k]) {
pivot = i
}
}
if m[pivot][k] == 0 {
continue
}
if pivot != k {
m[pivot], m[k] = m[k], m[pivot]
perm[pivot], perm[k] = perm[k], perm[pivot]
parity = -parity
}
pivotRow := m[k]
rows := m[k+1:]
work := len(rows) * (n - k)
w := min(work/quantum+1, engine.WorkersFor(len(rows)))
if w < 2 {
factorRows(rows, k, n, pivotRow)
continue
}
chunk := (len(rows) + w - 1) / w
if chunk < factorMinRows {
w = max(len(rows)/factorMinRows, 1)
chunk = (len(rows) + w - 1) / w
}
var wg sync.WaitGroup
for start := 0; start < len(rows); start += chunk {
end := min(start+chunk, len(rows))
wg.Go(func() {
factorRows(rows[start:end], k, n, pivotRow)
})
}
wg.Wait()
}
return perm, parity
}
// factorJobSlot is one crew member's preallocated work slot, mirroring
// factorJob for the float64 candidates.
type factorJobSlot struct {
rows [][]float64
pivotRow []float64
k, n int
start int
end int
wg *sync.WaitGroup
}
func factorSlotWorker(job *factorJobSlot) {
factorRows(job.rows[job.start:job.end], job.k, job.n, job.pivotRow)
job.wg.Done()
}
// factorCrew is the shipped dispatch with both tuning constants
// exposed.
func factorCrew(m [][]float64, quantum, maxCrew int) ([]int, int) {
n := len(m)
perm := make([]int, n)
for i := range perm {
perm[i] = i
}
parity := 1
var wg sync.WaitGroup
var jobs []factorJobSlot
for k := range n {
pivot := k
for i := k + 1; i < n; i++ {
if absOf(m[i][k]) > absOf(m[pivot][k]) {
pivot = i
}
}
if m[pivot][k] == 0 {
continue
}
if pivot != k {
m[pivot], m[k] = m[k], m[pivot]
perm[pivot], perm[k] = perm[k], perm[pivot]
parity = -parity
}
pivotRow := m[k]
rows := m[k+1:]
work := len(rows) * (n - k)
w := min(work/quantum+1, engine.WorkersFor(len(rows)))
w = min(w, maxCrew)
if w < 2 {
factorRows(rows, k, n, pivotRow)
continue
}
chunk := (len(rows) + w - 1) / w
if chunk < factorMinRows {
w = max(len(rows)/factorMinRows, 1)
chunk = (len(rows) + w - 1) / w
if w < 2 {
factorRows(rows, k, n, pivotRow)
continue
}
}
if jobs == nil {
jobs = make([]factorJobSlot, maxCrew)
}
spawned := 0
for start := 0; start < len(rows); start += chunk {
job := &jobs[spawned]
job.rows, job.pivotRow, job.k, job.n = rows, pivotRow, k, n
job.start, job.end, job.wg = start, min(start+chunk, len(rows)), &wg
spawned++
}
wg.Add(spawned)
for i := range spawned {
go factorSlotWorker(&jobs[i])
}
wg.Wait()
}
return perm, parity
}
// TestFactorDispatchBitIdentical proves every crew size, chunk boundary
// and spawn style leaves the factor and the permutation byte for byte
// as the serial reference, and that the shipped Factor matches the
// parameterised mirror the tuning benchmark measures.
func TestFactorDispatchBitIdentical(t *testing.T) {
for _, n := range []int{1, 2, 5, 16, 64, 129, 260} {
base, _ := benchFactorRows(n)
want := make([][]float64, n)
for i := range n {
want[i] = append([]float64(nil), base[i]...)
}
wp, wpar := factorSerial(want)
check := func(name string, got [][]float64, gp []int, gpar int) {
t.Helper()
if len(gp) != len(wp) {
t.Fatalf("%s: permutation length %d, want %d", name, len(gp), len(wp))
}
for i := range gp {
if gp[i] != wp[i] {
t.Fatalf("%s: permutation[%d] = %d, want %d", name, i, gp[i], wp[i])
}
}
for i := range got {
for j := range got[i] {
if got[i][j] != want[i][j] {
t.Fatalf("%s: factor[%d][%d] = %v, want %v", name, i, j, got[i][j], want[i][j])
}
}
}
if gpar != wpar {
t.Fatalf("%s: parity %d, want %d", name, gpar, wpar)
}
}
for _, q := range []int{1024, 8192, 65536} {
for _, w0 := range []int{1, 2, 4, 8, 16, 32} {
rows, _ := benchFactorRows(n)
gp, gpar := factorCrew(rows, q, w0)
check(fmt.Sprintf("crew q=%d w=%d n=%d", q, w0, n), rows, gp, gpar)
}
rows, _ := benchFactorRows(n)
gp, gpar := factorRetired(rows, q)
check(fmt.Sprintf("retired q=%d n=%d", q, n), rows, gp, gpar)
}
rows, _ := benchFactorRows(n)
gp, gpar := Factor(rows)
check(fmt.Sprintf("Factor n=%d", n), rows, gp, gpar)
mirror, _ := benchFactorRows(n)
mirrorGP, mirrorPar := factorCrew(mirror, factorWorkQuantum, factorMaxCrew)
check(fmt.Sprintf("mirror n=%d", n), mirror, mirrorGP, mirrorPar)
}
}
// BenchmarkForkJoinFloor measures an empty fork-join: the spawn, wake
// and join of w goroutines that touch nothing. It is the lower bound on
// what one dispatch costs, and the reason a pivot whose update is
// smaller than this cannot be worth splitting.
func BenchmarkForkJoinFloor(b *testing.B) {
for _, w := range []int{1, 2, 4, 8, 16, 32} {
b.Run(fmt.Sprintf("w=%d", w), func(b *testing.B) {
for b.Loop() {
var wg sync.WaitGroup
for range w {
wg.Go(func() {})
}
wg.Wait()
}
})
}
}
// BenchmarkFactorStylesAB interleaves the dispatch styles iteration by
// iteration on separate matrices, so a drift in the machine's speed
// lands on every style equally: the reported ns/op per style is the
// paired comparison, and the ns/op of the group as a whole is not
// comparable across groups.
func BenchmarkFactorStylesAB(b *testing.B) {
styles := []struct {
name string
run func(m [][]float64)
}{
{"serial", func(m [][]float64) { factorSerial(m) }},
{"retired/q=16384", func(m [][]float64) { factorRetired(m, 16384) }},
{"slots/q=8192/cap=16", func(m [][]float64) { factorCrew(m, 8192, 16) }},
{"slots/q=16384/cap=16", func(m [][]float64) { factorCrew(m, 16384, 16) }},
{"slots/q=24576/cap=16", func(m [][]float64) { factorCrew(m, 24576, 16) }},
{"slots/q=32768/cap=16", func(m [][]float64) { factorCrew(m, 32768, 16) }},
}
for _, n := range []int{256, 512} {
b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
rows := make([][][]float64, len(styles))
pristine := make([][]float64, len(styles))
for i := range styles {
rows[i], pristine[i] = benchFactorRows(n)
}
elapsed := make([]time.Duration, len(styles))
for b.Loop() {
for i, s := range styles {
resetFactorRows(rows[i], pristine[i])
start := time.Now()
s.run(rows[i])
elapsed[i] += time.Since(start)
}
}
for i, s := range styles {
b.ReportMetric(float64(elapsed[i].Nanoseconds())/float64(b.N), "ns/op-"+s.name)
}
})
}
}
// transposeFlatPlain is the untiled copy the tiled TransposeFlat
// replaces: the read runs along the rows, the write strides by the row
// length.
func transposeFlatPlain(a []float64, m, n int) []float64 {
out := make([]float64, m*n)
for i := range m {
for j := range n {
out[j*m+i] = a[i*n+j]
}
}
return out
}
// TestTransposeFlatTiledBitIdentical pins the tiled copy against the
// untiled one: a transpose is a permutation, so it must match value for
// value, and the tile boundary must not lose a corner of a ragged
// matrix.
func TestTransposeFlatTiledBitIdentical(t *testing.T) {
for _, dim := range [][2]int{{1, 1}, {1, 7}, {7, 1}, {16, 16}, {17, 16}, {16, 17}, {33, 5}, {5, 33}, {64, 64}} {
m, n := dim[0], dim[1]
a := make([]float64, m*n)
for i := range a {
a[i] = float64(i%13) - 6
}
got := TransposeFlat(a, m, n)
want := transposeFlatPlain(a, m, n)
if len(got) != len(want) {
t.Fatalf("%dx%d: length %d, want %d", m, n, len(got), len(want))
}
for i := range got {
if got[i] != want[i] {
t.Fatalf("%dx%d: entry %d = %v, want %v", m, n, i, got[i], want[i])
}
}
}
}
// BenchmarkTransposeFlatAB interleaves the tiled copy with the untiled
// one on separate buffers, so a drift in the machine's speed lands on
// both: the reported ns/op per style is the paired comparison.
func BenchmarkTransposeFlatAB(b *testing.B) {
for _, n := range []int{64, 256} {
b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
flat := make([]float64, n*n)
for i := range flat {
flat[i] = float64(i%17) - 8
}
var tiled, plain time.Duration
for b.Loop() {
start := time.Now()
out := TransposeFlat(flat, n, n)
tiled += time.Since(start)
if len(out) != len(flat) {
b.Fatal("transpose lost entries")
}
start = time.Now()
out = transposeFlatPlain(flat, n, n)
plain += time.Since(start)
if len(out) != len(flat) {
b.Fatal("transpose lost entries")
}
}
b.ReportMetric(float64(tiled.Nanoseconds())/float64(b.N), "ns/op-tiled")
b.ReportMetric(float64(plain.Nanoseconds())/float64(b.N), "ns/op-plain")
})
}
}
// BenchmarkTransposeFlat measures the tiled transpose alone.
func BenchmarkTransposeFlat(b *testing.B) {
for _, n := range []int{64, 256} {
flat := make([]float64, n*n)
for i := range flat {
flat[i] = float64(i%17) - 8
}
b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
out := TransposeFlat(flat, n, n)
if len(out) != len(flat) {
b.Fatal("transpose lost entries")
}
}
})
}
}
// BenchmarkSolveSystemColumns measures the column loop of SolveSystem
// with one column and with several: the several-column path permutes
// each column through a bitmap the crew reuses, which is what the
// one-column path does too.
func BenchmarkSolveSystemColumns(b *testing.B) {
const n = 256
for _, cols := range []int{1, 8} {
b.Run(fmt.Sprintf("cols=%d", cols), func(b *testing.B) {
rhs := make([][]float64, cols)
for c := range rhs {
rhs[c] = make([]float64, n)
for i := range rhs[c] {
rhs[c][i] = float64((i+c)%9) - 4
}
}
b.ReportAllocs()
for b.Loop() {
m, _ := benchFactorRows(n)
work := make([][]float64, cols)
for c := range work {
work[c] = append([]float64(nil), rhs[c]...)
}
if _, err := SolveSystem("Solve", m, work); err != nil {
b.Fatal(err)
}
}
})
}
}