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 invalid = |reason: String| malformed(source_line, source, line, reason);
699    let parse_counter = |value: &str, name: &str| {
700        if value.is_empty() {
701            return Err(invalid(format!("{name} counter is empty")));
702        }
703        value
704            .parse::<u128>()
705            .map_err(|_| invalid(format!("{name} counter is not an unsigned integer")))
706    };
707    let mut parts = line.split('|');
708    // Five fields and an absent sixth establish the complete marker shape
709    // without collecting an arbitrary number of columns from malformed logs.
710    let columns: [Option<&str>; 6] = std::array::from_fn(|_| parts.next());
711    let [
712        Some(prefix),
713        Some(label),
714        Some(instructions),
715        Some(heap_bytes),
716        Some(memory_bytes),
717        None,
718    ] = columns
719    else {
720        return Err(invalid("expected five pipe-separated columns".into()));
721    };
722
723    if !config.prefixes.iter().any(|known| known == prefix) {
724        return Err(invalid("prefix is not configured".into()));
725    }
726
727    if label.is_empty() {
728        return Err(invalid("label is empty".into()));
729    }
730
731    let (span_label, kind) = split_label_kind(label)
732        .ok_or_else(|| invalid("label must end in :start or :end".into()))?;
733
734    let counters = BenchmarkCounters {
735        instructions: parse_counter(instructions, "instructions")?,
736        heap_bytes: parse_counter(heap_bytes, "heap_bytes")?,
737        memory_bytes: parse_counter(memory_bytes, "memory_bytes")?,
738    };
739    let suite = config.suite_derivation.derive_suite(span_label);
740
741    Ok(RawBenchmarkEvent {
742        prefix: prefix.to_string(),
743        suite,
744        span_label: span_label.to_string(),
745        kind,
746        counters,
747        source_line,
748        source,
749    })
750}
751
752fn split_label_kind(label: &str) -> Option<(&str, BenchmarkEventKind)> {
753    let start = label.strip_suffix(":start");
754    let end = label.strip_suffix(":end");
755
756    match (start, end) {
757        (Some(span_label), None) if !span_label.is_empty() => {
758            Some((span_label, BenchmarkEventKind::Start))
759        }
760        (None, Some(span_label)) if !span_label.is_empty() => {
761            Some((span_label, BenchmarkEventKind::End))
762        }
763        _ => None,
764    }
765}
766
767fn has_configured_prefix(line: &str, prefixes: &[String]) -> bool {
768    prefixes.iter().any(|prefix| {
769        line.strip_prefix(prefix)
770            .is_some_and(|rest| rest.starts_with('|'))
771    })
772}
773
774fn malformed(
775    source_line: usize,
776    source: BenchmarkEventSource,
777    line: &str,
778    reason: impl Into<String>,
779) -> MalformedBenchmarkMarker {
780    MalformedBenchmarkMarker {
781        source_line,
782        source,
783        line: line.to_string(),
784        reason: reason.into(),
785    }
786}
787
788fn push_paired_span(
789    report: &mut BenchmarkSpanReport,
790    start: &RawBenchmarkEvent,
791    end: &RawBenchmarkEvent,
792) {
793    if let Some(delta) = end.counters.checked_delta(start.counters) {
794        report.spans.push(BenchmarkSpan {
795            suite: start.suite.clone(),
796            span_label: start.span_label.clone(),
797            start_line: start.source_line,
798            end_line: end.source_line,
799            end: end.counters,
800            delta,
801        });
802    } else {
803        report.invalid_spans.push(InvalidBenchmarkSpan {
804            start: start.clone(),
805            end: end.clone(),
806        });
807    }
808}
809
810// Borrow grouping identities until a row or error must own them. Keep the same
811// suite-before-all ordering as the public report scope.
812#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)]
813enum AggregateScope<'a> {
814    Suite(&'a str),
815    All,
816}
817
818impl AggregateScope<'_> {
819    fn into_owned(self) -> BenchmarkScope {
820        match self {
821            Self::Suite(suite) => BenchmarkScope::Suite(suite.to_owned()),
822            Self::All => BenchmarkScope::All,
823        }
824    }
825}
826
827#[derive(Clone, Debug)]
828struct AggregateBuilder {
829    runs: u64,
830    total: BenchmarkCounters,
831    min: BenchmarkCounters,
832    max: BenchmarkCounters,
833    peak_end: BenchmarkCounters,
834}
835
836impl AggregateBuilder {
837    const fn new(span: &BenchmarkSpan) -> Self {
838        Self {
839            runs: 1,
840            total: span.delta,
841            min: span.delta,
842            max: span.delta,
843            peak_end: span.end,
844        }
845    }
846
847    fn push(&mut self, span: &BenchmarkSpan) -> Result<(), BenchmarkCounter> {
848        let runs = self.runs.checked_add(1).ok_or(BenchmarkCounter::Runs)?;
849        let total = self.total.checked_add(span.delta)?;
850        self.runs = runs;
851        self.total = total;
852        self.min.min_assign(span.delta);
853        self.max.max_assign(span.delta);
854        self.peak_end.max_assign(span.end);
855        Ok(())
856    }
857
858    const fn finish(self, scope: BenchmarkScope, span_label: String) -> BenchmarkAggregateRow {
859        BenchmarkAggregateRow {
860            span_label,
861            runs: self.runs,
862            total: self.total,
863            min: self.min,
864            max: self.max,
865            peak_end: self.peak_end,
866            scope,
867        }
868    }
869}
870
871fn add_span_to_aggregate<'a>(
872    rows: &mut BTreeMap<(AggregateScope<'a>, &'a str), AggregateBuilder>,
873    scope: AggregateScope<'a>,
874    span: &'a BenchmarkSpan,
875) -> Result<(), BenchmarkAggregateError> {
876    match rows.entry((scope, span.span_label.as_str())) {
877        Entry::Occupied(mut entry) => entry.get_mut().push(span).map_err(|counter| {
878            let (scope, span_label) = entry.key();
879            BenchmarkAggregateError {
880                span_label: (*span_label).to_owned(),
881                counter,
882                scope: scope.into_owned(),
883            }
884        }),
885        Entry::Vacant(entry) => {
886            entry.insert(AggregateBuilder::new(span));
887            Ok(())
888        }
889    }
890}
891
892#[cfg(test)]
893mod tests {
894    use super::{AggregateBuilder, BenchmarkCounter, BenchmarkCounters, BenchmarkSpan};
895
896    #[test]
897    fn aggregate_run_count_overflow_is_rejected_without_changing_totals() {
898        let span = BenchmarkSpan {
899            suite: "probe".into(),
900            span_label: "probe/run".into(),
901            start_line: 1,
902            end_line: 2,
903            end: BenchmarkCounters::default(),
904            delta: BenchmarkCounters::default(),
905        };
906        let mut builder = AggregateBuilder::new(&span);
907        builder.runs = u64::MAX;
908        assert_eq!(builder.push(&span), Err(BenchmarkCounter::Runs));
909        assert_eq!(builder.runs, u64::MAX);
910        assert_eq!(builder.total, BenchmarkCounters::default());
911    }
912}