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