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