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 crate::settings::Settings;
17use anyhow::{Context, Result, bail};
18use std::collections::BTreeMap;
19use std::process::{Command, Stdio};
20use std::time::Instant;
21
22#[derive(Debug, Clone)]
23pub struct Plan {
24 pub cmd: Vec<String>,
25 pub warmup: u32,
26 pub runs: u32,
27 /// Directory to run in. Declared benchmarks resolve relative commands
28 /// against `tak.toml`'s directory, not the caller's — otherwise the same
29 /// benchmark measures different things depending on where you stood.
30 pub dir: Option<std::path::PathBuf>,
31 /// Resolved settings. Carried on the plan rather than read from a global so
32 /// a test can measure under a different configuration without touching the
33 /// environment of the whole test binary.
34 pub settings: Settings,
35}
36
37/// A command for running a benchmark subject, with the scrubbed variables
38/// removed.
39///
40/// Every subject spawn goes through here. Under cachegrind the removal is
41/// applied to valgrind itself, which the subject inherits from.
42///
43/// What gets removed is [`Settings::scrubbed_env`] — `env_deny` less
44/// `env_allow`, both declared in `settings.toml`. The default is the forge
45/// tokens, for two reasons. **Determinism**: a CLI that finds a token often
46/// does more with it than without, so a measurement would move depending on
47/// whether CI happened to export one. **Credentials**: `backfill` downloads
48/// release binaries and executes them, and any run that can push notes holds a
49/// repository-write token.
50///
51/// tak's own network calls are unaffected — `backfill` authenticates with
52/// `curl` directly rather than through this path.
53fn subject(bin: &str, settings: &Settings) -> Command {
54 let mut c = Command::new(bin);
55 for key in settings.scrubbed_env() {
56 c.env_remove(key);
57 }
58 c
59}
60
61/// Run once, discarding output, returning elapsed wall time in milliseconds.
62///
63/// No shell. Spawning a shell adds its own startup cost and variance to every
64/// sample, which for commands in the 10ms range is a large fraction of the
65/// measurement — the same reasoning behind poop's refusal to support one.
66fn time_once(cmd: &[String], dir: Option<&std::path::Path>, settings: &Settings) -> Result<f64> {
67 let (bin, args) = cmd.split_first().context("empty command")?;
68 let mut c = subject(bin, settings);
69 c.args(args).stdout(Stdio::null()).stderr(Stdio::null());
70 if let Some(d) = dir {
71 c.current_dir(d);
72 }
73 let start = Instant::now();
74 let status = c
75 .status()
76 .with_context(|| format!("failed to spawn `{bin}`"))?;
77 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
78 let _ = status;
79 Ok(elapsed)
80}
81
82/// Wall-clock statistics over `plan.runs` samples.
83///
84/// Reports `min` alongside the mean because contention is one-sided — a busy
85/// machine can only make a run slower, never faster — so the minimum is a far
86/// more robust estimator than the mean on shared CI hardware.
87pub fn wall(plan: &Plan) -> Result<BTreeMap<String, f64>> {
88 // Zero runs would leave `samples` empty and index straight off the end.
89 if plan.runs == 0 {
90 bail!("runs must be at least 1");
91 }
92 let dir = plan.dir.as_deref();
93 for _ in 0..plan.warmup {
94 time_once(&plan.cmd, dir, &plan.settings)?;
95 }
96 let mut samples = Vec::with_capacity(plan.runs as usize);
97 for _ in 0..plan.runs {
98 samples.push(time_once(&plan.cmd, dir, &plan.settings)?);
99 }
100 samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
101
102 let n = samples.len();
103 let mean = samples.iter().sum::<f64>() / n as f64;
104 let p50 = samples[n / 2];
105
106 Ok(BTreeMap::from([
107 ("wall_min_ms".to_string(), samples[0]),
108 ("wall_p50_ms".to_string(), p50),
109 ("wall_mean_ms".to_string(), mean),
110 ("wall_max_ms".to_string(), samples[n - 1]),
111 ("wall_n".to_string(), n as f64),
112 ]))
113}
114
115/// Is cachegrind usable on this machine?
116///
117/// Exposed so callers can tell "no counters because valgrind is missing" from
118/// "no counters because the measurement failed" — two problems with entirely
119/// different fixes.
120pub fn valgrind_available() -> bool {
121 Command::new("valgrind")
122 .arg("--version")
123 .stdout(Stdio::null())
124 .stderr(Stdio::null())
125 .status()
126 .map(|s| s.success())
127 .unwrap_or(false)
128}
129
130/// Repeats of the cachegrind run. Three is enough to catch a bimodal subject
131/// without tripling the cost of a measurement that is already ~50x slowed.
132const COUNTER_RUNS: u32 = 3;
133
134/// A subject varying by more than this is doing environment-dependent work, and
135/// its instruction count is not a usable gate. Well above the ~0.005% observed
136/// for a genuinely hermetic command, far below the 16% seen from one that
137/// touches the network.
138const SPREAD_WARN_PCT: f64 = 0.5;
139
140/// Instruction counts from repeated cachegrind runs.
141#[derive(Debug, Clone, Copy)]
142pub struct Counted {
143 pub min: u64,
144 pub max: u64,
145 pub runs: u32,
146}
147
148impl Counted {
149 /// Relative spread across runs, as a percentage of the minimum.
150 pub fn spread_pct(&self) -> f64 {
151 if self.min == 0 {
152 return 0.0;
153 }
154 (self.max - self.min) as f64 / self.min as f64 * 100.0
155 }
156
157 /// Whether this subject looks non-hermetic.
158 ///
159 /// The metric is deterministic; the program being measured need not be. A
160 /// CLI that checks for updates, reads a cache it may have just created, or
161 /// resolves DNS retires a different number of instructions depending on
162 /// conditions that have nothing to do with the code under test.
163 pub fn is_suspect(&self) -> bool {
164 self.spread_pct() > SPREAD_WARN_PCT
165 }
166}
167
168/// Instruction count via `valgrind --tool=cachegrind`, repeated.
169///
170/// Reports the **minimum**, for the same reason wall clock does: the extra work
171/// a subject sometimes performs is one-sided. A run that consults the network or
172/// populates a cache can only retire *more* instructions than the quiet path,
173/// never fewer, so the floor is the stable estimator.
174///
175/// Returns `Ok(None)` when valgrind is unavailable rather than failing: this is
176/// the expected state on macOS (no usable Apple Silicon support) and Windows.
177/// Those platforms record timing only, and the CI gate lives on the Linux job.
178/// Locally, a container gets you counters on any host.
179pub fn instructions(
180 cmd: &[String],
181 dir: Option<&std::path::Path>,
182 settings: &Settings,
183) -> Result<Option<Counted>> {
184 if !valgrind_available() {
185 return Ok(None);
186 }
187
188 let mut samples: Vec<u64> = Vec::with_capacity(COUNTER_RUNS as usize);
189 for _ in 0..COUNTER_RUNS {
190 let mut c = subject("valgrind", settings);
191 c.args([
192 "--tool=cachegrind",
193 "--cache-sim=no",
194 "--branch-sim=no",
195 "--cachegrind-out-file=/dev/null",
196 ])
197 .args(cmd)
198 .stdout(Stdio::null());
199 if let Some(d) = dir {
200 c.current_dir(d);
201 }
202 let out = c.output().context("failed to run valgrind")?;
203
204 // cachegrind writes its summary to stderr as e.g. "I refs: 48,349,132".
205 let stderr = String::from_utf8_lossy(&out.stderr);
206 match parse_irefs(&stderr) {
207 Some(n) => samples.push(n),
208 // Valgrind is installed but produced no summary — a real failure,
209 // not the same thing as valgrind being absent. Reporting it as
210 // absent sends people off installing something they already have.
211 None => bail!(
212 "valgrind ran but emitted no `I refs` summary: {}",
213 stderr.lines().last().unwrap_or("(no output)").trim()
214 ),
215 }
216 }
217
218 Ok(Some(Counted {
219 min: *samples.iter().min().expect("COUNTER_RUNS > 0"),
220 max: *samples.iter().max().expect("COUNTER_RUNS > 0"),
221 runs: COUNTER_RUNS,
222 }))
223}
224
225/// Extract the `I refs:` count from cachegrind's stderr summary.
226fn parse_irefs(stderr: &str) -> Option<u64> {
227 let line = stderr.lines().find(|l| l.contains("I refs:"))?;
228 let digits: String = line
229 .rsplit(':')
230 .next()?
231 .chars()
232 .filter(|c| c.is_ascii_digit())
233 .collect();
234 digits.parse().ok()
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
242 fn parses_cachegrind_summary() {
243 let s = "==12== I refs: 48,349,132\n";
244 assert_eq!(parse_irefs(s), Some(48_349_132));
245 }
246
247 #[test]
248 fn missing_summary_is_none_not_panic() {
249 assert_eq!(parse_irefs("valgrind: command not found"), None);
250 }
251
252 /// What `subject` removes, as configured.
253 fn removals(settings: &Settings) -> Vec<String> {
254 subject("true", settings)
255 .get_envs()
256 .filter(|(_, v)| v.is_none())
257 .map(|(k, _)| k.to_string_lossy().into_owned())
258 .collect()
259 }
260
261 /// Construction-level check. The end-to-end proof that a subject cannot see
262 /// the token lives in `tests/subject_env.rs`, which runs the real binary.
263 #[test]
264 fn subject_commands_remove_the_default_denied_variables() {
265 let removed = removals(&Settings::default());
266 for key in Settings::default().scrubbed_env() {
267 assert!(removed.contains(&key.to_string()), "{key} not removed");
268 }
269 }
270
271 /// The removal follows the setting rather than a compiled-in list, which is
272 /// the whole point of routing it through `Settings`.
273 #[test]
274 fn the_removal_follows_the_settings() {
275 let removed = removals(&Settings {
276 env_deny: vec!["CUSTOM_SECRET".into()],
277 env_allow: Vec::new(),
278 });
279 assert!(removed.contains(&"CUSTOM_SECRET".to_string()));
280 assert!(!removed.contains(&"GITHUB_TOKEN".to_string()));
281 }
282
283 /// An allowed variable is not removed, so a subject that genuinely needs
284 /// one can still be measured.
285 #[test]
286 fn an_allowed_variable_survives() {
287 let removed = removals(&Settings {
288 env_deny: vec!["GITHUB_TOKEN".into(), "GH_TOKEN".into()],
289 env_allow: vec!["GITHUB_TOKEN".into()],
290 });
291 assert!(!removed.contains(&"GITHUB_TOKEN".to_string()));
292 assert!(removed.contains(&"GH_TOKEN".to_string()));
293 }
294
295 #[test]
296 fn zero_runs_is_rejected_not_a_panic() {
297 let plan = Plan {
298 cmd: vec!["true".into()],
299 warmup: 0,
300 runs: 0,
301 dir: None,
302 settings: Settings::default(),
303 };
304 assert!(wall(&plan).is_err());
305 }
306
307 #[test]
308 fn wall_reports_min_le_p50_le_max() {
309 let plan = Plan {
310 cmd: vec!["true".into()],
311 warmup: 1,
312 runs: 5,
313 dir: None,
314 settings: Settings::default(),
315 };
316 let m = wall(&plan).unwrap();
317 assert!(m["wall_min_ms"] <= m["wall_p50_ms"]);
318 assert!(m["wall_p50_ms"] <= m["wall_max_ms"]);
319 assert_eq!(m["wall_n"], 5.0);
320 }
321}