Files
tensor/optim/bench_solvers_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

371 lines
10 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 optim
import (
"math"
"math/rand/v2"
"testing"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// Benchmarks for the solver iterations whose cost is dominated by
// repeated dense linear algebra or by a difference stencil: the revised
// simplex, the active-set QP, CMA-ES, L-BFGS and the finite-difference
// paths. Every input comes from a fixed seed, so each run walks one
// deterministic trajectory.
// solversRNG returns a generator with a fixed stream: the inputs are
// identical on every machine and every run.
func solversRNG() *rand.Rand { return rand.New(rand.NewPCG(0x5eed, 0x1234)) }
// solverArray builds a float array, panicking on a bad shape: every
// caller passes a literal shape.
func solverArray(vals []float64, shape ...int) *core.Array {
a, err := core.FromFloats(vals, shape...)
if err != nil {
panic("solverArray: " + err.Error())
}
return a
}
// lpProblem builds a standard-form LP with m rows and n = 2m columns:
// A = [I | R] with every entry of R at least 0.5, b = 1 and a random
// cost. The identity block makes x = (1, …, 1, 0, …, 0) feasible, and
// the feasible set is bounded: the slack block forces R·x_R ≤ 1, whose
// positive coefficients bound the 1-norm of x_R by 2, and x_I = 1 −
// R·x_R is bounded with it. The run therefore ends at a vertex rather
// than on an unbounded ray.
func lpProblem(m int, rng *rand.Rand) (c, a, b *core.Array) {
n := 2 * m
av := make([]float64, m*n)
for i := range m {
av[i*n+i] = 1
for j := range m {
av[i*n+m+j] = 0.5 + rng.Float64()
}
}
cv := make([]float64, n)
for j := range n {
cv[j] = 2*rng.Float64() - 1
}
bv := make([]float64, m)
for i := range m {
bv[i] = 1
}
return solverArray(cv, n), solverArray(av, m, n), solverArray(bv, m)
}
// qpProblem builds a strictly convex quadratic in n variables with r
// two-sided rows that all admit the origin, so the start point is
// feasible and the benchmark measures the active-set iteration itself.
func qpProblem(n, r int, rng *rand.Rand) (*core.Array, *core.Array, LinearConstraints) {
hv := make([]float64, n*n)
for i := range n {
hv[i*n+i] = 1 + rng.Float64()
for j := i + 1; j < n; j++ {
v := 0.25 * (2*rng.Float64() - 1)
hv[i*n+j], hv[j*n+i] = v, v
}
}
cv := make([]float64, n)
for j := range n {
cv[j] = 2*rng.Float64() - 1
}
av := make([]float64, r*n)
lower := make([]float64, r)
upper := make([]float64, r)
for i := range r {
for j := range n {
av[i*n+j] = 2*rng.Float64() - 1
}
lower[i], upper[i] = math.Inf(-1), 0.05+0.1*rng.Float64()
}
cons := LinearConstraints{A: solverArray(av, r, n), Lower: lower, Upper: upper}
return solverArray(hv, n, n), solverArray(cv, n), cons
}
// solverBowl returns a coupled bowl around (0.5, …, 0.5) and a start
// point away from it.
func solverBowl(n int, rng *rand.Rand) (f func(*core.Array) (float64, error), x0 *core.Array) {
weights := make([]float64, n)
for i := range n {
weights[i] = 1 + 2*rng.Float64()
}
f = func(p *core.Array) (float64, error) {
total := 0.0
for i := range n {
d := p.FloatAt(i) - 0.5
total += weights[i] * d * d
if i+1 < n {
total += 0.3 * d * (p.FloatAt(i+1) - 0.5)
}
}
return total, nil
}
start := make([]float64, n)
for i := range start {
start[i] = 1.5 + 0.1*float64(i)
}
return f, solverArray(start, n)
}
// BenchmarkSolversLinearProgram measures the two-phase revised simplex
// on a bounded LP with 60 rows and 120 columns, the shape the per-pivot
// refactorisation pays for.
func BenchmarkSolversLinearProgram(b *testing.B) {
rng := solversRNG()
c, a, rhs := lpProblem(60, rng)
opts := LinearProgramOptions{}
b.ReportAllocs()
for b.Loop() {
if _, _, err := MinimiseLinear(c, a, rhs, opts); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkSolversLinearProgramRows measures an LP of the same family
// through the two-sided-row wrapper, whose conversion builds the
// standard form before the same simplex runs. The equality rows carry
// the LP itself and every variable is boxed, so the feasible set the
// wrapper sees is bounded.
func BenchmarkSolversLinearProgramRows(b *testing.B) {
rng := solversRNG()
const m = 8
c, a, rhs := lpProblem(m, rng)
n := c.Len()
rows := m + n
av := make([]float64, rows*n)
for i := range m {
for j := range n {
av[i*n+j] = a.FloatAt(i*n + j)
}
}
lower := make([]float64, rows)
upper := make([]float64, rows)
for i := range m {
lower[i], upper[i] = rhs.FloatAt(i), rhs.FloatAt(i)
}
for i := range n {
av[(m+i)*n+i] = 1
lower[m+i], upper[m+i] = -3, 3
}
cons := LinearConstraints{A: solverArray(av, rows, n), Lower: lower, Upper: upper}
opts := LinearProgramOptions{}
b.ReportAllocs()
for b.Loop() {
if _, _, err := MinimiseLinearRows(c, cons, opts); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkSolversQuadraticProgram measures the active-set QP on a
// strictly convex quadratic with 10 variables and 24 two-sided rows,
// tight enough that the working set moves several times.
func BenchmarkSolversQuadraticProgram(b *testing.B) {
rng := solversRNG()
h, c, cons := qpProblem(12, 30, rng)
x0 := solverArray(make([]float64, 12), 12)
opts := QPOptions{}
b.ReportAllocs()
for b.Loop() {
if _, _, _, err := MinimiseQP(h, c, cons, x0, opts); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkSolversCMAES measures the covariance-adaptation strategy on
// a four-dimensional bowl, one generation pair being cheap at that size.
func BenchmarkSolversCMAES(b *testing.B) {
rng := solversRNG()
f, x0 := solverBowl(4, rng)
opts := CMAESOptions{Sigma0: 0.5, Generations: 60, Tolerance: 1e-12, Seed: 7, AllowBudgetExit: true}
b.ReportAllocs()
for b.Loop() {
if _, _, err := MinimiseCMAES(f, x0, opts); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkSolversLBFGSLeastSquares fits a six-term cosine series to
// samples of a fixed function with a consistent analytic gradient, the
// path whose history buffer rotates once the memory is full.
func BenchmarkSolversLBFGSLeastSquares(b *testing.B) {
const nPar, nObs = 6, 64
truth := make([]float64, nPar)
for j := range truth {
truth[j] = 1 / float64(j+1)
}
tSamples := make([]float64, nObs)
y := make([]float64, nObs)
for i := range nObs {
t := 0.5 * float64(i) / float64(nObs)
tSamples[i] = t
for j := range nPar {
y[i] += truth[j] * math.Cos(float64(j)*t)
}
}
f := func(p *core.Array) (float64, error) {
total := 0.0
for i := range nObs {
model := 0.0
for j := range nPar {
model += p.FloatAt(j) * math.Cos(float64(j)*tSamples[i])
}
d := model - y[i]
total += d * d
}
return total, nil
}
grad := func(p *core.Array) (*core.Array, error) {
g := make([]float64, nPar)
for i := range nObs {
model := 0.0
for j := range nPar {
model += p.FloatAt(j) * math.Cos(float64(j)*tSamples[i])
}
d := 2 * (model - y[i])
for j := range nPar {
g[j] += d * math.Cos(float64(j)*tSamples[i])
}
}
return core.FromFloats(g, nPar)
}
x0 := solverArray(make([]float64, nPar), nPar)
opts := LBFGSOptions{MaxIterations: 300, Memory: 4}
b.ReportAllocs()
for b.Loop() {
if _, _, err := MinimiseLBFGS(f, grad, x0, opts); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkSolversLBFGSFiniteDiff measures the central-difference
// gradient path: two objective evaluations per coordinate per step.
func BenchmarkSolversLBFGSFiniteDiff(b *testing.B) {
rng := solversRNG()
f, x0 := solverBowl(24, rng)
opts := LBFGSOptions{MaxIterations: 60}
b.ReportAllocs()
for b.Loop() {
if _, _, err := MinimiseLBFGS(f, nil, x0, opts); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkSolversLevenbergFiniteDiff measures Levenberg-Marquardt on a
// small polynomial fit with the Jacobian by central differences.
func BenchmarkSolversLevenbergFiniteDiff(b *testing.B) {
const nObs, nPar = 40, 6
t := make([]float64, nObs)
obs := make([]float64, nObs)
truth := make([]float64, nPar)
for j := range truth {
truth[j] = 0.5 + 0.1*float64(j)
}
for i := range nObs {
t[i] = float64(i) / 8
v := 0.0
for j := range nPar {
v += truth[j] * math.Pow(t[i], float64(j))
}
obs[i] = v
}
residual := func(p *core.Array) (*core.Array, error) {
r := make([]float64, nObs)
for i := range nObs {
v := 0.0
for j := range nPar {
v += p.FloatAt(j) * math.Pow(t[i], float64(j))
}
r[i] = v - obs[i]
}
return core.FromFloats(r, nObs)
}
p0 := solverArray(make([]float64, nPar), nPar)
opts := LMOptions{MaxIterations: 20}
b.ReportAllocs()
for b.Loop() {
if _, _, err := LevenbergMarquardt(residual, p0, opts); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkSolversRootSystemFiniteDiff measures the damped Newton
// iteration with a per-step central-difference Jacobian.
func BenchmarkSolversRootSystemFiniteDiff(b *testing.B) {
const n = 8
r := func(x *core.Array) (*core.Array, error) {
out := make([]float64, n)
for i := range n {
v := x.FloatAt(i)
out[i] = v*v + 0.1*v - float64(i+1)
if i+1 < n {
out[i] += 0.05 * x.FloatAt(i+1)
}
}
return core.FromFloats(out, n)
}
start := make([]float64, n)
for i := range start {
start[i] = 1 + 0.1*float64(i)
}
x0 := solverArray(start, n)
opts := RootSystemOptions{MaxIterations: 20}
b.ReportAllocs()
for b.Loop() {
if _, _, err := FindRootSystem(r, x0, opts); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkSolversNonlinearConstrained measures the augmented-Lagrangian
// outer loop over one equality and two inequality rows, whose stencil is
// the per-row, per-coordinate hot path.
func BenchmarkSolversNonlinearConstrained(b *testing.B) {
const n = 6
f := func(p *core.Array) (float64, error) { return p.FloatAt(0), nil }
cons := NonlinearConstraints{
Equalities: []func(*core.Array) (float64, error){
func(p *core.Array) (float64, error) {
s := -1.0
for i := range n {
s += p.FloatAt(i) * p.FloatAt(i)
}
return s, nil
},
},
Inequalities: []func(*core.Array) (float64, error){
func(p *core.Array) (float64, error) {
s := 0.0
for i := range n {
s += p.FloatAt(i)
}
return s - 2, nil
},
func(p *core.Array) (float64, error) { return -p.FloatAt(0) - 2, nil },
},
}
start := make([]float64, n)
for i := range start {
start[i] = 0.5
}
x0 := solverArray(start, n)
b.ReportAllocs()
for b.Loop() {
if _, _, _, err := MinimiseNonlinearConstrained(f, nil, x0, cons, LBFGSOptions{}); err != nil {
b.Fatal(err)
}
}
}