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