1use std::{
10 collections::{BTreeMap, btree_map::Entry},
11 fmt::Write as _,
12 fs, io,
13 path::{Path, PathBuf},
14};
15
16use serde_json::Value;
17
18pub const DEFAULT_PREFIX: &str = "ICTK";
20const ALL_SUITES_LABEL: &str = "ALL";
21
22#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
23enum AggregateScope {
24 Suite(String),
25 All,
26}
27
28impl AggregateScope {
29 fn label(&self) -> &str {
30 match self {
31 Self::Suite(suite) => suite,
32 Self::All => ALL_SUITES_LABEL,
33 }
34 }
35}
36
37#[derive(Clone, Debug, Eq, PartialEq)]
38pub struct BenchmarkParserConfig {
39 pub prefixes: Vec<String>,
40 pub suite_derivation: SuiteDerivation,
41 pub strict: bool,
44}
45
46impl Default for BenchmarkParserConfig {
47 fn default() -> Self {
48 Self {
49 prefixes: vec![DEFAULT_PREFIX.to_string()],
50 suite_derivation: SuiteDerivation::FirstPathSegment,
51 strict: false,
52 }
53 }
54}
55
56#[derive(Clone, Debug, Eq, PartialEq)]
57pub enum SuiteDerivation {
58 FirstPathSegment,
59 Fixed(String),
60}
61
62impl SuiteDerivation {
63 #[must_use]
64 pub fn derive_suite(&self, span_label: &str) -> String {
65 match self {
66 Self::FirstPathSegment => span_label
67 .split('/')
68 .next()
69 .filter(|part| !part.is_empty())
70 .unwrap_or(span_label)
71 .to_string(),
72 Self::Fixed(suite) => suite.clone(),
73 }
74 }
75}
76
77#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78pub enum BenchmarkEventKind {
79 Start,
80 End,
81}
82
83#[derive(Clone, Copy, Debug, Eq, PartialEq)]
84pub enum BenchmarkEventSource {
85 Unknown,
86 Stdout,
87 Stderr,
88 FetchedLog,
89}
90
91impl BenchmarkEventSource {
92 #[must_use]
93 pub const fn as_str(self) -> &'static str {
94 match self {
95 Self::Unknown => "unknown",
96 Self::Stdout => "stdout",
97 Self::Stderr => "stderr",
98 Self::FetchedLog => "fetched_log",
99 }
100 }
101}
102
103#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
104pub struct BenchmarkCounters {
105 pub instructions: u128,
106 pub heap_bytes: u128,
107 pub memory_bytes: u128,
108 pub total_allocation: u128,
109}
110
111impl BenchmarkCounters {
112 fn checked_delta(self, start: Self) -> Option<Self> {
113 Some(Self {
114 instructions: self.instructions.checked_sub(start.instructions)?,
115 heap_bytes: self.heap_bytes.checked_sub(start.heap_bytes)?,
116 memory_bytes: self.memory_bytes.checked_sub(start.memory_bytes)?,
117 total_allocation: self.total_allocation.checked_sub(start.total_allocation)?,
118 })
119 }
120
121 fn checked_add(self, other: Self) -> Result<Self, BenchmarkCounter> {
122 Ok(Self {
123 instructions: self
124 .instructions
125 .checked_add(other.instructions)
126 .ok_or(BenchmarkCounter::Instructions)?,
127 heap_bytes: self
128 .heap_bytes
129 .checked_add(other.heap_bytes)
130 .ok_or(BenchmarkCounter::HeapBytes)?,
131 memory_bytes: self
132 .memory_bytes
133 .checked_add(other.memory_bytes)
134 .ok_or(BenchmarkCounter::MemoryBytes)?,
135 total_allocation: self
136 .total_allocation
137 .checked_add(other.total_allocation)
138 .ok_or(BenchmarkCounter::TotalAllocation)?,
139 })
140 }
141
142 fn min_assign(&mut self, other: Self) {
143 self.instructions = self.instructions.min(other.instructions);
144 self.heap_bytes = self.heap_bytes.min(other.heap_bytes);
145 self.memory_bytes = self.memory_bytes.min(other.memory_bytes);
146 self.total_allocation = self.total_allocation.min(other.total_allocation);
147 }
148
149 fn max_assign(&mut self, other: Self) {
150 self.instructions = self.instructions.max(other.instructions);
151 self.heap_bytes = self.heap_bytes.max(other.heap_bytes);
152 self.memory_bytes = self.memory_bytes.max(other.memory_bytes);
153 self.total_allocation = self.total_allocation.max(other.total_allocation);
154 }
155}
156
157#[derive(Clone, Debug, Eq, PartialEq)]
158pub struct RawBenchmarkEvent {
159 pub prefix: String,
160 pub label: String,
161 pub suite: String,
162 pub span_label: String,
163 pub kind: BenchmarkEventKind,
164 pub counters: BenchmarkCounters,
165 pub source_line: usize,
166 pub source: BenchmarkEventSource,
167}
168
169#[derive(Clone, Debug, Eq, PartialEq)]
170pub struct MalformedBenchmarkMarker {
171 pub source_line: usize,
172 pub source: BenchmarkEventSource,
173 pub line: String,
174 pub reason: String,
175}
176
177#[derive(Clone, Debug, Default, Eq, PartialEq)]
178pub struct BenchmarkParseReport {
179 pub events: Vec<RawBenchmarkEvent>,
180 pub malformed_markers: Vec<MalformedBenchmarkMarker>,
181 pub ignored_line_count: usize,
182}
183
184#[derive(Clone, Debug, Eq, PartialEq)]
185pub struct BenchmarkSpan {
186 pub suite: String,
187 pub span_label: String,
188 pub start_line: usize,
189 pub end_line: usize,
190 pub start: BenchmarkCounters,
191 pub end: BenchmarkCounters,
192 pub delta: BenchmarkCounters,
193}
194
195#[derive(Clone, Debug, Eq, PartialEq)]
196pub enum UnpairedBenchmarkMarkerKind {
197 Start,
198 End,
199}
200
201#[derive(Clone, Debug, Eq, PartialEq)]
202pub struct UnpairedBenchmarkMarker {
203 pub event: RawBenchmarkEvent,
204 pub kind: UnpairedBenchmarkMarkerKind,
205}
206
207#[derive(Clone, Debug, Eq, PartialEq)]
208pub struct InvalidBenchmarkSpan {
209 pub start: RawBenchmarkEvent,
210 pub end: RawBenchmarkEvent,
211 pub reason: String,
212}
213
214#[derive(Clone, Debug, Default, Eq, PartialEq)]
215pub struct BenchmarkSpanReport {
216 pub spans: Vec<BenchmarkSpan>,
217 pub unpaired_markers: Vec<UnpairedBenchmarkMarker>,
218 pub invalid_spans: Vec<InvalidBenchmarkSpan>,
219}
220
221#[derive(Clone, Debug, Eq, PartialEq)]
222pub struct BenchmarkAggregateRow {
223 pub span_label: String,
224 pub runs: u64,
225 pub total: BenchmarkCounters,
226 pub min: BenchmarkCounters,
227 pub max: BenchmarkCounters,
228 pub peak_end: BenchmarkCounters,
229 scope: AggregateScope,
230}
231
232#[derive(Clone, Copy, Debug, Default, PartialEq)]
233pub struct BenchmarkAverages {
234 pub instructions: f64,
235 pub heap_bytes: f64,
236 pub memory_bytes: f64,
237 pub total_allocation: f64,
238}
239
240#[derive(Clone, Debug, Default, Eq, PartialEq)]
241pub struct BenchmarkAggregateReport {
242 pub rows: Vec<BenchmarkAggregateRow>,
243}
244
245#[derive(Clone, Copy, Debug, Eq, PartialEq)]
247pub enum BenchmarkCounter {
248 Runs,
249 Instructions,
250 HeapBytes,
251 MemoryBytes,
252 TotalAllocation,
253}
254
255#[derive(Clone, Debug, Eq, PartialEq)]
257pub struct BenchmarkAggregateError {
258 pub span_label: String,
259 pub counter: BenchmarkCounter,
260 scope: AggregateScope,
261}
262
263impl BenchmarkAggregateError {
264 #[must_use]
266 pub fn suite(&self) -> &str {
267 self.scope.label()
268 }
269
270 #[must_use]
272 pub const fn is_all_suites(&self) -> bool {
273 matches!(self.scope, AggregateScope::All)
274 }
275}
276
277impl std::fmt::Display for BenchmarkAggregateError {
278 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279 write!(
280 formatter,
281 "benchmark aggregate overflow: scope={:?}, span={}, counter={:?}",
282 self.scope, self.span_label, self.counter
283 )
284 }
285}
286
287impl std::error::Error for BenchmarkAggregateError {}
288
289#[derive(Clone, Debug, PartialEq)]
290pub struct BenchmarkComparisonRow {
291 pub span_label: String,
292 pub current_runs: Option<u64>,
293 pub previous_runs: Option<u64>,
294 pub instructions_avg_change_percent: Option<f64>,
295 pub heap_bytes_avg_change_percent: Option<f64>,
296 pub memory_bytes_avg_change_percent: Option<f64>,
297 pub total_allocation_avg_change_percent: Option<f64>,
298 scope: AggregateScope,
299}
300
301impl BenchmarkAggregateRow {
302 #[must_use]
304 pub fn average(&self) -> BenchmarkAverages {
305 averages(self.total, self.runs)
306 }
307
308 #[must_use]
310 pub fn suite(&self) -> &str {
311 self.scope.label()
312 }
313
314 #[must_use]
316 pub const fn is_all_suites(&self) -> bool {
317 matches!(self.scope, AggregateScope::All)
318 }
319}
320
321impl BenchmarkComparisonRow {
322 #[must_use]
324 pub fn suite(&self) -> &str {
325 self.scope.label()
326 }
327
328 #[must_use]
330 pub const fn is_all_suites(&self) -> bool {
331 matches!(self.scope, AggregateScope::All)
332 }
333}
334
335#[derive(Clone, Debug, Default, PartialEq)]
336pub struct BenchmarkComparisonReport {
337 pub rows: Vec<BenchmarkComparisonRow>,
338}
339
340#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
341pub struct BenchmarkRunMetadata {
342 pub timestamp: String,
343 pub run_directory_name: String,
344 pub run_index: u32,
345 pub git_commit_hash: Option<String>,
346 pub git_commit_short_hash: Option<String>,
347 pub ic_testkit_version: String,
348 pub pocket_ic_version: String,
349 pub rustc_version: String,
350 pub benchmark_command: Option<String>,
351 pub selected_previous_run: Option<String>,
352}
353
354#[derive(Clone, Debug, PartialEq)]
355pub struct BenchmarkRunReport {
356 pub parse: BenchmarkParseReport,
357 pub spans: BenchmarkSpanReport,
358 pub aggregates: BenchmarkAggregateReport,
359 pub comparison: Option<BenchmarkComparisonReport>,
360 pub metadata: BenchmarkRunMetadata,
361}
362
363#[derive(Clone, Debug, Eq, PartialEq)]
364pub struct BenchmarkRunDirectory {
365 pub path: PathBuf,
366 pub directory_name: String,
367 pub run_index: u32,
368 pub git_commit_hash: Option<String>,
369 pub git_commit_short_hash: Option<String>,
370}
371
372#[must_use]
373pub fn format_marker(prefix: &str, label: &str, counters: BenchmarkCounters) -> String {
374 format!(
375 "{}|{}|{}|{}|{}|{}",
376 prefix,
377 label,
378 counters.instructions,
379 counters.heap_bytes,
380 counters.memory_bytes,
381 counters.total_allocation
382 )
383}
384
385#[must_use]
386pub fn benchmark_run_directory_name(
387 timestamp: &str,
388 git_commit_short_hash: Option<&str>,
389 run_index: u32,
390) -> String {
391 let commit = git_commit_short_hash
392 .filter(|hash| !hash.is_empty())
393 .unwrap_or("unknown");
394 format!("{timestamp}-{commit}-{run_index:04}")
395}
396
397pub 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(
438 runs_root: impl AsRef<Path>,
439 current_run_directory_name: &str,
440 benchmark_command: Option<&str>,
441) -> io::Result<Option<PathBuf>> {
442 let runs_root = runs_root.as_ref();
443 let current_key = run_directory_sort_key(current_run_directory_name).ok_or_else(|| {
444 io::Error::new(
445 io::ErrorKind::InvalidInput,
446 "invalid benchmark run directory name",
447 )
448 })?;
449 let mut latest: Option<((String, String, u32), PathBuf)> = None;
450
451 let entries = match fs::read_dir(runs_root) {
452 Ok(entries) => entries,
453 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
454 Err(error) => return Err(error),
455 };
456 for entry in entries {
457 let entry = entry?;
458 if !entry.file_type()?.is_dir() {
459 continue;
460 }
461
462 let directory_name = entry.file_name().to_string_lossy().into_owned();
463 let Some((prefix, index)) = run_directory_sort_key(&directory_name) else {
464 continue;
465 };
466 if (prefix, index) >= current_key {
467 continue;
468 }
469
470 let metadata_path = entry.path().join("metadata.json");
471 let Ok(metadata) = read_benchmark_run_metadata(&metadata_path) else {
472 continue;
473 };
474
475 if let Some(command) = benchmark_command
476 && metadata.benchmark_command.as_deref() != Some(command)
477 {
478 continue;
479 }
480
481 let key = (metadata.timestamp, prefix.to_owned(), index);
482 if latest
483 .as_ref()
484 .is_none_or(|(latest_key, _)| key >= *latest_key)
485 {
486 latest = Some((key, entry.path()));
487 }
488 }
489
490 Ok(latest.map(|(_, path)| path))
491}
492
493pub fn read_benchmark_run_metadata(path: impl AsRef<Path>) -> io::Result<BenchmarkRunMetadata> {
494 let input = fs::read_to_string(path)?;
495 let value = serde_json::from_str::<Value>(&input).map_err(metadata_json_error)?;
496
497 if !value.is_object() {
498 return Err(io::Error::new(
499 io::ErrorKind::InvalidData,
500 "benchmark metadata JSON must be an object",
501 ));
502 }
503 serde_json::from_value(value).map_err(metadata_json_error)
504}
505
506#[must_use]
507pub fn parse_benchmark_events(input: &str, config: &BenchmarkParserConfig) -> BenchmarkParseReport {
508 parse_benchmark_events_from_source(input, config, BenchmarkEventSource::Unknown)
509}
510
511#[must_use]
512pub fn parse_benchmark_events_from_source(
513 input: &str,
514 config: &BenchmarkParserConfig,
515 source: BenchmarkEventSource,
516) -> BenchmarkParseReport {
517 let mut report = BenchmarkParseReport::default();
518 append_benchmark_events(&mut report, input, config, source);
519 report
520}
521
522fn append_benchmark_events(
523 report: &mut BenchmarkParseReport,
524 input: &str,
525 config: &BenchmarkParserConfig,
526 source: BenchmarkEventSource,
527) {
528 for (index, line) in input.lines().enumerate() {
529 let source_line = index + 1;
530 if !has_configured_prefix(line, &config.prefixes) {
531 if config.strict && !line.trim().is_empty() {
532 report.malformed_markers.push(malformed(
533 source_line,
534 source,
535 line,
536 "line does not use a configured marker prefix",
537 ));
538 } else {
539 report.ignored_line_count += 1;
540 }
541 continue;
542 }
543
544 match parse_marker_line(line, source_line, source, config) {
545 Ok(event) => report.events.push(event),
546 Err(marker) => report.malformed_markers.push(marker),
547 }
548 }
549}
550
551#[must_use]
557pub fn parse_benchmark_events_from_captured_output(
558 stdout: &str,
559 stderr: &str,
560 config: &BenchmarkParserConfig,
561) -> BenchmarkParseReport {
562 let mut report = BenchmarkParseReport::default();
563 append_benchmark_events(&mut report, stdout, config, BenchmarkEventSource::Stdout);
564 append_benchmark_events(&mut report, stderr, config, BenchmarkEventSource::Stderr);
565 report
566}
567
568#[must_use]
569pub fn pair_benchmark_spans(events: &[RawBenchmarkEvent]) -> BenchmarkSpanReport {
570 let mut report = BenchmarkSpanReport::default();
571 let mut open_starts: BTreeMap<(&str, &str), Vec<&RawBenchmarkEvent>> = BTreeMap::new();
572
573 for event in events {
574 let key = (event.suite.as_str(), event.span_label.as_str());
575 match event.kind {
576 BenchmarkEventKind::Start => open_starts.entry(key).or_default().push(event),
577 BenchmarkEventKind::End => match open_starts.entry(key) {
578 Entry::Occupied(mut entry) => {
579 let start = entry
580 .get_mut()
581 .pop()
582 .expect("open marker stacks must remain nonempty");
583 if entry.get().is_empty() {
584 entry.remove();
585 }
586 push_paired_span(&mut report, start, event);
587 }
588 Entry::Vacant(_) => report.unpaired_markers.push(UnpairedBenchmarkMarker {
589 event: event.clone(),
590 kind: UnpairedBenchmarkMarkerKind::End,
591 }),
592 },
593 }
594 }
595
596 for starts in open_starts.into_values() {
597 for event in starts {
598 report.unpaired_markers.push(UnpairedBenchmarkMarker {
599 event: event.clone(),
600 kind: UnpairedBenchmarkMarkerKind::Start,
601 });
602 }
603 }
604
605 report
606}
607
608pub fn aggregate_benchmark_spans(
613 spans: &[BenchmarkSpan],
614) -> Result<BenchmarkAggregateReport, BenchmarkAggregateError> {
615 let mut rows: BTreeMap<(AggregateScope, &str), AggregateBuilder> = BTreeMap::new();
616
617 for span in spans {
618 add_span_to_aggregate(&mut rows, AggregateScope::Suite(span.suite.clone()), span)?;
619 add_span_to_aggregate(&mut rows, AggregateScope::All, span)?;
620 }
621
622 Ok(BenchmarkAggregateReport {
623 rows: rows
624 .into_iter()
625 .map(|((scope, span_label), builder)| builder.finish(scope, span_label.to_owned()))
626 .collect(),
627 })
628}
629
630#[must_use]
631pub fn compare_benchmark_aggregates(
632 current: &[BenchmarkAggregateRow],
633 previous: &[BenchmarkAggregateRow],
634) -> BenchmarkComparisonReport {
635 let mut rows = BTreeMap::new();
636 for row in current {
637 rows.insert((&row.scope, row.span_label.as_str()), (Some(row), None));
638 }
639 for row in previous {
640 rows.entry((&row.scope, row.span_label.as_str()))
641 .or_insert((None, None))
642 .1 = Some(row);
643 }
644
645 BenchmarkComparisonReport {
646 rows: rows
647 .into_iter()
648 .map(|((scope, span_label), (current_row, previous_row))| {
649 let current_average = current_row.map(BenchmarkAggregateRow::average);
650 let previous_average = previous_row.map(BenchmarkAggregateRow::average);
651 BenchmarkComparisonRow {
652 span_label: span_label.to_owned(),
653 current_runs: current_row.map(|row| row.runs),
654 previous_runs: previous_row.map(|row| row.runs),
655 instructions_avg_change_percent: compare_average(
656 current_average.map(|average| average.instructions),
657 previous_average.map(|average| average.instructions),
658 ),
659 heap_bytes_avg_change_percent: compare_average(
660 current_average.map(|average| average.heap_bytes),
661 previous_average.map(|average| average.heap_bytes),
662 ),
663 memory_bytes_avg_change_percent: compare_average(
664 current_average.map(|average| average.memory_bytes),
665 previous_average.map(|average| average.memory_bytes),
666 ),
667 total_allocation_avg_change_percent: compare_average(
668 current_average.map(|average| average.total_allocation),
669 previous_average.map(|average| average.total_allocation),
670 ),
671 scope: scope.clone(),
672 }
673 })
674 .collect(),
675 }
676}
677
678pub fn write_benchmark_report_dir(
683 report: &BenchmarkRunReport,
684 path: impl AsRef<Path>,
685) -> io::Result<()> {
686 let path = path.as_ref();
687 fs::create_dir_all(path)?;
688
689 fs::write(
690 path.join("raw-events.csv"),
691 raw_events_csv(&report.parse.events),
692 )?;
693 fs::write(
694 path.join("malformed-markers.csv"),
695 malformed_markers_csv(&report.parse.malformed_markers),
696 )?;
697 fs::write(path.join("spans.csv"), spans_csv(&report.spans.spans))?;
698 fs::write(
699 path.join("unpaired-markers.csv"),
700 unpaired_markers_csv(&report.spans.unpaired_markers),
701 )?;
702 fs::write(
703 path.join("invalid-spans.csv"),
704 invalid_spans_csv(&report.spans.invalid_spans),
705 )?;
706 fs::write(
707 path.join("suite-aggregates.csv"),
708 aggregates_csv(
709 report
710 .aggregates
711 .rows
712 .iter()
713 .filter(|row| !row.is_all_suites()),
714 ),
715 )?;
716 fs::write(
717 path.join("all-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("comparison.csv"),
728 comparison_csv(report.comparison.as_ref()),
729 )?;
730 fs::write(
731 path.join("bench-summary.md"),
732 benchmark_summary_markdown(report),
733 )?;
734 fs::write(path.join("metadata.json"), metadata_json(&report.metadata))?;
735
736 Ok(())
737}
738
739fn parse_marker_line(
740 line: &str,
741 source_line: usize,
742 source: BenchmarkEventSource,
743 config: &BenchmarkParserConfig,
744) -> Result<RawBenchmarkEvent, MalformedBenchmarkMarker> {
745 let mut parts = line.split('|');
746 let columns: [Option<&str>; 7] = std::array::from_fn(|_| parts.next());
749 let [
750 Some(prefix),
751 Some(label),
752 Some(instructions),
753 Some(heap_bytes),
754 Some(memory_bytes),
755 Some(total_allocation),
756 None,
757 ] = columns
758 else {
759 return Err(malformed(
760 source_line,
761 source,
762 line,
763 "expected six pipe-separated columns",
764 ));
765 };
766
767 if !config.prefixes.iter().any(|known| known == prefix) {
768 return Err(malformed(
769 source_line,
770 source,
771 line,
772 "prefix is not configured",
773 ));
774 }
775
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(instructions, source_line, source, line, "instructions")?,
791 heap_bytes: parse_counter(heap_bytes, source_line, source, line, "heap_bytes")?,
792 memory_bytes: parse_counter(memory_bytes, source_line, source, line, "memory_bytes")?,
793 total_allocation: parse_counter(
794 total_allocation,
795 source_line,
796 source,
797 line,
798 "total_allocation",
799 )?,
800 };
801 let suite = config.suite_derivation.derive_suite(span_label);
802
803 Ok(RawBenchmarkEvent {
804 prefix: prefix.to_string(),
805 label: label.to_string(),
806 suite,
807 span_label: span_label.to_string(),
808 kind,
809 counters,
810 source_line,
811 source,
812 })
813}
814
815fn parse_counter(
816 value: &str,
817 source_line: usize,
818 source: BenchmarkEventSource,
819 line: &str,
820 name: &str,
821) -> Result<u128, MalformedBenchmarkMarker> {
822 if value.is_empty() {
823 return Err(malformed(
824 source_line,
825 source,
826 line,
827 &format!("{name} counter is empty"),
828 ));
829 }
830
831 value.parse::<u128>().map_err(|_| {
832 malformed(
833 source_line,
834 source,
835 line,
836 &format!("{name} counter is not an unsigned integer"),
837 )
838 })
839}
840
841fn split_label_kind(label: &str) -> Option<(&str, BenchmarkEventKind)> {
842 let start = label.strip_suffix(":start");
843 let end = label.strip_suffix(":end");
844
845 match (start, end) {
846 (Some(span_label), None) if !span_label.is_empty() => {
847 Some((span_label, BenchmarkEventKind::Start))
848 }
849 (None, Some(span_label)) if !span_label.is_empty() => {
850 Some((span_label, BenchmarkEventKind::End))
851 }
852 _ => None,
853 }
854}
855
856fn has_configured_prefix(line: &str, prefixes: &[String]) -> bool {
857 prefixes.iter().any(|prefix| {
858 line.strip_prefix(prefix)
859 .is_some_and(|rest| rest.starts_with('|'))
860 })
861}
862
863fn malformed(
864 source_line: usize,
865 source: BenchmarkEventSource,
866 line: &str,
867 reason: &str,
868) -> MalformedBenchmarkMarker {
869 MalformedBenchmarkMarker {
870 source_line,
871 source,
872 line: line.to_string(),
873 reason: reason.to_string(),
874 }
875}
876
877fn push_paired_span(
878 report: &mut BenchmarkSpanReport,
879 start: &RawBenchmarkEvent,
880 end: &RawBenchmarkEvent,
881) {
882 if let Some(delta) = end.counters.checked_delta(start.counters) {
883 report.spans.push(BenchmarkSpan {
884 suite: start.suite.clone(),
885 span_label: start.span_label.clone(),
886 start_line: start.source_line,
887 end_line: end.source_line,
888 start: start.counters,
889 end: end.counters,
890 delta,
891 });
892 } else {
893 report.invalid_spans.push(InvalidBenchmarkSpan {
894 start: start.clone(),
895 end: end.clone(),
896 reason: "end counter is lower than start counter".to_string(),
897 });
898 }
899}
900
901#[derive(Clone, Debug)]
902struct AggregateBuilder {
903 runs: u64,
904 total: BenchmarkCounters,
905 min: BenchmarkCounters,
906 max: BenchmarkCounters,
907 peak_end: BenchmarkCounters,
908}
909
910impl AggregateBuilder {
911 const fn new(span: &BenchmarkSpan) -> Self {
912 Self {
913 runs: 1,
914 total: span.delta,
915 min: span.delta,
916 max: span.delta,
917 peak_end: span.end,
918 }
919 }
920
921 fn push(&mut self, span: &BenchmarkSpan) -> Result<(), BenchmarkCounter> {
922 let runs = self.runs.checked_add(1).ok_or(BenchmarkCounter::Runs)?;
923 let total = self.total.checked_add(span.delta)?;
924 self.runs = runs;
925 self.total = total;
926 self.min.min_assign(span.delta);
927 self.max.max_assign(span.delta);
928 self.peak_end.max_assign(span.end);
929 Ok(())
930 }
931
932 const fn finish(self, scope: AggregateScope, span_label: String) -> BenchmarkAggregateRow {
933 BenchmarkAggregateRow {
934 span_label,
935 runs: self.runs,
936 total: self.total,
937 min: self.min,
938 max: self.max,
939 peak_end: self.peak_end,
940 scope,
941 }
942 }
943}
944
945fn add_span_to_aggregate<'a>(
946 rows: &mut BTreeMap<(AggregateScope, &'a str), AggregateBuilder>,
947 scope: AggregateScope,
948 span: &'a BenchmarkSpan,
949) -> Result<(), BenchmarkAggregateError> {
950 match rows.entry((scope, span.span_label.as_str())) {
951 Entry::Occupied(mut entry) => entry.get_mut().push(span).map_err(|counter| {
952 let (scope, span_label) = entry.key();
953 BenchmarkAggregateError {
954 span_label: (*span_label).to_owned(),
955 counter,
956 scope: scope.clone(),
957 }
958 }),
959 Entry::Vacant(entry) => {
960 entry.insert(AggregateBuilder::new(span));
961 Ok(())
962 }
963 }
964}
965
966#[expect(clippy::cast_precision_loss)]
967fn averages(total: BenchmarkCounters, runs: u64) -> BenchmarkAverages {
968 let runs = runs as f64;
969 BenchmarkAverages {
970 instructions: total.instructions as f64 / runs,
971 heap_bytes: total.heap_bytes as f64 / runs,
972 memory_bytes: total.memory_bytes as f64 / runs,
973 total_allocation: total.total_allocation as f64 / runs,
974 }
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 let entries = match fs::read_dir(runs_root) {
1195 Ok(entries) => entries,
1196 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(1),
1197 Err(error) => return Err(error),
1198 };
1199 let mut max_index = 0;
1200 for entry in entries {
1201 let entry = entry?;
1202 if !entry.file_type()?.is_dir() {
1203 continue;
1204 }
1205
1206 if let Some(index) = entry
1207 .file_name()
1208 .to_str()
1209 .and_then(|name| name.strip_prefix(prefix))
1210 .and_then(parse_run_index)
1211 {
1212 max_index = max_index.max(index);
1213 }
1214 }
1215
1216 max_index
1217 .checked_add(1)
1218 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "benchmark run index exhausted"))
1219}
1220
1221fn parse_run_index(index: &str) -> Option<u32> {
1222 if index.len() >= 4 && index.chars().all(|char| char.is_ascii_digit()) {
1223 index.parse().ok()
1224 } else {
1225 None
1226 }
1227}
1228
1229fn run_directory_sort_key(name: &str) -> Option<(&str, u32)> {
1230 let (prefix, index) = name.rsplit_once('-')?;
1231 Some((prefix, parse_run_index(index)?))
1232}
1233
1234fn short_commit_hash(hash: &str) -> String {
1235 hash.chars().take(7).collect()
1236}
1237
1238fn metadata_json_error(err: serde_json::Error) -> io::Error {
1239 io::Error::new(
1240 io::ErrorKind::InvalidData,
1241 format!("invalid benchmark metadata JSON: {err}"),
1242 )
1243}
1244
1245fn change_suffix(
1246 comparison: Option<&BenchmarkComparisonRow>,
1247 change: impl FnOnce(&BenchmarkComparisonRow) -> Option<f64>,
1248) -> Option<String> {
1249 comparison.and_then(|row| {
1250 if row.previous_runs.is_none() {
1251 Some("new".to_string())
1252 } else {
1253 change(row).map(|percent| format!("{percent:+.0}%"))
1254 }
1255 })
1256}
1257
1258fn format_instructions(value: f64, suffix: Option<String>) -> String {
1259 with_optional_suffix(format!("{:.4}B", value / 1_000_000_000.0), suffix)
1260}
1261
1262fn format_bytes(value: f64, suffix: Option<String>) -> String {
1263 with_optional_suffix(human_bytes(value), suffix)
1264}
1265
1266fn with_optional_suffix(value: String, suffix: Option<String>) -> String {
1267 match suffix {
1268 Some(suffix) => format!("{value} ({suffix})"),
1269 None => value,
1270 }
1271}
1272
1273fn human_bytes(value: f64) -> String {
1274 const KIB: f64 = 1024.0;
1275 const MIB: f64 = KIB * 1024.0;
1276 const GIB: f64 = MIB * 1024.0;
1277
1278 let (unit_value, unit) = if value.abs() >= GIB {
1279 (value / GIB, "GB")
1280 } else if value.abs() >= MIB {
1281 (value / MIB, "MB")
1282 } else if value.abs() >= KIB {
1283 (value / KIB, "KB")
1284 } else {
1285 (value, "B")
1286 };
1287
1288 format!("{unit_value:+.1} {unit}")
1289}
1290
1291const fn kind_str(kind: BenchmarkEventKind) -> &'static str {
1292 match kind {
1293 BenchmarkEventKind::Start => "start",
1294 BenchmarkEventKind::End => "end",
1295 }
1296}
1297
1298fn csv_cell(value: &str) -> String {
1299 if value.contains([',', '"', '\n', '\r']) {
1300 format!("\"{}\"", value.replace('"', "\"\""))
1301 } else {
1302 value.to_string()
1303 }
1304}
1305
1306fn optional_u64_cell(value: Option<u64>) -> String {
1307 value.map_or_else(String::new, |value| value.to_string())
1308}
1309
1310fn optional_f64_cell(value: Option<f64>) -> String {
1311 value.map_or_else(String::new, |value| format!("{value:.4}"))
1312}
1313
1314fn markdown_cell(value: &str) -> String {
1315 value.replace('|', "\\|")
1316}
1317
1318#[cfg(test)]
1319mod tests {
1320 use super::{AggregateBuilder, BenchmarkCounter, BenchmarkCounters, BenchmarkSpan};
1321
1322 #[test]
1323 fn aggregate_run_count_overflow_is_rejected_without_changing_totals() {
1324 let span = BenchmarkSpan {
1325 suite: "probe".into(),
1326 span_label: "probe/run".into(),
1327 start_line: 1,
1328 end_line: 2,
1329 start: BenchmarkCounters::default(),
1330 end: BenchmarkCounters::default(),
1331 delta: BenchmarkCounters::default(),
1332 };
1333 let mut builder = AggregateBuilder::new(&span);
1334 builder.runs = u64::MAX;
1335 assert_eq!(builder.push(&span), Err(BenchmarkCounter::Runs));
1336 assert_eq!(builder.runs, u64::MAX);
1337 assert_eq!(builder.total, BenchmarkCounters::default());
1338 }
1339}