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