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(
319 runs_root: impl AsRef<Path>,
320 timestamp: &str,
321 git_commit_hash: Option<&str>,
322) -> io::Result<BenchmarkRunDirectory> {
323 let runs_root = runs_root.as_ref();
324 let git_commit_short_hash = git_commit_hash.map(short_commit_hash);
325 let prefix = format!(
326 "{}-{}-",
327 timestamp,
328 git_commit_short_hash.as_deref().unwrap_or("unknown")
329 );
330 let run_index = next_run_index_for_prefix(runs_root, &prefix)?;
331 let directory_name =
332 benchmark_run_directory_name(timestamp, git_commit_short_hash.as_deref(), run_index);
333
334 Ok(BenchmarkRunDirectory {
335 path: runs_root.join(&directory_name),
336 directory_name,
337 run_index,
338 git_commit_hash: git_commit_hash.map(str::to_string),
339 git_commit_short_hash,
340 })
341}
342
343pub fn find_latest_previous_run(
344 runs_root: impl AsRef<Path>,
345 current_run_directory_name: &str,
346 benchmark_command: Option<&str>,
347) -> io::Result<Option<PathBuf>> {
348 let runs_root = runs_root.as_ref();
349 let mut candidates = Vec::new();
350
351 if !runs_root.exists() {
352 return Ok(None);
353 }
354
355 for entry in fs::read_dir(runs_root)? {
356 let entry = entry?;
357 if !entry.file_type()?.is_dir() {
358 continue;
359 }
360
361 let directory_name = entry.file_name().to_string_lossy().into_owned();
362 if directory_name == current_run_directory_name
363 || directory_name.as_str() > current_run_directory_name
364 {
365 continue;
366 }
367
368 let metadata_path = entry.path().join("metadata.json");
369 let Ok(metadata) = read_benchmark_run_metadata(&metadata_path) else {
370 continue;
371 };
372
373 if let Some(command) = benchmark_command
374 && metadata.benchmark_command.as_deref() != Some(command)
375 {
376 continue;
377 }
378
379 candidates.push((metadata.timestamp, directory_name, entry.path()));
380 }
381
382 candidates.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
383 Ok(candidates.pop().map(|(_, _, path)| path))
384}
385
386pub fn read_benchmark_run_metadata(path: impl AsRef<Path>) -> io::Result<BenchmarkRunMetadata> {
387 let input = fs::read_to_string(path)?;
388 let value = serde_json::from_str::<Value>(&input).map_err(metadata_json_error)?;
389
390 Ok(BenchmarkRunMetadata {
391 timestamp: metadata_required_string(&value, "timestamp")?,
392 run_directory_name: metadata_required_string(&value, "run_directory_name")?,
393 run_index: metadata_required_u32(&value, "run_index")?,
394 git_commit_hash: metadata_optional_string(&value, "git_commit_hash")?,
395 git_commit_short_hash: metadata_optional_string(&value, "git_commit_short_hash")?,
396 ic_testkit_version: metadata_required_string(&value, "ic_testkit_version")?,
397 pocket_ic_version: metadata_required_string(&value, "pocket_ic_version")?,
398 rustc_version: metadata_required_string(&value, "rustc_version")?,
399 benchmark_command: metadata_optional_string(&value, "benchmark_command")?,
400 selected_previous_run: metadata_optional_string(&value, "selected_previous_run")?,
401 })
402}
403
404#[must_use]
405pub fn parse_benchmark_events(input: &str, config: &BenchmarkParserConfig) -> BenchmarkParseReport {
406 parse_benchmark_events_from_source(input, config, BenchmarkEventSource::Unknown)
407}
408
409#[must_use]
410pub fn parse_benchmark_events_from_source(
411 input: &str,
412 config: &BenchmarkParserConfig,
413 source: BenchmarkEventSource,
414) -> BenchmarkParseReport {
415 let mut report = BenchmarkParseReport::default();
416
417 for (index, line) in input.lines().enumerate() {
418 let source_line = index + 1;
419 if !has_configured_prefix(line, &config.prefixes) {
420 if config.strict && !line.trim().is_empty() {
421 report.malformed_markers.push(malformed(
422 source_line,
423 source,
424 line,
425 "line does not use a configured marker prefix",
426 ));
427 } else {
428 report.ignored_line_count += 1;
429 }
430 continue;
431 }
432
433 match parse_marker_line(line, source_line, source, config) {
434 Ok(event) => report.events.push(event),
435 Err(marker) => report.malformed_markers.push(marker),
436 }
437 }
438
439 report
440}
441
442#[must_use]
448pub fn parse_benchmark_events_from_captured_output(
449 stdout: &str,
450 stderr: &str,
451 config: &BenchmarkParserConfig,
452) -> BenchmarkParseReport {
453 let mut report =
454 parse_benchmark_events_from_source(stdout, config, BenchmarkEventSource::Stdout);
455 let stderr_report =
456 parse_benchmark_events_from_source(stderr, config, BenchmarkEventSource::Stderr);
457
458 report.events.extend(stderr_report.events);
459 report
460 .malformed_markers
461 .extend(stderr_report.malformed_markers);
462 report.ignored_line_count += stderr_report.ignored_line_count;
463 report
464}
465
466#[must_use]
467pub fn pair_benchmark_spans(events: &[RawBenchmarkEvent]) -> BenchmarkSpanReport {
468 let mut report = BenchmarkSpanReport::default();
469 let mut open_starts: BTreeMap<(String, String), Vec<RawBenchmarkEvent>> = BTreeMap::new();
470
471 for event in events {
472 let key = (event.suite.clone(), event.span_label.clone());
473 match event.kind {
474 BenchmarkEventKind::Start => open_starts.entry(key).or_default().push(event.clone()),
475 BenchmarkEventKind::End => match open_starts.entry(key) {
476 Entry::Occupied(mut entry) => {
477 if let Some(start) = entry.get_mut().pop() {
478 if entry.get().is_empty() {
479 entry.remove();
480 }
481 push_paired_span(&mut report, start, event.clone());
482 } else {
483 report.unpaired_markers.push(UnpairedBenchmarkMarker {
484 event: event.clone(),
485 kind: UnpairedBenchmarkMarkerKind::End,
486 });
487 }
488 }
489 Entry::Vacant(_) => report.unpaired_markers.push(UnpairedBenchmarkMarker {
490 event: event.clone(),
491 kind: UnpairedBenchmarkMarkerKind::End,
492 }),
493 },
494 }
495 }
496
497 for starts in open_starts.into_values() {
498 for event in starts {
499 report.unpaired_markers.push(UnpairedBenchmarkMarker {
500 event,
501 kind: UnpairedBenchmarkMarkerKind::Start,
502 });
503 }
504 }
505
506 report
507}
508
509#[must_use]
510pub fn aggregate_benchmark_spans(spans: &[BenchmarkSpan]) -> BenchmarkAggregateReport {
511 let mut rows: BTreeMap<(AggregateScope, String), AggregateBuilder> = BTreeMap::new();
512
513 for span in spans {
514 add_span_to_aggregate(
515 &mut rows,
516 AggregateScope::Suite(span.suite.clone()),
517 &span.span_label,
518 span,
519 );
520 add_span_to_aggregate(&mut rows, AggregateScope::All, &span.span_label, span);
521 }
522
523 BenchmarkAggregateReport {
524 rows: rows.into_values().map(AggregateBuilder::finish).collect(),
525 }
526}
527
528#[must_use]
529pub fn compare_benchmark_aggregates(
530 current: &[BenchmarkAggregateRow],
531 previous: &[BenchmarkAggregateRow],
532) -> BenchmarkComparisonReport {
533 let current_by_key = aggregate_rows_by_key(current);
534 let previous_by_key = aggregate_rows_by_key(previous);
535 let mut keys = current_by_key.keys().cloned().collect::<Vec<_>>();
536
537 for key in previous_by_key.keys() {
538 if !current_by_key.contains_key(key) {
539 keys.push(key.clone());
540 }
541 }
542
543 keys.sort();
544 keys.dedup();
545
546 BenchmarkComparisonReport {
547 rows: keys
548 .into_iter()
549 .map(|(scope, span_label)| {
550 let current_row = current_by_key.get(&(scope.clone(), span_label.clone()));
551 let previous_row = previous_by_key.get(&(scope.clone(), span_label.clone()));
552 BenchmarkComparisonRow {
553 suite: scope.label().to_string(),
554 span_label,
555 current_runs: current_row.map(|row| row.runs),
556 previous_runs: previous_row.map(|row| row.runs),
557 instructions_avg_change_percent: compare_average(
558 current_row.map(|row| row.average.instructions),
559 previous_row.map(|row| row.average.instructions),
560 ),
561 heap_bytes_avg_change_percent: compare_average(
562 current_row.map(|row| row.average.heap_bytes),
563 previous_row.map(|row| row.average.heap_bytes),
564 ),
565 memory_bytes_avg_change_percent: compare_average(
566 current_row.map(|row| row.average.memory_bytes),
567 previous_row.map(|row| row.average.memory_bytes),
568 ),
569 total_allocation_avg_change_percent: compare_average(
570 current_row.map(|row| row.average.total_allocation),
571 previous_row.map(|row| row.average.total_allocation),
572 ),
573 scope,
574 }
575 })
576 .collect(),
577 }
578}
579
580pub fn write_benchmark_report_dir(
581 report: &BenchmarkRunReport,
582 path: impl AsRef<Path>,
583) -> io::Result<()> {
584 let path = path.as_ref();
585 fs::create_dir_all(path)?;
586
587 fs::write(
588 path.join("raw-events.csv"),
589 raw_events_csv(&report.parse.events),
590 )?;
591 fs::write(
592 path.join("malformed-markers.csv"),
593 malformed_markers_csv(&report.parse.malformed_markers),
594 )?;
595 fs::write(path.join("spans.csv"), spans_csv(&report.spans.spans))?;
596 fs::write(
597 path.join("unpaired-markers.csv"),
598 unpaired_markers_csv(&report.spans.unpaired_markers),
599 )?;
600 fs::write(
601 path.join("invalid-spans.csv"),
602 invalid_spans_csv(&report.spans.invalid_spans),
603 )?;
604 fs::write(
605 path.join("suite-aggregates.csv"),
606 aggregates_csv(
607 report
608 .aggregates
609 .rows
610 .iter()
611 .filter(|row| !row.is_all_suites()),
612 ),
613 )?;
614 fs::write(
615 path.join("all-aggregates.csv"),
616 aggregates_csv(
617 report
618 .aggregates
619 .rows
620 .iter()
621 .filter(|row| row.is_all_suites()),
622 ),
623 )?;
624 fs::write(
625 path.join("comparison.csv"),
626 comparison_csv(report.comparison.as_ref()),
627 )?;
628 fs::write(
629 path.join("bench-summary.md"),
630 benchmark_summary_markdown(report),
631 )?;
632 fs::write(path.join("metadata.json"), metadata_json(&report.metadata))?;
633
634 Ok(())
635}
636
637fn parse_marker_line(
638 line: &str,
639 source_line: usize,
640 source: BenchmarkEventSource,
641 config: &BenchmarkParserConfig,
642) -> Result<RawBenchmarkEvent, MalformedBenchmarkMarker> {
643 let parts = line.split('|').collect::<Vec<_>>();
644 if parts.len() != 6 {
645 return Err(malformed(
646 source_line,
647 source,
648 line,
649 "expected six pipe-separated columns",
650 ));
651 }
652
653 let prefix = parts[0];
654 if !config.prefixes.iter().any(|known| known == prefix) {
655 return Err(malformed(
656 source_line,
657 source,
658 line,
659 "prefix is not configured",
660 ));
661 }
662
663 let label = parts[1];
664 if label.is_empty() {
665 return Err(malformed(source_line, source, line, "label is empty"));
666 }
667
668 let (span_label, kind) = split_label_kind(label).ok_or_else(|| {
669 malformed(
670 source_line,
671 source,
672 line,
673 "label must end in :start or :end",
674 )
675 })?;
676
677 let counters = BenchmarkCounters {
678 instructions: parse_counter(parts[2], source_line, source, line, "instructions")?,
679 heap_bytes: parse_counter(parts[3], source_line, source, line, "heap_bytes")?,
680 memory_bytes: parse_counter(parts[4], source_line, source, line, "memory_bytes")?,
681 total_allocation: parse_counter(parts[5], source_line, source, line, "total_allocation")?,
682 };
683 let suite = config.suite_derivation.derive_suite(span_label);
684
685 Ok(RawBenchmarkEvent {
686 prefix: prefix.to_string(),
687 label: label.to_string(),
688 suite,
689 span_label: span_label.to_string(),
690 kind,
691 counters,
692 source_line,
693 source,
694 })
695}
696
697fn parse_counter(
698 value: &str,
699 source_line: usize,
700 source: BenchmarkEventSource,
701 line: &str,
702 name: &str,
703) -> Result<u128, MalformedBenchmarkMarker> {
704 if value.is_empty() {
705 return Err(malformed(
706 source_line,
707 source,
708 line,
709 &format!("{name} counter is empty"),
710 ));
711 }
712
713 value.parse::<u128>().map_err(|_| {
714 malformed(
715 source_line,
716 source,
717 line,
718 &format!("{name} counter is not an unsigned integer"),
719 )
720 })
721}
722
723fn split_label_kind(label: &str) -> Option<(&str, BenchmarkEventKind)> {
724 let start = label.strip_suffix(":start");
725 let end = label.strip_suffix(":end");
726
727 match (start, end) {
728 (Some(span_label), None) if !span_label.is_empty() => {
729 Some((span_label, BenchmarkEventKind::Start))
730 }
731 (None, Some(span_label)) if !span_label.is_empty() => {
732 Some((span_label, BenchmarkEventKind::End))
733 }
734 _ => None,
735 }
736}
737
738fn has_configured_prefix(line: &str, prefixes: &[String]) -> bool {
739 prefixes.iter().any(|prefix| {
740 line.strip_prefix(prefix)
741 .is_some_and(|rest| rest.starts_with('|'))
742 })
743}
744
745fn malformed(
746 source_line: usize,
747 source: BenchmarkEventSource,
748 line: &str,
749 reason: &str,
750) -> MalformedBenchmarkMarker {
751 MalformedBenchmarkMarker {
752 source_line,
753 source,
754 line: line.to_string(),
755 reason: reason.to_string(),
756 }
757}
758
759fn push_paired_span(
760 report: &mut BenchmarkSpanReport,
761 start: RawBenchmarkEvent,
762 end: RawBenchmarkEvent,
763) {
764 if let Some(delta) = end.counters.checked_delta(start.counters) {
765 report.spans.push(BenchmarkSpan {
766 suite: start.suite.clone(),
767 span_label: start.span_label.clone(),
768 start_line: start.source_line,
769 end_line: end.source_line,
770 start: start.counters,
771 end: end.counters,
772 delta,
773 });
774 } else {
775 report.invalid_spans.push(InvalidBenchmarkSpan {
776 start,
777 end,
778 reason: "end counter is lower than start counter".to_string(),
779 });
780 }
781}
782
783#[derive(Clone, Debug)]
784struct AggregateBuilder {
785 scope: AggregateScope,
786 span_label: String,
787 runs: u64,
788 total: BenchmarkCounters,
789 min: BenchmarkCounters,
790 max: BenchmarkCounters,
791 peak_end: BenchmarkCounters,
792}
793
794impl AggregateBuilder {
795 fn new(scope: AggregateScope, span_label: &str, span: &BenchmarkSpan) -> Self {
796 Self {
797 scope,
798 span_label: span_label.to_string(),
799 runs: 1,
800 total: span.delta,
801 min: span.delta,
802 max: span.delta,
803 peak_end: span.end,
804 }
805 }
806
807 fn push(&mut self, span: &BenchmarkSpan) {
808 self.runs += 1;
809 self.total.add_assign(span.delta);
810 self.min.min_assign(span.delta);
811 self.max.max_assign(span.delta);
812 self.peak_end.max_assign(span.end);
813 }
814
815 fn finish(self) -> BenchmarkAggregateRow {
816 BenchmarkAggregateRow {
817 suite: self.scope.label().to_string(),
818 span_label: self.span_label,
819 runs: self.runs,
820 total: self.total,
821 average: averages(self.total, self.runs),
822 min: self.min,
823 max: self.max,
824 peak_end: self.peak_end,
825 scope: self.scope,
826 }
827 }
828}
829
830fn add_span_to_aggregate(
831 rows: &mut BTreeMap<(AggregateScope, String), AggregateBuilder>,
832 scope: AggregateScope,
833 span_label: &str,
834 span: &BenchmarkSpan,
835) {
836 match rows.entry((scope.clone(), span_label.to_string())) {
837 Entry::Occupied(mut entry) => entry.get_mut().push(span),
838 Entry::Vacant(entry) => {
839 entry.insert(AggregateBuilder::new(scope, span_label, span));
840 }
841 }
842}
843
844#[expect(clippy::cast_precision_loss)]
845fn averages(total: BenchmarkCounters, runs: u64) -> BenchmarkAverages {
846 let runs = runs as f64;
847 BenchmarkAverages {
848 instructions: total.instructions as f64 / runs,
849 heap_bytes: total.heap_bytes as f64 / runs,
850 memory_bytes: total.memory_bytes as f64 / runs,
851 total_allocation: total.total_allocation as f64 / runs,
852 }
853}
854
855fn aggregate_rows_by_key(
856 rows: &[BenchmarkAggregateRow],
857) -> BTreeMap<(AggregateScope, String), &BenchmarkAggregateRow> {
858 rows.iter()
859 .map(|row| ((row.scope.clone(), row.span_label.clone()), row))
860 .collect()
861}
862
863fn compare_average(current: Option<f64>, previous: Option<f64>) -> Option<f64> {
864 match (current, previous) {
865 (Some(current), Some(previous)) if previous != 0.0 => {
866 Some(((current - previous) / previous) * 100.0)
867 }
868 _ => None,
869 }
870}
871
872fn raw_events_csv(events: &[RawBenchmarkEvent]) -> String {
873 let mut out = String::from(
874 "source_line,source,prefix,suite,label,span_label,kind,instructions,heap_bytes,memory_bytes,total_allocation\n",
875 );
876 for event in events {
877 let _ = writeln!(
878 out,
879 "{},{},{},{},{},{},{},{},{},{},{}",
880 event.source_line,
881 event.source.as_str(),
882 csv_cell(&event.prefix),
883 csv_cell(&event.suite),
884 csv_cell(&event.label),
885 csv_cell(&event.span_label),
886 kind_str(event.kind),
887 event.counters.instructions,
888 event.counters.heap_bytes,
889 event.counters.memory_bytes,
890 event.counters.total_allocation
891 );
892 }
893 out
894}
895
896fn malformed_markers_csv(markers: &[MalformedBenchmarkMarker]) -> String {
897 let mut out = String::from("source_line,source,reason,line\n");
898 for marker in markers {
899 let _ = writeln!(
900 out,
901 "{},{},{},{}",
902 marker.source_line,
903 marker.source.as_str(),
904 csv_cell(&marker.reason),
905 csv_cell(&marker.line)
906 );
907 }
908 out
909}
910
911fn spans_csv(spans: &[BenchmarkSpan]) -> String {
912 let mut out = String::from(
913 "suite,span_label,start_line,end_line,instructions_delta,heap_bytes_delta,memory_bytes_delta,total_allocation_delta\n",
914 );
915 for span in spans {
916 let _ = writeln!(
917 out,
918 "{},{},{},{},{},{},{},{}",
919 csv_cell(&span.suite),
920 csv_cell(&span.span_label),
921 span.start_line,
922 span.end_line,
923 span.delta.instructions,
924 span.delta.heap_bytes,
925 span.delta.memory_bytes,
926 span.delta.total_allocation
927 );
928 }
929 out
930}
931
932fn unpaired_markers_csv(markers: &[UnpairedBenchmarkMarker]) -> String {
933 let mut out = String::from("source_line,source,kind,suite,span_label,label\n");
934 for marker in markers {
935 let kind = match marker.kind {
936 UnpairedBenchmarkMarkerKind::Start => "start",
937 UnpairedBenchmarkMarkerKind::End => "end",
938 };
939 let _ = writeln!(
940 out,
941 "{},{},{},{},{},{}",
942 marker.event.source_line,
943 marker.event.source.as_str(),
944 kind,
945 csv_cell(&marker.event.suite),
946 csv_cell(&marker.event.span_label),
947 csv_cell(&marker.event.label)
948 );
949 }
950 out
951}
952
953fn invalid_spans_csv(spans: &[InvalidBenchmarkSpan]) -> String {
954 let mut out = String::from("start_line,end_line,suite,span_label,reason\n");
955 for span in spans {
956 let _ = writeln!(
957 out,
958 "{},{},{},{},{}",
959 span.start.source_line,
960 span.end.source_line,
961 csv_cell(&span.start.suite),
962 csv_cell(&span.start.span_label),
963 csv_cell(&span.reason)
964 );
965 }
966 out
967}
968
969fn aggregates_csv<'a>(rows: impl Iterator<Item = &'a BenchmarkAggregateRow>) -> String {
970 let mut out = String::from(
971 "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",
972 );
973 for row in rows {
974 let _ = writeln!(
975 out,
976 "{},{},{},{},{:.4},{},{:.4},{},{:.4},{},{:.4}",
977 csv_cell(&row.suite),
978 csv_cell(&row.span_label),
979 row.runs,
980 row.total.instructions,
981 row.average.instructions,
982 row.total.heap_bytes,
983 row.average.heap_bytes,
984 row.total.memory_bytes,
985 row.average.memory_bytes,
986 row.total.total_allocation,
987 row.average.total_allocation
988 );
989 }
990 out
991}
992
993fn comparison_csv(comparison: Option<&BenchmarkComparisonReport>) -> String {
994 let mut out = String::from(
995 "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",
996 );
997
998 let Some(comparison) = comparison else {
999 return out;
1000 };
1001
1002 for row in &comparison.rows {
1003 let _ = writeln!(
1004 out,
1005 "{},{},{},{},{},{},{},{}",
1006 csv_cell(&row.suite),
1007 csv_cell(&row.span_label),
1008 optional_u64_cell(row.current_runs),
1009 optional_u64_cell(row.previous_runs),
1010 optional_f64_cell(row.instructions_avg_change_percent),
1011 optional_f64_cell(row.heap_bytes_avg_change_percent),
1012 optional_f64_cell(row.memory_bytes_avg_change_percent),
1013 optional_f64_cell(row.total_allocation_avg_change_percent)
1014 );
1015 }
1016
1017 out
1018}
1019
1020fn benchmark_summary_markdown(report: &BenchmarkRunReport) -> String {
1021 let comparison_by_key = report.comparison.as_ref().map(|comparison| {
1022 comparison
1023 .rows
1024 .iter()
1025 .map(|row| ((row.scope.clone(), row.span_label.clone()), row))
1026 .collect::<BTreeMap<_, _>>()
1027 });
1028 let mut out = String::from(
1029 "# Benchmark Summary\n\n| Benchmark | Runs | Instructions Avg | Heap Delta Avg | Memory Delta Avg | Allocation Avg |\n| --- | ---: | ---: | ---: | ---: | ---: |\n",
1030 );
1031
1032 for row in report
1033 .aggregates
1034 .rows
1035 .iter()
1036 .filter(|row| !row.is_all_suites())
1037 {
1038 let comparison = comparison_by_key.as_ref().and_then(|rows| {
1039 rows.get(&(row.scope.clone(), row.span_label.clone()))
1040 .copied()
1041 });
1042 let _ = writeln!(
1043 out,
1044 "| {} | {} | {} | {} | {} | {} |",
1045 markdown_cell(&row.span_label),
1046 row.runs,
1047 format_instructions(
1048 row.average.instructions,
1049 change_suffix(comparison, |c| { c.instructions_avg_change_percent })
1050 ),
1051 format_bytes(
1052 row.average.heap_bytes,
1053 change_suffix(comparison, |c| c.heap_bytes_avg_change_percent)
1054 ),
1055 format_bytes(
1056 row.average.memory_bytes,
1057 change_suffix(comparison, |c| c.memory_bytes_avg_change_percent)
1058 ),
1059 format_bytes(
1060 row.average.total_allocation,
1061 change_suffix(comparison, |c| c.total_allocation_avg_change_percent)
1062 )
1063 );
1064 }
1065
1066 out
1067}
1068
1069fn metadata_json(metadata: &BenchmarkRunMetadata) -> String {
1070 let value = serde_json::json!({
1071 "timestamp": metadata.timestamp,
1072 "run_directory_name": metadata.run_directory_name,
1073 "run_index": metadata.run_index,
1074 "git_commit_hash": metadata.git_commit_hash,
1075 "git_commit_short_hash": metadata.git_commit_short_hash,
1076 "ic_testkit_version": metadata.ic_testkit_version,
1077 "pocket_ic_version": metadata.pocket_ic_version,
1078 "rustc_version": metadata.rustc_version,
1079 "benchmark_command": metadata.benchmark_command,
1080 "selected_previous_run": metadata.selected_previous_run,
1081 });
1082
1083 let mut output = serde_json::to_string_pretty(&value).expect("metadata JSON must serialize");
1084 output.push('\n');
1085 output
1086}
1087
1088fn next_run_index_for_prefix(runs_root: &Path, prefix: &str) -> io::Result<u32> {
1089 if !runs_root.exists() {
1090 return Ok(1);
1091 }
1092
1093 let mut max_index = 0;
1094 for entry in fs::read_dir(runs_root)? {
1095 let entry = entry?;
1096 if !entry.file_type()?.is_dir() {
1097 continue;
1098 }
1099
1100 if let Some(index) = run_index_from_directory_name(&entry.file_name(), prefix) {
1101 max_index = max_index.max(index);
1102 }
1103 }
1104
1105 Ok(max_index.saturating_add(1))
1106}
1107
1108fn run_index_from_directory_name(name: &OsStr, prefix: &str) -> Option<u32> {
1109 let name = name.to_str()?;
1110 let index = name.strip_prefix(prefix)?;
1111
1112 if index.len() == 4 && index.chars().all(|char| char.is_ascii_digit()) {
1113 index.parse().ok()
1114 } else {
1115 None
1116 }
1117}
1118
1119fn short_commit_hash(hash: &str) -> String {
1120 hash.chars().take(7).collect()
1121}
1122
1123fn metadata_required_string(value: &Value, key: &str) -> io::Result<String> {
1124 metadata_optional_string(value, key)?.ok_or_else(|| {
1125 io::Error::new(
1126 io::ErrorKind::InvalidData,
1127 format!("missing required metadata string field `{key}`"),
1128 )
1129 })
1130}
1131
1132fn metadata_required_u32(value: &Value, key: &str) -> io::Result<u32> {
1133 let Some(raw) = value.get(key).and_then(Value::as_u64) else {
1134 return Err(io::Error::new(
1135 io::ErrorKind::InvalidData,
1136 format!("missing required metadata integer field `{key}`"),
1137 ));
1138 };
1139
1140 u32::try_from(raw).map_err(|err| {
1141 io::Error::new(
1142 io::ErrorKind::InvalidData,
1143 format!("metadata integer field `{key}` is invalid: {err}"),
1144 )
1145 })
1146}
1147
1148fn metadata_optional_string(value: &Value, key: &str) -> io::Result<Option<String>> {
1149 match value.get(key) {
1150 None | Some(Value::Null) => Ok(None),
1151 Some(Value::String(value)) => Ok(Some(value.clone())),
1152 Some(_) => Err(io::Error::new(
1153 io::ErrorKind::InvalidData,
1154 format!("metadata string field `{key}` is not a string or null"),
1155 )),
1156 }
1157}
1158
1159fn metadata_json_error(err: serde_json::Error) -> io::Error {
1160 io::Error::new(
1161 io::ErrorKind::InvalidData,
1162 format!("invalid benchmark metadata JSON: {err}"),
1163 )
1164}
1165
1166fn change_suffix(
1167 comparison: Option<&BenchmarkComparisonRow>,
1168 change: impl FnOnce(&BenchmarkComparisonRow) -> Option<f64>,
1169) -> Option<String> {
1170 comparison.and_then(|row| {
1171 if row.previous_runs.is_none() {
1172 Some("new".to_string())
1173 } else {
1174 change(row).map(|percent| format!("{percent:+.0}%"))
1175 }
1176 })
1177}
1178
1179fn format_instructions(value: f64, suffix: Option<String>) -> String {
1180 with_optional_suffix(format!("{:.4}B", value / 1_000_000_000.0), suffix)
1181}
1182
1183fn format_bytes(value: f64, suffix: Option<String>) -> String {
1184 with_optional_suffix(human_bytes(value), suffix)
1185}
1186
1187fn with_optional_suffix(value: String, suffix: Option<String>) -> String {
1188 match suffix {
1189 Some(suffix) => format!("{value} ({suffix})"),
1190 None => value,
1191 }
1192}
1193
1194fn human_bytes(value: f64) -> String {
1195 const KIB: f64 = 1024.0;
1196 const MIB: f64 = KIB * 1024.0;
1197 const GIB: f64 = MIB * 1024.0;
1198
1199 let (unit_value, unit) = if value.abs() >= GIB {
1200 (value / GIB, "GB")
1201 } else if value.abs() >= MIB {
1202 (value / MIB, "MB")
1203 } else if value.abs() >= KIB {
1204 (value / KIB, "KB")
1205 } else {
1206 (value, "B")
1207 };
1208
1209 format!("{unit_value:+.1} {unit}")
1210}
1211
1212const fn kind_str(kind: BenchmarkEventKind) -> &'static str {
1213 match kind {
1214 BenchmarkEventKind::Start => "start",
1215 BenchmarkEventKind::End => "end",
1216 }
1217}
1218
1219fn csv_cell(value: &str) -> String {
1220 if value.contains([',', '"', '\n', '\r']) {
1221 format!("\"{}\"", value.replace('"', "\"\""))
1222 } else {
1223 value.to_string()
1224 }
1225}
1226
1227fn optional_u64_cell(value: Option<u64>) -> String {
1228 value.map_or_else(String::new, |value| value.to_string())
1229}
1230
1231fn optional_f64_cell(value: Option<f64>) -> String {
1232 value.map_or_else(String::new, |value| format!("{value:.4}"))
1233}
1234
1235fn markdown_cell(value: &str) -> String {
1236 value.replace('|', "\\|")
1237}