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