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`] plus [`write_benchmark_report_dir`]. Marker
6//! producers can use [`format_marker`] on the host or
7//! [`crate::performance::Performance`] in canister code.
8
9use std::{
10    collections::{BTreeMap, btree_map::Entry},
11    ffi::OsStr,
12    fmt::Write as _,
13    fs, io,
14    path::{Path, PathBuf},
15};
16
17use serde_json::Value;
18
19/// Default prefix for benchmark marker lines.
20pub const DEFAULT_PREFIX: &str = "ICTK";
21const ALL_SUITES_LABEL: &str = "ALL";
22
23#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
24enum AggregateScope {
25    Suite(String),
26    All,
27}
28
29impl AggregateScope {
30    fn label(&self) -> &str {
31        match self {
32            Self::Suite(suite) => suite,
33            Self::All => ALL_SUITES_LABEL,
34        }
35    }
36}
37
38#[derive(Clone, Debug, Eq, PartialEq)]
39pub struct BenchmarkParserConfig {
40    pub prefixes: Vec<String>,
41    pub suite_derivation: SuiteDerivation,
42    /// When enabled, non-empty lines without a configured marker prefix are
43    /// reported as malformed markers instead of ignored log noise.
44    pub strict: bool,
45}
46
47impl Default for BenchmarkParserConfig {
48    fn default() -> Self {
49        Self {
50            prefixes: vec![DEFAULT_PREFIX.to_string()],
51            suite_derivation: SuiteDerivation::FirstPathSegment,
52            strict: false,
53        }
54    }
55}
56
57#[derive(Clone, Debug, Eq, PartialEq)]
58pub enum SuiteDerivation {
59    FirstPathSegment,
60    Fixed(String),
61}
62
63impl SuiteDerivation {
64    #[must_use]
65    pub fn derive_suite(&self, span_label: &str) -> String {
66        match self {
67            Self::FirstPathSegment => span_label
68                .split('/')
69                .next()
70                .filter(|part| !part.is_empty())
71                .unwrap_or(span_label)
72                .to_string(),
73            Self::Fixed(suite) => suite.clone(),
74        }
75    }
76}
77
78#[derive(Clone, Copy, Debug, Eq, PartialEq)]
79pub enum BenchmarkEventKind {
80    Start,
81    End,
82}
83
84#[derive(Clone, Copy, Debug, Eq, PartialEq)]
85pub enum BenchmarkEventSource {
86    Unknown,
87    Stdout,
88    Stderr,
89    FetchedLog,
90}
91
92impl BenchmarkEventSource {
93    #[must_use]
94    pub const fn as_str(self) -> &'static str {
95        match self {
96            Self::Unknown => "unknown",
97            Self::Stdout => "stdout",
98            Self::Stderr => "stderr",
99            Self::FetchedLog => "fetched_log",
100        }
101    }
102}
103
104#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
105pub struct BenchmarkCounters {
106    pub instructions: u128,
107    pub heap_bytes: u128,
108    pub memory_bytes: u128,
109    pub total_allocation: u128,
110}
111
112impl BenchmarkCounters {
113    fn checked_delta(self, start: Self) -> Option<Self> {
114        Some(Self {
115            instructions: self.instructions.checked_sub(start.instructions)?,
116            heap_bytes: self.heap_bytes.checked_sub(start.heap_bytes)?,
117            memory_bytes: self.memory_bytes.checked_sub(start.memory_bytes)?,
118            total_allocation: self.total_allocation.checked_sub(start.total_allocation)?,
119        })
120    }
121
122    fn checked_add(self, other: Self) -> Result<Self, BenchmarkCounter> {
123        Ok(Self {
124            instructions: self
125                .instructions
126                .checked_add(other.instructions)
127                .ok_or(BenchmarkCounter::Instructions)?,
128            heap_bytes: self
129                .heap_bytes
130                .checked_add(other.heap_bytes)
131                .ok_or(BenchmarkCounter::HeapBytes)?,
132            memory_bytes: self
133                .memory_bytes
134                .checked_add(other.memory_bytes)
135                .ok_or(BenchmarkCounter::MemoryBytes)?,
136            total_allocation: self
137                .total_allocation
138                .checked_add(other.total_allocation)
139                .ok_or(BenchmarkCounter::TotalAllocation)?,
140        })
141    }
142
143    fn min_assign(&mut self, other: Self) {
144        self.instructions = self.instructions.min(other.instructions);
145        self.heap_bytes = self.heap_bytes.min(other.heap_bytes);
146        self.memory_bytes = self.memory_bytes.min(other.memory_bytes);
147        self.total_allocation = self.total_allocation.min(other.total_allocation);
148    }
149
150    fn max_assign(&mut self, other: Self) {
151        self.instructions = self.instructions.max(other.instructions);
152        self.heap_bytes = self.heap_bytes.max(other.heap_bytes);
153        self.memory_bytes = self.memory_bytes.max(other.memory_bytes);
154        self.total_allocation = self.total_allocation.max(other.total_allocation);
155    }
156}
157
158#[derive(Clone, Debug, Eq, PartialEq)]
159pub struct RawBenchmarkEvent {
160    pub prefix: String,
161    pub label: String,
162    pub suite: String,
163    pub span_label: String,
164    pub kind: BenchmarkEventKind,
165    pub counters: BenchmarkCounters,
166    pub source_line: usize,
167    pub source: BenchmarkEventSource,
168}
169
170#[derive(Clone, Debug, Eq, PartialEq)]
171pub struct MalformedBenchmarkMarker {
172    pub source_line: usize,
173    pub source: BenchmarkEventSource,
174    pub line: String,
175    pub reason: String,
176}
177
178#[derive(Clone, Debug, Default, Eq, PartialEq)]
179pub struct BenchmarkParseReport {
180    pub events: Vec<RawBenchmarkEvent>,
181    pub malformed_markers: Vec<MalformedBenchmarkMarker>,
182    pub ignored_line_count: usize,
183}
184
185#[derive(Clone, Debug, Eq, PartialEq)]
186pub struct BenchmarkSpan {
187    pub suite: String,
188    pub span_label: String,
189    pub start_line: usize,
190    pub end_line: usize,
191    pub start: BenchmarkCounters,
192    pub end: BenchmarkCounters,
193    pub delta: BenchmarkCounters,
194}
195
196#[derive(Clone, Debug, Eq, PartialEq)]
197pub enum UnpairedBenchmarkMarkerKind {
198    Start,
199    End,
200}
201
202#[derive(Clone, Debug, Eq, PartialEq)]
203pub struct UnpairedBenchmarkMarker {
204    pub event: RawBenchmarkEvent,
205    pub kind: UnpairedBenchmarkMarkerKind,
206}
207
208#[derive(Clone, Debug, Eq, PartialEq)]
209pub struct InvalidBenchmarkSpan {
210    pub start: RawBenchmarkEvent,
211    pub end: RawBenchmarkEvent,
212    pub reason: String,
213}
214
215#[derive(Clone, Debug, Default, Eq, PartialEq)]
216pub struct BenchmarkSpanReport {
217    pub spans: Vec<BenchmarkSpan>,
218    pub unpaired_markers: Vec<UnpairedBenchmarkMarker>,
219    pub invalid_spans: Vec<InvalidBenchmarkSpan>,
220}
221
222#[derive(Clone, Debug, Eq, PartialEq)]
223pub struct BenchmarkAggregateRow {
224    pub span_label: String,
225    pub runs: u64,
226    pub total: BenchmarkCounters,
227    pub min: BenchmarkCounters,
228    pub max: BenchmarkCounters,
229    pub peak_end: BenchmarkCounters,
230    scope: AggregateScope,
231}
232
233#[derive(Clone, Copy, Debug, Default, PartialEq)]
234pub struct BenchmarkAverages {
235    pub instructions: f64,
236    pub heap_bytes: f64,
237    pub memory_bytes: f64,
238    pub total_allocation: f64,
239}
240
241#[derive(Clone, Debug, Default, Eq, PartialEq)]
242pub struct BenchmarkAggregateReport {
243    pub rows: Vec<BenchmarkAggregateRow>,
244}
245
246/// Counter whose aggregate cannot be represented.
247#[derive(Clone, Copy, Debug, Eq, PartialEq)]
248pub enum BenchmarkCounter {
249    Runs,
250    Instructions,
251    HeapBytes,
252    MemoryBytes,
253    TotalAllocation,
254}
255
256/// Overflow rejects the complete aggregation, without returning partial totals.
257#[derive(Clone, Debug, Eq, PartialEq)]
258pub struct BenchmarkAggregateError {
259    pub span_label: String,
260    pub counter: BenchmarkCounter,
261    scope: AggregateScope,
262}
263
264impl BenchmarkAggregateError {
265    /// Suite label, or `ALL` for the cross-suite aggregate.
266    #[must_use]
267    pub fn suite(&self) -> &str {
268        self.scope.label()
269    }
270
271    /// Whether the overflow belongs to the cross-suite aggregate.
272    #[must_use]
273    pub const fn is_all_suites(&self) -> bool {
274        matches!(self.scope, AggregateScope::All)
275    }
276}
277
278impl std::fmt::Display for BenchmarkAggregateError {
279    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280        write!(
281            formatter,
282            "benchmark aggregate overflow: scope={:?}, span={}, counter={:?}",
283            self.scope, self.span_label, self.counter
284        )
285    }
286}
287
288impl std::error::Error for BenchmarkAggregateError {}
289
290#[derive(Clone, Debug, PartialEq)]
291pub struct BenchmarkComparisonRow {
292    pub span_label: String,
293    pub current_runs: Option<u64>,
294    pub previous_runs: Option<u64>,
295    pub instructions_avg_change_percent: Option<f64>,
296    pub heap_bytes_avg_change_percent: Option<f64>,
297    pub memory_bytes_avg_change_percent: Option<f64>,
298    pub total_allocation_avg_change_percent: Option<f64>,
299    scope: AggregateScope,
300}
301
302impl BenchmarkAggregateRow {
303    /// Averages derived from the current totals and run count.
304    #[must_use]
305    pub fn average(&self) -> BenchmarkAverages {
306        averages(self.total, self.runs)
307    }
308
309    /// Suite label, or `ALL` for the cross-suite aggregate.
310    #[must_use]
311    pub fn suite(&self) -> &str {
312        self.scope.label()
313    }
314
315    /// Report whether this row aggregates matching spans across every suite.
316    #[must_use]
317    pub const fn is_all_suites(&self) -> bool {
318        matches!(self.scope, AggregateScope::All)
319    }
320}
321
322impl BenchmarkComparisonRow {
323    /// Suite label, or `ALL` for the cross-suite aggregate.
324    #[must_use]
325    pub fn suite(&self) -> &str {
326        self.scope.label()
327    }
328
329    /// Report whether this row compares the aggregate across every suite.
330    #[must_use]
331    pub const fn is_all_suites(&self) -> bool {
332        matches!(self.scope, AggregateScope::All)
333    }
334}
335
336#[derive(Clone, Debug, Default, PartialEq)]
337pub struct BenchmarkComparisonReport {
338    pub rows: Vec<BenchmarkComparisonRow>,
339}
340
341#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
342pub struct BenchmarkRunMetadata {
343    pub timestamp: String,
344    pub run_directory_name: String,
345    pub run_index: u32,
346    pub git_commit_hash: Option<String>,
347    pub git_commit_short_hash: Option<String>,
348    pub ic_testkit_version: String,
349    pub pocket_ic_version: String,
350    pub rustc_version: String,
351    pub benchmark_command: Option<String>,
352    pub selected_previous_run: Option<String>,
353}
354
355#[derive(Clone, Debug, PartialEq)]
356pub struct BenchmarkRunReport {
357    pub parse: BenchmarkParseReport,
358    pub spans: BenchmarkSpanReport,
359    pub aggregates: BenchmarkAggregateReport,
360    pub comparison: Option<BenchmarkComparisonReport>,
361    pub metadata: BenchmarkRunMetadata,
362}
363
364#[derive(Clone, Debug, Eq, PartialEq)]
365pub struct BenchmarkRunDirectory {
366    pub path: PathBuf,
367    pub directory_name: String,
368    pub run_index: u32,
369    pub git_commit_hash: Option<String>,
370    pub git_commit_short_hash: Option<String>,
371}
372
373#[must_use]
374pub fn format_marker(prefix: &str, label: &str, counters: BenchmarkCounters) -> String {
375    format!(
376        "{}|{}|{}|{}|{}|{}",
377        prefix,
378        label,
379        counters.instructions,
380        counters.heap_bytes,
381        counters.memory_bytes,
382        counters.total_allocation
383    )
384}
385
386#[must_use]
387pub fn benchmark_run_directory_name(
388    timestamp: &str,
389    git_commit_short_hash: Option<&str>,
390    run_index: u32,
391) -> String {
392    let commit = git_commit_short_hash
393        .filter(|hash| !hash.is_empty())
394        .unwrap_or("unknown");
395    format!("{timestamp}-{commit}-{run_index:04}")
396}
397
398/// Compute the next available benchmark run path for one timestamp and commit.
399///
400/// This function does not create or reserve the returned directory. Callers
401/// allocating the same prefix concurrently must synchronize that shared
402/// resource or provide unique timestamps.
403/// A missing runs root starts at index one; other directory errors are returned.
404pub fn next_benchmark_run_directory(
405    runs_root: impl AsRef<Path>,
406    timestamp: &str,
407    git_commit_hash: Option<&str>,
408) -> io::Result<BenchmarkRunDirectory> {
409    let runs_root = runs_root.as_ref();
410    let git_commit_hash = git_commit_hash.filter(|hash| !hash.is_empty());
411    let git_commit_short_hash = git_commit_hash.map(short_commit_hash);
412    let prefix = format!(
413        "{}-{}-",
414        timestamp,
415        git_commit_short_hash.as_deref().unwrap_or("unknown")
416    );
417    let run_index = next_run_index_for_prefix(runs_root, &prefix)?;
418    let directory_name =
419        benchmark_run_directory_name(timestamp, git_commit_short_hash.as_deref(), run_index);
420
421    Ok(BenchmarkRunDirectory {
422        path: runs_root.join(&directory_name),
423        directory_name,
424        run_index,
425        git_commit_hash: git_commit_hash.map(str::to_string),
426        git_commit_short_hash,
427    })
428}
429
430/// Find the latest earlier run with readable metadata and a matching command.
431///
432/// Names must follow [`benchmark_run_directory_name`]; indices are ordered
433/// numerically even when they exceed four digits. Eligible runs are ranked by
434/// metadata timestamp, directory prefix, then numeric index. An invalid current
435/// name returns [`io::ErrorKind::InvalidInput`]. Unreadable or malformed
436/// candidates are skipped.
437/// A missing runs root has no previous run; other directory errors are returned.
438pub fn find_latest_previous_run(
439    runs_root: impl AsRef<Path>,
440    current_run_directory_name: &str,
441    benchmark_command: Option<&str>,
442) -> io::Result<Option<PathBuf>> {
443    let runs_root = runs_root.as_ref();
444    let current_key = run_directory_sort_key(current_run_directory_name).ok_or_else(|| {
445        io::Error::new(
446            io::ErrorKind::InvalidInput,
447            "invalid benchmark run directory name",
448        )
449    })?;
450    let mut latest: Option<((String, String, u32), PathBuf)> = None;
451
452    let entries = match fs::read_dir(runs_root) {
453        Ok(entries) => entries,
454        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
455        Err(error) => return Err(error),
456    };
457    for entry in entries {
458        let entry = entry?;
459        if !entry.file_type()?.is_dir() {
460            continue;
461        }
462
463        let directory_name = entry.file_name().to_string_lossy().into_owned();
464        let Some((prefix, index)) = run_directory_sort_key(&directory_name) else {
465            continue;
466        };
467        if (prefix, index) >= current_key {
468            continue;
469        }
470
471        let metadata_path = entry.path().join("metadata.json");
472        let Ok(metadata) = read_benchmark_run_metadata(&metadata_path) else {
473            continue;
474        };
475
476        if let Some(command) = benchmark_command
477            && metadata.benchmark_command.as_deref() != Some(command)
478        {
479            continue;
480        }
481
482        let key = (metadata.timestamp, prefix.to_owned(), index);
483        if latest
484            .as_ref()
485            .is_none_or(|(latest_key, _)| key >= *latest_key)
486        {
487            latest = Some((key, entry.path()));
488        }
489    }
490
491    Ok(latest.map(|(_, path)| path))
492}
493
494pub fn read_benchmark_run_metadata(path: impl AsRef<Path>) -> io::Result<BenchmarkRunMetadata> {
495    let input = fs::read_to_string(path)?;
496    let value = serde_json::from_str::<Value>(&input).map_err(metadata_json_error)?;
497
498    if !value.is_object() {
499        return Err(io::Error::new(
500            io::ErrorKind::InvalidData,
501            "benchmark metadata JSON must be an object",
502        ));
503    }
504    serde_json::from_value(value).map_err(metadata_json_error)
505}
506
507#[must_use]
508pub fn parse_benchmark_events(input: &str, config: &BenchmarkParserConfig) -> BenchmarkParseReport {
509    parse_benchmark_events_from_source(input, config, BenchmarkEventSource::Unknown)
510}
511
512#[must_use]
513pub fn parse_benchmark_events_from_source(
514    input: &str,
515    config: &BenchmarkParserConfig,
516    source: BenchmarkEventSource,
517) -> BenchmarkParseReport {
518    let mut report = BenchmarkParseReport::default();
519
520    for (index, line) in input.lines().enumerate() {
521        let source_line = index + 1;
522        if !has_configured_prefix(line, &config.prefixes) {
523            if config.strict && !line.trim().is_empty() {
524                report.malformed_markers.push(malformed(
525                    source_line,
526                    source,
527                    line,
528                    "line does not use a configured marker prefix",
529                ));
530            } else {
531                report.ignored_line_count += 1;
532            }
533            continue;
534        }
535
536        match parse_marker_line(line, source_line, source, config) {
537            Ok(event) => report.events.push(event),
538            Err(marker) => report.malformed_markers.push(marker),
539        }
540    }
541
542    report
543}
544
545/// Parse separately captured stdout and stderr.
546///
547/// Separate streams do not carry global ordering, so events are returned in
548/// stdout-then-stderr order. If a benchmark span can start on one stream and end
549/// on the other, capture combined process output and use [`parse_benchmark_events`].
550#[must_use]
551pub fn parse_benchmark_events_from_captured_output(
552    stdout: &str,
553    stderr: &str,
554    config: &BenchmarkParserConfig,
555) -> BenchmarkParseReport {
556    let mut report =
557        parse_benchmark_events_from_source(stdout, config, BenchmarkEventSource::Stdout);
558    let stderr_report =
559        parse_benchmark_events_from_source(stderr, config, BenchmarkEventSource::Stderr);
560
561    report.events.extend(stderr_report.events);
562    report
563        .malformed_markers
564        .extend(stderr_report.malformed_markers);
565    report.ignored_line_count += stderr_report.ignored_line_count;
566    report
567}
568
569#[must_use]
570pub fn pair_benchmark_spans(events: &[RawBenchmarkEvent]) -> BenchmarkSpanReport {
571    let mut report = BenchmarkSpanReport::default();
572    let mut open_starts: BTreeMap<(&str, &str), Vec<&RawBenchmarkEvent>> = BTreeMap::new();
573
574    for event in events {
575        let key = (event.suite.as_str(), event.span_label.as_str());
576        match event.kind {
577            BenchmarkEventKind::Start => open_starts.entry(key).or_default().push(event),
578            BenchmarkEventKind::End => match open_starts.entry(key) {
579                Entry::Occupied(mut entry) => {
580                    let start = entry
581                        .get_mut()
582                        .pop()
583                        .expect("open marker stacks must remain nonempty");
584                    if entry.get().is_empty() {
585                        entry.remove();
586                    }
587                    push_paired_span(&mut report, start, event);
588                }
589                Entry::Vacant(_) => report.unpaired_markers.push(UnpairedBenchmarkMarker {
590                    event: event.clone(),
591                    kind: UnpairedBenchmarkMarkerKind::End,
592                }),
593            },
594        }
595    }
596
597    for starts in open_starts.into_values() {
598        for event in starts {
599            report.unpaired_markers.push(UnpairedBenchmarkMarker {
600                event: event.clone(),
601                kind: UnpairedBenchmarkMarkerKind::Start,
602            });
603        }
604    }
605
606    report
607}
608
609/// Aggregate spans by named suite and across all suites.
610///
611/// Returns an error if any counter total or run count overflows. No partial
612/// report is returned, and totals are never wrapped or saturated.
613pub fn aggregate_benchmark_spans(
614    spans: &[BenchmarkSpan],
615) -> Result<BenchmarkAggregateReport, BenchmarkAggregateError> {
616    let mut rows: BTreeMap<(AggregateScope, String), AggregateBuilder> = BTreeMap::new();
617
618    for span in spans {
619        add_span_to_aggregate(
620            &mut rows,
621            AggregateScope::Suite(span.suite.clone()),
622            &span.span_label,
623            span,
624        )?;
625        add_span_to_aggregate(&mut rows, AggregateScope::All, &span.span_label, span)?;
626    }
627
628    Ok(BenchmarkAggregateReport {
629        rows: rows
630            .into_iter()
631            .map(|((scope, span_label), builder)| builder.finish(scope, span_label))
632            .collect(),
633    })
634}
635
636#[must_use]
637pub fn compare_benchmark_aggregates(
638    current: &[BenchmarkAggregateRow],
639    previous: &[BenchmarkAggregateRow],
640) -> BenchmarkComparisonReport {
641    let current_by_key = aggregate_rows_by_key(current);
642    let previous_by_key = aggregate_rows_by_key(previous);
643    let mut keys = current_by_key.keys().copied().collect::<Vec<_>>();
644
645    for key in previous_by_key.keys() {
646        if !current_by_key.contains_key(key) {
647            keys.push(*key);
648        }
649    }
650
651    keys.sort();
652
653    BenchmarkComparisonReport {
654        rows: keys
655            .into_iter()
656            .map(|(scope, span_label)| {
657                let current_row = current_by_key.get(&(scope, span_label));
658                let previous_row = previous_by_key.get(&(scope, span_label));
659                let current_average = current_row.map(|row| row.average());
660                let previous_average = previous_row.map(|row| row.average());
661                BenchmarkComparisonRow {
662                    span_label: span_label.to_owned(),
663                    current_runs: current_row.map(|row| row.runs),
664                    previous_runs: previous_row.map(|row| row.runs),
665                    instructions_avg_change_percent: compare_average(
666                        current_average.map(|average| average.instructions),
667                        previous_average.map(|average| average.instructions),
668                    ),
669                    heap_bytes_avg_change_percent: compare_average(
670                        current_average.map(|average| average.heap_bytes),
671                        previous_average.map(|average| average.heap_bytes),
672                    ),
673                    memory_bytes_avg_change_percent: compare_average(
674                        current_average.map(|average| average.memory_bytes),
675                        previous_average.map(|average| average.memory_bytes),
676                    ),
677                    total_allocation_avg_change_percent: compare_average(
678                        current_average.map(|average| average.total_allocation),
679                        previous_average.map(|average| average.total_allocation),
680                    ),
681                    scope: scope.clone(),
682                }
683            })
684            .collect(),
685    }
686}
687
688/// Write one complete benchmark report into the requested directory.
689///
690/// The directory is caller-owned. Concurrent writers must use unique paths or
691/// synchronize access to the same path outside this function.
692pub fn write_benchmark_report_dir(
693    report: &BenchmarkRunReport,
694    path: impl AsRef<Path>,
695) -> io::Result<()> {
696    let path = path.as_ref();
697    fs::create_dir_all(path)?;
698
699    fs::write(
700        path.join("raw-events.csv"),
701        raw_events_csv(&report.parse.events),
702    )?;
703    fs::write(
704        path.join("malformed-markers.csv"),
705        malformed_markers_csv(&report.parse.malformed_markers),
706    )?;
707    fs::write(path.join("spans.csv"), spans_csv(&report.spans.spans))?;
708    fs::write(
709        path.join("unpaired-markers.csv"),
710        unpaired_markers_csv(&report.spans.unpaired_markers),
711    )?;
712    fs::write(
713        path.join("invalid-spans.csv"),
714        invalid_spans_csv(&report.spans.invalid_spans),
715    )?;
716    fs::write(
717        path.join("suite-aggregates.csv"),
718        aggregates_csv(
719            report
720                .aggregates
721                .rows
722                .iter()
723                .filter(|row| !row.is_all_suites()),
724        ),
725    )?;
726    fs::write(
727        path.join("all-aggregates.csv"),
728        aggregates_csv(
729            report
730                .aggregates
731                .rows
732                .iter()
733                .filter(|row| row.is_all_suites()),
734        ),
735    )?;
736    fs::write(
737        path.join("comparison.csv"),
738        comparison_csv(report.comparison.as_ref()),
739    )?;
740    fs::write(
741        path.join("bench-summary.md"),
742        benchmark_summary_markdown(report),
743    )?;
744    fs::write(path.join("metadata.json"), metadata_json(&report.metadata))?;
745
746    Ok(())
747}
748
749fn parse_marker_line(
750    line: &str,
751    source_line: usize,
752    source: BenchmarkEventSource,
753    config: &BenchmarkParserConfig,
754) -> Result<RawBenchmarkEvent, MalformedBenchmarkMarker> {
755    let parts = line.split('|').collect::<Vec<_>>();
756    if parts.len() != 6 {
757        return Err(malformed(
758            source_line,
759            source,
760            line,
761            "expected six pipe-separated columns",
762        ));
763    }
764
765    let prefix = parts[0];
766    if !config.prefixes.iter().any(|known| known == prefix) {
767        return Err(malformed(
768            source_line,
769            source,
770            line,
771            "prefix is not configured",
772        ));
773    }
774
775    let label = parts[1];
776    if label.is_empty() {
777        return Err(malformed(source_line, source, line, "label is empty"));
778    }
779
780    let (span_label, kind) = split_label_kind(label).ok_or_else(|| {
781        malformed(
782            source_line,
783            source,
784            line,
785            "label must end in :start or :end",
786        )
787    })?;
788
789    let counters = BenchmarkCounters {
790        instructions: parse_counter(parts[2], source_line, source, line, "instructions")?,
791        heap_bytes: parse_counter(parts[3], source_line, source, line, "heap_bytes")?,
792        memory_bytes: parse_counter(parts[4], source_line, source, line, "memory_bytes")?,
793        total_allocation: parse_counter(parts[5], source_line, source, line, "total_allocation")?,
794    };
795    let suite = config.suite_derivation.derive_suite(span_label);
796
797    Ok(RawBenchmarkEvent {
798        prefix: prefix.to_string(),
799        label: label.to_string(),
800        suite,
801        span_label: span_label.to_string(),
802        kind,
803        counters,
804        source_line,
805        source,
806    })
807}
808
809fn parse_counter(
810    value: &str,
811    source_line: usize,
812    source: BenchmarkEventSource,
813    line: &str,
814    name: &str,
815) -> Result<u128, MalformedBenchmarkMarker> {
816    if value.is_empty() {
817        return Err(malformed(
818            source_line,
819            source,
820            line,
821            &format!("{name} counter is empty"),
822        ));
823    }
824
825    value.parse::<u128>().map_err(|_| {
826        malformed(
827            source_line,
828            source,
829            line,
830            &format!("{name} counter is not an unsigned integer"),
831        )
832    })
833}
834
835fn split_label_kind(label: &str) -> Option<(&str, BenchmarkEventKind)> {
836    let start = label.strip_suffix(":start");
837    let end = label.strip_suffix(":end");
838
839    match (start, end) {
840        (Some(span_label), None) if !span_label.is_empty() => {
841            Some((span_label, BenchmarkEventKind::Start))
842        }
843        (None, Some(span_label)) if !span_label.is_empty() => {
844            Some((span_label, BenchmarkEventKind::End))
845        }
846        _ => None,
847    }
848}
849
850fn has_configured_prefix(line: &str, prefixes: &[String]) -> bool {
851    prefixes.iter().any(|prefix| {
852        line.strip_prefix(prefix)
853            .is_some_and(|rest| rest.starts_with('|'))
854    })
855}
856
857fn malformed(
858    source_line: usize,
859    source: BenchmarkEventSource,
860    line: &str,
861    reason: &str,
862) -> MalformedBenchmarkMarker {
863    MalformedBenchmarkMarker {
864        source_line,
865        source,
866        line: line.to_string(),
867        reason: reason.to_string(),
868    }
869}
870
871fn push_paired_span(
872    report: &mut BenchmarkSpanReport,
873    start: &RawBenchmarkEvent,
874    end: &RawBenchmarkEvent,
875) {
876    if let Some(delta) = end.counters.checked_delta(start.counters) {
877        report.spans.push(BenchmarkSpan {
878            suite: start.suite.clone(),
879            span_label: start.span_label.clone(),
880            start_line: start.source_line,
881            end_line: end.source_line,
882            start: start.counters,
883            end: end.counters,
884            delta,
885        });
886    } else {
887        report.invalid_spans.push(InvalidBenchmarkSpan {
888            start: start.clone(),
889            end: end.clone(),
890            reason: "end counter is lower than start counter".to_string(),
891        });
892    }
893}
894
895#[derive(Clone, Debug)]
896struct AggregateBuilder {
897    runs: u64,
898    total: BenchmarkCounters,
899    min: BenchmarkCounters,
900    max: BenchmarkCounters,
901    peak_end: BenchmarkCounters,
902}
903
904impl AggregateBuilder {
905    const fn new(span: &BenchmarkSpan) -> Self {
906        Self {
907            runs: 1,
908            total: span.delta,
909            min: span.delta,
910            max: span.delta,
911            peak_end: span.end,
912        }
913    }
914
915    fn push(&mut self, span: &BenchmarkSpan) -> Result<(), BenchmarkCounter> {
916        let runs = self.runs.checked_add(1).ok_or(BenchmarkCounter::Runs)?;
917        let total = self.total.checked_add(span.delta)?;
918        self.runs = runs;
919        self.total = total;
920        self.min.min_assign(span.delta);
921        self.max.max_assign(span.delta);
922        self.peak_end.max_assign(span.end);
923        Ok(())
924    }
925
926    const fn finish(self, scope: AggregateScope, span_label: String) -> BenchmarkAggregateRow {
927        BenchmarkAggregateRow {
928            span_label,
929            runs: self.runs,
930            total: self.total,
931            min: self.min,
932            max: self.max,
933            peak_end: self.peak_end,
934            scope,
935        }
936    }
937}
938
939fn add_span_to_aggregate(
940    rows: &mut BTreeMap<(AggregateScope, String), AggregateBuilder>,
941    scope: AggregateScope,
942    span_label: &str,
943    span: &BenchmarkSpan,
944) -> Result<(), BenchmarkAggregateError> {
945    match rows.entry((scope, span_label.to_string())) {
946        Entry::Occupied(mut entry) => entry.get_mut().push(span).map_err(|counter| {
947            let (scope, span_label) = entry.key();
948            BenchmarkAggregateError {
949                span_label: span_label.clone(),
950                counter,
951                scope: scope.clone(),
952            }
953        }),
954        Entry::Vacant(entry) => {
955            entry.insert(AggregateBuilder::new(span));
956            Ok(())
957        }
958    }
959}
960
961#[expect(clippy::cast_precision_loss)]
962fn averages(total: BenchmarkCounters, runs: u64) -> BenchmarkAverages {
963    let runs = runs as f64;
964    BenchmarkAverages {
965        instructions: total.instructions as f64 / runs,
966        heap_bytes: total.heap_bytes as f64 / runs,
967        memory_bytes: total.memory_bytes as f64 / runs,
968        total_allocation: total.total_allocation as f64 / runs,
969    }
970}
971
972fn aggregate_rows_by_key(
973    rows: &[BenchmarkAggregateRow],
974) -> BTreeMap<(&AggregateScope, &str), &BenchmarkAggregateRow> {
975    rows.iter()
976        .map(|row| ((&row.scope, row.span_label.as_str()), row))
977        .collect()
978}
979
980fn compare_average(current: Option<f64>, previous: Option<f64>) -> Option<f64> {
981    match (current, previous) {
982        (Some(current), Some(previous)) if previous != 0.0 => {
983            Some(((current - previous) / previous) * 100.0)
984        }
985        _ => None,
986    }
987}
988
989fn raw_events_csv(events: &[RawBenchmarkEvent]) -> String {
990    let mut out = String::from(
991        "source_line,source,prefix,suite,label,span_label,kind,instructions,heap_bytes,memory_bytes,total_allocation\n",
992    );
993    for event in events {
994        let _ = writeln!(
995            out,
996            "{},{},{},{},{},{},{},{},{},{},{}",
997            event.source_line,
998            event.source.as_str(),
999            csv_cell(&event.prefix),
1000            csv_cell(&event.suite),
1001            csv_cell(&event.label),
1002            csv_cell(&event.span_label),
1003            kind_str(event.kind),
1004            event.counters.instructions,
1005            event.counters.heap_bytes,
1006            event.counters.memory_bytes,
1007            event.counters.total_allocation
1008        );
1009    }
1010    out
1011}
1012
1013fn malformed_markers_csv(markers: &[MalformedBenchmarkMarker]) -> String {
1014    let mut out = String::from("source_line,source,reason,line\n");
1015    for marker in markers {
1016        let _ = writeln!(
1017            out,
1018            "{},{},{},{}",
1019            marker.source_line,
1020            marker.source.as_str(),
1021            csv_cell(&marker.reason),
1022            csv_cell(&marker.line)
1023        );
1024    }
1025    out
1026}
1027
1028fn spans_csv(spans: &[BenchmarkSpan]) -> String {
1029    let mut out = String::from(
1030        "suite,span_label,start_line,end_line,instructions_delta,heap_bytes_delta,memory_bytes_delta,total_allocation_delta\n",
1031    );
1032    for span in spans {
1033        let _ = writeln!(
1034            out,
1035            "{},{},{},{},{},{},{},{}",
1036            csv_cell(&span.suite),
1037            csv_cell(&span.span_label),
1038            span.start_line,
1039            span.end_line,
1040            span.delta.instructions,
1041            span.delta.heap_bytes,
1042            span.delta.memory_bytes,
1043            span.delta.total_allocation
1044        );
1045    }
1046    out
1047}
1048
1049fn unpaired_markers_csv(markers: &[UnpairedBenchmarkMarker]) -> String {
1050    let mut out = String::from("source_line,source,kind,suite,span_label,label\n");
1051    for marker in markers {
1052        let kind = match marker.kind {
1053            UnpairedBenchmarkMarkerKind::Start => "start",
1054            UnpairedBenchmarkMarkerKind::End => "end",
1055        };
1056        let _ = writeln!(
1057            out,
1058            "{},{},{},{},{},{}",
1059            marker.event.source_line,
1060            marker.event.source.as_str(),
1061            kind,
1062            csv_cell(&marker.event.suite),
1063            csv_cell(&marker.event.span_label),
1064            csv_cell(&marker.event.label)
1065        );
1066    }
1067    out
1068}
1069
1070fn invalid_spans_csv(spans: &[InvalidBenchmarkSpan]) -> String {
1071    let mut out = String::from("start_line,end_line,suite,span_label,reason\n");
1072    for span in spans {
1073        let _ = writeln!(
1074            out,
1075            "{},{},{},{},{}",
1076            span.start.source_line,
1077            span.end.source_line,
1078            csv_cell(&span.start.suite),
1079            csv_cell(&span.start.span_label),
1080            csv_cell(&span.reason)
1081        );
1082    }
1083    out
1084}
1085
1086fn aggregates_csv<'a>(rows: impl Iterator<Item = &'a BenchmarkAggregateRow>) -> String {
1087    let mut out = String::from(
1088        "suite,span_label,runs,instructions_total,instructions_avg,heap_bytes_total,heap_bytes_avg,memory_bytes_total,memory_bytes_avg,total_allocation_total,total_allocation_avg\n",
1089    );
1090    for row in rows {
1091        let average = row.average();
1092        let _ = writeln!(
1093            out,
1094            "{},{},{},{},{:.4},{},{:.4},{},{:.4},{},{:.4}",
1095            csv_cell(row.suite()),
1096            csv_cell(&row.span_label),
1097            row.runs,
1098            row.total.instructions,
1099            average.instructions,
1100            row.total.heap_bytes,
1101            average.heap_bytes,
1102            row.total.memory_bytes,
1103            average.memory_bytes,
1104            row.total.total_allocation,
1105            average.total_allocation
1106        );
1107    }
1108    out
1109}
1110
1111fn comparison_csv(comparison: Option<&BenchmarkComparisonReport>) -> String {
1112    let mut out = String::from(
1113        "scope,suite,span_label,current_runs,previous_runs,instructions_avg_change_percent,heap_bytes_avg_change_percent,memory_bytes_avg_change_percent,total_allocation_avg_change_percent\n",
1114    );
1115
1116    let Some(comparison) = comparison else {
1117        return out;
1118    };
1119
1120    for row in &comparison.rows {
1121        let _ = writeln!(
1122            out,
1123            "{},{},{},{},{},{},{},{},{}",
1124            if row.is_all_suites() { "all" } else { "suite" },
1125            csv_cell(row.suite()),
1126            csv_cell(&row.span_label),
1127            optional_u64_cell(row.current_runs),
1128            optional_u64_cell(row.previous_runs),
1129            optional_f64_cell(row.instructions_avg_change_percent),
1130            optional_f64_cell(row.heap_bytes_avg_change_percent),
1131            optional_f64_cell(row.memory_bytes_avg_change_percent),
1132            optional_f64_cell(row.total_allocation_avg_change_percent)
1133        );
1134    }
1135
1136    out
1137}
1138
1139fn benchmark_summary_markdown(report: &BenchmarkRunReport) -> String {
1140    let comparison_by_key = report.comparison.as_ref().map(|comparison| {
1141        comparison
1142            .rows
1143            .iter()
1144            .map(|row| ((&row.scope, row.span_label.as_str()), row))
1145            .collect::<BTreeMap<_, _>>()
1146    });
1147    let mut out = String::from(
1148        "# Benchmark Summary\n\n| Benchmark | Runs | Instructions Avg | Heap Delta Avg | Memory Delta Avg | Allocation Avg |\n| --- | ---: | ---: | ---: | ---: | ---: |\n",
1149    );
1150
1151    for row in report
1152        .aggregates
1153        .rows
1154        .iter()
1155        .filter(|row| !row.is_all_suites())
1156    {
1157        let average = row.average();
1158        let comparison = comparison_by_key
1159            .as_ref()
1160            .and_then(|rows| rows.get(&(&row.scope, row.span_label.as_str())).copied());
1161        let _ = writeln!(
1162            out,
1163            "| {} | {} | {} | {} | {} | {} |",
1164            markdown_cell(&row.span_label),
1165            row.runs,
1166            format_instructions(
1167                average.instructions,
1168                change_suffix(comparison, |c| { c.instructions_avg_change_percent })
1169            ),
1170            format_bytes(
1171                average.heap_bytes,
1172                change_suffix(comparison, |c| c.heap_bytes_avg_change_percent)
1173            ),
1174            format_bytes(
1175                average.memory_bytes,
1176                change_suffix(comparison, |c| c.memory_bytes_avg_change_percent)
1177            ),
1178            format_bytes(
1179                average.total_allocation,
1180                change_suffix(comparison, |c| c.total_allocation_avg_change_percent)
1181            )
1182        );
1183    }
1184
1185    out
1186}
1187
1188fn metadata_json(metadata: &BenchmarkRunMetadata) -> String {
1189    let value = serde_json::to_value(metadata).expect("metadata JSON must serialize");
1190
1191    let mut output = serde_json::to_string_pretty(&value).expect("metadata JSON must serialize");
1192    output.push('\n');
1193    output
1194}
1195
1196fn next_run_index_for_prefix(runs_root: &Path, prefix: &str) -> io::Result<u32> {
1197    let entries = match fs::read_dir(runs_root) {
1198        Ok(entries) => entries,
1199        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(1),
1200        Err(error) => return Err(error),
1201    };
1202    let mut max_index = 0;
1203    for entry in entries {
1204        let entry = entry?;
1205        if !entry.file_type()?.is_dir() {
1206            continue;
1207        }
1208
1209        if let Some(index) = run_index_from_directory_name(&entry.file_name(), prefix) {
1210            max_index = max_index.max(index);
1211        }
1212    }
1213
1214    max_index
1215        .checked_add(1)
1216        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "benchmark run index exhausted"))
1217}
1218
1219fn run_index_from_directory_name(name: &OsStr, prefix: &str) -> Option<u32> {
1220    let name = name.to_str()?;
1221    let index = name.strip_prefix(prefix)?;
1222
1223    if index.len() >= 4 && index.chars().all(|char| char.is_ascii_digit()) {
1224        index.parse().ok()
1225    } else {
1226        None
1227    }
1228}
1229
1230fn run_directory_sort_key(name: &str) -> Option<(&str, u32)> {
1231    let (prefix, _) = name.rsplit_once('-')?;
1232    let index = run_index_from_directory_name(OsStr::new(name), &format!("{prefix}-"))?;
1233    Some((prefix, index))
1234}
1235
1236fn short_commit_hash(hash: &str) -> String {
1237    hash.chars().take(7).collect()
1238}
1239
1240fn metadata_json_error(err: serde_json::Error) -> io::Error {
1241    io::Error::new(
1242        io::ErrorKind::InvalidData,
1243        format!("invalid benchmark metadata JSON: {err}"),
1244    )
1245}
1246
1247fn change_suffix(
1248    comparison: Option<&BenchmarkComparisonRow>,
1249    change: impl FnOnce(&BenchmarkComparisonRow) -> Option<f64>,
1250) -> Option<String> {
1251    comparison.and_then(|row| {
1252        if row.previous_runs.is_none() {
1253            Some("new".to_string())
1254        } else {
1255            change(row).map(|percent| format!("{percent:+.0}%"))
1256        }
1257    })
1258}
1259
1260fn format_instructions(value: f64, suffix: Option<String>) -> String {
1261    with_optional_suffix(format!("{:.4}B", value / 1_000_000_000.0), suffix)
1262}
1263
1264fn format_bytes(value: f64, suffix: Option<String>) -> String {
1265    with_optional_suffix(human_bytes(value), suffix)
1266}
1267
1268fn with_optional_suffix(value: String, suffix: Option<String>) -> String {
1269    match suffix {
1270        Some(suffix) => format!("{value} ({suffix})"),
1271        None => value,
1272    }
1273}
1274
1275fn human_bytes(value: f64) -> String {
1276    const KIB: f64 = 1024.0;
1277    const MIB: f64 = KIB * 1024.0;
1278    const GIB: f64 = MIB * 1024.0;
1279
1280    let (unit_value, unit) = if value.abs() >= GIB {
1281        (value / GIB, "GB")
1282    } else if value.abs() >= MIB {
1283        (value / MIB, "MB")
1284    } else if value.abs() >= KIB {
1285        (value / KIB, "KB")
1286    } else {
1287        (value, "B")
1288    };
1289
1290    format!("{unit_value:+.1} {unit}")
1291}
1292
1293const fn kind_str(kind: BenchmarkEventKind) -> &'static str {
1294    match kind {
1295        BenchmarkEventKind::Start => "start",
1296        BenchmarkEventKind::End => "end",
1297    }
1298}
1299
1300fn csv_cell(value: &str) -> String {
1301    if value.contains([',', '"', '\n', '\r']) {
1302        format!("\"{}\"", value.replace('"', "\"\""))
1303    } else {
1304        value.to_string()
1305    }
1306}
1307
1308fn optional_u64_cell(value: Option<u64>) -> String {
1309    value.map_or_else(String::new, |value| value.to_string())
1310}
1311
1312fn optional_f64_cell(value: Option<f64>) -> String {
1313    value.map_or_else(String::new, |value| format!("{value:.4}"))
1314}
1315
1316fn markdown_cell(value: &str) -> String {
1317    value.replace('|', "\\|")
1318}
1319
1320#[cfg(test)]
1321mod tests {
1322    use super::{AggregateBuilder, BenchmarkCounter, BenchmarkCounters, BenchmarkSpan};
1323
1324    #[test]
1325    fn aggregate_run_count_overflow_is_rejected_without_changing_totals() {
1326        let span = BenchmarkSpan {
1327            suite: "probe".into(),
1328            span_label: "probe/run".into(),
1329            start_line: 1,
1330            end_line: 2,
1331            start: BenchmarkCounters::default(),
1332            end: BenchmarkCounters::default(),
1333            delta: BenchmarkCounters::default(),
1334        };
1335        let mut builder = AggregateBuilder::new(&span);
1336        builder.runs = u64::MAX;
1337        assert_eq!(builder.push(&span), Err(BenchmarkCounter::Runs));
1338        assert_eq!(builder.runs, u64::MAX);
1339        assert_eq!(builder.total, BenchmarkCounters::default());
1340    }
1341}