1use 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
24pub fn summarize(h: &SubMsPerfHarness) -> SubMsBenchSummary {
39 let summary = summarize_internal(
40 h,
41 true,
42 0,
43 h.sample_cap(),
44 );
45 if let Some(obs) = h.observer() {
46 obs.on_summarize(&summary);
47 }
48 summary
49}
50
51pub fn summarize_lean(h: &SubMsPerfHarness) -> SubMsBenchSummary {
54 summarize_internal(
55 h,
56 false,
57 0,
58 h.sample_cap(),
59 )
60}
61
62pub fn summarize_skipping(h: &SubMsPerfHarness, skip_warmup: usize) -> SubMsBenchSummary {
69 summarize_internal(
70 h,
71 true,
72 skip_warmup,
73 h.sample_cap(),
74 )
75}
76
77pub fn summarize_windowed(h: &SubMsPerfHarness, window: usize) -> Vec<SubMsBenchSummary> {
88 let window = window.max(1);
89 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 false,
118 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
141fn 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
176fn cpu_placement() -> (Option<u32>, Option<String>) {
180 let core = std::fs::read_to_string("/proc/self/stat")
181 .ok()
182 .and_then(|s| {
183 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
229pub(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
245pub 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
272pub 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#[derive(Debug, Clone, Copy)]
291pub struct SubMsBenchAssertion {
292 pub stage: &'static str,
294 pub p99_ns_max: u64,
296}
297
298pub 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
319pub 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
339pub fn run_bench<R: SubMsRecipe + ?Sized>(
345 recipe: &R,
346 params: &SubMsBenchParams,
347) -> SubMsPerfHarness {
348 crate::recipe::benchmark(recipe, params)
349}
350
351pub 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
379pub 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
507pub 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
532pub 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
550pub 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
596pub 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
614pub const DEFAULT_REGRESSION_THRESHOLD_PCT: f64 = 10.0;
622
623pub fn diff_summary(baseline: &SubMsBenchSummary, candidate: &SubMsBenchSummary) -> SubMsBenchDiff {
625 diff_summary_with(baseline, candidate, DEFAULT_REGRESSION_THRESHOLD_PCT)
626}
627
628pub 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
707pub 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
769pub 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
841fn json_number(d: f64) -> String {
844 if d.is_finite() {
845 d.to_string()
846 } else {
847 "null".to_string()
848 }
849}
850
851#[cfg(test)]
856#[path = "bench_tests.rs"]
857mod tests;