crates/nornir-bench/src/legacy.rs
//! Vs-legacy bench helpers — time a legacy decompressor subprocess against an
//! in-process Rust decoder and emit **paired** metrics, so the `benches` doc
//! renderer can bold the faster of the two ("how much faster than the old
//! tool"). The legacy baselines (`unzip`, `bzip2`, `lbzip2`, `gzip`, `pigz`)
//! are dev/bench-only subprocess sites — never library code — per the
//! 100%-Rust law (plan.md §4).
//!
//! A missing legacy tool is **not** an error: the comparison degrades to
//! ours-only (the legacy column is simply absent), so a bench box without
//! `pigz` still produces a valid run.
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
/// Megabytes-per-second for `bytes` processed in `dur` (1 MB = 1_000_000 B, the
/// convention the READMEs already use). Returns 0.0 for a zero duration.
pub fn mbs(bytes: u64, dur: Duration) -> f64 {
let secs = dur.as_secs_f64();
if secs <= 0.0 {
return 0.0;
}
(bytes as f64 / 1_000_000.0) / secs
}
/// Time an in-process decode `f` over `reps` repetitions, returning the **best**
/// (minimum) wall time — best-of-N rejects scheduler noise the way criterion's
/// min estimate does. `reps` is clamped to ≥1.
pub fn time_rust<F: FnMut()>(reps: usize, mut f: F) -> Duration {
let reps = reps.max(1);
let mut best = Duration::MAX;
for _ in 0..reps {
let t = Instant::now();
f();
best = best.min(t.elapsed());
}
best
}
/// Is `tool` on `PATH`? (Cheap `which`-style probe via spawning is avoided;
/// we just check `PATH` entries so a `None` result is fast and side-effect
/// free.)
pub fn tool_available(tool: &str) -> bool {
let Ok(path) = std::env::var("PATH") else { return false };
std::env::split_paths(&path).any(|dir| {
let p = dir.join(tool);
p.is_file() && is_executable(&p)
})
}
#[cfg(unix)]
fn is_executable(p: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(p)
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
fn is_executable(_p: &Path) -> bool {
true
}
/// Time a legacy decompressor over `reps` reps, returning the best wall time,
/// or `None` if the tool is missing or any run fails. The command is built by
/// `build` each rep (so per-run temp paths work) and is run with stdout+stderr
/// discarded.
///
/// Example (decompress a file to a null sink):
/// ```ignore
/// let dur = time_command_best("bzip2", 3, || {
/// let mut c = Command::new("bzip2");
/// c.arg("-dck").arg(&path); // -c stdout, -k keep input
/// c
/// });
/// ```
pub fn time_command_best<F: FnMut() -> Command>(
tool: &str,
reps: usize,
mut build: F,
) -> Option<Duration> {
if !tool_available(tool) {
return None;
}
let reps = reps.max(1);
let mut best = Duration::MAX;
for _ in 0..reps {
let mut cmd = build();
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let t = Instant::now();
let status = cmd.status().ok()?;
let elapsed = t.elapsed();
if !status.success() {
return None;
}
best = best.min(elapsed);
}
Some(best)
}
/// A finished ours-vs-legacy comparison for one workload.
#[derive(Debug, Clone)]
pub struct Comparison {
/// Bytes used as the throughput denominator (same for both sides, so the
/// speedup is exact). Typically the input (compressed) file size.
pub bytes: u64,
pub ours_label: String,
pub ours: Duration,
pub legacy_label: String,
/// `None` when the legacy tool was absent / failed.
pub legacy: Option<Duration>,
}
impl Comparison {
/// Build the paired `BenchResult` metrics map: `<ours>_mbs`, and — when the
/// legacy side ran — `<legacy>_mbs` plus a derived `speedup_x`. Metric keys
/// use the `_mbs` suffix so the renderer infers "higher is better" and the
/// bold-best comparison lights up.
pub fn into_result(self, name: &str) -> crate::BenchResult {
let mut m = serde_json::Map::new();
let ours_mbs = mbs(self.bytes, self.ours);
m.insert(format!("{}_mbs", self.ours_label), serde_json::json!(round2(ours_mbs)));
if let Some(leg) = self.legacy {
let leg_mbs = mbs(self.bytes, leg);
m.insert(format!("{}_mbs", self.legacy_label), serde_json::json!(round2(leg_mbs)));
let speedup = if leg_mbs > 0.0 { ours_mbs / leg_mbs } else { 0.0 };
m.insert("speedup_x".into(), serde_json::json!(round2(speedup)));
}
crate::BenchResult { name: name.to_string(), metrics: m , ..Default::default() }
}
}
fn round2(f: f64) -> f64 {
(f * 100.0).round() / 100.0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mbs_basic() {
assert!((mbs(1_000_000, Duration::from_secs(1)) - 1.0).abs() < 1e-9);
assert_eq!(mbs(123, Duration::ZERO), 0.0);
}
#[test]
fn missing_tool_is_none() {
assert!(!tool_available("definitely-not-a-real-tool-xyz"));
let d = time_command_best("definitely-not-a-real-tool-xyz", 1, || {
Command::new("definitely-not-a-real-tool-xyz")
});
assert!(d.is_none());
}
#[test]
fn comparison_pairs_metrics_and_speedup() {
let cmp = Comparison {
bytes: 2_000_000,
ours_label: "ljar".into(),
ours: Duration::from_millis(100), // 20 MB/s
legacy_label: "unzip".into(),
legacy: Some(Duration::from_millis(200)), // 10 MB/s
};
let r = cmp.into_result("jar_2000");
assert_eq!(r.name, "jar_2000");
assert_eq!(r.metrics["ljar_mbs"].as_f64().unwrap(), 20.0);
assert_eq!(r.metrics["unzip_mbs"].as_f64().unwrap(), 10.0);
assert_eq!(r.metrics["speedup_x"].as_f64().unwrap(), 2.0);
}
#[test]
fn comparison_without_legacy_is_ours_only() {
let cmp = Comparison {
bytes: 1_000_000,
ours_label: "lgz".into(),
ours: Duration::from_millis(500),
legacy_label: "pigz".into(),
legacy: None,
};
let r = cmp.into_result("gz_x");
assert!(r.metrics.contains_key("lgz_mbs"));
assert!(!r.metrics.contains_key("pigz_mbs"));
assert!(!r.metrics.contains_key("speedup_x"));
}
}