Files
tensor/stats/bench_kernels_test.go
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

217 lines
5.9 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package stats
import (
"fmt"
"math"
"slices"
"testing"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// Benchmarks for the estimation kernels: the mixture expectation
// maximisation sweep, the kernel-density sweep, the windowed extrema
// and the histogram passes. Every input is a fixed arithmetic
// progression, so two runs of a benchmark measure the same work.
// benchKernelsSeries builds a deterministic vector of length n: a few
// incommensurate frequencies plus a modular jitter, so the sorts and the
// window folds meet a spread without a generator. The phase separates
// two series of the same length.
func benchKernelsSeries(n int, phase float64) []float64 {
vals := make([]float64, n)
for i := range vals {
x := float64(i)
vals[i] = math.Sin(0.0017*x+phase)*4 + math.Cos(0.071*x+phase)*2 + float64((i*37)%101)/101
}
return vals
}
// benchKernelsVector wraps the series as a rank-1 array.
func benchKernelsVector(b *testing.B, n int, phase float64) *core.Array {
b.Helper()
a, err := core.FromFloats(benchKernelsSeries(n, phase), n)
if err != nil {
b.Fatal(err)
}
return a
}
// benchKernelsCloud builds an n-by-d sample of k well-separated clusters
// whose jitter follows a fixed congruence: the same rows on every run,
// and a cloud the mixture's covariances can factor.
func benchKernelsCloud(n, d, k int) []float64 {
vals := make([]float64, n*d)
for i := range vals {
row, col := i/d, i%d
jitter := float64((row*2654435761+col*40503)%1000)/1000 - 0.5
vals[i] = float64(row%k)*4 + jitter*1.5
}
return vals
}
// BenchmarkKernelsGaussianMixture measures one full fit: the k-means++
// seeding, the Lloyd sweeps and the expectation maximisation sweeps.
func BenchmarkKernelsGaussianMixture(b *testing.B) {
const n, d, k = 3000, 5, 5
data := benchKernelsCloud(n, d, k)
x, err := core.FromFloats(data, n, d)
if err != nil {
b.Fatal(err)
}
g := core.NewGenerator(7)
b.ReportAllocs()
for b.Loop() {
if _, err := GaussianMixture(g, x, k); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkKernelsGMMSweep measures the expectation maximisation sweeps
// alone: the seeding runs once outside the timed loop, and every
// iteration re-copies the seeded parameters, which gmmEM updates in
// place.
func BenchmarkKernelsGMMSweep(b *testing.B) {
const n, d, k = 3000, 5, 5
data := benchKernelsCloud(n, d, k)
weights, means, covs, err := gmmSeed(core.NewGenerator(7), data, n, d, k)
if err != nil {
b.Fatal(err)
}
b.ReportAllocs()
for b.Loop() {
w := slices.Clone(weights)
m := make([][]float64, k)
c := make([][]float64, k)
for j := range k {
m[j] = slices.Clone(means[j])
c[j] = slices.Clone(covs[j])
}
res, err := gmmEM(data, n, d, k, w, m, c)
if err != nil {
b.Fatal(err)
}
b.ReportMetric(float64(res.Iterations), "sweeps")
}
}
// BenchmarkKernelsKernelDensity measures the O(n·points) kernel-density
// sweep with a fixed bandwidth, so no Silverman pre-pass is timed.
func BenchmarkKernelsKernelDensity(b *testing.B) {
sample := benchKernelsVector(b, 2048, 0)
points := benchKernelsVector(b, 512, 0.5)
b.ReportAllocs()
for b.Loop() {
if _, err := KernelDensity(sample, 0.35, points); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkKernelsRollingMaxWide measures the windowed maximum at half
// the series length, the shape that separates a rescan from a deque.
func BenchmarkKernelsRollingMaxWide(b *testing.B) {
a := benchKernelsVector(b, 16384, 0)
b.ReportAllocs()
for b.Loop() {
if _, err := RollingMax(a, 8192); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkKernelsRollingMinWide is the minimum's counterpart.
func BenchmarkKernelsRollingMinWide(b *testing.B) {
a := benchKernelsVector(b, 16384, 0)
b.ReportAllocs()
for b.Loop() {
if _, err := RollingMin(a, 8192); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkKernelsHistogram measures the fused scan and the counted
// bins over a long sample.
func BenchmarkKernelsHistogram(b *testing.B) {
a := benchKernelsVector(b, 262144, 0)
b.ReportAllocs()
for b.Loop() {
if _, _, err := Histogram(a, 256); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkKernelsHistogram2D measures the paired binning over a long
// sample on both axes.
func BenchmarkKernelsHistogram2D(b *testing.B) {
const n = 65536
x := benchKernelsVector(b, n, 0)
y := benchKernelsVector(b, n, 0.5)
b.ReportAllocs()
for b.Loop() {
if _, _, _, err := Histogram2D(x, y, 64, 64); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkKernelsGaussianProcessFit measures one fit over a design the
// size a Gaussian-process regression usually runs on: the Gram matrix,
// its factorisation, the weights and the posterior at the test points.
func BenchmarkKernelsGaussianProcessFit(b *testing.B) {
const n, m = 150, 60
train := make([]float64, n)
test := make([]float64, m)
for i := range train {
train[i] = float64(i) / float64(n-1)
}
for i := range test {
test[i] = float64(i) / float64(m-1)
}
trainX, err := core.FromFloats(train, n, 1)
if err != nil {
b.Fatal(err)
}
trainY, err := core.FromFloats(benchKernelsSeries(n, 0), n)
if err != nil {
b.Fatal(err)
}
testX, err := core.FromFloats(test, m, 1)
if err != nil {
b.Fatal(err)
}
kernel, err := SquaredExponentialKernel(0.2)
if err != nil {
b.Fatal(err)
}
b.ReportAllocs()
for b.Loop() {
if _, err := GaussianProcessRegression(kernel, trainX, trainY, 0.05, testX); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkKernelsRollingMaxScaling reports the monotonic deque against
// the series length at a window of half the series: the linear growth
// the deque replaced the window rescan with.
func BenchmarkKernelsRollingMaxScaling(b *testing.B) {
for _, n := range []int{4096, 16384, 65536} {
a := benchKernelsVector(b, n, 0)
b.Run(fmt.Sprintf("n=%d/w=%d", n, n/2), func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
if _, err := RollingMax(a, n/2); err != nil {
b.Fatal(err)
}
}
})
}
}