use anyhow::{Context, Result, bail};
use std::collections::BTreeMap;
use std::process::{Command, Stdio};
use std::time::Instant;
#[derive(Debug, Clone)]
pub struct Plan {
pub cmd: Vec<String>,
pub warmup: u32,
pub runs: u32,
}
fn time_once(cmd: &[String]) -> Result<f64> {
let (bin, args) = cmd.split_first().context("empty command")?;
let start = Instant::now();
let status = Command::new(bin)
.args(args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.with_context(|| format!("failed to spawn `{bin}`"))?;
let elapsed = start.elapsed().as_secs_f64() * 1000.0;
let _ = status;
Ok(elapsed)
}
pub fn wall(plan: &Plan) -> Result<BTreeMap<String, f64>> {
for _ in 0..plan.warmup {
time_once(&plan.cmd)?;
}
let mut samples = Vec::with_capacity(plan.runs as usize);
for _ in 0..plan.runs {
samples.push(time_once(&plan.cmd)?);
}
samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
let n = samples.len();
let mean = samples.iter().sum::<f64>() / n as f64;
let p50 = samples[n / 2];
Ok(BTreeMap::from([
("wall_min_ms".to_string(), samples[0]),
("wall_p50_ms".to_string(), p50),
("wall_mean_ms".to_string(), mean),
("wall_max_ms".to_string(), samples[n - 1]),
("wall_n".to_string(), n as f64),
]))
}
pub fn valgrind_available() -> bool {
Command::new("valgrind")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
const COUNTER_RUNS: u32 = 3;
const SPREAD_WARN_PCT: f64 = 0.5;
#[derive(Debug, Clone, Copy)]
pub struct Counted {
pub min: u64,
pub max: u64,
pub runs: u32,
}
impl Counted {
pub fn spread_pct(&self) -> f64 {
if self.min == 0 {
return 0.0;
}
(self.max - self.min) as f64 / self.min as f64 * 100.0
}
pub fn is_suspect(&self) -> bool {
self.spread_pct() > SPREAD_WARN_PCT
}
}
pub fn instructions(cmd: &[String]) -> Result<Option<Counted>> {
if !valgrind_available() {
return Ok(None);
}
let mut samples: Vec<u64> = Vec::with_capacity(COUNTER_RUNS as usize);
for _ in 0..COUNTER_RUNS {
let out = Command::new("valgrind")
.args([
"--tool=cachegrind",
"--cache-sim=no",
"--branch-sim=no",
"--cachegrind-out-file=/dev/null",
])
.args(cmd)
.stdout(Stdio::null())
.output()
.context("failed to run valgrind")?;
let stderr = String::from_utf8_lossy(&out.stderr);
match parse_irefs(&stderr) {
Some(n) => samples.push(n),
None => bail!(
"valgrind ran but emitted no `I refs` summary: {}",
stderr.lines().last().unwrap_or("(no output)").trim()
),
}
}
Ok(Some(Counted {
min: *samples.iter().min().expect("COUNTER_RUNS > 0"),
max: *samples.iter().max().expect("COUNTER_RUNS > 0"),
runs: COUNTER_RUNS,
}))
}
fn parse_irefs(stderr: &str) -> Option<u64> {
let line = stderr.lines().find(|l| l.contains("I refs:"))?;
let digits: String = line
.rsplit(':')
.next()?
.chars()
.filter(|c| c.is_ascii_digit())
.collect();
digits.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_cachegrind_summary() {
let s = "==12== I refs: 48,349,132\n";
assert_eq!(parse_irefs(s), Some(48_349_132));
}
#[test]
fn missing_summary_is_none_not_panic() {
assert_eq!(parse_irefs("valgrind: command not found"), None);
}
#[test]
fn wall_reports_min_le_p50_le_max() {
let plan = Plan {
cmd: vec!["true".into()],
warmup: 1,
runs: 5,
};
let m = wall(&plan).unwrap();
assert!(m["wall_min_ms"] <= m["wall_p50_ms"]);
assert!(m["wall_p50_ms"] <= m["wall_max_ms"]);
assert_eq!(m["wall_n"], 5.0);
}
}