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///
440/// This raw formatter does not admit marker grammar. Use [`try_format_marker`]
441/// to refuse invalid fields before emission.
442#[must_use]
443pub fn format_marker(prefix: &str, label: &str, counters: BenchmarkCounters) -> String {
444    format!(
445        "{}|{}|{}|{}|{}",
446        prefix, label, counters.instructions, counters.heap_bytes, counters.memory_bytes
447    )
448}
449
450/// A marker field cannot be represented in the line-oriented marker grammar.
451#[derive(Clone, Copy, Debug, Eq, PartialEq)]
452pub enum BenchmarkMarkerError {
453    /// Prefix is empty or contains a pipe, CR or LF.
454    InvalidPrefix,
455    /// Label contains a pipe/line break or lacks a nonempty span and start/end suffix.
456    InvalidLabel,
457}
458
459impl std::fmt::Display for BenchmarkMarkerError {
460    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
461        f.write_str(match self {
462            Self::InvalidPrefix => "invalid benchmark marker prefix",
463            Self::InvalidLabel => "invalid benchmark marker label",
464        })
465    }
466}
467
468impl std::error::Error for BenchmarkMarkerError {}
469
470/// Format one complete marker after admitting its prefix and start/end label.
471///
472/// Printable Unicode and spaces remain literal. No fields are escaped or trimmed.
473/// The parser must separately be configured to accept this prefix.
474///
475/// # Errors
476/// Refuses empty prefixes, pipe/CR/LF delimiters and labels without a nonempty
477/// span followed by `:start` or `:end`. Errors do not include the rejected text.
478pub fn try_format_marker(
479    prefix: &str,
480    label: &str,
481    counters: BenchmarkCounters,
482) -> Result<String, BenchmarkMarkerError> {
483    if prefix.is_empty() || prefix.contains(['|', '\r', '\n']) {
484        return Err(BenchmarkMarkerError::InvalidPrefix);
485    }
486    if label.contains(['|', '\r', '\n']) || split_label_kind(label).is_none() {
487        return Err(BenchmarkMarkerError::InvalidLabel);
488    }
489    Ok(format_marker(prefix, label, counters))
490}
491
492/// Parse markers without assigning a stream identity.
493#[must_use]
494pub fn parse_benchmark_events(input: &str, config: &BenchmarkParserConfig) -> BenchmarkParseReport {
495    parse_benchmark_events_from_source(input, config, BenchmarkEventSource::Unknown)
496}
497
498/// Parse markers while retaining the supplied stream identity.
499#[must_use]
500pub fn parse_benchmark_events_from_source(
501    input: &str,
502    config: &BenchmarkParserConfig,
503    source: BenchmarkEventSource,
504) -> BenchmarkParseReport {
505    let mut report = BenchmarkParseReport::default();
506    append_benchmark_events(&mut report, input, config, source);
507    report
508}
509
510fn append_benchmark_events(
511    report: &mut BenchmarkParseReport,
512    input: &str,
513    config: &BenchmarkParserConfig,
514    source: BenchmarkEventSource,
515) {
516    for (index, line) in input.lines().enumerate() {
517        let source_line = index + 1;
518        if !has_configured_prefix(line, &config.prefixes) {
519            if config.strict && !line.trim().is_empty() {
520                report.malformed_markers.push(malformed(
521                    source_line,
522                    source,
523                    line,
524                    "line does not use a configured marker prefix",
525                ));
526            } else {
527                report.ignored_line_count += 1;
528            }
529            continue;
530        }
531
532        match parse_marker_line(line, source_line, source, config) {
533            Ok(event) => report.events.push(event),
534            Err(marker) => report.malformed_markers.push(marker),
535        }
536    }
537}
538
539/// Parse separately captured stdout and stderr.
540///
541/// Separate streams do not carry global ordering, so events are returned in
542/// stdout-then-stderr order. If a benchmark span can start on one stream and end
543/// on the other, capture combined process output and use [`parse_benchmark_events`].
544#[must_use]
545pub fn parse_benchmark_events_from_captured_output(
546    stdout: &str,
547    stderr: &str,
548    config: &BenchmarkParserConfig,
549) -> BenchmarkParseReport {
550    let mut report = BenchmarkParseReport::default();
551    append_benchmark_events(&mut report, stdout, config, BenchmarkEventSource::Stdout);
552    append_benchmark_events(&mut report, stderr, config, BenchmarkEventSource::Stderr);
553    report
554}
555
556/// Pair markers by suite and label, using the most recent unmatched start.
557#[must_use]
558pub fn pair_benchmark_spans(events: &[RawBenchmarkEvent]) -> BenchmarkSpanReport {
559    pair_spans(events, |event| (&event.suite, &event.span_label))
560}
561
562/// Pair only markers with the same prefix, stream, suite and span label.
563///
564/// This prevents a start from one prefix/stream consuming another's end. Order
565/// within each origin remains caller-supplied and nesting remains last-start-first.
566/// Prefix/stream are grouping facts, not authenticated producer identities;
567/// callers must keep independent producers with otherwise equal fields separate.
568/// Returned spans and subsequent aggregation retain their existing suite/label shape.
569#[must_use]
570pub fn pair_benchmark_spans_by_origin(events: &[RawBenchmarkEvent]) -> BenchmarkSpanReport {
571    pair_spans(events, |event| {
572        (
573            &event.prefix,
574            event.source.as_str(),
575            &event.suite,
576            &event.span_label,
577        )
578    })
579}
580
581fn pair_spans<'a, K: Ord>(
582    events: &'a [RawBenchmarkEvent],
583    key: impl Fn(&'a RawBenchmarkEvent) -> K,
584) -> BenchmarkSpanReport {
585    let mut report = BenchmarkSpanReport::default();
586    let mut open_starts = BTreeMap::<_, Vec<&RawBenchmarkEvent>>::new();
587
588    for event in events {
589        let key = key(event);
590        match event.kind {
591            BenchmarkEventKind::Start => open_starts.entry(key).or_default().push(event),
592            BenchmarkEventKind::End => match open_starts.entry(key) {
593                Entry::Occupied(mut entry) => {
594                    let start = entry
595                        .get_mut()
596                        .pop()
597                        .expect("open marker stacks must remain nonempty");
598                    if entry.get().is_empty() {
599                        entry.remove();
600                    }
601                    push_paired_span(&mut report, start, event);
602                }
603                Entry::Vacant(_) => report.unpaired_markers.push(event.clone()),
604            },
605        }
606    }
607
608    for starts in open_starts.into_values() {
609        for event in starts {
610            report.unpaired_markers.push(event.clone());
611        }
612    }
613
614    report
615}
616
617/// Aggregate spans by named suite and across all suites.
618///
619/// # Errors
620/// Returns an error if any counter total or run count overflows. No partial
621/// report is returned, and totals are never wrapped or saturated.
622pub fn aggregate_benchmark_spans(
623    spans: &[BenchmarkSpan],
624) -> Result<Vec<BenchmarkAggregateRow>, BenchmarkAggregateError> {
625    let mut rows = BTreeMap::new();
626
627    for span in spans {
628        add_span_to_aggregate(&mut rows, AggregateScope::Suite(&span.suite), span)?;
629        add_span_to_aggregate(&mut rows, AggregateScope::All, span)?;
630    }
631
632    Ok(rows
633        .into_iter()
634        .map(|((scope, span_label), builder)| {
635            builder.finish(scope.into_owned(), span_label.to_owned())
636        })
637        .collect())
638}
639
640/// Pair current and previous means at the same caller-selected scale.
641///
642/// # Errors
643/// Returns a typed error for duplicate scope/span rows on either side or an invalid mean.
644pub fn compare_benchmark_aggregates(
645    current: &[BenchmarkAggregateRow],
646    previous: &[BenchmarkAggregateRow],
647    scale: u64,
648) -> Result<Vec<BenchmarkComparisonRow>, BenchmarkComparisonError> {
649    let mut rows = BTreeMap::new();
650    for row in current {
651        if rows
652            .insert((&row.scope, row.span_label.as_str()), (Some(row), None))
653            .is_some()
654        {
655            return Err(BenchmarkComparisonError::DuplicateCurrent {
656                scope: row.scope.clone(),
657                span_label: row.span_label.clone(),
658            });
659        }
660    }
661    for row in previous {
662        let previous = &mut rows
663            .entry((&row.scope, row.span_label.as_str()))
664            .or_insert((None, None))
665            .1;
666        if previous.replace(row).is_some() {
667            return Err(BenchmarkComparisonError::DuplicatePrevious {
668                scope: row.scope.clone(),
669                span_label: row.span_label.clone(),
670            });
671        }
672    }
673    let measurement = |row: &BenchmarkAggregateRow| -> Result<_, BenchmarkAverageError> {
674        Ok(BenchmarkMeasurement {
675            runs: row.runs,
676            average: row.average(scale)?,
677        })
678    };
679
680    rows.into_iter()
681        .map(|((scope, span_label), (current_row, previous_row))| {
682            Ok(BenchmarkComparisonRow {
683                span_label: span_label.to_owned(),
684                current: current_row.map(measurement).transpose()?,
685                previous: previous_row.map(measurement).transpose()?,
686                scope: scope.clone(),
687            })
688        })
689        .collect()
690}
691
692fn parse_marker_line(
693    line: &str,
694    source_line: usize,
695    source: BenchmarkEventSource,
696    config: &BenchmarkParserConfig,
697) -> Result<RawBenchmarkEvent, MalformedBenchmarkMarker> {
698    let mut parts = line.split('|');
699    // Five fields and an absent sixth establish the complete marker shape
700    // without collecting an arbitrary number of columns from malformed logs.
701    let columns: [Option<&str>; 6] = std::array::from_fn(|_| parts.next());
702    let [
703        Some(prefix),
704        Some(label),
705        Some(instructions),
706        Some(heap_bytes),
707        Some(memory_bytes),
708        None,
709    ] = columns
710    else {
711        return Err(malformed(
712            source_line,
713            source,
714            line,
715            "expected five pipe-separated columns",
716        ));
717    };
718
719    if !config.prefixes.iter().any(|known| known == prefix) {
720        return Err(malformed(
721            source_line,
722            source,
723            line,
724            "prefix is not configured",
725        ));
726    }
727
728    if label.is_empty() {
729        return Err(malformed(source_line, source, line, "label is empty"));
730    }
731
732    let (span_label, kind) = split_label_kind(label).ok_or_else(|| {
733        malformed(
734            source_line,
735            source,
736            line,
737            "label must end in :start or :end",
738        )
739    })?;
740
741    let counters = BenchmarkCounters {
742        instructions: parse_counter(instructions, source_line, source, line, "instructions")?,
743        heap_bytes: parse_counter(heap_bytes, source_line, source, line, "heap_bytes")?,
744        memory_bytes: parse_counter(memory_bytes, source_line, source, line, "memory_bytes")?,
745    };
746    let suite = config.suite_derivation.derive_suite(span_label);
747
748    Ok(RawBenchmarkEvent {
749        prefix: prefix.to_string(),
750        suite,
751        span_label: span_label.to_string(),
752        kind,
753        counters,
754        source_line,
755        source,
756    })
757}
758
759fn parse_counter(
760    value: &str,
761    source_line: usize,
762    source: BenchmarkEventSource,
763    line: &str,
764    name: &str,
765) -> Result<u128, MalformedBenchmarkMarker> {
766    if value.is_empty() {
767        return Err(malformed(
768            source_line,
769            source,
770            line,
771            format!("{name} counter is empty"),
772        ));
773    }
774
775    value.parse::<u128>().map_err(|_| {
776        malformed(
777            source_line,
778            source,
779            line,
780            format!("{name} counter is not an unsigned integer"),
781        )
782    })
783}
784
785fn split_label_kind(label: &str) -> Option<(&str, BenchmarkEventKind)> {
786    let start = label.strip_suffix(":start");
787    let end = label.strip_suffix(":end");
788
789    match (start, end) {
790        (Some(span_label), None) if !span_label.is_empty() => {
791            Some((span_label, BenchmarkEventKind::Start))
792        }
793        (None, Some(span_label)) if !span_label.is_empty() => {
794            Some((span_label, BenchmarkEventKind::End))
795        }
796        _ => None,
797    }
798}
799
800fn has_configured_prefix(line: &str, prefixes: &[String]) -> bool {
801    prefixes.iter().any(|prefix| {
802        line.strip_prefix(prefix)
803            .is_some_and(|rest| rest.starts_with('|'))
804    })
805}
806
807fn malformed(
808    source_line: usize,
809    source: BenchmarkEventSource,
810    line: &str,
811    reason: impl Into<String>,
812) -> MalformedBenchmarkMarker {
813    MalformedBenchmarkMarker {
814        source_line,
815        source,
816        line: line.to_string(),
817        reason: reason.into(),
818    }
819}
820
821fn push_paired_span(
822    report: &mut BenchmarkSpanReport,
823    start: &RawBenchmarkEvent,
824    end: &RawBenchmarkEvent,
825) {
826    if let Some(delta) = end.counters.checked_delta(start.counters) {
827        report.spans.push(BenchmarkSpan {
828            suite: start.suite.clone(),
829            span_label: start.span_label.clone(),
830            start_line: start.source_line,
831            end_line: end.source_line,
832            end: end.counters,
833            delta,
834        });
835    } else {
836        report.invalid_spans.push(InvalidBenchmarkSpan {
837            start: start.clone(),
838            end: end.clone(),
839        });
840    }
841}
842
843// Borrow grouping identities until a row or error must own them. Keep the same
844// suite-before-all ordering as the public report scope.
845#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)]
846enum AggregateScope<'a> {
847    Suite(&'a str),
848    All,
849}
850
851impl AggregateScope<'_> {
852    fn into_owned(self) -> BenchmarkScope {
853        match self {
854            Self::Suite(suite) => BenchmarkScope::Suite(suite.to_owned()),
855            Self::All => BenchmarkScope::All,
856        }
857    }
858}
859
860#[derive(Clone, Debug)]
861struct AggregateBuilder {
862    runs: u64,
863    total: BenchmarkCounters,
864    min: BenchmarkCounters,
865    max: BenchmarkCounters,
866    peak_end: BenchmarkCounters,
867}
868
869impl AggregateBuilder {
870    const fn new(span: &BenchmarkSpan) -> Self {
871        Self {
872            runs: 1,
873            total: span.delta,
874            min: span.delta,
875            max: span.delta,
876            peak_end: span.end,
877        }
878    }
879
880    fn push(&mut self, span: &BenchmarkSpan) -> Result<(), BenchmarkCounter> {
881        let runs = self.runs.checked_add(1).ok_or(BenchmarkCounter::Runs)?;
882        let total = self.total.checked_add(span.delta)?;
883        self.runs = runs;
884        self.total = total;
885        self.min.min_assign(span.delta);
886        self.max.max_assign(span.delta);
887        self.peak_end.max_assign(span.end);
888        Ok(())
889    }
890
891    const fn finish(self, scope: BenchmarkScope, span_label: String) -> BenchmarkAggregateRow {
892        BenchmarkAggregateRow {
893            span_label,
894            runs: self.runs,
895            total: self.total,
896            min: self.min,
897            max: self.max,
898            peak_end: self.peak_end,
899            scope,
900        }
901    }
902}
903
904fn add_span_to_aggregate<'a>(
905    rows: &mut BTreeMap<(AggregateScope<'a>, &'a str), AggregateBuilder>,
906    scope: AggregateScope<'a>,
907    span: &'a BenchmarkSpan,
908) -> Result<(), BenchmarkAggregateError> {
909    match rows.entry((scope, span.span_label.as_str())) {
910        Entry::Occupied(mut entry) => entry.get_mut().push(span).map_err(|counter| {
911            let (scope, span_label) = entry.key();
912            BenchmarkAggregateError {
913                span_label: (*span_label).to_owned(),
914                counter,
915                scope: scope.into_owned(),
916            }
917        }),
918        Entry::Vacant(entry) => {
919            entry.insert(AggregateBuilder::new(span));
920            Ok(())
921        }
922    }
923}
924
925#[cfg(test)]
926mod tests {
927    use super::{AggregateBuilder, BenchmarkCounter, BenchmarkCounters, BenchmarkSpan};
928
929    #[test]
930    fn aggregate_run_count_overflow_is_rejected_without_changing_totals() {
931        let span = BenchmarkSpan {
932            suite: "probe".into(),
933            span_label: "probe/run".into(),
934            start_line: 1,
935            end_line: 2,
936            end: BenchmarkCounters::default(),
937            delta: BenchmarkCounters::default(),
938        };
939        let mut builder = AggregateBuilder::new(&span);
940        builder.runs = u64::MAX;
941        assert_eq!(builder.push(&span), Err(BenchmarkCounter::Runs));
942        assert_eq!(builder.runs, u64::MAX);
943        assert_eq!(builder.total, BenchmarkCounters::default());
944    }
945}