//! Machine-readable benchmark output. //! //! The printed tables are for reading; this is for diffing. Without a stable //! serialised form every number is a one-off observation, so "did that change //! help" can only be answered by eyeballing two terminal scrollbacks. //! //! Set `AF_BENCH_JSON` to a path to emit one object per run: //! //! AF_BENCH_JSON=/tmp/base.json cargo run --release -p audiofiles-bench -- ingest //! # ...change something... //! AF_BENCH_JSON=/tmp/new.json cargo run --release -p audiofiles-bench -- ingest //! diff <(jq -S . /tmp/base.json) <(jq -S . /tmp/new.json) //! //! `schema` is bumped whenever a key changes meaning, so an old baseline cannot //! be silently compared against a new run measuring something different. use std::io::Write; use std::time::{SystemTime, UNIX_EPOCH}; use serde_json::{Map, Value}; /// Bump when an existing key changes meaning or unit. Adding a key is not a /// schema change; a comparison tool can treat a missing key as "not measured". const SCHEMA: u32 = 1; pub(crate) struct Report { mode: &'static str, metrics: Map, storage: Map, } impl Report { pub(crate) fn new(mode: &'static str) -> Self { Self { mode, metrics: Map::new(), storage: Map::new(), } } /// Record the drive backing a path, under a role name (`corpus`, `vault`). /// /// Keyed by role rather than collapsed into one entry because a run can /// straddle two drives: reading a corpus from the external disk while /// writing the vault to the internal one is a different measurement from /// either drive alone, and the JSON has to say which. pub(crate) fn set_storage(&mut self, role: &str, storage: &crate::storage::Storage) { self.storage.insert(role.to_string(), storage.to_json()); } /// Record one metric. Keys are `snake_case` and carry their unit /// (`_ms`, `_mb_s`, `_pct`) so a diff is readable without the schema. pub(crate) fn set(&mut self, key: &str, value: impl Into) { self.metrics.insert(key.to_string(), value.into()); } /// Write what has been recorded so far, without announcing it. /// /// The runs worth recording are the long ones, and those are exactly the /// ones likely to be cut short: a 50,000-file import killed part way used /// to produce no JSON at all, because [`Report::write`] runs once at the /// end. Callers with a batch loop call this after each batch so the file on /// disk is never more than one batch behind the terminal. /// /// Silent on success (a checkpoint per batch would otherwise bury the batch /// table it sits under) and silent on repeat failures, but the first /// failure is reported: a checkpoint that cannot write is how a run ends up /// with nothing again, so it must not fail invisibly. pub(crate) fn checkpoint(&self) { static REPORTED: std::sync::Once = std::sync::Once::new(); if let Err(e) = self.write_json() { REPORTED.call_once(|| eprintln!("\n could not checkpoint AF_BENCH_JSON: {e}")); } } /// Write to `AF_BENCH_JSON` if set. A run with the variable unset is the /// normal case and must stay silent. pub(crate) fn write(&self) { if std::env::var("AF_BENCH_JSON").is_err() { return; } match self.write_json() { Ok(Some(path)) => println!("\n wrote {path}"), Ok(None) => {} // A failed write must not look like a failed benchmark. Err(e) => eprintln!("\n could not write AF_BENCH_JSON: {e}"), } } /// Render the current state and replace the file at `AF_BENCH_JSON`. /// /// Returns the path written, or `None` when the variable is unset. Every /// call rewrites the whole object rather than appending, so a run killed /// between two calls leaves a complete, parseable document rather than a /// truncated one. fn write_json(&self) -> std::io::Result> { let Ok(path) = std::env::var("AF_BENCH_JSON") else { return Ok(None); }; let mut root = Map::new(); root.insert("schema".into(), SCHEMA.into()); root.insert("mode".into(), self.mode.into()); // Unix seconds rather than a formatted date: no date dependency in this // crate, and a comparison tool wants an integer anyway. if let Ok(d) = SystemTime::now().duration_since(UNIX_EPOCH) { root.insert("unix_time".into(), d.as_secs().into()); } if let Some(rss) = peak_rss_mb() { root.insert("peak_rss_mb".into(), rss.into()); } // Conditions sit next to the numbers, not inside metrics: they are what // the run was taken under, and a comparison tool has to read them // before deciding whether two runs are comparable at all. if !self.storage.is_empty() { root.insert("storage".into(), Value::Object(self.storage.clone())); } if let Some(mem) = crate::storage::mem_total_mb() { root.insert("mem_total_mb".into(), mem.round().into()); } root.insert("metrics".into(), Value::Object(self.metrics.clone())); let text = serde_json::to_string_pretty(&Value::Object(root)).unwrap_or_default(); std::fs::File::create(&path).and_then(|mut f| f.write_all(text.as_bytes()))?; Ok(Some(path)) } } /// Peak resident set size in MB, high-water for the whole process lifetime. /// /// `VmHWM` rather than `VmRSS`: sampling current RSS after a run misses the /// peak, which is the number that decides whether a large import fits in /// memory. Linux only; returns None elsewhere, and callers treat that as "not /// measured" rather than zero. pub(crate) fn peak_rss_mb() -> Option { let status = std::fs::read_to_string("/proc/self/status").ok()?; let line = status.lines().find(|l| l.starts_with("VmHWM:"))?; let kb: f64 = line.split_whitespace().nth(1)?.parse().ok()?; Some(kb / 1024.0) }