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