1use std::collections::{BTreeMap, btree_map::Entry};
16
17#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
19pub enum BenchmarkAverageError {
20 #[error("benchmark run count is zero")]
22 ZeroDenominator,
23 #[error("scaled benchmark average exceeds u128")]
25 Overflow,
26}
27
28fn checked_scaled_average(
29 total: u128,
30 runs: u64,
31 scale: u64,
32) -> Result<u128, BenchmarkAverageError> {
33 if runs == 0 {
34 return Err(BenchmarkAverageError::ZeroDenominator);
35 }
36 let runs = u128::from(runs);
37 let scale = u128::from(scale);
38 let fraction = (total % runs) * scale / runs;
41 (total / runs)
42 .checked_mul(scale)
43 .and_then(|whole| whole.checked_add(fraction))
44 .ok_or(BenchmarkAverageError::Overflow)
45}
46
47#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
49pub enum BenchmarkComparisonError {
50 #[error("duplicate current benchmark row: {scope:?}/{span_label}")]
52 DuplicateCurrent {
53 scope: BenchmarkScope,
55 span_label: String,
57 },
58 #[error("duplicate previous benchmark row: {scope:?}/{span_label}")]
60 DuplicatePrevious {
61 scope: BenchmarkScope,
63 span_label: String,
65 },
66 #[error("{0}")]
68 Average(#[from] BenchmarkAverageError),
69}
70
71pub const DEFAULT_PREFIX: &str = "ICTK";
73
74#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
76pub enum BenchmarkScope {
77 Suite(String),
79 All,
81}
82
83#[derive(Clone, Debug, Eq, PartialEq)]
85pub struct BenchmarkParserConfig {
86 pub prefixes: Vec<String>,
88 pub suite_derivation: SuiteDerivation,
90 pub strict: bool,
93}
94
95impl Default for BenchmarkParserConfig {
96 fn default() -> Self {
97 Self {
98 prefixes: vec![DEFAULT_PREFIX.to_string()],
99 suite_derivation: SuiteDerivation::FirstPathSegment,
100 strict: false,
101 }
102 }
103}
104
105#[derive(Clone, Debug, Eq, PartialEq)]
107pub enum SuiteDerivation {
108 FirstPathSegment,
110 Fixed(String),
112}
113
114impl SuiteDerivation {
115 #[must_use]
117 pub fn derive_suite(&self, span_label: &str) -> String {
118 match self {
119 Self::FirstPathSegment => span_label
120 .split('/')
121 .next()
122 .filter(|part| !part.is_empty())
123 .unwrap_or(span_label)
124 .to_string(),
125 Self::Fixed(suite) => suite.clone(),
126 }
127 }
128}
129
130#[derive(Clone, Copy, Debug, Eq, PartialEq)]
132pub enum BenchmarkEventKind {
133 Start,
135 End,
137}
138
139#[derive(Clone, Copy, Debug, Eq, PartialEq)]
141pub enum BenchmarkEventSource {
142 Unknown,
144 Stdout,
146 Stderr,
148 FetchedLog,
150}
151
152impl BenchmarkEventSource {
153 #[must_use]
155 pub const fn as_str(self) -> &'static str {
156 match self {
157 Self::Unknown => "unknown",
158 Self::Stdout => "stdout",
159 Self::Stderr => "stderr",
160 Self::FetchedLog => "fetched_log",
161 }
162 }
163}
164
165#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
167pub struct BenchmarkCounters {
168 pub instructions: u128,
170 pub heap_bytes: u128,
172 pub memory_bytes: u128,
174}
175
176impl BenchmarkCounters {
177 fn checked_delta(self, start: Self) -> Option<Self> {
178 Some(Self {
179 instructions: self.instructions.checked_sub(start.instructions)?,
180 heap_bytes: self.heap_bytes.checked_sub(start.heap_bytes)?,
181 memory_bytes: self.memory_bytes.checked_sub(start.memory_bytes)?,
182 })
183 }
184
185 fn checked_add(self, other: Self) -> Result<Self, BenchmarkCounter> {
186 Ok(Self {
187 instructions: self
188 .instructions
189 .checked_add(other.instructions)
190 .ok_or(BenchmarkCounter::Instructions)?,
191 heap_bytes: self
192 .heap_bytes
193 .checked_add(other.heap_bytes)
194 .ok_or(BenchmarkCounter::HeapBytes)?,
195 memory_bytes: self
196 .memory_bytes
197 .checked_add(other.memory_bytes)
198 .ok_or(BenchmarkCounter::MemoryBytes)?,
199 })
200 }
201
202 fn min_assign(&mut self, other: Self) {
203 self.instructions = self.instructions.min(other.instructions);
204 self.heap_bytes = self.heap_bytes.min(other.heap_bytes);
205 self.memory_bytes = self.memory_bytes.min(other.memory_bytes);
206 }
207
208 fn max_assign(&mut self, other: Self) {
209 self.instructions = self.instructions.max(other.instructions);
210 self.heap_bytes = self.heap_bytes.max(other.heap_bytes);
211 self.memory_bytes = self.memory_bytes.max(other.memory_bytes);
212 }
213}
214
215#[derive(Clone, Debug, Eq, PartialEq)]
217pub struct RawBenchmarkEvent {
218 pub prefix: String,
220 pub suite: String,
222 pub span_label: String,
224 pub kind: BenchmarkEventKind,
226 pub counters: BenchmarkCounters,
228 pub source_line: usize,
230 pub source: BenchmarkEventSource,
232}
233
234#[derive(Clone, Debug, Eq, PartialEq)]
236pub struct MalformedBenchmarkMarker {
237 pub source_line: usize,
239 pub source: BenchmarkEventSource,
241 pub line: String,
243 pub reason: String,
245}
246
247#[derive(Clone, Debug, Default, Eq, PartialEq)]
249pub struct BenchmarkParseReport {
250 pub events: Vec<RawBenchmarkEvent>,
252 pub malformed_markers: Vec<MalformedBenchmarkMarker>,
254 pub ignored_line_count: usize,
256}
257
258#[derive(Clone, Debug, Eq, PartialEq)]
260pub struct BenchmarkSpan {
261 pub suite: String,
263 pub span_label: String,
265 pub start_line: usize,
267 pub end_line: usize,
269 pub end: BenchmarkCounters,
271 pub delta: BenchmarkCounters,
273}
274
275#[derive(Clone, Debug, Eq, PartialEq)]
277pub struct InvalidBenchmarkSpan {
278 pub start: RawBenchmarkEvent,
280 pub end: RawBenchmarkEvent,
282}
283
284#[derive(Clone, Debug, Default, Eq, PartialEq)]
286pub struct BenchmarkSpanReport {
287 pub spans: Vec<BenchmarkSpan>,
289 pub unpaired_markers: Vec<RawBenchmarkEvent>,
291 pub invalid_spans: Vec<InvalidBenchmarkSpan>,
293}
294
295#[derive(Clone, Debug, Eq, PartialEq)]
297pub struct BenchmarkAggregateRow {
298 pub span_label: String,
300 pub runs: u64,
302 pub total: BenchmarkCounters,
304 pub min: BenchmarkCounters,
306 pub max: BenchmarkCounters,
308 pub peak_end: BenchmarkCounters,
310 pub scope: BenchmarkScope,
312}
313
314#[derive(Clone, Copy, Debug, Eq, PartialEq)]
319pub struct BenchmarkAverages {
320 pub scale: u64,
322 pub instructions: u128,
324 pub heap_bytes: u128,
326 pub memory_bytes: u128,
328}
329
330#[derive(Clone, Copy, Debug, Eq, PartialEq)]
332pub enum BenchmarkCounter {
333 Runs,
335 Instructions,
337 HeapBytes,
339 MemoryBytes,
341}
342
343#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
345#[error("benchmark aggregate overflow: scope={scope:?}, span={span_label}, counter={counter:?}")]
346pub struct BenchmarkAggregateError {
347 pub span_label: String,
349 pub counter: BenchmarkCounter,
351 pub scope: BenchmarkScope,
353}
354
355#[derive(Clone, Copy, Debug, Eq, PartialEq)]
357pub struct BenchmarkMeasurement {
358 pub runs: u64,
360 pub average: BenchmarkAverages,
362}
363
364#[derive(Clone, Debug, Eq, PartialEq)]
366pub struct BenchmarkComparisonRow {
367 pub span_label: String,
369 pub current: Option<BenchmarkMeasurement>,
371 pub previous: Option<BenchmarkMeasurement>,
373 pub scope: BenchmarkScope,
375}
376
377impl BenchmarkAggregateRow {
378 pub fn average(&self, scale: u64) -> Result<BenchmarkAverages, BenchmarkAverageError> {
383 Ok(BenchmarkAverages {
384 scale,
385 instructions: checked_scaled_average(self.total.instructions, self.runs, scale)?,
386 heap_bytes: checked_scaled_average(self.total.heap_bytes, self.runs, scale)?,
387 memory_bytes: checked_scaled_average(self.total.memory_bytes, self.runs, scale)?,
388 })
389 }
390}
391
392#[must_use]
397pub fn format_marker(prefix: &str, label: &str, counters: BenchmarkCounters) -> String {
398 format!(
399 "{}|{}|{}|{}|{}",
400 prefix, label, counters.instructions, counters.heap_bytes, counters.memory_bytes
401 )
402}
403
404#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
406pub enum BenchmarkMarkerError {
407 #[error("invalid benchmark marker prefix")]
409 InvalidPrefix,
410 #[error("invalid benchmark marker label")]
412 InvalidLabel,
413}
414
415pub fn try_format_marker(
424 prefix: &str,
425 label: &str,
426 counters: BenchmarkCounters,
427) -> Result<String, BenchmarkMarkerError> {
428 if prefix.is_empty() || prefix.contains(['|', '\r', '\n']) {
429 return Err(BenchmarkMarkerError::InvalidPrefix);
430 }
431 if label.contains(['|', '\r', '\n']) || split_label_kind(label).is_none() {
432 return Err(BenchmarkMarkerError::InvalidLabel);
433 }
434 Ok(format_marker(prefix, label, counters))
435}
436
437#[must_use]
439pub fn parse_benchmark_events(input: &str, config: &BenchmarkParserConfig) -> BenchmarkParseReport {
440 parse_benchmark_events_from_source(input, config, BenchmarkEventSource::Unknown)
441}
442
443#[must_use]
445pub fn parse_benchmark_events_from_source(
446 input: &str,
447 config: &BenchmarkParserConfig,
448 source: BenchmarkEventSource,
449) -> BenchmarkParseReport {
450 let mut report = BenchmarkParseReport::default();
451 append_benchmark_events(&mut report, input, config, source);
452 report
453}
454
455fn append_benchmark_events(
456 report: &mut BenchmarkParseReport,
457 input: &str,
458 config: &BenchmarkParserConfig,
459 source: BenchmarkEventSource,
460) {
461 for (index, line) in input.lines().enumerate() {
462 let source_line = index + 1;
463 let Some(prefix) = configured_prefix(line, &config.prefixes) else {
464 if config.strict && !line.trim().is_empty() {
465 report.malformed_markers.push(malformed(
466 source_line,
467 source,
468 line,
469 "line does not use a configured marker prefix",
470 ));
471 } else {
472 report.ignored_line_count += 1;
473 }
474 continue;
475 };
476
477 match parse_marker_line(line, prefix, source_line, source, &config.suite_derivation) {
478 Ok(event) => report.events.push(event),
479 Err(marker) => report.malformed_markers.push(marker),
480 }
481 }
482}
483
484#[must_use]
490pub fn parse_benchmark_events_from_captured_output(
491 stdout: &str,
492 stderr: &str,
493 config: &BenchmarkParserConfig,
494) -> BenchmarkParseReport {
495 let mut report = BenchmarkParseReport::default();
496 append_benchmark_events(&mut report, stdout, config, BenchmarkEventSource::Stdout);
497 append_benchmark_events(&mut report, stderr, config, BenchmarkEventSource::Stderr);
498 report
499}
500
501#[must_use]
503pub fn pair_benchmark_spans(events: &[RawBenchmarkEvent]) -> BenchmarkSpanReport {
504 pair_spans(events, |event| (&event.suite, &event.span_label))
505}
506
507#[must_use]
515pub fn pair_benchmark_spans_by_origin(events: &[RawBenchmarkEvent]) -> BenchmarkSpanReport {
516 pair_spans(events, |event| {
517 (
518 &event.prefix,
519 event.source.as_str(),
520 &event.suite,
521 &event.span_label,
522 )
523 })
524}
525
526fn pair_spans<'a, K: Ord>(
527 events: &'a [RawBenchmarkEvent],
528 key: impl Fn(&'a RawBenchmarkEvent) -> K,
529) -> BenchmarkSpanReport {
530 let mut report = BenchmarkSpanReport::default();
531 let mut open_starts = BTreeMap::<_, Vec<&RawBenchmarkEvent>>::new();
532
533 for event in events {
534 let key = key(event);
535 match event.kind {
536 BenchmarkEventKind::Start => open_starts.entry(key).or_default().push(event),
537 BenchmarkEventKind::End => match open_starts.entry(key) {
538 Entry::Occupied(mut entry) => {
539 let start = entry
540 .get_mut()
541 .pop()
542 .expect("open marker stacks must remain nonempty");
543 if entry.get().is_empty() {
544 entry.remove();
545 }
546 push_paired_span(&mut report, start, event);
547 }
548 Entry::Vacant(_) => report.unpaired_markers.push(event.clone()),
549 },
550 }
551 }
552
553 for starts in open_starts.into_values() {
554 for event in starts {
555 report.unpaired_markers.push(event.clone());
556 }
557 }
558
559 report
560}
561
562pub fn aggregate_benchmark_spans(
568 spans: &[BenchmarkSpan],
569) -> Result<Vec<BenchmarkAggregateRow>, BenchmarkAggregateError> {
570 let mut rows = BTreeMap::new();
571
572 for span in spans {
573 add_span_to_aggregate(&mut rows, AggregateScope::Suite(&span.suite), span)?;
574 add_span_to_aggregate(&mut rows, AggregateScope::All, span)?;
575 }
576
577 Ok(rows
578 .into_iter()
579 .map(|((scope, span_label), builder)| {
580 builder.finish(scope.into_owned(), span_label.to_owned())
581 })
582 .collect())
583}
584
585pub fn compare_benchmark_aggregates(
590 current: &[BenchmarkAggregateRow],
591 previous: &[BenchmarkAggregateRow],
592 scale: u64,
593) -> Result<Vec<BenchmarkComparisonRow>, BenchmarkComparisonError> {
594 let mut rows = BTreeMap::new();
595 for row in current {
596 if rows
597 .insert((&row.scope, row.span_label.as_str()), (Some(row), None))
598 .is_some()
599 {
600 return Err(BenchmarkComparisonError::DuplicateCurrent {
601 scope: row.scope.clone(),
602 span_label: row.span_label.clone(),
603 });
604 }
605 }
606 for row in previous {
607 let previous = &mut rows
608 .entry((&row.scope, row.span_label.as_str()))
609 .or_insert((None, None))
610 .1;
611 if previous.replace(row).is_some() {
612 return Err(BenchmarkComparisonError::DuplicatePrevious {
613 scope: row.scope.clone(),
614 span_label: row.span_label.clone(),
615 });
616 }
617 }
618 let measurement = |row: &BenchmarkAggregateRow| -> Result<_, BenchmarkAverageError> {
619 Ok(BenchmarkMeasurement {
620 runs: row.runs,
621 average: row.average(scale)?,
622 })
623 };
624
625 rows.into_iter()
626 .map(|((scope, span_label), (current_row, previous_row))| {
627 Ok(BenchmarkComparisonRow {
628 span_label: span_label.to_owned(),
629 current: current_row.map(measurement).transpose()?,
630 previous: previous_row.map(measurement).transpose()?,
631 scope: scope.clone(),
632 })
633 })
634 .collect()
635}
636
637fn parse_marker_line(
638 line: &str,
639 admitted_prefix: &str,
640 source_line: usize,
641 source: BenchmarkEventSource,
642 suite_derivation: &SuiteDerivation,
643) -> Result<RawBenchmarkEvent, MalformedBenchmarkMarker> {
644 let invalid = |reason: String| malformed(source_line, source, line, reason);
645 let parse_counter = |value: &str, name: &str| {
646 if value.is_empty() {
647 return Err(invalid(format!("{name} counter is empty")));
648 }
649 value
650 .parse::<u128>()
651 .map_err(|_| invalid(format!("{name} counter is not an unsigned integer")))
652 };
653 let mut parts = line.split('|');
654 let columns: [Option<&str>; 6] = std::array::from_fn(|_| parts.next());
657 let [
658 Some(prefix),
659 Some(label),
660 Some(instructions),
661 Some(heap_bytes),
662 Some(memory_bytes),
663 None,
664 ] = columns
665 else {
666 return Err(invalid("expected five pipe-separated columns".into()));
667 };
668
669 if prefix != admitted_prefix {
672 return Err(invalid("prefix is not configured".into()));
673 }
674
675 if label.is_empty() {
676 return Err(invalid("label is empty".into()));
677 }
678
679 let (span_label, kind) = split_label_kind(label)
680 .ok_or_else(|| invalid("label must end in :start or :end".into()))?;
681
682 let counters = BenchmarkCounters {
683 instructions: parse_counter(instructions, "instructions")?,
684 heap_bytes: parse_counter(heap_bytes, "heap_bytes")?,
685 memory_bytes: parse_counter(memory_bytes, "memory_bytes")?,
686 };
687 let suite = suite_derivation.derive_suite(span_label);
688
689 Ok(RawBenchmarkEvent {
690 prefix: prefix.to_string(),
691 suite,
692 span_label: span_label.to_string(),
693 kind,
694 counters,
695 source_line,
696 source,
697 })
698}
699
700fn split_label_kind(label: &str) -> Option<(&str, BenchmarkEventKind)> {
701 label
702 .strip_suffix(":start")
703 .map(|span| (span, BenchmarkEventKind::Start))
704 .or_else(|| {
705 label
706 .strip_suffix(":end")
707 .map(|span| (span, BenchmarkEventKind::End))
708 })
709 .filter(|(span, _)| !span.is_empty())
710}
711
712fn configured_prefix<'a>(line: &str, prefixes: &'a [String]) -> Option<&'a str> {
713 let (field, _) = line.split_once('|')?;
714 prefixes
715 .iter()
716 .find(|known| known.as_str() == field)
717 .or_else(|| {
718 prefixes.iter().find(|known| {
719 line.strip_prefix(known.as_str())
720 .is_some_and(|rest| rest.starts_with('|'))
721 })
722 })
723 .map(String::as_str)
724}
725
726fn malformed(
727 source_line: usize,
728 source: BenchmarkEventSource,
729 line: &str,
730 reason: impl Into<String>,
731) -> MalformedBenchmarkMarker {
732 MalformedBenchmarkMarker {
733 source_line,
734 source,
735 line: line.to_string(),
736 reason: reason.into(),
737 }
738}
739
740fn push_paired_span(
741 report: &mut BenchmarkSpanReport,
742 start: &RawBenchmarkEvent,
743 end: &RawBenchmarkEvent,
744) {
745 if let Some(delta) = end.counters.checked_delta(start.counters) {
746 report.spans.push(BenchmarkSpan {
747 suite: start.suite.clone(),
748 span_label: start.span_label.clone(),
749 start_line: start.source_line,
750 end_line: end.source_line,
751 end: end.counters,
752 delta,
753 });
754 } else {
755 report.invalid_spans.push(InvalidBenchmarkSpan {
756 start: start.clone(),
757 end: end.clone(),
758 });
759 }
760}
761
762#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)]
765enum AggregateScope<'a> {
766 Suite(&'a str),
767 All,
768}
769
770impl AggregateScope<'_> {
771 fn into_owned(self) -> BenchmarkScope {
772 match self {
773 Self::Suite(suite) => BenchmarkScope::Suite(suite.to_owned()),
774 Self::All => BenchmarkScope::All,
775 }
776 }
777}
778
779#[derive(Clone, Debug)]
780struct AggregateBuilder {
781 runs: u64,
782 total: BenchmarkCounters,
783 min: BenchmarkCounters,
784 max: BenchmarkCounters,
785 peak_end: BenchmarkCounters,
786}
787
788impl AggregateBuilder {
789 const fn new(span: &BenchmarkSpan) -> Self {
790 Self {
791 runs: 1,
792 total: span.delta,
793 min: span.delta,
794 max: span.delta,
795 peak_end: span.end,
796 }
797 }
798
799 fn push(&mut self, span: &BenchmarkSpan) -> Result<(), BenchmarkCounter> {
800 let runs = self.runs.checked_add(1).ok_or(BenchmarkCounter::Runs)?;
801 let total = self.total.checked_add(span.delta)?;
802 self.runs = runs;
803 self.total = total;
804 self.min.min_assign(span.delta);
805 self.max.max_assign(span.delta);
806 self.peak_end.max_assign(span.end);
807 Ok(())
808 }
809
810 const fn finish(self, scope: BenchmarkScope, span_label: String) -> BenchmarkAggregateRow {
811 BenchmarkAggregateRow {
812 span_label,
813 runs: self.runs,
814 total: self.total,
815 min: self.min,
816 max: self.max,
817 peak_end: self.peak_end,
818 scope,
819 }
820 }
821}
822
823fn add_span_to_aggregate<'a>(
824 rows: &mut BTreeMap<(AggregateScope<'a>, &'a str), AggregateBuilder>,
825 scope: AggregateScope<'a>,
826 span: &'a BenchmarkSpan,
827) -> Result<(), BenchmarkAggregateError> {
828 match rows.entry((scope, span.span_label.as_str())) {
829 Entry::Occupied(mut entry) => entry.get_mut().push(span).map_err(|counter| {
830 let (scope, span_label) = entry.key();
831 BenchmarkAggregateError {
832 span_label: (*span_label).to_owned(),
833 counter,
834 scope: scope.into_owned(),
835 }
836 }),
837 Entry::Vacant(entry) => {
838 entry.insert(AggregateBuilder::new(span));
839 Ok(())
840 }
841 }
842}
843
844#[cfg(test)]
845mod tests {
846 use super::{AggregateBuilder, BenchmarkCounter, BenchmarkCounters, BenchmarkSpan};
847
848 #[test]
849 fn aggregate_run_count_overflow_is_rejected_without_changing_totals() {
850 let span = BenchmarkSpan {
851 suite: "probe".into(),
852 span_label: "probe/run".into(),
853 start_line: 1,
854 end_line: 2,
855 end: BenchmarkCounters::default(),
856 delta: BenchmarkCounters::default(),
857 };
858 let mut builder = AggregateBuilder::new(&span);
859 builder.runs = u64::MAX;
860 assert_eq!(builder.push(&span), Err(BenchmarkCounter::Runs));
861 assert_eq!(builder.runs, u64::MAX);
862 assert_eq!(builder.total, BenchmarkCounters::default());
863 }
864}