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