1use std::collections::{BTreeMap, btree_map::Entry};
16
17#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
19pub enum BenchmarkAverageError {
20 #[error("benchmark run count is zero")]
22 ZeroDenominator,
23 #[error("scaled benchmark average exceeds u128")]
25 Overflow,
26}
27
28fn checked_scaled_average(
29 total: u128,
30 runs: u64,
31 scale: u64,
32) -> Result<u128, BenchmarkAverageError> {
33 ic_metrics::checked_scaled_ratio(total, runs, scale).map_err(|error| match error {
34 ic_metrics::MeasurementRatioError::ZeroDenominator => {
35 BenchmarkAverageError::ZeroDenominator
36 }
37 ic_metrics::MeasurementRatioError::Overflow => BenchmarkAverageError::Overflow,
38 })
39}
40
41#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
43pub enum BenchmarkComparisonError {
44 #[error("duplicate current benchmark row: {scope:?}/{span_label}")]
46 DuplicateCurrent {
47 scope: BenchmarkScope,
49 span_label: String,
51 },
52 #[error("duplicate previous benchmark row: {scope:?}/{span_label}")]
54 DuplicatePrevious {
55 scope: BenchmarkScope,
57 span_label: String,
59 },
60 #[error("{0}")]
62 Average(#[from] BenchmarkAverageError),
63}
64
65pub const DEFAULT_PREFIX: &str = "ICTK";
67
68#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
70pub enum BenchmarkScope {
71 Suite(String),
73 All,
75}
76
77#[derive(Clone, Debug, Eq, PartialEq)]
79pub struct BenchmarkParserConfig {
80 pub prefixes: Vec<String>,
82 pub suite_derivation: SuiteDerivation,
84 pub strict: bool,
87}
88
89impl Default for BenchmarkParserConfig {
90 fn default() -> Self {
91 Self {
92 prefixes: vec![DEFAULT_PREFIX.to_string()],
93 suite_derivation: SuiteDerivation::FirstPathSegment,
94 strict: false,
95 }
96 }
97}
98
99#[derive(Clone, Debug, Eq, PartialEq)]
101pub enum SuiteDerivation {
102 FirstPathSegment,
104 Fixed(String),
106}
107
108impl SuiteDerivation {
109 #[must_use]
111 pub fn derive_suite(&self, span_label: &str) -> String {
112 match self {
113 Self::FirstPathSegment => span_label
114 .split('/')
115 .next()
116 .filter(|part| !part.is_empty())
117 .unwrap_or(span_label)
118 .to_string(),
119 Self::Fixed(suite) => suite.clone(),
120 }
121 }
122}
123
124#[derive(Clone, Copy, Debug, Eq, PartialEq)]
126pub enum BenchmarkEventKind {
127 Start,
129 End,
131}
132
133#[derive(Clone, Copy, Debug, Eq, PartialEq)]
135pub enum BenchmarkEventSource {
136 Unknown,
138 Stdout,
140 Stderr,
142 FetchedLog,
144}
145
146impl BenchmarkEventSource {
147 #[must_use]
149 pub const fn as_str(self) -> &'static str {
150 match self {
151 Self::Unknown => "unknown",
152 Self::Stdout => "stdout",
153 Self::Stderr => "stderr",
154 Self::FetchedLog => "fetched_log",
155 }
156 }
157}
158
159#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
161pub struct BenchmarkCounters {
162 pub instructions: u128,
164 pub heap_bytes: u128,
166 pub memory_bytes: u128,
168}
169
170impl BenchmarkCounters {
171 fn checked_delta(self, start: Self) -> Option<Self> {
172 Some(Self {
173 instructions: self.instructions.checked_sub(start.instructions)?,
174 heap_bytes: self.heap_bytes.checked_sub(start.heap_bytes)?,
175 memory_bytes: self.memory_bytes.checked_sub(start.memory_bytes)?,
176 })
177 }
178
179 fn checked_add(self, other: Self) -> Result<Self, BenchmarkCounter> {
180 Ok(Self {
181 instructions: self
182 .instructions
183 .checked_add(other.instructions)
184 .ok_or(BenchmarkCounter::Instructions)?,
185 heap_bytes: self
186 .heap_bytes
187 .checked_add(other.heap_bytes)
188 .ok_or(BenchmarkCounter::HeapBytes)?,
189 memory_bytes: self
190 .memory_bytes
191 .checked_add(other.memory_bytes)
192 .ok_or(BenchmarkCounter::MemoryBytes)?,
193 })
194 }
195
196 fn min_assign(&mut self, other: Self) {
197 self.instructions = self.instructions.min(other.instructions);
198 self.heap_bytes = self.heap_bytes.min(other.heap_bytes);
199 self.memory_bytes = self.memory_bytes.min(other.memory_bytes);
200 }
201
202 fn max_assign(&mut self, other: Self) {
203 self.instructions = self.instructions.max(other.instructions);
204 self.heap_bytes = self.heap_bytes.max(other.heap_bytes);
205 self.memory_bytes = self.memory_bytes.max(other.memory_bytes);
206 }
207}
208
209#[derive(Clone, Debug, Eq, PartialEq)]
211pub struct RawBenchmarkEvent {
212 pub prefix: String,
214 pub suite: String,
216 pub span_label: String,
218 pub kind: BenchmarkEventKind,
220 pub counters: BenchmarkCounters,
222 pub source_line: usize,
224 pub source: BenchmarkEventSource,
226}
227
228#[derive(Clone, Debug, Eq, PartialEq)]
230pub struct MalformedBenchmarkMarker {
231 pub source_line: usize,
233 pub source: BenchmarkEventSource,
235 pub line: String,
237 pub reason: String,
239}
240
241#[derive(Clone, Debug, Default, Eq, PartialEq)]
243pub struct BenchmarkParseReport {
244 pub events: Vec<RawBenchmarkEvent>,
246 pub malformed_markers: Vec<MalformedBenchmarkMarker>,
248 pub ignored_line_count: usize,
250}
251
252#[derive(Clone, Debug, Eq, PartialEq)]
254pub struct BenchmarkSpan {
255 pub suite: String,
257 pub span_label: String,
259 pub start_line: usize,
261 pub end_line: usize,
263 pub end: BenchmarkCounters,
265 pub delta: BenchmarkCounters,
267}
268
269#[derive(Clone, Debug, Eq, PartialEq)]
271pub struct InvalidBenchmarkSpan {
272 pub start: RawBenchmarkEvent,
274 pub end: RawBenchmarkEvent,
276}
277
278#[derive(Clone, Debug, Default, Eq, PartialEq)]
280pub struct BenchmarkSpanReport {
281 pub spans: Vec<BenchmarkSpan>,
283 pub unpaired_markers: Vec<RawBenchmarkEvent>,
285 pub invalid_spans: Vec<InvalidBenchmarkSpan>,
287}
288
289#[derive(Clone, Debug, Eq, PartialEq)]
291pub struct BenchmarkAggregateRow {
292 pub span_label: String,
294 pub runs: u64,
296 pub total: BenchmarkCounters,
298 pub min: BenchmarkCounters,
300 pub max: BenchmarkCounters,
302 pub peak_end: BenchmarkCounters,
304 pub scope: BenchmarkScope,
306}
307
308#[derive(Clone, Copy, Debug, Eq, PartialEq)]
313pub struct BenchmarkAverages {
314 pub scale: u64,
316 pub instructions: u128,
318 pub heap_bytes: u128,
320 pub memory_bytes: u128,
322}
323
324#[derive(Clone, Copy, Debug, Eq, PartialEq)]
326pub enum BenchmarkCounter {
327 Runs,
329 Instructions,
331 HeapBytes,
333 MemoryBytes,
335}
336
337#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
339#[error("benchmark aggregate overflow: scope={scope:?}, span={span_label}, counter={counter:?}")]
340pub struct BenchmarkAggregateError {
341 pub span_label: String,
343 pub counter: BenchmarkCounter,
345 pub scope: BenchmarkScope,
347}
348
349#[derive(Clone, Copy, Debug, Eq, PartialEq)]
351pub struct BenchmarkMeasurement {
352 pub runs: u64,
354 pub average: BenchmarkAverages,
356}
357
358#[derive(Clone, Debug, Eq, PartialEq)]
360pub struct BenchmarkComparisonRow {
361 pub span_label: String,
363 pub current: Option<BenchmarkMeasurement>,
365 pub previous: Option<BenchmarkMeasurement>,
367 pub scope: BenchmarkScope,
369}
370
371impl BenchmarkAggregateRow {
372 pub fn average(&self, scale: u64) -> Result<BenchmarkAverages, BenchmarkAverageError> {
377 Ok(BenchmarkAverages {
378 scale,
379 instructions: checked_scaled_average(self.total.instructions, self.runs, scale)?,
380 heap_bytes: checked_scaled_average(self.total.heap_bytes, self.runs, scale)?,
381 memory_bytes: checked_scaled_average(self.total.memory_bytes, self.runs, scale)?,
382 })
383 }
384}
385
386#[must_use]
391pub fn format_marker(prefix: &str, label: &str, counters: BenchmarkCounters) -> String {
392 format!(
393 "{}|{}|{}|{}|{}",
394 prefix, label, counters.instructions, counters.heap_bytes, counters.memory_bytes
395 )
396}
397
398#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
400pub enum BenchmarkMarkerError {
401 #[error("invalid benchmark marker prefix")]
403 InvalidPrefix,
404 #[error("invalid benchmark marker label")]
406 InvalidLabel,
407}
408
409pub fn try_format_marker(
418 prefix: &str,
419 label: &str,
420 counters: BenchmarkCounters,
421) -> Result<String, BenchmarkMarkerError> {
422 if prefix.is_empty() || prefix.contains(['|', '\r', '\n']) {
423 return Err(BenchmarkMarkerError::InvalidPrefix);
424 }
425 if label.contains(['|', '\r', '\n']) || split_label_kind(label).is_none() {
426 return Err(BenchmarkMarkerError::InvalidLabel);
427 }
428 Ok(format_marker(prefix, label, counters))
429}
430
431#[must_use]
433pub fn parse_benchmark_events(input: &str, config: &BenchmarkParserConfig) -> BenchmarkParseReport {
434 parse_benchmark_events_from_source(input, config, BenchmarkEventSource::Unknown)
435}
436
437#[must_use]
439pub fn parse_benchmark_events_from_source(
440 input: &str,
441 config: &BenchmarkParserConfig,
442 source: BenchmarkEventSource,
443) -> BenchmarkParseReport {
444 let mut report = BenchmarkParseReport::default();
445 append_benchmark_events(&mut report, input, config, source);
446 report
447}
448
449fn append_benchmark_events(
450 report: &mut BenchmarkParseReport,
451 input: &str,
452 config: &BenchmarkParserConfig,
453 source: BenchmarkEventSource,
454) {
455 for (index, line) in input.lines().enumerate() {
456 let source_line = index + 1;
457 let Some(prefix) = configured_prefix(line, &config.prefixes) else {
458 if config.strict && !line.trim().is_empty() {
459 report.malformed_markers.push(malformed(
460 source_line,
461 source,
462 line,
463 "line does not use a configured marker prefix",
464 ));
465 } else {
466 report.ignored_line_count += 1;
467 }
468 continue;
469 };
470
471 match parse_marker_line(line, prefix, source_line, source, &config.suite_derivation) {
472 Ok(event) => report.events.push(event),
473 Err(marker) => report.malformed_markers.push(marker),
474 }
475 }
476}
477
478#[must_use]
484pub fn parse_benchmark_events_from_captured_output(
485 stdout: &str,
486 stderr: &str,
487 config: &BenchmarkParserConfig,
488) -> BenchmarkParseReport {
489 let mut report = BenchmarkParseReport::default();
490 append_benchmark_events(&mut report, stdout, config, BenchmarkEventSource::Stdout);
491 append_benchmark_events(&mut report, stderr, config, BenchmarkEventSource::Stderr);
492 report
493}
494
495#[must_use]
497pub fn pair_benchmark_spans(events: &[RawBenchmarkEvent]) -> BenchmarkSpanReport {
498 pair_spans(events, |event| (&event.suite, &event.span_label))
499}
500
501#[must_use]
509pub fn pair_benchmark_spans_by_origin(events: &[RawBenchmarkEvent]) -> BenchmarkSpanReport {
510 pair_spans(events, |event| {
511 (
512 &event.prefix,
513 event.source.as_str(),
514 &event.suite,
515 &event.span_label,
516 )
517 })
518}
519
520fn pair_spans<'a, K: Ord>(
521 events: &'a [RawBenchmarkEvent],
522 key: impl Fn(&'a RawBenchmarkEvent) -> K,
523) -> BenchmarkSpanReport {
524 let mut report = BenchmarkSpanReport::default();
525 let mut open_starts = BTreeMap::<_, Vec<&RawBenchmarkEvent>>::new();
526
527 for event in events {
528 let key = key(event);
529 match event.kind {
530 BenchmarkEventKind::Start => open_starts.entry(key).or_default().push(event),
531 BenchmarkEventKind::End => match open_starts.entry(key) {
532 Entry::Occupied(mut entry) => {
533 let start = entry
534 .get_mut()
535 .pop()
536 .expect("open marker stacks must remain nonempty");
537 if entry.get().is_empty() {
538 entry.remove();
539 }
540 push_paired_span(&mut report, start, event);
541 }
542 Entry::Vacant(_) => report.unpaired_markers.push(event.clone()),
543 },
544 }
545 }
546
547 for starts in open_starts.into_values() {
548 for event in starts {
549 report.unpaired_markers.push(event.clone());
550 }
551 }
552
553 report
554}
555
556pub fn aggregate_benchmark_spans(
562 spans: &[BenchmarkSpan],
563) -> Result<Vec<BenchmarkAggregateRow>, BenchmarkAggregateError> {
564 let mut rows = BTreeMap::new();
565
566 for span in spans {
567 add_span_to_aggregate(&mut rows, AggregateScope::Suite(&span.suite), span)?;
568 add_span_to_aggregate(&mut rows, AggregateScope::All, span)?;
569 }
570
571 Ok(rows
572 .into_iter()
573 .map(|((scope, span_label), builder)| {
574 builder.finish(scope.into_owned(), span_label.to_owned())
575 })
576 .collect())
577}
578
579pub fn compare_benchmark_aggregates(
584 current: &[BenchmarkAggregateRow],
585 previous: &[BenchmarkAggregateRow],
586 scale: u64,
587) -> Result<Vec<BenchmarkComparisonRow>, BenchmarkComparisonError> {
588 let mut rows = BTreeMap::new();
589 for row in current {
590 if rows
591 .insert((&row.scope, row.span_label.as_str()), (Some(row), None))
592 .is_some()
593 {
594 return Err(BenchmarkComparisonError::DuplicateCurrent {
595 scope: row.scope.clone(),
596 span_label: row.span_label.clone(),
597 });
598 }
599 }
600 for row in previous {
601 let previous = &mut rows
602 .entry((&row.scope, row.span_label.as_str()))
603 .or_insert((None, None))
604 .1;
605 if previous.replace(row).is_some() {
606 return Err(BenchmarkComparisonError::DuplicatePrevious {
607 scope: row.scope.clone(),
608 span_label: row.span_label.clone(),
609 });
610 }
611 }
612 let measurement = |row: &BenchmarkAggregateRow| -> Result<_, BenchmarkAverageError> {
613 Ok(BenchmarkMeasurement {
614 runs: row.runs,
615 average: row.average(scale)?,
616 })
617 };
618
619 rows.into_iter()
620 .map(|((scope, span_label), (current_row, previous_row))| {
621 Ok(BenchmarkComparisonRow {
622 span_label: span_label.to_owned(),
623 current: current_row.map(measurement).transpose()?,
624 previous: previous_row.map(measurement).transpose()?,
625 scope: scope.clone(),
626 })
627 })
628 .collect()
629}
630
631fn parse_marker_line(
632 line: &str,
633 admitted_prefix: &str,
634 source_line: usize,
635 source: BenchmarkEventSource,
636 suite_derivation: &SuiteDerivation,
637) -> Result<RawBenchmarkEvent, MalformedBenchmarkMarker> {
638 let invalid = |reason: String| malformed(source_line, source, line, reason);
639 let parse_counter = |value: &str, name: &str| {
640 if value.is_empty() {
641 return Err(invalid(format!("{name} counter is empty")));
642 }
643 value
644 .parse::<u128>()
645 .map_err(|_| invalid(format!("{name} counter is not an unsigned integer")))
646 };
647 let mut parts = line.split('|');
648 let columns: [Option<&str>; 6] = std::array::from_fn(|_| parts.next());
651 let [
652 Some(prefix),
653 Some(label),
654 Some(instructions),
655 Some(heap_bytes),
656 Some(memory_bytes),
657 None,
658 ] = columns
659 else {
660 return Err(invalid("expected five pipe-separated columns".into()));
661 };
662
663 if prefix != admitted_prefix {
666 return Err(invalid("prefix is not configured".into()));
667 }
668
669 if label.is_empty() {
670 return Err(invalid("label is empty".into()));
671 }
672
673 let (span_label, kind) = split_label_kind(label)
674 .ok_or_else(|| invalid("label must end in :start or :end".into()))?;
675
676 let counters = BenchmarkCounters {
677 instructions: parse_counter(instructions, "instructions")?,
678 heap_bytes: parse_counter(heap_bytes, "heap_bytes")?,
679 memory_bytes: parse_counter(memory_bytes, "memory_bytes")?,
680 };
681 let suite = suite_derivation.derive_suite(span_label);
682
683 Ok(RawBenchmarkEvent {
684 prefix: prefix.to_string(),
685 suite,
686 span_label: span_label.to_string(),
687 kind,
688 counters,
689 source_line,
690 source,
691 })
692}
693
694fn split_label_kind(label: &str) -> Option<(&str, BenchmarkEventKind)> {
695 label
696 .strip_suffix(":start")
697 .map(|span| (span, BenchmarkEventKind::Start))
698 .or_else(|| {
699 label
700 .strip_suffix(":end")
701 .map(|span| (span, BenchmarkEventKind::End))
702 })
703 .filter(|(span, _)| !span.is_empty())
704}
705
706fn configured_prefix<'a>(line: &str, prefixes: &'a [String]) -> Option<&'a str> {
707 let (field, _) = line.split_once('|')?;
708 prefixes
709 .iter()
710 .find(|known| known.as_str() == field)
711 .or_else(|| {
712 prefixes.iter().find(|known| {
713 line.strip_prefix(known.as_str())
714 .is_some_and(|rest| rest.starts_with('|'))
715 })
716 })
717 .map(String::as_str)
718}
719
720fn malformed(
721 source_line: usize,
722 source: BenchmarkEventSource,
723 line: &str,
724 reason: impl Into<String>,
725) -> MalformedBenchmarkMarker {
726 MalformedBenchmarkMarker {
727 source_line,
728 source,
729 line: line.to_string(),
730 reason: reason.into(),
731 }
732}
733
734fn push_paired_span(
735 report: &mut BenchmarkSpanReport,
736 start: &RawBenchmarkEvent,
737 end: &RawBenchmarkEvent,
738) {
739 if let Some(delta) = end.counters.checked_delta(start.counters) {
740 report.spans.push(BenchmarkSpan {
741 suite: start.suite.clone(),
742 span_label: start.span_label.clone(),
743 start_line: start.source_line,
744 end_line: end.source_line,
745 end: end.counters,
746 delta,
747 });
748 } else {
749 report.invalid_spans.push(InvalidBenchmarkSpan {
750 start: start.clone(),
751 end: end.clone(),
752 });
753 }
754}
755
756#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)]
759enum AggregateScope<'a> {
760 Suite(&'a str),
761 All,
762}
763
764impl AggregateScope<'_> {
765 fn into_owned(self) -> BenchmarkScope {
766 match self {
767 Self::Suite(suite) => BenchmarkScope::Suite(suite.to_owned()),
768 Self::All => BenchmarkScope::All,
769 }
770 }
771}
772
773#[derive(Clone, Debug)]
774struct AggregateBuilder {
775 runs: u64,
776 total: BenchmarkCounters,
777 min: BenchmarkCounters,
778 max: BenchmarkCounters,
779 peak_end: BenchmarkCounters,
780}
781
782impl AggregateBuilder {
783 const fn new(span: &BenchmarkSpan) -> Self {
784 Self {
785 runs: 1,
786 total: span.delta,
787 min: span.delta,
788 max: span.delta,
789 peak_end: span.end,
790 }
791 }
792
793 fn push(&mut self, span: &BenchmarkSpan) -> Result<(), BenchmarkCounter> {
794 let runs = self.runs.checked_add(1).ok_or(BenchmarkCounter::Runs)?;
795 let total = self.total.checked_add(span.delta)?;
796 self.runs = runs;
797 self.total = total;
798 self.min.min_assign(span.delta);
799 self.max.max_assign(span.delta);
800 self.peak_end.max_assign(span.end);
801 Ok(())
802 }
803
804 const fn finish(self, scope: BenchmarkScope, span_label: String) -> BenchmarkAggregateRow {
805 BenchmarkAggregateRow {
806 span_label,
807 runs: self.runs,
808 total: self.total,
809 min: self.min,
810 max: self.max,
811 peak_end: self.peak_end,
812 scope,
813 }
814 }
815}
816
817fn add_span_to_aggregate<'a>(
818 rows: &mut BTreeMap<(AggregateScope<'a>, &'a str), AggregateBuilder>,
819 scope: AggregateScope<'a>,
820 span: &'a BenchmarkSpan,
821) -> Result<(), BenchmarkAggregateError> {
822 match rows.entry((scope, span.span_label.as_str())) {
823 Entry::Occupied(mut entry) => entry.get_mut().push(span).map_err(|counter| {
824 let (scope, span_label) = entry.key();
825 BenchmarkAggregateError {
826 span_label: (*span_label).to_owned(),
827 counter,
828 scope: scope.into_owned(),
829 }
830 }),
831 Entry::Vacant(entry) => {
832 entry.insert(AggregateBuilder::new(span));
833 Ok(())
834 }
835 }
836}
837
838#[cfg(test)]
839mod tests {
840 use super::{AggregateBuilder, BenchmarkCounter, BenchmarkCounters, BenchmarkSpan};
841
842 #[test]
843 fn aggregate_run_count_overflow_is_rejected_without_changing_totals() {
844 let span = BenchmarkSpan {
845 suite: "probe".into(),
846 span_label: "probe/run".into(),
847 start_line: 1,
848 end_line: 2,
849 end: BenchmarkCounters::default(),
850 delta: BenchmarkCounters::default(),
851 };
852 let mut builder = AggregateBuilder::new(&span);
853 builder.runs = u64::MAX;
854 assert_eq!(builder.push(&span), Err(BenchmarkCounter::Runs));
855 assert_eq!(builder.runs, u64::MAX);
856 assert_eq!(builder.total, BenchmarkCounters::default());
857 }
858}