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("bench-summary.md"),
587 benchmark_summary_markdown(report),
588 )?;
589 fs::write(path.join("metadata.json"), metadata_json(&report.metadata))?;
590
591 Ok(())
592}
593
594fn parse_marker_line(
595 line: &str,
596 source_line: usize,
597 source: BenchmarkEventSource,
598 config: &BenchmarkParserConfig,
599) -> Result<RawBenchmarkEvent, MalformedBenchmarkMarker> {
600 let parts = line.split('|').collect::<Vec<_>>();
601 if parts.len() != 6 {
602 return Err(malformed(
603 source_line,
604 source,
605 line,
606 "expected six pipe-separated columns",
607 ));
608 }
609
610 let prefix = parts[0];
611 if !config.prefixes.iter().any(|known| known == prefix) {
612 return Err(malformed(
613 source_line,
614 source,
615 line,
616 "prefix is not configured",
617 ));
618 }
619
620 let label = parts[1];
621 if label.is_empty() {
622 return Err(malformed(source_line, source, line, "label is empty"));
623 }
624
625 let (span_label, kind) = split_label_kind(label).ok_or_else(|| {
626 malformed(
627 source_line,
628 source,
629 line,
630 "label must end in :start or :end",
631 )
632 })?;
633
634 let counters = BenchmarkCounters {
635 instructions: parse_counter(parts[2], source_line, source, line, "instructions")?,
636 heap_bytes: parse_counter(parts[3], source_line, source, line, "heap_bytes")?,
637 memory_bytes: parse_counter(parts[4], source_line, source, line, "memory_bytes")?,
638 total_allocation: parse_counter(parts[5], source_line, source, line, "total_allocation")?,
639 };
640 let suite = config.suite_derivation.derive_suite(span_label);
641
642 Ok(RawBenchmarkEvent {
643 prefix: prefix.to_string(),
644 label: label.to_string(),
645 suite,
646 span_label: span_label.to_string(),
647 kind,
648 counters,
649 source_line,
650 source,
651 })
652}
653
654fn parse_counter(
655 value: &str,
656 source_line: usize,
657 source: BenchmarkEventSource,
658 line: &str,
659 name: &str,
660) -> Result<u128, MalformedBenchmarkMarker> {
661 if value.is_empty() {
662 return Err(malformed(
663 source_line,
664 source,
665 line,
666 &format!("{name} counter is empty"),
667 ));
668 }
669
670 value.parse::<u128>().map_err(|_| {
671 malformed(
672 source_line,
673 source,
674 line,
675 &format!("{name} counter is not an unsigned integer"),
676 )
677 })
678}
679
680fn split_label_kind(label: &str) -> Option<(&str, BenchmarkEventKind)> {
681 let start = label.strip_suffix(":start");
682 let end = label.strip_suffix(":end");
683
684 match (start, end) {
685 (Some(span_label), None) if !span_label.is_empty() => {
686 Some((span_label, BenchmarkEventKind::Start))
687 }
688 (None, Some(span_label)) if !span_label.is_empty() => {
689 Some((span_label, BenchmarkEventKind::End))
690 }
691 _ => None,
692 }
693}
694
695fn has_configured_prefix(line: &str, prefixes: &[String]) -> bool {
696 prefixes.iter().any(|prefix| {
697 line.strip_prefix(prefix)
698 .is_some_and(|rest| rest.starts_with('|'))
699 })
700}
701
702fn malformed(
703 source_line: usize,
704 source: BenchmarkEventSource,
705 line: &str,
706 reason: &str,
707) -> MalformedBenchmarkMarker {
708 MalformedBenchmarkMarker {
709 source_line,
710 source,
711 line: line.to_string(),
712 reason: reason.to_string(),
713 }
714}
715
716fn push_paired_span(
717 report: &mut BenchmarkSpanReport,
718 start: RawBenchmarkEvent,
719 end: RawBenchmarkEvent,
720) {
721 if let Some(delta) = end.counters.checked_delta(start.counters) {
722 report.spans.push(BenchmarkSpan {
723 suite: start.suite.clone(),
724 span_label: start.span_label.clone(),
725 start_line: start.source_line,
726 end_line: end.source_line,
727 start: start.counters,
728 end: end.counters,
729 delta,
730 });
731 } else {
732 report.invalid_spans.push(InvalidBenchmarkSpan {
733 start,
734 end,
735 reason: "end counter is lower than start counter".to_string(),
736 });
737 }
738}
739
740#[derive(Clone, Debug)]
741struct AggregateBuilder {
742 suite: String,
743 span_label: String,
744 runs: u64,
745 total: BenchmarkCounters,
746 min: BenchmarkCounters,
747 max: BenchmarkCounters,
748 peak_end: BenchmarkCounters,
749}
750
751impl AggregateBuilder {
752 fn new(suite: &str, span_label: &str, span: &BenchmarkSpan) -> Self {
753 Self {
754 suite: suite.to_string(),
755 span_label: span_label.to_string(),
756 runs: 1,
757 total: span.delta,
758 min: span.delta,
759 max: span.delta,
760 peak_end: span.end,
761 }
762 }
763
764 fn push(&mut self, span: &BenchmarkSpan) {
765 self.runs += 1;
766 self.total.add_assign(span.delta);
767 self.min.min_assign(span.delta);
768 self.max.max_assign(span.delta);
769 self.peak_end.max_assign(span.end);
770 }
771
772 fn finish(self) -> BenchmarkAggregateRow {
773 BenchmarkAggregateRow {
774 suite: self.suite,
775 span_label: self.span_label,
776 runs: self.runs,
777 total: self.total,
778 average: averages(self.total, self.runs),
779 min: self.min,
780 max: self.max,
781 peak_end: self.peak_end,
782 }
783 }
784}
785
786fn add_span_to_aggregate(
787 rows: &mut BTreeMap<(String, String), AggregateBuilder>,
788 suite: &str,
789 span_label: &str,
790 span: &BenchmarkSpan,
791) {
792 match rows.entry((suite.to_string(), span_label.to_string())) {
793 Entry::Occupied(mut entry) => entry.get_mut().push(span),
794 Entry::Vacant(entry) => {
795 entry.insert(AggregateBuilder::new(suite, span_label, span));
796 }
797 }
798}
799
800#[expect(clippy::cast_precision_loss)]
801fn averages(total: BenchmarkCounters, runs: u64) -> BenchmarkAverages {
802 let runs = runs as f64;
803 BenchmarkAverages {
804 instructions: total.instructions as f64 / runs,
805 heap_bytes: total.heap_bytes as f64 / runs,
806 memory_bytes: total.memory_bytes as f64 / runs,
807 total_allocation: total.total_allocation as f64 / runs,
808 }
809}
810
811fn aggregate_rows_by_key(
812 rows: &[BenchmarkAggregateRow],
813) -> BTreeMap<(String, String), &BenchmarkAggregateRow> {
814 rows.iter()
815 .map(|row| ((row.suite.clone(), row.span_label.clone()), row))
816 .collect()
817}
818
819fn compare_average(current: Option<f64>, previous: Option<f64>) -> Option<f64> {
820 match (current, previous) {
821 (Some(current), Some(previous)) if previous != 0.0 => {
822 Some(((current - previous) / previous) * 100.0)
823 }
824 _ => None,
825 }
826}
827
828fn raw_events_csv(events: &[RawBenchmarkEvent]) -> String {
829 let mut out = String::from(
830 "source_line,source,prefix,suite,label,span_label,kind,instructions,heap_bytes,memory_bytes,total_allocation\n",
831 );
832 for event in events {
833 let _ = writeln!(
834 out,
835 "{},{},{},{},{},{},{},{},{},{},{}",
836 event.source_line,
837 event.source.as_str(),
838 csv_cell(&event.prefix),
839 csv_cell(&event.suite),
840 csv_cell(&event.label),
841 csv_cell(&event.span_label),
842 kind_str(event.kind),
843 event.counters.instructions,
844 event.counters.heap_bytes,
845 event.counters.memory_bytes,
846 event.counters.total_allocation
847 );
848 }
849 out
850}
851
852fn malformed_markers_csv(markers: &[MalformedBenchmarkMarker]) -> String {
853 let mut out = String::from("source_line,source,reason,line\n");
854 for marker in markers {
855 let _ = writeln!(
856 out,
857 "{},{},{},{}",
858 marker.source_line,
859 marker.source.as_str(),
860 csv_cell(&marker.reason),
861 csv_cell(&marker.line)
862 );
863 }
864 out
865}
866
867fn spans_csv(spans: &[BenchmarkSpan]) -> String {
868 let mut out = String::from(
869 "suite,span_label,start_line,end_line,instructions_delta,heap_bytes_delta,memory_bytes_delta,total_allocation_delta\n",
870 );
871 for span in spans {
872 let _ = writeln!(
873 out,
874 "{},{},{},{},{},{},{},{}",
875 csv_cell(&span.suite),
876 csv_cell(&span.span_label),
877 span.start_line,
878 span.end_line,
879 span.delta.instructions,
880 span.delta.heap_bytes,
881 span.delta.memory_bytes,
882 span.delta.total_allocation
883 );
884 }
885 out
886}
887
888fn unpaired_markers_csv(markers: &[UnpairedBenchmarkMarker]) -> String {
889 let mut out = String::from("source_line,source,kind,suite,span_label,label\n");
890 for marker in markers {
891 let kind = match marker.kind {
892 UnpairedBenchmarkMarkerKind::Start => "start",
893 UnpairedBenchmarkMarkerKind::End => "end",
894 };
895 let _ = writeln!(
896 out,
897 "{},{},{},{},{},{}",
898 marker.event.source_line,
899 marker.event.source.as_str(),
900 kind,
901 csv_cell(&marker.event.suite),
902 csv_cell(&marker.event.span_label),
903 csv_cell(&marker.event.label)
904 );
905 }
906 out
907}
908
909fn invalid_spans_csv(spans: &[InvalidBenchmarkSpan]) -> String {
910 let mut out = String::from("start_line,end_line,suite,span_label,reason\n");
911 for span in spans {
912 let _ = writeln!(
913 out,
914 "{},{},{},{},{}",
915 span.start.source_line,
916 span.end.source_line,
917 csv_cell(&span.start.suite),
918 csv_cell(&span.start.span_label),
919 csv_cell(&span.reason)
920 );
921 }
922 out
923}
924
925fn aggregates_csv<'a>(rows: impl Iterator<Item = &'a BenchmarkAggregateRow>) -> String {
926 let mut out = String::from(
927 "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",
928 );
929 for row in rows {
930 let _ = writeln!(
931 out,
932 "{},{},{},{},{:.4},{},{:.4},{},{:.4},{},{:.4}",
933 csv_cell(&row.suite),
934 csv_cell(&row.span_label),
935 row.runs,
936 row.total.instructions,
937 row.average.instructions,
938 row.total.heap_bytes,
939 row.average.heap_bytes,
940 row.total.memory_bytes,
941 row.average.memory_bytes,
942 row.total.total_allocation,
943 row.average.total_allocation
944 );
945 }
946 out
947}
948
949fn benchmark_summary_markdown(report: &BenchmarkRunReport) -> String {
950 let comparison_by_key = report.comparison.as_ref().map(|comparison| {
951 comparison
952 .rows
953 .iter()
954 .map(|row| ((row.suite.clone(), row.span_label.clone()), row))
955 .collect::<BTreeMap<_, _>>()
956 });
957 let mut out = String::from(
958 "# Benchmark Summary\n\n| Benchmark | Runs | Instructions Avg | Heap Delta Avg | Memory Delta Avg | Allocation Avg |\n| --- | ---: | ---: | ---: | ---: | ---: |\n",
959 );
960
961 for row in report
962 .aggregates
963 .rows
964 .iter()
965 .filter(|row| row.suite != ALL_SUITES)
966 {
967 let comparison = comparison_by_key.as_ref().and_then(|rows| {
968 rows.get(&(row.suite.clone(), row.span_label.clone()))
969 .copied()
970 });
971 let _ = writeln!(
972 out,
973 "| {} | {} | {} | {} | {} | {} |",
974 markdown_cell(&row.span_label),
975 row.runs,
976 format_instructions(
977 row.average.instructions,
978 change_suffix(comparison, |c| { c.instructions_avg_change_percent })
979 ),
980 format_bytes(
981 row.average.heap_bytes,
982 change_suffix(comparison, |c| c.heap_bytes_avg_change_percent)
983 ),
984 format_bytes(
985 row.average.memory_bytes,
986 change_suffix(comparison, |c| c.memory_bytes_avg_change_percent)
987 ),
988 format_bytes(
989 row.average.total_allocation,
990 change_suffix(comparison, |c| c.total_allocation_avg_change_percent)
991 )
992 );
993 }
994
995 out
996}
997
998fn metadata_json(metadata: &BenchmarkRunMetadata) -> String {
999 let value = serde_json::json!({
1000 "timestamp": metadata.timestamp,
1001 "run_directory_name": metadata.run_directory_name,
1002 "run_index": metadata.run_index,
1003 "git_commit_hash": metadata.git_commit_hash,
1004 "git_commit_short_hash": metadata.git_commit_short_hash,
1005 "ic_testkit_version": metadata.ic_testkit_version,
1006 "pocket_ic_version": metadata.pocket_ic_version,
1007 "rustc_version": metadata.rustc_version,
1008 "benchmark_command": metadata.benchmark_command,
1009 "selected_previous_run": metadata.selected_previous_run,
1010 });
1011
1012 let mut output = serde_json::to_string_pretty(&value).expect("metadata JSON must serialize");
1013 output.push('\n');
1014 output
1015}
1016
1017fn next_run_index_for_prefix(runs_root: &Path, prefix: &str) -> io::Result<u32> {
1018 if !runs_root.exists() {
1019 return Ok(1);
1020 }
1021
1022 let mut max_index = 0;
1023 for entry in fs::read_dir(runs_root)? {
1024 let entry = entry?;
1025 if !entry.file_type()?.is_dir() {
1026 continue;
1027 }
1028
1029 if let Some(index) = run_index_from_directory_name(&entry.file_name(), prefix) {
1030 max_index = max_index.max(index);
1031 }
1032 }
1033
1034 Ok(max_index.saturating_add(1))
1035}
1036
1037fn run_index_from_directory_name(name: &OsStr, prefix: &str) -> Option<u32> {
1038 let name = name.to_str()?;
1039 let index = name.strip_prefix(prefix)?;
1040
1041 if index.len() == 4 && index.chars().all(|char| char.is_ascii_digit()) {
1042 index.parse().ok()
1043 } else {
1044 None
1045 }
1046}
1047
1048fn short_commit_hash(hash: &str) -> String {
1049 hash.chars().take(7).collect()
1050}
1051
1052fn metadata_required_string(value: &Value, key: &str) -> io::Result<String> {
1053 metadata_optional_string(value, key)?.ok_or_else(|| {
1054 io::Error::new(
1055 io::ErrorKind::InvalidData,
1056 format!("missing required metadata string field `{key}`"),
1057 )
1058 })
1059}
1060
1061fn metadata_required_u32(value: &Value, key: &str) -> io::Result<u32> {
1062 let Some(raw) = value.get(key).and_then(Value::as_u64) else {
1063 return Err(io::Error::new(
1064 io::ErrorKind::InvalidData,
1065 format!("missing required metadata integer field `{key}`"),
1066 ));
1067 };
1068
1069 u32::try_from(raw).map_err(|err| {
1070 io::Error::new(
1071 io::ErrorKind::InvalidData,
1072 format!("metadata integer field `{key}` is invalid: {err}"),
1073 )
1074 })
1075}
1076
1077fn metadata_optional_string(value: &Value, key: &str) -> io::Result<Option<String>> {
1078 match value.get(key) {
1079 None | Some(Value::Null) => Ok(None),
1080 Some(Value::String(value)) => Ok(Some(value.clone())),
1081 Some(_) => Err(io::Error::new(
1082 io::ErrorKind::InvalidData,
1083 format!("metadata string field `{key}` is not a string or null"),
1084 )),
1085 }
1086}
1087
1088fn metadata_json_error(err: serde_json::Error) -> io::Error {
1089 io::Error::new(
1090 io::ErrorKind::InvalidData,
1091 format!("invalid benchmark metadata JSON: {err}"),
1092 )
1093}
1094
1095fn change_suffix(
1096 comparison: Option<&BenchmarkComparisonRow>,
1097 change: impl FnOnce(&BenchmarkComparisonRow) -> Option<f64>,
1098) -> Option<String> {
1099 comparison.and_then(|row| {
1100 if row.previous_runs.is_none() {
1101 Some("new".to_string())
1102 } else {
1103 change(row).map(|percent| format!("{percent:+.0}%"))
1104 }
1105 })
1106}
1107
1108fn format_instructions(value: f64, suffix: Option<String>) -> String {
1109 with_optional_suffix(format!("{:.4}B", value / 1_000_000_000.0), suffix)
1110}
1111
1112fn format_bytes(value: f64, suffix: Option<String>) -> String {
1113 with_optional_suffix(human_bytes(value), suffix)
1114}
1115
1116fn with_optional_suffix(value: String, suffix: Option<String>) -> String {
1117 match suffix {
1118 Some(suffix) => format!("{value} ({suffix})"),
1119 None => value,
1120 }
1121}
1122
1123fn human_bytes(value: f64) -> String {
1124 const KIB: f64 = 1024.0;
1125 const MIB: f64 = KIB * 1024.0;
1126 const GIB: f64 = MIB * 1024.0;
1127
1128 let (unit_value, unit) = if value.abs() >= GIB {
1129 (value / GIB, "GB")
1130 } else if value.abs() >= MIB {
1131 (value / MIB, "MB")
1132 } else if value.abs() >= KIB {
1133 (value / KIB, "KB")
1134 } else {
1135 (value, "B")
1136 };
1137
1138 format!("{unit_value:+.1} {unit}")
1139}
1140
1141const fn kind_str(kind: BenchmarkEventKind) -> &'static str {
1142 match kind {
1143 BenchmarkEventKind::Start => "start",
1144 BenchmarkEventKind::End => "end",
1145 }
1146}
1147
1148fn csv_cell(value: &str) -> String {
1149 if value.contains([',', '"', '\n', '\r']) {
1150 format!("\"{}\"", value.replace('"', "\"\""))
1151 } else {
1152 value.to_string()
1153 }
1154}
1155
1156fn markdown_cell(value: &str) -> String {
1157 value.replace('|', "\\|")
1158}