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