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}
34
35impl Environment {
36 pub fn detect(rerun: impl Into<String>, digest: Option<String>) -> Self {
39 Environment {
40 machine: machine(),
41 os: std::env::consts::OS.to_string(),
42 arch: std::env::consts::ARCH.to_string(),
43 date: today_utc(),
44 digest,
45 rerun: clean(&rerun.into(), true),
46 }
47 }
48
49 pub fn comparable_key(&self) -> String {
51 format!("{} | {} | {}", self.machine, self.os, self.arch)
52 }
53
54 fn to_config(&self) -> Config {
55 let mut c = Config::new()
56 .with("arch", &self.arch)
57 .with("date", &self.date)
58 .with("machine", clean(&self.machine, false))
59 .with("os", &self.os);
60 if let Some(d) = &self.digest {
61 c = c.with("digest", clean(d, false));
62 }
63 c
64 }
65}
66
67fn clean(s: &str, free_text: bool) -> String {
70 s.chars()
71 .map(|c| match c {
72 '\t' | '\n' | '\r' => ' ',
73 ';' | '=' if !free_text => ' ',
74 c => c,
75 })
76 .collect::<String>()
77 .trim()
78 .to_string()
79}
80
81fn machine() -> String {
82 let cores = std::thread::available_parallelism().map_or(0, std::num::NonZero::get);
83 let model = std::env::var("PROCESSOR_IDENTIFIER")
84 .ok()
85 .or_else(|| {
86 std::fs::read_to_string("/proc/cpuinfo").ok().and_then(|t| {
87 t.lines()
88 .find(|l| l.starts_with("model name"))
89 .and_then(|l| l.split_once(':'))
90 .map(|(_, v)| v.trim().to_string())
91 })
92 })
93 .unwrap_or_else(|| "unknown cpu".to_string());
94 format!("{} x{cores}", clean(&model, false))
95}
96
97fn today_utc() -> String {
98 let secs = std::time::SystemTime::now()
99 .duration_since(std::time::UNIX_EPOCH)
100 .map_or(0, |d| d.as_secs());
101 let (y, m, d) = civil_from_days(i64::try_from(secs / 86_400).unwrap_or(0));
102 format!("{y:04}-{m:02}-{d:02}")
103}
104
105fn civil_from_days(z: i64) -> (i64, u32, u32) {
107 let z = z + 719_468;
108 let era = z.div_euclid(146_097);
109 let doe = z.rem_euclid(146_097);
110 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
111 let y = yoe + era * 400;
112 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
113 let mp = (5 * doy + 2) / 153;
114 let d = u32::try_from(doy - (153 * mp + 2) / 5 + 1).unwrap_or(1);
115 let m = u32::try_from(if mp < 10 { mp + 3 } else { mp - 9 }).unwrap_or(1);
116 (if m <= 2 { y + 1 } else { y }, m, d)
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub struct Stats {
123 pub n: u32,
125 pub p50_ns: u64,
127 pub p95_ns: u64,
129 pub p99_ns: u64,
131 pub max_ns: u64,
133 pub ops_per_sec: u64,
135}
136
137impl Stats {
138 pub fn from_samples(mut us: Vec<u64>) -> Option<Self> {
140 if us.is_empty() {
141 return None;
142 }
143 us.sort_unstable();
144 let n = us.len();
145 let at = |p: usize| us[(n * p).div_ceil(100).saturating_sub(1).min(n - 1)];
146 let total: u64 = us.iter().sum();
147 Some(Stats {
148 n: u32::try_from(n).unwrap_or(u32::MAX),
149 p50_ns: at(50),
150 p95_ns: at(95),
151 p99_ns: at(99),
152 max_ns: us[n - 1],
153 ops_per_sec: u64::try_from(n)
154 .unwrap_or(u64::MAX)
155 .saturating_mul(1_000_000_000)
156 .checked_div(total)
157 .unwrap_or(0),
158 })
159 }
160}
161
162#[derive(Debug, Clone, PartialEq, Eq)]
164pub enum Outcome {
165 Measured(Stats),
167 Excluded {
169 leaf: String,
171 reason: Reason,
173 note: String,
175 },
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct SpeedRow {
181 pub workload: String,
183 pub backend: String,
185 pub version: String,
187 pub config: Config,
189 pub env: Environment,
191 pub outcome: Outcome,
193}
194
195const HEADER: &str = "workload\tbackend\tversion\tconfig\tstatus\tn\tp50_ns\tp95_ns\tp99_ns\tmax_ns\tops_per_sec\tnote\tenv\trerun";
196
197#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct SpeedParseError(pub String);
200
201impl fmt::Display for SpeedParseError {
202 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203 write!(f, "speed file: {}", self.0)
204 }
205}
206
207impl std::error::Error for SpeedParseError {}
208
209impl SpeedRow {
210 fn to_line(&self) -> String {
211 let (status, nums, note) = match &self.outcome {
212 Outcome::Measured(s) => (
213 "measured",
214 [
215 s.n.to_string(),
216 s.p50_ns.to_string(),
217 s.p95_ns.to_string(),
218 s.p99_ns.to_string(),
219 s.max_ns.to_string(),
220 s.ops_per_sec.to_string(),
221 ],
222 String::new(),
223 ),
224 Outcome::Excluded { leaf, reason, note } => (
225 "excluded",
226 Default::default(),
227 format!("{leaf}: {}: {}", reason.as_str(), clean(note, true)),
228 ),
229 };
230 let mut cols: Vec<String> = vec![
231 self.workload.clone(),
232 self.backend.clone(),
233 self.version.clone(),
234 self.config.to_string(),
235 status.to_string(),
236 ];
237 cols.extend(nums);
238 cols.push(note);
239 cols.push(self.env.to_config().to_string());
240 cols.push(self.env.rerun.clone());
241 cols.join("\t")
242 }
243
244 fn from_line(line: &str) -> Result<Self, SpeedParseError> {
245 let c: Vec<&str> = line.split('\t').collect();
246 if c.len() != 14 {
247 return Err(SpeedParseError(format!(
248 "{} columns, want 14: {line:?}",
249 c.len()
250 )));
251 }
252 let bad = |what: &str| SpeedParseError(format!("{what} in {line:?}"));
253 let env_cfg = Config::parse(c[12]).map_err(|e| SpeedParseError(e.to_string()))?;
254 let get = |k: &str| env_cfg.get(k).map(str::to_string).ok_or_else(|| bad(k));
255 let env = Environment {
256 machine: get("machine")?,
257 os: get("os")?,
258 arch: get("arch")?,
259 date: get("date")?,
260 digest: env_cfg.get("digest").map(str::to_string),
261 rerun: c[13].to_string(),
262 };
263 let num = |i: usize| c[i].parse::<u64>().map_err(|_| bad("number"));
264 let outcome = match c[4] {
265 "measured" => Outcome::Measured(Stats {
266 n: u32::try_from(num(5)?).map_err(|_| bad("n"))?,
267 p50_ns: num(6)?,
268 p95_ns: num(7)?,
269 p99_ns: num(8)?,
270 max_ns: num(9)?,
271 ops_per_sec: num(10)?,
272 }),
273 "excluded" => {
274 let mut parts = c[11].splitn(3, ": ");
275 let (leaf, reason, note) = (
276 parts.next().unwrap_or(""),
277 parts.next().unwrap_or(""),
278 parts.next().unwrap_or(""),
279 );
280 let reason = match reason {
281 "missing" => Reason::Missing,
282 "wrong" => Reason::Wrong,
283 "untested" => Reason::Untested,
284 _ => return Err(bad("reason")),
285 };
286 Outcome::Excluded {
287 leaf: leaf.to_string(),
288 reason,
289 note: note.to_string(),
290 }
291 }
292 _ => return Err(bad("status")),
293 };
294 Ok(SpeedRow {
295 workload: c[0].to_string(),
296 backend: c[1].to_string(),
297 version: c[2].to_string(),
298 config: Config::parse(c[3]).map_err(|e| SpeedParseError(e.to_string()))?,
299 env,
300 outcome,
301 })
302 }
303}
304
305#[derive(Debug, Clone, Default, PartialEq, Eq)]
307pub struct SpeedTable {
308 rows: Vec<SpeedRow>,
309}
310
311impl SpeedTable {
312 pub fn from_rows(rows: Vec<SpeedRow>) -> Self {
314 SpeedTable { rows }
315 }
316
317 pub fn rows(&self) -> &[SpeedRow] {
319 &self.rows
320 }
321
322 pub fn merge(&mut self, other: SpeedTable) {
324 self.rows.extend(other.rows);
325 }
326
327 pub fn to_tsv(&self) -> String {
329 let mut out = String::from(HEADER);
330 out.push('\n');
331 for r in &self.rows {
332 out.push_str(&r.to_line());
333 out.push('\n');
334 }
335 out
336 }
337
338 pub fn parse(text: &str) -> Result<Self, SpeedParseError> {
340 let mut lines = text.lines();
341 if lines.next() != Some(HEADER) {
342 return Err(SpeedParseError("missing or wrong header".to_string()));
343 }
344 let rows = lines
345 .filter(|l| !l.is_empty())
346 .map(SpeedRow::from_line)
347 .collect::<Result<_, _>>()?;
348 Ok(SpeedTable { rows })
349 }
350}
351
352#[derive(Debug, Clone, PartialEq, Eq)]
354pub struct Ranked {
355 pub backend: String,
357 pub version: String,
359 pub config: Config,
361 pub stats: Stats,
363 pub date: String,
365 pub rerun: String,
367}
368
369#[derive(Debug, Clone, PartialEq, Eq)]
371pub struct Left {
372 pub backend: String,
374 pub version: String,
376 pub leaf: String,
378 pub reason: Reason,
380}
381
382#[derive(Debug, Clone, PartialEq, Eq)]
385pub struct Comparison {
386 pub workload: String,
388 pub environment: String,
390 pub ranked: Vec<Ranked>,
392 pub excluded: Vec<Left>,
394}
395
396pub fn compare(table: &SpeedTable) -> Vec<Comparison> {
399 let mut groups: BTreeMap<(String, String), Comparison> = BTreeMap::new();
400 for r in table.rows() {
401 let key = (r.workload.clone(), r.env.comparable_key());
402 let g = groups.entry(key.clone()).or_insert_with(|| Comparison {
403 workload: key.0.clone(),
404 environment: key.1.clone(),
405 ranked: Vec::new(),
406 excluded: Vec::new(),
407 });
408 match &r.outcome {
409 Outcome::Measured(s) => g.ranked.push(Ranked {
410 backend: r.backend.clone(),
411 version: r.version.clone(),
412 config: r.config.clone(),
413 stats: *s,
414 date: r.env.date.clone(),
415 rerun: r.env.rerun.clone(),
416 }),
417 Outcome::Excluded { leaf, reason, .. } => g.excluded.push(Left {
418 backend: r.backend.clone(),
419 version: r.version.clone(),
420 leaf: leaf.clone(),
421 reason: *reason,
422 }),
423 }
424 }
425 let mut out: Vec<Comparison> = groups.into_values().collect();
426 for g in &mut out {
427 g.ranked.sort_by(|a, b| {
428 (a.stats.p50_ns, &a.backend, &a.version).cmp(&(b.stats.p50_ns, &b.backend, &b.version))
429 });
430 }
431 out
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 fn env(machine: &str) -> Environment {
439 Environment {
440 machine: machine.to_string(),
441 os: "linux".to_string(),
442 arch: "x86_64".to_string(),
443 date: "2026-10-16".to_string(),
444 digest: Some("sha256:abc".to_string()),
445 rerun: "cargo run --example speed".to_string(),
446 }
447 }
448
449 fn measured(backend: &str, machine: &str, p50: u64) -> SpeedRow {
450 SpeedRow {
451 workload: "speed.kv.get".to_string(),
452 backend: backend.to_string(),
453 version: "1".to_string(),
454 config: Config::new(),
455 env: env(machine),
456 outcome: Outcome::Measured(Stats::from_samples(vec![p50; 10]).unwrap()),
457 }
458 }
459
460 #[test]
461 fn percentiles_are_nearest_rank_and_an_empty_sample_has_none() {
462 assert!(Stats::from_samples(vec![]).is_none());
463 let s = Stats::from_samples((1..=100).collect()).unwrap();
464 assert_eq!(
465 (s.n, s.p50_ns, s.p95_ns, s.p99_ns, s.max_ns),
466 (100, 50, 95, 99, 100)
467 );
468 let one = Stats::from_samples(vec![7]).unwrap();
469 assert_eq!((one.p50_ns, one.p99_ns, one.max_ns), (7, 7, 7));
470 }
471
472 #[test]
473 fn dates_are_civil_utc() {
474 assert_eq!(civil_from_days(0), (1970, 1, 1));
475 assert_eq!(civil_from_days(19_000), (2022, 1, 8));
476 assert_eq!(civil_from_days(20_742), (2026, 10, 16));
477 }
478
479 #[test]
480 fn a_table_round_trips_measured_and_excluded_rows() {
481 let mut rows = vec![measured("a", "m", 5)];
482 rows.push(SpeedRow {
483 outcome: Outcome::Excluded {
484 leaf: "kv.get".to_string(),
485 reason: Reason::Wrong,
486 note: "lost a write".to_string(),
487 },
488 ..measured("b", "m", 1)
489 });
490 let t = SpeedTable::from_rows(rows);
491 assert_eq!(SpeedTable::parse(&t.to_tsv()).unwrap(), t);
492 }
493
494 #[test]
495 fn a_malformed_file_is_an_error_not_an_empty_table() {
496 assert!(SpeedTable::parse("nope").is_err());
497 assert!(SpeedTable::parse(&format!("{HEADER}\ntoo\tfew")).is_err());
498 }
499
500 #[test]
501 fn rankings_never_cross_machines_and_excluded_backends_are_listed_not_ranked() {
502 let mut rows = vec![
503 measured("slow", "laptop", 900),
504 measured("fast", "laptop", 20),
505 measured("slow", "server", 5),
507 measured("fast", "server", 50),
508 ];
509 rows.push(SpeedRow {
510 outcome: Outcome::Excluded {
511 leaf: "kv.get".to_string(),
512 reason: Reason::Wrong,
513 note: "lost a write".to_string(),
514 },
515 ..measured("broken", "laptop", 1)
516 });
517 let cmp = compare(&SpeedTable::from_rows(rows));
518 assert_eq!(cmp.len(), 2, "one comparison per environment");
519 let laptop = cmp
520 .iter()
521 .find(|c| c.environment.starts_with("laptop"))
522 .unwrap();
523 let names: Vec<_> = laptop.ranked.iter().map(|r| r.backend.as_str()).collect();
524 assert_eq!(names, ["fast", "slow"]);
525 assert_eq!(laptop.excluded.len(), 1);
526 assert_eq!(laptop.excluded[0].backend, "broken");
527 let server = cmp
528 .iter()
529 .find(|c| c.environment.starts_with("server"))
530 .unwrap();
531 let names: Vec<_> = server.ranked.iter().map(|r| r.backend.as_str()).collect();
532 assert_eq!(names, ["slow", "fast"]);
533 }
534}