Skip to main content

ic_testkit/benchmark/
mod.rs

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