1use super::marker::{BenchmarkCounters, BenchmarkEventKind, DEFAULT_PREFIX, split_label_kind};
4use std::collections::{BTreeMap, btree_map::Entry};
5
6#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
8pub enum BenchmarkAverageError {
9 #[error("benchmark run count is zero")]
11 ZeroDenominator,
12 #[error("scaled benchmark average exceeds u128")]
14 Overflow,
15}
16
17fn checked_scaled_average(
18 total: u128,
19 runs: u64,
20 scale: u64,
21) -> Result<u128, BenchmarkAverageError> {
22 ic_metrics::checked_scaled_ratio(total, runs, scale).map_err(|error| match error {
23 ic_metrics::MeasurementRatioError::ZeroDenominator => {
24 BenchmarkAverageError::ZeroDenominator
25 }
26 ic_metrics::MeasurementRatioError::Overflow => BenchmarkAverageError::Overflow,
27 })
28}
29
30#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
32pub enum BenchmarkComparisonError {
33 #[error("duplicate current benchmark row: {scope:?}/{span_label}")]
35 DuplicateCurrent {
36 scope: BenchmarkScope,
38 span_label: String,
40 },
41 #[error("duplicate previous benchmark row: {scope:?}/{span_label}")]
43 DuplicatePrevious {
44 scope: BenchmarkScope,
46 span_label: String,
48 },
49 #[error("{0}")]
51 Average(#[from] BenchmarkAverageError),
52}
53
54#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
56pub enum BenchmarkScope {
57 Suite(String),
59 All,
61}
62
63#[derive(Clone, Debug, Eq, PartialEq)]
65pub struct BenchmarkParserConfig {
66 pub prefixes: Vec<String>,
68 pub suite_derivation: SuiteDerivation,
70 pub strict: bool,
73}
74
75impl Default for BenchmarkParserConfig {
76 fn default() -> Self {
77 Self {
78 prefixes: vec![DEFAULT_PREFIX.to_string()],
79 suite_derivation: SuiteDerivation::FirstPathSegment,
80 strict: false,
81 }
82 }
83}
84
85#[derive(Clone, Debug, Eq, PartialEq)]
87pub enum SuiteDerivation {
88 FirstPathSegment,
90 Fixed(String),
92}
93
94impl SuiteDerivation {
95 #[must_use]
97 pub fn derive_suite(&self, span_label: &str) -> String {
98 match self {
99 Self::FirstPathSegment => span_label
100 .split('/')
101 .next()
102 .filter(|part| !part.is_empty())
103 .unwrap_or(span_label)
104 .to_string(),
105 Self::Fixed(suite) => suite.clone(),
106 }
107 }
108}
109
110#[derive(Clone, Copy, Debug, Eq, PartialEq)]
112pub enum BenchmarkEventSource {
113 Unknown,
115 Stdout,
117 Stderr,
119 FetchedLog,
121}
122
123impl BenchmarkEventSource {
124 #[must_use]
126 pub const fn as_str(self) -> &'static str {
127 match self {
128 Self::Unknown => "unknown",
129 Self::Stdout => "stdout",
130 Self::Stderr => "stderr",
131 Self::FetchedLog => "fetched_log",
132 }
133 }
134}
135
136#[derive(Clone, Debug, Eq, PartialEq)]
138pub struct RawBenchmarkEvent {
139 pub prefix: String,
141 pub suite: String,
143 pub span_label: String,
145 pub kind: BenchmarkEventKind,
147 pub counters: BenchmarkCounters,
149 pub source_line: usize,
151 pub source: BenchmarkEventSource,
153}
154
155#[derive(Clone, Debug, Eq, PartialEq)]
157pub struct MalformedBenchmarkMarker {
158 pub source_line: usize,
160 pub source: BenchmarkEventSource,
162 pub line: String,
164 pub reason: String,
166}
167
168#[derive(Clone, Debug, Default, Eq, PartialEq)]
170pub struct BenchmarkParseReport {
171 pub events: Vec<RawBenchmarkEvent>,
173 pub malformed_markers: Vec<MalformedBenchmarkMarker>,
175 pub ignored_line_count: usize,
177}
178
179#[derive(Clone, Debug, Eq, PartialEq)]
181pub struct BenchmarkSpan {
182 pub suite: String,
184 pub span_label: String,
186 pub start_line: usize,
188 pub end_line: usize,
190 pub end: BenchmarkCounters,
192 pub delta: BenchmarkCounters,
194}
195
196#[derive(Clone, Debug, Eq, PartialEq)]
198pub struct InvalidBenchmarkSpan {
199 pub start: RawBenchmarkEvent,
201 pub end: RawBenchmarkEvent,
203}
204
205#[derive(Clone, Debug, Default, Eq, PartialEq)]
207pub struct BenchmarkSpanReport {
208 pub spans: Vec<BenchmarkSpan>,
210 pub unpaired_markers: Vec<RawBenchmarkEvent>,
212 pub invalid_spans: Vec<InvalidBenchmarkSpan>,
214}
215
216#[derive(Clone, Debug, Eq, PartialEq)]
218pub struct BenchmarkAggregateRow {
219 pub span_label: String,
221 pub runs: u64,
223 pub total: BenchmarkCounters,
225 pub min: BenchmarkCounters,
227 pub max: BenchmarkCounters,
229 pub peak_end: BenchmarkCounters,
231 pub scope: BenchmarkScope,
233}
234
235#[derive(Clone, Copy, Debug, Eq, PartialEq)]
240pub struct BenchmarkAverages {
241 pub scale: u64,
243 pub instructions: u128,
245 pub heap_bytes: u128,
247 pub memory_bytes: u128,
249}
250
251#[derive(Clone, Copy, Debug, Eq, PartialEq)]
253pub enum BenchmarkCounter {
254 Runs,
256 Instructions,
258 HeapBytes,
260 MemoryBytes,
262}
263
264#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
266#[error("benchmark aggregate overflow: scope={scope:?}, span={span_label}, counter={counter:?}")]
267pub struct BenchmarkAggregateError {
268 pub span_label: String,
270 pub counter: BenchmarkCounter,
272 pub scope: BenchmarkScope,
274}
275
276#[derive(Clone, Copy, Debug, Eq, PartialEq)]
278pub struct BenchmarkMeasurement {
279 pub runs: u64,
281 pub average: BenchmarkAverages,
283}
284
285#[derive(Clone, Debug, Eq, PartialEq)]
287pub struct BenchmarkComparisonRow {
288 pub span_label: String,
290 pub current: Option<BenchmarkMeasurement>,
292 pub previous: Option<BenchmarkMeasurement>,
294 pub scope: BenchmarkScope,
296}
297
298impl BenchmarkAggregateRow {
299 pub fn average(&self, scale: u64) -> Result<BenchmarkAverages, BenchmarkAverageError> {
304 Ok(BenchmarkAverages {
305 scale,
306 instructions: checked_scaled_average(self.total.instructions, self.runs, scale)?,
307 heap_bytes: checked_scaled_average(self.total.heap_bytes, self.runs, scale)?,
308 memory_bytes: checked_scaled_average(self.total.memory_bytes, self.runs, scale)?,
309 })
310 }
311}
312
313#[must_use]
315pub fn parse_benchmark_events(input: &str, config: &BenchmarkParserConfig) -> BenchmarkParseReport {
316 parse_benchmark_events_from_source(input, config, BenchmarkEventSource::Unknown)
317}
318
319#[must_use]
321pub fn parse_benchmark_events_from_source(
322 input: &str,
323 config: &BenchmarkParserConfig,
324 source: BenchmarkEventSource,
325) -> BenchmarkParseReport {
326 let mut report = BenchmarkParseReport::default();
327 append_benchmark_events(&mut report, input, config, source);
328 report
329}
330
331fn append_benchmark_events(
332 report: &mut BenchmarkParseReport,
333 input: &str,
334 config: &BenchmarkParserConfig,
335 source: BenchmarkEventSource,
336) {
337 for (index, line) in input.lines().enumerate() {
338 let source_line = index + 1;
339 let Some(prefix) = configured_prefix(line, &config.prefixes) else {
340 if config.strict && !line.trim().is_empty() {
341 report.malformed_markers.push(malformed(
342 source_line,
343 source,
344 line,
345 "line does not use a configured marker prefix",
346 ));
347 } else {
348 report.ignored_line_count += 1;
349 }
350 continue;
351 };
352
353 match parse_marker_line(line, prefix, source_line, source, &config.suite_derivation) {
354 Ok(event) => report.events.push(event),
355 Err(marker) => report.malformed_markers.push(marker),
356 }
357 }
358}
359
360#[must_use]
366pub fn parse_benchmark_events_from_captured_output(
367 stdout: &str,
368 stderr: &str,
369 config: &BenchmarkParserConfig,
370) -> BenchmarkParseReport {
371 let mut report = BenchmarkParseReport::default();
372 append_benchmark_events(&mut report, stdout, config, BenchmarkEventSource::Stdout);
373 append_benchmark_events(&mut report, stderr, config, BenchmarkEventSource::Stderr);
374 report
375}
376
377#[must_use]
388pub fn pair_benchmark_spans(events: &[RawBenchmarkEvent]) -> BenchmarkSpanReport {
389 let mut report = BenchmarkSpanReport::default();
390 let mut open_starts = BTreeMap::<_, Vec<&RawBenchmarkEvent>>::new();
391
392 for event in events {
393 let key = (
394 &event.prefix,
395 event.source.as_str(),
396 &event.suite,
397 &event.span_label,
398 );
399 match event.kind {
400 BenchmarkEventKind::Start => open_starts.entry(key).or_default().push(event),
401 BenchmarkEventKind::End => match open_starts.entry(key) {
402 Entry::Occupied(mut entry) => {
403 let start = entry
404 .get_mut()
405 .pop()
406 .expect("open marker stacks must remain nonempty");
407 if entry.get().is_empty() {
408 entry.remove();
409 }
410 push_paired_span(&mut report, start, event);
411 }
412 Entry::Vacant(_) => report.unpaired_markers.push(event.clone()),
413 },
414 }
415 }
416
417 for starts in open_starts.into_values() {
418 for event in starts {
419 report.unpaired_markers.push(event.clone());
420 }
421 }
422
423 report
424}
425
426pub fn aggregate_benchmark_spans(
432 spans: &[BenchmarkSpan],
433) -> Result<Vec<BenchmarkAggregateRow>, BenchmarkAggregateError> {
434 let mut rows = BTreeMap::new();
435
436 for span in spans {
437 add_span_to_aggregate(&mut rows, AggregateScope::Suite(&span.suite), span)?;
438 add_span_to_aggregate(&mut rows, AggregateScope::All, span)?;
439 }
440
441 Ok(rows
442 .into_iter()
443 .map(|((scope, span_label), builder)| {
444 builder.finish(scope.into_owned(), span_label.to_owned())
445 })
446 .collect())
447}
448
449pub fn compare_benchmark_aggregates(
454 current: &[BenchmarkAggregateRow],
455 previous: &[BenchmarkAggregateRow],
456 scale: u64,
457) -> Result<Vec<BenchmarkComparisonRow>, BenchmarkComparisonError> {
458 let mut rows = BTreeMap::new();
459 for row in current {
460 if rows
461 .insert((&row.scope, row.span_label.as_str()), (Some(row), None))
462 .is_some()
463 {
464 return Err(BenchmarkComparisonError::DuplicateCurrent {
465 scope: row.scope.clone(),
466 span_label: row.span_label.clone(),
467 });
468 }
469 }
470 for row in previous {
471 let previous = &mut rows
472 .entry((&row.scope, row.span_label.as_str()))
473 .or_insert((None, None))
474 .1;
475 if previous.replace(row).is_some() {
476 return Err(BenchmarkComparisonError::DuplicatePrevious {
477 scope: row.scope.clone(),
478 span_label: row.span_label.clone(),
479 });
480 }
481 }
482 let measurement = |row: &BenchmarkAggregateRow| -> Result<_, BenchmarkAverageError> {
483 Ok(BenchmarkMeasurement {
484 runs: row.runs,
485 average: row.average(scale)?,
486 })
487 };
488
489 rows.into_iter()
490 .map(|((scope, span_label), (current_row, previous_row))| {
491 Ok(BenchmarkComparisonRow {
492 span_label: span_label.to_owned(),
493 current: current_row.map(measurement).transpose()?,
494 previous: previous_row.map(measurement).transpose()?,
495 scope: scope.clone(),
496 })
497 })
498 .collect()
499}
500
501fn parse_marker_line(
502 line: &str,
503 admitted_prefix: &str,
504 source_line: usize,
505 source: BenchmarkEventSource,
506 suite_derivation: &SuiteDerivation,
507) -> Result<RawBenchmarkEvent, MalformedBenchmarkMarker> {
508 let invalid = |reason: String| malformed(source_line, source, line, reason);
509 let parse_counter = |value: &str, name: &str| {
510 if value.is_empty() {
511 return Err(invalid(format!("{name} counter is empty")));
512 }
513 value
514 .parse::<u128>()
515 .map_err(|_| invalid(format!("{name} counter is not an unsigned integer")))
516 };
517 let mut parts = line.split('|');
518 let columns: [Option<&str>; 6] = std::array::from_fn(|_| parts.next());
521 let [
522 Some(prefix),
523 Some(label),
524 Some(instructions),
525 Some(heap_bytes),
526 Some(memory_bytes),
527 None,
528 ] = columns
529 else {
530 return Err(invalid("expected five pipe-separated columns".into()));
531 };
532
533 if prefix != admitted_prefix {
536 return Err(invalid("prefix is not configured".into()));
537 }
538
539 if label.is_empty() {
540 return Err(invalid("label is empty".into()));
541 }
542
543 let (span_label, kind) = split_label_kind(label)
544 .ok_or_else(|| invalid("label must end in :start or :end".into()))?;
545
546 let counters = BenchmarkCounters {
547 instructions: parse_counter(instructions, "instructions")?,
548 heap_bytes: parse_counter(heap_bytes, "heap_bytes")?,
549 memory_bytes: parse_counter(memory_bytes, "memory_bytes")?,
550 };
551 let suite = suite_derivation.derive_suite(span_label);
552
553 Ok(RawBenchmarkEvent {
554 prefix: prefix.to_string(),
555 suite,
556 span_label: span_label.to_string(),
557 kind,
558 counters,
559 source_line,
560 source,
561 })
562}
563
564fn configured_prefix<'a>(line: &str, prefixes: &'a [String]) -> Option<&'a str> {
565 let (field, _) = line.split_once('|')?;
566 prefixes
567 .iter()
568 .find(|known| known.as_str() == field)
569 .or_else(|| {
570 prefixes.iter().find(|known| {
571 line.strip_prefix(known.as_str())
572 .is_some_and(|rest| rest.starts_with('|'))
573 })
574 })
575 .map(String::as_str)
576}
577
578fn malformed(
579 source_line: usize,
580 source: BenchmarkEventSource,
581 line: &str,
582 reason: impl Into<String>,
583) -> MalformedBenchmarkMarker {
584 MalformedBenchmarkMarker {
585 source_line,
586 source,
587 line: line.to_string(),
588 reason: reason.into(),
589 }
590}
591
592fn push_paired_span(
593 report: &mut BenchmarkSpanReport,
594 start: &RawBenchmarkEvent,
595 end: &RawBenchmarkEvent,
596) {
597 if let Some(delta) = end.counters.checked_delta(start.counters) {
598 report.spans.push(BenchmarkSpan {
599 suite: start.suite.clone(),
600 span_label: start.span_label.clone(),
601 start_line: start.source_line,
602 end_line: end.source_line,
603 end: end.counters,
604 delta,
605 });
606 } else {
607 report.invalid_spans.push(InvalidBenchmarkSpan {
608 start: start.clone(),
609 end: end.clone(),
610 });
611 }
612}
613
614#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)]
617enum AggregateScope<'a> {
618 Suite(&'a str),
619 All,
620}
621
622impl AggregateScope<'_> {
623 fn into_owned(self) -> BenchmarkScope {
624 match self {
625 Self::Suite(suite) => BenchmarkScope::Suite(suite.to_owned()),
626 Self::All => BenchmarkScope::All,
627 }
628 }
629}
630
631#[derive(Clone, Debug)]
632struct AggregateBuilder {
633 runs: u64,
634 total: BenchmarkCounters,
635 min: BenchmarkCounters,
636 max: BenchmarkCounters,
637 peak_end: BenchmarkCounters,
638}
639
640impl AggregateBuilder {
641 const fn new(span: &BenchmarkSpan) -> Self {
642 Self {
643 runs: 1,
644 total: span.delta,
645 min: span.delta,
646 max: span.delta,
647 peak_end: span.end,
648 }
649 }
650
651 fn push(&mut self, span: &BenchmarkSpan) -> Result<(), BenchmarkCounter> {
652 let runs = self.runs.checked_add(1).ok_or(BenchmarkCounter::Runs)?;
653 let total = self.total.checked_add(span.delta)?;
654 self.runs = runs;
655 self.total = total;
656 self.min.min_assign(span.delta);
657 self.max.max_assign(span.delta);
658 self.peak_end.max_assign(span.end);
659 Ok(())
660 }
661
662 const fn finish(self, scope: BenchmarkScope, span_label: String) -> BenchmarkAggregateRow {
663 BenchmarkAggregateRow {
664 span_label,
665 runs: self.runs,
666 total: self.total,
667 min: self.min,
668 max: self.max,
669 peak_end: self.peak_end,
670 scope,
671 }
672 }
673}
674
675fn add_span_to_aggregate<'a>(
676 rows: &mut BTreeMap<(AggregateScope<'a>, &'a str), AggregateBuilder>,
677 scope: AggregateScope<'a>,
678 span: &'a BenchmarkSpan,
679) -> Result<(), BenchmarkAggregateError> {
680 match rows.entry((scope, span.span_label.as_str())) {
681 Entry::Occupied(mut entry) => entry.get_mut().push(span).map_err(|counter| {
682 let (scope, span_label) = entry.key();
683 BenchmarkAggregateError {
684 span_label: (*span_label).to_owned(),
685 counter,
686 scope: scope.into_owned(),
687 }
688 }),
689 Entry::Vacant(entry) => {
690 entry.insert(AggregateBuilder::new(span));
691 Ok(())
692 }
693 }
694}
695
696#[cfg(test)]
697mod tests {
698 use super::{AggregateBuilder, BenchmarkCounter, BenchmarkCounters, BenchmarkSpan};
699
700 #[test]
701 fn aggregate_run_count_overflow_is_rejected_without_changing_totals() {
702 let span = BenchmarkSpan {
703 suite: "probe".into(),
704 span_label: "probe/run".into(),
705 start_line: 1,
706 end_line: 2,
707 end: BenchmarkCounters::default(),
708 delta: BenchmarkCounters::default(),
709 };
710 let mut builder = AggregateBuilder::new(&span);
711 builder.runs = u64::MAX;
712 assert_eq!(builder.push(&span), Err(BenchmarkCounter::Runs));
713 assert_eq!(builder.runs, u64::MAX);
714 assert_eq!(builder.total, BenchmarkCounters::default());
715 }
716}