Skip to main content

max / audiofiles

6.0 KB · 140 lines History Blame Raw
1 //! Machine-readable benchmark output.
2 //!
3 //! The printed tables are for reading; this is for diffing. Without a stable
4 //! serialised form every number is a one-off observation, so "did that change
5 //! help" can only be answered by eyeballing two terminal scrollbacks.
6 //!
7 //! Set `AF_BENCH_JSON` to a path to emit one object per run:
8 //!
9 //! AF_BENCH_JSON=/tmp/base.json cargo run --release -p audiofiles-bench -- ingest
10 //! # ...change something...
11 //! AF_BENCH_JSON=/tmp/new.json cargo run --release -p audiofiles-bench -- ingest
12 //! diff <(jq -S . /tmp/base.json) <(jq -S . /tmp/new.json)
13 //!
14 //! `schema` is bumped whenever a key changes meaning, so an old baseline cannot
15 //! be silently compared against a new run measuring something different.
16
17 use std::io::Write;
18 use std::time::{SystemTime, UNIX_EPOCH};
19
20 use serde_json::{Map, Value};
21
22 /// Bump when an existing key changes meaning or unit. Adding a key is not a
23 /// schema change; a comparison tool can treat a missing key as "not measured".
24 const SCHEMA: u32 = 1;
25
26 pub(crate) struct Report {
27 mode: &'static str,
28 metrics: Map<String, Value>,
29 storage: Map<String, Value>,
30 }
31
32 impl Report {
33 pub(crate) fn new(mode: &'static str) -> Self {
34 Self {
35 mode,
36 metrics: Map::new(),
37 storage: Map::new(),
38 }
39 }
40
41 /// Record the drive backing a path, under a role name (`corpus`, `vault`).
42 ///
43 /// Keyed by role rather than collapsed into one entry because a run can
44 /// straddle two drives: reading a corpus from the external disk while
45 /// writing the vault to the internal one is a different measurement from
46 /// either drive alone, and the JSON has to say which.
47 pub(crate) fn set_storage(&mut self, role: &str, storage: &crate::storage::Storage) {
48 self.storage.insert(role.to_string(), storage.to_json());
49 }
50
51 /// Record one metric. Keys are `snake_case` and carry their unit
52 /// (`_ms`, `_mb_s`, `_pct`) so a diff is readable without the schema.
53 pub(crate) fn set(&mut self, key: &str, value: impl Into<Value>) {
54 self.metrics.insert(key.to_string(), value.into());
55 }
56
57 /// Write what has been recorded so far, without announcing it.
58 ///
59 /// The runs worth recording are the long ones, and those are exactly the
60 /// ones likely to be cut short: a 50,000-file import killed part way used
61 /// to produce no JSON at all, because [`Report::write`] runs once at the
62 /// end. Callers with a batch loop call this after each batch so the file on
63 /// disk is never more than one batch behind the terminal.
64 ///
65 /// Silent on success (a checkpoint per batch would otherwise bury the batch
66 /// table it sits under) and silent on repeat failures, but the first
67 /// failure is reported: a checkpoint that cannot write is how a run ends up
68 /// with nothing again, so it must not fail invisibly.
69 pub(crate) fn checkpoint(&self) {
70 static REPORTED: std::sync::Once = std::sync::Once::new();
71 if let Err(e) = self.write_json() {
72 REPORTED.call_once(|| eprintln!("\n could not checkpoint AF_BENCH_JSON: {e}"));
73 }
74 }
75
76 /// Write to `AF_BENCH_JSON` if set. A run with the variable unset is the
77 /// normal case and must stay silent.
78 pub(crate) fn write(&self) {
79 if std::env::var("AF_BENCH_JSON").is_err() {
80 return;
81 }
82 match self.write_json() {
83 Ok(Some(path)) => println!("\n wrote {path}"),
84 Ok(None) => {}
85 // A failed write must not look like a failed benchmark.
86 Err(e) => eprintln!("\n could not write AF_BENCH_JSON: {e}"),
87 }
88 }
89
90 /// Render the current state and replace the file at `AF_BENCH_JSON`.
91 ///
92 /// Returns the path written, or `None` when the variable is unset. Every
93 /// call rewrites the whole object rather than appending, so a run killed
94 /// between two calls leaves a complete, parseable document rather than a
95 /// truncated one.
96 fn write_json(&self) -> std::io::Result<Option<String>> {
97 let Ok(path) = std::env::var("AF_BENCH_JSON") else {
98 return Ok(None);
99 };
100 let mut root = Map::new();
101 root.insert("schema".into(), SCHEMA.into());
102 root.insert("mode".into(), self.mode.into());
103 // Unix seconds rather than a formatted date: no date dependency in this
104 // crate, and a comparison tool wants an integer anyway.
105 if let Ok(d) = SystemTime::now().duration_since(UNIX_EPOCH) {
106 root.insert("unix_time".into(), d.as_secs().into());
107 }
108 if let Some(rss) = peak_rss_mb() {
109 root.insert("peak_rss_mb".into(), rss.into());
110 }
111 // Conditions sit next to the numbers, not inside metrics: they are what
112 // the run was taken under, and a comparison tool has to read them
113 // before deciding whether two runs are comparable at all.
114 if !self.storage.is_empty() {
115 root.insert("storage".into(), Value::Object(self.storage.clone()));
116 }
117 if let Some(mem) = crate::storage::mem_total_mb() {
118 root.insert("mem_total_mb".into(), mem.round().into());
119 }
120 root.insert("metrics".into(), Value::Object(self.metrics.clone()));
121
122 let text = serde_json::to_string_pretty(&Value::Object(root)).unwrap_or_default();
123 std::fs::File::create(&path).and_then(|mut f| f.write_all(text.as_bytes()))?;
124 Ok(Some(path))
125 }
126 }
127
128 /// Peak resident set size in MB, high-water for the whole process lifetime.
129 ///
130 /// `VmHWM` rather than `VmRSS`: sampling current RSS after a run misses the
131 /// peak, which is the number that decides whether a large import fits in
132 /// memory. Linux only; returns None elsewhere, and callers treat that as "not
133 /// measured" rather than zero.
134 pub(crate) fn peak_rss_mb() -> Option<f64> {
135 let status = std::fs::read_to_string("/proc/self/status").ok()?;
136 let line = status.lines().find(|l| l.starts_with("VmHWM:"))?;
137 let kb: f64 = line.split_whitespace().nth(1)?.parse().ok()?;
138 Some(kb / 1024.0)
139 }
140