Skip to main content

mobench_report/
render.rs

1use crate::{
2    BenchmarkFailureStats, BenchmarkResourceUsage, BenchmarkStats, CompareReport, CompareRow,
3    RegressionFinding, SummaryReport, csv_field, markdown_inline_field_text,
4    markdown_table_field_text,
5};
6use serde_json::{Value, json};
7use std::collections::{BTreeMap, HashMap};
8use std::fmt::{Display, Write};
9use std::path::Path;
10
11const MEMORY_BASELINE_GAP_MIN_DIFF_KB: u64 = 256 * 1024;
12const MEMORY_BASELINE_GAP_RATIO: u64 = 4;
13pub const MEMORY_BASELINE_GAP_NOTE: &str =
14    "memory growth excludes warmup/baseline retained before the measured iteration.";
15
16/// Render the released Markdown compatibility report from the canonical model.
17#[must_use]
18pub fn render_markdown_summary<T: Display>(summary: &SummaryReport<T>) -> String {
19    let mut output = String::new();
20    let devices = if summary.devices.is_empty() {
21        "none".to_string()
22    } else {
23        summary
24            .devices
25            .iter()
26            .map(|device| markdown_inline_field_text(device))
27            .collect::<Vec<_>>()
28            .join(", ")
29    };
30
31    let _ = writeln!(output, "### Benchmark Summary");
32    let _ = writeln!(output);
33    let _ = writeln!(
34        output,
35        "- Generated: {}",
36        markdown_inline_field_text(&summary.generated_at)
37    );
38    let _ = writeln!(
39        output,
40        "- Target: {}",
41        markdown_inline_field_text(&summary.target.to_string())
42    );
43    let _ = writeln!(
44        output,
45        "- Function: {}",
46        markdown_inline_field_text(&summary.function)
47    );
48    let _ = writeln!(
49        output,
50        "- Iterations/Warmup: {} / {}",
51        summary.iterations, summary.warmup
52    );
53    let _ = writeln!(output, "- Devices: {devices}");
54    let _ = writeln!(output);
55
56    if summary.device_summaries.is_empty() {
57        let _ = writeln!(output, "No benchmark samples were collected.");
58        return output;
59    }
60
61    let has_failures = summary.device_summaries.iter().any(|device| {
62        device
63            .benchmarks
64            .iter()
65            .any(|benchmark| benchmark.failure.is_some())
66    });
67    if has_failures {
68        let _ = writeln!(
69            output,
70            "| Device | Function | Status | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak growth | Process peak | Elapsed | Exit reason |"
71        );
72        let _ = writeln!(
73            output,
74            "| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |"
75        );
76    } else {
77        let _ = writeln!(
78            output,
79            "| Device | Function | Samples | Warmup | Wall mean / iter | Wall total | CPU median / iter | CPU total | CPU / wall | Peak growth | Process peak |"
80        );
81        let _ = writeln!(
82            output,
83            "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |"
84        );
85    }
86
87    for device in &summary.device_summaries {
88        for bench in &device.benchmarks {
89            if has_failures {
90                let _ = writeln!(
91                    output,
92                    "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |",
93                    markdown_table_field_text(&device.device),
94                    markdown_table_field_text(&bench.function),
95                    format_benchmark_status(bench),
96                    bench.samples,
97                    summary.warmup,
98                    format_ms(bench.mean_ns),
99                    format_wall_total(bench.mean_ns, bench.samples),
100                    format_cpu_median_ms(bench.resource_usage.as_ref()),
101                    format_cpu_total_ms(bench.resource_usage.as_ref()),
102                    format_cpu_wall_ratio(
103                        bench.mean_ns,
104                        bench.samples,
105                        bench.resource_usage.as_ref()
106                    ),
107                    format_peak_memory(
108                        bench
109                            .resource_usage
110                            .as_ref()
111                            .and_then(BenchmarkResourceUsage::peak_memory_growth_or_legacy_kb)
112                    ),
113                    format_peak_memory(
114                        bench
115                            .resource_usage
116                            .as_ref()
117                            .and_then(|usage| usage.process_peak_memory_kb)
118                    ),
119                    format_failure_elapsed_ms(bench.failure.as_ref()),
120                    markdown_table_field_text(
121                        bench
122                            .failure
123                            .as_ref()
124                            .and_then(|failure| failure.exit_reason.as_deref())
125                            .unwrap_or("-")
126                    ),
127                );
128            } else {
129                let _ = writeln!(
130                    output,
131                    "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |",
132                    markdown_table_field_text(&device.device),
133                    markdown_table_field_text(&bench.function),
134                    bench.samples,
135                    summary.warmup,
136                    format_ms(bench.mean_ns),
137                    format_wall_total(bench.mean_ns, bench.samples),
138                    format_cpu_median_ms(bench.resource_usage.as_ref()),
139                    format_cpu_total_ms(bench.resource_usage.as_ref()),
140                    format_cpu_wall_ratio(
141                        bench.mean_ns,
142                        bench.samples,
143                        bench.resource_usage.as_ref()
144                    ),
145                    format_peak_memory(
146                        bench
147                            .resource_usage
148                            .as_ref()
149                            .and_then(BenchmarkResourceUsage::peak_memory_growth_or_legacy_kb)
150                    ),
151                    format_peak_memory(
152                        bench
153                            .resource_usage
154                            .as_ref()
155                            .and_then(|usage| usage.process_peak_memory_kb)
156                    ),
157                );
158            }
159        }
160    }
161    let _ = writeln!(output);
162    if summary_has_memory_baseline_gap(summary) {
163        let _ = writeln!(output, "_Note: {MEMORY_BASELINE_GAP_NOTE}_");
164        let _ = writeln!(output);
165    }
166    output
167}
168
169/// Render the released RFC 4180 CSV report from the canonical model.
170#[must_use]
171pub fn render_csv_summary<T>(summary: &SummaryReport<T>) -> String {
172    let mut output = String::new();
173    let _ = writeln!(
174        output,
175        "device,function,samples,mean_ns,median_ns,p95_ns,min_ns,max_ns,cpu_total_ms,cpu_median_ms,peak_memory_kb,peak_memory_growth_kb,process_peak_memory_kb"
176    );
177    for device in &summary.device_summaries {
178        for bench in &device.benchmarks {
179            let _ = writeln!(
180                output,
181                "{},{},{},{},{},{},{},{},{},{},{},{},{}",
182                csv_field(&device.device),
183                csv_field(&bench.function),
184                bench.samples,
185                optional_number(bench.mean_ns),
186                optional_number(bench.median_ns),
187                optional_number(bench.p95_ns),
188                optional_number(bench.min_ns),
189                optional_number(bench.max_ns),
190                optional_number(
191                    bench
192                        .resource_usage
193                        .as_ref()
194                        .and_then(|usage| usage.cpu_total_ms)
195                ),
196                optional_number(
197                    bench
198                        .resource_usage
199                        .as_ref()
200                        .and_then(|usage| usage.cpu_median_ms)
201                ),
202                optional_number(
203                    bench
204                        .resource_usage
205                        .as_ref()
206                        .and_then(|usage| usage.peak_memory_kb)
207                ),
208                optional_number(
209                    bench
210                        .resource_usage
211                        .as_ref()
212                        .and_then(BenchmarkResourceUsage::peak_memory_growth_or_legacy_kb)
213                ),
214                optional_number(
215                    bench
216                        .resource_usage
217                        .as_ref()
218                        .and_then(|usage| usage.process_peak_memory_kb)
219                ),
220            );
221        }
222    }
223    output
224}
225
226/// Compare two canonical summary models in deterministic device/function order.
227#[must_use]
228pub fn compare_summaries<T, U>(
229    baseline_path: &Path,
230    candidate_path: &Path,
231    baseline: &SummaryReport<T>,
232    candidate: &SummaryReport<U>,
233) -> CompareReport {
234    let baseline_map = summary_lookup(baseline);
235    let candidate_map = summary_lookup(candidate);
236    let mut rows = Vec::new();
237    let mut devices = BTreeMap::new();
238    devices.extend(baseline_map.keys().map(|key| (key.clone(), ())));
239    devices.extend(candidate_map.keys().map(|key| (key.clone(), ())));
240
241    for device in devices.keys() {
242        let mut functions = BTreeMap::new();
243        if let Some(entry) = baseline_map.get(device) {
244            functions.extend(entry.keys().map(|key| (key.clone(), ())));
245        }
246        if let Some(entry) = candidate_map.get(device) {
247            functions.extend(entry.keys().map(|key| (key.clone(), ())));
248        }
249        for function in functions.keys() {
250            let baseline_stats = baseline_map
251                .get(device)
252                .and_then(|entry| entry.get(function));
253            let candidate_stats = candidate_map
254                .get(device)
255                .and_then(|entry| entry.get(function));
256            let baseline_median_ns = baseline_stats.and_then(|stats| stats.median_ns);
257            let candidate_median_ns = candidate_stats.and_then(|stats| stats.median_ns);
258            let median_delta_pct = percent_delta(baseline_median_ns, candidate_median_ns);
259            let baseline_p95_ns = baseline_stats.and_then(|stats| stats.p95_ns);
260            let candidate_p95_ns = candidate_stats.and_then(|stats| stats.p95_ns);
261            let p95_delta_pct = percent_delta(baseline_p95_ns, candidate_p95_ns);
262            rows.push(CompareRow {
263                device: device.clone(),
264                function: function.clone(),
265                baseline_median_ns,
266                candidate_median_ns,
267                median_delta_pct,
268                median_label: delta_label(median_delta_pct, 0.0).to_string(),
269                baseline_p95_ns,
270                candidate_p95_ns,
271                p95_delta_pct,
272                p95_label: delta_label(p95_delta_pct, 0.0).to_string(),
273            });
274        }
275    }
276    CompareReport {
277        baseline: baseline_path.to_path_buf(),
278        candidate: candidate_path.to_path_buf(),
279        rows,
280    }
281}
282
283#[must_use]
284pub fn detect_regressions(report: &CompareReport, threshold_pct: f64) -> Vec<RegressionFinding> {
285    let mut findings = Vec::new();
286    for row in &report.rows {
287        for (metric, delta) in [("median", row.median_delta_pct), ("p95", row.p95_delta_pct)] {
288            if let Some(delta_pct) = delta.filter(|value| *value > threshold_pct) {
289                findings.push(RegressionFinding {
290                    device: row.device.clone(),
291                    function: row.function.clone(),
292                    metric: metric.to_string(),
293                    delta_pct,
294                });
295            }
296        }
297    }
298    findings
299}
300
301#[must_use]
302pub fn render_compare_markdown(report: &CompareReport) -> String {
303    let mut output = String::new();
304    let _ = writeln!(output, "### Benchmark Comparison");
305    let _ = writeln!(output);
306    let _ = writeln!(
307        output,
308        "- Baseline: {}",
309        markdown_inline_field_text(&report.baseline.display().to_string())
310    );
311    let _ = writeln!(
312        output,
313        "- Candidate: {}",
314        markdown_inline_field_text(&report.candidate.display().to_string())
315    );
316    let _ = writeln!(output);
317    let _ = writeln!(
318        output,
319        "| Device | Function | Median base | Median cand | Median Δ% | Median Label | P95 base | P95 cand | P95 Δ% | P95 Label |"
320    );
321    let _ = writeln!(
322        output,
323        "| --- | --- | ---: | ---: | ---: | --- | ---: | ---: | ---: | --- |"
324    );
325    for row in &report.rows {
326        let _ = writeln!(
327            output,
328            "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |",
329            markdown_table_field_text(&row.device),
330            markdown_table_field_text(&row.function),
331            format_ms(row.baseline_median_ns),
332            format_ms(row.candidate_median_ns),
333            format_delta(row.median_delta_pct),
334            markdown_table_field_text(&row.median_label),
335            format_ms(row.baseline_p95_ns),
336            format_ms(row.candidate_p95_ns),
337            format_delta(row.p95_delta_pct),
338            markdown_table_field_text(&row.p95_label),
339        );
340    }
341    output
342}
343
344/// Render JUnit for CI consumers from the canonical model and regression set.
345#[must_use]
346pub fn render_junit_report<T>(
347    summary: &SummaryReport<T>,
348    regressions: &[RegressionFinding],
349) -> String {
350    let mut output = String::new();
351    let mut failures_by_case: HashMap<(String, String), Vec<&RegressionFinding>> = HashMap::new();
352    for finding in regressions {
353        failures_by_case
354            .entry((finding.device.clone(), finding.function.clone()))
355            .or_default()
356            .push(finding);
357    }
358    let total_tests = summary
359        .device_summaries
360        .iter()
361        .map(|device| device.benchmarks.len())
362        .sum::<usize>();
363    let total_failures = summary
364        .device_summaries
365        .iter()
366        .flat_map(|device| device.benchmarks.iter().map(move |bench| (device, bench)))
367        .filter(|(device, bench)| {
368            failures_by_case.contains_key(&(device.device.clone(), bench.function.clone()))
369        })
370        .count();
371
372    let _ = writeln!(output, r#"<?xml version="1.0" encoding="UTF-8"?>"#);
373    let _ = writeln!(
374        output,
375        r#"<testsuite name="mobench" tests="{total_tests}" failures="{total_failures}">"#
376    );
377    for device in &summary.device_summaries {
378        for bench in &device.benchmarks {
379            let case_name = format!("{}::{}", device.device, bench.function);
380            let time_secs = bench
381                .median_ns
382                .map(|ns| ns as f64 / 1_000_000_000.0)
383                .unwrap_or(0.0);
384            let _ = writeln!(
385                output,
386                r#"  <testcase name="{}" classname="{}" time="{time_secs:.6}">"#,
387                escape_xml(&case_name),
388                escape_xml(&device.device)
389            );
390            if let Some(findings) =
391                failures_by_case.get(&(device.device.clone(), bench.function.clone()))
392            {
393                let mut details = String::new();
394                for finding in findings {
395                    let _ = writeln!(
396                        details,
397                        "{} regression: {:+.2}%",
398                        finding.metric, finding.delta_pct
399                    );
400                }
401                let _ = writeln!(
402                    output,
403                    r#"    <failure message="Performance regression">{}</failure>"#,
404                    escape_xml(details.trim())
405                );
406            }
407            let _ = writeln!(output, "  </testcase>");
408        }
409    }
410    let _ = writeln!(output, "</testsuite>");
411    output
412}
413
414/// JSON projection used by compatibility summaries and GitHub adapters.
415#[must_use]
416pub fn comparison_json(
417    report: &CompareReport,
418    threshold_pct: f64,
419    baseline_source: Option<&str>,
420) -> Value {
421    json!({
422        "baseline": report.baseline.display().to_string(),
423        "baseline_source": baseline_source,
424        "candidate": report.candidate.display().to_string(),
425        "threshold_pct": threshold_pct,
426        "rows": report.rows.iter().map(|row| json!({
427            "device": row.device,
428            "function": row.function,
429            "baseline_median_ns": row.baseline_median_ns,
430            "candidate_median_ns": row.candidate_median_ns,
431            "median_delta_pct": row.median_delta_pct,
432            "median_label": delta_label(row.median_delta_pct, threshold_pct),
433            "baseline_p95_ns": row.baseline_p95_ns,
434            "candidate_p95_ns": row.candidate_p95_ns,
435            "p95_delta_pct": row.p95_delta_pct,
436            "p95_label": delta_label(row.p95_delta_pct, threshold_pct),
437        })).collect::<Vec<_>>()
438    })
439}
440
441fn summary_lookup<T>(
442    summary: &SummaryReport<T>,
443) -> BTreeMap<String, BTreeMap<String, BenchmarkStats>> {
444    summary
445        .device_summaries
446        .iter()
447        .map(|device| {
448            (
449                device.device.clone(),
450                device
451                    .benchmarks
452                    .iter()
453                    .map(|bench| (bench.function.clone(), bench.clone()))
454                    .collect(),
455            )
456        })
457        .collect()
458}
459
460fn percent_delta(baseline: Option<u64>, candidate: Option<u64>) -> Option<f64> {
461    let baseline = baseline? as f64;
462    let candidate = candidate? as f64;
463    (baseline != 0.0).then_some(((candidate - baseline) / baseline) * 100.0)
464}
465
466fn delta_label(delta: Option<f64>, threshold_pct: f64) -> &'static str {
467    match delta {
468        Some(value) if value >= threshold_pct => "regressed",
469        Some(value) if value <= -threshold_pct => "improved",
470        _ => "neutral",
471    }
472}
473
474fn optional_number<T: Display>(value: Option<T>) -> String {
475    value.map_or_else(String::new, |value| value.to_string())
476}
477
478fn format_benchmark_status(bench: &BenchmarkStats) -> String {
479    bench.failure.as_ref().map_or_else(
480        || "ok".to_string(),
481        |failure| format!("failed ({})", markdown_table_field_text(&failure.kind)),
482    )
483}
484
485#[must_use]
486pub fn format_failure_elapsed_ms(failure: Option<&BenchmarkFailureStats>) -> String {
487    failure
488        .and_then(|failure| failure.elapsed_ms)
489        .map(|elapsed_ms| format!("{:.3}s", elapsed_ms as f64 / 1_000.0))
490        .unwrap_or_else(|| "-".to_string())
491}
492
493#[must_use]
494pub fn format_duration_smart(ns: u64) -> String {
495    let ms = ns as f64 / 1_000_000.0;
496    if ms >= 1_000.0 {
497        format!("{:.3}s", ms / 1_000.0)
498    } else {
499        format!("{ms:.3}ms")
500    }
501}
502
503#[must_use]
504pub fn format_ms(value: Option<u64>) -> String {
505    value
506        .map(format_duration_smart)
507        .unwrap_or_else(|| "-".to_string())
508}
509
510fn wall_total_ns(mean_ns: Option<u64>, samples: usize) -> Option<u64> {
511    let total = u128::from(mean_ns?).saturating_mul(u128::try_from(samples).ok()?);
512    Some(total.min(u128::from(u64::MAX)) as u64)
513}
514
515fn format_wall_total(mean_ns: Option<u64>, samples: usize) -> String {
516    wall_total_ns(mean_ns, samples)
517        .map(format_duration_smart)
518        .unwrap_or_else(|| "-".to_string())
519}
520
521fn format_cpu_median_ms(value: Option<&BenchmarkResourceUsage>) -> String {
522    value
523        .and_then(|usage| usage.cpu_median_ms)
524        .map(format_cpu_total_duration_ms)
525        .unwrap_or_else(|| "-".to_string())
526}
527
528fn format_cpu_total_ms(value: Option<&BenchmarkResourceUsage>) -> String {
529    value
530        .and_then(|usage| usage.cpu_total_ms)
531        .map(format_cpu_total_duration_ms)
532        .unwrap_or_else(|| "-".to_string())
533}
534
535fn format_cpu_wall_ratio(
536    mean_ns: Option<u64>,
537    samples: usize,
538    value: Option<&BenchmarkResourceUsage>,
539) -> String {
540    match (
541        wall_total_ns(mean_ns, samples),
542        value.and_then(|usage| usage.cpu_total_ms),
543    ) {
544        (Some(wall_ns), Some(cpu_ms)) if wall_ns > 0 => format!(
545            "{:.1}%",
546            cpu_ms as f64 / (wall_ns as f64 / 1_000_000.0) * 100.0
547        ),
548        _ => "-".to_string(),
549    }
550}
551
552#[must_use]
553pub fn format_cpu_total_duration_ms(ms: u64) -> String {
554    if ms < 1_000 {
555        format!("{ms}ms")
556    } else {
557        format!("{:.3}s", ms as f64 / 1_000.0)
558    }
559}
560
561fn format_peak_memory(value_kb: Option<u64>) -> String {
562    value_kb
563        .map(|value| format!("{:.2} MB", value as f64 / 1_024.0))
564        .unwrap_or_else(|| "-".to_string())
565}
566
567fn summary_has_memory_baseline_gap<T>(summary: &SummaryReport<T>) -> bool {
568    summary.device_summaries.iter().any(|device| {
569        device.benchmarks.iter().any(|benchmark| {
570            benchmark
571                .resource_usage
572                .as_ref()
573                .is_some_and(resource_usage_has_memory_baseline_gap)
574        })
575    })
576}
577
578fn resource_usage_has_memory_baseline_gap(usage: &BenchmarkResourceUsage) -> bool {
579    match (
580        usage.peak_memory_growth_or_legacy_kb(),
581        usage.process_peak_memory_kb,
582    ) {
583        (Some(growth), Some(peak)) if peak > growth => {
584            peak.saturating_sub(growth) >= MEMORY_BASELINE_GAP_MIN_DIFF_KB
585                && peak >= growth.saturating_mul(MEMORY_BASELINE_GAP_RATIO)
586        }
587        _ => false,
588    }
589}
590
591fn format_delta(value: Option<f64>) -> String {
592    value
593        .map(|delta| format!("{delta:+.2}%"))
594        .unwrap_or_else(|| "-".to_string())
595}
596
597fn escape_xml(input: &str) -> String {
598    input
599        .replace('&', "&amp;")
600        .replace('<', "&lt;")
601        .replace('>', "&gt;")
602        .replace('"', "&quot;")
603        .replace('\'', "&apos;")
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609    use crate::{BenchmarkStats, DeviceSummary};
610
611    fn summary() -> SummaryReport<&'static str> {
612        SummaryReport {
613            generated_at: "2026-07-16T00:00:00Z".to_string(),
614            generated_at_unix: 1,
615            target: "Android",
616            function: "crate::bench".to_string(),
617            iterations: 3,
618            warmup: 1,
619            devices: vec!["Pixel 7".to_string()],
620            device_summaries: vec![DeviceSummary {
621                device: "Pixel 7".to_string(),
622                benchmarks: vec![BenchmarkStats {
623                    function: "crate::bench".to_string(),
624                    samples: 3,
625                    mean_ns: Some(2_000_000),
626                    median_ns: Some(2_000_000),
627                    p95_ns: Some(3_000_000),
628                    min_ns: Some(1_000_000),
629                    max_ns: Some(3_000_000),
630                    resource_usage: None,
631                    failure: None,
632                }],
633            }],
634        }
635    }
636
637    #[test]
638    fn all_primary_adapters_render_one_canonical_summary() {
639        let summary = summary();
640        assert!(render_markdown_summary(&summary).contains("Pixel 7"));
641        assert!(render_csv_summary(&summary).contains("Pixel 7,crate::bench,3"));
642        assert!(render_junit_report(&summary, &[]).contains("tests=\"1\""));
643    }
644
645    #[test]
646    fn comparison_order_and_regression_gate_are_deterministic() {
647        let baseline = summary();
648        let mut candidate = summary();
649        candidate.device_summaries[0].benchmarks[0].median_ns = Some(3_000_000);
650        let report = compare_summaries(
651            Path::new("base.json"),
652            Path::new("candidate.json"),
653            &baseline,
654            &candidate,
655        );
656        assert_eq!(report.rows.len(), 1);
657        assert_eq!(detect_regressions(&report, 10.0).len(), 1);
658        assert!(render_compare_markdown(&report).contains("+50.00%"));
659    }
660}