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