// Copyright (c) 2026 Petr Balvín (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) } } }