Skip to main content

persistant_core/
bench.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! The kind-agnostic half of backend benchmarking stage 3 (speed): the environment a figure was taken
3//! in, timing statistics, the data file, and the comparison that never crosses machines. The kinds
4//! (`persistant_kv::speed`, `persistant_blob::speed`) own their workloads and how they are timed; they
5//! produce [`SpeedRow`]s and nothing here knows what a key-value store or a blob store is.
6//!
7//! - **Speed ranks only what passes the contract.** A row is either [`Outcome::Measured`] or
8//!   [`Outcome::Excluded`] with the leaf and reason; an excluded backend is listed, never ranked.
9//! - **No constant rankings.** Every row carries its [`Environment`] and the command that re-runs it;
10//!   [`compare`] orders backends only *within one environment*.
11//! - **Results are data files.** [`SpeedTable`] reads and writes a tab-separated file.
12
13use crate::{Config, Reason};
14use std::collections::BTreeMap;
15use std::fmt;
16
17/// Where and when a figure was measured. Keep every field honest: a row without its environment is a
18/// claim about the present.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Environment {
21    /// CPU model and logical core count, as the OS reports them.
22    pub machine: String,
23    /// `std::env::consts::OS`.
24    pub os: String,
25    /// `std::env::consts::ARCH`.
26    pub arch: String,
27    /// UTC date, `YYYY-MM-DD`.
28    pub date: String,
29    /// The server image digest, when the caller ran one (`sha256:...`).
30    pub digest: Option<String>,
31    /// The command that reproduces this run.
32    pub rerun: String,
33    /// What the host was doing at the start and end of the run (load, available RAM; see
34    /// [`host_snapshot`]), for runs where that decides whether a figure means anything. `None` when
35    /// not recorded. It is evidence, not part of [`comparable_key`](Environment::comparable_key).
36    pub host: Option<String>,
37}
38
39impl Environment {
40    /// Read the machine, OS, architecture and date; the caller supplies the re-run command and the
41    /// image digest, if any.
42    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    /// What decides whether two rows may be compared: same machine, OS and architecture.
55    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
75/// What the host is doing right now, as `cpu_load_pct:N,ram_available_mb:N` (Windows: CPU load percent
76/// from WMI and free physical memory; Linux: the one-minute load average and `MemAvailable`), or
77/// `unavailable` when the OS gives neither. Costs a process launch on Windows, so call it outside any
78/// timed region. A benchmark records it at the start and end of a run: a figure taken while something
79/// else used the machine says so.
80pub 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
106/// Make a value safe for the data file: no tab or newline, and (inside a `k=v;k=v` field) no `;`
107/// or `=`.
108fn 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
144/// Days since 1970-01-01 to a proleptic Gregorian date (Howard Hinnant's algorithm).
145fn 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/// Timing of one workload: nearest-rank percentiles of per-operation wall-clock time, in
159/// nanoseconds. The clock's own resolution is coarser on some platforms; a figure near it means little.
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub struct Stats {
162    /// Operations timed.
163    pub n: u32,
164    /// Median, nanoseconds.
165    pub p50_ns: u64,
166    /// 95th percentile, nanoseconds.
167    pub p95_ns: u64,
168    /// 99th percentile, nanoseconds.
169    pub p99_ns: u64,
170    /// Slowest, nanoseconds.
171    pub max_ns: u64,
172    /// Operations per second over the summed operation time (not wall-clock including setup).
173    pub ops_per_sec: u64,
174}
175
176impl Stats {
177    /// From per-operation durations in nanoseconds. `None` for no samples.
178    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/// A figure, or the reason there is none.
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub enum Outcome {
204    /// Timed. Only a backend that passed the contract for the workload gets this.
205    Measured(Stats),
206    /// Failed the contract for the workload's leaf: listed, never ranked.
207    Excluded {
208        /// The leaf that decided.
209        leaf: String,
210        /// `Missing`, `Wrong` or `Untested`.
211        reason: Reason,
212        /// The test's own words.
213        note: String,
214    },
215}
216
217/// One recorded fact: a workload on a backend in an environment.
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct SpeedRow {
220    /// [`Workload::id`].
221    pub workload: String,
222    /// Backend name, as in the capability matrix.
223    pub backend: String,
224    /// Backend version, as in the capability matrix.
225    pub version: String,
226    /// Backend configuration, as in the capability matrix.
227    pub config: Config,
228    /// Where and when.
229    pub env: Environment,
230    /// The figure or the exclusion.
231    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/// Why a speed file did not parse.
237#[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/// A set of rows, read from and written to one tab-separated file.
346#[derive(Debug, Clone, Default, PartialEq, Eq)]
347pub struct SpeedTable {
348    rows: Vec<SpeedRow>,
349}
350
351impl SpeedTable {
352    /// A table of `rows`.
353    pub fn from_rows(rows: Vec<SpeedRow>) -> Self {
354        SpeedTable { rows }
355    }
356
357    /// The rows.
358    pub fn rows(&self) -> &[SpeedRow] {
359        &self.rows
360    }
361
362    /// Add the rows of another file (a different backend, maybe a different machine).
363    pub fn merge(&mut self, other: SpeedTable) {
364        self.rows.extend(other.rows);
365    }
366
367    /// The file text.
368    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    /// Parse a file written by [`to_tsv`](Self::to_tsv).
379    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/// A backend that has a number for a workload.
393#[derive(Debug, Clone, PartialEq, Eq)]
394pub struct Ranked {
395    /// Backend name.
396    pub backend: String,
397    /// Backend version.
398    pub version: String,
399    /// Backend configuration.
400    pub config: Config,
401    /// The measurement.
402    pub stats: Stats,
403    /// The date of the measurement.
404    pub date: String,
405    /// The command that reproduces it.
406    pub rerun: String,
407}
408
409/// A backend that has no number for a workload, and why.
410#[derive(Debug, Clone, PartialEq, Eq)]
411pub struct Left {
412    /// Backend name.
413    pub backend: String,
414    /// Backend version.
415    pub version: String,
416    /// The leaf that excluded it.
417    pub leaf: String,
418    /// Why.
419    pub reason: Reason,
420}
421
422/// One workload in one environment: the eligible backends fastest first by median, then the excluded
423/// ones. The environment is part of the answer.
424#[derive(Debug, Clone, PartialEq, Eq)]
425pub struct Comparison {
426    /// [`Workload::id`].
427    pub workload: String,
428    /// [`Environment::comparable_key`]: this ordering holds here and nowhere else.
429    pub environment: String,
430    /// Backends with a number, ascending by `p50_ns` (ties by name).
431    pub ranked: Vec<Ranked>,
432    /// Backends listed without a number.
433    pub excluded: Vec<Left>,
434}
435
436/// Group rows by workload and environment and order the measured ones. Rows from different machines
437/// never share a [`Comparison`].
438pub 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/// One backend's place in each of several workloads, side by side, in one environment. There is no
475/// combined score: how much each workload matters is the consumer's call, so the places are shown and
476/// never summed.
477#[derive(Debug, Clone, PartialEq, Eq)]
478pub struct Position {
479    /// Backend name.
480    pub backend: String,
481    /// Backend version.
482    pub version: String,
483    /// Backend configuration.
484    pub config: Config,
485    /// One entry per workload of the [`Profile`], same order: `Some((place, p50_ns))` with place 1 for
486    /// the fastest median (ties by name), `None` where the backend has no number for that workload
487    /// (excluded or not measured).
488    pub places: Vec<Option<(usize, u64)>>,
489    /// Backends that are at least as fast on every workload and strictly faster on one ("backend
490    /// version"). Only judged between backends with a number on every workload; empty for one that
491    /// lacks a number somewhere. A backend nobody dominates is on the Pareto front: no other is
492    /// better everywhere.
493    pub dominated_by: Vec<String>,
494}
495
496/// Several workloads in one environment: each backend's [`Position`] in all of them. The environment
497/// is part of the answer, as in [`Comparison`].
498#[derive(Debug, Clone, PartialEq, Eq)]
499pub struct Profile {
500    /// [`Environment::comparable_key`]: this holds here and nowhere else.
501    pub environment: String,
502    /// The workload ids asked for, in the order asked.
503    pub workloads: Vec<String>,
504    /// Every backend that has a number for at least one of them, ordered by name, version and
505    /// configuration (not by speed).
506    pub positions: Vec<Position>,
507}
508
509/// Compare backends over several `workloads` at once, per environment. Environments with no number for
510/// any of them are left out. Built on [`compare`]: the places are its rankings, unchanged.
511pub 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        // p50 on every workload, for the backends that have them all.
540        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            // On another machine the order is the opposite: it must be a separate comparison.
639            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            // a is fastest at get among a, b, c but slowest at put; b is the reverse; c is slower
679            // than b everywhere.
680            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            // d has no put number.
687            measured_on("speed.kv.get", "d", 5),
688            // A workload not asked for must not leak in.
689            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        // Places are compare()'s own rankings: d is first at get, a is third at put.
700        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        // c is worse than b on both: dominated. a and b each win one: on the front. d lacks a number,
704        // so nobody is judged against it and it is judged by nobody.
705        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        // Controls: a workload nobody ran gives nothing; one workload gives every backend a place.
710        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}