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.
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.
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
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
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.
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 point | experiment |
|---|---|
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 |
| experiment | tests |
|---|---|
exp1_comparison | like-for-like comparison; performance + data profiles |
exp2_trajectories | Δ, ‖g‖, ρ per iteration; ΣΔ and ΣΔ² by family |
exp3_zeta_sweep | the ζ threshold: reliability against efficiency |
exp4_mu_sweep | the μ_max threshold; Cauchy-point diagnostic |
exp5_inactivity | fraction of late iterations with ‖s‖ = Δ |
exp6_interaction | rule × model grid; additivity residuals |
exp7_convergence_rate | local order, conditioned on inactivity |
exp8_single_problem | one problem in detail: Δ, ‖g‖, ρ and the inactivity countdown |
exp9_second_order | ‖g‖- vs τ-anchoring at a saddle; measure × subsolver grid |
exp11_bhhh | BHHH and BHHH2 against the exact Hessian on a likelihood |
exp12_sampling_examples | the sampling rules on the three worked examples |
exp13_flat_well | the 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.jlIt 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.jlrun_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.
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 — Functionperformance_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).
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")TrustRegionRadius.data_profile — Functiondata_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.
TrustRegionRadius.profile_to_pgfplots — Functionprofile_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"])
endthen in LaTeX:
\begin{axis}[xmode=log, xlabel={$\tau$}, ylabel={$\pi_s(\tau)$}]
\input{prof_mech.tex}
\end{axis}TrustRegionRadius.run_matrix — Functionrun_matrix(problems, configs; cost = :iter, verbose = true)
-> (T, stats_matrix)Run every configuration on every problem.
Arguments
problems: vector of thunks, each returning a freshAbstractNLPModel. Thunks rather than models so that CUTEst handles are opened and finalised around a single run.configs: vector ofTRConfig.
Keyword arguments
cost: which quantity fills the matrix.costmeasures valid on :iterstats.iterany :obj,:grad,:hprodneval_*(nlp)any :timestats.elapsed_timeany :samplescumulative term evaluations sampled oracles only :iteris proportional to work only underFixedSample. Under any adaptive sampling rule,N_kvaries per iteration and the same runs order differently by the two measures — which is the whole point of the sampling axis. Use:sampleswhenever 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, orInfwhere the run did not reach:first_order.stats_matrix[p, c]: theGenericExecutionStats, ornothingon 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])TrustRegionRadius.summarise — Functionsummarise(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.
TrustRegionRadius.TRConfig — TypeTRConfig(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.
TrustRegionRadius.sweep_configs — Functionsweep_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.