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. Means use IC Metrics' exact
10//! scaled integer projection, rounded down. Callers choose the scale and handle
11//! projection errors; reports use scale one in the original counter units.
12//! Comparisons retain current and previous means instead of percentage estimates.
13
14use std::{
15    collections::{BTreeMap, btree_map::Entry},
16    fmt::Write as _,
17    fs, io,
18    path::{Path, PathBuf},
19};
20
21use ic_metrics::{MeasurementRatioError, checked_scaled_ratio};
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/// Floor-rounded scaled means of exact aggregate totals and run counts.
239///
240/// Fields are `floor(total * scale / runs)`; choose a positive scale to
241/// interpret them in the original counter unit. Scale zero projects zero.
242#[derive(Clone, Copy, Debug, Eq, PartialEq)]
243pub struct BenchmarkAverages {
244    pub scale: u64,
245    pub instructions: u128,
246    pub heap_bytes: u128,
247    pub memory_bytes: u128,
248    pub total_allocation: u128,
249}
250
251#[derive(Clone, Debug, Default, Eq, PartialEq)]
252pub struct BenchmarkAggregateReport {
253    pub rows: Vec<BenchmarkAggregateRow>,
254}
255
256/// Counter whose aggregate cannot be represented.
257#[derive(Clone, Copy, Debug, Eq, PartialEq)]
258pub enum BenchmarkCounter {
259    Runs,
260    Instructions,
261    HeapBytes,
262    MemoryBytes,
263    TotalAllocation,
264}
265
266/// Overflow rejects the complete aggregation, without returning partial totals.
267#[derive(Clone, Debug, Eq, PartialEq)]
268pub struct BenchmarkAggregateError {
269    pub span_label: String,
270    pub counter: BenchmarkCounter,
271    scope: AggregateScope,
272}
273
274impl BenchmarkAggregateError {
275    /// Suite label, or `ALL` for the cross-suite aggregate.
276    #[must_use]
277    pub fn suite(&self) -> &str {
278        self.scope.label()
279    }
280
281    /// Whether the overflow belongs to the cross-suite aggregate.
282    #[must_use]
283    pub const fn is_all_suites(&self) -> bool {
284        matches!(self.scope, AggregateScope::All)
285    }
286}
287
288impl std::fmt::Display for BenchmarkAggregateError {
289    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
290        write!(
291            formatter,
292            "benchmark aggregate overflow: scope={:?}, span={}, counter={:?}",
293            self.scope, self.span_label, self.counter
294        )
295    }
296}
297
298impl std::error::Error for BenchmarkAggregateError {}
299
300/// Current and previous scaled means, preserving missing rows and measured zero.
301#[derive(Clone, Debug, Eq, PartialEq)]
302pub struct BenchmarkComparisonRow {
303    pub span_label: String,
304    pub current_runs: Option<u64>,
305    pub previous_runs: Option<u64>,
306    pub current_average: Option<BenchmarkAverages>,
307    pub previous_average: Option<BenchmarkAverages>,
308    scope: AggregateScope,
309}
310
311impl BenchmarkAggregateRow {
312    /// Project exact totals at the caller's scale, rounding down.
313    /// Zero runs and unrepresentable scaled results return IC Metrics' typed error.
314    pub fn average(&self, scale: u64) -> Result<BenchmarkAverages, MeasurementRatioError> {
315        Ok(BenchmarkAverages {
316            scale,
317            instructions: checked_scaled_ratio(self.total.instructions, self.runs, scale)?,
318            heap_bytes: checked_scaled_ratio(self.total.heap_bytes, self.runs, scale)?,
319            memory_bytes: checked_scaled_ratio(self.total.memory_bytes, self.runs, scale)?,
320            total_allocation: checked_scaled_ratio(self.total.total_allocation, self.runs, scale)?,
321        })
322    }
323
324    /// Suite label, or `ALL` for the cross-suite aggregate.
325    #[must_use]
326    pub fn suite(&self) -> &str {
327        self.scope.label()
328    }
329
330    /// Report whether this row aggregates matching spans across every suite.
331    #[must_use]
332    pub const fn is_all_suites(&self) -> bool {
333        matches!(self.scope, AggregateScope::All)
334    }
335}
336
337impl BenchmarkComparisonRow {
338    /// Suite label, or `ALL` for the cross-suite aggregate.
339    #[must_use]
340    pub fn suite(&self) -> &str {
341        self.scope.label()
342    }
343
344    /// Report whether this row compares the aggregate across every suite.
345    #[must_use]
346    pub const fn is_all_suites(&self) -> bool {
347        matches!(self.scope, AggregateScope::All)
348    }
349}
350
351#[derive(Clone, Debug, Default, Eq, PartialEq)]
352pub struct BenchmarkComparisonReport {
353    pub rows: Vec<BenchmarkComparisonRow>,
354}
355
356#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
357pub struct BenchmarkRunMetadata {
358    pub timestamp: String,
359    pub run_directory_name: String,
360    pub run_index: u32,
361    pub git_commit_hash: Option<String>,
362    pub git_commit_short_hash: Option<String>,
363    pub ic_testkit_version: String,
364    pub pocket_ic_version: String,
365    pub rustc_version: String,
366    pub benchmark_command: Option<String>,
367    pub selected_previous_run: Option<String>,
368}
369
370#[derive(Clone, Debug, Eq, PartialEq)]
371pub struct BenchmarkRunReport {
372    pub parse: BenchmarkParseReport,
373    pub spans: BenchmarkSpanReport,
374    pub aggregates: BenchmarkAggregateReport,
375    pub comparison: Option<BenchmarkComparisonReport>,
376    pub metadata: BenchmarkRunMetadata,
377}
378
379#[derive(Clone, Debug, Eq, PartialEq)]
380pub struct BenchmarkRunDirectory {
381    pub path: PathBuf,
382    pub directory_name: String,
383    pub run_index: u32,
384    pub git_commit_hash: Option<String>,
385    pub git_commit_short_hash: Option<String>,
386}
387
388#[must_use]
389pub fn format_marker(prefix: &str, label: &str, counters: BenchmarkCounters) -> String {
390    format!(
391        "{}|{}|{}|{}|{}|{}",
392        prefix,
393        label,
394        counters.instructions,
395        counters.heap_bytes,
396        counters.memory_bytes,
397        counters.total_allocation
398    )
399}
400
401#[must_use]
402pub fn benchmark_run_directory_name(
403    timestamp: &str,
404    git_commit_short_hash: Option<&str>,
405    run_index: u32,
406) -> String {
407    let commit = git_commit_short_hash
408        .filter(|hash| !hash.is_empty())
409        .unwrap_or("unknown");
410    format!("{timestamp}-{commit}-{run_index:04}")
411}
412
413/// Compute the next available benchmark run path for one timestamp and commit.
414///
415/// This function does not create or reserve the returned directory. Callers
416/// allocating the same prefix concurrently must synchronize that shared
417/// resource or provide unique timestamps.
418/// A missing runs root starts at index one; other directory errors are returned.
419pub fn next_benchmark_run_directory(
420    runs_root: impl AsRef<Path>,
421    timestamp: &str,
422    git_commit_hash: Option<&str>,
423) -> io::Result<BenchmarkRunDirectory> {
424    let runs_root = runs_root.as_ref();
425    let git_commit_hash = git_commit_hash.filter(|hash| !hash.is_empty());
426    let git_commit_short_hash = git_commit_hash.map(short_commit_hash);
427    let prefix = format!(
428        "{}-{}-",
429        timestamp,
430        git_commit_short_hash.as_deref().unwrap_or("unknown")
431    );
432    let run_index = next_run_index_for_prefix(runs_root, &prefix)?;
433    let directory_name =
434        benchmark_run_directory_name(timestamp, git_commit_short_hash.as_deref(), run_index);
435
436    Ok(BenchmarkRunDirectory {
437        path: runs_root.join(&directory_name),
438        directory_name,
439        run_index,
440        git_commit_hash: git_commit_hash.map(str::to_string),
441        git_commit_short_hash,
442    })
443}
444
445/// Find the latest earlier run with readable metadata and a matching command.
446///
447/// Names must follow [`benchmark_run_directory_name`]; indices are ordered
448/// numerically even when they exceed four digits. Eligible runs are ranked by
449/// metadata timestamp, directory prefix, then numeric index. An invalid current
450/// name returns [`io::ErrorKind::InvalidInput`]. Unreadable or malformed
451/// candidates are skipped.
452/// A missing runs root has no previous run; other directory errors are returned.
453pub fn find_latest_previous_run(
454    runs_root: impl AsRef<Path>,
455    current_run_directory_name: &str,
456    benchmark_command: Option<&str>,
457) -> io::Result<Option<PathBuf>> {
458    let runs_root = runs_root.as_ref();
459    let current_key = run_directory_sort_key(current_run_directory_name).ok_or_else(|| {
460        io::Error::new(
461            io::ErrorKind::InvalidInput,
462            "invalid benchmark run directory name",
463        )
464    })?;
465    let mut latest: Option<((String, String, u32), PathBuf)> = None;
466
467    let entries = match fs::read_dir(runs_root) {
468        Ok(entries) => entries,
469        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
470        Err(error) => return Err(error),
471    };
472    for entry in entries {
473        let entry = entry?;
474        if !entry.file_type()?.is_dir() {
475            continue;
476        }
477
478        let directory_name = entry.file_name().to_string_lossy().into_owned();
479        let Some((prefix, index)) = run_directory_sort_key(&directory_name) else {
480            continue;
481        };
482        if (prefix, index) >= current_key {
483            continue;
484        }
485
486        let metadata_path = entry.path().join("metadata.json");
487        let Ok(metadata) = read_benchmark_run_metadata(&metadata_path) else {
488            continue;
489        };
490
491        if let Some(command) = benchmark_command
492            && metadata.benchmark_command.as_deref() != Some(command)
493        {
494            continue;
495        }
496
497        let key = (metadata.timestamp, prefix.to_owned(), index);
498        if latest
499            .as_ref()
500            .is_none_or(|(latest_key, _)| key >= *latest_key)
501        {
502            latest = Some((key, entry.path()));
503        }
504    }
505
506    Ok(latest.map(|(_, path)| path))
507}
508
509pub fn read_benchmark_run_metadata(path: impl AsRef<Path>) -> io::Result<BenchmarkRunMetadata> {
510    let input = fs::read_to_string(path)?;
511    let value = serde_json::from_str::<Value>(&input).map_err(metadata_json_error)?;
512
513    if !value.is_object() {
514        return Err(io::Error::new(
515            io::ErrorKind::InvalidData,
516            "benchmark metadata JSON must be an object",
517        ));
518    }
519    serde_json::from_value(value).map_err(metadata_json_error)
520}
521
522#[must_use]
523pub fn parse_benchmark_events(input: &str, config: &BenchmarkParserConfig) -> BenchmarkParseReport {
524    parse_benchmark_events_from_source(input, config, BenchmarkEventSource::Unknown)
525}
526
527#[must_use]
528pub fn parse_benchmark_events_from_source(
529    input: &str,
530    config: &BenchmarkParserConfig,
531    source: BenchmarkEventSource,
532) -> BenchmarkParseReport {
533    let mut report = BenchmarkParseReport::default();
534    append_benchmark_events(&mut report, input, config, source);
535    report
536}
537
538fn append_benchmark_events(
539    report: &mut BenchmarkParseReport,
540    input: &str,
541    config: &BenchmarkParserConfig,
542    source: BenchmarkEventSource,
543) {
544    for (index, line) in input.lines().enumerate() {
545        let source_line = index + 1;
546        if !has_configured_prefix(line, &config.prefixes) {
547            if config.strict && !line.trim().is_empty() {
548                report.malformed_markers.push(malformed(
549                    source_line,
550                    source,
551                    line,
552                    "line does not use a configured marker prefix",
553                ));
554            } else {
555                report.ignored_line_count += 1;
556            }
557            continue;
558        }
559
560        match parse_marker_line(line, source_line, source, config) {
561            Ok(event) => report.events.push(event),
562            Err(marker) => report.malformed_markers.push(marker),
563        }
564    }
565}
566
567/// Parse separately captured stdout and stderr.
568///
569/// Separate streams do not carry global ordering, so events are returned in
570/// stdout-then-stderr order. If a benchmark span can start on one stream and end
571/// on the other, capture combined process output and use [`parse_benchmark_events`].
572#[must_use]
573pub fn parse_benchmark_events_from_captured_output(
574    stdout: &str,
575    stderr: &str,
576    config: &BenchmarkParserConfig,
577) -> BenchmarkParseReport {
578    let mut report = BenchmarkParseReport::default();
579    append_benchmark_events(&mut report, stdout, config, BenchmarkEventSource::Stdout);
580    append_benchmark_events(&mut report, stderr, config, BenchmarkEventSource::Stderr);
581    report
582}
583
584#[must_use]
585pub fn pair_benchmark_spans(events: &[RawBenchmarkEvent]) -> BenchmarkSpanReport {
586    let mut report = BenchmarkSpanReport::default();
587    let mut open_starts: BTreeMap<(&str, &str), Vec<&RawBenchmarkEvent>> = BTreeMap::new();
588
589    for event in events {
590        let key = (event.suite.as_str(), event.span_label.as_str());
591        match event.kind {
592            BenchmarkEventKind::Start => open_starts.entry(key).or_default().push(event),
593            BenchmarkEventKind::End => match open_starts.entry(key) {
594                Entry::Occupied(mut entry) => {
595                    let start = entry
596                        .get_mut()
597                        .pop()
598                        .expect("open marker stacks must remain nonempty");
599                    if entry.get().is_empty() {
600                        entry.remove();
601                    }
602                    push_paired_span(&mut report, start, event);
603                }
604                Entry::Vacant(_) => report.unpaired_markers.push(UnpairedBenchmarkMarker {
605                    event: event.clone(),
606                    kind: UnpairedBenchmarkMarkerKind::End,
607                }),
608            },
609        }
610    }
611
612    for starts in open_starts.into_values() {
613        for event in starts {
614            report.unpaired_markers.push(UnpairedBenchmarkMarker {
615                event: event.clone(),
616                kind: UnpairedBenchmarkMarkerKind::Start,
617            });
618        }
619    }
620
621    report
622}
623
624/// Aggregate spans by named suite and across all suites.
625///
626/// Returns an error if any counter total or run count overflows. No partial
627/// report is returned, and totals are never wrapped or saturated.
628pub fn aggregate_benchmark_spans(
629    spans: &[BenchmarkSpan],
630) -> Result<BenchmarkAggregateReport, BenchmarkAggregateError> {
631    let mut rows: BTreeMap<(AggregateScope, &str), AggregateBuilder> = BTreeMap::new();
632
633    for span in spans {
634        add_span_to_aggregate(&mut rows, AggregateScope::Suite(span.suite.clone()), span)?;
635        add_span_to_aggregate(&mut rows, AggregateScope::All, span)?;
636    }
637
638    Ok(BenchmarkAggregateReport {
639        rows: rows
640            .into_iter()
641            .map(|((scope, span_label), builder)| builder.finish(scope, span_label.to_owned()))
642            .collect(),
643    })
644}
645
646/// Pair current and previous means at the same caller-selected scale.
647/// Returns a typed error if either side cannot supply the selected projection.
648pub fn compare_benchmark_aggregates(
649    current: &[BenchmarkAggregateRow],
650    previous: &[BenchmarkAggregateRow],
651    scale: u64,
652) -> Result<BenchmarkComparisonReport, MeasurementRatioError> {
653    let mut rows = BTreeMap::new();
654    for row in current {
655        rows.insert((&row.scope, row.span_label.as_str()), (Some(row), None));
656    }
657    for row in previous {
658        rows.entry((&row.scope, row.span_label.as_str()))
659            .or_insert((None, None))
660            .1 = Some(row);
661    }
662
663    Ok(BenchmarkComparisonReport {
664        rows: rows
665            .into_iter()
666            .map(|((scope, span_label), (current_row, previous_row))| {
667                Ok(BenchmarkComparisonRow {
668                    span_label: span_label.to_owned(),
669                    current_runs: current_row.map(|row| row.runs),
670                    previous_runs: previous_row.map(|row| row.runs),
671                    current_average: current_row.map(|row| row.average(scale)).transpose()?,
672                    previous_average: previous_row.map(|row| row.average(scale)).transpose()?,
673                    scope: scope.clone(),
674                })
675            })
676            .collect::<Result<_, MeasurementRatioError>>()?,
677    })
678}
679
680/// Write one complete benchmark report into the requested directory.
681///
682/// The directory is caller-owned. Concurrent writers must use unique paths or
683/// synchronize access to the same path outside this function.
684pub fn write_benchmark_report_dir(
685    report: &BenchmarkRunReport,
686    path: impl AsRef<Path>,
687) -> io::Result<()> {
688    let path = path.as_ref();
689    let suite_aggregates = aggregates_csv(
690        report
691            .aggregates
692            .rows
693            .iter()
694            .filter(|row| !row.is_all_suites()),
695    )?;
696    let all_aggregates = aggregates_csv(
697        report
698            .aggregates
699            .rows
700            .iter()
701            .filter(|row| row.is_all_suites()),
702    )?;
703    let summary = benchmark_summary_markdown(report)?;
704    fs::create_dir_all(path)?;
705
706    fs::write(
707        path.join("raw-events.csv"),
708        raw_events_csv(&report.parse.events),
709    )?;
710    fs::write(
711        path.join("malformed-markers.csv"),
712        malformed_markers_csv(&report.parse.malformed_markers),
713    )?;
714    fs::write(path.join("spans.csv"), spans_csv(&report.spans.spans))?;
715    fs::write(
716        path.join("unpaired-markers.csv"),
717        unpaired_markers_csv(&report.spans.unpaired_markers),
718    )?;
719    fs::write(
720        path.join("invalid-spans.csv"),
721        invalid_spans_csv(&report.spans.invalid_spans),
722    )?;
723    fs::write(path.join("suite-aggregates.csv"), suite_aggregates)?;
724    fs::write(path.join("all-aggregates.csv"), all_aggregates)?;
725    fs::write(
726        path.join("comparison.csv"),
727        comparison_csv(report.comparison.as_ref()),
728    )?;
729    fs::write(path.join("bench-summary.md"), summary)?;
730    fs::write(path.join("metadata.json"), metadata_json(&report.metadata))?;
731
732    Ok(())
733}
734
735fn parse_marker_line(
736    line: &str,
737    source_line: usize,
738    source: BenchmarkEventSource,
739    config: &BenchmarkParserConfig,
740) -> Result<RawBenchmarkEvent, MalformedBenchmarkMarker> {
741    let mut parts = line.split('|');
742    // Six fields and an absent seventh establish the complete marker shape
743    // without collecting an arbitrary number of columns from malformed logs.
744    let columns: [Option<&str>; 7] = std::array::from_fn(|_| parts.next());
745    let [
746        Some(prefix),
747        Some(label),
748        Some(instructions),
749        Some(heap_bytes),
750        Some(memory_bytes),
751        Some(total_allocation),
752        None,
753    ] = columns
754    else {
755        return Err(malformed(
756            source_line,
757            source,
758            line,
759            "expected six pipe-separated columns",
760        ));
761    };
762
763    if !config.prefixes.iter().any(|known| known == prefix) {
764        return Err(malformed(
765            source_line,
766            source,
767            line,
768            "prefix is not configured",
769        ));
770    }
771
772    if label.is_empty() {
773        return Err(malformed(source_line, source, line, "label is empty"));
774    }
775
776    let (span_label, kind) = split_label_kind(label).ok_or_else(|| {
777        malformed(
778            source_line,
779            source,
780            line,
781            "label must end in :start or :end",
782        )
783    })?;
784
785    let counters = BenchmarkCounters {
786        instructions: parse_counter(instructions, source_line, source, line, "instructions")?,
787        heap_bytes: parse_counter(heap_bytes, source_line, source, line, "heap_bytes")?,
788        memory_bytes: parse_counter(memory_bytes, source_line, source, line, "memory_bytes")?,
789        total_allocation: parse_counter(
790            total_allocation,
791            source_line,
792            source,
793            line,
794            "total_allocation",
795        )?,
796    };
797    let suite = config.suite_derivation.derive_suite(span_label);
798
799    Ok(RawBenchmarkEvent {
800        prefix: prefix.to_string(),
801        label: label.to_string(),
802        suite,
803        span_label: span_label.to_string(),
804        kind,
805        counters,
806        source_line,
807        source,
808    })
809}
810
811fn parse_counter(
812    value: &str,
813    source_line: usize,
814    source: BenchmarkEventSource,
815    line: &str,
816    name: &str,
817) -> Result<u128, MalformedBenchmarkMarker> {
818    if value.is_empty() {
819        return Err(malformed(
820            source_line,
821            source,
822            line,
823            &format!("{name} counter is empty"),
824        ));
825    }
826
827    value.parse::<u128>().map_err(|_| {
828        malformed(
829            source_line,
830            source,
831            line,
832            &format!("{name} counter is not an unsigned integer"),
833        )
834    })
835}
836
837fn split_label_kind(label: &str) -> Option<(&str, BenchmarkEventKind)> {
838    let start = label.strip_suffix(":start");
839    let end = label.strip_suffix(":end");
840
841    match (start, end) {
842        (Some(span_label), None) if !span_label.is_empty() => {
843            Some((span_label, BenchmarkEventKind::Start))
844        }
845        (None, Some(span_label)) if !span_label.is_empty() => {
846            Some((span_label, BenchmarkEventKind::End))
847        }
848        _ => None,
849    }
850}
851
852fn has_configured_prefix(line: &str, prefixes: &[String]) -> bool {
853    prefixes.iter().any(|prefix| {
854        line.strip_prefix(prefix)
855            .is_some_and(|rest| rest.starts_with('|'))
856    })
857}
858
859fn malformed(
860    source_line: usize,
861    source: BenchmarkEventSource,
862    line: &str,
863    reason: &str,
864) -> MalformedBenchmarkMarker {
865    MalformedBenchmarkMarker {
866        source_line,
867        source,
868        line: line.to_string(),
869        reason: reason.to_string(),
870    }
871}
872
873fn push_paired_span(
874    report: &mut BenchmarkSpanReport,
875    start: &RawBenchmarkEvent,
876    end: &RawBenchmarkEvent,
877) {
878    if let Some(delta) = end.counters.checked_delta(start.counters) {
879        report.spans.push(BenchmarkSpan {
880            suite: start.suite.clone(),
881            span_label: start.span_label.clone(),
882            start_line: start.source_line,
883            end_line: end.source_line,
884            start: start.counters,
885            end: end.counters,
886            delta,
887        });
888    } else {
889        report.invalid_spans.push(InvalidBenchmarkSpan {
890            start: start.clone(),
891            end: end.clone(),
892            reason: "end counter is lower than start counter".to_string(),
893        });
894    }
895}
896
897#[derive(Clone, Debug)]
898struct AggregateBuilder {
899    runs: u64,
900    total: BenchmarkCounters,
901    min: BenchmarkCounters,
902    max: BenchmarkCounters,
903    peak_end: BenchmarkCounters,
904}
905
906impl AggregateBuilder {
907    const fn new(span: &BenchmarkSpan) -> Self {
908        Self {
909            runs: 1,
910            total: span.delta,
911            min: span.delta,
912            max: span.delta,
913            peak_end: span.end,
914        }
915    }
916
917    fn push(&mut self, span: &BenchmarkSpan) -> Result<(), BenchmarkCounter> {
918        let runs = self.runs.checked_add(1).ok_or(BenchmarkCounter::Runs)?;
919        let total = self.total.checked_add(span.delta)?;
920        self.runs = runs;
921        self.total = total;
922        self.min.min_assign(span.delta);
923        self.max.max_assign(span.delta);
924        self.peak_end.max_assign(span.end);
925        Ok(())
926    }
927
928    const fn finish(self, scope: AggregateScope, span_label: String) -> BenchmarkAggregateRow {
929        BenchmarkAggregateRow {
930            span_label,
931            runs: self.runs,
932            total: self.total,
933            min: self.min,
934            max: self.max,
935            peak_end: self.peak_end,
936            scope,
937        }
938    }
939}
940
941fn add_span_to_aggregate<'a>(
942    rows: &mut BTreeMap<(AggregateScope, &'a str), AggregateBuilder>,
943    scope: AggregateScope,
944    span: &'a BenchmarkSpan,
945) -> Result<(), BenchmarkAggregateError> {
946    match rows.entry((scope, span.span_label.as_str())) {
947        Entry::Occupied(mut entry) => entry.get_mut().push(span).map_err(|counter| {
948            let (scope, span_label) = entry.key();
949            BenchmarkAggregateError {
950                span_label: (*span_label).to_owned(),
951                counter,
952                scope: scope.clone(),
953            }
954        }),
955        Entry::Vacant(entry) => {
956            entry.insert(AggregateBuilder::new(span));
957            Ok(())
958        }
959    }
960}
961
962fn raw_events_csv(events: &[RawBenchmarkEvent]) -> String {
963    let mut out = String::from(
964        "source_line,source,prefix,suite,label,span_label,kind,instructions,heap_bytes,memory_bytes,total_allocation\n",
965    );
966    for event in events {
967        let _ = writeln!(
968            out,
969            "{},{},{},{},{},{},{},{},{},{},{}",
970            event.source_line,
971            event.source.as_str(),
972            csv_cell(&event.prefix),
973            csv_cell(&event.suite),
974            csv_cell(&event.label),
975            csv_cell(&event.span_label),
976            kind_str(event.kind),
977            event.counters.instructions,
978            event.counters.heap_bytes,
979            event.counters.memory_bytes,
980            event.counters.total_allocation
981        );
982    }
983    out
984}
985
986fn malformed_markers_csv(markers: &[MalformedBenchmarkMarker]) -> String {
987    let mut out = String::from("source_line,source,reason,line\n");
988    for marker in markers {
989        let _ = writeln!(
990            out,
991            "{},{},{},{}",
992            marker.source_line,
993            marker.source.as_str(),
994            csv_cell(&marker.reason),
995            csv_cell(&marker.line)
996        );
997    }
998    out
999}
1000
1001fn spans_csv(spans: &[BenchmarkSpan]) -> String {
1002    let mut out = String::from(
1003        "suite,span_label,start_line,end_line,instructions_delta,heap_bytes_delta,memory_bytes_delta,total_allocation_delta\n",
1004    );
1005    for span in spans {
1006        let _ = writeln!(
1007            out,
1008            "{},{},{},{},{},{},{},{}",
1009            csv_cell(&span.suite),
1010            csv_cell(&span.span_label),
1011            span.start_line,
1012            span.end_line,
1013            span.delta.instructions,
1014            span.delta.heap_bytes,
1015            span.delta.memory_bytes,
1016            span.delta.total_allocation
1017        );
1018    }
1019    out
1020}
1021
1022fn unpaired_markers_csv(markers: &[UnpairedBenchmarkMarker]) -> String {
1023    let mut out = String::from("source_line,source,kind,suite,span_label,label\n");
1024    for marker in markers {
1025        let kind = match marker.kind {
1026            UnpairedBenchmarkMarkerKind::Start => "start",
1027            UnpairedBenchmarkMarkerKind::End => "end",
1028        };
1029        let _ = writeln!(
1030            out,
1031            "{},{},{},{},{},{}",
1032            marker.event.source_line,
1033            marker.event.source.as_str(),
1034            kind,
1035            csv_cell(&marker.event.suite),
1036            csv_cell(&marker.event.span_label),
1037            csv_cell(&marker.event.label)
1038        );
1039    }
1040    out
1041}
1042
1043fn invalid_spans_csv(spans: &[InvalidBenchmarkSpan]) -> String {
1044    let mut out = String::from("start_line,end_line,suite,span_label,reason\n");
1045    for span in spans {
1046        let _ = writeln!(
1047            out,
1048            "{},{},{},{},{}",
1049            span.start.source_line,
1050            span.end.source_line,
1051            csv_cell(&span.start.suite),
1052            csv_cell(&span.start.span_label),
1053            csv_cell(&span.reason)
1054        );
1055    }
1056    out
1057}
1058
1059fn aggregates_csv<'a>(rows: impl Iterator<Item = &'a BenchmarkAggregateRow>) -> io::Result<String> {
1060    let mut out = String::from(
1061        "suite,span_label,runs,instructions_total,instructions_mean,heap_bytes_total,heap_bytes_mean,memory_bytes_total,memory_bytes_mean,total_allocation_total,total_allocation_mean\n",
1062    );
1063    for row in rows {
1064        let average = row.average(1).map_err(io::Error::other)?;
1065        let _ = writeln!(
1066            out,
1067            "{},{},{},{},{},{},{},{},{},{},{}",
1068            csv_cell(row.suite()),
1069            csv_cell(&row.span_label),
1070            row.runs,
1071            row.total.instructions,
1072            average.instructions,
1073            row.total.heap_bytes,
1074            average.heap_bytes,
1075            row.total.memory_bytes,
1076            average.memory_bytes,
1077            row.total.total_allocation,
1078            average.total_allocation
1079        );
1080    }
1081    Ok(out)
1082}
1083
1084fn comparison_csv(comparison: Option<&BenchmarkComparisonReport>) -> String {
1085    let mut out = String::from(
1086        "scope,suite,span_label,current_runs,previous_runs,current_mean_scale,previous_mean_scale,current_instructions_mean,previous_instructions_mean,current_heap_bytes_mean,previous_heap_bytes_mean,current_memory_bytes_mean,previous_memory_bytes_mean,current_total_allocation_mean,previous_total_allocation_mean\n",
1087    );
1088    if let Some(comparison) = comparison {
1089        for row in &comparison.rows {
1090            let current = row.current_average;
1091            let previous = row.previous_average;
1092            let value = |mean: Option<BenchmarkAverages>, field: fn(BenchmarkAverages) -> u128| {
1093                mean.map_or_else(String::new, |mean| field(mean).to_string())
1094            };
1095            let _ = writeln!(
1096                out,
1097                "{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}",
1098                if row.is_all_suites() { "all" } else { "suite" },
1099                csv_cell(row.suite()),
1100                csv_cell(&row.span_label),
1101                optional_u64_cell(row.current_runs),
1102                optional_u64_cell(row.previous_runs),
1103                current.map_or_else(String::new, |mean| mean.scale.to_string()),
1104                previous.map_or_else(String::new, |mean| mean.scale.to_string()),
1105                value(current, |m| m.instructions),
1106                value(previous, |m| m.instructions),
1107                value(current, |m| m.heap_bytes),
1108                value(previous, |m| m.heap_bytes),
1109                value(current, |m| m.memory_bytes),
1110                value(previous, |m| m.memory_bytes),
1111                value(current, |m| m.total_allocation),
1112                value(previous, |m| m.total_allocation)
1113            );
1114        }
1115    }
1116    out
1117}
1118
1119fn benchmark_summary_markdown(report: &BenchmarkRunReport) -> io::Result<String> {
1120    let mut out = String::from(
1121        "# Benchmark Summary\n\nMeans are floor-rounded in original counter units. Comparison CSV retains current and previous scaled means.\n\n| Benchmark | Runs | Instructions Mean | Heap Bytes Mean | Memory Bytes Mean | Allocation Mean |\n| --- | ---: | ---: | ---: | ---: | ---: |\n",
1122    );
1123    for row in report
1124        .aggregates
1125        .rows
1126        .iter()
1127        .filter(|row| !row.is_all_suites())
1128    {
1129        let average = row.average(1).map_err(io::Error::other)?;
1130        let _ = writeln!(
1131            out,
1132            "| {} | {} | {} | {} | {} | {} |",
1133            markdown_cell(&row.span_label),
1134            row.runs,
1135            average.instructions,
1136            average.heap_bytes,
1137            average.memory_bytes,
1138            average.total_allocation
1139        );
1140    }
1141    Ok(out)
1142}
1143
1144fn metadata_json(metadata: &BenchmarkRunMetadata) -> String {
1145    let mut output = serde_json::to_string_pretty(metadata).expect("metadata JSON must serialize");
1146    output.push('\n');
1147    output
1148}
1149
1150fn next_run_index_for_prefix(runs_root: &Path, prefix: &str) -> io::Result<u32> {
1151    let entries = match fs::read_dir(runs_root) {
1152        Ok(entries) => entries,
1153        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(1),
1154        Err(error) => return Err(error),
1155    };
1156    let mut max_index = 0;
1157    for entry in entries {
1158        let entry = entry?;
1159        if !entry.file_type()?.is_dir() {
1160            continue;
1161        }
1162
1163        if let Some(index) = entry
1164            .file_name()
1165            .to_str()
1166            .and_then(|name| name.strip_prefix(prefix))
1167            .and_then(parse_run_index)
1168        {
1169            max_index = max_index.max(index);
1170        }
1171    }
1172
1173    max_index
1174        .checked_add(1)
1175        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "benchmark run index exhausted"))
1176}
1177
1178fn parse_run_index(index: &str) -> Option<u32> {
1179    if index.len() >= 4 && index.chars().all(|char| char.is_ascii_digit()) {
1180        index.parse().ok()
1181    } else {
1182        None
1183    }
1184}
1185
1186fn run_directory_sort_key(name: &str) -> Option<(&str, u32)> {
1187    let (prefix, index) = name.rsplit_once('-')?;
1188    Some((prefix, parse_run_index(index)?))
1189}
1190
1191fn short_commit_hash(hash: &str) -> String {
1192    hash.chars().take(7).collect()
1193}
1194
1195fn metadata_json_error(err: serde_json::Error) -> io::Error {
1196    io::Error::new(
1197        io::ErrorKind::InvalidData,
1198        format!("invalid benchmark metadata JSON: {err}"),
1199    )
1200}
1201
1202const fn kind_str(kind: BenchmarkEventKind) -> &'static str {
1203    match kind {
1204        BenchmarkEventKind::Start => "start",
1205        BenchmarkEventKind::End => "end",
1206    }
1207}
1208
1209fn csv_cell(value: &str) -> String {
1210    if value.contains([',', '"', '\n', '\r']) {
1211        format!("\"{}\"", value.replace('"', "\"\""))
1212    } else {
1213        value.to_string()
1214    }
1215}
1216
1217fn optional_u64_cell(value: Option<u64>) -> String {
1218    value.map_or_else(String::new, |value| value.to_string())
1219}
1220
1221fn markdown_cell(value: &str) -> String {
1222    value.replace('|', "\\|")
1223}
1224
1225#[cfg(test)]
1226mod tests {
1227    use super::{AggregateBuilder, BenchmarkCounter, BenchmarkCounters, BenchmarkSpan};
1228
1229    #[test]
1230    fn aggregate_run_count_overflow_is_rejected_without_changing_totals() {
1231        let span = BenchmarkSpan {
1232            suite: "probe".into(),
1233            span_label: "probe/run".into(),
1234            start_line: 1,
1235            end_line: 2,
1236            start: BenchmarkCounters::default(),
1237            end: BenchmarkCounters::default(),
1238            delta: BenchmarkCounters::default(),
1239        };
1240        let mut builder = AggregateBuilder::new(&span);
1241        builder.runs = u64::MAX;
1242        assert_eq!(builder.push(&span), Err(BenchmarkCounter::Runs));
1243        assert_eq!(builder.runs, u64::MAX);
1244        assert_eq!(builder.total, BenchmarkCounters::default());
1245    }
1246}