Skip to main content

ic_testkit/benchmark/
mod.rs

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