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