Benchmarking

Two layers: profile functions in the package (pure functions of a cost matrix), and an experiment harness in benchmark/ that produces self-documenting archives.

Profiles

using TrustRegionRadius

T = [ 12.0  18.0  Inf ;      # rows: problems, columns: solvers
      30.0  25.0  41.0 ]     # Inf marks a failure

τ, prof = performance_profile(T)
(size(prof), prof[1, :], prof[end, :])
((500, 3), [0.5, 0.5, 0.0], [1.0, 1.0, 0.5])

prof[1, s] is the fraction of problems on which solver s is fastest, and prof[end, s] the fraction it solves at all. Solver 3 fails one of the two problems, so its reliability asymptote is 0.5.

prof[1, s] is the fraction of problems on which solver s is fastest (efficiency); prof[end, s] is the fraction it solves at all (reliability). These move independently, and the distinction is the substance of most comparisons here — a parameter choice can leave efficiency untouched while collapsing reliability.

Ranking depends on the solver set

Ratios are normalised by the per-problem best, so adding or removing a solver can reorder the curves (Gould & Scott, 2016). Report pairwise profiles against a fixed baseline alongside the full comparison.

Problems that cost nothing

A problem already critical at x₀ returns :first_order with zero iterations. A cost of 0 is treated as a failure here, while success_table counts the same run as solved, so one such problem lowers every reliability asymptote and every median — and neither number says anything about a radius rule. Screen these out of the problem list before building the cost matrix, and log the names dropped. Several CUTEst *NE variants have a null objective, so ∇f ≡ 0 everywhere and every mechanism reports zero iterations.

data_profile is the complementary instrument: it reports the fraction solved within a budget measured in units of n+1 evaluations, is not normalised by the best solver, and so does not suffer that set-dependence.

Export to pgfplots with profile_to_pgfplots:

open(io -> profile_to_pgfplots(io, τ, prof, labels), "prof.tex", "w")
\begin{axis}[xmode=log, xlabel={$\tau$}, ylabel={$\pi_s(\tau)$}]
  \input{prof.tex}
\end{axis}

Run matrix

Not executed

The next three blocks are templates: they refer to a problem list and a sampled problem you supply. They are shown for reference and are not run when the documentation is built.

problems = [() -> ADNLPModel(f, x0, name = nm) for (nm, f, x0) in my_list]
configs  = sweep_configs("ζ", [0.01, 0.1, 1.0, 10.0], ζ -> RDFO(ζ = ζ))

T, S = run_matrix(problems, configs; cost = :iter)
summarise(T, [c.label for c in configs])

Problems are thunks, not models. This matters for CUTEst, where each CUTEstModel holds an open handle to a compiled SIF problem and only one may be live at a time; run_matrix finalises each before opening the next.

Runs ending :first_order or :second_order count as solved. A solver that stops early with a large gradient has not solved the problem, whatever its iteration count — but a run that reached a certified second-order point has solved it more strongly than one that stopped at ‖g‖ ≤ tol, so counting :second_order as a failure would zero the reliability of exactly the columns doing best.

cost = :samples fills the matrix with cumulative term evaluations, and is the measure to use whenever the columns differ in sampling rule — :iter is proportional to work only under FixedSample. It raises on a deterministic problem rather than returning zero.

The sampling rule lives on the oracle, not on TRConfig, so a sweep over sampling rules varies the problem thunks:

problems = [() -> FiniteSumNLP(prob, r) for r in (FixedSample(64), RadiusProportional())]
T, _ = run_matrix(problems, [TRConfig("R-delta"; rule = RDelta())]; cost = :samples)

Rules and quasi-Newton models carry mutable state, so a sweep passes factories rather than instances: ζ -> RDFO(ζ = ζ) above, not a vector of built rules. Reusing one instance across problems makes the results depend on the order they were visited.

The experiment suite

`benchmark/` is not part of the package

success_table, run_experiment, load_config, latest_archive and ExperimentArchive live in benchmark/, not in src/. They are not available from using TrustRegionRadius; load them with include("benchmark/initialisation.jl") from the benchmark project. Only performance_profile, data_profile, profile_to_pgfplots, run_matrix, summarise, TRConfig and sweep_configs are exported by the package itself.

julia --project=benchmark -e 'using Pkg; Pkg.instantiate()'
julia --project=benchmark -e 'include("benchmark/initialisation.jl"); comparison()'

benchmark/initialisation.jl loads the package, the harness, the archive layer and config.jl once, in that order, and then includes every experiment file. Each experiment defines one entry point, so call the one you want by name. The experiment files themselves contain no using and no include: re-including config.jl would re-evaluate its consts, which Julia rejects.

Not executed

The shell commands on this page are shown for reference. They are not run when the documentation is built, and the benchmark suite is not a dependency of the docs.

entry pointexperiment
comparison()1
trajectories()2
zeta_sweep()3
mu_sweep()4
inactivity()5
interaction()6
CVRate()7
second_order()9
bhhh_study()11
sampling_examples()12
flat_well_experiment()13
experimenttests
exp1_comparisonlike-for-like comparison; performance + data profiles
exp2_trajectoriesΔ, ‖g‖, ρ per iteration; ΣΔ and ΣΔ² by family
exp3_zeta_sweepthe ζ threshold: reliability against efficiency
exp4_mu_sweepthe μ_max threshold; Cauchy-point diagnostic
exp5_inactivityfraction of late iterations with ‖s‖ = Δ
exp6_interactionrule × model grid; additivity residuals
exp7_convergence_ratelocal order, conditioned on inactivity
exp8_single_problemone problem in detail: Δ, ‖g‖, ρ and the inactivity countdown
exp9_second_order‖g‖- vs τ-anchoring at a saddle; measure × subsolver grid
exp11_bhhhBHHH and BHHH2 against the exact Hessian on a likelihood
exp12_sampling_examplesthe sampling rules on the three worked examples
exp13_flat_wellthe radius thresholds measured by bisection on ε

There is no exp10: the suite jumps from exp9_second_order to exp11_bhhh.

Experiment 8 is absent from the entry-point table because initialisation.jl does not include it. The include line is commented out there: the filenames from exp2 to exp8 were once shifted by one against their contents, and the file now named exp8_single_problem.jl is a collection of helpers with no entry point of its own, so the experiment-8 script is genuinely missing rather than merely unlisted.

Experiment 8 takes a problem name, so it can be pointed anywhere without editing the script:

julia --project=benchmark benchmark/experiments/exp8_single_problem.jl WOOD
TRR_PROBLEM=ROSENBR julia --project=benchmark benchmark/experiments/exp8_single_problem.jl

It looks in the analytic set first and falls through to CUTEstModel, and writes one figure per quantity plus a stacked panel sharing an iteration axis. The countdown panel plots the number of active iterations still ahead of each index, so the onset of inactivity can be read against what Δ, ‖g‖ and ρ were doing at the time; a run whose staircase never reaches zero is labelled [never].

Each run writes a self-documenting archive:

benchmark/results/exp_2026-04-16_02-08-34_zeta_sweep/
├── experiment_config.toml     what was run
├── experiment_summary.md      what came out
├── figures/                   PDFs
├── tables/                    text tables and .tex profile coordinates
└── data/                      raw JLD2, one file per (problem, rule)

experiment_config.toml records all three axes of a run, not just the radius rule: [[rules]], [[models]] and [[subsolvers]] list each axis with its parameters, and [[configurations]] lists the (rule, model, subsolver) triples actually solved. The last is the one to read for a grid: experiment 6 crosses four rules with four model Hessians, and three independent axis lists cannot say which sixteen cells were run. experiment_summary.md renders the same four sections in Markdown. An experiment that passes configs to save_config gets the axis lists derived from the cross for free.

Recover a past configuration with load_config(dir), or the most recent with latest_archive(). An interrupted campaign resumes in place:

TRR_RESUME=benchmark/results/exp_2026-07-29_02-15-23_comparison \
  julia --project=benchmark benchmark/experiments/exp1_comparison.jl

run_experiment then skips any (problem, configuration) whose .jld2 already exists, and does not open a model at all when every configuration for that problem is cached — on CUTEst the SIF decode dominates the cost of a resume. See RESUME.md for the details.

Experiment 7 conditions on inactivity

The convergence order is estimated only over the final run of inactive iterations. Including active ones mixes a linear phase with the asymptotic one and returns an order between the two — an artefact, not a finding. Configurations whose constraint never stops binding report never and no order; that is the result, not a gap in it.

API

TrustRegionRadius.performance_profile — Function
performance_profile(T; ntau = 500, taumax = nothing) -> (τ, prof)

Performance profile of Dolan & Moré (2002).

T[p, s] is the cost of solver s on problem p; use Inf or NaN for a failure. Returns the abscissa τ and the matrix

prof[i, s] = fraction of problems with r_{p,s} ≤ τ[i],
r_{p,s} = T[p,s] / min_{s'} T[p,s'].

prof[1, s] is the fraction of problems on which s is fastest (efficiency); the right-hand asymptote is the fraction it solves at all (reliability).

Ranking depends on the solver set

Ratios are normalised by the per-problem best, so adding or removing a solver can reorder the curves (Gould & Scott 2016). Report pairwise profiles against a fixed baseline alongside the full comparison.

Example

τ, prof = performance_profile(T)
open(io -> profile_to_pgfplots(io, τ, prof, labels), "prof.tex", "w")
source
TrustRegionRadius.data_profile — Function
data_profile(N, dims; nkappa = 500, kmax = nothing) -> (κ, d)

Data profile of Moré & Wild (2009).

N[p, s] is the number of function evaluations solver s needed on problem p (Inf if unsolved) and dims[p] the dimension of problem p. The abscissa is the budget measured in units of n+1 evaluations — one simplex gradient — which is what makes problems of different size comparable.

Unlike the performance profile, the data profile is not normalised by the best solver, so it does not suffer the set-dependence noted above; it answers "what fraction is solved within this budget?" directly.

source
TrustRegionRadius.profile_to_pgfplots — Function
profile_to_pgfplots(io, x, y, labels; npoints = 60, styles = nothing, logx = true)

Write \addplot blocks ready to \input into a pgfplots axis.

The curves are subsampled onto a logarithmic grid (or a linear one if logx = false, appropriate for data profiles) so the emitted file stays small: a profile computed on a 2000-point grid becomes ~60 coordinates per curve with no visible difference.

styles, if given, is a vector of pgfplots option strings, one per curve.

Example

τ, prof = performance_profile(T)
open("prof_mech.tex", "w") do io
    profile_to_pgfplots(io, τ, prof, ["R-delta", "R-step", "R-DFO", "R-grad"];
                        styles = ["black,solid", "black,dashed",
                                  "gray,solid", "gray,dashdotted"])
end

then in LaTeX:

\begin{axis}[xmode=log, xlabel={$\tau$}, ylabel={$\pi_s(\tau)$}]
  \input{prof_mech.tex}
\end{axis}
source
TrustRegionRadius.run_matrix — Function
run_matrix(problems, configs; cost = :iter, verbose = true)
    -> (T, stats_matrix)

Run every configuration on every problem.

Arguments

  • problems: vector of thunks, each returning a fresh AbstractNLPModel. Thunks rather than models so that CUTEst handles are opened and finalised around a single run.
  • configs: vector of TRConfig.

Keyword arguments

  • cost: which quantity fills the matrix.

    costmeasuresvalid on
    :iterstats.iterany
    :obj, :grad, :hprodneval_*(nlp)any
    :timestats.elapsed_timeany
    :samplescumulative term evaluationssampled oracles only

    :iter is proportional to work only under FixedSample. Under any adaptive sampling rule, N_k varies per iteration and the same runs order differently by the two measures — which is the whole point of the sampling axis. Use :samples whenever the columns differ in sampling rule; it raises on a deterministic problem rather than returning a meaningless zero.

  • verbose: print one line per problem as it completes.

Returns

  • T[p, c]: the cost, or Inf where the run did not reach :first_order.
  • stats_matrix[p, c]: the GenericExecutionStats, or nothing on error.

Runs terminating :first_order or :second_order count as solved; :max_iter, :stalled, :user, :exception and any thrown error give Inf. That convention keeps the profile honest: a solver that stops early with a large gradient has not solved the problem, whatever its iteration count.

A configuration rejected by the class checks — BHHHModel on a problem that is not a likelihood, a rule carrying N_max on a finite sum — raises from the solver constructor and is recorded as Inf with a warning, so one illegal cell does not abort the matrix.

Example

problems = [() -> ADNLPModel(x -> sum(x.^2), ones(5), name = "sphere")]
configs  = [TRConfig("R-delta"; rule = RDelta()),
            TRConfig("R-grad";  rule = RGrad())]
T, S = run_matrix(problems, configs)
summarise(T, [c.label for c in configs])
source
TrustRegionRadius.summarise — Function
summarise(T, labels; io = stdout)

Print solved counts, median and mean cost per configuration.

The median is over solved problems only, so it is not comparable across columns with very different reliability — read it together with the solved count, or use a performance profile, which handles the two jointly.

source
TrustRegionRadius.TRConfig — Type
TRConfig(label; rule, model, subsolver, params)

One cell of the experiment matrix: a named triple of radius rule, model Hessian, and subproblem solver, together with the solver parameters.

rule and model carry mutable state (μ for RGrad, the operator for L-BFGS/SR1), so run_matrix deep-copies them per run; a single TRConfig can therefore be reused across problems without contamination.

The solver constructor deep-copies its three axes as well, so the copy here is now belt-and-braces rather than load-bearing. It is kept deliberately: it makes this file correct on its own terms rather than by relying on an invariant maintained two modules away, and one deepcopy of a rule is free beside a solve.

The sampling rule is not a field here. It lives on the oracle, because it decides what the oracle returns — so a sweep over sampling rules varies the problem thunks, not the configurations:

problems = [() -> FiniteSumNLP(prob, r) for r in (FixedSample(64),
                                                  RadiusProportional(),
                                                  NormTest())]
configs  = [TRConfig("R-delta"; rule = RDelta())]
T, _ = run_matrix(problems, configs; cost = :samples)

Note that this puts the sampling rules down the rows, where performance_profile treats them as different problems rather than different solvers. To profile them as solvers, transpose T — or build one thunk per problem and one config per rule and accept that the sampling rule is then fixed across the matrix.

source
TrustRegionRadius.sweep_configs — Function
sweep_configs(name, values, mkrule; model, subsolver, params) -> Vector{TRConfig}

Build one TRConfig per parameter value, labelled name = value.

mkrule(v) must return a fresh rule for the value v.

configs = sweep_configs("ζ", [0.1, 1.0, 10.0], ζ -> RDFO(ζ = ζ))
T, _    = run_matrix(problems, configs)

A sweep of this kind is the right way to study ζ or μ_max: the survey's thresholds involve κ̄ = 8/λ*_min, which is a property of the solution and so cannot be chosen a priori, and only a sweep reveals where the transition sits on a given family of problems.

mkrule may return any RadiusRule, including a SecondOrder wrapper — in which case pair it with params = TRParams(tol_H = 1e-6) and a model that can report negative curvature, or the sweep measures an expensive no-op. Runs reaching a certified second-order point report :second_order, which run_matrix counts as solved.

source