371 lines
10 KiB
Go
371 lines
10 KiB
Go
// 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)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|