feat: initial release
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-03 10:00:00 +02:00
commit af4ee19703
617 changed files with 191195 additions and 0 deletions
+428
View File
@@ -0,0 +1,428 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// Package base holds the low-level primitives the library's domain
// packages share: error construction, shape formatting, the machine
// epsilon and the generic LU factorisation the solvers build on. It
// touches no arrays, so every package in the module can depend on it
// without a cycle.
package base
import (
"fmt"
"math"
"math/cmplx"
"strconv"
"strings"
"sync"
"sourcedock.dev/petrbalvin/tensor/internal/engine"
)
// EpsF is the float64 machine epsilon.
const EpsF = 2.220446049250313e-16
// Errf builds a package-prefixed error: every error the library
// returns starts with "tensor: ", whatever package raised it.
func Errf(format string, args ...any) error {
return fmt.Errorf("tensor: "+format, args...)
}
// WrapErr wraps an error the library already prefixed, naming the
// operation that surfaces it. The chain stays intact for errors.Is and
// errors.As, and the message carries the tensor tag and the operation
// once each instead of the doubled prefix a plain Errf wrap of a
// prefixed error prints.
func WrapErr(operation string, err error) error {
if err == nil {
return nil
}
return &wrappedErr{op: operation, err: err}
}
type wrappedErr struct {
op string
err error
}
func (e *wrappedErr) Error() string {
return "tensor: " + e.op + ": " + strings.TrimPrefix(e.err.Error(), "tensor: ")
}
func (e *wrappedErr) Unwrap() error { return e.err }
// ShapeText renders a shape as (n, m, ...).
func ShapeText(shape []int) string {
if len(shape) == 0 {
return "scalar"
}
parts := make([]string, len(shape))
for i, d := range shape {
parts[i] = strconv.Itoa(d)
}
return "(" + strings.Join(parts, ", ") + ")"
}
// AbsComplex returns |z|, the magnitude of a complex number. It is
// math.Hypot rather than sqrt(r² + i²), which overflows above about
// 1.34e154 and underflows below about 1.5e-162 even though the true
// magnitude is an ordinary number there.
func AbsComplex(z complex128) float64 {
return math.Hypot(real(z), imag(z))
}
// RangeN returns 0..n-1.
func RangeN(n int) []int {
out := make([]int, n)
for i := range out {
out[i] = i
}
return out
}
// CmplxPolar builds a complex number from magnitude and angle.
func CmplxPolar(r, theta float64) complex128 {
return complex(r*math.Cos(theta), r*math.Sin(theta))
}
// Real2 returns |z|², the squared magnitude of a complex number.
func Real2(z complex128) float64 {
return real(z)*real(z) + imag(z)*imag(z)
}
// transposeBlock is the tile side of TransposeFlat: the destination
// column segment a tile writes spans transposeBlock consecutive
// entries of the tile's own rows, which keeps the strided side of the
// copy inside the cache instead of paying a miss per element.
const transposeBlock = 16
// TransposeFlat transposes a row-major m×n matrix held flat. The copy
// is tiled, so the strided side walks transposeBlock rows at a time
// rather than the whole matrix. A pure permutation of values, so the
// tiling cannot move a bit.
func TransposeFlat(a []float64, m, n int) []float64 {
out := make([]float64, m*n)
for i0 := 0; i0 < m; i0 += transposeBlock {
i1 := min(i0+transposeBlock, m)
for j0 := 0; j0 < n; j0 += transposeBlock {
j1 := min(j0+transposeBlock, n)
for i := i0; i < i1; i++ {
row := a[i*n+j0 : i*n+j1]
dst := j0*m + i
_ = out[dst] // bounds-check proof: the first tile entry
for _, v := range row {
out[dst] = v
dst += m
}
}
}
}
return out
}
// Scalar is the element type the generic linear algebra serves.
type Scalar interface {
float64 | float32 | complex128
}
// factorWorkQuantum is the element-update budget one worker receives in
// a rank-1 update dispatch, counted as rows remaining × columns
// remaining. A pivot whose update does not fill one quantum runs on the
// calling goroutine: the dispatch would cost more than the update. The
// crew is sized by the work, never by the machine, because the updates
// shrink every pivot and a wide dispatch of a small update costs more
// than it saves. Measured over the rank-1 update alone at the sizes the
// solvers reach, the best crew shrinks with the quantum and the curve is
// flat between 8 192 and 49 152, one size preferring a smaller crew and
// the next a larger one; this value keeps both within a few percent of
// their own optimum. The pivot SEARCH and the row SWAP stay strictly
// serial: they pick the pivot every update reads, and their order
// defines the factorisation.
const factorWorkQuantum = 24576
// factorSpawnFloor is the update size from which any crew may spawn.
// Crew SIZE above it stays tied to factorWorkQuantum; below it the whole
// update runs on the calling goroutine. The paired crossover probe
// (BenchmarkFactorCrossoverAB) measures the lone walk ahead of every
// crew through n = 384, whose largest update is 147 072, and the shipped
// crew ahead of the lone walk from n = 448, whose largest update is
// 200 256; the floor sits between the two measured updates. The crew
// size never moves a bit: every row keeps the serial per-row sequence
// whatever dispatch carries it (TestFactorDispatchBitIdentical).
const factorSpawnFloor = 147456
// factorMaxCrew caps the crew a single dispatch may use. Past this the
// per-goroutine spawn and join cost outgrows the update it spreads: an
// empty fork-join of w goroutines costs about 0.2 µs per goroutine, so a
// 512² pivot on a 16-way crew already pays more in synchronisation than
// the last worker's chunk earns.
const factorMaxCrew = 16
// factorMinRows is the floor on rows per worker for the rank-1 update;
// fewer rows per goroutine than this only adds scheduling latency.
const factorMinRows = 4
// factorJob is one crew member's slice of a pivot's rank-1 update: the
// operands every member shares and the row range this member owns. The
// jobs are allocated once per Factor call and rewritten in place per
// pivot, so a dispatch allocates the goroutine's argument frame only,
// never a closure.
type factorJob[T Scalar] struct {
rows [][]T
pivotRow []T
k, n int
start int
end int
wg *sync.WaitGroup
}
// factorUpdate runs one job through the serial per-row sequence, the
// same call the inline path makes, so the crew size never moves a bit.
func factorUpdate[T Scalar](job *factorJob[T]) {
factorRows(job.rows[job.start:job.end], job.k, job.n, job.pivotRow)
job.wg.Done()
}
// factorRows subtracts f·pivotRow from every row of rows, the rank-1
// update of one pivot: f is the row's multiplier, then the products are
// subtracted along j ascending. The whole family of entry points shares
// this one function, which is what makes the parallel result
// bit-identical to the serial one.
func factorRows[T Scalar](rows [][]T, k, n int, pivotRow []T) {
pk := pivotRow[k]
_ = pivotRow[n-1] // bounds-check proof: every pivot row spans n entries
for _, row := range rows {
f := row[k] / pk
row[k] = f
_ = row[n-1] // bounds-check proof: every row spans n entries
for j := k + 1; j < n; j++ {
row[j] -= f * pivotRow[j]
}
}
}
// Factor factors a row-major square matrix in place with partial
// pivoting, returning the row permutation and the parity of the
// permutation. The stored subdiagonal holds the elimination factors,
// so a zero pivot column is skipped and reported through the zero
// diagonal the singular check reads.
//
// The elimination is parallelised per pivot over the rows below it.
// Every row's update reads only the pivot row, which is final once the
// search and swap have run, and writes only its own row: each element
// is updated exactly once per pivot with the same operands and in the
// same order as the serial loop (f, then f·pivot[j] subtracted along
// j ascending), so the result is bit-identical for either element
// type, complex128 included.
func Factor[T Scalar](m [][]T) ([]int, int) {
n := len(m)
perm := make([]int, n)
for i := range perm {
perm[i] = i
}
parity := 1
var wg sync.WaitGroup
var jobs []factorJob[T]
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 // the whole column is zero: singular from here on
}
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:]
// Crew sized by the update's size, not by the machine: the
// updates shrink every pivot, and a 32-goroutine dispatch of a
// small update costs more than it saves. Below one quantum the
// update cannot fund even a two-way split, and below the spawn
// floor no crew at all earns its fork-join, so the update runs
// inline.
work := len(rows) * (n - k)
w := min(work/factorWorkQuantum+1, engine.WorkersFor(len(rows)))
w = min(w, factorMaxCrew)
if work < factorSpawnFloor {
w = 1
}
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
}
}
// Each row's update reads only the pivot row, which is final
// once the search and swap have run, and writes only its own
// row: the per-row sequence (f, then f·pivotRow[j] subtracted
// along j ascending) is the serial one, so the crew size and
// the chunk boundaries never move a bit. The crew runs over
// preallocated slots, so a dispatch allocates the goroutine's
// argument frame and nothing else.
if jobs == nil {
jobs = make([]factorJob[T], factorMaxCrew)
}
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 factorUpdate(&jobs[i])
}
wg.Wait()
}
return perm, parity
}
// CheckSingular reports an error when a factored matrix carries a
// zero pivot.
func CheckSingular[T Scalar](name string, m [][]T) error {
for i := range m {
if m[i][i] == 0 {
return Errf("%s: matrix is singular", name)
}
}
return nil
}
// SolveColumn back-substitutes one right-hand column through the
// factored matrix. It is a recurrence along the column (each col[i]
// reads every earlier entry) and is intentionally serial; the hoisted
// row slices and the index proofs only strip bounds checks, the
// per-element operation sequence is untouched.
func SolveColumn[T Scalar](m [][]T, col []T) {
n := len(m)
if n == 0 {
return
}
_ = col[n-1] // bounds-check proof: every index below stays in range
for i := 1; i < n; i++ {
row := m[i]
_ = row[i-1]
ci := col[i]
for j := range i {
ci -= row[j] * col[j]
}
col[i] = ci
}
for i := n - 1; i >= 0; i-- {
row := m[i]
_ = row[n-1]
ci := col[i]
for j := i + 1; j < n; j++ {
ci -= row[j] * col[j]
}
col[i] = ci / m[i][i]
}
}
// PermuteColumn reorders col in place so that col[i] takes the value
// that sat at perm[i]. The permutation is applied by walking the cycles
// of perm: every value moves exactly once with plain assignment, bit
// for bit, and perm itself is never written (callers reuse it across
// columns). The only allocation is the visit bitmap, not a copy of the
// column.
func PermuteColumn[T Scalar](col []T, perm []int) {
permuteColumn(col, perm, make([]bool, len(col)))
}
// permuteColumn is PermuteColumn with the caller supplying the visit
// bitmap, so a caller solving many columns can reuse one bitmap instead
// of allocating one per column. The bitmap is cleared on entry, so a
// dirty buffer behaves exactly like a fresh one.
func permuteColumn[T Scalar](col []T, perm []int, visited []bool) {
clear(visited[:len(col)])
for i := range col {
if visited[i] || perm[i] == i {
visited[i] = true
continue
}
// Carry the displaced value around the cycle.
tmp := col[i]
j := i
for {
visited[j] = true
k := perm[j]
if k == i {
break
}
col[j] = col[k]
j = k
}
col[j] = tmp
}
}
// SolveSystem factors a fresh copy-safe matrix, rejects singularity
// and solves every right-hand column in one pass: each column is
// permuted the way factoring moved the rows, then substitutes through
// L and U. m is consumed in place; callers hand over freshly built
// matrices.
//
// With several right-hand columns the substitution runs in parallel
// over the columns: a column's permutation and back-substitution read
// only the factored matrix and write only that column, so each column
// runs the identical serial sequence on identical inputs, and the
// results are bit-identical to solving the columns one after another.
// SolveColumn itself is a recurrence along the column and stays
// serial.
func SolveSystem[T Scalar](name string, m [][]T, rhs [][]T) ([][]T, error) {
perm, _ := Factor(m)
if err := CheckSingular(name, m); err != nil {
return nil, err
}
if len(rhs) > 1 {
// Each crew member owns one visit bitmap reused across its
// columns: permuteColumn clears it on entry, so the walk sees
// a fresh bitmap without an allocation per column. Bitmaps are
// never shared between crew members, so there is no race.
engine.ParallelMin(len(rhs), 1, func(start, end int) {
visited := make([]bool, len(m))
for _, col := range rhs[start:end] {
permuteColumn(col, perm, visited)
SolveColumn(m, col)
}
})
return rhs, nil
}
// The serial path reuses one bitmap across the columns for the
// same reason the parallel path does: permuteColumn clears it on
// entry, so a dirty buffer behaves exactly like a fresh one.
visited := make([]bool, len(m))
for _, col := range rhs {
permuteColumn(col, perm, visited)
SolveColumn(m, col)
}
return rhs, nil
}
func absOf[T Scalar](v T) float64 {
switch x := any(v).(type) {
case float64:
return math.Abs(x)
case float32:
return math.Abs(float64(x))
case complex128:
return cmplx.Abs(x)
}
// Every type the Scalar constraint admits is handled above; the
// clause is what tells the compiler so.
return 0
}
+431
View File
@@ -0,0 +1,431 @@
// 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)
}
}
})
}
}
+189
View File
@@ -0,0 +1,189 @@
// 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"
)
// Paired crossover probe for Factor's dispatch: the serial elimination
// against the shipped crew constants on the same deterministic matrix,
// interleaved iteration by iteration so a drift in the machine's speed
// lands on both styles equally. The reported ns/op per style is the
// paired comparison; the ns/op of the group as a whole is not
// comparable across groups. The spawn floor factorSpawnFloor sits where
// the crew starts winning.
// probeFactorRows builds the deterministic n×n fixture: a diagonally
// dominant integer pattern every style factors identically.
func probeFactorRows(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 probeFactorReset(rows [][]float64, pristine []float64) {
off := 0
for _, row := range rows {
copy(row, pristine[off:off+len(row)])
off += len(row)
}
}
// probeFactorSerial is Factor with the dispatch removed: the reference
// every candidate must match byte for byte.
func probeFactorSerial(m [][]float64) {
n := len(m)
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]
}
factorRows(m[k+1:], k, n, m[k])
}
}
// probeFactorJob mirrors factorJob for the probe's own dispatch, so the
// probe keeps compiling whatever the production type later gains.
type probeFactorJob struct {
rows [][]float64
pivotRow []float64
k, n int
start int
end int
wg *sync.WaitGroup
}
func probeFactorWorker(job *probeFactorJob) {
factorRows(job.rows[job.start:job.end], job.k, job.n, job.pivotRow)
job.wg.Done()
}
// probeFactorCrew mirrors Factor's dispatch at the production constants,
// which it reads directly: the quantum sizes the crew, the spawn floor
// decides whether any crew runs, and the per-row update is the same
// factorRows call the serial reference makes.
func probeFactorCrew(m [][]float64) {
n := len(m)
var wg sync.WaitGroup
var jobs []probeFactorJob
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]
}
pivotRow := m[k]
rows := m[k+1:]
work := len(rows) * (n - k)
w := min(work/factorWorkQuantum+1, engine.WorkersFor(len(rows)))
w = min(w, factorMaxCrew)
if work < factorSpawnFloor {
w = 1
}
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([]probeFactorJob, factorMaxCrew)
}
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 probeFactorWorker(&jobs[i])
}
wg.Wait()
}
}
// TestFactorSpawnFloorBitIdentical pins the crew mirror against the
// serial reference at the sizes the crossover probe sweeps, whatever
// the dispatch decides: crew size never moves a bit.
func TestFactorSpawnFloorBitIdentical(t *testing.T) {
for _, n := range []int{2, 3, 16, 64, 256, 320, 384} {
want, _ := probeFactorRows(n)
probeFactorSerial(want)
got, _ := probeFactorRows(n)
probeFactorCrew(got)
for i := range n {
for j := range n {
if got[i][j] != want[i][j] {
t.Fatalf("n=%d factor[%d][%d] = %v, want %v", n, i, j, got[i][j], want[i][j])
}
}
}
}
}
// BenchmarkFactorCrossoverAB interleaves the serial elimination with the
// shipped dispatch iteration by iteration across the sizes around the
// spawn floor.
func BenchmarkFactorCrossoverAB(b *testing.B) {
for _, n := range []int{256, 320, 384, 448, 512} {
b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
rowsS, prS := probeFactorRows(n)
rowsC, prC := probeFactorRows(n)
var tSerial, tCrew time.Duration
for b.Loop() {
probeFactorReset(rowsS, prS)
probeFactorReset(rowsC, prC)
start := time.Now()
probeFactorSerial(rowsS)
tSerial += time.Since(start)
start = time.Now()
probeFactorCrew(rowsC)
tCrew += time.Since(start)
}
b.ReportMetric(float64(tSerial.Nanoseconds())/float64(b.N), "ns/op-serial")
b.ReportMetric(float64(tCrew.Nanoseconds())/float64(b.N), "ns/op-crew")
})
}
}
+129
View File
@@ -0,0 +1,129 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package base
import (
"math"
"testing"
)
// TestAbsOfEveryScalarType pins absOf for every element type the Scalar
// constraint admits. The float32 case used to fall through to the 0 the
// switch returns for an unhandled type, so a Factor[float32] pivot
// search compared every element against that zero and lost its partial
// pivoting silently.
func TestAbsOfEveryScalarType(t *testing.T) {
// float64: the plain magnitude, including the negative zero and the
// signed extremes.
for _, c := range []struct {
in float64
want float64
}{
{3.5, 3.5},
{-3.5, 3.5},
{0, 0},
{math.Copysign(0, -1), 0},
{-math.MaxFloat64, math.MaxFloat64},
{math.Inf(-1), math.Inf(1)},
} {
if got := absOf(c.in); got != c.want {
t.Errorf("absOf(float64(%v)) = %v, want %v", c.in, got, c.want)
}
}
// float32: what Factor[float32] reads. The value must be the exact
// magnitude widened to float64, not a zero.
for _, c := range []struct {
in float32
want float64
}{
{3.5, 3.5},
{-3.5, 3.5},
{0, 0},
{float32(math.Copysign(0, -1)), 0},
{-math.MaxFloat32, float64(math.MaxFloat32)},
{1e-30, float64(float32(1e-30))},
} {
got := absOf(c.in)
if got != c.want {
t.Errorf("absOf(float32(%v)) = %v, want %v", c.in, got, c.want)
}
if got == 0 && c.in != 0 {
t.Errorf("absOf(float32(%v)) = 0: the float32 case is missing again", c.in)
}
}
// complex128: the modulus.
for _, c := range []struct {
in complex128
want float64
}{
{complex(3, 4), 5},
{complex(-3, -4), 5},
{complex(0, 0), 0},
{complex(-0.0, 0), 0},
{complex(1e200, 1e200), math.Sqrt2 * 1e200},
} {
if got := absOf(c.in); got != c.want {
t.Errorf("absOf(complex128(%v)) = %v, want %v", c.in, got, c.want)
}
}
// The pivot search is the caller that matters: a float32 matrix
// whose largest element is negative must pick that pivot.
m := [][]float32{
{0.05, 0},
{-0.1, 0},
}
perm, _ := Factor(m)
if perm[0] != 1 {
t.Errorf("Factor[float32] picked row %d as the first pivot, want the larger magnitude at row 1", perm[0])
}
}
// TestAbsComplexExtremeScale pins AbsComplex beyond the range of the
// sqrt(r² + i²) it used to be. The squared form overflows above about
// 1.34e154 and underflows below about 1.5e-162 while the magnitude is an
// ordinary number in both directions.
func TestAbsComplexExtremeScale(t *testing.T) {
const big = 1e200
z := complex(big, big)
naive := math.Sqrt(real(z)*real(z) + imag(z)*imag(z))
if !math.IsInf(naive, 1) {
t.Fatalf("sqrt(r² + i²) at 1e200 = %v, the overflow this test relies on is gone", naive)
}
if got, want := AbsComplex(z), math.Sqrt2*big; math.Abs(got-want) > 1e-15*want {
t.Errorf("AbsComplex(1e200 + 1e200i) = %v, want %v", got, want)
}
// The same pair with one component zero: the magnitude is finite and
// the squared form would still overflow.
if got := AbsComplex(complex(big, 0)); got != big {
t.Errorf("AbsComplex(1e200) = %v, want %v", got, big)
}
// The underflow side.
const small = 1e-200
if naive := math.Sqrt(small*small + small*small); naive != 0 {
t.Fatalf("sqrt(r² + i²) at 1e-200 = %v, the underflow this test relies on is gone", naive)
}
if got, want := AbsComplex(complex(small, small)), math.Sqrt2*small; math.Abs(got-want) > 1e-15*want {
t.Errorf("AbsComplex(1e-200 + 1e-200i) = %v, want %v", got, want)
}
// Ordinary magnitudes keep their exact values: the classics and a
// Pythagorean pair whose components are exactly representable.
for _, c := range []struct {
in complex128
want float64
}{
{complex(3, 4), 5},
{complex(5, 12), 13},
{complex(1, 0), 1},
{complex(0, -1), 1},
{complex(0, 0), 0},
} {
if got := AbsComplex(c.in); got != c.want {
t.Errorf("AbsComplex(%v) = %v, want %v", c.in, got, c.want)
}
}
// A mixed pair: the large component does not swallow the small one.
if got := AbsComplex(complex(1e200, 1e-200)); got != 1e200 {
t.Errorf("AbsComplex(1e200 + 1e-200i) = %v, want 1e200", got)
}
}
+51
View File
@@ -0,0 +1,51 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package base
// The tridiagonal Thomas elimination, one kernel for every caller. The
// linalg package's public SolveTridiagonal and the integrate package's
// PDE step sweeps solve the same systems; both call TriSolve, so the
// arithmetic and the refusal texts exist once. The messages name
// SolveTridiagonal because both public surfaces publish that text
// today, and a message a caller matches is part of the contract.
// TriSolve solves the tridiagonal system with lower diagonal a
// (length n-1), main diagonal b (length n), upper diagonal c (length
// n-1) and right side d (length n), writing the solution into dst. The
// scratch cp and dp must hold at least n elements. A zero pivot is
// refused. Every buffer is fully overwritten before the kernel reads
// it, except cp, whose prefix is written and read in the same sweep
// order a fresh buffer saw, so reused scratch and fresh allocations
// solve bit-identically. dst must not alias any diagonal or d.
func TriSolve(dst, cp, dp, a, b, c, d []float64) error {
n := len(b)
if n == 0 {
return Errf("SolveTridiagonal: empty system")
}
b0 := b[0]
if b0 == 0 {
return Errf("SolveTridiagonal: zero pivot at row 0")
}
// For n = 1 the c diagonal is empty per the length contract, so the
// seed must not read it; the single unknown falls out of dp[0].
if n > 1 {
cp[0] = c[0] / b0
}
dp[0] = d[0] / b0
for i := 1; i < n; i++ {
den := b[i] - a[i-1]*cp[i-1]
if den == 0 {
return Errf("SolveTridiagonal: zero pivot at row %d", i)
}
if i < n-1 {
cp[i] = c[i] / den
}
dp[i] = (d[i] - a[i-1]*dp[i-1]) / den
}
dst[n-1] = dp[n-1]
for i := n - 2; i >= 0; i-- {
dst[i] = dp[i] - cp[i]*dst[i+1]
}
return nil
}
+57
View File
@@ -0,0 +1,57 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package base
import (
"errors"
"strings"
"testing"
)
// TestWrapErrCarriesTagOnce pins the WrapErr contract the ten wrap
// points rely on: the message carries the tensor tag and the operation
// once each, whatever the inner error already carries, and the chain
// stays open for errors.Is. The wrap these properties answer to used to
// be fmt.Errorf("op: %w", err) over an Errf-built cause, which printed
// the tag twice.
func TestWrapErrCarriesTagOnce(t *testing.T) {
if got := WrapErr("Anything", nil); got != nil {
t.Fatalf("WrapErr of nil = %v, want nil", got)
}
// A bare cause the library did not build: the tag and the operation
// are added, once each.
bare := errors.New("the caller's own failure")
got := WrapErr("Jacobian", bare)
if want := "tensor: Jacobian: the caller's own failure"; got.Error() != want {
t.Errorf("WrapErr of a bare cause = %q, want %q", got.Error(), want)
}
if !errors.Is(got, bare) {
t.Errorf("WrapErr of a bare cause does not unwrap to it")
}
// A cause the library already tagged: the tag is not doubled.
tagged := Errf("the shape holds more elements than fit in an index")
got = WrapErr("Tile", tagged)
if want := "tensor: Tile: the shape holds more elements than fit in an index"; got.Error() != want {
t.Errorf("WrapErr of a tagged cause = %q, want %q", got.Error(), want)
}
if !errors.Is(got, tagged) {
t.Errorf("WrapErr of a tagged cause does not unwrap to it")
}
if n := strings.Count(got.Error(), "tensor: "); n != 1 {
t.Errorf("WrapErr message carries the tag %d times, want exactly one", n)
}
// A nested WrapErr composes the same way: each layer names its own
// operation and the tag still appears once.
inner := WrapErr("Concat", tagged)
got = WrapErr("Repeat", inner)
if want := "tensor: Repeat: Concat: the shape holds more elements than fit in an index"; got.Error() != want {
t.Errorf("nested WrapErr = %q, want %q", got.Error(), want)
}
if !errors.Is(got, tagged) {
t.Errorf("nested WrapErr does not unwrap down to the cause")
}
}