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