Skip to main content

subms/
bench.rs

1//! Shared bench helpers. Pipeline:
2//!
3//! ```text
4//! recipe -> SubMsPerfHarness -> SubMsBenchSummary -> { print, assert, JSON }
5//! ```
6//!
7//! [`summarize`] turns the raw harness into a typed [`SubMsBenchSummary`].
8//! [`print_summary`], [`assert_p99_under`], and [`summary_to_json`] are
9//! presenters / asserters on top of that data; none of them recompute stats.
10//!
11//! The Java sibling ships the same surface (`SubMsBench.summarize`,
12//! `SubMsBench.printSummary`, `SubMsBench.summaryToJson`) with byte-equivalent
13//! output, so tooling can consume either runtime interchangeably.
14
15use std::collections::BTreeMap;
16use std::fmt::Write as _;
17use std::io::{self, Write};
18
19use crate::{
20    SubMsBenchDiff, SubMsBenchParams, SubMsBenchSummary, SubMsBenchSweep, SubMsMetricDiff,
21    SubMsPerfHarness, SubMsRecipe, SubMsStageDiff, SubMsStageSummary, stats,
22};
23
24// ---------------------------------------------------------------------
25// Summarise (structured)
26// ---------------------------------------------------------------------
27
28/// Build a [`SubMsBenchSummary`] from the harness. Includes the downsampled
29/// chronological per-stage timeline, capped at the harness's
30/// [`SubMsPerfHarness::sample_cap`] (default 500). Order matches stage
31/// registration.
32///
33/// If the harness has a [`crate::SubMsObserver`] registered, fires
34/// `on_summarize` exactly once with the produced summary before returning.
35/// Other summary variants (`summarize_lean`, `summarize_skipping`,
36/// `summarize_windowed`) do NOT fire the observer - they're considered
37/// internal re-summarisations rather than the canonical post-bench result.
38pub fn summarize(h: &SubMsPerfHarness) -> SubMsBenchSummary {
39    let summary = summarize_internal(
40        h,
41        /*include_samples*/ true,
42        /*skip_warmup*/ 0,
43        h.sample_cap(),
44    );
45    if let Some(obs) = h.observer() {
46        obs.on_summarize(&summary);
47    }
48    summary
49}
50
51/// Same as [`summarize`] but drops the per-stage sample arrays. Use when you
52/// only need count + percentiles + mean.
53pub fn summarize_lean(h: &SubMsPerfHarness) -> SubMsBenchSummary {
54    summarize_internal(
55        h,
56        /*include_samples*/ false,
57        /*skip_warmup*/ 0,
58        h.sample_cap(),
59    )
60}
61
62/// Same as [`summarize`] but discards the first `skip_warmup` samples per
63/// stage before computing percentiles + mean + stddev. Use when the
64/// recipe can't insert a pre-pass warmup itself (e.g. a JIT- or cache-
65/// cold first ~1k operations would skew p99).
66///
67/// `samples_ns` in the output reflects the trimmed timeline.
68pub fn summarize_skipping(h: &SubMsPerfHarness, skip_warmup: usize) -> SubMsBenchSummary {
69    summarize_internal(
70        h,
71        /*include_samples*/ true,
72        skip_warmup,
73        h.sample_cap(),
74    )
75}
76
77/// Slice each stage's chronological sample buffer into `window` equal-sized
78/// chunks and produce a [`SubMsBenchSummary`] per chunk. Useful for
79/// rolling-window p99 analysis - "how did p99 evolve across the run?"
80///
81/// Windows are sample-count-based, not wall-clock-based, since the harness
82/// doesn't record per-sample timestamps. For wall-clock windows, ensure
83/// the workload runs at a roughly steady rate; then the i-th window
84/// approximates the i-th time slice.
85///
86/// Returns an empty vector if the harness has zero stages.
87pub fn summarize_windowed(h: &SubMsPerfHarness, window: usize) -> Vec<SubMsBenchSummary> {
88    let window = window.max(1);
89    // Find the longest stage; that determines how many windows we emit.
90    let max_len = h
91        .stages()
92        .iter()
93        .map(|s| s.samples().len())
94        .max()
95        .unwrap_or(0);
96    if max_len == 0 {
97        return Vec::new();
98    }
99    let n_windows = max_len.div_ceil(window);
100    let mut out = Vec::with_capacity(n_windows);
101    for w in 0..n_windows {
102        let start = w * window;
103        let stages = h
104            .stages()
105            .iter()
106            .map(|s| {
107                let samples = s.samples();
108                let end = (start + window).min(samples.len());
109                let slice = if start < samples.len() {
110                    &samples[start..end]
111                } else {
112                    &[][..]
113                };
114                summarize_stage(
115                    s.name(),
116                    slice,
117                    /*include_samples*/ false,
118                    /*sample_cap*/ 500,
119                )
120            })
121            .collect();
122        out.push(SubMsBenchSummary {
123            workload: h.workload().to_string(),
124            lang: h.lang().to_string(),
125            timestamp: h.timestamp(),
126            cpu_core: None,
127            cpu_affinity: None,
128            inputs: {
129                let mut m = clone_map(h.inputs());
130                m.insert("__window_index".to_string(), w.to_string());
131                m.insert("__window_size".to_string(), window.to_string());
132                m
133            },
134            meta: clone_map(h.meta()),
135            stages,
136        });
137    }
138    out
139}
140
141// percentile_sweep moved to `crate::stats::percentile_sweep`. Recipes
142// previously importing it from this module can keep using `subms::percentile_sweep`
143// via the top-level re-export.
144
145fn summarize_internal(
146    h: &SubMsPerfHarness,
147    include_samples: bool,
148    skip_warmup: usize,
149    sample_cap: usize,
150) -> SubMsBenchSummary {
151    let stages = h
152        .stages()
153        .iter()
154        .map(|s| {
155            let trimmed = if skip_warmup > 0 && s.samples().len() > skip_warmup {
156                &s.samples()[skip_warmup..]
157            } else {
158                s.samples()
159            };
160            summarize_stage(s.name(), trimmed, include_samples, sample_cap)
161        })
162        .collect();
163    let (cpu_core, cpu_affinity) = cpu_placement();
164    SubMsBenchSummary {
165        workload: h.workload().to_string(),
166        lang: h.lang().to_string(),
167        timestamp: h.timestamp(),
168        cpu_core,
169        cpu_affinity,
170        inputs: clone_map(h.inputs()),
171        meta: clone_map(h.meta()),
172        stages,
173    }
174}
175
176/// Best-effort per-run CPU placement from Linux `/proc`. Returns
177/// (last-run core, allowed-affinity list). `(None, None)` off Linux or on any
178/// read/parse failure - the harness never fails a bench over provenance.
179fn cpu_placement() -> (Option<u32>, Option<String>) {
180    let core = std::fs::read_to_string("/proc/self/stat")
181        .ok()
182        .and_then(|s| {
183            // Fields after the final ')' (which closes `comm`) begin at field 3, so
184            // field 39 (`processor`, the last core the task ran on) is index 36.
185            let start = s.rfind(')').map(|i| i + 1)?;
186            s[start..]
187                .split_whitespace()
188                .nth(36)
189                .and_then(|v| v.parse::<u32>().ok())
190        });
191    let affinity = std::fs::read_to_string("/proc/self/status")
192        .ok()
193        .and_then(|s| {
194            s.lines()
195                .find_map(|l| l.strip_prefix("Cpus_allowed_list:"))
196                .map(|v| v.trim().to_string())
197        });
198    (core, affinity)
199}
200
201fn summarize_stage(
202    name: &str,
203    chronological: &[u64],
204    include_samples: bool,
205    sample_cap: usize,
206) -> SubMsStageSummary {
207    let mut sorted = chronological.to_vec();
208    sorted.sort_unstable();
209    let samples_ns = if include_samples {
210        Some(downsample(chronological, sample_cap))
211    } else {
212        None
213    };
214    SubMsStageSummary {
215        name: name.to_string(),
216        count: sorted.len(),
217        p50_ns: stats::percentile(&sorted, 0.50),
218        p99_ns: stats::percentile(&sorted, 0.99),
219        p999_ns: stats::percentile(&sorted, 0.999),
220        max_ns: sorted.last().copied().unwrap_or(0),
221        mean_ns: stats::mean(chronological),
222        stddev_ns: stats::stddev(chronological),
223        cdf_buckets_ns: stats::cdf_buckets(chronological),
224        jitter_score: stats::jitter_score(chronological),
225        samples_ns,
226    }
227}
228
229/// Evenly-spaced downsample to at most `cap` points, chronological order
230/// preserved. `cap == 0` is treated as 1. Pass a `cap >= len` (e.g. equal to
231/// `entries`) to keep every point.
232pub(crate) fn downsample(chronological: &[u64], cap: usize) -> Vec<u64> {
233    let n = chronological.len();
234    if n == 0 {
235        return Vec::new();
236    }
237    let step = (n / cap.max(1)).max(1);
238    chronological.iter().copied().step_by(step).collect()
239}
240
241fn clone_map(src: &BTreeMap<String, String>) -> BTreeMap<String, String> {
242    src.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
243}
244
245// ---------------------------------------------------------------------
246// Print (presenter)
247// ---------------------------------------------------------------------
248
249/// Print a fixed-width percentile table for every stage in the summary, in
250/// registration order. Byte-equivalent to Java's `SubMsBench.printSummary`.
251pub fn print_summary<W: Write>(s: &SubMsBenchSummary, out: &mut W) -> io::Result<()> {
252    writeln!(
253        out,
254        "  {:<9}  {:>9}  {:>9}  {:>9}  {:>9}  {:>9}",
255        "stage", "p50", "p99", "p99.9", "max", "mean"
256    )?;
257    for stage in &s.stages {
258        writeln!(
259            out,
260            "  {:<9}  {:>9}  {:>9}  {:>9}  {:>9}  {:>9}",
261            stage.name,
262            format_ns(stage.p50_ns),
263            format_ns(stage.p99_ns),
264            format_ns(stage.p999_ns),
265            format_ns(stage.max_ns),
266            format_ns(stage.mean_ns),
267        )?;
268    }
269    Ok(())
270}
271
272/// Compact unit-aware ns formatter. Sub-microsecond stays in ns; sub-millisecond
273/// goes to us with one decimal; everything else is ms to two decimals. Matches
274/// Java's `SubMsBench.formatNs`.
275pub fn format_ns(ns: u64) -> String {
276    if ns < 1_000 {
277        format!("{}ns", ns)
278    } else if ns < 1_000_000 {
279        format!("{:.1}us", ns as f64 / 1_000.0)
280    } else {
281        format!("{:.2}ms", ns as f64 / 1_000_000.0)
282    }
283}
284
285// ---------------------------------------------------------------------
286// Assert
287// ---------------------------------------------------------------------
288
289/// A single stage-level p99 assertion.
290#[derive(Debug, Clone, Copy)]
291pub struct SubMsBenchAssertion {
292    /// Stage name as registered with the harness.
293    pub stage: &'static str,
294    /// Upper bound, ns.
295    pub p99_ns_max: u64,
296}
297
298/// Accept either a [`SubMsBenchSummary`] (recommended) or a
299/// [`SubMsPerfHarness`] (back-compat). Used by [`assert_p99_under`].
300pub trait SubMsAssertionTarget {
301    fn lookup_p99_ns(&self, stage: &str) -> Option<u64>;
302}
303
304impl SubMsAssertionTarget for SubMsBenchSummary {
305    fn lookup_p99_ns(&self, stage: &str) -> Option<u64> {
306        self.stage(stage).map(|s| s.p99_ns)
307    }
308}
309
310impl SubMsAssertionTarget for SubMsPerfHarness {
311    fn lookup_p99_ns(&self, stage: &str) -> Option<u64> {
312        let st = self.stage_by_name(stage)?;
313        let mut sorted = st.samples().to_vec();
314        sorted.sort_unstable();
315        Some(stats::percentile(&sorted, 0.99))
316    }
317}
318
319/// `Err` on the first stage that exceeds its p99 bound (or is missing).
320/// Accepts either a summary or the raw harness.
321pub fn assert_p99_under<T: SubMsAssertionTarget + ?Sized>(
322    target: &T,
323    assertions: &[SubMsBenchAssertion],
324) -> Result<(), String> {
325    for a in assertions {
326        let p99 = target
327            .lookup_p99_ns(a.stage)
328            .ok_or_else(|| format!("stage '{}' not found", a.stage))?;
329        if p99 > a.p99_ns_max {
330            return Err(format!(
331                "stage '{}' p99 = {} ns exceeded limit {} ns",
332                a.stage, p99, a.p99_ns_max
333            ));
334        }
335    }
336    Ok(())
337}
338
339// ---------------------------------------------------------------------
340// Run
341// ---------------------------------------------------------------------
342
343/// Alias of [`crate::recipe::benchmark`]; matches Java's `Bench.runBench`.
344pub fn run_bench<R: SubMsRecipe + ?Sized>(
345    recipe: &R,
346    params: &SubMsBenchParams,
347) -> SubMsPerfHarness {
348    crate::recipe::benchmark(recipe, params)
349}
350
351/// Run a contended workload `iterations_per_thread` times on `threads`
352/// threads and discard the timings. The standard way to warm up
353/// recipes whose hot path runs under multi-producer contention: serial
354/// warmup compiles the function under uncontended cache-line traffic,
355/// which doesn't expose the JIT / branch predictor to the actual
356/// contended pattern the timed loop uses. Without this pre-pass the
357/// first 1-2k samples in the timed loop run "cold" under contention
358/// and inflate p99 by 3-5x.
359///
360/// `work` is invoked once per iteration on each thread with the
361/// `(thread_id, iteration)` pair.
362pub fn contended_warmup<F>(threads: usize, iterations_per_thread: usize, work: F)
363where
364    F: Fn(usize, usize) + Send + Sync + 'static + Copy,
365{
366    let mut handles = Vec::with_capacity(threads);
367    for tid in 0..threads {
368        handles.push(std::thread::spawn(move || {
369            for i in 0..iterations_per_thread {
370                work(tid, i);
371            }
372        }));
373    }
374    for h in handles {
375        h.join().expect("contended_warmup thread");
376    }
377}
378
379// ---------------------------------------------------------------------
380// JSON (presenter)
381// ---------------------------------------------------------------------
382
383/// Serialise the summary to the standard subms JSON shape. Byte-equivalent
384/// to Java's `SubMsBench.summaryToJson`.
385pub fn summary_to_json<W: Write>(s: &SubMsBenchSummary, out: &mut W) -> io::Result<()> {
386    let mut buf = String::with_capacity(64 * 1024);
387    append_summary_json(&mut buf, s);
388    out.write_all(buf.as_bytes())?;
389    out.write_all(b"\n")?;
390    Ok(())
391}
392
393pub(crate) fn append_summary_json(out: &mut String, s: &SubMsBenchSummary) {
394    out.push('{');
395    json_kv_str(out, "workload", &s.workload);
396    out.push(',');
397    json_kv_str(out, "lang", &s.lang);
398    out.push(',');
399    json_kv_str(out, "timestamp", &s.timestamp);
400    out.push(',');
401    out.push_str("\"inputs\":");
402    json_map(out, &s.inputs);
403    out.push(',');
404    out.push_str("\"meta\":");
405    json_map(out, &s.meta);
406    out.push(',');
407    out.push_str("\"cpu\":");
408    cpu_json(out, s.cpu_core, s.cpu_affinity.as_deref());
409    out.push(',');
410    out.push_str("\"stages\":{");
411    for (i, stage) in s.stages.iter().enumerate() {
412        if i > 0 {
413            out.push(',');
414        }
415        json_str(out, &stage.name);
416        out.push(':');
417        stage_json(out, stage);
418    }
419    out.push_str("}}");
420}
421
422fn cpu_json(out: &mut String, core: Option<u32>, affinity: Option<&str>) {
423    if core.is_none() && affinity.is_none() {
424        out.push_str("null");
425        return;
426    }
427    out.push('{');
428    match core {
429        Some(c) => {
430            let _ = write!(out, "\"core\":{c}");
431        }
432        None => out.push_str("\"core\":null"),
433    }
434    out.push_str(",\"affinity\":");
435    match affinity {
436        Some(a) => json_str(out, a),
437        None => out.push_str("null"),
438    }
439    out.push('}');
440}
441
442fn stage_json(out: &mut String, stage: &SubMsStageSummary) {
443    out.push('{');
444    let _ = write!(out, "\"count\":{},", stage.count);
445    let _ = write!(out, "\"p50_ns\":{},", stage.p50_ns);
446    let _ = write!(out, "\"p99_ns\":{},", stage.p99_ns);
447    let _ = write!(out, "\"p999_ns\":{},", stage.p999_ns);
448    let _ = write!(out, "\"max_ns\":{},", stage.max_ns);
449    let _ = write!(out, "\"mean_ns\":{},", stage.mean_ns);
450    let _ = write!(out, "\"stddev_ns\":{},", stage.stddev_ns);
451    let _ = write!(out, "\"jitter_score\":{:.4},", stage.jitter_score);
452    out.push_str("\"cdf_buckets_ns\":[");
453    for (i, c) in stage.cdf_buckets_ns.iter().enumerate() {
454        if i > 0 {
455            out.push(',');
456        }
457        let _ = write!(out, "{}", c);
458    }
459    out.push_str("],");
460    out.push_str("\"samples_ns\":[");
461    if let Some(samples) = &stage.samples_ns {
462        for (i, x) in samples.iter().enumerate() {
463            if i > 0 {
464                out.push(',');
465            }
466            let _ = write!(out, "{}", x);
467        }
468    }
469    out.push_str("]}");
470}
471
472fn json_str(out: &mut String, s: &str) {
473    out.push('"');
474    for c in s.chars() {
475        match c {
476            '"' => out.push_str("\\\""),
477            '\\' => out.push_str("\\\\"),
478            '\n' => out.push_str("\\n"),
479            '\r' => out.push_str("\\r"),
480            '\t' => out.push_str("\\t"),
481            c if (c as u32) < 0x20 => {
482                let _ = write!(out, "\\u{:04x}", c as u32);
483            }
484            c => out.push(c),
485        }
486    }
487    out.push('"');
488}
489
490fn json_kv_str(out: &mut String, k: &str, v: &str) {
491    json_str(out, k);
492    out.push(':');
493    json_str(out, v);
494}
495
496fn json_map(out: &mut String, m: &BTreeMap<String, String>) {
497    out.push('{');
498    for (i, (k, v)) in m.iter().enumerate() {
499        if i > 0 {
500            out.push(',');
501        }
502        json_kv_str(out, k, v);
503    }
504    out.push('}');
505}
506
507// ---------------------------------------------------------------------
508// Sweep (multi-run varied-input pipeline)
509// ---------------------------------------------------------------------
510
511/// Run the recipe once per element of `params_list`, summarise each run, and
512/// bundle the summaries into a [`SubMsBenchSweep`]. `varied_input_key` should
513/// name the input that differs across runs (typically `"entries"`); pass
514/// `None` to leave it unset.
515pub fn run_sweep<R: SubMsRecipe + ?Sized>(
516    recipe: &R,
517    params_list: &[SubMsBenchParams],
518    varied_input_key: Option<&str>,
519) -> SubMsBenchSweep {
520    let runs = params_list
521        .iter()
522        .map(|p| summarize(&run_bench(recipe, p)))
523        .collect();
524    SubMsBenchSweep {
525        workload: recipe.name().to_string(),
526        lang: "rust".to_string(),
527        varied_input_key: varied_input_key.map(|s| s.to_string()),
528        runs,
529    }
530}
531
532/// Bundle pre-computed summaries (e.g. captured separately) into a sweep. All
533/// summaries should share a workload; the first summary's workload is used.
534pub fn summarize_sweep(
535    summaries: Vec<SubMsBenchSummary>,
536    varied_input_key: Option<&str>,
537) -> SubMsBenchSweep {
538    assert!(
539        !summaries.is_empty(),
540        "summarize_sweep requires at least one run"
541    );
542    SubMsBenchSweep {
543        workload: summaries[0].workload.clone(),
544        lang: summaries[0].lang.clone(),
545        varied_input_key: varied_input_key.map(|s| s.to_string()),
546        runs: summaries,
547    }
548}
549
550/// Print a pivoted percentile table per stage: one block per stage, one row
551/// per run, labelled by the varied input value (or by ordinal if no varied
552/// key was supplied). Byte-equivalent to Java's `SubMsBench.printSweep`.
553pub fn print_sweep<W: Write>(sweep: &SubMsBenchSweep, out: &mut W) -> io::Result<()> {
554    if sweep.runs.is_empty() {
555        writeln!(out, "(empty sweep)")?;
556        return Ok(());
557    }
558    let first = &sweep.runs[0];
559    let header_label = sweep.varied_input_key.as_deref().unwrap_or("run");
560    for stage in &first.stages {
561        writeln!(out, "stage: {}", stage.name)?;
562        writeln!(
563            out,
564            "  {:<15}  {:>9}  {:>9}  {:>9}  {:>9}  {:>9}  {:>9}",
565            header_label, "count", "p50", "p99", "p99.9", "max", "mean"
566        )?;
567        for (i, run) in sweep.runs.iter().enumerate() {
568            let label = match &sweep.varied_input_key {
569                Some(k) => run
570                    .inputs
571                    .get(k)
572                    .cloned()
573                    .unwrap_or_else(|| "?".to_string()),
574                None => format!("run {}", i + 1),
575            };
576            match run.stage(&stage.name) {
577                None => writeln!(out, "  {:<15}  (stage missing)", label)?,
578                Some(s) => writeln!(
579                    out,
580                    "  {:<15}  {:>9}  {:>9}  {:>9}  {:>9}  {:>9}  {:>9}",
581                    label,
582                    s.count,
583                    format_ns(s.p50_ns),
584                    format_ns(s.p99_ns),
585                    format_ns(s.p999_ns),
586                    format_ns(s.max_ns),
587                    format_ns(s.mean_ns)
588                )?,
589            }
590        }
591        writeln!(out)?;
592    }
593    Ok(())
594}
595
596/// Emit a JSON array of run-summaries, identical shape to
597/// on-disk `perf/<lang>.json`. Byte-equivalent to Java's
598/// `SubMsBench.sweepToJson`.
599pub fn sweep_to_json<W: Write>(sweep: &SubMsBenchSweep, out: &mut W) -> io::Result<()> {
600    let mut buf = String::with_capacity(64 * 1024);
601    buf.push('[');
602    for (i, run) in sweep.runs.iter().enumerate() {
603        if i > 0 {
604            buf.push(',');
605        }
606        append_summary_json(&mut buf, run);
607    }
608    buf.push(']');
609    out.write_all(buf.as_bytes())?;
610    out.write_all(b"\n")?;
611    Ok(())
612}
613
614// ---------------------------------------------------------------------
615// Diff (baseline vs candidate regression detection)
616// ---------------------------------------------------------------------
617
618/// Default percent above which a stage's metric is considered a regression for
619/// the subms-perf-gate CI workflow. Matches Java's
620/// `SubMsBench.DEFAULT_REGRESSION_THRESHOLD_PCT`.
621pub const DEFAULT_REGRESSION_THRESHOLD_PCT: f64 = 10.0;
622
623/// Build a typed diff between two summaries using the default 10 % threshold.
624pub fn diff_summary(baseline: &SubMsBenchSummary, candidate: &SubMsBenchSummary) -> SubMsBenchDiff {
625    diff_summary_with(baseline, candidate, DEFAULT_REGRESSION_THRESHOLD_PCT)
626}
627
628/// Same as [`diff_summary`] but caller specifies the regression threshold.
629pub fn diff_summary_with(
630    baseline: &SubMsBenchSummary,
631    candidate: &SubMsBenchSummary,
632    regression_threshold_pct: f64,
633) -> SubMsBenchDiff {
634    let baseline_names: Vec<&str> = baseline.stages.iter().map(|s| s.name.as_str()).collect();
635    let candidate_names: std::collections::BTreeSet<&str> =
636        candidate.stages.iter().map(|s| s.name.as_str()).collect();
637
638    let mut stage_diffs = Vec::new();
639    for cand in &candidate.stages {
640        if let Some(base) = baseline.stage(&cand.name) {
641            stage_diffs.push(diff_stage(base, cand));
642        }
643    }
644
645    let candidate_name_set: std::collections::BTreeSet<&str> = candidate_names.clone();
646    let baseline_name_set: std::collections::BTreeSet<&str> =
647        baseline_names.iter().copied().collect();
648    let baseline_only: Vec<String> = baseline_names
649        .iter()
650        .filter(|n| !candidate_name_set.contains(*n))
651        .map(|s| s.to_string())
652        .collect();
653    let candidate_only: Vec<String> = candidate
654        .stages
655        .iter()
656        .map(|s| s.name.clone())
657        .filter(|n| !baseline_name_set.contains(n.as_str()))
658        .collect();
659
660    SubMsBenchDiff {
661        baseline_workload: baseline.workload.clone(),
662        candidate_workload: candidate.workload.clone(),
663        lang: candidate.lang.clone(),
664        stages: stage_diffs,
665        baseline_only_stages: baseline_only,
666        candidate_only_stages: candidate_only,
667        regression_threshold_pct,
668    }
669}
670
671fn diff_stage(baseline: &SubMsStageSummary, candidate: &SubMsStageSummary) -> SubMsStageDiff {
672    let metrics = vec![
673        metric_diff("p50", baseline.p50_ns, candidate.p50_ns),
674        metric_diff("p99", baseline.p99_ns, candidate.p99_ns),
675        metric_diff("p99.9", baseline.p999_ns, candidate.p999_ns),
676        metric_diff("max", baseline.max_ns, candidate.max_ns),
677        metric_diff("mean", baseline.mean_ns, candidate.mean_ns),
678    ];
679    let worst = metrics
680        .iter()
681        .filter(|m| m.delta_pct.is_finite())
682        .map(|m| m.delta_pct)
683        .fold(0.0_f64, f64::max);
684    SubMsStageDiff {
685        stage: baseline.name.clone(),
686        metrics,
687        worst_regression_pct: worst,
688    }
689}
690
691fn metric_diff(name: &str, baseline: u64, candidate: u64) -> SubMsMetricDiff {
692    let delta_ns = candidate as i64 - baseline as i64;
693    let delta_pct = if baseline == 0 {
694        if candidate == 0 { 0.0 } else { f64::INFINITY }
695    } else {
696        (100.0 * delta_ns as f64) / baseline as f64
697    };
698    SubMsMetricDiff {
699        metric: name.to_string(),
700        baseline_ns: baseline,
701        candidate_ns: candidate,
702        delta_ns,
703        delta_pct,
704    }
705}
706
707/// Print a regression-table view of the diff. Byte-equivalent to Java's
708/// `SubMsBench.printDiff`.
709pub fn print_diff<W: Write>(diff: &SubMsBenchDiff, out: &mut W) -> io::Result<()> {
710    writeln!(
711        out,
712        "diff: {} vs {} ({})  threshold=+{:.1}%",
713        diff.baseline_workload, diff.candidate_workload, diff.lang, diff.regression_threshold_pct
714    )?;
715    writeln!(
716        out,
717        "  {:<12}  {:<7}  {:>9}  {:>9}  {:>9}  {:>9}  verdict",
718        "stage", "metric", "baseline", "candidate", "delta", "%delta"
719    )?;
720    for stage in &diff.stages {
721        for m in &stage.metrics {
722            let pct_str = if m.delta_pct.is_finite() {
723                format!("{:+.1}%", m.delta_pct)
724            } else {
725                "+inf%".to_string()
726            };
727            let verdict = if m.delta_pct.is_finite() && m.delta_pct > diff.regression_threshold_pct
728            {
729                "REGRESSED"
730            } else {
731                "ok"
732            };
733            let abs = m.delta_ns.unsigned_abs();
734            let delta_str = if m.delta_ns >= 0 {
735                format!("+{}", format_ns(abs))
736            } else {
737                format!("-{}", format_ns(abs))
738            };
739            writeln!(
740                out,
741                "  {:<12}  {:<7}  {:>9}  {:>9}  {:>9}  {:>9}  {}",
742                stage.stage,
743                m.metric,
744                format_ns(m.baseline_ns),
745                format_ns(m.candidate_ns),
746                delta_str,
747                pct_str,
748                verdict,
749            )?;
750        }
751    }
752    if !diff.baseline_only_stages.is_empty() {
753        writeln!(
754            out,
755            "  stages only in baseline:  {}",
756            diff.baseline_only_stages.join(", ")
757        )?;
758    }
759    if !diff.candidate_only_stages.is_empty() {
760        writeln!(
761            out,
762            "  stages only in candidate: {}",
763            diff.candidate_only_stages.join(", ")
764        )?;
765    }
766    Ok(())
767}
768
769/// Emit the diff as a single JSON object for downstream CI tooling.
770/// Byte-equivalent to Java's `SubMsBench.diffToJson`.
771pub fn diff_to_json<W: Write>(diff: &SubMsBenchDiff, out: &mut W) -> io::Result<()> {
772    let mut buf = String::with_capacity(8 * 1024);
773    append_diff_json(&mut buf, diff);
774    out.write_all(buf.as_bytes())?;
775    out.write_all(b"\n")?;
776    Ok(())
777}
778
779fn append_diff_json(out: &mut String, diff: &SubMsBenchDiff) {
780    out.push('{');
781    json_kv_str(out, "baseline_workload", &diff.baseline_workload);
782    out.push(',');
783    json_kv_str(out, "candidate_workload", &diff.candidate_workload);
784    out.push(',');
785    json_kv_str(out, "lang", &diff.lang);
786    out.push(',');
787    let _ = write!(
788        out,
789        "\"regression_threshold_pct\":{},",
790        diff.regression_threshold_pct
791    );
792    let _ = write!(out, "\"has_regression\":{},", diff.has_regression());
793    out.push_str("\"stages\":[");
794    for (i, s) in diff.stages.iter().enumerate() {
795        if i > 0 {
796            out.push(',');
797        }
798        out.push('{');
799        json_kv_str(out, "stage", &s.stage);
800        out.push(',');
801        let _ = write!(
802            out,
803            "\"worst_regression_pct\":{},",
804            json_number(s.worst_regression_pct)
805        );
806        out.push_str("\"metrics\":[");
807        for (j, m) in s.metrics.iter().enumerate() {
808            if j > 0 {
809                out.push(',');
810            }
811            out.push('{');
812            json_kv_str(out, "metric", &m.metric);
813            out.push(',');
814            let _ = write!(out, "\"baseline_ns\":{},", m.baseline_ns);
815            let _ = write!(out, "\"candidate_ns\":{},", m.candidate_ns);
816            let _ = write!(out, "\"delta_ns\":{},", m.delta_ns);
817            let _ = write!(out, "\"delta_pct\":{}", json_number(m.delta_pct));
818            out.push('}');
819        }
820        out.push_str("]}");
821    }
822    out.push_str("],");
823    out.push_str("\"baseline_only_stages\":[");
824    for (i, n) in diff.baseline_only_stages.iter().enumerate() {
825        if i > 0 {
826            out.push(',');
827        }
828        json_str(out, n);
829    }
830    out.push_str("],");
831    out.push_str("\"candidate_only_stages\":[");
832    for (i, n) in diff.candidate_only_stages.iter().enumerate() {
833        if i > 0 {
834            out.push(',');
835        }
836        json_str(out, n);
837    }
838    out.push_str("]}");
839}
840
841/// Render a finite f64 as a JSON number; non-finite values become `null`
842/// (JSON has no inf/NaN literal).
843fn json_number(d: f64) -> String {
844    if d.is_finite() {
845        d.to_string()
846    } else {
847        "null".to_string()
848    }
849}
850
851// percentile is INTERNAL ONLY. Recipes don't reach for it directly;
852// they read the percentile fields off `SubMsStageSummary`. External
853// consumers wanting a standalone percentile fn pull in `subms-stats`.
854
855#[cfg(test)]
856#[path = "bench_tests.rs"]
857mod tests;