1use std::collections::{BTreeMap, btree_map::Entry};
16
17use ic_metrics::{MeasurementRatioError, checked_scaled_ratio};
18
19#[cfg(not(target_arch = "wasm32"))]
20pub mod report;
21
22pub const DEFAULT_PREFIX: &str = "ICTK";
24
25#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
27pub enum BenchmarkScope {
28 Suite(String),
30 All,
32}
33
34#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct BenchmarkParserConfig {
36 pub prefixes: Vec<String>,
37 pub suite_derivation: SuiteDerivation,
38 pub strict: bool,
41}
42
43impl Default for BenchmarkParserConfig {
44 fn default() -> Self {
45 Self {
46 prefixes: vec![DEFAULT_PREFIX.to_string()],
47 suite_derivation: SuiteDerivation::FirstPathSegment,
48 strict: false,
49 }
50 }
51}
52
53#[derive(Clone, Debug, Eq, PartialEq)]
54pub enum SuiteDerivation {
55 FirstPathSegment,
56 Fixed(String),
57}
58
59impl SuiteDerivation {
60 #[must_use]
61 pub fn derive_suite(&self, span_label: &str) -> String {
62 match self {
63 Self::FirstPathSegment => span_label
64 .split('/')
65 .next()
66 .filter(|part| !part.is_empty())
67 .unwrap_or(span_label)
68 .to_string(),
69 Self::Fixed(suite) => suite.clone(),
70 }
71 }
72}
73
74#[derive(Clone, Copy, Debug, Eq, PartialEq)]
75pub enum BenchmarkEventKind {
76 Start,
77 End,
78}
79
80#[derive(Clone, Copy, Debug, Eq, PartialEq)]
81pub enum BenchmarkEventSource {
82 Unknown,
83 Stdout,
84 Stderr,
85 FetchedLog,
86}
87
88impl BenchmarkEventSource {
89 #[must_use]
90 pub const fn as_str(self) -> &'static str {
91 match self {
92 Self::Unknown => "unknown",
93 Self::Stdout => "stdout",
94 Self::Stderr => "stderr",
95 Self::FetchedLog => "fetched_log",
96 }
97 }
98}
99
100#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
101pub struct BenchmarkCounters {
102 pub instructions: u128,
103 pub heap_bytes: u128,
104 pub memory_bytes: u128,
105 pub total_allocation: u128,
106}
107
108impl BenchmarkCounters {
109 fn checked_delta(self, start: Self) -> Option<Self> {
110 Some(Self {
111 instructions: self.instructions.checked_sub(start.instructions)?,
112 heap_bytes: self.heap_bytes.checked_sub(start.heap_bytes)?,
113 memory_bytes: self.memory_bytes.checked_sub(start.memory_bytes)?,
114 total_allocation: self.total_allocation.checked_sub(start.total_allocation)?,
115 })
116 }
117
118 fn checked_add(self, other: Self) -> Result<Self, BenchmarkCounter> {
119 Ok(Self {
120 instructions: self
121 .instructions
122 .checked_add(other.instructions)
123 .ok_or(BenchmarkCounter::Instructions)?,
124 heap_bytes: self
125 .heap_bytes
126 .checked_add(other.heap_bytes)
127 .ok_or(BenchmarkCounter::HeapBytes)?,
128 memory_bytes: self
129 .memory_bytes
130 .checked_add(other.memory_bytes)
131 .ok_or(BenchmarkCounter::MemoryBytes)?,
132 total_allocation: self
133 .total_allocation
134 .checked_add(other.total_allocation)
135 .ok_or(BenchmarkCounter::TotalAllocation)?,
136 })
137 }
138
139 fn min_assign(&mut self, other: Self) {
140 self.instructions = self.instructions.min(other.instructions);
141 self.heap_bytes = self.heap_bytes.min(other.heap_bytes);
142 self.memory_bytes = self.memory_bytes.min(other.memory_bytes);
143 self.total_allocation = self.total_allocation.min(other.total_allocation);
144 }
145
146 fn max_assign(&mut self, other: Self) {
147 self.instructions = self.instructions.max(other.instructions);
148 self.heap_bytes = self.heap_bytes.max(other.heap_bytes);
149 self.memory_bytes = self.memory_bytes.max(other.memory_bytes);
150 self.total_allocation = self.total_allocation.max(other.total_allocation);
151 }
152}
153
154#[derive(Clone, Debug, Eq, PartialEq)]
155pub struct RawBenchmarkEvent {
156 pub prefix: String,
157 pub label: String,
158 pub suite: String,
159 pub span_label: String,
160 pub kind: BenchmarkEventKind,
161 pub counters: BenchmarkCounters,
162 pub source_line: usize,
163 pub source: BenchmarkEventSource,
164}
165
166#[derive(Clone, Debug, Eq, PartialEq)]
167pub struct MalformedBenchmarkMarker {
168 pub source_line: usize,
169 pub source: BenchmarkEventSource,
170 pub line: String,
171 pub reason: String,
172}
173
174#[derive(Clone, Debug, Default, Eq, PartialEq)]
175pub struct BenchmarkParseReport {
176 pub events: Vec<RawBenchmarkEvent>,
177 pub malformed_markers: Vec<MalformedBenchmarkMarker>,
178 pub ignored_line_count: usize,
179}
180
181#[derive(Clone, Debug, Eq, PartialEq)]
182pub struct BenchmarkSpan {
183 pub suite: String,
184 pub span_label: String,
185 pub start_line: usize,
186 pub end_line: usize,
187 pub start: BenchmarkCounters,
188 pub end: BenchmarkCounters,
189 pub delta: BenchmarkCounters,
190}
191
192#[derive(Clone, Debug, Eq, PartialEq)]
193pub enum UnpairedBenchmarkMarkerKind {
194 Start,
195 End,
196}
197
198#[derive(Clone, Debug, Eq, PartialEq)]
199pub struct UnpairedBenchmarkMarker {
200 pub event: RawBenchmarkEvent,
201 pub kind: UnpairedBenchmarkMarkerKind,
202}
203
204#[derive(Clone, Debug, Eq, PartialEq)]
205pub struct InvalidBenchmarkSpan {
206 pub start: RawBenchmarkEvent,
207 pub end: RawBenchmarkEvent,
208 pub reason: String,
209}
210
211#[derive(Clone, Debug, Default, Eq, PartialEq)]
212pub struct BenchmarkSpanReport {
213 pub spans: Vec<BenchmarkSpan>,
214 pub unpaired_markers: Vec<UnpairedBenchmarkMarker>,
215 pub invalid_spans: Vec<InvalidBenchmarkSpan>,
216}
217
218#[derive(Clone, Debug, Eq, PartialEq)]
219pub struct BenchmarkAggregateRow {
220 pub span_label: String,
221 pub runs: u64,
222 pub total: BenchmarkCounters,
223 pub min: BenchmarkCounters,
224 pub max: BenchmarkCounters,
225 pub peak_end: BenchmarkCounters,
226 pub scope: BenchmarkScope,
228}
229
230#[derive(Clone, Copy, Debug, Eq, PartialEq)]
235pub struct BenchmarkAverages {
236 pub scale: u64,
237 pub instructions: u128,
238 pub heap_bytes: u128,
239 pub memory_bytes: u128,
240 pub total_allocation: u128,
241}
242
243#[derive(Clone, Debug, Default, Eq, PartialEq)]
244pub struct BenchmarkAggregateReport {
245 pub rows: Vec<BenchmarkAggregateRow>,
246}
247
248#[derive(Clone, Copy, Debug, Eq, PartialEq)]
250pub enum BenchmarkCounter {
251 Runs,
252 Instructions,
253 HeapBytes,
254 MemoryBytes,
255 TotalAllocation,
256}
257
258#[derive(Clone, Debug, Eq, PartialEq)]
260pub struct BenchmarkAggregateError {
261 pub span_label: String,
262 pub counter: BenchmarkCounter,
263 pub scope: BenchmarkScope,
265}
266
267impl std::fmt::Display for BenchmarkAggregateError {
268 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269 write!(
270 formatter,
271 "benchmark aggregate overflow: scope={:?}, span={}, counter={:?}",
272 self.scope, self.span_label, self.counter
273 )
274 }
275}
276
277impl std::error::Error for BenchmarkAggregateError {}
278
279#[derive(Clone, Debug, Eq, PartialEq)]
281pub struct BenchmarkComparisonRow {
282 pub span_label: String,
283 pub current_runs: Option<u64>,
284 pub previous_runs: Option<u64>,
285 pub current_average: Option<BenchmarkAverages>,
286 pub previous_average: Option<BenchmarkAverages>,
287 pub scope: BenchmarkScope,
289}
290
291impl BenchmarkAggregateRow {
292 pub fn average(&self, scale: u64) -> Result<BenchmarkAverages, MeasurementRatioError> {
295 Ok(BenchmarkAverages {
296 scale,
297 instructions: checked_scaled_ratio(self.total.instructions, self.runs, scale)?,
298 heap_bytes: checked_scaled_ratio(self.total.heap_bytes, self.runs, scale)?,
299 memory_bytes: checked_scaled_ratio(self.total.memory_bytes, self.runs, scale)?,
300 total_allocation: checked_scaled_ratio(self.total.total_allocation, self.runs, scale)?,
301 })
302 }
303}
304
305#[derive(Clone, Debug, Default, Eq, PartialEq)]
306pub struct BenchmarkComparisonReport {
307 pub rows: Vec<BenchmarkComparisonRow>,
308}
309
310#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
311pub struct BenchmarkRunMetadata {
312 pub timestamp: String,
313 pub run_directory_name: String,
314 pub run_index: u32,
315 pub git_commit_hash: Option<String>,
316 pub git_commit_short_hash: Option<String>,
317 pub ic_testkit_version: String,
318 pub pocket_ic_version: String,
319 pub rustc_version: String,
320 pub benchmark_command: Option<String>,
321 pub selected_previous_run: Option<String>,
322}
323
324#[derive(Clone, Debug, Eq, PartialEq)]
325pub struct BenchmarkRunReport {
326 pub parse: BenchmarkParseReport,
327 pub spans: BenchmarkSpanReport,
328 pub aggregates: BenchmarkAggregateReport,
329 pub comparison: Option<BenchmarkComparisonReport>,
330 pub metadata: BenchmarkRunMetadata,
331}
332
333#[must_use]
334pub fn format_marker(prefix: &str, label: &str, counters: BenchmarkCounters) -> String {
335 format!(
336 "{}|{}|{}|{}|{}|{}",
337 prefix,
338 label,
339 counters.instructions,
340 counters.heap_bytes,
341 counters.memory_bytes,
342 counters.total_allocation
343 )
344}
345
346#[must_use]
347pub fn parse_benchmark_events(input: &str, config: &BenchmarkParserConfig) -> BenchmarkParseReport {
348 parse_benchmark_events_from_source(input, config, BenchmarkEventSource::Unknown)
349}
350
351#[must_use]
352pub fn parse_benchmark_events_from_source(
353 input: &str,
354 config: &BenchmarkParserConfig,
355 source: BenchmarkEventSource,
356) -> BenchmarkParseReport {
357 let mut report = BenchmarkParseReport::default();
358 append_benchmark_events(&mut report, input, config, source);
359 report
360}
361
362fn append_benchmark_events(
363 report: &mut BenchmarkParseReport,
364 input: &str,
365 config: &BenchmarkParserConfig,
366 source: BenchmarkEventSource,
367) {
368 for (index, line) in input.lines().enumerate() {
369 let source_line = index + 1;
370 if !has_configured_prefix(line, &config.prefixes) {
371 if config.strict && !line.trim().is_empty() {
372 report.malformed_markers.push(malformed(
373 source_line,
374 source,
375 line,
376 "line does not use a configured marker prefix",
377 ));
378 } else {
379 report.ignored_line_count += 1;
380 }
381 continue;
382 }
383
384 match parse_marker_line(line, source_line, source, config) {
385 Ok(event) => report.events.push(event),
386 Err(marker) => report.malformed_markers.push(marker),
387 }
388 }
389}
390
391#[must_use]
397pub fn parse_benchmark_events_from_captured_output(
398 stdout: &str,
399 stderr: &str,
400 config: &BenchmarkParserConfig,
401) -> BenchmarkParseReport {
402 let mut report = BenchmarkParseReport::default();
403 append_benchmark_events(&mut report, stdout, config, BenchmarkEventSource::Stdout);
404 append_benchmark_events(&mut report, stderr, config, BenchmarkEventSource::Stderr);
405 report
406}
407
408#[must_use]
409pub fn pair_benchmark_spans(events: &[RawBenchmarkEvent]) -> BenchmarkSpanReport {
410 let mut report = BenchmarkSpanReport::default();
411 let mut open_starts: BTreeMap<(&str, &str), Vec<&RawBenchmarkEvent>> = BTreeMap::new();
412
413 for event in events {
414 let key = (event.suite.as_str(), event.span_label.as_str());
415 match event.kind {
416 BenchmarkEventKind::Start => open_starts.entry(key).or_default().push(event),
417 BenchmarkEventKind::End => match open_starts.entry(key) {
418 Entry::Occupied(mut entry) => {
419 let start = entry
420 .get_mut()
421 .pop()
422 .expect("open marker stacks must remain nonempty");
423 if entry.get().is_empty() {
424 entry.remove();
425 }
426 push_paired_span(&mut report, start, event);
427 }
428 Entry::Vacant(_) => report.unpaired_markers.push(UnpairedBenchmarkMarker {
429 event: event.clone(),
430 kind: UnpairedBenchmarkMarkerKind::End,
431 }),
432 },
433 }
434 }
435
436 for starts in open_starts.into_values() {
437 for event in starts {
438 report.unpaired_markers.push(UnpairedBenchmarkMarker {
439 event: event.clone(),
440 kind: UnpairedBenchmarkMarkerKind::Start,
441 });
442 }
443 }
444
445 report
446}
447
448pub fn aggregate_benchmark_spans(
453 spans: &[BenchmarkSpan],
454) -> Result<BenchmarkAggregateReport, BenchmarkAggregateError> {
455 let mut rows: BTreeMap<(BenchmarkScope, &str), AggregateBuilder> = BTreeMap::new();
456
457 for span in spans {
458 add_span_to_aggregate(&mut rows, BenchmarkScope::Suite(span.suite.clone()), span)?;
459 add_span_to_aggregate(&mut rows, BenchmarkScope::All, span)?;
460 }
461
462 Ok(BenchmarkAggregateReport {
463 rows: rows
464 .into_iter()
465 .map(|((scope, span_label), builder)| builder.finish(scope, span_label.to_owned()))
466 .collect(),
467 })
468}
469
470pub fn compare_benchmark_aggregates(
473 current: &[BenchmarkAggregateRow],
474 previous: &[BenchmarkAggregateRow],
475 scale: u64,
476) -> Result<BenchmarkComparisonReport, MeasurementRatioError> {
477 let mut rows = BTreeMap::new();
478 for row in current {
479 rows.insert((&row.scope, row.span_label.as_str()), (Some(row), None));
480 }
481 for row in previous {
482 rows.entry((&row.scope, row.span_label.as_str()))
483 .or_insert((None, None))
484 .1 = Some(row);
485 }
486
487 Ok(BenchmarkComparisonReport {
488 rows: rows
489 .into_iter()
490 .map(|((scope, span_label), (current_row, previous_row))| {
491 Ok(BenchmarkComparisonRow {
492 span_label: span_label.to_owned(),
493 current_runs: current_row.map(|row| row.runs),
494 previous_runs: previous_row.map(|row| row.runs),
495 current_average: current_row.map(|row| row.average(scale)).transpose()?,
496 previous_average: previous_row.map(|row| row.average(scale)).transpose()?,
497 scope: scope.clone(),
498 })
499 })
500 .collect::<Result<_, MeasurementRatioError>>()?,
501 })
502}
503
504fn parse_marker_line(
505 line: &str,
506 source_line: usize,
507 source: BenchmarkEventSource,
508 config: &BenchmarkParserConfig,
509) -> Result<RawBenchmarkEvent, MalformedBenchmarkMarker> {
510 let mut parts = line.split('|');
511 let columns: [Option<&str>; 7] = std::array::from_fn(|_| parts.next());
514 let [
515 Some(prefix),
516 Some(label),
517 Some(instructions),
518 Some(heap_bytes),
519 Some(memory_bytes),
520 Some(total_allocation),
521 None,
522 ] = columns
523 else {
524 return Err(malformed(
525 source_line,
526 source,
527 line,
528 "expected six pipe-separated columns",
529 ));
530 };
531
532 if !config.prefixes.iter().any(|known| known == prefix) {
533 return Err(malformed(
534 source_line,
535 source,
536 line,
537 "prefix is not configured",
538 ));
539 }
540
541 if label.is_empty() {
542 return Err(malformed(source_line, source, line, "label is empty"));
543 }
544
545 let (span_label, kind) = split_label_kind(label).ok_or_else(|| {
546 malformed(
547 source_line,
548 source,
549 line,
550 "label must end in :start or :end",
551 )
552 })?;
553
554 let counters = BenchmarkCounters {
555 instructions: parse_counter(instructions, source_line, source, line, "instructions")?,
556 heap_bytes: parse_counter(heap_bytes, source_line, source, line, "heap_bytes")?,
557 memory_bytes: parse_counter(memory_bytes, source_line, source, line, "memory_bytes")?,
558 total_allocation: parse_counter(
559 total_allocation,
560 source_line,
561 source,
562 line,
563 "total_allocation",
564 )?,
565 };
566 let suite = config.suite_derivation.derive_suite(span_label);
567
568 Ok(RawBenchmarkEvent {
569 prefix: prefix.to_string(),
570 label: label.to_string(),
571 suite,
572 span_label: span_label.to_string(),
573 kind,
574 counters,
575 source_line,
576 source,
577 })
578}
579
580fn parse_counter(
581 value: &str,
582 source_line: usize,
583 source: BenchmarkEventSource,
584 line: &str,
585 name: &str,
586) -> Result<u128, MalformedBenchmarkMarker> {
587 if value.is_empty() {
588 return Err(malformed(
589 source_line,
590 source,
591 line,
592 &format!("{name} counter is empty"),
593 ));
594 }
595
596 value.parse::<u128>().map_err(|_| {
597 malformed(
598 source_line,
599 source,
600 line,
601 &format!("{name} counter is not an unsigned integer"),
602 )
603 })
604}
605
606fn split_label_kind(label: &str) -> Option<(&str, BenchmarkEventKind)> {
607 let start = label.strip_suffix(":start");
608 let end = label.strip_suffix(":end");
609
610 match (start, end) {
611 (Some(span_label), None) if !span_label.is_empty() => {
612 Some((span_label, BenchmarkEventKind::Start))
613 }
614 (None, Some(span_label)) if !span_label.is_empty() => {
615 Some((span_label, BenchmarkEventKind::End))
616 }
617 _ => None,
618 }
619}
620
621fn has_configured_prefix(line: &str, prefixes: &[String]) -> bool {
622 prefixes.iter().any(|prefix| {
623 line.strip_prefix(prefix)
624 .is_some_and(|rest| rest.starts_with('|'))
625 })
626}
627
628fn malformed(
629 source_line: usize,
630 source: BenchmarkEventSource,
631 line: &str,
632 reason: &str,
633) -> MalformedBenchmarkMarker {
634 MalformedBenchmarkMarker {
635 source_line,
636 source,
637 line: line.to_string(),
638 reason: reason.to_string(),
639 }
640}
641
642fn push_paired_span(
643 report: &mut BenchmarkSpanReport,
644 start: &RawBenchmarkEvent,
645 end: &RawBenchmarkEvent,
646) {
647 if let Some(delta) = end.counters.checked_delta(start.counters) {
648 report.spans.push(BenchmarkSpan {
649 suite: start.suite.clone(),
650 span_label: start.span_label.clone(),
651 start_line: start.source_line,
652 end_line: end.source_line,
653 start: start.counters,
654 end: end.counters,
655 delta,
656 });
657 } else {
658 report.invalid_spans.push(InvalidBenchmarkSpan {
659 start: start.clone(),
660 end: end.clone(),
661 reason: "end counter is lower than start counter".to_string(),
662 });
663 }
664}
665
666#[derive(Clone, Debug)]
667struct AggregateBuilder {
668 runs: u64,
669 total: BenchmarkCounters,
670 min: BenchmarkCounters,
671 max: BenchmarkCounters,
672 peak_end: BenchmarkCounters,
673}
674
675impl AggregateBuilder {
676 const fn new(span: &BenchmarkSpan) -> Self {
677 Self {
678 runs: 1,
679 total: span.delta,
680 min: span.delta,
681 max: span.delta,
682 peak_end: span.end,
683 }
684 }
685
686 fn push(&mut self, span: &BenchmarkSpan) -> Result<(), BenchmarkCounter> {
687 let runs = self.runs.checked_add(1).ok_or(BenchmarkCounter::Runs)?;
688 let total = self.total.checked_add(span.delta)?;
689 self.runs = runs;
690 self.total = total;
691 self.min.min_assign(span.delta);
692 self.max.max_assign(span.delta);
693 self.peak_end.max_assign(span.end);
694 Ok(())
695 }
696
697 const fn finish(self, scope: BenchmarkScope, span_label: String) -> BenchmarkAggregateRow {
698 BenchmarkAggregateRow {
699 span_label,
700 runs: self.runs,
701 total: self.total,
702 min: self.min,
703 max: self.max,
704 peak_end: self.peak_end,
705 scope,
706 }
707 }
708}
709
710fn add_span_to_aggregate<'a>(
711 rows: &mut BTreeMap<(BenchmarkScope, &'a str), AggregateBuilder>,
712 scope: BenchmarkScope,
713 span: &'a BenchmarkSpan,
714) -> Result<(), BenchmarkAggregateError> {
715 match rows.entry((scope, span.span_label.as_str())) {
716 Entry::Occupied(mut entry) => entry.get_mut().push(span).map_err(|counter| {
717 let (scope, span_label) = entry.key();
718 BenchmarkAggregateError {
719 span_label: (*span_label).to_owned(),
720 counter,
721 scope: scope.clone(),
722 }
723 }),
724 Entry::Vacant(entry) => {
725 entry.insert(AggregateBuilder::new(span));
726 Ok(())
727 }
728 }
729}
730
731#[cfg(test)]
732mod tests {
733 use super::{AggregateBuilder, BenchmarkCounter, BenchmarkCounters, BenchmarkSpan};
734
735 #[test]
736 fn aggregate_run_count_overflow_is_rejected_without_changing_totals() {
737 let span = BenchmarkSpan {
738 suite: "probe".into(),
739 span_label: "probe/run".into(),
740 start_line: 1,
741 end_line: 2,
742 start: BenchmarkCounters::default(),
743 end: BenchmarkCounters::default(),
744 delta: BenchmarkCounters::default(),
745 };
746 let mut builder = AggregateBuilder::new(&span);
747 builder.runs = u64::MAX;
748 assert_eq!(builder.push(&span), Err(BenchmarkCounter::Runs));
749 assert_eq!(builder.runs, u64::MAX);
750 assert_eq!(builder.total, BenchmarkCounters::default());
751 }
752}