Files
Jungfraujoch/docs/RUGNUX_BENCHMARKING.md
T
leonarski_f 8261b7354e
Build Packages / Create release (push) Successful in 9s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m8s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 11m12s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 16m43s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 20m56s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 22m4s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 26m57s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 28m24s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 27m58s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 29m29s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 18m23s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 19m55s
Build Packages / Generate python client (push) Successful in 32s
Build Packages / Build documentation (push) Successful in 1m32s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 27m20s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 29m27s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 32m41s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 34m46s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 37m25s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 35m56s
Build Packages / Unit tests (push) Successful in 2h8m44s
Build Packages / Validation Battery (push) Successful in 33m35s
v1.0.0-rc.176 (#86)
* jfjoch_broker: Optional live Rugnux analysis of DECTRIS rotation collections inside the broker, with merged data, maps and the placed model written beside the master file, the rotation lattice in the scan result, and a "Rugnux live" tab and dashboard row in the web frontend.
* Rugnux: Faster processing, with molecular replacement and model validation spread over several GPUs and run on the Apple GPU on macOS.
* Rugnux: The polarization stated in miniCBF and d*TREK headers is used, twinned crystals' space groups are decided more reliably, and a run that finds no lattice still reports every image.

Reviewed-on: #86
Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
2026-10-19 07:03:28 +02:00

626 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Measuring Rugnux: validation and timing
This page is for anyone who has to say how well, or how fast, `rugnux` works on a machine: a
colleague with a new GPU server, a laptop, a Mac, an ARM box, or an agent running there on someone's
behalf. It explains the two measurements the project uses, how to run them, how to read what they
print, and how to take timings that can be compared with someone else's. `tools/battery/README.md`
in the source tree is the detailed guide to the runner itself: every option, the manifests, the
scoring rules and the `results.json` schema. This page summarises it and does not replace it.
```{contents} On this page
:local:
:depth: 2
```
## Two measurements, one runner
Everything goes through one script, `tools/battery/battery.py`. Do not write your own loop over
datasets: numbers made by ad-hoc scripts have never been comparable with each other, which is why
the old ones were retired.
- **The validation battery** (`battery.py run`) processes a fixed list of rotation datasets and
scores each answer against an independent reference. It answers *is the result right, and as
good as the reference*. A full run takes hours.
- **The timing benchmark** (`battery.py timing`) processes four sets, each started from a model
that has to be placed by molecular replacement, so one short run exercises the whole pipeline
and also checks that the answers are right. It answers *how fast, on this machine*. It takes
minutes, and needs nothing on the machine but four open datasets.
The two are kept apart on purpose. A battery run reads hundreds of gigabytes once, so its wall clock
mostly measures the disk and the page cache (see [the page-cache trap](#the-page-cache)). Use the
battery to judge correctness and the timing benchmark, run under controlled conditions, to judge
speed.
### Setting up a machine
The runner needs Python 3.9 or newer with `gemmi` and `numpy` (`pip install gemmi numpy`), a
`rugnux` binary, the datasets, and a **site config**: a small JSON file naming this machine's data
roots, where runs go, the GPU lock and the persisted baselines. `tools/battery/site.example.json` is
the template. The runner looks for the config in `--site PATH`, then in `$JFJOCH_BATTERY_SITE`,
then in `tools/battery/site.json`, which git ignores. `--site` is an option of the script itself,
so it goes **before** the subcommand:
```
python3 tools/battery/battery.py --site /path/to/site.json run --rugnux build/rugnux/rugnux --tier smoke
```
A relative path in the site config (data roots, runs root, lock file, caches) is taken relative to
the site config's own directory, so a config shipped beside its data works wherever it is unpacked.
A `pdb_cache` copied from another machine can be used as it is: the coordinates are looked up where
the cache now is, not where its `<entry>.json` files say they were first fetched.
The data layout (`<data root>/<set>/...`) and where to get each arm's data are in the README's
*Getting the data*. The open arm is public (each dataset's DOI is in
[External test data](EXTERNAL_TEST_DATA.md)); the in-house data come from the maintainers. The
timing benchmark needs only four open dataset directories and the smallest site config there is
([below](#what-a-machine-needs)); models, references and expected answers are all in the repository.
## The validation battery
### Arms and references
The battery has three **arms**, each scored against a reference that does not come from `rugnux`:
| arm | datasets | scored against |
|---|---|---|
| open | public PDB depositions of raw diffraction data, plus a few published small-molecule sets | the deposition: its space group, cell and resolution |
| inhouse | standard test crystals (lysozyme, thaumatin, insulin, cytochrome C, myoglobin), small-molecule standards, and no-crystal controls | XDS, from the `CORRECT.LP` stored beside each dataset |
| private | user data | XDS, as in-house |
Each set runs **once**, with one command per arm. The open arm runs
`rugnux -o p --no-export-unmerged --model <deposited coordinates> <input>`. The XDS arms run
`rugnux -o p --no-export-unmerged [-A] --report-resolution <XDS d_min>,<XDS d_max> [--model <reference model>] <input>`,
with `-A` where XDS merged with Friedel's law off. Neither `--model` nor `--report-resolution`
changes the processing: the merged reflections, the resolution cut and every decision are the same
with or without them (this has been checked bit for bit). So one run gives both what a user gets
and the extra report sections that compare it with the reference.
### Verdicts
Every set gets one verdict. Scoring asks four questions in order, and the first that fails decides:
did it run, is the lattice right, is the symmetry right, and, on the XDS arms, is the merge as good
as XDS's?
- **pass**: the lattice matches the reference (Niggli-reduced primitive cells: volume ratio 0.95 to
1.05, reduced edges within 2%) and so does the symmetry. On the open arm the space group is
compared; on the XDS arms only the point group, because XDS never tests a screw axis. A group
that the intensities cannot tell apart from the reference (same Laue class, same systematic
absences) passes as `indistinguishable_alternative`. A row whose manifest lists a second
defensible answer can pass as `accepted_alternative`. The report counts both kinds separately.
- **fail**, with a `cause`: wrong lattice (`lattice_halved`, `lattice_doubled`, ...), wrong
symmetry (`sym_under`, `sym_over`, `sym_screw`, ...), a reader or indexing failure, a timeout.
- **crash**: rugnux was killed by a signal, its log shows `Processing failed`, a GPU fault, an
uncaught exception, or a non-zero sanitizer summary. A crash fails the set whatever its report
says.
- **unscored**: a question the data cannot answer. Either there is no reference, or the hand of an
enantiomorphic pair is in question, or a screw axis lies on a row the sweep never measured (and
rugnux said so). This is not a pass, but it is not counted as a failure either.
- **not_run**: the input is missing.
A no-crystal control passes only when rugnux reports no lattice. **Data quality is reported, not
scored**: resolution, CC1/2, R_meas, ISa, completeness and the model R-factors are shown next to
the reference's values for a human to judge.
### The like-for-like table
On the XDS arms, rugnux keeps its own resolution cut and the reference does not change it.
`--report-resolution` adds a second statistics table (the `REFRES_*` keys of the report) made from
the same merge but binned over XDS's own range. That gives two separate answers. The own numbers say
what a user gets. The reference-range numbers say whether rugnux's merge is better than XDS's over
the same reflections. Where rugnux's cut is coarser than XDS's, the shells past it were never
merged, so the completeness there measures **coverage**, not a drop in quality. The report marks
those rows.
On the open arm the deposition gives only a space group, a cell and a resolution, so the data
quality is judged from outside: R-free of the deposited model, from `rugnux --model` (a trend number,
see below) and from the REFMAC check, and a per-shell correlation of the merge with the model beside
the depositor's own data (always on, needs no CCP4).
### Running it
```
B="python3 tools/battery/battery.py --site /path/to/site.json"
R=build/rugnux/rugnux
$B run --rugnux $R --only 5reo,lyso_ref # named sets
$B run --rugnux $R --tier smoke # readers, symmetry classes, known hard cases
$B run --rugnux $R --tier ci # the routine regression check CI runs on every push
$B run --rugnux $R # the full battery: open + inhouse
$B run --rugnux $R --gpulock --model-check # queued behind the site's GPU lock, with REFMAC
```
`--threads N` passes `-N` to rugnux, `--extra "<args>"` adds rugnux arguments to every set,
`--label` names the run, and `--baseline RUN|none` picks the run the report is compared against.
`--gpulock` puts every rugnux behind the command the site config names as `gpulock` (at PSI, a
`flock` wrapper shared by every GPU job on the machine). A run made that way is marked
"timing NOT a reference", and so is a run that found another process on the GPU before a set.
`run` exits with status 1 when any set crashed or a ci-tier set failed. Other failures and every
metric are reported, not judged.
### The run directory
Each run gets a new directory `<runs_root>/<YYYYMMDD-HHMM>_<rugnux git>_<label>/`, which is never
reused and is made read-only when the run finishes. It holds:
- `bin/rugnux`: a copy of the exact binary that ran;
- `manifest.json`: the options, the binary's version and sha256, the build flags (read from the
`CMakeCache.txt` above the binary, or from `--build-dir`), the host and its hardware, start and
end;
- `results.json`: one row per set, with the verdict, the cause, every number and the exact command;
- `report.md` and `report.html`: the report;
- `work/<arm>/<set>/`: what rugnux wrote, `p_report.txt`, `p_log.txt`, `p.mtz` and the rest, plus
`model_check/` when the REFMAC check ran.
The report opens with the build, host and hardware and whether timing counts. Then come any crashes,
a summary per arm (pass, fail, crash, unscored, not run, median resolution gain, ISa, R_meas, time),
tables per population, the like-for-like table and, if there is a baseline, every set that moved
beyond noise.
### Comparing runs
```
$B list # runs, their state, the baselines
$B compare RUN_A RUN_B [--all] # every set whose verdict or numbers moved beyond noise
$B compare RUN_A RUN_B --rerun-changed # rerun the changed sets with A's binary
```
Verdicts are scored again whenever a run is read, with today's rules and today's references, so the
two sides of a comparison are always scored the same way. The noise thresholds are a first guess.
`--rerun-changed` measures the noise directly: a set that moves again under the same binary is
noise, and one that reproduces is a real change.
### Rules
- **Never start a full battery (no `--only`, no `--tier`) on a shared machine without the owner's
go-ahead.** It takes hours and everything else on the machine competes with it. A full run takes
the site lock, so two cannot overlap.
- **Judge a change against a persisted baseline run, never against a remembered number.** The site
config's `baseline` names a complete run that the owner has blessed, and every new run is
reported against it. Totals remembered from earlier runs have been off by a factor of three on
the same sets.
- **Keep the manifests unchanged between the two runs you compare.** A manifest change is committed
on its own, and the baseline is run again with it.
- **Pool fixes.** Run the full battery once on a branch that carries several fixes. For a single
fix, run the sets it touches with `--only`, plus the smoke tier.
- **The private arm never leaves the machine.** Its names, paths, cells and statistics go into no
commit, issue, public report or pack sent elsewhere. A private run gets its own directory and
report (`report_PRIVATE.*`), and the runner refuses to write it into the repository.
## The REFMAC model check
R-free from `rugnux --model` is a **trend number**. It is a rigid-body placement scored on rugnux's
own free set, with a deliberately simple scale model, so it shows how a set moves between runs, but
it cannot be compared with the deposition's R-free. `--model-check` adds an independent number that
can be. It runs `tools/battery/model_check.py` on every open-arm set with a deposited X-ray model,
after rugnux has finished, so it cannot influence the processing. It needs CCP4: the runner sources
`$CCP4_SETUP`, by default `/opt/xtal/ccp4-9/bin/ccp4.setup-sh`, and calls the `refmac5` found there
(at PSI, CCP4 9.0 with REFMAC 5.8.0431).
### What it does
For each set the check takes `p.mtz` (its F, SIGF and FreeR_flag columns) and the deposited entry
(the mmCIF and, when deposited, the structure factors, downloaded from the RCSB into the site's
`pdb_cache`). Then it:
1. **Reads the published R-work, R-free and d_min** from the entry's `_refine` record, and skips
entries that are not X-ray, have no deposited R-free, hold several models, or have more than
200 000 atoms. Strict-NCS copies the entry only describes by operators are generated.
2. **Finds the model's setting.** Every change of basis that maps the data's lattice onto the
model's is tried; their Niggli cells are compared loosely, 5% on edges and 3° on angles. That
covers an alternative setting, a centred-versus-primitive description and the merohedral indexing
ambiguity. Each candidate is scored by the R between the reindexed amplitudes and the model's
|Fcalc| (gemmi, after an overall scale and B) on a mid-resolution shell, and the best one wins.
3. **Re-expresses the data in the deposited space group.** The merge is expanded to P1 with
rugnux's own group, reindexed, reduced to the asymmetric unit of the deposited group
(equivalents averaged with 1/σ² weights) and given the **deposited cell**. A space group rugnux
called too high just splits equivalents apart here, and one it called too low averages them.
4. **Has REFMAC compute R-work and R-free of the model as deposited**, at
`d_min_used = max(rugnux's d_min, deposited d_min)`, the lower of the two resolutions. The run is
REFMAC's rigid-body mode with one cycle, and the R-factors are those REFMAC prints for that cycle,
before it applies any shift. REFMAC's default bulk solvent and overall anisotropic scale are
fitted, and nothing else. Rigid-body mode reads no restraint dictionary, so ligands, waters and
hydrogens stay in. Only atoms of unknown element (UNX), which stop REFMAC and scatter nothing,
are removed. Nothing is refined, by design. The data were moved into the model's frame, so a
rigid body has nothing to correct, and at low resolution REFMAC's rigid body can walk a correct
model away.
5. **Scores the depositor's own structure factors the same way**, as the baseline. The depositor's
data go through the same choice of setting, because they can sit on the other branch of an
indexing ambiguity. Amplitudes are `F_meas`, else the mean of F(+) and F(−), else intensities
converted by French-Wilson (ctruncate). Their free set comes from `_refln.status` or
`pdbx_r_free_flag`. The depositor's free flags are also carried over onto rugnux's reflections.
6. **Uses twin refinement where the entry declares twinning** (`_pdbx_reflns_twin`), for both data
sets, because the published R-free is then a twinned one. The untwinned numbers are kept as well.
7. **Second protocol, refined on a shared free set.** The same deposited model is refined against
each data set at that set's own resolution: 10 cycles of restrained refinement, automatic weight,
isotropic B for every atom, twin refinement where declared. A ligand that the monomer library
describes with different atoms is renamed so that REFMAC builds its restraints from the model.
The free set is the depositor's free reflections that are present in **both** data sets.
Depositor free reflections outside that intersection are dropped from both, so neither refinement
uses a free reflection as a work reflection.
### The numbers
| field in `results.json` | data | free set | read it as |
|---|---|---|---|
| `refmac_rfree` | rugnux | rugnux's | close to a work R: most of rugnux's free reflections were work reflections in the depositor's refinement |
| `refmac_rfree_depflags` | rugnux | the depositor's | **the fair first-cycle number for rugnux's data** |
| `refmac_rfree_depdata` | the depositor's | the depositor's | the baseline: same model, same protocol, the data the model was refined against |
| `refmac_rfree_ratio` | | | `refmac_rfree_depflags / refmac_rfree_depdata`; below 1 means rugnux's data fit the model better |
| `refmac_refined_rfree_shared` | rugnux | shared | **the refined number for rugnux's data** |
| `refmac_refined_rfree_shared_depdata` | the depositor's | shared | the same protocol on the depositor's data |
| `refmac_refined_ratio` | | | their ratio; below 1 means rugnux's data give the better refined model |
`refmac_status` and `refmac_reason` say why a set has no numbers: not X-ray, too large, no
deposited free set, a download that failed. The same applies to `refmac_refined_reason` for the
second protocol. `python3 tools/battery/model_check.py <p.mtz> <pdb id> --workdir DIR` runs the
check on one merge and prints every field, including the reindexing operators chosen for both data
sets. `battery.py recheck RUN` reruns it on a finished run's merges without running rugnux again.
### Limits
- **The comparison favours the deposition.** The model was refined against the depositor's data, so
those data start with an advantage. A ratio near 1 is a good result for rugnux.
- **`refmac_rfree_depdata` is not the published R-free.** It comes from a different program (REFMAC,
where the deposition often used PHENIX, whose resolution-binned scaling absorbs a falloff that
REFMAC's overall scale cannot) and from no refinement at all.
- **The data are given the deposited cell.** A real cell difference between the two crystals is not
modelled, and costs R. The check records it as `cell_diff_pct`.
- **The first-cycle numbers use the common resolution limit**, so they say nothing about the shells
only one data set reaches. The refined protocol uses each set's own range, but its free set is
limited to what both data sets measured.
- **The setting is chosen by agreement with the model.** That is right for this purpose, but it
means the check cannot referee rugnux's own indexing choice where the model is ambiguous.
### Cost
The check is opt-in because it is the expensive part. In a battery run on 2026-10-16, an open-arm
set took about 2.5 minutes with the check, against well under a minute of rugnux. The
refined protocol can take up to an hour on a large entry. Across a full battery, REFMAC accounts for
most of the time, so add `--model-check` only when the R-free figures are the point, for example in
a release battery or for a paper.
## The timing benchmark
```
$B timing --rugnux $R [--benchmark standard|inhouse] [--threads N] [--label L] [--baseline RUN|none] [--extra=<rugnux args>] [--gpulock]
```
The benchmark runs a few sets. Each starts from a model that the run has to place by molecular
replacement, so a single run times every stage: reading, pre-scan, indexing, integration, scaling,
the space-group search, MR and model validation. It also checks that every answer is right.
`--benchmark standard`, the default, is the one to run on a new machine. It uses open data only:
three EIGER 16M rotation sets of ordinary size and quality, and a virus capsid as the large case.
| set | start model (`--model`) | what makes it hard |
|---|---|---|
| `9zlo` | the AlphaFold DB prediction of UreE (*Proteus mirabilis*), committed in `tools/battery/models/` | two copies to place, P2₁2₁2₁, 2.0 Å |
| `9s02` | the AlphaFold DB prediction of human PYCR1, also in `models/` | five copies, P2₁2₁2, 1.65 Å |
| `9gjx` | the AlphaFold DB prediction of a *Bacillus licheniformis* nitroreductase, also in `models/` | six copies, P2₁, 2.4 Å, the largest of the three datasets (37 GB) |
| `4nwv` | the deposited 4NWV (a virus capsid, I222, 45 chains once strict NCS is expanded), turned 30° and shifted 33 Å by a fixed transform | the largest: too far off for the rigid body, so MR places the whole asymmetric unit |
`--benchmark inhouse` is the benchmark as first set up, kept so that earlier runs stay comparable:
the predictions of horse myoglobin (`myob_x10sa`, a prediction without heme) and bovine cytochrome c
(`cytc_x10sa`: three copies related by a pseudo-translation; the data alone cannot tell P3₁21 from
P3₂21, so the model must decide the hand), and the same `4nwv`. Its two in-house datasets come from
the maintainers.
The predictions are committed next to their predicted-aligned-error files, which rugnux uses to cut
them into rigid domains. The deposited entries the placements are checked against are committed too
(`tools/battery/models/pdb/`), and the moved 4NWV is made at run time from the committed 4NWV with
the transform in `tools/battery/timing.json`. A timing run needs no `pdb_cache` and no network.
### What a machine needs
Copy these directories under one open data root (sizes as unpacked; please cite the DOIs):
| directory | size | source |
|---|---:|---|
| `9zlo/` | 11 GB | Zenodo [10.5281/zenodo.18652652](https://doi.org/10.5281/zenodo.18652652) |
| `9s02/` | 11 GB | MX Raw Data Repository [10.60884/NRNGS4](https://doi.org/10.60884/NRNGS4) |
| `9gjx/` | 37 GB | IRRMC [10.18430/M39GJX](https://doi.org/10.18430/M39GJX) |
| `4nwv/` | 16 GB | SBGrid Data Bank [10.15785/sbgrid/9](https://doi.org/10.15785/sbgrid/9) |
The paths inside each directory are the `input`s of `tools/battery/open.json` (for example
`9gjx/BlNTR_9gjx/data/SC6_1_master.h5`), and [External test data](EXTERNAL_TEST_DATA.md) says what
each archive holds. The site config needs only the runs root and that data root
(`tools/battery/site.timing.example.json`):
```json
{
"runs_root": "/path/to/runs",
"arms": {
"open": {"data_root": "/path/to/open"}
}
}
```
and the run is
```
python3 tools/battery/battery.py --site /path/to/site.json timing --rugnux /path/to/rugnux
```
Python 3.9 or newer with `gemmi` and `numpy` is all the runner needs.
### The checks
A set passes only when every check holds. The thresholds are in `timing.json`.
| check | passes when | why |
|---|---|---|
| `space_group` | the battery's own verdict on lattice and space group passes (the hand excepted) | the data's own determination is right |
| `placed` | `MODEL_PLACEMENT` is `MR` | molecular replacement placed the model, and the R-factors, maps and model written are of that placement |
| `mr_group` | `MR_SPACE_GROUP` is the deposited group, hand included (P 21 21 21, P 21 21 2, P 1 21 1 and I 2 2 2 for `standard`; P 1 21 1 and P 32 2 1 for the two in-house sets) | the model settles the hand, so this is where a wrong enantiomorph shows |
| `r_free` | `R_FREE` ≤ 0.50 for the predictions, ≤ 0.35 for 4NWV | a random structure gives 0.586 |
| `confirmed` | `CC_MODEL_CONFIRMED_TO_D_MIN` is not `NONE` | some shell's CC(model, data) clears rugnux's empirical null |
| `placement_cc` | the placed `p_model.pdb` and the reference model have correlated intensities, ≥ 0.30 | see below |
`placement_cc` is the one check that does **not** depend on rugnux's own scores. It computes |F|²
from the placed model and from the battery's reference model for the set, each in its own cell and
space group. It normalises each resolution shell to its mean, from 10 to 4 Å, and takes the best
correlation over the changes of basis the two lattices allow. |F| is the same for every allowed
origin and every symmetry copy, so no superposition is needed. A right placement correlates
strongly (measured 0.55 to 0.96). A wrong placement gives 0.01 to 0.04, and the wrong hand gives
about 0.2. This catches a placement that rugnux reports as right but is not.
### Reading the output
The console prints one block per set: the verdict, rugnux's wall time and the MR time, one line per
check (`ok` or `BAD`, the value, the limit), and the step times. For example:
```
4nwv pass wall 167.2 s MR 52.0 s
ok space_group I 2 2 2 (want I 2 2 2)
ok placed MR (want MR)
...
Reading input 0.2, Pre-scan 4.6, Indexing 5.5, Integration (geometry pre-pass) 11.8, ...
```
The run directory is an ordinary one, so `list`, `table`, `compare` and `keep` all work on it. Its
`report.md` opens with a **Timing benchmark** section: the checks per set, then a table with one row
per step and one column per set. Each `results.json` row carries `bench_verdict`, `bench_reason`,
`bench_checks`, `mr_seconds` and `steps`, the seconds per step. `wall_s` is rugnux's own
`WALL_TIME`, so time spent waiting for a GPU queue is not in it. The run exits with status 1 when a
set fails a check or crashes. A wrong answer is a failure even when the time is good.
If the site config names a `baseline_timing` for the benchmark (`{"standard": RUN, "inhouse": RUN}`,
earlier timing runs on this machine), the report is compared against it. Otherwise pass
`--baseline RUN`. Compare timing runs only from the same machine and the same benchmark.
### CPU only
```
$B timing --rugnux $R --extra=--no-gpu
```
This runs a CUDA build as if the machine had no GPU: CUDA is never touched and every step takes its
CPU path. Write the option with `=`, because `--extra --no-gpu` makes Python's argument parser take
`--no-gpu` for an option of its own. Several options go in quotes: `--extra="--no-gpu -N 8"`. A CPU
run and a GPU run agree to rounding, not bit for bit, so their last digits can differ.
## Taking timings that mean something
A wall-clock number is only useful if someone else can reproduce it and if two numbers can be
compared. Each lesson below was learned by getting a timing wrong first.
### Build like CI
The build system sets no `-march` and no LTO, so that each site can choose. CI and the released
binaries are built with `-march=x86-64-v3 -flto=auto` on Linux x86 and `/arch:AVX` with MSVC on
Windows. A plain `cmake -DCMAKE_BUILD_TYPE=Release` build is a baseline binary that nobody ships. GPU
work runs at the same speed in it, but the CPU-bound phases (first-pass indexing, scaling and
merging) are **about 26% slower**. A profile of that build also points at the wrong code: Eigen has
no vectorised `round` below SSE4.1. For any CPU-side measurement, build with at least
`-DCMAKE_CXX_FLAGS="-march=x86-64-v3" -DCMAKE_C_FLAGS="-march=x86-64-v3"`. On ARM (a GB10 or a
Grace) and on Apple Silicon, leave `-march` out: the default target already has NEON and a
vectorised round. The runner records the flags in `manifest.json`, so always state them.
### Do not time the first run after a build
The first runs straight after a build are not representative. On a Windows laptop on 2026-10-14,
the runs started immediately after a 500-target build were 1.3 to 2 times slower in every phase:
GPU image loop, file reads, everything. Rerun back to back, the same binaries matched to within a
few percent. Let the machine settle (the build's own I/O, antivirus scans of new binaries, a hot
CPU on a laptop), then make a throw-away run first.
### A quiet machine
On a shared machine, another job's load makes wall-clock numbers and parallel-efficiency numbers
meaningless: they measure the other job. Profiles that report **shares of cycles** (`perf`, Nsight)
still show where the time goes, but not how long it took. If the numbers are the point, take the
machine to yourself. Check `uptime` and `nvidia-smi` before and after, and say what else was running.
On a laptop, plug it in and keep it in the same power mode: on 2026-10-16 two back-to-back runs of
4nwv on the same binary on a Windows laptop took 167 and 206 s, with the growth spread over every
CPU- and GPU-heavy step. That is thermals and power, not code. Treat such a spread as the machine's
noise.
### Interleave: ABAB, not AB
One run of the old binary against one of the new says almost nothing. Run them **interleaved**, old,
new, old, new, on the same two or three sets, in one session on a quiet machine, and compare the
pairs. Slow drift (the page cache, thermals, another user) then affects both sides alike. Keep the
old binary for this; the run directory's `bin/rugnux` is one. Several minutes of ABAB have settled
questions that a whole battery's totals had answered wrongly, more than once.
### The page cache
The battery and the benchmark read every image from disk. Whether those files are still in the
operating system's page cache can change a set's time several-fold, with every result identical. On
the PSI workstation, whose data disk is a 7200 rpm HDD, one 10 GB set took 15 s warm and 111 s cold.
A whole battery once swung from 11 to 19 minutes only because a large job beforehand had pushed its
data out of the cache. The signs are:
- the slowdown scales with each set's size on disk, not with its content;
- it can be bimodal: the sets still cached are unchanged to the second, while the evicted ones are
several times slower;
- divide a set's extra seconds by its size: if the result is the disk's read speed, it is the disk;
- rugnux reads the frames in each of its passes, and on a machine that can hold the set in RAM the
later passes are always warm. A real regression slows them too, while a cold read slows only the
first pass (compare the `Integration (geometry pre-pass)` step with `Integration (refined
geometry)` in the log).
Either time warm runs (one throw-away run first, and enough RAM to hold the set) or time cold ones
deliberately (on Linux, drop the file from the cache with `posix_fadvise(POSIX_FADV_DONTNEED)` or
`echo 1 > /proc/sys/vm/drop_caches`). Either way, **say which**. A machine whose RAM is about the
size of a set cannot keep it warm, so on a 16 GB Mac a 16 GB set always reads cold.
### The GPU lock
On a machine where several people or agents share the GPUs, the site's `gpulock` serialises the
jobs. `battery.py --gpulock` takes the lock for each rugnux separately, so other jobs can run
between sets, and the run is marked "timing NOT a reference". For a timing session, take the lock
**once** around the whole session instead, so that nothing else gets in between your A and B runs:
```
flock /data/tmp/gpulock bash -c "
$B timing --rugnux old/rugnux --label A1
$B timing --rugnux new/rugnux --label B1
$B timing --rugnux old/rugnux --label A2
$B timing --rugnux new/rugnux --label B2"
```
(`/data/tmp/gpulock` is the lock file at PSI; use your site's.)
The runner still checks for other processes on the GPU before each set (`gpu_others` in
`results.json`). A machine without such a lock, a laptop for example, sets `"gpulock": null` and runs
without `--gpulock`.
### Report the hardware and the conditions
`manifest.json` records the CPU, cores and threads, RAM and GPUs on Linux. On Windows and macOS it
may say "not recorded", so write the hardware down yourself. A timing means nothing without: the
machine (CPU, RAM, GPU and its memory, driver), the OS, the binary (git hash, sha256, build flags,
compiler and CUDA version), `-N` if set, warm or cold data, what else was running, mains or battery
power, and which runs are the first after a build.
### Windows
- Run the runner as `python -X utf8 tools\battery\battery.py ...`, because the report and the
console table are UTF-8. Give `--rugnux` the path of `rugnux.exe`. The run directory gets its own
copy of the binary, so any DLL it needs must be found on `PATH`.
- **WDDM can quietly page GPU memory to system RAM.** A consumer or laptop card under Windows' WDDM
driver model does not fail an allocation that exceeds the card. It spills to "shared GPU memory"
in system RAM, and the kernels touching it slow down several-fold with no error. rugnux decides
between GPU and CPU from the card's total memory (80% for scaling and the model fits), but memory
held from earlier phases and the driver's own reserve count too. During a run, watch Task
Manager's **Dedicated** and **Shared GPU memory** for the process: if the shared figure grows, the
timing measures paging. On 2026-10-15 an 8 GB laptop card spent five times longer in model
validation for this reason alone.
- Windows has no `flock` and no process groups. The timing benchmark does not need them.
### macOS
- The macOS build has no CUDA. Bragg integration, the model density and the molecular-replacement
rotation function run on the Apple GPU through Metal. Everything else runs on the CPU, so a Mac
timing is mostly a CPU-bound timing. Molecular replacement in particular takes much longer than on
a CUDA machine, as its translation function and rigid body stay on the CPU.
- Memory is unified: CPU and GPU share it, and the OS compresses memory before it swaps. A 16 GB Mac
can run the benchmark, but its biggest set comes close to the limit, so report both measures
below.
### Memory peaks
The battery does not record memory, so measure it next to a timing when it matters. Each row's
`cmd` in `results.json` is the exact command a set ran, so you can rerun that command by hand under
a tool that measures:
- **Linux:** `/usr/bin/time -v <cmd>` reports *Maximum resident set size*, which is `VmHWM` in
`/proc/<pid>/status` while the process runs. RSS is not the same as live data: glibc keeps freed
memory in its arenas, and pinned host buffers for the GPU count too. On one large set about
0.5 GB of the peak was memory freed but not yet returned to the system, and up to 2.4 GB on an
older build.
- **macOS:** `/usr/bin/time -l <cmd>` reports both the maximum RSS and the *peak memory footprint*,
which includes compressed memory and GPU buffers and can be much larger. On 2026-10-16, 4nwv
showed 9.1 GiB of RSS against about 22 GiB of footprint.
- **Windows:** sample the process's peak working set (PowerShell
`(Get-Process rugnux).PeakWorkingSet64`) while it runs, and Task Manager's dedicated and shared
GPU memory.
- **GPU:** `nvidia-smi --query-gpu=memory.used --format=csv -lms 500` while the set runs. rugnux's
log also states each GPU-or-CPU decision with its numbers, for example
`Model validation: ... 4.42 GB held of a 6.84 GB budget, 80% of the card's 8.55 GB` and
`... scaling on the CPU instead`. Quote those lines; they explain most surprises between machines.
[Installing Rugnux ▸ Memory](RUGNUX_INSTALL.md#memory) explains what sets the peak.
### Per-step times: the TIMING lines
rugnux writes its full log to `<prefix>_log.txt` (`p_log.txt` in the battery) at debug level, and
every timed section adds a line when it ends:
```
[2026-10-16 07:42:45.430] [rugnux] [debug] TIMING step Integration (geometry pre-pass), 500 images 11.800
[2026-10-16 07:42:52.230] [Rugnux] [debug] TIMING merge P1 4.697
```
The `TIMING step ...` lines are the steps shown on the console, and they add up to the run's wall
time. These are what the benchmark tabulates. The other `TIMING ...` lines are nested sections
inside a step (`rsm ...` sections inside `merge ...`, everything in a pass before its
`pass (...)` line). `tools/rugnux_timing.py p_log.txt` tabulates them, and `--all` lists every
section. For a rotation run the steps are, in order:
| step | covers |
|---|---|
| Reading input | opening the master file, geometry and mask |
| Pre-scan | a projection of a sample of frames: beam stop, background beam centre, spot widths, defective pixels |
| Indexing | first-pass spot finding and lattice search on the first part of the sweep |
| Integration (geometry pre-pass), N images | integrating the first part of the sweep at the file's geometry |
| Scaling and post-refinement | scaling that pre-pass and post-refining the detector geometry, cell and axis from it |
| Metric-symmetry check, Geometry check | only where needed: re-integration of a promoted lattice class, more geometry rounds |
| Indexing at the refined geometry | re-indexing with the refined geometry |
| Integration (refined geometry), N images | the whole sweep, every frame |
| Scaling and merging | combining partials, scaling, the space-group search, the resolution cut, merging |
| Analyses | twinning, anisotropy and the other report-only analyses |
| Model validation | `--model` only: structure factors, scale fits, rigid-body placement, maps |
| Molecular replacement | only where the rigid body did not place the model (always, in the timing benchmark) |
| Writing files | MTZ, mmCIF, HKL, report |
A step that is slower on one machine than on another points at the code, and a step that is slower
everywhere at once points at the conditions. Send the whole step table, not only the total.
### NVTX ranges for Nsight Systems
A CUDA build compiled with the NVTX headers (found next to `nvcc`; otherwise the configure says
`NVTX3 headers not found - NVTX ranges compiled out`) opens an NVTX range for every console step
(`Step <n> <title>`) and for every `TIMING` section, with the same names. An Nsight Systems trace
therefore shows the pipeline by name above the CUDA kernels and copies:
```
nsys profile --trace=cuda,nvtx,osrt -o 4nwv_trace <the set's cmd from results.json>
```
Without a profiler attached, the ranges cost nothing.
## What to send back
A remote measurement is only useful if the owner can compare it with the others. Send one archive
(a "pack") per session, named after the machine, the binary and the date, for example
`rugnux_<machine>_<git>_<YYYYMMDD>.tar.gz`, laid out like this:
```
BRIEF.md
runs/<n>_<what>/report.md # unedited, as the runner wrote them
runs/<n>_<what>/report.html
runs/<n>_<what>/results.json
runs/<n>_<what>/manifest.json
runs/<n>_<what>/SOURCE_RUN.txt # the run directory's original path
runs/<n>_<what>/logs/<set>/{p_log.txt,p_report.txt,run.log}
timing/ # step tables, ABAB comparisons
memory/ # peak RSS / footprint / GPU memory samples, with how they were taken
build/ # the exact configure command (or CMakeCache.txt flags)
```
`BRIEF.md` comes first and should be short: the host and hardware, the OS, the binary (git, sha256,
build flags, compiler, CUDA), the data (which pack, warm or cold), the conditions (quiet or shared,
power, first run after a build), and then the results. Give a table per run, the step table of the
slowest set, what changed against the previous pack from the same machine, and what is not
explained yet together with the experiment that would explain it. Number the runs in the order they
were made, and say which ones are not representative and why (`1_` straight after the build, for
example).
Leave out the maps, MTZ files and binaries unless they are asked for, since they are large and the
logs and reports carry the numbers. Never edit a `report.md` or `results.json`: comment in the
brief instead. If you had to patch the runner to make it work, include the patch and say so. A pack
never contains anything from the private arm.