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 suite: String,
158 pub span_label: String,
159 pub kind: BenchmarkEventKind,
160 pub counters: BenchmarkCounters,
161 pub source_line: usize,
162 pub source: BenchmarkEventSource,
163}
164
165#[derive(Clone, Debug, Eq, PartialEq)]
166pub struct MalformedBenchmarkMarker {
167 pub source_line: usize,
168 pub source: BenchmarkEventSource,
169 pub line: String,
170 pub reason: String,
171}
172
173#[derive(Clone, Debug, Default, Eq, PartialEq)]
174pub struct BenchmarkParseReport {
175 pub events: Vec<RawBenchmarkEvent>,
176 pub malformed_markers: Vec<MalformedBenchmarkMarker>,
177 pub ignored_line_count: usize,
178}
179
180#[derive(Clone, Debug, Eq, PartialEq)]
181pub struct BenchmarkSpan {
182 pub suite: String,
183 pub span_label: String,
184 pub start_line: usize,
185 pub end_line: usize,
186 pub end: BenchmarkCounters,
187 pub delta: BenchmarkCounters,
188}
189
190#[derive(Clone, Debug, Eq, PartialEq)]
191pub struct InvalidBenchmarkSpan {
192 pub start: RawBenchmarkEvent,
193 pub end: RawBenchmarkEvent,
194 pub reason: String,
195}
196
197#[derive(Clone, Debug, Default, Eq, PartialEq)]
198pub struct BenchmarkSpanReport {
199 pub spans: Vec<BenchmarkSpan>,
200 pub unpaired_markers: Vec<RawBenchmarkEvent>,
201 pub invalid_spans: Vec<InvalidBenchmarkSpan>,
202}
203
204#[derive(Clone, Debug, Eq, PartialEq)]
205pub struct BenchmarkAggregateRow {
206 pub span_label: String,
207 pub runs: u64,
208 pub total: BenchmarkCounters,
209 pub min: BenchmarkCounters,
210 pub max: BenchmarkCounters,
211 pub peak_end: BenchmarkCounters,
212 pub scope: BenchmarkScope,
214}
215
216#[derive(Clone, Copy, Debug, Eq, PartialEq)]
221pub struct BenchmarkAverages {
222 pub scale: u64,
223 pub instructions: u128,
224 pub heap_bytes: u128,
225 pub memory_bytes: u128,
226 pub total_allocation: u128,
227}
228
229#[derive(Clone, Debug, Default, Eq, PartialEq)]
230pub struct BenchmarkAggregateReport {
231 pub rows: Vec<BenchmarkAggregateRow>,
232}
233
234#[derive(Clone, Copy, Debug, Eq, PartialEq)]
236pub enum BenchmarkCounter {
237 Runs,
238 Instructions,
239 HeapBytes,
240 MemoryBytes,
241 TotalAllocation,
242}
243
244#[derive(Clone, Debug, Eq, PartialEq)]
246pub struct BenchmarkAggregateError {
247 pub span_label: String,
248 pub counter: BenchmarkCounter,
249 pub scope: BenchmarkScope,
251}
252
253impl std::fmt::Display for BenchmarkAggregateError {
254 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255 write!(
256 formatter,
257 "benchmark aggregate overflow: scope={:?}, span={}, counter={:?}",
258 self.scope, self.span_label, self.counter
259 )
260 }
261}
262
263impl std::error::Error for BenchmarkAggregateError {}
264
265#[derive(Clone, Copy, Debug, Eq, PartialEq)]
267pub struct BenchmarkMeasurement {
268 pub runs: u64,
269 pub average: BenchmarkAverages,
270}
271
272#[derive(Clone, Debug, Eq, PartialEq)]
274pub struct BenchmarkComparisonRow {
275 pub span_label: String,
276 pub current: Option<BenchmarkMeasurement>,
277 pub previous: Option<BenchmarkMeasurement>,
278 pub scope: BenchmarkScope,
280}
281
282impl BenchmarkAggregateRow {
283 pub fn average(&self, scale: u64) -> Result<BenchmarkAverages, MeasurementRatioError> {
286 Ok(BenchmarkAverages {
287 scale,
288 instructions: checked_scaled_ratio(self.total.instructions, self.runs, scale)?,
289 heap_bytes: checked_scaled_ratio(self.total.heap_bytes, self.runs, scale)?,
290 memory_bytes: checked_scaled_ratio(self.total.memory_bytes, self.runs, scale)?,
291 total_allocation: checked_scaled_ratio(self.total.total_allocation, self.runs, scale)?,
292 })
293 }
294}
295
296#[derive(Clone, Debug, Default, Eq, PartialEq)]
297pub struct BenchmarkComparisonReport {
298 pub rows: Vec<BenchmarkComparisonRow>,
299}
300
301#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
302pub struct BenchmarkRunMetadata {
303 pub timestamp: String,
304 pub run_directory_name: String,
305 pub run_index: u32,
306 pub git_commit_hash: Option<String>,
307 pub git_commit_short_hash: Option<String>,
308 pub ic_testkit_version: String,
309 pub pocket_ic_version: String,
310 pub rustc_version: String,
311 pub benchmark_command: Option<String>,
312 pub selected_previous_run: Option<String>,
313}
314
315#[derive(Clone, Debug, Eq, PartialEq)]
316pub struct BenchmarkRunReport {
317 pub parse: BenchmarkParseReport,
318 pub spans: BenchmarkSpanReport,
319 pub aggregates: BenchmarkAggregateReport,
320 pub comparison: Option<BenchmarkComparisonReport>,
321 pub metadata: BenchmarkRunMetadata,
322}
323
324#[must_use]
325pub fn format_marker(prefix: &str, label: &str, counters: BenchmarkCounters) -> String {
326 format!(
327 "{}|{}|{}|{}|{}|{}",
328 prefix,
329 label,
330 counters.instructions,
331 counters.heap_bytes,
332 counters.memory_bytes,
333 counters.total_allocation
334 )
335}
336
337#[must_use]
338pub fn parse_benchmark_events(input: &str, config: &BenchmarkParserConfig) -> BenchmarkParseReport {
339 parse_benchmark_events_from_source(input, config, BenchmarkEventSource::Unknown)
340}
341
342#[must_use]
343pub fn parse_benchmark_events_from_source(
344 input: &str,
345 config: &BenchmarkParserConfig,
346 source: BenchmarkEventSource,
347) -> BenchmarkParseReport {
348 let mut report = BenchmarkParseReport::default();
349 append_benchmark_events(&mut report, input, config, source);
350 report
351}
352
353fn append_benchmark_events(
354 report: &mut BenchmarkParseReport,
355 input: &str,
356 config: &BenchmarkParserConfig,
357 source: BenchmarkEventSource,
358) {
359 for (index, line) in input.lines().enumerate() {
360 let source_line = index + 1;
361 if !has_configured_prefix(line, &config.prefixes) {
362 if config.strict && !line.trim().is_empty() {
363 report.malformed_markers.push(malformed(
364 source_line,
365 source,
366 line,
367 "line does not use a configured marker prefix",
368 ));
369 } else {
370 report.ignored_line_count += 1;
371 }
372 continue;
373 }
374
375 match parse_marker_line(line, source_line, source, config) {
376 Ok(event) => report.events.push(event),
377 Err(marker) => report.malformed_markers.push(marker),
378 }
379 }
380}
381
382#[must_use]
388pub fn parse_benchmark_events_from_captured_output(
389 stdout: &str,
390 stderr: &str,
391 config: &BenchmarkParserConfig,
392) -> BenchmarkParseReport {
393 let mut report = BenchmarkParseReport::default();
394 append_benchmark_events(&mut report, stdout, config, BenchmarkEventSource::Stdout);
395 append_benchmark_events(&mut report, stderr, config, BenchmarkEventSource::Stderr);
396 report
397}
398
399#[must_use]
400pub fn pair_benchmark_spans(events: &[RawBenchmarkEvent]) -> BenchmarkSpanReport {
401 let mut report = BenchmarkSpanReport::default();
402 let mut open_starts: BTreeMap<(&str, &str), Vec<&RawBenchmarkEvent>> = BTreeMap::new();
403
404 for event in events {
405 let key = (event.suite.as_str(), event.span_label.as_str());
406 match event.kind {
407 BenchmarkEventKind::Start => open_starts.entry(key).or_default().push(event),
408 BenchmarkEventKind::End => match open_starts.entry(key) {
409 Entry::Occupied(mut entry) => {
410 let start = entry
411 .get_mut()
412 .pop()
413 .expect("open marker stacks must remain nonempty");
414 if entry.get().is_empty() {
415 entry.remove();
416 }
417 push_paired_span(&mut report, start, event);
418 }
419 Entry::Vacant(_) => report.unpaired_markers.push(event.clone()),
420 },
421 }
422 }
423
424 for starts in open_starts.into_values() {
425 for event in starts {
426 report.unpaired_markers.push(event.clone());
427 }
428 }
429
430 report
431}
432
433pub fn aggregate_benchmark_spans(
438 spans: &[BenchmarkSpan],
439) -> Result<BenchmarkAggregateReport, BenchmarkAggregateError> {
440 let mut rows: BTreeMap<(BenchmarkScope, &str), AggregateBuilder> = BTreeMap::new();
441
442 for span in spans {
443 add_span_to_aggregate(&mut rows, BenchmarkScope::Suite(span.suite.clone()), span)?;
444 add_span_to_aggregate(&mut rows, BenchmarkScope::All, span)?;
445 }
446
447 Ok(BenchmarkAggregateReport {
448 rows: rows
449 .into_iter()
450 .map(|((scope, span_label), builder)| builder.finish(scope, span_label.to_owned()))
451 .collect(),
452 })
453}
454
455pub fn compare_benchmark_aggregates(
458 current: &[BenchmarkAggregateRow],
459 previous: &[BenchmarkAggregateRow],
460 scale: u64,
461) -> Result<BenchmarkComparisonReport, MeasurementRatioError> {
462 let mut rows = BTreeMap::new();
463 for row in current {
464 rows.insert((&row.scope, row.span_label.as_str()), (Some(row), None));
465 }
466 for row in previous {
467 rows.entry((&row.scope, row.span_label.as_str()))
468 .or_insert((None, None))
469 .1 = Some(row);
470 }
471 let measurement = |row: &BenchmarkAggregateRow| -> Result<_, MeasurementRatioError> {
472 Ok(BenchmarkMeasurement {
473 runs: row.runs,
474 average: row.average(scale)?,
475 })
476 };
477
478 Ok(BenchmarkComparisonReport {
479 rows: rows
480 .into_iter()
481 .map(|((scope, span_label), (current_row, previous_row))| {
482 Ok(BenchmarkComparisonRow {
483 span_label: span_label.to_owned(),
484 current: current_row.map(measurement).transpose()?,
485 previous: previous_row.map(measurement).transpose()?,
486 scope: scope.clone(),
487 })
488 })
489 .collect::<Result<_, MeasurementRatioError>>()?,
490 })
491}
492
493fn parse_marker_line(
494 line: &str,
495 source_line: usize,
496 source: BenchmarkEventSource,
497 config: &BenchmarkParserConfig,
498) -> Result<RawBenchmarkEvent, MalformedBenchmarkMarker> {
499 let mut parts = line.split('|');
500 let columns: [Option<&str>; 7] = std::array::from_fn(|_| parts.next());
503 let [
504 Some(prefix),
505 Some(label),
506 Some(instructions),
507 Some(heap_bytes),
508 Some(memory_bytes),
509 Some(total_allocation),
510 None,
511 ] = columns
512 else {
513 return Err(malformed(
514 source_line,
515 source,
516 line,
517 "expected six pipe-separated columns",
518 ));
519 };
520
521 if !config.prefixes.iter().any(|known| known == prefix) {
522 return Err(malformed(
523 source_line,
524 source,
525 line,
526 "prefix is not configured",
527 ));
528 }
529
530 if label.is_empty() {
531 return Err(malformed(source_line, source, line, "label is empty"));
532 }
533
534 let (span_label, kind) = split_label_kind(label).ok_or_else(|| {
535 malformed(
536 source_line,
537 source,
538 line,
539 "label must end in :start or :end",
540 )
541 })?;
542
543 let counters = BenchmarkCounters {
544 instructions: parse_counter(instructions, source_line, source, line, "instructions")?,
545 heap_bytes: parse_counter(heap_bytes, source_line, source, line, "heap_bytes")?,
546 memory_bytes: parse_counter(memory_bytes, source_line, source, line, "memory_bytes")?,
547 total_allocation: parse_counter(
548 total_allocation,
549 source_line,
550 source,
551 line,
552 "total_allocation",
553 )?,
554 };
555 let suite = config.suite_derivation.derive_suite(span_label);
556
557 Ok(RawBenchmarkEvent {
558 prefix: prefix.to_string(),
559 suite,
560 span_label: span_label.to_string(),
561 kind,
562 counters,
563 source_line,
564 source,
565 })
566}
567
568fn parse_counter(
569 value: &str,
570 source_line: usize,
571 source: BenchmarkEventSource,
572 line: &str,
573 name: &str,
574) -> Result<u128, MalformedBenchmarkMarker> {
575 if value.is_empty() {
576 return Err(malformed(
577 source_line,
578 source,
579 line,
580 &format!("{name} counter is empty"),
581 ));
582 }
583
584 value.parse::<u128>().map_err(|_| {
585 malformed(
586 source_line,
587 source,
588 line,
589 &format!("{name} counter is not an unsigned integer"),
590 )
591 })
592}
593
594fn split_label_kind(label: &str) -> Option<(&str, BenchmarkEventKind)> {
595 let start = label.strip_suffix(":start");
596 let end = label.strip_suffix(":end");
597
598 match (start, end) {
599 (Some(span_label), None) if !span_label.is_empty() => {
600 Some((span_label, BenchmarkEventKind::Start))
601 }
602 (None, Some(span_label)) if !span_label.is_empty() => {
603 Some((span_label, BenchmarkEventKind::End))
604 }
605 _ => None,
606 }
607}
608
609fn has_configured_prefix(line: &str, prefixes: &[String]) -> bool {
610 prefixes.iter().any(|prefix| {
611 line.strip_prefix(prefix)
612 .is_some_and(|rest| rest.starts_with('|'))
613 })
614}
615
616fn malformed(
617 source_line: usize,
618 source: BenchmarkEventSource,
619 line: &str,
620 reason: &str,
621) -> MalformedBenchmarkMarker {
622 MalformedBenchmarkMarker {
623 source_line,
624 source,
625 line: line.to_string(),
626 reason: reason.to_string(),
627 }
628}
629
630fn push_paired_span(
631 report: &mut BenchmarkSpanReport,
632 start: &RawBenchmarkEvent,
633 end: &RawBenchmarkEvent,
634) {
635 if let Some(delta) = end.counters.checked_delta(start.counters) {
636 report.spans.push(BenchmarkSpan {
637 suite: start.suite.clone(),
638 span_label: start.span_label.clone(),
639 start_line: start.source_line,
640 end_line: end.source_line,
641 end: end.counters,
642 delta,
643 });
644 } else {
645 report.invalid_spans.push(InvalidBenchmarkSpan {
646 start: start.clone(),
647 end: end.clone(),
648 reason: "end counter is lower than start counter".to_string(),
649 });
650 }
651}
652
653#[derive(Clone, Debug)]
654struct AggregateBuilder {
655 runs: u64,
656 total: BenchmarkCounters,
657 min: BenchmarkCounters,
658 max: BenchmarkCounters,
659 peak_end: BenchmarkCounters,
660}
661
662impl AggregateBuilder {
663 const fn new(span: &BenchmarkSpan) -> Self {
664 Self {
665 runs: 1,
666 total: span.delta,
667 min: span.delta,
668 max: span.delta,
669 peak_end: span.end,
670 }
671 }
672
673 fn push(&mut self, span: &BenchmarkSpan) -> Result<(), BenchmarkCounter> {
674 let runs = self.runs.checked_add(1).ok_or(BenchmarkCounter::Runs)?;
675 let total = self.total.checked_add(span.delta)?;
676 self.runs = runs;
677 self.total = total;
678 self.min.min_assign(span.delta);
679 self.max.max_assign(span.delta);
680 self.peak_end.max_assign(span.end);
681 Ok(())
682 }
683
684 const fn finish(self, scope: BenchmarkScope, span_label: String) -> BenchmarkAggregateRow {
685 BenchmarkAggregateRow {
686 span_label,
687 runs: self.runs,
688 total: self.total,
689 min: self.min,
690 max: self.max,
691 peak_end: self.peak_end,
692 scope,
693 }
694 }
695}
696
697fn add_span_to_aggregate<'a>(
698 rows: &mut BTreeMap<(BenchmarkScope, &'a str), AggregateBuilder>,
699 scope: BenchmarkScope,
700 span: &'a BenchmarkSpan,
701) -> Result<(), BenchmarkAggregateError> {
702 match rows.entry((scope, span.span_label.as_str())) {
703 Entry::Occupied(mut entry) => entry.get_mut().push(span).map_err(|counter| {
704 let (scope, span_label) = entry.key();
705 BenchmarkAggregateError {
706 span_label: (*span_label).to_owned(),
707 counter,
708 scope: scope.clone(),
709 }
710 }),
711 Entry::Vacant(entry) => {
712 entry.insert(AggregateBuilder::new(span));
713 Ok(())
714 }
715 }
716}
717
718#[cfg(test)]
719mod tests {
720 use super::{AggregateBuilder, BenchmarkCounter, BenchmarkCounters, BenchmarkSpan};
721
722 #[test]
723 fn aggregate_run_count_overflow_is_rejected_without_changing_totals() {
724 let span = BenchmarkSpan {
725 suite: "probe".into(),
726 span_label: "probe/run".into(),
727 start_line: 1,
728 end_line: 2,
729 end: BenchmarkCounters::default(),
730 delta: BenchmarkCounters::default(),
731 };
732 let mut builder = AggregateBuilder::new(&span);
733 builder.runs = u64::MAX;
734 assert_eq!(builder.push(&span), Err(BenchmarkCounter::Runs));
735 assert_eq!(builder.runs, u64::MAX);
736 assert_eq!(builder.total, BenchmarkCounters::default());
737 }
738}