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