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#[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#[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#[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#[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#[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('&', "&")
600 .replace('<', "<")
601 .replace('>', ">")
602 .replace('"', """)
603 .replace('\'', "'")
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}