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