1use std::collections::{BTreeMap, btree_map::Entry};
16
17#[derive(Clone, Copy, Debug, Eq, PartialEq)]
19pub enum BenchmarkAverageError {
20 ZeroDenominator,
22 Overflow,
24}
25
26impl std::fmt::Display for BenchmarkAverageError {
27 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 formatter.write_str(match self {
29 Self::ZeroDenominator => "benchmark run count is zero",
30 Self::Overflow => "scaled benchmark average exceeds u128",
31 })
32 }
33}
34
35impl std::error::Error for BenchmarkAverageError {}
36
37fn checked_scaled_average(
38 total: u128,
39 runs: u64,
40 scale: u64,
41) -> Result<u128, BenchmarkAverageError> {
42 if runs == 0 {
43 return Err(BenchmarkAverageError::ZeroDenominator);
44 }
45 let runs = u128::from(runs);
46 let scale = u128::from(scale);
47 let fraction = (total % runs) * scale / runs;
50 (total / runs)
51 .checked_mul(scale)
52 .and_then(|whole| whole.checked_add(fraction))
53 .ok_or(BenchmarkAverageError::Overflow)
54}
55
56#[derive(Clone, Debug, Eq, PartialEq)]
58pub enum BenchmarkComparisonError {
59 DuplicateCurrent {
61 scope: BenchmarkScope,
63 span_label: String,
65 },
66 DuplicatePrevious {
68 scope: BenchmarkScope,
70 span_label: String,
72 },
73 Average(BenchmarkAverageError),
75}
76
77impl std::fmt::Display for BenchmarkComparisonError {
78 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 match self {
80 Self::DuplicateCurrent { scope, span_label } => write!(
81 formatter,
82 "duplicate current benchmark row: {scope:?}/{span_label}"
83 ),
84 Self::DuplicatePrevious { scope, span_label } => write!(
85 formatter,
86 "duplicate previous benchmark row: {scope:?}/{span_label}"
87 ),
88 Self::Average(error) => error.fmt(formatter),
89 }
90 }
91}
92impl std::error::Error for BenchmarkComparisonError {
93 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
94 match self {
95 Self::Average(error) => Some(error),
96 Self::DuplicateCurrent { .. } | Self::DuplicatePrevious { .. } => None,
97 }
98 }
99}
100impl From<BenchmarkAverageError> for BenchmarkComparisonError {
101 fn from(error: BenchmarkAverageError) -> Self {
102 Self::Average(error)
103 }
104}
105
106pub const DEFAULT_PREFIX: &str = "ICTK";
108
109#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
111pub enum BenchmarkScope {
112 Suite(String),
114 All,
116}
117
118#[derive(Clone, Debug, Eq, PartialEq)]
120pub struct BenchmarkParserConfig {
121 pub prefixes: Vec<String>,
123 pub suite_derivation: SuiteDerivation,
125 pub strict: bool,
128}
129
130impl Default for BenchmarkParserConfig {
131 fn default() -> Self {
132 Self {
133 prefixes: vec![DEFAULT_PREFIX.to_string()],
134 suite_derivation: SuiteDerivation::FirstPathSegment,
135 strict: false,
136 }
137 }
138}
139
140#[derive(Clone, Debug, Eq, PartialEq)]
142pub enum SuiteDerivation {
143 FirstPathSegment,
145 Fixed(String),
147}
148
149impl SuiteDerivation {
150 #[must_use]
152 pub fn derive_suite(&self, span_label: &str) -> String {
153 match self {
154 Self::FirstPathSegment => span_label
155 .split('/')
156 .next()
157 .filter(|part| !part.is_empty())
158 .unwrap_or(span_label)
159 .to_string(),
160 Self::Fixed(suite) => suite.clone(),
161 }
162 }
163}
164
165#[derive(Clone, Copy, Debug, Eq, PartialEq)]
167pub enum BenchmarkEventKind {
168 Start,
170 End,
172}
173
174#[derive(Clone, Copy, Debug, Eq, PartialEq)]
176pub enum BenchmarkEventSource {
177 Unknown,
179 Stdout,
181 Stderr,
183 FetchedLog,
185}
186
187impl BenchmarkEventSource {
188 #[must_use]
190 pub const fn as_str(self) -> &'static str {
191 match self {
192 Self::Unknown => "unknown",
193 Self::Stdout => "stdout",
194 Self::Stderr => "stderr",
195 Self::FetchedLog => "fetched_log",
196 }
197 }
198}
199
200#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
202pub struct BenchmarkCounters {
203 pub instructions: u128,
205 pub heap_bytes: u128,
207 pub memory_bytes: u128,
209}
210
211impl BenchmarkCounters {
212 fn checked_delta(self, start: Self) -> Option<Self> {
213 Some(Self {
214 instructions: self.instructions.checked_sub(start.instructions)?,
215 heap_bytes: self.heap_bytes.checked_sub(start.heap_bytes)?,
216 memory_bytes: self.memory_bytes.checked_sub(start.memory_bytes)?,
217 })
218 }
219
220 fn checked_add(self, other: Self) -> Result<Self, BenchmarkCounter> {
221 Ok(Self {
222 instructions: self
223 .instructions
224 .checked_add(other.instructions)
225 .ok_or(BenchmarkCounter::Instructions)?,
226 heap_bytes: self
227 .heap_bytes
228 .checked_add(other.heap_bytes)
229 .ok_or(BenchmarkCounter::HeapBytes)?,
230 memory_bytes: self
231 .memory_bytes
232 .checked_add(other.memory_bytes)
233 .ok_or(BenchmarkCounter::MemoryBytes)?,
234 })
235 }
236
237 fn min_assign(&mut self, other: Self) {
238 self.instructions = self.instructions.min(other.instructions);
239 self.heap_bytes = self.heap_bytes.min(other.heap_bytes);
240 self.memory_bytes = self.memory_bytes.min(other.memory_bytes);
241 }
242
243 fn max_assign(&mut self, other: Self) {
244 self.instructions = self.instructions.max(other.instructions);
245 self.heap_bytes = self.heap_bytes.max(other.heap_bytes);
246 self.memory_bytes = self.memory_bytes.max(other.memory_bytes);
247 }
248}
249
250#[derive(Clone, Debug, Eq, PartialEq)]
252pub struct RawBenchmarkEvent {
253 pub prefix: String,
255 pub suite: String,
257 pub span_label: String,
259 pub kind: BenchmarkEventKind,
261 pub counters: BenchmarkCounters,
263 pub source_line: usize,
265 pub source: BenchmarkEventSource,
267}
268
269#[derive(Clone, Debug, Eq, PartialEq)]
271pub struct MalformedBenchmarkMarker {
272 pub source_line: usize,
274 pub source: BenchmarkEventSource,
276 pub line: String,
278 pub reason: String,
280}
281
282#[derive(Clone, Debug, Default, Eq, PartialEq)]
284pub struct BenchmarkParseReport {
285 pub events: Vec<RawBenchmarkEvent>,
287 pub malformed_markers: Vec<MalformedBenchmarkMarker>,
289 pub ignored_line_count: usize,
291}
292
293#[derive(Clone, Debug, Eq, PartialEq)]
295pub struct BenchmarkSpan {
296 pub suite: String,
298 pub span_label: String,
300 pub start_line: usize,
302 pub end_line: usize,
304 pub end: BenchmarkCounters,
306 pub delta: BenchmarkCounters,
308}
309
310#[derive(Clone, Debug, Eq, PartialEq)]
312pub struct InvalidBenchmarkSpan {
313 pub start: RawBenchmarkEvent,
315 pub end: RawBenchmarkEvent,
317}
318
319#[derive(Clone, Debug, Default, Eq, PartialEq)]
321pub struct BenchmarkSpanReport {
322 pub spans: Vec<BenchmarkSpan>,
324 pub unpaired_markers: Vec<RawBenchmarkEvent>,
326 pub invalid_spans: Vec<InvalidBenchmarkSpan>,
328}
329
330#[derive(Clone, Debug, Eq, PartialEq)]
332pub struct BenchmarkAggregateRow {
333 pub span_label: String,
335 pub runs: u64,
337 pub total: BenchmarkCounters,
339 pub min: BenchmarkCounters,
341 pub max: BenchmarkCounters,
343 pub peak_end: BenchmarkCounters,
345 pub scope: BenchmarkScope,
347}
348
349#[derive(Clone, Copy, Debug, Eq, PartialEq)]
354pub struct BenchmarkAverages {
355 pub scale: u64,
357 pub instructions: u128,
359 pub heap_bytes: u128,
361 pub memory_bytes: u128,
363}
364
365#[derive(Clone, Copy, Debug, Eq, PartialEq)]
367pub enum BenchmarkCounter {
368 Runs,
370 Instructions,
372 HeapBytes,
374 MemoryBytes,
376}
377
378#[derive(Clone, Debug, Eq, PartialEq)]
380pub struct BenchmarkAggregateError {
381 pub span_label: String,
383 pub counter: BenchmarkCounter,
385 pub scope: BenchmarkScope,
387}
388
389impl std::fmt::Display for BenchmarkAggregateError {
390 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
391 write!(
392 formatter,
393 "benchmark aggregate overflow: scope={:?}, span={}, counter={:?}",
394 self.scope, self.span_label, self.counter
395 )
396 }
397}
398
399impl std::error::Error for BenchmarkAggregateError {}
400
401#[derive(Clone, Copy, Debug, Eq, PartialEq)]
403pub struct BenchmarkMeasurement {
404 pub runs: u64,
406 pub average: BenchmarkAverages,
408}
409
410#[derive(Clone, Debug, Eq, PartialEq)]
412pub struct BenchmarkComparisonRow {
413 pub span_label: String,
415 pub current: Option<BenchmarkMeasurement>,
417 pub previous: Option<BenchmarkMeasurement>,
419 pub scope: BenchmarkScope,
421}
422
423impl BenchmarkAggregateRow {
424 pub fn average(&self, scale: u64) -> Result<BenchmarkAverages, BenchmarkAverageError> {
429 Ok(BenchmarkAverages {
430 scale,
431 instructions: checked_scaled_average(self.total.instructions, self.runs, scale)?,
432 heap_bytes: checked_scaled_average(self.total.heap_bytes, self.runs, scale)?,
433 memory_bytes: checked_scaled_average(self.total.memory_bytes, self.runs, scale)?,
434 })
435 }
436}
437
438#[must_use]
440pub fn format_marker(prefix: &str, label: &str, counters: BenchmarkCounters) -> String {
441 format!(
442 "{}|{}|{}|{}|{}",
443 prefix, label, counters.instructions, counters.heap_bytes, counters.memory_bytes
444 )
445}
446
447#[must_use]
449pub fn parse_benchmark_events(input: &str, config: &BenchmarkParserConfig) -> BenchmarkParseReport {
450 parse_benchmark_events_from_source(input, config, BenchmarkEventSource::Unknown)
451}
452
453#[must_use]
455pub fn parse_benchmark_events_from_source(
456 input: &str,
457 config: &BenchmarkParserConfig,
458 source: BenchmarkEventSource,
459) -> BenchmarkParseReport {
460 let mut report = BenchmarkParseReport::default();
461 append_benchmark_events(&mut report, input, config, source);
462 report
463}
464
465fn append_benchmark_events(
466 report: &mut BenchmarkParseReport,
467 input: &str,
468 config: &BenchmarkParserConfig,
469 source: BenchmarkEventSource,
470) {
471 for (index, line) in input.lines().enumerate() {
472 let source_line = index + 1;
473 if !has_configured_prefix(line, &config.prefixes) {
474 if config.strict && !line.trim().is_empty() {
475 report.malformed_markers.push(malformed(
476 source_line,
477 source,
478 line,
479 "line does not use a configured marker prefix",
480 ));
481 } else {
482 report.ignored_line_count += 1;
483 }
484 continue;
485 }
486
487 match parse_marker_line(line, source_line, source, config) {
488 Ok(event) => report.events.push(event),
489 Err(marker) => report.malformed_markers.push(marker),
490 }
491 }
492}
493
494#[must_use]
500pub fn parse_benchmark_events_from_captured_output(
501 stdout: &str,
502 stderr: &str,
503 config: &BenchmarkParserConfig,
504) -> BenchmarkParseReport {
505 let mut report = BenchmarkParseReport::default();
506 append_benchmark_events(&mut report, stdout, config, BenchmarkEventSource::Stdout);
507 append_benchmark_events(&mut report, stderr, config, BenchmarkEventSource::Stderr);
508 report
509}
510
511#[must_use]
513#[expect(
514 clippy::missing_panics_doc,
515 reason = "only an internal nonempty-stack invariant can panic"
516)]
517pub fn pair_benchmark_spans(events: &[RawBenchmarkEvent]) -> BenchmarkSpanReport {
518 let mut report = BenchmarkSpanReport::default();
519 let mut open_starts: BTreeMap<(&str, &str), Vec<&RawBenchmarkEvent>> = BTreeMap::new();
520
521 for event in events {
522 let key = (event.suite.as_str(), event.span_label.as_str());
523 match event.kind {
524 BenchmarkEventKind::Start => open_starts.entry(key).or_default().push(event),
525 BenchmarkEventKind::End => match open_starts.entry(key) {
526 Entry::Occupied(mut entry) => {
527 let start = entry
528 .get_mut()
529 .pop()
530 .expect("open marker stacks must remain nonempty");
531 if entry.get().is_empty() {
532 entry.remove();
533 }
534 push_paired_span(&mut report, start, event);
535 }
536 Entry::Vacant(_) => report.unpaired_markers.push(event.clone()),
537 },
538 }
539 }
540
541 for starts in open_starts.into_values() {
542 for event in starts {
543 report.unpaired_markers.push(event.clone());
544 }
545 }
546
547 report
548}
549
550pub fn aggregate_benchmark_spans(
556 spans: &[BenchmarkSpan],
557) -> Result<Vec<BenchmarkAggregateRow>, BenchmarkAggregateError> {
558 let mut rows = BTreeMap::new();
559
560 for span in spans {
561 add_span_to_aggregate(&mut rows, AggregateScope::Suite(&span.suite), span)?;
562 add_span_to_aggregate(&mut rows, AggregateScope::All, span)?;
563 }
564
565 Ok(rows
566 .into_iter()
567 .map(|((scope, span_label), builder)| {
568 builder.finish(scope.into_owned(), span_label.to_owned())
569 })
570 .collect())
571}
572
573pub fn compare_benchmark_aggregates(
578 current: &[BenchmarkAggregateRow],
579 previous: &[BenchmarkAggregateRow],
580 scale: u64,
581) -> Result<Vec<BenchmarkComparisonRow>, BenchmarkComparisonError> {
582 let mut rows = BTreeMap::new();
583 for row in current {
584 if rows
585 .insert((&row.scope, row.span_label.as_str()), (Some(row), None))
586 .is_some()
587 {
588 return Err(BenchmarkComparisonError::DuplicateCurrent {
589 scope: row.scope.clone(),
590 span_label: row.span_label.clone(),
591 });
592 }
593 }
594 for row in previous {
595 let previous = &mut rows
596 .entry((&row.scope, row.span_label.as_str()))
597 .or_insert((None, None))
598 .1;
599 if previous.replace(row).is_some() {
600 return Err(BenchmarkComparisonError::DuplicatePrevious {
601 scope: row.scope.clone(),
602 span_label: row.span_label.clone(),
603 });
604 }
605 }
606 let measurement = |row: &BenchmarkAggregateRow| -> Result<_, BenchmarkAverageError> {
607 Ok(BenchmarkMeasurement {
608 runs: row.runs,
609 average: row.average(scale)?,
610 })
611 };
612
613 rows.into_iter()
614 .map(|((scope, span_label), (current_row, previous_row))| {
615 Ok(BenchmarkComparisonRow {
616 span_label: span_label.to_owned(),
617 current: current_row.map(measurement).transpose()?,
618 previous: previous_row.map(measurement).transpose()?,
619 scope: scope.clone(),
620 })
621 })
622 .collect()
623}
624
625fn parse_marker_line(
626 line: &str,
627 source_line: usize,
628 source: BenchmarkEventSource,
629 config: &BenchmarkParserConfig,
630) -> Result<RawBenchmarkEvent, MalformedBenchmarkMarker> {
631 let mut parts = line.split('|');
632 let columns: [Option<&str>; 6] = std::array::from_fn(|_| parts.next());
635 let [
636 Some(prefix),
637 Some(label),
638 Some(instructions),
639 Some(heap_bytes),
640 Some(memory_bytes),
641 None,
642 ] = columns
643 else {
644 return Err(malformed(
645 source_line,
646 source,
647 line,
648 "expected five pipe-separated columns",
649 ));
650 };
651
652 if !config.prefixes.iter().any(|known| known == prefix) {
653 return Err(malformed(
654 source_line,
655 source,
656 line,
657 "prefix is not configured",
658 ));
659 }
660
661 if label.is_empty() {
662 return Err(malformed(source_line, source, line, "label is empty"));
663 }
664
665 let (span_label, kind) = split_label_kind(label).ok_or_else(|| {
666 malformed(
667 source_line,
668 source,
669 line,
670 "label must end in :start or :end",
671 )
672 })?;
673
674 let counters = BenchmarkCounters {
675 instructions: parse_counter(instructions, source_line, source, line, "instructions")?,
676 heap_bytes: parse_counter(heap_bytes, source_line, source, line, "heap_bytes")?,
677 memory_bytes: parse_counter(memory_bytes, source_line, source, line, "memory_bytes")?,
678 };
679 let suite = config.suite_derivation.derive_suite(span_label);
680
681 Ok(RawBenchmarkEvent {
682 prefix: prefix.to_string(),
683 suite,
684 span_label: span_label.to_string(),
685 kind,
686 counters,
687 source_line,
688 source,
689 })
690}
691
692fn parse_counter(
693 value: &str,
694 source_line: usize,
695 source: BenchmarkEventSource,
696 line: &str,
697 name: &str,
698) -> Result<u128, MalformedBenchmarkMarker> {
699 if value.is_empty() {
700 return Err(malformed(
701 source_line,
702 source,
703 line,
704 format!("{name} counter is empty"),
705 ));
706 }
707
708 value.parse::<u128>().map_err(|_| {
709 malformed(
710 source_line,
711 source,
712 line,
713 format!("{name} counter is not an unsigned integer"),
714 )
715 })
716}
717
718fn split_label_kind(label: &str) -> Option<(&str, BenchmarkEventKind)> {
719 let start = label.strip_suffix(":start");
720 let end = label.strip_suffix(":end");
721
722 match (start, end) {
723 (Some(span_label), None) if !span_label.is_empty() => {
724 Some((span_label, BenchmarkEventKind::Start))
725 }
726 (None, Some(span_label)) if !span_label.is_empty() => {
727 Some((span_label, BenchmarkEventKind::End))
728 }
729 _ => None,
730 }
731}
732
733fn has_configured_prefix(line: &str, prefixes: &[String]) -> bool {
734 prefixes.iter().any(|prefix| {
735 line.strip_prefix(prefix)
736 .is_some_and(|rest| rest.starts_with('|'))
737 })
738}
739
740fn malformed(
741 source_line: usize,
742 source: BenchmarkEventSource,
743 line: &str,
744 reason: impl Into<String>,
745) -> MalformedBenchmarkMarker {
746 MalformedBenchmarkMarker {
747 source_line,
748 source,
749 line: line.to_string(),
750 reason: reason.into(),
751 }
752}
753
754fn push_paired_span(
755 report: &mut BenchmarkSpanReport,
756 start: &RawBenchmarkEvent,
757 end: &RawBenchmarkEvent,
758) {
759 if let Some(delta) = end.counters.checked_delta(start.counters) {
760 report.spans.push(BenchmarkSpan {
761 suite: start.suite.clone(),
762 span_label: start.span_label.clone(),
763 start_line: start.source_line,
764 end_line: end.source_line,
765 end: end.counters,
766 delta,
767 });
768 } else {
769 report.invalid_spans.push(InvalidBenchmarkSpan {
770 start: start.clone(),
771 end: end.clone(),
772 });
773 }
774}
775
776#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)]
779enum AggregateScope<'a> {
780 Suite(&'a str),
781 All,
782}
783
784impl AggregateScope<'_> {
785 fn into_owned(self) -> BenchmarkScope {
786 match self {
787 Self::Suite(suite) => BenchmarkScope::Suite(suite.to_owned()),
788 Self::All => BenchmarkScope::All,
789 }
790 }
791}
792
793#[derive(Clone, Debug)]
794struct AggregateBuilder {
795 runs: u64,
796 total: BenchmarkCounters,
797 min: BenchmarkCounters,
798 max: BenchmarkCounters,
799 peak_end: BenchmarkCounters,
800}
801
802impl AggregateBuilder {
803 const fn new(span: &BenchmarkSpan) -> Self {
804 Self {
805 runs: 1,
806 total: span.delta,
807 min: span.delta,
808 max: span.delta,
809 peak_end: span.end,
810 }
811 }
812
813 fn push(&mut self, span: &BenchmarkSpan) -> Result<(), BenchmarkCounter> {
814 let runs = self.runs.checked_add(1).ok_or(BenchmarkCounter::Runs)?;
815 let total = self.total.checked_add(span.delta)?;
816 self.runs = runs;
817 self.total = total;
818 self.min.min_assign(span.delta);
819 self.max.max_assign(span.delta);
820 self.peak_end.max_assign(span.end);
821 Ok(())
822 }
823
824 const fn finish(self, scope: BenchmarkScope, span_label: String) -> BenchmarkAggregateRow {
825 BenchmarkAggregateRow {
826 span_label,
827 runs: self.runs,
828 total: self.total,
829 min: self.min,
830 max: self.max,
831 peak_end: self.peak_end,
832 scope,
833 }
834 }
835}
836
837fn add_span_to_aggregate<'a>(
838 rows: &mut BTreeMap<(AggregateScope<'a>, &'a str), AggregateBuilder>,
839 scope: AggregateScope<'a>,
840 span: &'a BenchmarkSpan,
841) -> Result<(), BenchmarkAggregateError> {
842 match rows.entry((scope, span.span_label.as_str())) {
843 Entry::Occupied(mut entry) => entry.get_mut().push(span).map_err(|counter| {
844 let (scope, span_label) = entry.key();
845 BenchmarkAggregateError {
846 span_label: (*span_label).to_owned(),
847 counter,
848 scope: scope.into_owned(),
849 }
850 }),
851 Entry::Vacant(entry) => {
852 entry.insert(AggregateBuilder::new(span));
853 Ok(())
854 }
855 }
856}
857
858#[cfg(test)]
859mod tests {
860 use super::{AggregateBuilder, BenchmarkCounter, BenchmarkCounters, BenchmarkSpan};
861
862 #[test]
863 fn aggregate_run_count_overflow_is_rejected_without_changing_totals() {
864 let span = BenchmarkSpan {
865 suite: "probe".into(),
866 span_label: "probe/run".into(),
867 start_line: 1,
868 end_line: 2,
869 end: BenchmarkCounters::default(),
870 delta: BenchmarkCounters::default(),
871 };
872 let mut builder = AggregateBuilder::new(&span);
873 builder.runs = u64::MAX;
874 assert_eq!(builder.push(&span), Err(BenchmarkCounter::Runs));
875 assert_eq!(builder.runs, u64::MAX);
876 assert_eq!(builder.total, BenchmarkCounters::default());
877 }
878}