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