1use crate::{Config, Reason};
14use std::collections::BTreeMap;
15use std::fmt;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Environment {
21 pub machine: String,
23 pub os: String,
25 pub arch: String,
27 pub date: String,
29 pub digest: Option<String>,
31 pub rerun: String,
33 pub host: Option<String>,
37}
38
39impl Environment {
40 pub fn detect(rerun: impl Into<String>, digest: Option<String>) -> Self {
43 Environment {
44 machine: machine(),
45 os: std::env::consts::OS.to_string(),
46 arch: std::env::consts::ARCH.to_string(),
47 date: today_utc(),
48 digest,
49 rerun: clean(&rerun.into(), true),
50 host: None,
51 }
52 }
53
54 pub fn comparable_key(&self) -> String {
56 format!("{} | {} | {}", self.machine, self.os, self.arch)
57 }
58
59 fn to_config(&self) -> Config {
60 let mut c = Config::new()
61 .with("arch", &self.arch)
62 .with("date", &self.date)
63 .with("machine", clean(&self.machine, false))
64 .with("os", &self.os);
65 if let Some(d) = &self.digest {
66 c = c.with("digest", clean(d, false));
67 }
68 if let Some(h) = &self.host {
69 c = c.with("host", clean(h, false));
70 }
71 c
72 }
73}
74
75pub fn host_snapshot() -> String {
81 if let (Ok(load), Ok(mem)) = (
82 std::fs::read_to_string("/proc/loadavg"),
83 std::fs::read_to_string("/proc/meminfo"),
84 ) {
85 let load1 = load.split_whitespace().next().unwrap_or("?");
86 let kb = mem
87 .lines()
88 .find_map(|l| l.strip_prefix("MemAvailable:"))
89 .and_then(|v| v.split_whitespace().next())
90 .and_then(|v| v.parse::<u64>().ok());
91 if let Some(kb) = kb {
92 return format!("load1:{load1},ram_available_mb:{}", kb / 1024);
93 }
94 }
95 let script = "$o=Get-CimInstance Win32_OperatingSystem; $c=(Get-CimInstance Win32_Processor | Measure-Object LoadPercentage -Average).Average; \"cpu_load_pct:$([int]$c),ram_available_mb:$([int]($o.FreePhysicalMemory/1024))\"";
96 std::process::Command::new("powershell")
97 .args(["-NoProfile", "-NonInteractive", "-Command", script])
98 .output()
99 .ok()
100 .filter(|o| o.status.success())
101 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
102 .filter(|t| t.starts_with("cpu_load_pct:"))
103 .unwrap_or_else(|| "unavailable".to_string())
104}
105
106fn clean(s: &str, free_text: bool) -> String {
109 s.chars()
110 .map(|c| match c {
111 '\t' | '\n' | '\r' => ' ',
112 ';' | '=' if !free_text => ' ',
113 c => c,
114 })
115 .collect::<String>()
116 .trim()
117 .to_string()
118}
119
120fn machine() -> String {
121 let cores = std::thread::available_parallelism().map_or(0, std::num::NonZero::get);
122 let model = std::env::var("PROCESSOR_IDENTIFIER")
123 .ok()
124 .or_else(|| {
125 std::fs::read_to_string("/proc/cpuinfo").ok().and_then(|t| {
126 t.lines()
127 .find(|l| l.starts_with("model name"))
128 .and_then(|l| l.split_once(':'))
129 .map(|(_, v)| v.trim().to_string())
130 })
131 })
132 .unwrap_or_else(|| "unknown cpu".to_string());
133 format!("{} x{cores}", clean(&model, false))
134}
135
136fn today_utc() -> String {
137 let secs = std::time::SystemTime::now()
138 .duration_since(std::time::UNIX_EPOCH)
139 .map_or(0, |d| d.as_secs());
140 let (y, m, d) = civil_from_days(i64::try_from(secs / 86_400).unwrap_or(0));
141 format!("{y:04}-{m:02}-{d:02}")
142}
143
144fn civil_from_days(z: i64) -> (i64, u32, u32) {
146 let z = z + 719_468;
147 let era = z.div_euclid(146_097);
148 let doe = z.rem_euclid(146_097);
149 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
150 let y = yoe + era * 400;
151 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
152 let mp = (5 * doy + 2) / 153;
153 let d = u32::try_from(doy - (153 * mp + 2) / 5 + 1).unwrap_or(1);
154 let m = u32::try_from(if mp < 10 { mp + 3 } else { mp - 9 }).unwrap_or(1);
155 (if m <= 2 { y + 1 } else { y }, m, d)
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub struct Stats {
162 pub n: u32,
164 pub p50_ns: u64,
166 pub p95_ns: u64,
168 pub p99_ns: u64,
170 pub max_ns: u64,
172 pub ops_per_sec: u64,
174}
175
176impl Stats {
177 pub fn from_samples(mut us: Vec<u64>) -> Option<Self> {
179 if us.is_empty() {
180 return None;
181 }
182 us.sort_unstable();
183 let n = us.len();
184 let at = |p: usize| us[(n * p).div_ceil(100).saturating_sub(1).min(n - 1)];
185 let total: u64 = us.iter().sum();
186 Some(Stats {
187 n: u32::try_from(n).unwrap_or(u32::MAX),
188 p50_ns: at(50),
189 p95_ns: at(95),
190 p99_ns: at(99),
191 max_ns: us[n - 1],
192 ops_per_sec: u64::try_from(n)
193 .unwrap_or(u64::MAX)
194 .saturating_mul(1_000_000_000)
195 .checked_div(total)
196 .unwrap_or(0),
197 })
198 }
199}
200
201#[derive(Debug, Clone, PartialEq, Eq)]
203pub enum Outcome {
204 Measured(Stats),
206 Excluded {
208 leaf: String,
210 reason: Reason,
212 note: String,
214 },
215}
216
217#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct SpeedRow {
220 pub workload: String,
222 pub backend: String,
224 pub version: String,
226 pub config: Config,
228 pub env: Environment,
230 pub outcome: Outcome,
232}
233
234const HEADER: &str = "workload\tbackend\tversion\tconfig\tstatus\tn\tp50_ns\tp95_ns\tp99_ns\tmax_ns\tops_per_sec\tnote\tenv\trerun";
235
236#[derive(Debug, Clone, PartialEq, Eq)]
238pub struct SpeedParseError(pub String);
239
240impl fmt::Display for SpeedParseError {
241 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242 write!(f, "speed file: {}", self.0)
243 }
244}
245
246impl std::error::Error for SpeedParseError {}
247
248impl SpeedRow {
249 fn to_line(&self) -> String {
250 let (status, nums, note) = match &self.outcome {
251 Outcome::Measured(s) => (
252 "measured",
253 [
254 s.n.to_string(),
255 s.p50_ns.to_string(),
256 s.p95_ns.to_string(),
257 s.p99_ns.to_string(),
258 s.max_ns.to_string(),
259 s.ops_per_sec.to_string(),
260 ],
261 String::new(),
262 ),
263 Outcome::Excluded { leaf, reason, note } => (
264 "excluded",
265 Default::default(),
266 format!("{leaf}: {}: {}", reason.as_str(), clean(note, true)),
267 ),
268 };
269 let mut cols: Vec<String> = vec![
270 self.workload.clone(),
271 self.backend.clone(),
272 self.version.clone(),
273 self.config.to_string(),
274 status.to_string(),
275 ];
276 cols.extend(nums);
277 cols.push(note);
278 cols.push(self.env.to_config().to_string());
279 cols.push(self.env.rerun.clone());
280 cols.join("\t")
281 }
282
283 fn from_line(line: &str) -> Result<Self, SpeedParseError> {
284 let c: Vec<&str> = line.split('\t').collect();
285 if c.len() != 14 {
286 return Err(SpeedParseError(format!(
287 "{} columns, want 14: {line:?}",
288 c.len()
289 )));
290 }
291 let bad = |what: &str| SpeedParseError(format!("{what} in {line:?}"));
292 let env_cfg = Config::parse(c[12]).map_err(|e| SpeedParseError(e.to_string()))?;
293 let get = |k: &str| env_cfg.get(k).map(str::to_string).ok_or_else(|| bad(k));
294 let env = Environment {
295 machine: get("machine")?,
296 os: get("os")?,
297 arch: get("arch")?,
298 date: get("date")?,
299 digest: env_cfg.get("digest").map(str::to_string),
300 rerun: c[13].to_string(),
301 host: env_cfg.get("host").map(str::to_string),
302 };
303 let num = |i: usize| c[i].parse::<u64>().map_err(|_| bad("number"));
304 let outcome = match c[4] {
305 "measured" => Outcome::Measured(Stats {
306 n: u32::try_from(num(5)?).map_err(|_| bad("n"))?,
307 p50_ns: num(6)?,
308 p95_ns: num(7)?,
309 p99_ns: num(8)?,
310 max_ns: num(9)?,
311 ops_per_sec: num(10)?,
312 }),
313 "excluded" => {
314 let mut parts = c[11].splitn(3, ": ");
315 let (leaf, reason, note) = (
316 parts.next().unwrap_or(""),
317 parts.next().unwrap_or(""),
318 parts.next().unwrap_or(""),
319 );
320 let reason = match reason {
321 "missing" => Reason::Missing,
322 "wrong" => Reason::Wrong,
323 "untested" => Reason::Untested,
324 _ => return Err(bad("reason")),
325 };
326 Outcome::Excluded {
327 leaf: leaf.to_string(),
328 reason,
329 note: note.to_string(),
330 }
331 }
332 _ => return Err(bad("status")),
333 };
334 Ok(SpeedRow {
335 workload: c[0].to_string(),
336 backend: c[1].to_string(),
337 version: c[2].to_string(),
338 config: Config::parse(c[3]).map_err(|e| SpeedParseError(e.to_string()))?,
339 env,
340 outcome,
341 })
342 }
343}
344
345#[derive(Debug, Clone, Default, PartialEq, Eq)]
347pub struct SpeedTable {
348 rows: Vec<SpeedRow>,
349}
350
351impl SpeedTable {
352 pub fn from_rows(rows: Vec<SpeedRow>) -> Self {
354 SpeedTable { rows }
355 }
356
357 pub fn rows(&self) -> &[SpeedRow] {
359 &self.rows
360 }
361
362 pub fn merge(&mut self, other: SpeedTable) {
364 self.rows.extend(other.rows);
365 }
366
367 pub fn to_tsv(&self) -> String {
369 let mut out = String::from(HEADER);
370 out.push('\n');
371 for r in &self.rows {
372 out.push_str(&r.to_line());
373 out.push('\n');
374 }
375 out
376 }
377
378 pub fn parse(text: &str) -> Result<Self, SpeedParseError> {
380 let mut lines = text.lines();
381 if lines.next() != Some(HEADER) {
382 return Err(SpeedParseError("missing or wrong header".to_string()));
383 }
384 let rows = lines
385 .filter(|l| !l.is_empty())
386 .map(SpeedRow::from_line)
387 .collect::<Result<_, _>>()?;
388 Ok(SpeedTable { rows })
389 }
390}
391
392#[derive(Debug, Clone, PartialEq, Eq)]
394pub struct Ranked {
395 pub backend: String,
397 pub version: String,
399 pub config: Config,
401 pub stats: Stats,
403 pub date: String,
405 pub rerun: String,
407}
408
409#[derive(Debug, Clone, PartialEq, Eq)]
411pub struct Left {
412 pub backend: String,
414 pub version: String,
416 pub leaf: String,
418 pub reason: Reason,
420}
421
422#[derive(Debug, Clone, PartialEq, Eq)]
425pub struct Comparison {
426 pub workload: String,
428 pub environment: String,
430 pub ranked: Vec<Ranked>,
432 pub excluded: Vec<Left>,
434}
435
436pub fn compare(table: &SpeedTable) -> Vec<Comparison> {
439 let mut groups: BTreeMap<(String, String), Comparison> = BTreeMap::new();
440 for r in table.rows() {
441 let key = (r.workload.clone(), r.env.comparable_key());
442 let g = groups.entry(key.clone()).or_insert_with(|| Comparison {
443 workload: key.0.clone(),
444 environment: key.1.clone(),
445 ranked: Vec::new(),
446 excluded: Vec::new(),
447 });
448 match &r.outcome {
449 Outcome::Measured(s) => g.ranked.push(Ranked {
450 backend: r.backend.clone(),
451 version: r.version.clone(),
452 config: r.config.clone(),
453 stats: *s,
454 date: r.env.date.clone(),
455 rerun: r.env.rerun.clone(),
456 }),
457 Outcome::Excluded { leaf, reason, .. } => g.excluded.push(Left {
458 backend: r.backend.clone(),
459 version: r.version.clone(),
460 leaf: leaf.clone(),
461 reason: *reason,
462 }),
463 }
464 }
465 let mut out: Vec<Comparison> = groups.into_values().collect();
466 for g in &mut out {
467 g.ranked.sort_by(|a, b| {
468 (a.stats.p50_ns, &a.backend, &a.version).cmp(&(b.stats.p50_ns, &b.backend, &b.version))
469 });
470 }
471 out
472}
473
474#[derive(Debug, Clone, PartialEq, Eq)]
478pub struct Position {
479 pub backend: String,
481 pub version: String,
483 pub config: Config,
485 pub places: Vec<Option<(usize, u64)>>,
489 pub dominated_by: Vec<String>,
494}
495
496#[derive(Debug, Clone, PartialEq, Eq)]
499pub struct Profile {
500 pub environment: String,
502 pub workloads: Vec<String>,
504 pub positions: Vec<Position>,
507}
508
509pub fn compare_across(table: &SpeedTable, workloads: &[&str]) -> Vec<Profile> {
512 let mut by_env: BTreeMap<String, Vec<Comparison>> = BTreeMap::new();
513 for c in compare(table) {
514 if workloads.contains(&c.workload.as_str()) {
515 by_env.entry(c.environment.clone()).or_default().push(c);
516 }
517 }
518 let mut out = Vec::new();
519 for (environment, comps) in by_env {
520 let mut who: BTreeMap<(String, String, String), Position> = BTreeMap::new();
521 for (wi, w) in workloads.iter().enumerate() {
522 let Some(c) = comps.iter().find(|c| c.workload == *w) else {
523 continue;
524 };
525 for (i, r) in c.ranked.iter().enumerate() {
526 let p = who
527 .entry((r.backend.clone(), r.version.clone(), r.config.to_string()))
528 .or_insert_with(|| Position {
529 backend: r.backend.clone(),
530 version: r.version.clone(),
531 config: r.config.clone(),
532 places: vec![None; workloads.len()],
533 dominated_by: Vec::new(),
534 });
535 p.places[wi] = Some((i + 1, r.stats.p50_ns));
536 }
537 }
538 let mut positions: Vec<Position> = who.into_values().collect();
539 let full: Vec<Option<Vec<u64>>> = positions
541 .iter()
542 .map(|p| p.places.iter().map(|x| x.map(|(_, t)| t)).collect())
543 .collect();
544 for i in 0..positions.len() {
545 let Some(mine) = &full[i] else { continue };
546 for (j, other) in full.iter().enumerate() {
547 let Some(theirs) = other else { continue };
548 if i != j
549 && theirs.iter().zip(mine).all(|(t, m)| t <= m)
550 && theirs.iter().zip(mine).any(|(t, m)| t < m)
551 {
552 let name = format!("{} {}", positions[j].backend, positions[j].version);
553 positions[i].dominated_by.push(name);
554 }
555 }
556 }
557 out.push(Profile {
558 environment,
559 workloads: workloads.iter().map(|w| (*w).to_string()).collect(),
560 positions,
561 });
562 }
563 out
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569
570 fn env(machine: &str) -> Environment {
571 Environment {
572 machine: machine.to_string(),
573 os: "linux".to_string(),
574 arch: "x86_64".to_string(),
575 date: "2026-10-16".to_string(),
576 digest: Some("sha256:abc".to_string()),
577 rerun: "cargo run --example speed".to_string(),
578 host: None,
579 }
580 }
581
582 fn measured(backend: &str, machine: &str, p50: u64) -> SpeedRow {
583 SpeedRow {
584 workload: "speed.kv.get".to_string(),
585 backend: backend.to_string(),
586 version: "1".to_string(),
587 config: Config::new(),
588 env: env(machine),
589 outcome: Outcome::Measured(Stats::from_samples(vec![p50; 10]).unwrap()),
590 }
591 }
592
593 #[test]
594 fn percentiles_are_nearest_rank_and_an_empty_sample_has_none() {
595 assert!(Stats::from_samples(vec![]).is_none());
596 let s = Stats::from_samples((1..=100).collect()).unwrap();
597 assert_eq!(
598 (s.n, s.p50_ns, s.p95_ns, s.p99_ns, s.max_ns),
599 (100, 50, 95, 99, 100)
600 );
601 let one = Stats::from_samples(vec![7]).unwrap();
602 assert_eq!((one.p50_ns, one.p99_ns, one.max_ns), (7, 7, 7));
603 }
604
605 #[test]
606 fn dates_are_civil_utc() {
607 assert_eq!(civil_from_days(0), (1970, 1, 1));
608 assert_eq!(civil_from_days(19_000), (2022, 1, 8));
609 assert_eq!(civil_from_days(20_742), (2026, 10, 16));
610 }
611
612 #[test]
613 fn a_table_round_trips_measured_and_excluded_rows() {
614 let mut rows = vec![measured("a", "m", 5)];
615 rows.push(SpeedRow {
616 outcome: Outcome::Excluded {
617 leaf: "kv.get".to_string(),
618 reason: Reason::Wrong,
619 note: "lost a write".to_string(),
620 },
621 ..measured("b", "m", 1)
622 });
623 let t = SpeedTable::from_rows(rows);
624 assert_eq!(SpeedTable::parse(&t.to_tsv()).unwrap(), t);
625 }
626
627 #[test]
628 fn a_malformed_file_is_an_error_not_an_empty_table() {
629 assert!(SpeedTable::parse("nope").is_err());
630 assert!(SpeedTable::parse(&format!("{HEADER}\ntoo\tfew")).is_err());
631 }
632
633 #[test]
634 fn rankings_never_cross_machines_and_excluded_backends_are_listed_not_ranked() {
635 let mut rows = vec![
636 measured("slow", "laptop", 900),
637 measured("fast", "laptop", 20),
638 measured("slow", "server", 5),
640 measured("fast", "server", 50),
641 ];
642 rows.push(SpeedRow {
643 outcome: Outcome::Excluded {
644 leaf: "kv.get".to_string(),
645 reason: Reason::Wrong,
646 note: "lost a write".to_string(),
647 },
648 ..measured("broken", "laptop", 1)
649 });
650 let cmp = compare(&SpeedTable::from_rows(rows));
651 assert_eq!(cmp.len(), 2, "one comparison per environment");
652 let laptop = cmp
653 .iter()
654 .find(|c| c.environment.starts_with("laptop"))
655 .unwrap();
656 let names: Vec<_> = laptop.ranked.iter().map(|r| r.backend.as_str()).collect();
657 assert_eq!(names, ["fast", "slow"]);
658 assert_eq!(laptop.excluded.len(), 1);
659 assert_eq!(laptop.excluded[0].backend, "broken");
660 let server = cmp
661 .iter()
662 .find(|c| c.environment.starts_with("server"))
663 .unwrap();
664 let names: Vec<_> = server.ranked.iter().map(|r| r.backend.as_str()).collect();
665 assert_eq!(names, ["slow", "fast"]);
666 }
667
668 fn measured_on(workload: &str, backend: &str, p50: u64) -> SpeedRow {
669 SpeedRow {
670 workload: workload.to_string(),
671 ..measured(backend, "laptop", p50)
672 }
673 }
674
675 #[test]
676 fn several_workloads_are_shown_side_by_side_with_no_combined_score() {
677 let rows = vec![
678 measured_on("speed.kv.get", "a", 10),
681 measured_on("speed.kv.get", "b", 20),
682 measured_on("speed.kv.get", "c", 30),
683 measured_on("speed.kv.put", "a", 900),
684 measured_on("speed.kv.put", "b", 100),
685 measured_on("speed.kv.put", "c", 500),
686 measured_on("speed.kv.get", "d", 5),
688 measured_on("speed.kv.batch", "a", 1),
690 ];
691 let t = SpeedTable::from_rows(rows);
692 let prof = compare_across(&t, &["speed.kv.get", "speed.kv.put"]);
693 assert_eq!(prof.len(), 1);
694 let p = &prof[0];
695 assert_eq!(p.workloads, ["speed.kv.get", "speed.kv.put"]);
696 let names: Vec<_> = p.positions.iter().map(|x| x.backend.as_str()).collect();
697 assert_eq!(names, ["a", "b", "c", "d"], "ordered by name, not by speed");
698 let places = |n: &str| p.positions.iter().find(|x| x.backend == n).unwrap();
699 assert_eq!(places("d").places, [Some((1, 5)), None]);
701 assert_eq!(places("a").places, [Some((2, 10)), Some((3, 900))]);
702 assert_eq!(places("b").places, [Some((3, 20)), Some((1, 100))]);
703 assert_eq!(places("c").dominated_by, ["b 1"]);
706 assert!(places("a").dominated_by.is_empty());
707 assert!(places("b").dominated_by.is_empty());
708 assert!(places("d").dominated_by.is_empty());
709 assert_eq!(compare_across(&t, &["speed.kv.nothing"]).len(), 0);
711 assert_eq!(compare_across(&t, &["speed.kv.get"])[0].positions.len(), 4);
712 }
713
714 #[test]
715 fn profiles_never_cross_machines() {
716 let rows = vec![measured("a", "laptop", 10), measured("a", "server", 99)];
717 let prof = compare_across(&SpeedTable::from_rows(rows), &["speed.kv.get"]);
718 assert_eq!(prof.len(), 2);
719 assert!(prof.iter().all(|p| p.positions.len() == 1));
720 }
721}