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