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