Skip to main content

tak_cli/
measure.rs

1//! Measurement backends.
2//!
3//! Two tiers, deliberately separated:
4//!
5//! - **Deterministic** (`instructions`) — reproducible to ~0.02% run-to-run and
6//!   ~0.035% across wildly different machine load. This is the only tier that may
7//!   gate CI.
8//! - **Timing** (`wall_*`) — recorded and charted, never gated. On a quiet 32-core
9//!   host wall clock still shows 4–20% coefficient of variation; under contention
10//!   the median moves ~150%.
11//!
12//! Syscall counts and peak RSS sit awkwardly between the two: better than wall
13//! clock (~1%) but not deterministic, because they move with thread scheduling.
14//! They are recorded, and may be flagged, but must not gate at a tight threshold.
15
16use anyhow::{Context, Result, bail};
17use std::collections::BTreeMap;
18use std::process::{Command, Stdio};
19use std::time::Instant;
20
21#[derive(Debug, Clone)]
22pub struct Plan {
23    pub cmd: Vec<String>,
24    pub warmup: u32,
25    pub runs: u32,
26    /// Directory to run in. Declared benchmarks resolve relative commands
27    /// against `tak.toml`'s directory, not the caller's — otherwise the same
28    /// benchmark measures different things depending on where you stood.
29    pub dir: Option<std::path::PathBuf>,
30}
31
32/// Run once, discarding output, returning elapsed wall time in milliseconds.
33///
34/// No shell. Spawning a shell adds its own startup cost and variance to every
35/// sample, which for commands in the 10ms range is a large fraction of the
36/// measurement — the same reasoning behind poop's refusal to support one.
37fn time_once(cmd: &[String], dir: Option<&std::path::Path>) -> Result<f64> {
38    let (bin, args) = cmd.split_first().context("empty command")?;
39    let mut c = Command::new(bin);
40    c.args(args).stdout(Stdio::null()).stderr(Stdio::null());
41    if let Some(d) = dir {
42        c.current_dir(d);
43    }
44    let start = Instant::now();
45    let status = c
46        .status()
47        .with_context(|| format!("failed to spawn `{bin}`"))?;
48    let elapsed = start.elapsed().as_secs_f64() * 1000.0;
49    let _ = status;
50    Ok(elapsed)
51}
52
53/// Wall-clock statistics over `plan.runs` samples.
54///
55/// Reports `min` alongside the mean because contention is one-sided — a busy
56/// machine can only make a run slower, never faster — so the minimum is a far
57/// more robust estimator than the mean on shared CI hardware.
58pub fn wall(plan: &Plan) -> Result<BTreeMap<String, f64>> {
59    // Zero runs would leave `samples` empty and index straight off the end.
60    if plan.runs == 0 {
61        bail!("runs must be at least 1");
62    }
63    let dir = plan.dir.as_deref();
64    for _ in 0..plan.warmup {
65        time_once(&plan.cmd, dir)?;
66    }
67    let mut samples = Vec::with_capacity(plan.runs as usize);
68    for _ in 0..plan.runs {
69        samples.push(time_once(&plan.cmd, dir)?);
70    }
71    samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
72
73    let n = samples.len();
74    let mean = samples.iter().sum::<f64>() / n as f64;
75    let p50 = samples[n / 2];
76
77    Ok(BTreeMap::from([
78        ("wall_min_ms".to_string(), samples[0]),
79        ("wall_p50_ms".to_string(), p50),
80        ("wall_mean_ms".to_string(), mean),
81        ("wall_max_ms".to_string(), samples[n - 1]),
82        ("wall_n".to_string(), n as f64),
83    ]))
84}
85
86/// Is cachegrind usable on this machine?
87///
88/// Exposed so callers can tell "no counters because valgrind is missing" from
89/// "no counters because the measurement failed" — two problems with entirely
90/// different fixes.
91pub fn valgrind_available() -> bool {
92    Command::new("valgrind")
93        .arg("--version")
94        .stdout(Stdio::null())
95        .stderr(Stdio::null())
96        .status()
97        .map(|s| s.success())
98        .unwrap_or(false)
99}
100
101/// Repeats of the cachegrind run. Three is enough to catch a bimodal subject
102/// without tripling the cost of a measurement that is already ~50x slowed.
103const COUNTER_RUNS: u32 = 3;
104
105/// A subject varying by more than this is doing environment-dependent work, and
106/// its instruction count is not a usable gate. Well above the ~0.005% observed
107/// for a genuinely hermetic command, far below the 16% seen from one that
108/// touches the network.
109const SPREAD_WARN_PCT: f64 = 0.5;
110
111/// Instruction counts from repeated cachegrind runs.
112#[derive(Debug, Clone, Copy)]
113pub struct Counted {
114    pub min: u64,
115    pub max: u64,
116    pub runs: u32,
117}
118
119impl Counted {
120    /// Relative spread across runs, as a percentage of the minimum.
121    pub fn spread_pct(&self) -> f64 {
122        if self.min == 0 {
123            return 0.0;
124        }
125        (self.max - self.min) as f64 / self.min as f64 * 100.0
126    }
127
128    /// Whether this subject looks non-hermetic.
129    ///
130    /// The metric is deterministic; the program being measured need not be. A
131    /// CLI that checks for updates, reads a cache it may have just created, or
132    /// resolves DNS retires a different number of instructions depending on
133    /// conditions that have nothing to do with the code under test.
134    pub fn is_suspect(&self) -> bool {
135        self.spread_pct() > SPREAD_WARN_PCT
136    }
137}
138
139/// Instruction count via `valgrind --tool=cachegrind`, repeated.
140///
141/// Reports the **minimum**, for the same reason wall clock does: the extra work
142/// a subject sometimes performs is one-sided. A run that consults the network or
143/// populates a cache can only retire *more* instructions than the quiet path,
144/// never fewer, so the floor is the stable estimator.
145///
146/// Returns `Ok(None)` when valgrind is unavailable rather than failing: this is
147/// the expected state on macOS (no usable Apple Silicon support) and Windows.
148/// Those platforms record timing only, and the CI gate lives on the Linux job.
149/// Locally, a container gets you counters on any host.
150pub fn instructions(cmd: &[String], dir: Option<&std::path::Path>) -> Result<Option<Counted>> {
151    if !valgrind_available() {
152        return Ok(None);
153    }
154
155    let mut samples: Vec<u64> = Vec::with_capacity(COUNTER_RUNS as usize);
156    for _ in 0..COUNTER_RUNS {
157        let mut c = Command::new("valgrind");
158        c.args([
159            "--tool=cachegrind",
160            "--cache-sim=no",
161            "--branch-sim=no",
162            "--cachegrind-out-file=/dev/null",
163        ])
164        .args(cmd)
165        .stdout(Stdio::null());
166        if let Some(d) = dir {
167            c.current_dir(d);
168        }
169        let out = c.output().context("failed to run valgrind")?;
170
171        // cachegrind writes its summary to stderr as e.g. "I refs:  48,349,132".
172        let stderr = String::from_utf8_lossy(&out.stderr);
173        match parse_irefs(&stderr) {
174            Some(n) => samples.push(n),
175            // Valgrind is installed but produced no summary — a real failure,
176            // not the same thing as valgrind being absent. Reporting it as
177            // absent sends people off installing something they already have.
178            None => bail!(
179                "valgrind ran but emitted no `I refs` summary: {}",
180                stderr.lines().last().unwrap_or("(no output)").trim()
181            ),
182        }
183    }
184
185    Ok(Some(Counted {
186        min: *samples.iter().min().expect("COUNTER_RUNS > 0"),
187        max: *samples.iter().max().expect("COUNTER_RUNS > 0"),
188        runs: COUNTER_RUNS,
189    }))
190}
191
192/// Extract the `I refs:` count from cachegrind's stderr summary.
193fn parse_irefs(stderr: &str) -> Option<u64> {
194    let line = stderr.lines().find(|l| l.contains("I refs:"))?;
195    let digits: String = line
196        .rsplit(':')
197        .next()?
198        .chars()
199        .filter(|c| c.is_ascii_digit())
200        .collect();
201    digits.parse().ok()
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn parses_cachegrind_summary() {
210        let s = "==12== I refs:      48,349,132\n";
211        assert_eq!(parse_irefs(s), Some(48_349_132));
212    }
213
214    #[test]
215    fn missing_summary_is_none_not_panic() {
216        assert_eq!(parse_irefs("valgrind: command not found"), None);
217    }
218
219    #[test]
220    fn zero_runs_is_rejected_not_a_panic() {
221        let plan = Plan {
222            cmd: vec!["true".into()],
223            warmup: 0,
224            runs: 0,
225            dir: None,
226        };
227        assert!(wall(&plan).is_err());
228    }
229
230    #[test]
231    fn wall_reports_min_le_p50_le_max() {
232        let plan = Plan {
233            cmd: vec!["true".into()],
234            warmup: 1,
235            runs: 5,
236            dir: None,
237        };
238        let m = wall(&plan).unwrap();
239        assert!(m["wall_min_ms"] <= m["wall_p50_ms"]);
240        assert!(m["wall_p50_ms"] <= m["wall_max_ms"]);
241        assert_eq!(m["wall_n"], 5.0);
242    }
243}