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 Metrics' 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, thiserror::Error)]
19pub enum BenchmarkAverageError {
20    /// A mean is undefined when the run count is zero, even at scale zero.
21    #[error("benchmark run count is zero")]
22    ZeroDenominator,
23    /// The scaled average exceeds `u128::MAX`.
24    #[error("scaled benchmark average exceeds u128")]
25    Overflow,
26}
27
28fn checked_scaled_average(
29    total: u128,
30    runs: u64,
31    scale: u64,
32) -> Result<u128, BenchmarkAverageError> {
33    ic_metrics::checked_scaled_ratio(total, runs, scale).map_err(|error| match error {
34        ic_metrics::MeasurementRatioError::ZeroDenominator => {
35            BenchmarkAverageError::ZeroDenominator
36        }
37        ic_metrics::MeasurementRatioError::Overflow => BenchmarkAverageError::Overflow,
38    })
39}
40
41/// A comparison refuses ambiguous row identities or unrepresentable means.
42#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
43pub enum BenchmarkComparisonError {
44    /// More than one current row has the same scope and span.
45    #[error("duplicate current benchmark row: {scope:?}/{span_label}")]
46    DuplicateCurrent {
47        /// Repeated scope.
48        scope: BenchmarkScope,
49        /// Repeated span label.
50        span_label: String,
51    },
52    /// More than one previous row has the same scope and span.
53    #[error("duplicate previous benchmark row: {scope:?}/{span_label}")]
54    DuplicatePrevious {
55        /// Repeated scope.
56        scope: BenchmarkScope,
57        /// Repeated span label.
58        span_label: String,
59    },
60    /// A row's selected mean cannot be represented.
61    #[error("{0}")]
62    Average(#[from] BenchmarkAverageError),
63}
64
65/// Default prefix for benchmark marker lines.
66pub const DEFAULT_PREFIX: &str = "ICTK";
67
68/// Structural identity of one suite or the cross-suite aggregate.
69#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
70pub enum BenchmarkScope {
71    /// One caller-authored suite, including a suite literally named `ALL`.
72    Suite(String),
73    /// Aggregate across every suite.
74    All,
75}
76
77/// Marker prefixes, suite naming and noise handling selected by the caller.
78#[derive(Clone, Debug, Eq, PartialEq)]
79pub struct BenchmarkParserConfig {
80    /// Accepted marker prefixes.
81    pub prefixes: Vec<String>,
82    /// Rule used to derive each event’s suite.
83    pub suite_derivation: SuiteDerivation,
84    /// When enabled, non-empty lines without a configured marker prefix are
85    /// reported as malformed markers instead of ignored log noise.
86    pub strict: bool,
87}
88
89impl Default for BenchmarkParserConfig {
90    fn default() -> Self {
91        Self {
92            prefixes: vec![DEFAULT_PREFIX.to_string()],
93            suite_derivation: SuiteDerivation::FirstPathSegment,
94            strict: false,
95        }
96    }
97}
98
99/// How a marker label determines its suite.
100#[derive(Clone, Debug, Eq, PartialEq)]
101pub enum SuiteDerivation {
102    /// Use the first slash-delimited label segment, falling back to the whole label.
103    FirstPathSegment,
104    /// Use the supplied suite for every marker.
105    Fixed(String),
106}
107
108impl SuiteDerivation {
109    /// Derive the suite name for a marker label.
110    #[must_use]
111    pub fn derive_suite(&self, span_label: &str) -> String {
112        match self {
113            Self::FirstPathSegment => span_label
114                .split('/')
115                .next()
116                .filter(|part| !part.is_empty())
117                .unwrap_or(span_label)
118                .to_string(),
119            Self::Fixed(suite) => suite.clone(),
120        }
121    }
122}
123
124/// Whether a marker opens or closes a measured span.
125#[derive(Clone, Copy, Debug, Eq, PartialEq)]
126pub enum BenchmarkEventKind {
127    /// Opening counter sample.
128    Start,
129    /// Closing counter sample.
130    End,
131}
132
133/// Stream from which a marker was collected.
134#[derive(Clone, Copy, Debug, Eq, PartialEq)]
135pub enum BenchmarkEventSource {
136    /// Stream identity was not supplied.
137    Unknown,
138    /// Captured standard output.
139    Stdout,
140    /// Captured standard error.
141    Stderr,
142    /// Fetched canister log.
143    FetchedLog,
144}
145
146impl BenchmarkEventSource {
147    /// Return the stable stream label used in reports.
148    #[must_use]
149    pub const fn as_str(self) -> &'static str {
150        match self {
151            Self::Unknown => "unknown",
152            Self::Stdout => "stdout",
153            Self::Stderr => "stderr",
154            Self::FetchedLog => "fetched_log",
155        }
156    }
157}
158
159/// Exact instruction and byte counters reported by a marker.
160#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
161pub struct BenchmarkCounters {
162    /// Instruction counter in the representation described by this type.
163    pub instructions: u128,
164    /// Heap byte counter in the representation described by this type.
165    pub heap_bytes: u128,
166    /// Memory byte counter in the representation described by this type.
167    pub memory_bytes: u128,
168}
169
170impl BenchmarkCounters {
171    fn checked_delta(self, start: Self) -> Option<Self> {
172        Some(Self {
173            instructions: self.instructions.checked_sub(start.instructions)?,
174            heap_bytes: self.heap_bytes.checked_sub(start.heap_bytes)?,
175            memory_bytes: self.memory_bytes.checked_sub(start.memory_bytes)?,
176        })
177    }
178
179    fn checked_add(self, other: Self) -> Result<Self, BenchmarkCounter> {
180        Ok(Self {
181            instructions: self
182                .instructions
183                .checked_add(other.instructions)
184                .ok_or(BenchmarkCounter::Instructions)?,
185            heap_bytes: self
186                .heap_bytes
187                .checked_add(other.heap_bytes)
188                .ok_or(BenchmarkCounter::HeapBytes)?,
189            memory_bytes: self
190                .memory_bytes
191                .checked_add(other.memory_bytes)
192                .ok_or(BenchmarkCounter::MemoryBytes)?,
193        })
194    }
195
196    fn min_assign(&mut self, other: Self) {
197        self.instructions = self.instructions.min(other.instructions);
198        self.heap_bytes = self.heap_bytes.min(other.heap_bytes);
199        self.memory_bytes = self.memory_bytes.min(other.memory_bytes);
200    }
201
202    fn max_assign(&mut self, other: Self) {
203        self.instructions = self.instructions.max(other.instructions);
204        self.heap_bytes = self.heap_bytes.max(other.heap_bytes);
205        self.memory_bytes = self.memory_bytes.max(other.memory_bytes);
206    }
207}
208
209/// Parsed marker with its original stream and line provenance.
210#[derive(Clone, Debug, Eq, PartialEq)]
211pub struct RawBenchmarkEvent {
212    /// Matched marker prefix.
213    pub prefix: String,
214    /// Derived suite name.
215    pub suite: String,
216    /// Marker label used to group matching spans.
217    pub span_label: String,
218    /// Opening or closing marker.
219    pub kind: BenchmarkEventKind,
220    /// Counters observed at this marker.
221    pub counters: BenchmarkCounters,
222    /// One-based line number in the original input stream.
223    pub source_line: usize,
224    /// Originating stream.
225    pub source: BenchmarkEventSource,
226}
227
228/// Rejected marker and its parse diagnostic.
229#[derive(Clone, Debug, Eq, PartialEq)]
230pub struct MalformedBenchmarkMarker {
231    /// One-based line number in the original input stream.
232    pub source_line: usize,
233    /// Originating stream.
234    pub source: BenchmarkEventSource,
235    /// Original rejected line.
236    pub line: String,
237    /// Explanation of the rejected marker.
238    pub reason: String,
239}
240
241/// Parsed events, rejected markers and ignored log noise.
242#[derive(Clone, Debug, Default, Eq, PartialEq)]
243pub struct BenchmarkParseReport {
244    /// Successfully parsed markers in input order.
245    pub events: Vec<RawBenchmarkEvent>,
246    /// Rejected marker lines with diagnostics.
247    pub malformed_markers: Vec<MalformedBenchmarkMarker>,
248    /// Lines ignored as non-marker noise.
249    pub ignored_line_count: usize,
250}
251
252/// Matched start/end markers with nonnegative counter differences.
253#[derive(Clone, Debug, Eq, PartialEq)]
254pub struct BenchmarkSpan {
255    /// Derived suite name.
256    pub suite: String,
257    /// Marker label used to group matching spans.
258    pub span_label: String,
259    /// One-based line of the opening marker.
260    pub start_line: usize,
261    /// One-based line of the closing marker.
262    pub end_line: usize,
263    /// Counter values observed at the closing marker.
264    pub end: BenchmarkCounters,
265    /// Closing counters minus opening counters.
266    pub delta: BenchmarkCounters,
267}
268
269/// Matched markers whose counters decreased.
270#[derive(Clone, Debug, Eq, PartialEq)]
271pub struct InvalidBenchmarkSpan {
272    /// Opening marker.
273    pub start: RawBenchmarkEvent,
274    /// Closing marker.
275    pub end: RawBenchmarkEvent,
276}
277
278/// Pairing result retaining unmatched and invalid markers.
279#[derive(Clone, Debug, Default, Eq, PartialEq)]
280pub struct BenchmarkSpanReport {
281    /// Successfully paired spans.
282    pub spans: Vec<BenchmarkSpan>,
283    /// Markers without a matching start or end.
284    pub unpaired_markers: Vec<RawBenchmarkEvent>,
285    /// Matched pairs whose counters decreased.
286    pub invalid_spans: Vec<InvalidBenchmarkSpan>,
287}
288
289/// Exact totals and extrema for one scope and span label.
290#[derive(Clone, Debug, Eq, PartialEq)]
291pub struct BenchmarkAggregateRow {
292    /// Marker label used to group matching spans.
293    pub span_label: String,
294    /// Number of measured spans.
295    pub runs: u64,
296    /// Sum of the per-span counter differences.
297    pub total: BenchmarkCounters,
298    /// Smallest per-counter span difference.
299    pub min: BenchmarkCounters,
300    /// Largest per-counter span difference.
301    pub max: BenchmarkCounters,
302    /// Largest per-counter closing value.
303    pub peak_end: BenchmarkCounters,
304    /// Suite identity or the cross-suite aggregate.
305    pub scope: BenchmarkScope,
306}
307
308/// Floor-rounded scaled means of exact aggregate totals and run counts.
309///
310/// Fields are `floor(total * scale / runs)`; choose a positive scale to
311/// interpret them in the original counter unit. Scale zero projects zero.
312#[derive(Clone, Copy, Debug, Eq, PartialEq)]
313pub struct BenchmarkAverages {
314    /// Multiplier applied before dividing by the run count.
315    pub scale: u64,
316    /// Instruction counter in the representation described by this type.
317    pub instructions: u128,
318    /// Heap byte counter in the representation described by this type.
319    pub heap_bytes: u128,
320    /// Memory byte counter in the representation described by this type.
321    pub memory_bytes: u128,
322}
323
324/// Counter whose aggregate cannot be represented.
325#[derive(Clone, Copy, Debug, Eq, PartialEq)]
326pub enum BenchmarkCounter {
327    /// Measured span count.
328    Runs,
329    /// Instruction total.
330    Instructions,
331    /// Heap byte total.
332    HeapBytes,
333    /// Memory byte total.
334    MemoryBytes,
335}
336
337/// Overflow rejects the complete aggregation, without returning partial totals.
338#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
339#[error("benchmark aggregate overflow: scope={scope:?}, span={span_label}, counter={counter:?}")]
340pub struct BenchmarkAggregateError {
341    /// Marker label used to group matching spans.
342    pub span_label: String,
343    /// Counter that overflowed.
344    pub counter: BenchmarkCounter,
345    /// Suite identity or the cross-suite aggregate.
346    pub scope: BenchmarkScope,
347}
348
349/// Run count and scaled means for one present comparison side.
350#[derive(Clone, Copy, Debug, Eq, PartialEq)]
351pub struct BenchmarkMeasurement {
352    /// Number of measured spans.
353    pub runs: u64,
354    /// Scaled, floor-rounded means.
355    pub average: BenchmarkAverages,
356}
357
358/// Current and previous measurements, preserving missing rows and measured zero.
359#[derive(Clone, Debug, Eq, PartialEq)]
360pub struct BenchmarkComparisonRow {
361    /// Marker label used to group matching spans.
362    pub span_label: String,
363    /// Current measurement, absent when that side has no row.
364    pub current: Option<BenchmarkMeasurement>,
365    /// Previous measurement, absent when that side has no row.
366    pub previous: Option<BenchmarkMeasurement>,
367    /// Suite identity or the cross-suite aggregate.
368    pub scope: BenchmarkScope,
369}
370
371impl BenchmarkAggregateRow {
372    /// Project exact totals at the caller's scale, rounding down.
373    ///
374    /// # Errors
375    /// Zero runs and unrepresentable scaled results return [`BenchmarkAverageError`].
376    pub fn average(&self, scale: u64) -> Result<BenchmarkAverages, BenchmarkAverageError> {
377        Ok(BenchmarkAverages {
378            scale,
379            instructions: checked_scaled_average(self.total.instructions, self.runs, scale)?,
380            heap_bytes: checked_scaled_average(self.total.heap_bytes, self.runs, scale)?,
381            memory_bytes: checked_scaled_average(self.total.memory_bytes, self.runs, scale)?,
382        })
383    }
384}
385
386/// Format one marker using the supplied prefix, label and counters.
387///
388/// This raw formatter does not admit marker grammar. Use [`try_format_marker`]
389/// to refuse invalid fields before emission.
390#[must_use]
391pub fn format_marker(prefix: &str, label: &str, counters: BenchmarkCounters) -> String {
392    format!(
393        "{}|{}|{}|{}|{}",
394        prefix, label, counters.instructions, counters.heap_bytes, counters.memory_bytes
395    )
396}
397
398/// A marker field cannot be represented in the line-oriented marker grammar.
399#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
400pub enum BenchmarkMarkerError {
401    /// Prefix is empty or contains a pipe, CR or LF.
402    #[error("invalid benchmark marker prefix")]
403    InvalidPrefix,
404    /// Label contains a pipe/line break or lacks a nonempty span and start/end suffix.
405    #[error("invalid benchmark marker label")]
406    InvalidLabel,
407}
408
409/// Format one complete marker after admitting its prefix and start/end label.
410///
411/// Printable Unicode and spaces remain literal. No fields are escaped or trimmed.
412/// The parser must separately be configured to accept this prefix.
413///
414/// # Errors
415/// Refuses empty prefixes, pipe/CR/LF delimiters and labels without a nonempty
416/// span followed by `:start` or `:end`. Errors do not include the rejected text.
417pub fn try_format_marker(
418    prefix: &str,
419    label: &str,
420    counters: BenchmarkCounters,
421) -> Result<String, BenchmarkMarkerError> {
422    if prefix.is_empty() || prefix.contains(['|', '\r', '\n']) {
423        return Err(BenchmarkMarkerError::InvalidPrefix);
424    }
425    if label.contains(['|', '\r', '\n']) || split_label_kind(label).is_none() {
426        return Err(BenchmarkMarkerError::InvalidLabel);
427    }
428    Ok(format_marker(prefix, label, counters))
429}
430
431/// Parse markers without assigning a stream identity.
432#[must_use]
433pub fn parse_benchmark_events(input: &str, config: &BenchmarkParserConfig) -> BenchmarkParseReport {
434    parse_benchmark_events_from_source(input, config, BenchmarkEventSource::Unknown)
435}
436
437/// Parse markers while retaining the supplied stream identity.
438#[must_use]
439pub fn parse_benchmark_events_from_source(
440    input: &str,
441    config: &BenchmarkParserConfig,
442    source: BenchmarkEventSource,
443) -> BenchmarkParseReport {
444    let mut report = BenchmarkParseReport::default();
445    append_benchmark_events(&mut report, input, config, source);
446    report
447}
448
449fn append_benchmark_events(
450    report: &mut BenchmarkParseReport,
451    input: &str,
452    config: &BenchmarkParserConfig,
453    source: BenchmarkEventSource,
454) {
455    for (index, line) in input.lines().enumerate() {
456        let source_line = index + 1;
457        let Some(prefix) = configured_prefix(line, &config.prefixes) else {
458            if config.strict && !line.trim().is_empty() {
459                report.malformed_markers.push(malformed(
460                    source_line,
461                    source,
462                    line,
463                    "line does not use a configured marker prefix",
464                ));
465            } else {
466                report.ignored_line_count += 1;
467            }
468            continue;
469        };
470
471        match parse_marker_line(line, prefix, source_line, source, &config.suite_derivation) {
472            Ok(event) => report.events.push(event),
473            Err(marker) => report.malformed_markers.push(marker),
474        }
475    }
476}
477
478/// Parse separately captured stdout and stderr.
479///
480/// Separate streams do not carry global ordering, so events are returned in
481/// stdout-then-stderr order. If a benchmark span can start on one stream and end
482/// on the other, capture combined process output and use [`parse_benchmark_events`].
483#[must_use]
484pub fn parse_benchmark_events_from_captured_output(
485    stdout: &str,
486    stderr: &str,
487    config: &BenchmarkParserConfig,
488) -> BenchmarkParseReport {
489    let mut report = BenchmarkParseReport::default();
490    append_benchmark_events(&mut report, stdout, config, BenchmarkEventSource::Stdout);
491    append_benchmark_events(&mut report, stderr, config, BenchmarkEventSource::Stderr);
492    report
493}
494
495/// Pair markers by suite and label, using the most recent unmatched start.
496#[must_use]
497pub fn pair_benchmark_spans(events: &[RawBenchmarkEvent]) -> BenchmarkSpanReport {
498    pair_spans(events, |event| (&event.suite, &event.span_label))
499}
500
501/// Pair only markers with the same prefix, stream, suite and span label.
502///
503/// This prevents a start from one prefix/stream consuming another's end. Order
504/// within each origin remains caller-supplied and nesting remains last-start-first.
505/// Prefix/stream are grouping facts, not authenticated producer identities;
506/// callers must keep independent producers with otherwise equal fields separate.
507/// Returned spans and subsequent aggregation retain their existing suite/label shape.
508#[must_use]
509pub fn pair_benchmark_spans_by_origin(events: &[RawBenchmarkEvent]) -> BenchmarkSpanReport {
510    pair_spans(events, |event| {
511        (
512            &event.prefix,
513            event.source.as_str(),
514            &event.suite,
515            &event.span_label,
516        )
517    })
518}
519
520fn pair_spans<'a, K: Ord>(
521    events: &'a [RawBenchmarkEvent],
522    key: impl Fn(&'a RawBenchmarkEvent) -> K,
523) -> BenchmarkSpanReport {
524    let mut report = BenchmarkSpanReport::default();
525    let mut open_starts = BTreeMap::<_, Vec<&RawBenchmarkEvent>>::new();
526
527    for event in events {
528        let key = key(event);
529        match event.kind {
530            BenchmarkEventKind::Start => open_starts.entry(key).or_default().push(event),
531            BenchmarkEventKind::End => match open_starts.entry(key) {
532                Entry::Occupied(mut entry) => {
533                    let start = entry
534                        .get_mut()
535                        .pop()
536                        .expect("open marker stacks must remain nonempty");
537                    if entry.get().is_empty() {
538                        entry.remove();
539                    }
540                    push_paired_span(&mut report, start, event);
541                }
542                Entry::Vacant(_) => report.unpaired_markers.push(event.clone()),
543            },
544        }
545    }
546
547    for starts in open_starts.into_values() {
548        for event in starts {
549            report.unpaired_markers.push(event.clone());
550        }
551    }
552
553    report
554}
555
556/// Aggregate spans by named suite and across all suites.
557///
558/// # Errors
559/// Returns an error if any counter total or run count overflows. No partial
560/// report is returned, and totals are never wrapped or saturated.
561pub fn aggregate_benchmark_spans(
562    spans: &[BenchmarkSpan],
563) -> Result<Vec<BenchmarkAggregateRow>, BenchmarkAggregateError> {
564    let mut rows = BTreeMap::new();
565
566    for span in spans {
567        add_span_to_aggregate(&mut rows, AggregateScope::Suite(&span.suite), span)?;
568        add_span_to_aggregate(&mut rows, AggregateScope::All, span)?;
569    }
570
571    Ok(rows
572        .into_iter()
573        .map(|((scope, span_label), builder)| {
574            builder.finish(scope.into_owned(), span_label.to_owned())
575        })
576        .collect())
577}
578
579/// Pair current and previous means at the same caller-selected scale.
580///
581/// # Errors
582/// Returns a typed error for duplicate scope/span rows on either side or an invalid mean.
583pub fn compare_benchmark_aggregates(
584    current: &[BenchmarkAggregateRow],
585    previous: &[BenchmarkAggregateRow],
586    scale: u64,
587) -> Result<Vec<BenchmarkComparisonRow>, BenchmarkComparisonError> {
588    let mut rows = BTreeMap::new();
589    for row in current {
590        if rows
591            .insert((&row.scope, row.span_label.as_str()), (Some(row), None))
592            .is_some()
593        {
594            return Err(BenchmarkComparisonError::DuplicateCurrent {
595                scope: row.scope.clone(),
596                span_label: row.span_label.clone(),
597            });
598        }
599    }
600    for row in previous {
601        let previous = &mut rows
602            .entry((&row.scope, row.span_label.as_str()))
603            .or_insert((None, None))
604            .1;
605        if previous.replace(row).is_some() {
606            return Err(BenchmarkComparisonError::DuplicatePrevious {
607                scope: row.scope.clone(),
608                span_label: row.span_label.clone(),
609            });
610        }
611    }
612    let measurement = |row: &BenchmarkAggregateRow| -> Result<_, BenchmarkAverageError> {
613        Ok(BenchmarkMeasurement {
614            runs: row.runs,
615            average: row.average(scale)?,
616        })
617    };
618
619    rows.into_iter()
620        .map(|((scope, span_label), (current_row, previous_row))| {
621            Ok(BenchmarkComparisonRow {
622                span_label: span_label.to_owned(),
623                current: current_row.map(measurement).transpose()?,
624                previous: previous_row.map(measurement).transpose()?,
625                scope: scope.clone(),
626            })
627        })
628        .collect()
629}
630
631fn parse_marker_line(
632    line: &str,
633    admitted_prefix: &str,
634    source_line: usize,
635    source: BenchmarkEventSource,
636    suite_derivation: &SuiteDerivation,
637) -> Result<RawBenchmarkEvent, MalformedBenchmarkMarker> {
638    let invalid = |reason: String| malformed(source_line, source, line, reason);
639    let parse_counter = |value: &str, name: &str| {
640        if value.is_empty() {
641            return Err(invalid(format!("{name} counter is empty")));
642        }
643        value
644            .parse::<u128>()
645            .map_err(|_| invalid(format!("{name} counter is not an unsigned integer")))
646    };
647    let mut parts = line.split('|');
648    // Five fields and an absent sixth establish the complete marker shape
649    // without collecting an arbitrary number of columns from malformed logs.
650    let columns: [Option<&str>; 6] = std::array::from_fn(|_| parts.next());
651    let [
652        Some(prefix),
653        Some(label),
654        Some(instructions),
655        Some(heap_bytes),
656        Some(memory_bytes),
657        None,
658    ] = columns
659    else {
660        return Err(invalid("expected five pipe-separated columns".into()));
661    };
662
663    // Prefixes containing delimiters still earn the existing malformed-marker
664    // diagnostic after shape admission; they never become valid field prefixes.
665    if prefix != admitted_prefix {
666        return Err(invalid("prefix is not configured".into()));
667    }
668
669    if label.is_empty() {
670        return Err(invalid("label is empty".into()));
671    }
672
673    let (span_label, kind) = split_label_kind(label)
674        .ok_or_else(|| invalid("label must end in :start or :end".into()))?;
675
676    let counters = BenchmarkCounters {
677        instructions: parse_counter(instructions, "instructions")?,
678        heap_bytes: parse_counter(heap_bytes, "heap_bytes")?,
679        memory_bytes: parse_counter(memory_bytes, "memory_bytes")?,
680    };
681    let suite = suite_derivation.derive_suite(span_label);
682
683    Ok(RawBenchmarkEvent {
684        prefix: prefix.to_string(),
685        suite,
686        span_label: span_label.to_string(),
687        kind,
688        counters,
689        source_line,
690        source,
691    })
692}
693
694fn split_label_kind(label: &str) -> Option<(&str, BenchmarkEventKind)> {
695    label
696        .strip_suffix(":start")
697        .map(|span| (span, BenchmarkEventKind::Start))
698        .or_else(|| {
699            label
700                .strip_suffix(":end")
701                .map(|span| (span, BenchmarkEventKind::End))
702        })
703        .filter(|(span, _)| !span.is_empty())
704}
705
706fn configured_prefix<'a>(line: &str, prefixes: &'a [String]) -> Option<&'a str> {
707    let (field, _) = line.split_once('|')?;
708    prefixes
709        .iter()
710        .find(|known| known.as_str() == field)
711        .or_else(|| {
712            prefixes.iter().find(|known| {
713                line.strip_prefix(known.as_str())
714                    .is_some_and(|rest| rest.starts_with('|'))
715            })
716        })
717        .map(String::as_str)
718}
719
720fn malformed(
721    source_line: usize,
722    source: BenchmarkEventSource,
723    line: &str,
724    reason: impl Into<String>,
725) -> MalformedBenchmarkMarker {
726    MalformedBenchmarkMarker {
727        source_line,
728        source,
729        line: line.to_string(),
730        reason: reason.into(),
731    }
732}
733
734fn push_paired_span(
735    report: &mut BenchmarkSpanReport,
736    start: &RawBenchmarkEvent,
737    end: &RawBenchmarkEvent,
738) {
739    if let Some(delta) = end.counters.checked_delta(start.counters) {
740        report.spans.push(BenchmarkSpan {
741            suite: start.suite.clone(),
742            span_label: start.span_label.clone(),
743            start_line: start.source_line,
744            end_line: end.source_line,
745            end: end.counters,
746            delta,
747        });
748    } else {
749        report.invalid_spans.push(InvalidBenchmarkSpan {
750            start: start.clone(),
751            end: end.clone(),
752        });
753    }
754}
755
756// Borrow grouping identities until a row or error must own them. Keep the same
757// suite-before-all ordering as the public report scope.
758#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)]
759enum AggregateScope<'a> {
760    Suite(&'a str),
761    All,
762}
763
764impl AggregateScope<'_> {
765    fn into_owned(self) -> BenchmarkScope {
766        match self {
767            Self::Suite(suite) => BenchmarkScope::Suite(suite.to_owned()),
768            Self::All => BenchmarkScope::All,
769        }
770    }
771}
772
773#[derive(Clone, Debug)]
774struct AggregateBuilder {
775    runs: u64,
776    total: BenchmarkCounters,
777    min: BenchmarkCounters,
778    max: BenchmarkCounters,
779    peak_end: BenchmarkCounters,
780}
781
782impl AggregateBuilder {
783    const fn new(span: &BenchmarkSpan) -> Self {
784        Self {
785            runs: 1,
786            total: span.delta,
787            min: span.delta,
788            max: span.delta,
789            peak_end: span.end,
790        }
791    }
792
793    fn push(&mut self, span: &BenchmarkSpan) -> Result<(), BenchmarkCounter> {
794        let runs = self.runs.checked_add(1).ok_or(BenchmarkCounter::Runs)?;
795        let total = self.total.checked_add(span.delta)?;
796        self.runs = runs;
797        self.total = total;
798        self.min.min_assign(span.delta);
799        self.max.max_assign(span.delta);
800        self.peak_end.max_assign(span.end);
801        Ok(())
802    }
803
804    const fn finish(self, scope: BenchmarkScope, span_label: String) -> BenchmarkAggregateRow {
805        BenchmarkAggregateRow {
806            span_label,
807            runs: self.runs,
808            total: self.total,
809            min: self.min,
810            max: self.max,
811            peak_end: self.peak_end,
812            scope,
813        }
814    }
815}
816
817fn add_span_to_aggregate<'a>(
818    rows: &mut BTreeMap<(AggregateScope<'a>, &'a str), AggregateBuilder>,
819    scope: AggregateScope<'a>,
820    span: &'a BenchmarkSpan,
821) -> Result<(), BenchmarkAggregateError> {
822    match rows.entry((scope, span.span_label.as_str())) {
823        Entry::Occupied(mut entry) => entry.get_mut().push(span).map_err(|counter| {
824            let (scope, span_label) = entry.key();
825            BenchmarkAggregateError {
826                span_label: (*span_label).to_owned(),
827                counter,
828                scope: scope.into_owned(),
829            }
830        }),
831        Entry::Vacant(entry) => {
832            entry.insert(AggregateBuilder::new(span));
833            Ok(())
834        }
835    }
836}
837
838#[cfg(test)]
839mod tests {
840    use super::{AggregateBuilder, BenchmarkCounter, BenchmarkCounters, BenchmarkSpan};
841
842    #[test]
843    fn aggregate_run_count_overflow_is_rejected_without_changing_totals() {
844        let span = BenchmarkSpan {
845            suite: "probe".into(),
846            span_label: "probe/run".into(),
847            start_line: 1,
848            end_line: 2,
849            end: BenchmarkCounters::default(),
850            delta: BenchmarkCounters::default(),
851        };
852        let mut builder = AggregateBuilder::new(&span);
853        builder.runs = u64::MAX;
854        assert_eq!(builder.push(&span), Err(BenchmarkCounter::Runs));
855        assert_eq!(builder.runs, u64::MAX);
856        assert_eq!(builder.total, BenchmarkCounters::default());
857    }
858}