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