Skip to main content

ic_testkit/benchmark/
mod.rs

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