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