Files
DarkRoom/tools/bench/src/main.rs
T
dtourolle 84fade99ec Put the developer docs under docs/dev and index the folder for users first
docs/ had 26 developer documents flat beside the manual, and the two
audiences are very differently sized: most readers want the manual and
the gesture reference, a few want the register, the designs and the
measurements. The manual and gestures.md stay at the top; everything for
someone changing the code moves to docs/dev/, and the two documents that
name their own successors — the v0.1 milestone and the UI-refinement plan
— go to docs/dev/archive/ rather than being deleted, since both are still
cited. docs/README.md is the index, users first.

Every reference follows: code comments, Cargo manifests, the workflows,
the pre-commit hook, the bench and traceability tools (which locate the
repo root by docs/dev/requirements.md now), packaging, the Docker READMEs,
CLAUDE.md, CONTRIBUTING.md and the README. The matrix links one level
deeper and is regenerated. Links out of the moved documents into the tree
gain a level; a link checker over every Markdown file finds none broken.
2026-09-20 21:16:03 +02:00

541 lines
19 KiB
Rust

//! The benchmark suite `docs/dev/requirements.md` §8 has been promising.
//!
//! §8 says performance is verified by *"an automated benchmark suite against a
//! synthetic 50k catalog, run per-commit … A regression beyond stated
//! tolerance fails the build."* Until this crate there was none: no `benches/`,
//! no `[[bench]]`, no criterion, no fixture. Ten performance requirements could
//! therefore be neither passed nor failed, and five of them carried a
//! requirement tag anyway.
//!
//! ```sh
//! cargo run --release -p dr-bench -- check # measure and gate
//! cargo run --release -p dr-bench -- check --reference # …on the reference desktop
//! cargo run --release -p dr-bench -- record --reference # rewrite the baseline
//! ```
//!
//! **Release, always.** The workspace builds its own crates at `opt-level = 0`
//! in dev (see the root `Cargo.toml`), and every number here is dominated by
//! this workspace's own code — the JPEG decode, the box filter, the resample,
//! the sharpen. A debug run measures rustc's shadow, exactly as
//! `core/dr-gpu/examples/frame_budget.rs` warns for the same reason. The
//! harness says so at the top of every report rather than trusting anyone to
//! remember.
//!
//! # What this covers, and what it deliberately does not
//!
//! Covered, with a gate:
//!
//! - **NFR-P1** and R2's catalog clause — opening a 50k catalog and painting
//! the first grid ([`catalog_open`]).
//! - **NFR-P3** — thumbnail throughput on the embedded preview path
//! ([`thumbnails`]).
//!
//! Measured, reported, and honestly *not* tagged, because only part of the
//! requirement is in reach without a GPU or a running UI:
//!
//! - **NFR-P7** — the encode half of a 24 MP export ([`exporting`]). A
//! one-sided gate: it can fail the requirement, it cannot pass it.
//! - **NFR-P8** — the catalog layer's share of idle RSS ([`memory`]), together
//! with the answer to the question §4.1 asks about GPU memory.
//!
//! Out of scope entirely, and left to say so rather than faked: NFR-P2, P4,
//! P5, P6, P9, P10, P11, P12, P13, P14 and P15. Every one of them needs a
//! frame-timing probe inside a running Slint application, a GPU adapter, or
//! both. The GPU half of the suite that *does* exist is
//! `core/dr-gpu/tests/frame_budget.rs`, which asserts FR-DSP-3 and skips itself
//! where there is no adapter; `.gitea/workflows/benchmark.yml` runs it as its
//! own job for exactly that reason.
//!
//! # Exit codes
//!
//! `0` everything inside its budget and its tolerance; `1` a gate failed; `2`
//! the harness itself could not run. Distinguished because a CI log that says
//! "failed" should not leave anyone guessing whether the code got slower or the
//! fixture would not build.
mod baseline;
mod catalog_open;
mod exporting;
mod fixture;
mod memory;
mod stats;
mod thumbnails;
use std::collections::BTreeMap;
use std::path::PathBuf;
use anyhow::Result;
use baseline::{Baseline, Judging};
/// The seed the committed baseline describes.
///
/// Changing it invalidates every recorded figure, because it changes the
/// library being measured. That is why it is a constant here and a field in
/// the stamp rather than something a flag quietly varies.
const SEED: u64 = 20_260_829;
/// Rows in the synthetic library. §8 says 50k; this is that.
const IMAGES: usize = 50_000;
/// Thumbnails produced for the throughput row.
///
/// Twelve hundred rather than fifty thousand. At the target rate the whole
/// library is eight minutes of CI, and a rate measured over 1,200 images is
/// the same rate — the sweep has no state that changes after the first chunk,
/// which the per-image percentile alongside it is there to demonstrate.
const THUMBNAILS: usize = 1_200;
/// Windows fetched for the scroll row.
///
/// A hundred, so nearest-rank puts the 99th percentile on the second-worst —
/// the same reading `core/dr-gpu/examples/frame_budget.rs` takes of a hundred
/// frames, and the reason it takes it: one bad one in a hundred is one too
/// many, and a single scheduler hiccup on an unrelated process should not
/// decide the verdict on its own.
const WINDOWS: usize = 100;
fn main() {
env_logger::init();
match run() {
Ok(true) => {}
Ok(false) => std::process::exit(1),
Err(e) => {
eprintln!("dr-bench: {e:#}");
std::process::exit(2);
}
}
}
/// Returns whether every gate passed.
fn run() -> Result<bool> {
let mut args = std::env::args().skip(1);
let command = args.next().unwrap_or_else(|| "help".to_string());
// The probe takes a path and nothing else — see `memory` for why it is a
// separate process rather than a function call.
if command == "memory-probe" {
let path = args
.next()
.ok_or_else(|| anyhow::anyhow!("memory-probe needs a catalog path"))?;
memory::probe(&PathBuf::from(path))?;
return Ok(true);
}
let mut reference = false;
let mut fixture_dir = fixture::default_dir();
let mut baseline_path = Baseline::default_path()?;
let mut lanes = thumbnails::default_lanes();
let mut thumbnail_count = THUMBNAILS;
while let Some(flag) = args.next() {
match flag.as_str() {
"--reference" => reference = true,
"--fixture" => fixture_dir = PathBuf::from(expect_value(&mut args, "--fixture")?),
"--baseline" => baseline_path = PathBuf::from(expect_value(&mut args, "--baseline")?),
"--lanes" => lanes = expect_value(&mut args, "--lanes")?.parse()?,
"--thumbnails" => thumbnail_count = expect_value(&mut args, "--thumbnails")?.parse()?,
other => anyhow::bail!("unknown flag {other}; try `dr-bench help`"),
}
}
match command.as_str() {
"run" => measure_and_report(
&fixture_dir,
&baseline_path,
reference,
lanes,
thumbnail_count,
Mode::Report,
),
"check" => measure_and_report(
&fixture_dir,
&baseline_path,
reference,
lanes,
thumbnail_count,
Mode::Gate,
),
"record" => measure_and_report(
&fixture_dir,
&baseline_path,
reference,
lanes,
thumbnail_count,
Mode::Record,
),
"help" | "--help" | "-h" => {
print_help();
Ok(true)
}
other => anyhow::bail!("unknown command {other}; try `dr-bench help`"),
}
}
fn expect_value(args: &mut impl Iterator<Item = String>, flag: &str) -> Result<String> {
args.next()
.ok_or_else(|| anyhow::anyhow!("{flag} needs a value"))
}
/// What a run does with what it measured.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
/// Print, judge nothing.
Report,
/// Print and fail the build on a violated budget or a regression.
Gate,
/// Print and rewrite the committed baseline from what was measured.
Record,
}
fn print_help() {
println!(
"\
dr-bench — DarkRoom's performance suite (docs/dev/requirements.md §8)
run measure and print, judging nothing
check measure, print, and exit 1 on a violated budget or a regression
record measure and rewrite docs/dev/bench-baseline.json from the result
Flags:
--reference this machine is the reference desktop, so machine-sensitive
budgets are asserted rather than reported
--fixture <dir> where the synthetic 50k catalog lives (or $DR_BENCH_DIR)
--baseline <file> the committed numbers (default docs/dev/bench-baseline.json)
--lanes <n> sweep lanes for the thumbnail row (default: CPU threads)
--thumbnails <n> images in the thumbnail row (default 1200)
Build it in release. A debug build measures the compiler, not the pipeline —
see the module documentation and core/dr-gpu/examples/frame_budget.rs."
);
}
// ---------------------------------------------------------------------------
// The run
// ---------------------------------------------------------------------------
fn measure_and_report(
fixture_dir: &std::path::Path,
baseline_path: &std::path::Path,
reference: bool,
lanes: usize,
thumbnail_count: usize,
mode: Mode,
) -> Result<bool> {
let machine = baseline::machine_id();
println!("DarkRoom benchmark suite — the half that needs no GPU");
println!("machine {machine}");
if cfg!(debug_assertions) {
println!(
"profile DEBUG — every figure below is several times worse than the \
product's. Rerun with --release."
);
} else {
println!("profile release");
}
let (fx, built) = fixture::build(fixture_dir, SEED, IMAGES)?;
println!(
"fixture {} images, {} sources at {}x{}, seed {}, catalog {:.1} MB{}",
fx.stamp.images,
fx.stamp.sources,
fx.stamp.preview_width,
fx.stamp.preview_height,
fx.stamp.seed,
fx.catalog_bytes as f64 / 1e6,
if built { " (built just now)" } else { "" }
);
println!(" {}", fx.dir.display());
println!();
// Memory first, and in its own process. See `memory` for why: measuring
// RSS after the thumbnail sweep would report the sweep's high-water mark
// wearing the catalog's name.
let rss = match memory::in_a_fresh_process(&fx.catalog) {
Ok(rss) => Some(rss),
Err(e) => {
println!("memory unavailable: {e}");
None
}
};
let open = catalog_open::measure(&fx.catalog, WINDOWS)?;
let thumbs = thumbnails::measure(&fx.sources, &fx.thumbs, thumbnail_count, lanes)?;
let exports = exporting::measure()?;
print_details(&open, &thumbs, &exports, rss);
let values = collect(&open, &thumbs, &exports, rss);
let mut base = Baseline::load(baseline_path)?;
if mode == Mode::Record {
if !reference {
println!(
"note recording from a machine that has not declared itself the \
reference desktop.\n §8 records the reference desktop's numbers; \
this overwrites them."
);
}
for (key, value) in &values {
if let Some(metric) = base.metrics.get_mut(key) {
metric.recorded = Some(*value);
}
}
base.recorded_on = Some(machine);
base.recorded_at_unix = Some(baseline::now_unix());
base.fixture = Some(fx.stamp.clone());
base.save(baseline_path)?;
println!("\nrecorded {}", baseline_path.display());
return Ok(true);
}
let same_machine = base.recorded_on.as_deref() == Some(machine.as_str());
let comparable = base.fixture.as_ref().is_some_and(|f| *f == fx.stamp);
let cx = Judging {
reference,
same_machine: same_machine && comparable,
tolerance: base.tolerance,
};
let failures = print_verdict(&base, &values, cx, &machine, comparable);
if failures.is_empty() {
return Ok(true);
}
println!();
for line in &failures {
println!("FAIL {line}");
}
println!();
println!(
" {} gate(s) failed. docs/dev/benchmarks.md says what each metric measures and\n \
docs/dev/bench-baseline.json holds the numbers these used to be.",
failures.len()
);
Ok(mode != Mode::Gate)
}
/// The metric keys, and the measurement each one reads.
///
/// One place, so the report, the gate and the recorded file cannot disagree
/// about what `catalog_open_ms` means.
fn collect(
open: &catalog_open::Open,
thumbs: &thumbnails::Throughput,
exports: &[exporting::EncodeRun],
rss: Option<memory::Rss>,
) -> BTreeMap<String, f64> {
let mut v = BTreeMap::new();
v.insert("catalog_open_ms".to_string(), open.cold_ms);
v.insert("catalog_open_warm_ms".to_string(), open.warm_ms);
v.insert("catalog_window_p99_ms".to_string(), open.window_ms.p99);
v.insert("catalog_filtered_ms".to_string(), open.filtered_ms);
let ips = thumbs.images_per_second;
v.insert("thumbnail_throughput_ips".to_string(), ips);
v.insert(
"thumbnail_per_image_p99_ms".to_string(),
thumbs.per_image.p99,
);
for row in exports {
v.insert(row.key.to_string(), row.times.p99);
}
if let Some(rss) = rss {
v.insert("catalog_idle_rss_mb".to_string(), rss.now_mb());
}
v
}
// ---------------------------------------------------------------------------
// The report
// ---------------------------------------------------------------------------
fn print_details(
open: &catalog_open::Open,
thumbs: &thumbnails::Throughput,
exports: &[exporting::EncodeRun],
rss: Option<memory::Rss>,
) {
println!("Opening the catalog (NFR-P1, and R2's second sentence)");
println!(
" {:>10.1} ms open, count, first window and timeline — cold, first \
connection of the process",
open.cold_ms
);
println!(
" {:>10.1} ms the same four calls on a second connection",
open.warm_ms
);
println!(
" {:>10.1} ms Catalog::open alone (connect, migrate, backfill)",
open.open_only_ms
);
println!(
" {:>10.1} ms count and first window under a rating filter",
open.filtered_ms
);
println!(
" {:>10.2} ms one 400-row window at a random offset, p99 of {WINDOWS} \
(p50 {:.2}, max {:.2})",
open.window_ms.p99, open.window_ms.p50, open.window_ms.max
);
println!(
" {} images, {} monthly timeline buckets",
open.images, open.buckets
);
println!();
println!("Thumbnails on the embedded preview path (NFR-P3: >= 100 img/s)");
println!(
" {:>10.1} img/s over {} images on {} lanes, {:.2} s of wall clock",
thumbs.images_per_second,
thumbs.images,
thumbs.lanes,
thumbs.wall_ms / 1e3
);
println!(
" {:>10.2} ms per image on its lane, p99 (p50 {:.2}, max {:.2})",
thumbs.per_image.p99, thumbs.per_image.p50, thumbs.per_image.max
);
println!(
" {} stored, {} failed",
thumbs.stored, thumbs.failed
);
println!();
println!(" The bytes are in memory before the clock starts, so this is CPU");
println!(" throughput with the fetch already paid for. That is what the target's");
println!(" \"embedded preview path\" names; it is not a claim about a remote library.");
println!();
println!("Exporting 24 MP — the encode half only (NFR-P7 is the whole chain)");
println!(
" {:>14} {:>11} {:>9} {:>9} {:>9}",
"sizing", "output", "p50", "p99", "file"
);
for row in exports {
println!(
" {:>14} {:>5}x{:<5} {:>7.1}ms {:>7.1}ms {:>7.1}MB",
row.label,
row.width,
row.height,
row.times.p50,
row.times.p99,
row.bytes as f64 / 1e6
);
}
println!();
println!(" No GPU render is in these figures, so they cannot pass NFR-P7 — only fail");
println!(" it. See tools/bench/src/exporting.rs for why that is still worth gating.");
println!();
println!("Idle memory with the catalog open (NFR-P8's catalog share)");
match rss {
Some(rss) => {
println!(
" {:>10.1} MB resident after opening 50k and scrolling 10k rows",
rss.now_mb()
);
println!(" {:>10.1} MB peak for that process", rss.peak_mb());
println!();
println!(" RSS is exclusive of device-local GPU memory and this process has no");
println!(" toolkit, no adapter and no decode cache — so it is the catalog layer's");
println!(" share of NFR-P8's 500 MB, not NFR-P8. tools/bench/src/memory.rs holds");
println!(" the answer to the question §4.1 asks, and the change it recommends.");
}
None => println!(" not measured on this platform"),
}
println!();
}
/// Print the gate table and return the failures, one line each.
fn print_verdict(
base: &Baseline,
values: &BTreeMap<String, f64>,
cx: Judging,
machine: &str,
comparable_fixture: bool,
) -> Vec<String> {
println!("Against docs/dev/bench-baseline.json");
match (&base.recorded_on, cx.same_machine) {
(None, _) => println!(
" No baseline has been recorded yet. Budgets are still gated; drift is not.\n \
Run `dr-bench record --reference` on the reference desktop and commit the diff."
),
(Some(on), true) => println!(" Recorded on {on} — drift below is a verdict."),
(Some(on), false) if !comparable_fixture => println!(
" Recorded on {on} against a different fixture; drift below is information only."
),
(Some(on), false) => println!(
" Recorded on {on}, and this is {machine}. Drift below is information, not a verdict."
),
}
if !cx.reference {
println!(
" Not the reference desktop (--reference), so machine-sensitive budgets are\n \
reported rather than asserted — §8 names the reference desktop, not CI."
);
}
println!();
println!(
" {:<30} {:>12} {:>13} {:>12} {:>8} verdict",
"metric", "measured", "budget", "baseline", "drift"
);
let mut failures = Vec::new();
for (key, measured) in values {
let Some(metric) = base.metrics.get(key) else {
println!(
" {key:<30} {measured:>12.2} {:>13} {:>12} {:>8} not in the baseline",
"—", "—", "—"
);
continue;
};
let j = metric.judge(*measured, cx);
let budget = match (metric.budget, metric.direction) {
(None, _) => "—".to_string(),
(Some(b), baseline::Direction::LowerIsBetter) => format!("< {b:.1}"),
(Some(b), baseline::Direction::HigherIsBetter) => format!("> {b:.1}"),
};
let recorded = match metric.recorded {
Some(r) => format!("{r:.2}"),
None => "—".to_string(),
};
let drift = match j.drift {
Some(d) => format!("{:+.1}%", d * 100.0),
None => "—".to_string(),
};
let mut verdict = String::new();
if j.over_budget {
verdict.push_str("OVER BUDGET ");
failures.push(format!(
"{key} is {measured:.2} {}, past {budget} ({})",
metric.unit, metric.requirement
));
}
if j.regressed {
verdict.push_str("REGRESSED ");
failures.push(format!(
"{key} drifted {drift} against the baseline, past the {:.0}% tolerance",
cx.tolerance * 100.0
));
}
if verdict.is_empty() {
verdict.push_str(if j.budget_deferred {
"ok (budget deferred)"
} else {
"ok"
});
}
println!(" {key:<30} {measured:>12.2} {budget:>13} {recorded:>12} {drift:>8} {verdict}");
}
// A metric in the file that nothing measured is a harness that has drifted
// from its own record, and is worth saying out loud rather than leaving as
// a row that quietly stopped appearing.
for key in base.metrics.keys() {
if !values.contains_key(key) {
println!(" {key:<30} {:>12} measured nothing this run", "—");
}
}
failures
}