1use std::{
4 collections::{BTreeMap, btree_map::Entry},
5 ffi::OsStr,
6 fmt::Write as _,
7 fs, io,
8 path::{Path, PathBuf},
9};
10
11use serde_json::Value;
12
13pub const DEFAULT_PREFIX: &str = "ICTK";
14const ALL_SUITES_LABEL: &str = "ALL";
15
16#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
17enum AggregateScope {
18 Suite(String),
19 All,
20}
21
22impl AggregateScope {
23 fn label(&self) -> &str {
24 match self {
25 Self::Suite(suite) => suite,
26 Self::All => ALL_SUITES_LABEL,
27 }
28 }
29}
30
31#[derive(Clone, Debug, Eq, PartialEq)]
32pub struct BenchmarkParserConfig {
33 pub prefixes: Vec<String>,
34 pub suite_derivation: SuiteDerivation,
35 pub strict: bool,
38}
39
40impl Default for BenchmarkParserConfig {
41 fn default() -> Self {
42 Self {
43 prefixes: vec![DEFAULT_PREFIX.to_string()],
44 suite_derivation: SuiteDerivation::FirstPathSegment,
45 strict: false,
46 }
47 }
48}
49
50#[derive(Clone, Debug, Eq, PartialEq)]
51pub enum SuiteDerivation {
52 FirstPathSegment,
53 Fixed(String),
54}
55
56impl SuiteDerivation {
57 #[must_use]
58 pub fn derive_suite(&self, span_label: &str) -> String {
59 match self {
60 Self::FirstPathSegment => span_label
61 .split('/')
62 .next()
63 .filter(|part| !part.is_empty())
64 .unwrap_or(span_label)
65 .to_string(),
66 Self::Fixed(suite) => suite.clone(),
67 }
68 }
69}
70
71#[derive(Clone, Copy, Debug, Eq, PartialEq)]
72pub enum BenchmarkEventKind {
73 Start,
74 End,
75}
76
77#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78pub enum BenchmarkEventSource {
79 Unknown,
80 Stdout,
81 Stderr,
82 FetchedLog,
83}
84
85impl BenchmarkEventSource {
86 #[must_use]
87 pub const fn as_str(self) -> &'static str {
88 match self {
89 Self::Unknown => "unknown",
90 Self::Stdout => "stdout",
91 Self::Stderr => "stderr",
92 Self::FetchedLog => "fetched_log",
93 }
94 }
95}
96
97#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
98pub struct BenchmarkCounters {
99 pub instructions: u128,
100 pub heap_bytes: u128,
101 pub memory_bytes: u128,
102 pub total_allocation: u128,
103}
104
105impl BenchmarkCounters {
106 fn checked_delta(self, start: Self) -> Option<Self> {
107 Some(Self {
108 instructions: self.instructions.checked_sub(start.instructions)?,
109 heap_bytes: self.heap_bytes.checked_sub(start.heap_bytes)?,
110 memory_bytes: self.memory_bytes.checked_sub(start.memory_bytes)?,
111 total_allocation: self.total_allocation.checked_sub(start.total_allocation)?,
112 })
113 }
114
115 const fn add_assign(&mut self, other: Self) {
116 self.instructions += other.instructions;
117 self.heap_bytes += other.heap_bytes;
118 self.memory_bytes += other.memory_bytes;
119 self.total_allocation += other.total_allocation;
120 }
121
122 fn min_assign(&mut self, other: Self) {
123 self.instructions = self.instructions.min(other.instructions);
124 self.heap_bytes = self.heap_bytes.min(other.heap_bytes);
125 self.memory_bytes = self.memory_bytes.min(other.memory_bytes);
126 self.total_allocation = self.total_allocation.min(other.total_allocation);
127 }
128
129 fn max_assign(&mut self, other: Self) {
130 self.instructions = self.instructions.max(other.instructions);
131 self.heap_bytes = self.heap_bytes.max(other.heap_bytes);
132 self.memory_bytes = self.memory_bytes.max(other.memory_bytes);
133 self.total_allocation = self.total_allocation.max(other.total_allocation);
134 }
135}
136
137#[derive(Clone, Debug, Eq, PartialEq)]
138pub struct RawBenchmarkEvent {
139 pub prefix: String,
140 pub label: String,
141 pub suite: String,
142 pub span_label: String,
143 pub kind: BenchmarkEventKind,
144 pub counters: BenchmarkCounters,
145 pub source_line: usize,
146 pub source: BenchmarkEventSource,
147}
148
149#[derive(Clone, Debug, Eq, PartialEq)]
150pub struct MalformedBenchmarkMarker {
151 pub source_line: usize,
152 pub source: BenchmarkEventSource,
153 pub line: String,
154 pub reason: String,
155}
156
157#[derive(Clone, Debug, Default, Eq, PartialEq)]
158pub struct BenchmarkParseReport {
159 pub events: Vec<RawBenchmarkEvent>,
160 pub malformed_markers: Vec<MalformedBenchmarkMarker>,
161 pub ignored_line_count: usize,
162}
163
164#[derive(Clone, Debug, Eq, PartialEq)]
165pub struct BenchmarkSpan {
166 pub suite: String,
167 pub span_label: String,
168 pub start_line: usize,
169 pub end_line: usize,
170 pub start: BenchmarkCounters,
171 pub end: BenchmarkCounters,
172 pub delta: BenchmarkCounters,
173}
174
175#[derive(Clone, Debug, Eq, PartialEq)]
176pub enum UnpairedBenchmarkMarkerKind {
177 Start,
178 End,
179}
180
181#[derive(Clone, Debug, Eq, PartialEq)]
182pub struct UnpairedBenchmarkMarker {
183 pub event: RawBenchmarkEvent,
184 pub kind: UnpairedBenchmarkMarkerKind,
185}
186
187#[derive(Clone, Debug, Eq, PartialEq)]
188pub struct InvalidBenchmarkSpan {
189 pub start: RawBenchmarkEvent,
190 pub end: RawBenchmarkEvent,
191 pub reason: String,
192}
193
194#[derive(Clone, Debug, Default, Eq, PartialEq)]
195pub struct BenchmarkSpanReport {
196 pub spans: Vec<BenchmarkSpan>,
197 pub unpaired_markers: Vec<UnpairedBenchmarkMarker>,
198 pub invalid_spans: Vec<InvalidBenchmarkSpan>,
199}
200
201#[derive(Clone, Debug, PartialEq)]
202pub struct BenchmarkAggregateRow {
203 pub suite: String,
204 pub span_label: String,
205 pub runs: u64,
206 pub total: BenchmarkCounters,
207 pub average: BenchmarkAverages,
208 pub min: BenchmarkCounters,
209 pub max: BenchmarkCounters,
210 pub peak_end: BenchmarkCounters,
211 scope: AggregateScope,
212}
213
214#[derive(Clone, Copy, Debug, Default, PartialEq)]
215pub struct BenchmarkAverages {
216 pub instructions: f64,
217 pub heap_bytes: f64,
218 pub memory_bytes: f64,
219 pub total_allocation: f64,
220}
221
222#[derive(Clone, Debug, Default, PartialEq)]
223pub struct BenchmarkAggregateReport {
224 pub rows: Vec<BenchmarkAggregateRow>,
225}
226
227#[derive(Clone, Debug, PartialEq)]
228pub struct BenchmarkComparisonRow {
229 pub suite: String,
230 pub span_label: String,
231 pub current_runs: Option<u64>,
232 pub previous_runs: Option<u64>,
233 pub instructions_avg_change_percent: Option<f64>,
234 pub heap_bytes_avg_change_percent: Option<f64>,
235 pub memory_bytes_avg_change_percent: Option<f64>,
236 pub total_allocation_avg_change_percent: Option<f64>,
237 scope: AggregateScope,
238}
239
240impl BenchmarkAggregateRow {
241 #[must_use]
243 pub const fn is_all_suites(&self) -> bool {
244 matches!(self.scope, AggregateScope::All)
245 }
246}
247
248impl BenchmarkComparisonRow {
249 #[must_use]
251 pub const fn is_all_suites(&self) -> bool {
252 matches!(self.scope, AggregateScope::All)
253 }
254}
255
256#[derive(Clone, Debug, Default, PartialEq)]
257pub struct BenchmarkComparisonReport {
258 pub rows: Vec<BenchmarkComparisonRow>,
259}
260
261#[derive(Clone, Debug, Eq, PartialEq)]
262pub struct BenchmarkRunMetadata {
263 pub timestamp: String,
264 pub run_directory_name: String,
265 pub run_index: u32,
266 pub git_commit_hash: Option<String>,
267 pub git_commit_short_hash: Option<String>,
268 pub ic_testkit_version: String,
269 pub pocket_ic_version: String,
270 pub rustc_version: String,
271 pub benchmark_command: Option<String>,
272 pub selected_previous_run: Option<String>,
273}
274
275#[derive(Clone, Debug, PartialEq)]
276pub struct BenchmarkRunReport {
277 pub parse: BenchmarkParseReport,
278 pub spans: BenchmarkSpanReport,
279 pub aggregates: BenchmarkAggregateReport,
280 pub comparison: Option<BenchmarkComparisonReport>,
281 pub metadata: BenchmarkRunMetadata,
282}
283
284#[derive(Clone, Debug, Eq, PartialEq)]
285pub struct BenchmarkRunDirectory {
286 pub path: PathBuf,
287 pub directory_name: String,
288 pub run_index: u32,
289 pub git_commit_hash: Option<String>,
290 pub git_commit_short_hash: Option<String>,
291}
292
293#[must_use]
294pub fn format_marker(prefix: &str, label: &str, counters: BenchmarkCounters) -> String {
295 format!(
296 "{}|{}|{}|{}|{}|{}",
297 prefix,
298 label,
299 counters.instructions,
300 counters.heap_bytes,
301 counters.memory_bytes,
302 counters.total_allocation
303 )
304}
305
306#[must_use]
307pub fn benchmark_run_directory_name(
308 timestamp: &str,
309 git_commit_short_hash: Option<&str>,
310 run_index: u32,
311) -> String {
312 let commit = git_commit_short_hash
313 .filter(|hash| !hash.is_empty())
314 .unwrap_or("unknown");
315 format!("{timestamp}-{commit}-{run_index:04}")
316}
317
318pub fn next_benchmark_run_directory(
324 runs_root: impl AsRef<Path>,
325 timestamp: &str,
326 git_commit_hash: Option<&str>,
327) -> io::Result<BenchmarkRunDirectory> {
328 let runs_root = runs_root.as_ref();
329 let git_commit_short_hash = git_commit_hash.map(short_commit_hash);
330 let prefix = format!(
331 "{}-{}-",
332 timestamp,
333 git_commit_short_hash.as_deref().unwrap_or("unknown")
334 );
335 let run_index = next_run_index_for_prefix(runs_root, &prefix)?;
336 let directory_name =
337 benchmark_run_directory_name(timestamp, git_commit_short_hash.as_deref(), run_index);
338
339 Ok(BenchmarkRunDirectory {
340 path: runs_root.join(&directory_name),
341 directory_name,
342 run_index,
343 git_commit_hash: git_commit_hash.map(str::to_string),
344 git_commit_short_hash,
345 })
346}
347
348pub fn find_latest_previous_run(
349 runs_root: impl AsRef<Path>,
350 current_run_directory_name: &str,
351 benchmark_command: Option<&str>,
352) -> io::Result<Option<PathBuf>> {
353 let runs_root = runs_root.as_ref();
354 let mut candidates = Vec::new();
355
356 if !runs_root.exists() {
357 return Ok(None);
358 }
359
360 for entry in fs::read_dir(runs_root)? {
361 let entry = entry?;
362 if !entry.file_type()?.is_dir() {
363 continue;
364 }
365
366 let directory_name = entry.file_name().to_string_lossy().into_owned();
367 if directory_name == current_run_directory_name
368 || directory_name.as_str() > current_run_directory_name
369 {
370 continue;
371 }
372
373 let metadata_path = entry.path().join("metadata.json");
374 let Ok(metadata) = read_benchmark_run_metadata(&metadata_path) else {
375 continue;
376 };
377
378 if let Some(command) = benchmark_command
379 && metadata.benchmark_command.as_deref() != Some(command)
380 {
381 continue;
382 }
383
384 candidates.push((metadata.timestamp, directory_name, entry.path()));
385 }
386
387 candidates.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
388 Ok(candidates.pop().map(|(_, _, path)| path))
389}
390
391pub fn read_benchmark_run_metadata(path: impl AsRef<Path>) -> io::Result<BenchmarkRunMetadata> {
392 let input = fs::read_to_string(path)?;
393 let value = serde_json::from_str::<Value>(&input).map_err(metadata_json_error)?;
394
395 Ok(BenchmarkRunMetadata {
396 timestamp: metadata_required_string(&value, "timestamp")?,
397 run_directory_name: metadata_required_string(&value, "run_directory_name")?,
398 run_index: metadata_required_u32(&value, "run_index")?,
399 git_commit_hash: metadata_optional_string(&value, "git_commit_hash")?,
400 git_commit_short_hash: metadata_optional_string(&value, "git_commit_short_hash")?,
401 ic_testkit_version: metadata_required_string(&value, "ic_testkit_version")?,
402 pocket_ic_version: metadata_required_string(&value, "pocket_ic_version")?,
403 rustc_version: metadata_required_string(&value, "rustc_version")?,
404 benchmark_command: metadata_optional_string(&value, "benchmark_command")?,
405 selected_previous_run: metadata_optional_string(&value, "selected_previous_run")?,
406 })
407}
408
409#[must_use]
410pub fn parse_benchmark_events(input: &str, config: &BenchmarkParserConfig) -> BenchmarkParseReport {
411 parse_benchmark_events_from_source(input, config, BenchmarkEventSource::Unknown)
412}
413
414#[must_use]
415pub fn parse_benchmark_events_from_source(
416 input: &str,
417 config: &BenchmarkParserConfig,
418 source: BenchmarkEventSource,
419) -> BenchmarkParseReport {
420 let mut report = BenchmarkParseReport::default();
421
422 for (index, line) in input.lines().enumerate() {
423 let source_line = index + 1;
424 if !has_configured_prefix(line, &config.prefixes) {
425 if config.strict && !line.trim().is_empty() {
426 report.malformed_markers.push(malformed(
427 source_line,
428 source,
429 line,
430 "line does not use a configured marker prefix",
431 ));
432 } else {
433 report.ignored_line_count += 1;
434 }
435 continue;
436 }
437
438 match parse_marker_line(line, source_line, source, config) {
439 Ok(event) => report.events.push(event),
440 Err(marker) => report.malformed_markers.push(marker),
441 }
442 }
443
444 report
445}
446
447#[must_use]
453pub fn parse_benchmark_events_from_captured_output(
454 stdout: &str,
455 stderr: &str,
456 config: &BenchmarkParserConfig,
457) -> BenchmarkParseReport {
458 let mut report =
459 parse_benchmark_events_from_source(stdout, config, BenchmarkEventSource::Stdout);
460 let stderr_report =
461 parse_benchmark_events_from_source(stderr, config, BenchmarkEventSource::Stderr);
462
463 report.events.extend(stderr_report.events);
464 report
465 .malformed_markers
466 .extend(stderr_report.malformed_markers);
467 report.ignored_line_count += stderr_report.ignored_line_count;
468 report
469}
470
471#[must_use]
472pub fn pair_benchmark_spans(events: &[RawBenchmarkEvent]) -> BenchmarkSpanReport {
473 let mut report = BenchmarkSpanReport::default();
474 let mut open_starts: BTreeMap<(String, String), Vec<RawBenchmarkEvent>> = BTreeMap::new();
475
476 for event in events {
477 let key = (event.suite.clone(), event.span_label.clone());
478 match event.kind {
479 BenchmarkEventKind::Start => open_starts.entry(key).or_default().push(event.clone()),
480 BenchmarkEventKind::End => match open_starts.entry(key) {
481 Entry::Occupied(mut entry) => {
482 if let Some(start) = entry.get_mut().pop() {
483 if entry.get().is_empty() {
484 entry.remove();
485 }
486 push_paired_span(&mut report, start, event.clone());
487 } else {
488 report.unpaired_markers.push(UnpairedBenchmarkMarker {
489 event: event.clone(),
490 kind: UnpairedBenchmarkMarkerKind::End,
491 });
492 }
493 }
494 Entry::Vacant(_) => report.unpaired_markers.push(UnpairedBenchmarkMarker {
495 event: event.clone(),
496 kind: UnpairedBenchmarkMarkerKind::End,
497 }),
498 },
499 }
500 }
501
502 for starts in open_starts.into_values() {
503 for event in starts {
504 report.unpaired_markers.push(UnpairedBenchmarkMarker {
505 event,
506 kind: UnpairedBenchmarkMarkerKind::Start,
507 });
508 }
509 }
510
511 report
512}
513
514#[must_use]
515pub fn aggregate_benchmark_spans(spans: &[BenchmarkSpan]) -> BenchmarkAggregateReport {
516 let mut rows: BTreeMap<(AggregateScope, String), AggregateBuilder> = BTreeMap::new();
517
518 for span in spans {
519 add_span_to_aggregate(
520 &mut rows,
521 AggregateScope::Suite(span.suite.clone()),
522 &span.span_label,
523 span,
524 );
525 add_span_to_aggregate(&mut rows, AggregateScope::All, &span.span_label, span);
526 }
527
528 BenchmarkAggregateReport {
529 rows: rows.into_values().map(AggregateBuilder::finish).collect(),
530 }
531}
532
533#[must_use]
534pub fn compare_benchmark_aggregates(
535 current: &[BenchmarkAggregateRow],
536 previous: &[BenchmarkAggregateRow],
537) -> BenchmarkComparisonReport {
538 let current_by_key = aggregate_rows_by_key(current);
539 let previous_by_key = aggregate_rows_by_key(previous);
540 let mut keys = current_by_key.keys().cloned().collect::<Vec<_>>();
541
542 for key in previous_by_key.keys() {
543 if !current_by_key.contains_key(key) {
544 keys.push(key.clone());
545 }
546 }
547
548 keys.sort();
549 keys.dedup();
550
551 BenchmarkComparisonReport {
552 rows: keys
553 .into_iter()
554 .map(|(scope, span_label)| {
555 let current_row = current_by_key.get(&(scope.clone(), span_label.clone()));
556 let previous_row = previous_by_key.get(&(scope.clone(), span_label.clone()));
557 BenchmarkComparisonRow {
558 suite: scope.label().to_string(),
559 span_label,
560 current_runs: current_row.map(|row| row.runs),
561 previous_runs: previous_row.map(|row| row.runs),
562 instructions_avg_change_percent: compare_average(
563 current_row.map(|row| row.average.instructions),
564 previous_row.map(|row| row.average.instructions),
565 ),
566 heap_bytes_avg_change_percent: compare_average(
567 current_row.map(|row| row.average.heap_bytes),
568 previous_row.map(|row| row.average.heap_bytes),
569 ),
570 memory_bytes_avg_change_percent: compare_average(
571 current_row.map(|row| row.average.memory_bytes),
572 previous_row.map(|row| row.average.memory_bytes),
573 ),
574 total_allocation_avg_change_percent: compare_average(
575 current_row.map(|row| row.average.total_allocation),
576 previous_row.map(|row| row.average.total_allocation),
577 ),
578 scope,
579 }
580 })
581 .collect(),
582 }
583}
584
585pub fn write_benchmark_report_dir(
590 report: &BenchmarkRunReport,
591 path: impl AsRef<Path>,
592) -> io::Result<()> {
593 let path = path.as_ref();
594 fs::create_dir_all(path)?;
595
596 fs::write(
597 path.join("raw-events.csv"),
598 raw_events_csv(&report.parse.events),
599 )?;
600 fs::write(
601 path.join("malformed-markers.csv"),
602 malformed_markers_csv(&report.parse.malformed_markers),
603 )?;
604 fs::write(path.join("spans.csv"), spans_csv(&report.spans.spans))?;
605 fs::write(
606 path.join("unpaired-markers.csv"),
607 unpaired_markers_csv(&report.spans.unpaired_markers),
608 )?;
609 fs::write(
610 path.join("invalid-spans.csv"),
611 invalid_spans_csv(&report.spans.invalid_spans),
612 )?;
613 fs::write(
614 path.join("suite-aggregates.csv"),
615 aggregates_csv(
616 report
617 .aggregates
618 .rows
619 .iter()
620 .filter(|row| !row.is_all_suites()),
621 ),
622 )?;
623 fs::write(
624 path.join("all-aggregates.csv"),
625 aggregates_csv(
626 report
627 .aggregates
628 .rows
629 .iter()
630 .filter(|row| row.is_all_suites()),
631 ),
632 )?;
633 fs::write(
634 path.join("comparison.csv"),
635 comparison_csv(report.comparison.as_ref()),
636 )?;
637 fs::write(
638 path.join("bench-summary.md"),
639 benchmark_summary_markdown(report),
640 )?;
641 fs::write(path.join("metadata.json"), metadata_json(&report.metadata))?;
642
643 Ok(())
644}
645
646fn parse_marker_line(
647 line: &str,
648 source_line: usize,
649 source: BenchmarkEventSource,
650 config: &BenchmarkParserConfig,
651) -> Result<RawBenchmarkEvent, MalformedBenchmarkMarker> {
652 let parts = line.split('|').collect::<Vec<_>>();
653 if parts.len() != 6 {
654 return Err(malformed(
655 source_line,
656 source,
657 line,
658 "expected six pipe-separated columns",
659 ));
660 }
661
662 let prefix = parts[0];
663 if !config.prefixes.iter().any(|known| known == prefix) {
664 return Err(malformed(
665 source_line,
666 source,
667 line,
668 "prefix is not configured",
669 ));
670 }
671
672 let label = parts[1];
673 if label.is_empty() {
674 return Err(malformed(source_line, source, line, "label is empty"));
675 }
676
677 let (span_label, kind) = split_label_kind(label).ok_or_else(|| {
678 malformed(
679 source_line,
680 source,
681 line,
682 "label must end in :start or :end",
683 )
684 })?;
685
686 let counters = BenchmarkCounters {
687 instructions: parse_counter(parts[2], source_line, source, line, "instructions")?,
688 heap_bytes: parse_counter(parts[3], source_line, source, line, "heap_bytes")?,
689 memory_bytes: parse_counter(parts[4], source_line, source, line, "memory_bytes")?,
690 total_allocation: parse_counter(parts[5], source_line, source, line, "total_allocation")?,
691 };
692 let suite = config.suite_derivation.derive_suite(span_label);
693
694 Ok(RawBenchmarkEvent {
695 prefix: prefix.to_string(),
696 label: label.to_string(),
697 suite,
698 span_label: span_label.to_string(),
699 kind,
700 counters,
701 source_line,
702 source,
703 })
704}
705
706fn parse_counter(
707 value: &str,
708 source_line: usize,
709 source: BenchmarkEventSource,
710 line: &str,
711 name: &str,
712) -> Result<u128, MalformedBenchmarkMarker> {
713 if value.is_empty() {
714 return Err(malformed(
715 source_line,
716 source,
717 line,
718 &format!("{name} counter is empty"),
719 ));
720 }
721
722 value.parse::<u128>().map_err(|_| {
723 malformed(
724 source_line,
725 source,
726 line,
727 &format!("{name} counter is not an unsigned integer"),
728 )
729 })
730}
731
732fn split_label_kind(label: &str) -> Option<(&str, BenchmarkEventKind)> {
733 let start = label.strip_suffix(":start");
734 let end = label.strip_suffix(":end");
735
736 match (start, end) {
737 (Some(span_label), None) if !span_label.is_empty() => {
738 Some((span_label, BenchmarkEventKind::Start))
739 }
740 (None, Some(span_label)) if !span_label.is_empty() => {
741 Some((span_label, BenchmarkEventKind::End))
742 }
743 _ => None,
744 }
745}
746
747fn has_configured_prefix(line: &str, prefixes: &[String]) -> bool {
748 prefixes.iter().any(|prefix| {
749 line.strip_prefix(prefix)
750 .is_some_and(|rest| rest.starts_with('|'))
751 })
752}
753
754fn malformed(
755 source_line: usize,
756 source: BenchmarkEventSource,
757 line: &str,
758 reason: &str,
759) -> MalformedBenchmarkMarker {
760 MalformedBenchmarkMarker {
761 source_line,
762 source,
763 line: line.to_string(),
764 reason: reason.to_string(),
765 }
766}
767
768fn push_paired_span(
769 report: &mut BenchmarkSpanReport,
770 start: RawBenchmarkEvent,
771 end: RawBenchmarkEvent,
772) {
773 if let Some(delta) = end.counters.checked_delta(start.counters) {
774 report.spans.push(BenchmarkSpan {
775 suite: start.suite.clone(),
776 span_label: start.span_label.clone(),
777 start_line: start.source_line,
778 end_line: end.source_line,
779 start: start.counters,
780 end: end.counters,
781 delta,
782 });
783 } else {
784 report.invalid_spans.push(InvalidBenchmarkSpan {
785 start,
786 end,
787 reason: "end counter is lower than start counter".to_string(),
788 });
789 }
790}
791
792#[derive(Clone, Debug)]
793struct AggregateBuilder {
794 scope: AggregateScope,
795 span_label: String,
796 runs: u64,
797 total: BenchmarkCounters,
798 min: BenchmarkCounters,
799 max: BenchmarkCounters,
800 peak_end: BenchmarkCounters,
801}
802
803impl AggregateBuilder {
804 fn new(scope: AggregateScope, span_label: &str, span: &BenchmarkSpan) -> Self {
805 Self {
806 scope,
807 span_label: span_label.to_string(),
808 runs: 1,
809 total: span.delta,
810 min: span.delta,
811 max: span.delta,
812 peak_end: span.end,
813 }
814 }
815
816 fn push(&mut self, span: &BenchmarkSpan) {
817 self.runs += 1;
818 self.total.add_assign(span.delta);
819 self.min.min_assign(span.delta);
820 self.max.max_assign(span.delta);
821 self.peak_end.max_assign(span.end);
822 }
823
824 fn finish(self) -> BenchmarkAggregateRow {
825 BenchmarkAggregateRow {
826 suite: self.scope.label().to_string(),
827 span_label: self.span_label,
828 runs: self.runs,
829 total: self.total,
830 average: averages(self.total, self.runs),
831 min: self.min,
832 max: self.max,
833 peak_end: self.peak_end,
834 scope: self.scope,
835 }
836 }
837}
838
839fn add_span_to_aggregate(
840 rows: &mut BTreeMap<(AggregateScope, String), AggregateBuilder>,
841 scope: AggregateScope,
842 span_label: &str,
843 span: &BenchmarkSpan,
844) {
845 match rows.entry((scope.clone(), span_label.to_string())) {
846 Entry::Occupied(mut entry) => entry.get_mut().push(span),
847 Entry::Vacant(entry) => {
848 entry.insert(AggregateBuilder::new(scope, span_label, span));
849 }
850 }
851}
852
853#[expect(clippy::cast_precision_loss)]
854fn averages(total: BenchmarkCounters, runs: u64) -> BenchmarkAverages {
855 let runs = runs as f64;
856 BenchmarkAverages {
857 instructions: total.instructions as f64 / runs,
858 heap_bytes: total.heap_bytes as f64 / runs,
859 memory_bytes: total.memory_bytes as f64 / runs,
860 total_allocation: total.total_allocation as f64 / runs,
861 }
862}
863
864fn aggregate_rows_by_key(
865 rows: &[BenchmarkAggregateRow],
866) -> BTreeMap<(AggregateScope, String), &BenchmarkAggregateRow> {
867 rows.iter()
868 .map(|row| ((row.scope.clone(), row.span_label.clone()), row))
869 .collect()
870}
871
872fn compare_average(current: Option<f64>, previous: Option<f64>) -> Option<f64> {
873 match (current, previous) {
874 (Some(current), Some(previous)) if previous != 0.0 => {
875 Some(((current - previous) / previous) * 100.0)
876 }
877 _ => None,
878 }
879}
880
881fn raw_events_csv(events: &[RawBenchmarkEvent]) -> String {
882 let mut out = String::from(
883 "source_line,source,prefix,suite,label,span_label,kind,instructions,heap_bytes,memory_bytes,total_allocation\n",
884 );
885 for event in events {
886 let _ = writeln!(
887 out,
888 "{},{},{},{},{},{},{},{},{},{},{}",
889 event.source_line,
890 event.source.as_str(),
891 csv_cell(&event.prefix),
892 csv_cell(&event.suite),
893 csv_cell(&event.label),
894 csv_cell(&event.span_label),
895 kind_str(event.kind),
896 event.counters.instructions,
897 event.counters.heap_bytes,
898 event.counters.memory_bytes,
899 event.counters.total_allocation
900 );
901 }
902 out
903}
904
905fn malformed_markers_csv(markers: &[MalformedBenchmarkMarker]) -> String {
906 let mut out = String::from("source_line,source,reason,line\n");
907 for marker in markers {
908 let _ = writeln!(
909 out,
910 "{},{},{},{}",
911 marker.source_line,
912 marker.source.as_str(),
913 csv_cell(&marker.reason),
914 csv_cell(&marker.line)
915 );
916 }
917 out
918}
919
920fn spans_csv(spans: &[BenchmarkSpan]) -> String {
921 let mut out = String::from(
922 "suite,span_label,start_line,end_line,instructions_delta,heap_bytes_delta,memory_bytes_delta,total_allocation_delta\n",
923 );
924 for span in spans {
925 let _ = writeln!(
926 out,
927 "{},{},{},{},{},{},{},{}",
928 csv_cell(&span.suite),
929 csv_cell(&span.span_label),
930 span.start_line,
931 span.end_line,
932 span.delta.instructions,
933 span.delta.heap_bytes,
934 span.delta.memory_bytes,
935 span.delta.total_allocation
936 );
937 }
938 out
939}
940
941fn unpaired_markers_csv(markers: &[UnpairedBenchmarkMarker]) -> String {
942 let mut out = String::from("source_line,source,kind,suite,span_label,label\n");
943 for marker in markers {
944 let kind = match marker.kind {
945 UnpairedBenchmarkMarkerKind::Start => "start",
946 UnpairedBenchmarkMarkerKind::End => "end",
947 };
948 let _ = writeln!(
949 out,
950 "{},{},{},{},{},{}",
951 marker.event.source_line,
952 marker.event.source.as_str(),
953 kind,
954 csv_cell(&marker.event.suite),
955 csv_cell(&marker.event.span_label),
956 csv_cell(&marker.event.label)
957 );
958 }
959 out
960}
961
962fn invalid_spans_csv(spans: &[InvalidBenchmarkSpan]) -> String {
963 let mut out = String::from("start_line,end_line,suite,span_label,reason\n");
964 for span in spans {
965 let _ = writeln!(
966 out,
967 "{},{},{},{},{}",
968 span.start.source_line,
969 span.end.source_line,
970 csv_cell(&span.start.suite),
971 csv_cell(&span.start.span_label),
972 csv_cell(&span.reason)
973 );
974 }
975 out
976}
977
978fn aggregates_csv<'a>(rows: impl Iterator<Item = &'a BenchmarkAggregateRow>) -> String {
979 let mut out = String::from(
980 "suite,span_label,runs,instructions_total,instructions_avg,heap_bytes_total,heap_bytes_avg,memory_bytes_total,memory_bytes_avg,total_allocation_total,total_allocation_avg\n",
981 );
982 for row in rows {
983 let _ = writeln!(
984 out,
985 "{},{},{},{},{:.4},{},{:.4},{},{:.4},{},{:.4}",
986 csv_cell(&row.suite),
987 csv_cell(&row.span_label),
988 row.runs,
989 row.total.instructions,
990 row.average.instructions,
991 row.total.heap_bytes,
992 row.average.heap_bytes,
993 row.total.memory_bytes,
994 row.average.memory_bytes,
995 row.total.total_allocation,
996 row.average.total_allocation
997 );
998 }
999 out
1000}
1001
1002fn comparison_csv(comparison: Option<&BenchmarkComparisonReport>) -> String {
1003 let mut out = String::from(
1004 "suite,span_label,current_runs,previous_runs,instructions_avg_change_percent,heap_bytes_avg_change_percent,memory_bytes_avg_change_percent,total_allocation_avg_change_percent\n",
1005 );
1006
1007 let Some(comparison) = comparison else {
1008 return out;
1009 };
1010
1011 for row in &comparison.rows {
1012 let _ = writeln!(
1013 out,
1014 "{},{},{},{},{},{},{},{}",
1015 csv_cell(&row.suite),
1016 csv_cell(&row.span_label),
1017 optional_u64_cell(row.current_runs),
1018 optional_u64_cell(row.previous_runs),
1019 optional_f64_cell(row.instructions_avg_change_percent),
1020 optional_f64_cell(row.heap_bytes_avg_change_percent),
1021 optional_f64_cell(row.memory_bytes_avg_change_percent),
1022 optional_f64_cell(row.total_allocation_avg_change_percent)
1023 );
1024 }
1025
1026 out
1027}
1028
1029fn benchmark_summary_markdown(report: &BenchmarkRunReport) -> String {
1030 let comparison_by_key = report.comparison.as_ref().map(|comparison| {
1031 comparison
1032 .rows
1033 .iter()
1034 .map(|row| ((row.scope.clone(), row.span_label.clone()), row))
1035 .collect::<BTreeMap<_, _>>()
1036 });
1037 let mut out = String::from(
1038 "# Benchmark Summary\n\n| Benchmark | Runs | Instructions Avg | Heap Delta Avg | Memory Delta Avg | Allocation Avg |\n| --- | ---: | ---: | ---: | ---: | ---: |\n",
1039 );
1040
1041 for row in report
1042 .aggregates
1043 .rows
1044 .iter()
1045 .filter(|row| !row.is_all_suites())
1046 {
1047 let comparison = comparison_by_key.as_ref().and_then(|rows| {
1048 rows.get(&(row.scope.clone(), row.span_label.clone()))
1049 .copied()
1050 });
1051 let _ = writeln!(
1052 out,
1053 "| {} | {} | {} | {} | {} | {} |",
1054 markdown_cell(&row.span_label),
1055 row.runs,
1056 format_instructions(
1057 row.average.instructions,
1058 change_suffix(comparison, |c| { c.instructions_avg_change_percent })
1059 ),
1060 format_bytes(
1061 row.average.heap_bytes,
1062 change_suffix(comparison, |c| c.heap_bytes_avg_change_percent)
1063 ),
1064 format_bytes(
1065 row.average.memory_bytes,
1066 change_suffix(comparison, |c| c.memory_bytes_avg_change_percent)
1067 ),
1068 format_bytes(
1069 row.average.total_allocation,
1070 change_suffix(comparison, |c| c.total_allocation_avg_change_percent)
1071 )
1072 );
1073 }
1074
1075 out
1076}
1077
1078fn metadata_json(metadata: &BenchmarkRunMetadata) -> String {
1079 let value = serde_json::json!({
1080 "timestamp": metadata.timestamp,
1081 "run_directory_name": metadata.run_directory_name,
1082 "run_index": metadata.run_index,
1083 "git_commit_hash": metadata.git_commit_hash,
1084 "git_commit_short_hash": metadata.git_commit_short_hash,
1085 "ic_testkit_version": metadata.ic_testkit_version,
1086 "pocket_ic_version": metadata.pocket_ic_version,
1087 "rustc_version": metadata.rustc_version,
1088 "benchmark_command": metadata.benchmark_command,
1089 "selected_previous_run": metadata.selected_previous_run,
1090 });
1091
1092 let mut output = serde_json::to_string_pretty(&value).expect("metadata JSON must serialize");
1093 output.push('\n');
1094 output
1095}
1096
1097fn next_run_index_for_prefix(runs_root: &Path, prefix: &str) -> io::Result<u32> {
1098 if !runs_root.exists() {
1099 return Ok(1);
1100 }
1101
1102 let mut max_index = 0;
1103 for entry in fs::read_dir(runs_root)? {
1104 let entry = entry?;
1105 if !entry.file_type()?.is_dir() {
1106 continue;
1107 }
1108
1109 if let Some(index) = run_index_from_directory_name(&entry.file_name(), prefix) {
1110 max_index = max_index.max(index);
1111 }
1112 }
1113
1114 Ok(max_index.saturating_add(1))
1115}
1116
1117fn run_index_from_directory_name(name: &OsStr, prefix: &str) -> Option<u32> {
1118 let name = name.to_str()?;
1119 let index = name.strip_prefix(prefix)?;
1120
1121 if index.len() == 4 && index.chars().all(|char| char.is_ascii_digit()) {
1122 index.parse().ok()
1123 } else {
1124 None
1125 }
1126}
1127
1128fn short_commit_hash(hash: &str) -> String {
1129 hash.chars().take(7).collect()
1130}
1131
1132fn metadata_required_string(value: &Value, key: &str) -> io::Result<String> {
1133 metadata_optional_string(value, key)?.ok_or_else(|| {
1134 io::Error::new(
1135 io::ErrorKind::InvalidData,
1136 format!("missing required metadata string field `{key}`"),
1137 )
1138 })
1139}
1140
1141fn metadata_required_u32(value: &Value, key: &str) -> io::Result<u32> {
1142 let Some(raw) = value.get(key).and_then(Value::as_u64) else {
1143 return Err(io::Error::new(
1144 io::ErrorKind::InvalidData,
1145 format!("missing required metadata integer field `{key}`"),
1146 ));
1147 };
1148
1149 u32::try_from(raw).map_err(|err| {
1150 io::Error::new(
1151 io::ErrorKind::InvalidData,
1152 format!("metadata integer field `{key}` is invalid: {err}"),
1153 )
1154 })
1155}
1156
1157fn metadata_optional_string(value: &Value, key: &str) -> io::Result<Option<String>> {
1158 match value.get(key) {
1159 None | Some(Value::Null) => Ok(None),
1160 Some(Value::String(value)) => Ok(Some(value.clone())),
1161 Some(_) => Err(io::Error::new(
1162 io::ErrorKind::InvalidData,
1163 format!("metadata string field `{key}` is not a string or null"),
1164 )),
1165 }
1166}
1167
1168fn metadata_json_error(err: serde_json::Error) -> io::Error {
1169 io::Error::new(
1170 io::ErrorKind::InvalidData,
1171 format!("invalid benchmark metadata JSON: {err}"),
1172 )
1173}
1174
1175fn change_suffix(
1176 comparison: Option<&BenchmarkComparisonRow>,
1177 change: impl FnOnce(&BenchmarkComparisonRow) -> Option<f64>,
1178) -> Option<String> {
1179 comparison.and_then(|row| {
1180 if row.previous_runs.is_none() {
1181 Some("new".to_string())
1182 } else {
1183 change(row).map(|percent| format!("{percent:+.0}%"))
1184 }
1185 })
1186}
1187
1188fn format_instructions(value: f64, suffix: Option<String>) -> String {
1189 with_optional_suffix(format!("{:.4}B", value / 1_000_000_000.0), suffix)
1190}
1191
1192fn format_bytes(value: f64, suffix: Option<String>) -> String {
1193 with_optional_suffix(human_bytes(value), suffix)
1194}
1195
1196fn with_optional_suffix(value: String, suffix: Option<String>) -> String {
1197 match suffix {
1198 Some(suffix) => format!("{value} ({suffix})"),
1199 None => value,
1200 }
1201}
1202
1203fn human_bytes(value: f64) -> String {
1204 const KIB: f64 = 1024.0;
1205 const MIB: f64 = KIB * 1024.0;
1206 const GIB: f64 = MIB * 1024.0;
1207
1208 let (unit_value, unit) = if value.abs() >= GIB {
1209 (value / GIB, "GB")
1210 } else if value.abs() >= MIB {
1211 (value / MIB, "MB")
1212 } else if value.abs() >= KIB {
1213 (value / KIB, "KB")
1214 } else {
1215 (value, "B")
1216 };
1217
1218 format!("{unit_value:+.1} {unit}")
1219}
1220
1221const fn kind_str(kind: BenchmarkEventKind) -> &'static str {
1222 match kind {
1223 BenchmarkEventKind::Start => "start",
1224 BenchmarkEventKind::End => "end",
1225 }
1226}
1227
1228fn csv_cell(value: &str) -> String {
1229 if value.contains([',', '"', '\n', '\r']) {
1230 format!("\"{}\"", value.replace('"', "\"\""))
1231 } else {
1232 value.to_string()
1233 }
1234}
1235
1236fn optional_u64_cell(value: Option<u64>) -> String {
1237 value.map_or_else(String::new, |value| value.to_string())
1238}
1239
1240fn optional_f64_cell(value: Option<f64>) -> String {
1241 value.map_or_else(String::new, |value| format!("{value:.4}"))
1242}
1243
1244fn markdown_cell(value: &str) -> String {
1245 value.replace('|', "\\|")
1246}