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