Spack extension that measures how long the concretizer takes. spack solve-benchmark runs
suites of concretization benchmarks in isolated processes, records each sample in a result file,
and compares two result files with a statistical test.
$ unset SPACK_PYTHON # ensure Spack uses the Python from the virtual environment
$ python3 -m venv .venv
$ source .venv/bin/activate
$ pip install "git+https://github.com/spack/spack-benchmark#egg=spack_solver_benchmark"List the suites:
$ spack solve-benchmark list
compilers solves with 1 to 8 compilers offered for reuse
duplicates solves under each duplicates strategy
env-roots RADIUSS packages solved as one environment, per solve kind
propagation variant propagation
radiuss RADIUSS packages, one solve each
reuse solves with 0 to 1900 specs of the E4S stack offered for reuse
static-analysis solves with static analysis off and on, under the requirements of E4SRun one, with 3 samples per benchmark and 4 samples at a time:
$ spack solve-benchmark run radiuss -r 3 -n 4 -o radiuss.json
==> Warming up suite radiuss...
==> suite radiuss: builtin@14d4cfe7a8195d714b09d67dd2a104c4cd2ea867, linux-ubuntu24.04-alderlake, 2 compilers from lockfile
==> clingo 5.5.0, Python 3.14.5
==> 14.9s [ 1%] 2.0 GB ok ascent [kind=together, roots=1] (seed 2820513866)
==> 13.9s [ 2%] 2.0 GB ok mfem [kind=together, roots=1] (seed 3231483282)
...Each line shows a sample: its time, the peak memory of its process, its status, the benchmark, and the seed used to shuffle its problem.
Print a markdown report on the result file:
$ spack solve-benchmark report radiuss.jsonRun the suite with each Spack, and read recipes from the same checkout of spack-packages:
$ ~/spack-develop/bin/spack solve-benchmark run radiuss -r 5 --builtin ~/spack-packages -o before.json
$ ~/spack-pr/bin/spack solve-benchmark run radiuss -r 5 --builtin ~/spack-packages -o after.json
$ spack solve-benchmark compare before.json after.json -o plot.pngcompare exits with status 1 if a benchmark changed solution.
Run the suite with the same Spack on each checkout of spack-packages, and pass
--packages-change to compare:
$ spack solve-benchmark run radiuss -r 5 --builtin ~/spack-packages -o before.json
$ spack solve-benchmark run radiuss -r 5 --builtin ~/pr-checkout -o after.json
$ spack solve-benchmark compare before.json after.json --packages-changeBoth files must then come from the same Spack commit, in a git checkout with no uncommitted
changes. The builtin commits may differ. Changed recipes change the hashes of the specs that use
them, so hash changes are listed but do not make compare fail.
Dump the problems of a suite once, then replay them before and after the change, so setup does not run again:
$ spack solve-benchmark run radiuss --dump problems -o dump.json
...
==> wrote the problems of 34 benchmarks to problems
$ spack solve-benchmark replay problems -r 5 -o before.json
$ $EDITOR lib/spack/spack/solver/concretize.lp
$ spack solve-benchmark replay problems -r 5 -o after.json
$ spack solve-benchmark compare before.json after.jsonSee Replaying dumped problems for what is dumped and what a replay checks.
Rerun the benchmark with the seed printed on the line of the slow sample:
$ spack solve-benchmark run radiuss --filter '^mfem ' --seed 3231483282 -o again.jsonIf the sample was dumped, replay --dumped-seed --filter NAME solves the dumped problem with the
same order of facts, without running setup.
Runs are hermetic: a benchmark solves the same problem on every machine with the same platform,
operating system, microarchitecture and spack-packages commit. Each worker reads only:
- Spack's
defaultsconfiguration scope; - the configuration scope of the suite, which sets the target granularity;
- the configuration scope of the benchmark, if it has one;
- a scope of the run, with the builtin checkout, an empty store, and a misc cache shared by the workers of the run.
The site, system and user configuration of the machine are ignored. The compilers come from a lockfile in the suite, and are offered for reuse in every solve.
Each sample solves a benchmark once, in its own process. It first shuffles the facts of its problem, since the time clingo takes depends on their order, and records the seed of the shuffle. A sample ends with one of these statuses:
ok;timeout, if it ran longer than the timeout of its benchmark;invariant-failed, if its result does not satisfy an invariant of the benchmark;error, if the solve failed.
run and replay exit with status 1 if any sample is not ok. At the end, run warns about each
benchmark whose ok samples found more than one solution: these are ties between optimal
solutions.
Each sample is appended to the result file as soon as it completes. A result file has one JSON record per line, and the first record is the metadata of the run:
- the suite, the host arch, and the configuration scopes;
- the builtin commit, and whether its checkout had uncommitted changes;
- the Spack commit, and whether its checkout had uncommitted changes;
- the compilers in the lockfile;
- the versions of clingo and Python.
run fails if the builtin commit changes while it runs, or if a clean checkout gets uncommitted
changes.
Each following record is a sample, with:
- its benchmark, series, parameters and configuration scope;
- its seed and status;
- the time spent in each phase of each solve;
- the clingo statistics of each solve: atoms, rules, bodies, vars, constraints, choices, conflicts and restarts;
- the hashes of the concrete roots;
- its profile, if it was profiled;
- the peak resident memory of its process, in bytes.
A sample has one solve per round for when_possible, and one per root for separately.
run --profile adds one profiled sample per benchmark to the result file. A thread of the worker
reads the Python stack of the solve every 10 ms, and attributes to it the time since the previous
reading. Time spent in clingo shows up as _run_clingo. Profiled samples are left out of the
timings of compare and report.
report lists the functions with the most own time, with the share of the sampled time in which
each is the innermost frame (own), or anywhere in the stack (inclusive). Frames are written as
function (file:line), with the path of the file relative to its sys.path entry and the first
line of the function, e.g. reorder_flags (spack/solver/asp.py:3182). --focus PATTERN
restricts the profile to the stacks with a frame matching a regular expression, so
--focus '^reorder_flags ' selects the post-processing of the flags of a solve.
replay is the other command writing a result file. run --dump DIR writes to a new directory,
for the first sample of each benchmark, the problem of each of its solves and the names of the
control files clingo loaded. The statements of a problem are stored in the order setup emitted
them.
replay loads, grounds and solves each problem with the control files of the Spack running it,
and records the time of each of those phases. Each replayed sample shuffles the problems with its
seed, as a sample of run does, or with the seed of the dumped sample of its benchmark with
--dumped-seed. The dumped benchmarks keep their timeouts.
A replayed sample is error if its problem is unsatisfiable or its best model has errors.
Invariants are not checked and no hashes are recorded, since no specs are built from the models,
so compare reports no hash changes or ties for replays. A control file added to the solver after
the dump is not loaded, unless an #include in a loaded file names it.
report prints:
- the median share of each phase in the time of each benchmark, with the time outside the
phases as
other; - for each series, the exponent x of a fit of the median time of each phase to k^x, k being the parameter of the series;
- the median clingo statistics of each benchmark;
- the benchmarks whose
oksamples have more than one solution; - if the file has profiled samples, their profile, see Profiling.
--filter PATTERN restricts the report to the benchmarks whose name matches a regular expression.
compare refuses two files:
- from different suites or host archs;
- run on different builtin commits, or on a builtin checkout with uncommitted changes;
- that timed different phases, such as a replay and a run;
- replayed from different dumps.
With --packages-change, the second condition is on the Spack commit instead, and a Spack outside
a git checkout is refused too, see A change to spack-packages.
It then prints, in markdown:
- the Spack and builtin commits and the clingo and Python versions of each file, with a warning if the clingo or Python versions differ;
- the benchmarks whose status changed;
- the benchmarks whose solution changed, and the ties in each file;
- for each phase, the median time of each benchmark in both files, and whether the phase improved or regressed: two one-sided Wilcoxon tests on the ratios of the medians, at a 5% significance level.
The plot shows the median time of each phase. Benchmarks of a series that differ in one numeric
parameter share a panel, with time against that parameter: env-roots has one panel per kind,
with time against the number of roots.
A suite is a directory under src/spack_solver_benchmark/suites/:
suites/radiuss/
suite.py defines SUITE
config/ configuration scope of the suite
compilers/ spack.yaml and spack.lock of the compilers offered for reuse
reuse/ optional, spack.yaml and spack.lock of the specs a benchmark can reuse
config/ and compilers/ can be symlinks to those of another suite, as in propagation, which
shares both with radiuss.
suite.py creates the suite and adds benchmarks to it:
from spack_solver_benchmark.suite import Suite, no_duplicates, satisfies
SUITE = Suite(description="RADIUSS packages, one solve each", platforms=["linux"])
SUITE.add(
name="mfem",
specs=["mfem"],
kind="together",
timeout=120,
invariants=[satisfies("mfem ^hypre")],
)The specs of a benchmark are solved as the roots of an environment, as kind prescribes:
togethersolves them all at once, asunify: truedoes;when_possiblesolves them in rounds, asunify: when_possibledoes;separatelysolves them one at a time, asunify: falsedoes, but not in parallel.
The kind and the number of roots are parameters of every benchmark.
A series is a loop adding benchmarks with the same name and different parameters. Parameters are recorded in each sample and appended to the name of the benchmark:
compilers=koffers only the first k compilers of the lockfile;reuse=koffers the first k reusable specs of the reuse lockfile, dependencies first, so that the specs offered include their dependencies;- other parameters go in
params.
A name such as mfem [kind=together, roots=1, compilers=4] lets --filter 'compilers=(1|8)'
select benchmarks by parameter.
for k in (1, 2, 4, 8):
SUITE.add(
name="mfem", specs=["mfem"], kind="together", compilers=k, invariants=[no_duplicates()]
)config is a configuration scope of the benchmark, which takes precedence over the scope of the
suite. It is recorded in each sample but is not a parameter, so a series varying it names the
value in params:
for strategy in ("none", "minimal", "full"):
SUITE.add(
name="mfem",
specs=["mfem"],
kind="together",
params={"duplicates": strategy},
config={"concretizer": {"duplicates": {"strategy": strategy}}},
)satisfies(constraint)requires every concrete root to satisfy a constraint;no_duplicates(allow=[...])requires each package to appear at most once among the link and run dependencies of a root;reused(constraint)requires every root to be reused when a spec offered from the reuse lockfile satisfies the constraint.
An invariant is a function taking a Solved tuple, whose roots are the concrete roots and whose
reuse_set holds the specs offered from the reuse lockfile, and returning an error message or
None.
regenerate concretizes the lockfiles of a suite again, with the configuration of the suite and
the recipes of the builtin checkout (or of --builtin PATH):
$ spack solve-benchmark regenerate reuse
==> Concretizing the lockfiles of suite reuse with builtin@14d4cfe7a8195d714b09d67dd2a104c4cd2ea867...
==> wrote compilers/spack.lock, 2 roots, 38 specs
==> wrote reuse/spack.lock, 279 roots, 1975 specs- The reuse environment is concretized with the compilers of the new compiler lockfile offered for reuse.
- Compilers on the host are never detected, so the compiler environment has to declare the compiler that builds the others as an external. Its roots must not be externals, since the suite scope does not declare them.
- The lockfiles are written only if every environment concretizes, through the symlinks of the suite if there are any.
The specs in the lockfiles remain reusable when the builtin checkout moves, so they only need regenerating to change their content.
$ spack python -m pytest testsThe tests in tests/test_run.py run a small suite end to end, reading recipes from the builtin
repository of the Spack running them.