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