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