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}