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