Skip to main content

ic_testkit/benchmark/
mod.rs

1//! Parse, pair, aggregate, compare, and report compact benchmark markers.
2//!
3//! The normal pipeline is [`parse_benchmark_events`],
4//! [`pair_benchmark_spans`], [`aggregate_benchmark_spans`], and optionally
5//! [`compare_benchmark_aggregates`]. Native report output lives in
6//! the repository's `benchmark_report` example. Marker
7//! producers can use [`format_marker`] on the host or
8//! `ic_testkit::performance` in canister code.
9//!
10//! Totals and run counts remain exact integers. Means use Testkit's exact
11//! scaled integer projection, rounded down. Callers choose the scale and handle
12//! projection errors; reports use scale one in the original counter units.
13//! Comparisons retain current and previous means instead of percentage estimates.
14
15use std::collections::{BTreeMap, btree_map::Entry};
16
17/// Why a benchmark's scaled average cannot be represented.
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
19pub enum BenchmarkAverageError {
20    /// A mean is undefined when the run count is zero, even at scale zero.
21    ZeroDenominator,
22    /// The scaled average exceeds `u128::MAX`.
23    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    // Split before multiplying: only the final average must fit. The remainder
48    // and scale are at most u64::MAX, so their product always fits in u128.
49    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/// A comparison refuses ambiguous row identities or unrepresentable means.
57#[derive(Clone, Debug, Eq, PartialEq)]
58pub enum BenchmarkComparisonError {
59    /// More than one current row has the same scope and span.
60    DuplicateCurrent {
61        /// Repeated scope.
62        scope: BenchmarkScope,
63        /// Repeated span label.
64        span_label: String,
65    },
66    /// More than one previous row has the same scope and span.
67    DuplicatePrevious {
68        /// Repeated scope.
69        scope: BenchmarkScope,
70        /// Repeated span label.
71        span_label: String,
72    },
73    /// A row's selected mean cannot be represented.
74    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
106/// Default prefix for benchmark marker lines.
107pub const DEFAULT_PREFIX: &str = "ICTK";
108
109/// Structural identity of one suite or the cross-suite aggregate.
110#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
111pub enum BenchmarkScope {
112    /// One caller-authored suite, including a suite literally named `ALL`.
113    Suite(String),
114    /// Aggregate across every suite.
115    All,
116}
117
118/// Marker prefixes, suite naming and noise handling selected by the caller.
119#[derive(Clone, Debug, Eq, PartialEq)]
120pub struct BenchmarkParserConfig {
121    /// Accepted marker prefixes.
122    pub prefixes: Vec<String>,
123    /// Rule used to derive each event’s suite.
124    pub suite_derivation: SuiteDerivation,
125    /// When enabled, non-empty lines without a configured marker prefix are
126    /// reported as malformed markers instead of ignored log noise.
127    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/// How a marker label determines its suite.
141#[derive(Clone, Debug, Eq, PartialEq)]
142pub enum SuiteDerivation {
143    /// Use the first slash-delimited label segment, falling back to the whole label.
144    FirstPathSegment,
145    /// Use the supplied suite for every marker.
146    Fixed(String),
147}
148
149impl SuiteDerivation {
150    /// Derive the suite name for a marker label.
151    #[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/// Whether a marker opens or closes a measured span.
166#[derive(Clone, Copy, Debug, Eq, PartialEq)]
167pub enum BenchmarkEventKind {
168    /// Opening counter sample.
169    Start,
170    /// Closing counter sample.
171    End,
172}
173
174/// Stream from which a marker was collected.
175#[derive(Clone, Copy, Debug, Eq, PartialEq)]
176pub enum BenchmarkEventSource {
177    /// Stream identity was not supplied.
178    Unknown,
179    /// Captured standard output.
180    Stdout,
181    /// Captured standard error.
182    Stderr,
183    /// Fetched canister log.
184    FetchedLog,
185}
186
187impl BenchmarkEventSource {
188    /// Return the stable stream label used in reports.
189    #[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/// Exact instruction and byte counters reported by a marker.
201#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
202pub struct BenchmarkCounters {
203    /// Instruction counter in the representation described by this type.
204    pub instructions: u128,
205    /// Heap byte counter in the representation described by this type.
206    pub heap_bytes: u128,
207    /// Memory byte counter in the representation described by this type.
208    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/// Parsed marker with its original stream and line provenance.
251#[derive(Clone, Debug, Eq, PartialEq)]
252pub struct RawBenchmarkEvent {
253    /// Matched marker prefix.
254    pub prefix: String,
255    /// Derived suite name.
256    pub suite: String,
257    /// Marker label used to group matching spans.
258    pub span_label: String,
259    /// Opening or closing marker.
260    pub kind: BenchmarkEventKind,
261    /// Counters observed at this marker.
262    pub counters: BenchmarkCounters,
263    /// One-based line number in the original input stream.
264    pub source_line: usize,
265    /// Originating stream.
266    pub source: BenchmarkEventSource,
267}
268
269/// Rejected marker and its parse diagnostic.
270#[derive(Clone, Debug, Eq, PartialEq)]
271pub struct MalformedBenchmarkMarker {
272    /// One-based line number in the original input stream.
273    pub source_line: usize,
274    /// Originating stream.
275    pub source: BenchmarkEventSource,
276    /// Original rejected line.
277    pub line: String,
278    /// Explanation of the rejected marker.
279    pub reason: String,
280}
281
282/// Parsed events, rejected markers and ignored log noise.
283#[derive(Clone, Debug, Default, Eq, PartialEq)]
284pub struct BenchmarkParseReport {
285    /// Successfully parsed markers in input order.
286    pub events: Vec<RawBenchmarkEvent>,
287    /// Rejected marker lines with diagnostics.
288    pub malformed_markers: Vec<MalformedBenchmarkMarker>,
289    /// Lines ignored as non-marker noise.
290    pub ignored_line_count: usize,
291}
292
293/// Matched start/end markers with nonnegative counter differences.
294#[derive(Clone, Debug, Eq, PartialEq)]
295pub struct BenchmarkSpan {
296    /// Derived suite name.
297    pub suite: String,
298    /// Marker label used to group matching spans.
299    pub span_label: String,
300    /// One-based line of the opening marker.
301    pub start_line: usize,
302    /// One-based line of the closing marker.
303    pub end_line: usize,
304    /// Counter values observed at the closing marker.
305    pub end: BenchmarkCounters,
306    /// Closing counters minus opening counters.
307    pub delta: BenchmarkCounters,
308}
309
310/// Matched markers whose counters decreased.
311#[derive(Clone, Debug, Eq, PartialEq)]
312pub struct InvalidBenchmarkSpan {
313    /// Opening marker.
314    pub start: RawBenchmarkEvent,
315    /// Closing marker.
316    pub end: RawBenchmarkEvent,
317}
318
319/// Pairing result retaining unmatched and invalid markers.
320#[derive(Clone, Debug, Default, Eq, PartialEq)]
321pub struct BenchmarkSpanReport {
322    /// Successfully paired spans.
323    pub spans: Vec<BenchmarkSpan>,
324    /// Markers without a matching start or end.
325    pub unpaired_markers: Vec<RawBenchmarkEvent>,
326    /// Matched pairs whose counters decreased.
327    pub invalid_spans: Vec<InvalidBenchmarkSpan>,
328}
329
330/// Exact totals and extrema for one scope and span label.
331#[derive(Clone, Debug, Eq, PartialEq)]
332pub struct BenchmarkAggregateRow {
333    /// Marker label used to group matching spans.
334    pub span_label: String,
335    /// Number of measured spans.
336    pub runs: u64,
337    /// Sum of the per-span counter differences.
338    pub total: BenchmarkCounters,
339    /// Smallest per-counter span difference.
340    pub min: BenchmarkCounters,
341    /// Largest per-counter span difference.
342    pub max: BenchmarkCounters,
343    /// Largest per-counter closing value.
344    pub peak_end: BenchmarkCounters,
345    /// Suite identity or the cross-suite aggregate.
346    pub scope: BenchmarkScope,
347}
348
349/// Floor-rounded scaled means of exact aggregate totals and run counts.
350///
351/// Fields are `floor(total * scale / runs)`; choose a positive scale to
352/// interpret them in the original counter unit. Scale zero projects zero.
353#[derive(Clone, Copy, Debug, Eq, PartialEq)]
354pub struct BenchmarkAverages {
355    /// Multiplier applied before dividing by the run count.
356    pub scale: u64,
357    /// Instruction counter in the representation described by this type.
358    pub instructions: u128,
359    /// Heap byte counter in the representation described by this type.
360    pub heap_bytes: u128,
361    /// Memory byte counter in the representation described by this type.
362    pub memory_bytes: u128,
363}
364
365/// Counter whose aggregate cannot be represented.
366#[derive(Clone, Copy, Debug, Eq, PartialEq)]
367pub enum BenchmarkCounter {
368    /// Measured span count.
369    Runs,
370    /// Instruction total.
371    Instructions,
372    /// Heap byte total.
373    HeapBytes,
374    /// Memory byte total.
375    MemoryBytes,
376}
377
378/// Overflow rejects the complete aggregation, without returning partial totals.
379#[derive(Clone, Debug, Eq, PartialEq)]
380pub struct BenchmarkAggregateError {
381    /// Marker label used to group matching spans.
382    pub span_label: String,
383    /// Counter that overflowed.
384    pub counter: BenchmarkCounter,
385    /// Suite identity or the cross-suite aggregate.
386    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/// Run count and scaled means for one present comparison side.
402#[derive(Clone, Copy, Debug, Eq, PartialEq)]
403pub struct BenchmarkMeasurement {
404    /// Number of measured spans.
405    pub runs: u64,
406    /// Scaled, floor-rounded means.
407    pub average: BenchmarkAverages,
408}
409
410/// Current and previous measurements, preserving missing rows and measured zero.
411#[derive(Clone, Debug, Eq, PartialEq)]
412pub struct BenchmarkComparisonRow {
413    /// Marker label used to group matching spans.
414    pub span_label: String,
415    /// Current measurement, absent when that side has no row.
416    pub current: Option<BenchmarkMeasurement>,
417    /// Previous measurement, absent when that side has no row.
418    pub previous: Option<BenchmarkMeasurement>,
419    /// Suite identity or the cross-suite aggregate.
420    pub scope: BenchmarkScope,
421}
422
423impl BenchmarkAggregateRow {
424    /// Project exact totals at the caller's scale, rounding down.
425    ///
426    /// # Errors
427    /// Zero runs and unrepresentable scaled results return [`BenchmarkAverageError`].
428    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/// Format one marker using the supplied prefix, label and counters.
439#[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/// Parse markers without assigning a stream identity.
448#[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/// Parse markers while retaining the supplied stream identity.
454#[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/// Parse separately captured stdout and stderr.
495///
496/// Separate streams do not carry global ordering, so events are returned in
497/// stdout-then-stderr order. If a benchmark span can start on one stream and end
498/// on the other, capture combined process output and use [`parse_benchmark_events`].
499#[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/// Pair markers by suite and label, using the most recent unmatched start.
512#[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
550/// Aggregate spans by named suite and across all suites.
551///
552/// # Errors
553/// Returns an error if any counter total or run count overflows. No partial
554/// report is returned, and totals are never wrapped or saturated.
555pub 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
573/// Pair current and previous means at the same caller-selected scale.
574///
575/// # Errors
576/// Returns a typed error for duplicate scope/span rows on either side or an invalid mean.
577pub 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    // Five fields and an absent sixth establish the complete marker shape
633    // without collecting an arbitrary number of columns from malformed logs.
634    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// Borrow grouping identities until a row or error must own them. Keep the same
777// suite-before-all ordering as the public report scope.
778#[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}