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("comparison.csv"),
587        comparison_csv(report.comparison.as_ref()),
588    )?;
589    fs::write(
590        path.join("bench-summary.md"),
591        benchmark_summary_markdown(report),
592    )?;
593    fs::write(path.join("metadata.json"), metadata_json(&report.metadata))?;
594
595    Ok(())
596}
597
598fn parse_marker_line(
599    line: &str,
600    source_line: usize,
601    source: BenchmarkEventSource,
602    config: &BenchmarkParserConfig,
603) -> Result<RawBenchmarkEvent, MalformedBenchmarkMarker> {
604    let parts = line.split('|').collect::<Vec<_>>();
605    if parts.len() != 6 {
606        return Err(malformed(
607            source_line,
608            source,
609            line,
610            "expected six pipe-separated columns",
611        ));
612    }
613
614    let prefix = parts[0];
615    if !config.prefixes.iter().any(|known| known == prefix) {
616        return Err(malformed(
617            source_line,
618            source,
619            line,
620            "prefix is not configured",
621        ));
622    }
623
624    let label = parts[1];
625    if label.is_empty() {
626        return Err(malformed(source_line, source, line, "label is empty"));
627    }
628
629    let (span_label, kind) = split_label_kind(label).ok_or_else(|| {
630        malformed(
631            source_line,
632            source,
633            line,
634            "label must end in :start or :end",
635        )
636    })?;
637
638    let counters = BenchmarkCounters {
639        instructions: parse_counter(parts[2], source_line, source, line, "instructions")?,
640        heap_bytes: parse_counter(parts[3], source_line, source, line, "heap_bytes")?,
641        memory_bytes: parse_counter(parts[4], source_line, source, line, "memory_bytes")?,
642        total_allocation: parse_counter(parts[5], source_line, source, line, "total_allocation")?,
643    };
644    let suite = config.suite_derivation.derive_suite(span_label);
645
646    Ok(RawBenchmarkEvent {
647        prefix: prefix.to_string(),
648        label: label.to_string(),
649        suite,
650        span_label: span_label.to_string(),
651        kind,
652        counters,
653        source_line,
654        source,
655    })
656}
657
658fn parse_counter(
659    value: &str,
660    source_line: usize,
661    source: BenchmarkEventSource,
662    line: &str,
663    name: &str,
664) -> Result<u128, MalformedBenchmarkMarker> {
665    if value.is_empty() {
666        return Err(malformed(
667            source_line,
668            source,
669            line,
670            &format!("{name} counter is empty"),
671        ));
672    }
673
674    value.parse::<u128>().map_err(|_| {
675        malformed(
676            source_line,
677            source,
678            line,
679            &format!("{name} counter is not an unsigned integer"),
680        )
681    })
682}
683
684fn split_label_kind(label: &str) -> Option<(&str, BenchmarkEventKind)> {
685    let start = label.strip_suffix(":start");
686    let end = label.strip_suffix(":end");
687
688    match (start, end) {
689        (Some(span_label), None) if !span_label.is_empty() => {
690            Some((span_label, BenchmarkEventKind::Start))
691        }
692        (None, Some(span_label)) if !span_label.is_empty() => {
693            Some((span_label, BenchmarkEventKind::End))
694        }
695        _ => None,
696    }
697}
698
699fn has_configured_prefix(line: &str, prefixes: &[String]) -> bool {
700    prefixes.iter().any(|prefix| {
701        line.strip_prefix(prefix)
702            .is_some_and(|rest| rest.starts_with('|'))
703    })
704}
705
706fn malformed(
707    source_line: usize,
708    source: BenchmarkEventSource,
709    line: &str,
710    reason: &str,
711) -> MalformedBenchmarkMarker {
712    MalformedBenchmarkMarker {
713        source_line,
714        source,
715        line: line.to_string(),
716        reason: reason.to_string(),
717    }
718}
719
720fn push_paired_span(
721    report: &mut BenchmarkSpanReport,
722    start: RawBenchmarkEvent,
723    end: RawBenchmarkEvent,
724) {
725    if let Some(delta) = end.counters.checked_delta(start.counters) {
726        report.spans.push(BenchmarkSpan {
727            suite: start.suite.clone(),
728            span_label: start.span_label.clone(),
729            start_line: start.source_line,
730            end_line: end.source_line,
731            start: start.counters,
732            end: end.counters,
733            delta,
734        });
735    } else {
736        report.invalid_spans.push(InvalidBenchmarkSpan {
737            start,
738            end,
739            reason: "end counter is lower than start counter".to_string(),
740        });
741    }
742}
743
744#[derive(Clone, Debug)]
745struct AggregateBuilder {
746    suite: String,
747    span_label: String,
748    runs: u64,
749    total: BenchmarkCounters,
750    min: BenchmarkCounters,
751    max: BenchmarkCounters,
752    peak_end: BenchmarkCounters,
753}
754
755impl AggregateBuilder {
756    fn new(suite: &str, span_label: &str, span: &BenchmarkSpan) -> Self {
757        Self {
758            suite: suite.to_string(),
759            span_label: span_label.to_string(),
760            runs: 1,
761            total: span.delta,
762            min: span.delta,
763            max: span.delta,
764            peak_end: span.end,
765        }
766    }
767
768    fn push(&mut self, span: &BenchmarkSpan) {
769        self.runs += 1;
770        self.total.add_assign(span.delta);
771        self.min.min_assign(span.delta);
772        self.max.max_assign(span.delta);
773        self.peak_end.max_assign(span.end);
774    }
775
776    fn finish(self) -> BenchmarkAggregateRow {
777        BenchmarkAggregateRow {
778            suite: self.suite,
779            span_label: self.span_label,
780            runs: self.runs,
781            total: self.total,
782            average: averages(self.total, self.runs),
783            min: self.min,
784            max: self.max,
785            peak_end: self.peak_end,
786        }
787    }
788}
789
790fn add_span_to_aggregate(
791    rows: &mut BTreeMap<(String, String), AggregateBuilder>,
792    suite: &str,
793    span_label: &str,
794    span: &BenchmarkSpan,
795) {
796    match rows.entry((suite.to_string(), span_label.to_string())) {
797        Entry::Occupied(mut entry) => entry.get_mut().push(span),
798        Entry::Vacant(entry) => {
799            entry.insert(AggregateBuilder::new(suite, span_label, span));
800        }
801    }
802}
803
804#[expect(clippy::cast_precision_loss)]
805fn averages(total: BenchmarkCounters, runs: u64) -> BenchmarkAverages {
806    let runs = runs as f64;
807    BenchmarkAverages {
808        instructions: total.instructions as f64 / runs,
809        heap_bytes: total.heap_bytes as f64 / runs,
810        memory_bytes: total.memory_bytes as f64 / runs,
811        total_allocation: total.total_allocation as f64 / runs,
812    }
813}
814
815fn aggregate_rows_by_key(
816    rows: &[BenchmarkAggregateRow],
817) -> BTreeMap<(String, String), &BenchmarkAggregateRow> {
818    rows.iter()
819        .map(|row| ((row.suite.clone(), row.span_label.clone()), row))
820        .collect()
821}
822
823fn compare_average(current: Option<f64>, previous: Option<f64>) -> Option<f64> {
824    match (current, previous) {
825        (Some(current), Some(previous)) if previous != 0.0 => {
826            Some(((current - previous) / previous) * 100.0)
827        }
828        _ => None,
829    }
830}
831
832fn raw_events_csv(events: &[RawBenchmarkEvent]) -> String {
833    let mut out = String::from(
834        "source_line,source,prefix,suite,label,span_label,kind,instructions,heap_bytes,memory_bytes,total_allocation\n",
835    );
836    for event in events {
837        let _ = writeln!(
838            out,
839            "{},{},{},{},{},{},{},{},{},{},{}",
840            event.source_line,
841            event.source.as_str(),
842            csv_cell(&event.prefix),
843            csv_cell(&event.suite),
844            csv_cell(&event.label),
845            csv_cell(&event.span_label),
846            kind_str(event.kind),
847            event.counters.instructions,
848            event.counters.heap_bytes,
849            event.counters.memory_bytes,
850            event.counters.total_allocation
851        );
852    }
853    out
854}
855
856fn malformed_markers_csv(markers: &[MalformedBenchmarkMarker]) -> String {
857    let mut out = String::from("source_line,source,reason,line\n");
858    for marker in markers {
859        let _ = writeln!(
860            out,
861            "{},{},{},{}",
862            marker.source_line,
863            marker.source.as_str(),
864            csv_cell(&marker.reason),
865            csv_cell(&marker.line)
866        );
867    }
868    out
869}
870
871fn spans_csv(spans: &[BenchmarkSpan]) -> String {
872    let mut out = String::from(
873        "suite,span_label,start_line,end_line,instructions_delta,heap_bytes_delta,memory_bytes_delta,total_allocation_delta\n",
874    );
875    for span in spans {
876        let _ = writeln!(
877            out,
878            "{},{},{},{},{},{},{},{}",
879            csv_cell(&span.suite),
880            csv_cell(&span.span_label),
881            span.start_line,
882            span.end_line,
883            span.delta.instructions,
884            span.delta.heap_bytes,
885            span.delta.memory_bytes,
886            span.delta.total_allocation
887        );
888    }
889    out
890}
891
892fn unpaired_markers_csv(markers: &[UnpairedBenchmarkMarker]) -> String {
893    let mut out = String::from("source_line,source,kind,suite,span_label,label\n");
894    for marker in markers {
895        let kind = match marker.kind {
896            UnpairedBenchmarkMarkerKind::Start => "start",
897            UnpairedBenchmarkMarkerKind::End => "end",
898        };
899        let _ = writeln!(
900            out,
901            "{},{},{},{},{},{}",
902            marker.event.source_line,
903            marker.event.source.as_str(),
904            kind,
905            csv_cell(&marker.event.suite),
906            csv_cell(&marker.event.span_label),
907            csv_cell(&marker.event.label)
908        );
909    }
910    out
911}
912
913fn invalid_spans_csv(spans: &[InvalidBenchmarkSpan]) -> String {
914    let mut out = String::from("start_line,end_line,suite,span_label,reason\n");
915    for span in spans {
916        let _ = writeln!(
917            out,
918            "{},{},{},{},{}",
919            span.start.source_line,
920            span.end.source_line,
921            csv_cell(&span.start.suite),
922            csv_cell(&span.start.span_label),
923            csv_cell(&span.reason)
924        );
925    }
926    out
927}
928
929fn aggregates_csv<'a>(rows: impl Iterator<Item = &'a BenchmarkAggregateRow>) -> String {
930    let mut out = String::from(
931        "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",
932    );
933    for row in rows {
934        let _ = writeln!(
935            out,
936            "{},{},{},{},{:.4},{},{:.4},{},{:.4},{},{:.4}",
937            csv_cell(&row.suite),
938            csv_cell(&row.span_label),
939            row.runs,
940            row.total.instructions,
941            row.average.instructions,
942            row.total.heap_bytes,
943            row.average.heap_bytes,
944            row.total.memory_bytes,
945            row.average.memory_bytes,
946            row.total.total_allocation,
947            row.average.total_allocation
948        );
949    }
950    out
951}
952
953fn comparison_csv(comparison: Option<&BenchmarkComparisonReport>) -> String {
954    let mut out = String::from(
955        "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",
956    );
957
958    let Some(comparison) = comparison else {
959        return out;
960    };
961
962    for row in &comparison.rows {
963        let _ = writeln!(
964            out,
965            "{},{},{},{},{},{},{},{}",
966            csv_cell(&row.suite),
967            csv_cell(&row.span_label),
968            optional_u64_cell(row.current_runs),
969            optional_u64_cell(row.previous_runs),
970            optional_f64_cell(row.instructions_avg_change_percent),
971            optional_f64_cell(row.heap_bytes_avg_change_percent),
972            optional_f64_cell(row.memory_bytes_avg_change_percent),
973            optional_f64_cell(row.total_allocation_avg_change_percent)
974        );
975    }
976
977    out
978}
979
980fn benchmark_summary_markdown(report: &BenchmarkRunReport) -> String {
981    let comparison_by_key = report.comparison.as_ref().map(|comparison| {
982        comparison
983            .rows
984            .iter()
985            .map(|row| ((row.suite.clone(), row.span_label.clone()), row))
986            .collect::<BTreeMap<_, _>>()
987    });
988    let mut out = String::from(
989        "# Benchmark Summary\n\n| Benchmark | Runs | Instructions Avg | Heap Delta Avg | Memory Delta Avg | Allocation Avg |\n| --- | ---: | ---: | ---: | ---: | ---: |\n",
990    );
991
992    for row in report
993        .aggregates
994        .rows
995        .iter()
996        .filter(|row| row.suite != ALL_SUITES)
997    {
998        let comparison = comparison_by_key.as_ref().and_then(|rows| {
999            rows.get(&(row.suite.clone(), row.span_label.clone()))
1000                .copied()
1001        });
1002        let _ = writeln!(
1003            out,
1004            "| {} | {} | {} | {} | {} | {} |",
1005            markdown_cell(&row.span_label),
1006            row.runs,
1007            format_instructions(
1008                row.average.instructions,
1009                change_suffix(comparison, |c| { c.instructions_avg_change_percent })
1010            ),
1011            format_bytes(
1012                row.average.heap_bytes,
1013                change_suffix(comparison, |c| c.heap_bytes_avg_change_percent)
1014            ),
1015            format_bytes(
1016                row.average.memory_bytes,
1017                change_suffix(comparison, |c| c.memory_bytes_avg_change_percent)
1018            ),
1019            format_bytes(
1020                row.average.total_allocation,
1021                change_suffix(comparison, |c| c.total_allocation_avg_change_percent)
1022            )
1023        );
1024    }
1025
1026    out
1027}
1028
1029fn metadata_json(metadata: &BenchmarkRunMetadata) -> String {
1030    let value = serde_json::json!({
1031        "timestamp": metadata.timestamp,
1032        "run_directory_name": metadata.run_directory_name,
1033        "run_index": metadata.run_index,
1034        "git_commit_hash": metadata.git_commit_hash,
1035        "git_commit_short_hash": metadata.git_commit_short_hash,
1036        "ic_testkit_version": metadata.ic_testkit_version,
1037        "pocket_ic_version": metadata.pocket_ic_version,
1038        "rustc_version": metadata.rustc_version,
1039        "benchmark_command": metadata.benchmark_command,
1040        "selected_previous_run": metadata.selected_previous_run,
1041    });
1042
1043    let mut output = serde_json::to_string_pretty(&value).expect("metadata JSON must serialize");
1044    output.push('\n');
1045    output
1046}
1047
1048fn next_run_index_for_prefix(runs_root: &Path, prefix: &str) -> io::Result<u32> {
1049    if !runs_root.exists() {
1050        return Ok(1);
1051    }
1052
1053    let mut max_index = 0;
1054    for entry in fs::read_dir(runs_root)? {
1055        let entry = entry?;
1056        if !entry.file_type()?.is_dir() {
1057            continue;
1058        }
1059
1060        if let Some(index) = run_index_from_directory_name(&entry.file_name(), prefix) {
1061            max_index = max_index.max(index);
1062        }
1063    }
1064
1065    Ok(max_index.saturating_add(1))
1066}
1067
1068fn run_index_from_directory_name(name: &OsStr, prefix: &str) -> Option<u32> {
1069    let name = name.to_str()?;
1070    let index = name.strip_prefix(prefix)?;
1071
1072    if index.len() == 4 && index.chars().all(|char| char.is_ascii_digit()) {
1073        index.parse().ok()
1074    } else {
1075        None
1076    }
1077}
1078
1079fn short_commit_hash(hash: &str) -> String {
1080    hash.chars().take(7).collect()
1081}
1082
1083fn metadata_required_string(value: &Value, key: &str) -> io::Result<String> {
1084    metadata_optional_string(value, key)?.ok_or_else(|| {
1085        io::Error::new(
1086            io::ErrorKind::InvalidData,
1087            format!("missing required metadata string field `{key}`"),
1088        )
1089    })
1090}
1091
1092fn metadata_required_u32(value: &Value, key: &str) -> io::Result<u32> {
1093    let Some(raw) = value.get(key).and_then(Value::as_u64) else {
1094        return Err(io::Error::new(
1095            io::ErrorKind::InvalidData,
1096            format!("missing required metadata integer field `{key}`"),
1097        ));
1098    };
1099
1100    u32::try_from(raw).map_err(|err| {
1101        io::Error::new(
1102            io::ErrorKind::InvalidData,
1103            format!("metadata integer field `{key}` is invalid: {err}"),
1104        )
1105    })
1106}
1107
1108fn metadata_optional_string(value: &Value, key: &str) -> io::Result<Option<String>> {
1109    match value.get(key) {
1110        None | Some(Value::Null) => Ok(None),
1111        Some(Value::String(value)) => Ok(Some(value.clone())),
1112        Some(_) => Err(io::Error::new(
1113            io::ErrorKind::InvalidData,
1114            format!("metadata string field `{key}` is not a string or null"),
1115        )),
1116    }
1117}
1118
1119fn metadata_json_error(err: serde_json::Error) -> io::Error {
1120    io::Error::new(
1121        io::ErrorKind::InvalidData,
1122        format!("invalid benchmark metadata JSON: {err}"),
1123    )
1124}
1125
1126fn change_suffix(
1127    comparison: Option<&BenchmarkComparisonRow>,
1128    change: impl FnOnce(&BenchmarkComparisonRow) -> Option<f64>,
1129) -> Option<String> {
1130    comparison.and_then(|row| {
1131        if row.previous_runs.is_none() {
1132            Some("new".to_string())
1133        } else {
1134            change(row).map(|percent| format!("{percent:+.0}%"))
1135        }
1136    })
1137}
1138
1139fn format_instructions(value: f64, suffix: Option<String>) -> String {
1140    with_optional_suffix(format!("{:.4}B", value / 1_000_000_000.0), suffix)
1141}
1142
1143fn format_bytes(value: f64, suffix: Option<String>) -> String {
1144    with_optional_suffix(human_bytes(value), suffix)
1145}
1146
1147fn with_optional_suffix(value: String, suffix: Option<String>) -> String {
1148    match suffix {
1149        Some(suffix) => format!("{value} ({suffix})"),
1150        None => value,
1151    }
1152}
1153
1154fn human_bytes(value: f64) -> String {
1155    const KIB: f64 = 1024.0;
1156    const MIB: f64 = KIB * 1024.0;
1157    const GIB: f64 = MIB * 1024.0;
1158
1159    let (unit_value, unit) = if value.abs() >= GIB {
1160        (value / GIB, "GB")
1161    } else if value.abs() >= MIB {
1162        (value / MIB, "MB")
1163    } else if value.abs() >= KIB {
1164        (value / KIB, "KB")
1165    } else {
1166        (value, "B")
1167    };
1168
1169    format!("{unit_value:+.1} {unit}")
1170}
1171
1172const fn kind_str(kind: BenchmarkEventKind) -> &'static str {
1173    match kind {
1174        BenchmarkEventKind::Start => "start",
1175        BenchmarkEventKind::End => "end",
1176    }
1177}
1178
1179fn csv_cell(value: &str) -> String {
1180    if value.contains([',', '"', '\n', '\r']) {
1181        format!("\"{}\"", value.replace('"', "\"\""))
1182    } else {
1183        value.to_string()
1184    }
1185}
1186
1187fn optional_u64_cell(value: Option<u64>) -> String {
1188    value.map_or_else(String::new, |value| value.to_string())
1189}
1190
1191fn optional_f64_cell(value: Option<f64>) -> String {
1192    value.map_or_else(String::new, |value| format!("{value:.4}"))
1193}
1194
1195fn markdown_cell(value: &str) -> String {
1196    value.replace('|', "\\|")
1197}