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<(&str, &str), Vec<&RawBenchmarkEvent>> = BTreeMap::new();
564
565 for event in events {
566 let key = (event.suite.as_str(), event.span_label.as_str());
567 match event.kind {
568 BenchmarkEventKind::Start => open_starts.entry(key).or_default().push(event),
569 BenchmarkEventKind::End => match open_starts.entry(key) {
570 Entry::Occupied(mut entry) => {
571 let start = entry
572 .get_mut()
573 .pop()
574 .expect("open marker stacks must remain nonempty");
575 if entry.get().is_empty() {
576 entry.remove();
577 }
578 push_paired_span(&mut report, start, event);
579 }
580 Entry::Vacant(_) => report.unpaired_markers.push(UnpairedBenchmarkMarker {
581 event: event.clone(),
582 kind: UnpairedBenchmarkMarkerKind::End,
583 }),
584 },
585 }
586 }
587
588 for starts in open_starts.into_values() {
589 for event in starts {
590 report.unpaired_markers.push(UnpairedBenchmarkMarker {
591 event: event.clone(),
592 kind: UnpairedBenchmarkMarkerKind::Start,
593 });
594 }
595 }
596
597 report
598}
599
600pub fn aggregate_benchmark_spans(
605 spans: &[BenchmarkSpan],
606) -> Result<BenchmarkAggregateReport, BenchmarkAggregateError> {
607 let mut rows: BTreeMap<(AggregateScope, String), AggregateBuilder> = BTreeMap::new();
608
609 for span in spans {
610 add_span_to_aggregate(
611 &mut rows,
612 AggregateScope::Suite(span.suite.clone()),
613 &span.span_label,
614 span,
615 )?;
616 add_span_to_aggregate(&mut rows, AggregateScope::All, &span.span_label, span)?;
617 }
618
619 Ok(BenchmarkAggregateReport {
620 rows: rows
621 .into_iter()
622 .map(|((scope, span_label), builder)| builder.finish(scope, span_label))
623 .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().copied().collect::<Vec<_>>();
635
636 for key in previous_by_key.keys() {
637 if !current_by_key.contains_key(key) {
638 keys.push(*key);
639 }
640 }
641
642 keys.sort();
643
644 BenchmarkComparisonReport {
645 rows: keys
646 .into_iter()
647 .map(|(scope, span_label)| {
648 let current_row = current_by_key.get(&(scope, span_label));
649 let previous_row = previous_by_key.get(&(scope, span_label));
650 let current_average = current_row.map(|row| row.average());
651 let previous_average = previous_row.map(|row| row.average());
652 BenchmarkComparisonRow {
653 span_label: span_label.to_owned(),
654 current_runs: current_row.map(|row| row.runs),
655 previous_runs: previous_row.map(|row| row.runs),
656 instructions_avg_change_percent: compare_average(
657 current_average.map(|average| average.instructions),
658 previous_average.map(|average| average.instructions),
659 ),
660 heap_bytes_avg_change_percent: compare_average(
661 current_average.map(|average| average.heap_bytes),
662 previous_average.map(|average| average.heap_bytes),
663 ),
664 memory_bytes_avg_change_percent: compare_average(
665 current_average.map(|average| average.memory_bytes),
666 previous_average.map(|average| average.memory_bytes),
667 ),
668 total_allocation_avg_change_percent: compare_average(
669 current_average.map(|average| average.total_allocation),
670 previous_average.map(|average| average.total_allocation),
671 ),
672 scope: scope.clone(),
673 }
674 })
675 .collect(),
676 }
677}
678
679pub fn write_benchmark_report_dir(
684 report: &BenchmarkRunReport,
685 path: impl AsRef<Path>,
686) -> io::Result<()> {
687 let path = path.as_ref();
688 fs::create_dir_all(path)?;
689
690 fs::write(
691 path.join("raw-events.csv"),
692 raw_events_csv(&report.parse.events),
693 )?;
694 fs::write(
695 path.join("malformed-markers.csv"),
696 malformed_markers_csv(&report.parse.malformed_markers),
697 )?;
698 fs::write(path.join("spans.csv"), spans_csv(&report.spans.spans))?;
699 fs::write(
700 path.join("unpaired-markers.csv"),
701 unpaired_markers_csv(&report.spans.unpaired_markers),
702 )?;
703 fs::write(
704 path.join("invalid-spans.csv"),
705 invalid_spans_csv(&report.spans.invalid_spans),
706 )?;
707 fs::write(
708 path.join("suite-aggregates.csv"),
709 aggregates_csv(
710 report
711 .aggregates
712 .rows
713 .iter()
714 .filter(|row| !row.is_all_suites()),
715 ),
716 )?;
717 fs::write(
718 path.join("all-aggregates.csv"),
719 aggregates_csv(
720 report
721 .aggregates
722 .rows
723 .iter()
724 .filter(|row| row.is_all_suites()),
725 ),
726 )?;
727 fs::write(
728 path.join("comparison.csv"),
729 comparison_csv(report.comparison.as_ref()),
730 )?;
731 fs::write(
732 path.join("bench-summary.md"),
733 benchmark_summary_markdown(report),
734 )?;
735 fs::write(path.join("metadata.json"), metadata_json(&report.metadata))?;
736
737 Ok(())
738}
739
740fn parse_marker_line(
741 line: &str,
742 source_line: usize,
743 source: BenchmarkEventSource,
744 config: &BenchmarkParserConfig,
745) -> Result<RawBenchmarkEvent, MalformedBenchmarkMarker> {
746 let parts = line.split('|').collect::<Vec<_>>();
747 if parts.len() != 6 {
748 return Err(malformed(
749 source_line,
750 source,
751 line,
752 "expected six pipe-separated columns",
753 ));
754 }
755
756 let prefix = parts[0];
757 if !config.prefixes.iter().any(|known| known == prefix) {
758 return Err(malformed(
759 source_line,
760 source,
761 line,
762 "prefix is not configured",
763 ));
764 }
765
766 let label = parts[1];
767 if label.is_empty() {
768 return Err(malformed(source_line, source, line, "label is empty"));
769 }
770
771 let (span_label, kind) = split_label_kind(label).ok_or_else(|| {
772 malformed(
773 source_line,
774 source,
775 line,
776 "label must end in :start or :end",
777 )
778 })?;
779
780 let counters = BenchmarkCounters {
781 instructions: parse_counter(parts[2], source_line, source, line, "instructions")?,
782 heap_bytes: parse_counter(parts[3], source_line, source, line, "heap_bytes")?,
783 memory_bytes: parse_counter(parts[4], source_line, source, line, "memory_bytes")?,
784 total_allocation: parse_counter(parts[5], source_line, source, line, "total_allocation")?,
785 };
786 let suite = config.suite_derivation.derive_suite(span_label);
787
788 Ok(RawBenchmarkEvent {
789 prefix: prefix.to_string(),
790 label: label.to_string(),
791 suite,
792 span_label: span_label.to_string(),
793 kind,
794 counters,
795 source_line,
796 source,
797 })
798}
799
800fn parse_counter(
801 value: &str,
802 source_line: usize,
803 source: BenchmarkEventSource,
804 line: &str,
805 name: &str,
806) -> Result<u128, MalformedBenchmarkMarker> {
807 if value.is_empty() {
808 return Err(malformed(
809 source_line,
810 source,
811 line,
812 &format!("{name} counter is empty"),
813 ));
814 }
815
816 value.parse::<u128>().map_err(|_| {
817 malformed(
818 source_line,
819 source,
820 line,
821 &format!("{name} counter is not an unsigned integer"),
822 )
823 })
824}
825
826fn split_label_kind(label: &str) -> Option<(&str, BenchmarkEventKind)> {
827 let start = label.strip_suffix(":start");
828 let end = label.strip_suffix(":end");
829
830 match (start, end) {
831 (Some(span_label), None) if !span_label.is_empty() => {
832 Some((span_label, BenchmarkEventKind::Start))
833 }
834 (None, Some(span_label)) if !span_label.is_empty() => {
835 Some((span_label, BenchmarkEventKind::End))
836 }
837 _ => None,
838 }
839}
840
841fn has_configured_prefix(line: &str, prefixes: &[String]) -> bool {
842 prefixes.iter().any(|prefix| {
843 line.strip_prefix(prefix)
844 .is_some_and(|rest| rest.starts_with('|'))
845 })
846}
847
848fn malformed(
849 source_line: usize,
850 source: BenchmarkEventSource,
851 line: &str,
852 reason: &str,
853) -> MalformedBenchmarkMarker {
854 MalformedBenchmarkMarker {
855 source_line,
856 source,
857 line: line.to_string(),
858 reason: reason.to_string(),
859 }
860}
861
862fn push_paired_span(
863 report: &mut BenchmarkSpanReport,
864 start: &RawBenchmarkEvent,
865 end: &RawBenchmarkEvent,
866) {
867 if let Some(delta) = end.counters.checked_delta(start.counters) {
868 report.spans.push(BenchmarkSpan {
869 suite: start.suite.clone(),
870 span_label: start.span_label.clone(),
871 start_line: start.source_line,
872 end_line: end.source_line,
873 start: start.counters,
874 end: end.counters,
875 delta,
876 });
877 } else {
878 report.invalid_spans.push(InvalidBenchmarkSpan {
879 start: start.clone(),
880 end: end.clone(),
881 reason: "end counter is lower than start counter".to_string(),
882 });
883 }
884}
885
886#[derive(Clone, Debug)]
887struct AggregateBuilder {
888 runs: u64,
889 total: BenchmarkCounters,
890 min: BenchmarkCounters,
891 max: BenchmarkCounters,
892 peak_end: BenchmarkCounters,
893}
894
895impl AggregateBuilder {
896 const fn new(span: &BenchmarkSpan) -> Self {
897 Self {
898 runs: 1,
899 total: span.delta,
900 min: span.delta,
901 max: span.delta,
902 peak_end: span.end,
903 }
904 }
905
906 fn push(&mut self, span: &BenchmarkSpan) -> Result<(), BenchmarkCounter> {
907 let runs = self.runs.checked_add(1).ok_or(BenchmarkCounter::Runs)?;
908 let total = self.total.checked_add(span.delta)?;
909 self.runs = runs;
910 self.total = total;
911 self.min.min_assign(span.delta);
912 self.max.max_assign(span.delta);
913 self.peak_end.max_assign(span.end);
914 Ok(())
915 }
916
917 const fn finish(self, scope: AggregateScope, span_label: String) -> BenchmarkAggregateRow {
918 BenchmarkAggregateRow {
919 span_label,
920 runs: self.runs,
921 total: self.total,
922 min: self.min,
923 max: self.max,
924 peak_end: self.peak_end,
925 scope,
926 }
927 }
928}
929
930fn add_span_to_aggregate(
931 rows: &mut BTreeMap<(AggregateScope, String), AggregateBuilder>,
932 scope: AggregateScope,
933 span_label: &str,
934 span: &BenchmarkSpan,
935) -> Result<(), BenchmarkAggregateError> {
936 match rows.entry((scope, span_label.to_string())) {
937 Entry::Occupied(mut entry) => entry.get_mut().push(span).map_err(|counter| {
938 let (scope, span_label) = entry.key();
939 BenchmarkAggregateError {
940 span_label: span_label.clone(),
941 counter,
942 scope: scope.clone(),
943 }
944 }),
945 Entry::Vacant(entry) => {
946 entry.insert(AggregateBuilder::new(span));
947 Ok(())
948 }
949 }
950}
951
952#[expect(clippy::cast_precision_loss)]
953fn averages(total: BenchmarkCounters, runs: u64) -> BenchmarkAverages {
954 let runs = runs as f64;
955 BenchmarkAverages {
956 instructions: total.instructions as f64 / runs,
957 heap_bytes: total.heap_bytes as f64 / runs,
958 memory_bytes: total.memory_bytes as f64 / runs,
959 total_allocation: total.total_allocation as f64 / runs,
960 }
961}
962
963fn aggregate_rows_by_key(
964 rows: &[BenchmarkAggregateRow],
965) -> BTreeMap<(&AggregateScope, &str), &BenchmarkAggregateRow> {
966 rows.iter()
967 .map(|row| ((&row.scope, row.span_label.as_str()), row))
968 .collect()
969}
970
971fn compare_average(current: Option<f64>, previous: Option<f64>) -> Option<f64> {
972 match (current, previous) {
973 (Some(current), Some(previous)) if previous != 0.0 => {
974 Some(((current - previous) / previous) * 100.0)
975 }
976 _ => None,
977 }
978}
979
980fn raw_events_csv(events: &[RawBenchmarkEvent]) -> String {
981 let mut out = String::from(
982 "source_line,source,prefix,suite,label,span_label,kind,instructions,heap_bytes,memory_bytes,total_allocation\n",
983 );
984 for event in events {
985 let _ = writeln!(
986 out,
987 "{},{},{},{},{},{},{},{},{},{},{}",
988 event.source_line,
989 event.source.as_str(),
990 csv_cell(&event.prefix),
991 csv_cell(&event.suite),
992 csv_cell(&event.label),
993 csv_cell(&event.span_label),
994 kind_str(event.kind),
995 event.counters.instructions,
996 event.counters.heap_bytes,
997 event.counters.memory_bytes,
998 event.counters.total_allocation
999 );
1000 }
1001 out
1002}
1003
1004fn malformed_markers_csv(markers: &[MalformedBenchmarkMarker]) -> String {
1005 let mut out = String::from("source_line,source,reason,line\n");
1006 for marker in markers {
1007 let _ = writeln!(
1008 out,
1009 "{},{},{},{}",
1010 marker.source_line,
1011 marker.source.as_str(),
1012 csv_cell(&marker.reason),
1013 csv_cell(&marker.line)
1014 );
1015 }
1016 out
1017}
1018
1019fn spans_csv(spans: &[BenchmarkSpan]) -> String {
1020 let mut out = String::from(
1021 "suite,span_label,start_line,end_line,instructions_delta,heap_bytes_delta,memory_bytes_delta,total_allocation_delta\n",
1022 );
1023 for span in spans {
1024 let _ = writeln!(
1025 out,
1026 "{},{},{},{},{},{},{},{}",
1027 csv_cell(&span.suite),
1028 csv_cell(&span.span_label),
1029 span.start_line,
1030 span.end_line,
1031 span.delta.instructions,
1032 span.delta.heap_bytes,
1033 span.delta.memory_bytes,
1034 span.delta.total_allocation
1035 );
1036 }
1037 out
1038}
1039
1040fn unpaired_markers_csv(markers: &[UnpairedBenchmarkMarker]) -> String {
1041 let mut out = String::from("source_line,source,kind,suite,span_label,label\n");
1042 for marker in markers {
1043 let kind = match marker.kind {
1044 UnpairedBenchmarkMarkerKind::Start => "start",
1045 UnpairedBenchmarkMarkerKind::End => "end",
1046 };
1047 let _ = writeln!(
1048 out,
1049 "{},{},{},{},{},{}",
1050 marker.event.source_line,
1051 marker.event.source.as_str(),
1052 kind,
1053 csv_cell(&marker.event.suite),
1054 csv_cell(&marker.event.span_label),
1055 csv_cell(&marker.event.label)
1056 );
1057 }
1058 out
1059}
1060
1061fn invalid_spans_csv(spans: &[InvalidBenchmarkSpan]) -> String {
1062 let mut out = String::from("start_line,end_line,suite,span_label,reason\n");
1063 for span in spans {
1064 let _ = writeln!(
1065 out,
1066 "{},{},{},{},{}",
1067 span.start.source_line,
1068 span.end.source_line,
1069 csv_cell(&span.start.suite),
1070 csv_cell(&span.start.span_label),
1071 csv_cell(&span.reason)
1072 );
1073 }
1074 out
1075}
1076
1077fn aggregates_csv<'a>(rows: impl Iterator<Item = &'a BenchmarkAggregateRow>) -> String {
1078 let mut out = String::from(
1079 "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",
1080 );
1081 for row in rows {
1082 let average = row.average();
1083 let _ = writeln!(
1084 out,
1085 "{},{},{},{},{:.4},{},{:.4},{},{:.4},{},{:.4}",
1086 csv_cell(row.suite()),
1087 csv_cell(&row.span_label),
1088 row.runs,
1089 row.total.instructions,
1090 average.instructions,
1091 row.total.heap_bytes,
1092 average.heap_bytes,
1093 row.total.memory_bytes,
1094 average.memory_bytes,
1095 row.total.total_allocation,
1096 average.total_allocation
1097 );
1098 }
1099 out
1100}
1101
1102fn comparison_csv(comparison: Option<&BenchmarkComparisonReport>) -> String {
1103 let mut out = String::from(
1104 "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",
1105 );
1106
1107 let Some(comparison) = comparison else {
1108 return out;
1109 };
1110
1111 for row in &comparison.rows {
1112 let _ = writeln!(
1113 out,
1114 "{},{},{},{},{},{},{},{},{}",
1115 if row.is_all_suites() { "all" } else { "suite" },
1116 csv_cell(row.suite()),
1117 csv_cell(&row.span_label),
1118 optional_u64_cell(row.current_runs),
1119 optional_u64_cell(row.previous_runs),
1120 optional_f64_cell(row.instructions_avg_change_percent),
1121 optional_f64_cell(row.heap_bytes_avg_change_percent),
1122 optional_f64_cell(row.memory_bytes_avg_change_percent),
1123 optional_f64_cell(row.total_allocation_avg_change_percent)
1124 );
1125 }
1126
1127 out
1128}
1129
1130fn benchmark_summary_markdown(report: &BenchmarkRunReport) -> String {
1131 let comparison_by_key = report.comparison.as_ref().map(|comparison| {
1132 comparison
1133 .rows
1134 .iter()
1135 .map(|row| ((&row.scope, row.span_label.as_str()), row))
1136 .collect::<BTreeMap<_, _>>()
1137 });
1138 let mut out = String::from(
1139 "# Benchmark Summary\n\n| Benchmark | Runs | Instructions Avg | Heap Delta Avg | Memory Delta Avg | Allocation Avg |\n| --- | ---: | ---: | ---: | ---: | ---: |\n",
1140 );
1141
1142 for row in report
1143 .aggregates
1144 .rows
1145 .iter()
1146 .filter(|row| !row.is_all_suites())
1147 {
1148 let average = row.average();
1149 let comparison = comparison_by_key
1150 .as_ref()
1151 .and_then(|rows| rows.get(&(&row.scope, row.span_label.as_str())).copied());
1152 let _ = writeln!(
1153 out,
1154 "| {} | {} | {} | {} | {} | {} |",
1155 markdown_cell(&row.span_label),
1156 row.runs,
1157 format_instructions(
1158 average.instructions,
1159 change_suffix(comparison, |c| { c.instructions_avg_change_percent })
1160 ),
1161 format_bytes(
1162 average.heap_bytes,
1163 change_suffix(comparison, |c| c.heap_bytes_avg_change_percent)
1164 ),
1165 format_bytes(
1166 average.memory_bytes,
1167 change_suffix(comparison, |c| c.memory_bytes_avg_change_percent)
1168 ),
1169 format_bytes(
1170 average.total_allocation,
1171 change_suffix(comparison, |c| c.total_allocation_avg_change_percent)
1172 )
1173 );
1174 }
1175
1176 out
1177}
1178
1179fn metadata_json(metadata: &BenchmarkRunMetadata) -> String {
1180 let value = serde_json::to_value(metadata).expect("metadata JSON must serialize");
1181
1182 let mut output = serde_json::to_string_pretty(&value).expect("metadata JSON must serialize");
1183 output.push('\n');
1184 output
1185}
1186
1187fn next_run_index_for_prefix(runs_root: &Path, prefix: &str) -> io::Result<u32> {
1188 if !runs_root.exists() {
1189 return Ok(1);
1190 }
1191
1192 let mut max_index = 0;
1193 for entry in fs::read_dir(runs_root)? {
1194 let entry = entry?;
1195 if !entry.file_type()?.is_dir() {
1196 continue;
1197 }
1198
1199 if let Some(index) = run_index_from_directory_name(&entry.file_name(), prefix) {
1200 max_index = max_index.max(index);
1201 }
1202 }
1203
1204 max_index
1205 .checked_add(1)
1206 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "benchmark run index exhausted"))
1207}
1208
1209fn run_index_from_directory_name(name: &OsStr, prefix: &str) -> Option<u32> {
1210 let name = name.to_str()?;
1211 let index = name.strip_prefix(prefix)?;
1212
1213 if index.len() >= 4 && index.chars().all(|char| char.is_ascii_digit()) {
1214 index.parse().ok()
1215 } else {
1216 None
1217 }
1218}
1219
1220fn run_directory_sort_key(name: &str) -> Option<(&str, u32)> {
1221 let (prefix, _) = name.rsplit_once('-')?;
1222 let index = run_index_from_directory_name(OsStr::new(name), &format!("{prefix}-"))?;
1223 Some((prefix, index))
1224}
1225
1226fn short_commit_hash(hash: &str) -> String {
1227 hash.chars().take(7).collect()
1228}
1229
1230fn metadata_json_error(err: serde_json::Error) -> io::Error {
1231 io::Error::new(
1232 io::ErrorKind::InvalidData,
1233 format!("invalid benchmark metadata JSON: {err}"),
1234 )
1235}
1236
1237fn change_suffix(
1238 comparison: Option<&BenchmarkComparisonRow>,
1239 change: impl FnOnce(&BenchmarkComparisonRow) -> Option<f64>,
1240) -> Option<String> {
1241 comparison.and_then(|row| {
1242 if row.previous_runs.is_none() {
1243 Some("new".to_string())
1244 } else {
1245 change(row).map(|percent| format!("{percent:+.0}%"))
1246 }
1247 })
1248}
1249
1250fn format_instructions(value: f64, suffix: Option<String>) -> String {
1251 with_optional_suffix(format!("{:.4}B", value / 1_000_000_000.0), suffix)
1252}
1253
1254fn format_bytes(value: f64, suffix: Option<String>) -> String {
1255 with_optional_suffix(human_bytes(value), suffix)
1256}
1257
1258fn with_optional_suffix(value: String, suffix: Option<String>) -> String {
1259 match suffix {
1260 Some(suffix) => format!("{value} ({suffix})"),
1261 None => value,
1262 }
1263}
1264
1265fn human_bytes(value: f64) -> String {
1266 const KIB: f64 = 1024.0;
1267 const MIB: f64 = KIB * 1024.0;
1268 const GIB: f64 = MIB * 1024.0;
1269
1270 let (unit_value, unit) = if value.abs() >= GIB {
1271 (value / GIB, "GB")
1272 } else if value.abs() >= MIB {
1273 (value / MIB, "MB")
1274 } else if value.abs() >= KIB {
1275 (value / KIB, "KB")
1276 } else {
1277 (value, "B")
1278 };
1279
1280 format!("{unit_value:+.1} {unit}")
1281}
1282
1283const fn kind_str(kind: BenchmarkEventKind) -> &'static str {
1284 match kind {
1285 BenchmarkEventKind::Start => "start",
1286 BenchmarkEventKind::End => "end",
1287 }
1288}
1289
1290fn csv_cell(value: &str) -> String {
1291 if value.contains([',', '"', '\n', '\r']) {
1292 format!("\"{}\"", value.replace('"', "\"\""))
1293 } else {
1294 value.to_string()
1295 }
1296}
1297
1298fn optional_u64_cell(value: Option<u64>) -> String {
1299 value.map_or_else(String::new, |value| value.to_string())
1300}
1301
1302fn optional_f64_cell(value: Option<f64>) -> String {
1303 value.map_or_else(String::new, |value| format!("{value:.4}"))
1304}
1305
1306fn markdown_cell(value: &str) -> String {
1307 value.replace('|', "\\|")
1308}
1309
1310#[cfg(test)]
1311mod tests {
1312 use super::{AggregateBuilder, BenchmarkCounter, BenchmarkCounters, BenchmarkSpan};
1313
1314 #[test]
1315 fn aggregate_run_count_overflow_is_rejected_without_changing_totals() {
1316 let span = BenchmarkSpan {
1317 suite: "probe".into(),
1318 span_label: "probe/run".into(),
1319 start_line: 1,
1320 end_line: 2,
1321 start: BenchmarkCounters::default(),
1322 end: BenchmarkCounters::default(),
1323 delta: BenchmarkCounters::default(),
1324 };
1325 let mut builder = AggregateBuilder::new(&span);
1326 builder.runs = u64::MAX;
1327 assert_eq!(builder.push(&span), Err(BenchmarkCounter::Runs));
1328 assert_eq!(builder.runs, u64::MAX);
1329 assert_eq!(builder.total, BenchmarkCounters::default());
1330 }
1331}