Skip to main content

ic_testkit/benchmark/
report.rs

1//! Native benchmark report directories, metadata, CSV and Markdown output.
2//!
3//! Paths and synchronization remain caller-owned; retained report formats are
4//! unchanged. Marker parsing and arithmetic live in the parent module.
5
6use std::{
7    fmt::Write as _,
8    fs, io,
9    path::{Path, PathBuf},
10};
11
12use serde_json::Value;
13
14use super::{
15    BenchmarkAggregateRow, BenchmarkAverages, BenchmarkComparisonReport, BenchmarkEventKind,
16    BenchmarkRunMetadata, BenchmarkRunReport, BenchmarkScope, BenchmarkSpan, InvalidBenchmarkSpan,
17    MalformedBenchmarkMarker, RawBenchmarkEvent, UnpairedBenchmarkMarker,
18    UnpairedBenchmarkMarkerKind,
19};
20
21const ALL_SUITES_LABEL: &str = "ALL";
22
23impl BenchmarkScope {
24    fn label(&self) -> &str {
25        match self {
26            Self::Suite(suite) => suite,
27            Self::All => ALL_SUITES_LABEL,
28        }
29    }
30}
31
32#[derive(Clone, Debug, Eq, PartialEq)]
33pub struct BenchmarkRunDirectory {
34    pub path: PathBuf,
35    pub directory_name: String,
36    pub run_index: u32,
37    pub git_commit_hash: Option<String>,
38    pub git_commit_short_hash: Option<String>,
39}
40
41#[must_use]
42pub fn benchmark_run_directory_name(
43    timestamp: &str,
44    git_commit_short_hash: Option<&str>,
45    run_index: u32,
46) -> String {
47    let commit = git_commit_short_hash
48        .filter(|hash| !hash.is_empty())
49        .unwrap_or("unknown");
50    format!("{timestamp}-{commit}-{run_index:04}")
51}
52
53/// Compute the next available benchmark run path for one timestamp and commit.
54///
55/// This function does not create or reserve the returned directory. Callers
56/// allocating the same prefix concurrently must synchronize that shared
57/// resource or provide unique timestamps.
58/// A missing runs root starts at index one; other directory errors are returned.
59pub fn next_benchmark_run_directory(
60    runs_root: impl AsRef<Path>,
61    timestamp: &str,
62    git_commit_hash: Option<&str>,
63) -> io::Result<BenchmarkRunDirectory> {
64    let runs_root = runs_root.as_ref();
65    let git_commit_hash = git_commit_hash.filter(|hash| !hash.is_empty());
66    let git_commit_short_hash = git_commit_hash.map(short_commit_hash);
67    let prefix = format!(
68        "{}-{}-",
69        timestamp,
70        git_commit_short_hash.as_deref().unwrap_or("unknown")
71    );
72    let run_index = next_run_index_for_prefix(runs_root, &prefix)?;
73    let directory_name =
74        benchmark_run_directory_name(timestamp, git_commit_short_hash.as_deref(), run_index);
75
76    Ok(BenchmarkRunDirectory {
77        path: runs_root.join(&directory_name),
78        directory_name,
79        run_index,
80        git_commit_hash: git_commit_hash.map(str::to_string),
81        git_commit_short_hash,
82    })
83}
84
85/// Find the latest earlier run with readable metadata and a matching command.
86///
87/// Names must follow [`benchmark_run_directory_name`]; indices are ordered
88/// numerically even when they exceed four digits. Eligible runs are ranked by
89/// metadata timestamp, directory prefix, then numeric index. An invalid current
90/// name returns [`io::ErrorKind::InvalidInput`]. Unreadable or malformed
91/// candidates are skipped.
92/// A missing runs root has no previous run; other directory errors are returned.
93pub fn find_latest_previous_run(
94    runs_root: impl AsRef<Path>,
95    current_run_directory_name: &str,
96    benchmark_command: Option<&str>,
97) -> io::Result<Option<PathBuf>> {
98    let runs_root = runs_root.as_ref();
99    let current_key = run_directory_sort_key(current_run_directory_name).ok_or_else(|| {
100        io::Error::new(
101            io::ErrorKind::InvalidInput,
102            "invalid benchmark run directory name",
103        )
104    })?;
105    let mut latest: Option<((String, String, u32), PathBuf)> = None;
106
107    let entries = match fs::read_dir(runs_root) {
108        Ok(entries) => entries,
109        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
110        Err(error) => return Err(error),
111    };
112    for entry in entries {
113        let entry = entry?;
114        if !entry.file_type()?.is_dir() {
115            continue;
116        }
117
118        let directory_name = entry.file_name().to_string_lossy().into_owned();
119        let Some((prefix, index)) = run_directory_sort_key(&directory_name) else {
120            continue;
121        };
122        if (prefix, index) >= current_key {
123            continue;
124        }
125
126        let metadata_path = entry.path().join("metadata.json");
127        let Ok(metadata) = read_benchmark_run_metadata(&metadata_path) else {
128            continue;
129        };
130
131        if let Some(command) = benchmark_command
132            && metadata.benchmark_command.as_deref() != Some(command)
133        {
134            continue;
135        }
136
137        let key = (metadata.timestamp, prefix.to_owned(), index);
138        if latest
139            .as_ref()
140            .is_none_or(|(latest_key, _)| key >= *latest_key)
141        {
142            latest = Some((key, entry.path()));
143        }
144    }
145
146    Ok(latest.map(|(_, path)| path))
147}
148
149pub fn read_benchmark_run_metadata(path: impl AsRef<Path>) -> io::Result<BenchmarkRunMetadata> {
150    let input = fs::read_to_string(path)?;
151    let value = serde_json::from_str::<Value>(&input).map_err(metadata_json_error)?;
152
153    if !value.is_object() {
154        return Err(io::Error::new(
155            io::ErrorKind::InvalidData,
156            "benchmark metadata JSON must be an object",
157        ));
158    }
159    serde_json::from_value(value).map_err(metadata_json_error)
160}
161
162/// Write one complete benchmark report into the requested directory.
163///
164/// The directory is caller-owned. Concurrent writers must use unique paths or
165/// synchronize access to the same path outside this function.
166pub fn write_benchmark_report_dir(
167    report: &BenchmarkRunReport,
168    path: impl AsRef<Path>,
169) -> io::Result<()> {
170    let path = path.as_ref();
171    let suite_aggregates = aggregates_csv(
172        report
173            .aggregates
174            .rows
175            .iter()
176            .filter(|row| matches!(row.scope, BenchmarkScope::Suite(_))),
177    )?;
178    let all_aggregates = aggregates_csv(
179        report
180            .aggregates
181            .rows
182            .iter()
183            .filter(|row| matches!(row.scope, BenchmarkScope::All)),
184    )?;
185    let summary = benchmark_summary_markdown(report)?;
186    fs::create_dir_all(path)?;
187
188    fs::write(
189        path.join("raw-events.csv"),
190        raw_events_csv(&report.parse.events),
191    )?;
192    fs::write(
193        path.join("malformed-markers.csv"),
194        malformed_markers_csv(&report.parse.malformed_markers),
195    )?;
196    fs::write(path.join("spans.csv"), spans_csv(&report.spans.spans))?;
197    fs::write(
198        path.join("unpaired-markers.csv"),
199        unpaired_markers_csv(&report.spans.unpaired_markers),
200    )?;
201    fs::write(
202        path.join("invalid-spans.csv"),
203        invalid_spans_csv(&report.spans.invalid_spans),
204    )?;
205    fs::write(path.join("suite-aggregates.csv"), suite_aggregates)?;
206    fs::write(path.join("all-aggregates.csv"), all_aggregates)?;
207    fs::write(
208        path.join("comparison.csv"),
209        comparison_csv(report.comparison.as_ref()),
210    )?;
211    fs::write(path.join("bench-summary.md"), summary)?;
212    fs::write(path.join("metadata.json"), metadata_json(&report.metadata))?;
213
214    Ok(())
215}
216
217fn raw_events_csv(events: &[RawBenchmarkEvent]) -> String {
218    let mut out = String::from(
219        "source_line,source,prefix,suite,label,span_label,kind,instructions,heap_bytes,memory_bytes,total_allocation\n",
220    );
221    for event in events {
222        let _ = writeln!(
223            out,
224            "{},{},{},{},{},{},{},{},{},{},{}",
225            event.source_line,
226            event.source.as_str(),
227            csv_cell(&event.prefix),
228            csv_cell(&event.suite),
229            csv_cell(&event.label),
230            csv_cell(&event.span_label),
231            kind_str(event.kind),
232            event.counters.instructions,
233            event.counters.heap_bytes,
234            event.counters.memory_bytes,
235            event.counters.total_allocation
236        );
237    }
238    out
239}
240
241fn malformed_markers_csv(markers: &[MalformedBenchmarkMarker]) -> String {
242    let mut out = String::from("source_line,source,reason,line\n");
243    for marker in markers {
244        let _ = writeln!(
245            out,
246            "{},{},{},{}",
247            marker.source_line,
248            marker.source.as_str(),
249            csv_cell(&marker.reason),
250            csv_cell(&marker.line)
251        );
252    }
253    out
254}
255
256fn spans_csv(spans: &[BenchmarkSpan]) -> String {
257    let mut out = String::from(
258        "suite,span_label,start_line,end_line,instructions_delta,heap_bytes_delta,memory_bytes_delta,total_allocation_delta\n",
259    );
260    for span in spans {
261        let _ = writeln!(
262            out,
263            "{},{},{},{},{},{},{},{}",
264            csv_cell(&span.suite),
265            csv_cell(&span.span_label),
266            span.start_line,
267            span.end_line,
268            span.delta.instructions,
269            span.delta.heap_bytes,
270            span.delta.memory_bytes,
271            span.delta.total_allocation
272        );
273    }
274    out
275}
276
277fn unpaired_markers_csv(markers: &[UnpairedBenchmarkMarker]) -> String {
278    let mut out = String::from("source_line,source,kind,suite,span_label,label\n");
279    for marker in markers {
280        let kind = match marker.kind {
281            UnpairedBenchmarkMarkerKind::Start => "start",
282            UnpairedBenchmarkMarkerKind::End => "end",
283        };
284        let _ = writeln!(
285            out,
286            "{},{},{},{},{},{}",
287            marker.event.source_line,
288            marker.event.source.as_str(),
289            kind,
290            csv_cell(&marker.event.suite),
291            csv_cell(&marker.event.span_label),
292            csv_cell(&marker.event.label)
293        );
294    }
295    out
296}
297
298fn invalid_spans_csv(spans: &[InvalidBenchmarkSpan]) -> String {
299    let mut out = String::from("start_line,end_line,suite,span_label,reason\n");
300    for span in spans {
301        let _ = writeln!(
302            out,
303            "{},{},{},{},{}",
304            span.start.source_line,
305            span.end.source_line,
306            csv_cell(&span.start.suite),
307            csv_cell(&span.start.span_label),
308            csv_cell(&span.reason)
309        );
310    }
311    out
312}
313
314fn aggregates_csv<'a>(rows: impl Iterator<Item = &'a BenchmarkAggregateRow>) -> io::Result<String> {
315    let mut out = String::from(
316        "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",
317    );
318    for row in rows {
319        let average = row.average(1).map_err(io::Error::other)?;
320        let _ = writeln!(
321            out,
322            "{},{},{},{},{},{},{},{},{},{},{}",
323            csv_cell(row.scope.label()),
324            csv_cell(&row.span_label),
325            row.runs,
326            row.total.instructions,
327            average.instructions,
328            row.total.heap_bytes,
329            average.heap_bytes,
330            row.total.memory_bytes,
331            average.memory_bytes,
332            row.total.total_allocation,
333            average.total_allocation
334        );
335    }
336    Ok(out)
337}
338
339fn comparison_csv(comparison: Option<&BenchmarkComparisonReport>) -> String {
340    let mut out = String::from(
341        "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",
342    );
343    if let Some(comparison) = comparison {
344        for row in &comparison.rows {
345            let current = row.current_average;
346            let previous = row.previous_average;
347            let value = |mean: Option<BenchmarkAverages>, field: fn(BenchmarkAverages) -> u128| {
348                mean.map_or_else(String::new, |mean| field(mean).to_string())
349            };
350            let _ = writeln!(
351                out,
352                "{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}",
353                if matches!(row.scope, BenchmarkScope::All) {
354                    "all"
355                } else {
356                    "suite"
357                },
358                csv_cell(row.scope.label()),
359                csv_cell(&row.span_label),
360                optional_u64_cell(row.current_runs),
361                optional_u64_cell(row.previous_runs),
362                current.map_or_else(String::new, |mean| mean.scale.to_string()),
363                previous.map_or_else(String::new, |mean| mean.scale.to_string()),
364                value(current, |m| m.instructions),
365                value(previous, |m| m.instructions),
366                value(current, |m| m.heap_bytes),
367                value(previous, |m| m.heap_bytes),
368                value(current, |m| m.memory_bytes),
369                value(previous, |m| m.memory_bytes),
370                value(current, |m| m.total_allocation),
371                value(previous, |m| m.total_allocation)
372            );
373        }
374    }
375    out
376}
377
378fn benchmark_summary_markdown(report: &BenchmarkRunReport) -> io::Result<String> {
379    let mut out = String::from(
380        "# 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",
381    );
382    for row in report
383        .aggregates
384        .rows
385        .iter()
386        .filter(|row| matches!(row.scope, BenchmarkScope::Suite(_)))
387    {
388        let average = row.average(1).map_err(io::Error::other)?;
389        let _ = writeln!(
390            out,
391            "| {} | {} | {} | {} | {} | {} |",
392            markdown_cell(&row.span_label),
393            row.runs,
394            average.instructions,
395            average.heap_bytes,
396            average.memory_bytes,
397            average.total_allocation
398        );
399    }
400    Ok(out)
401}
402
403fn metadata_json(metadata: &BenchmarkRunMetadata) -> String {
404    let mut output = serde_json::to_string_pretty(metadata).expect("metadata JSON must serialize");
405    output.push('\n');
406    output
407}
408
409fn next_run_index_for_prefix(runs_root: &Path, prefix: &str) -> io::Result<u32> {
410    let entries = match fs::read_dir(runs_root) {
411        Ok(entries) => entries,
412        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(1),
413        Err(error) => return Err(error),
414    };
415    let mut max_index = 0;
416    for entry in entries {
417        let entry = entry?;
418        if !entry.file_type()?.is_dir() {
419            continue;
420        }
421
422        if let Some(index) = entry
423            .file_name()
424            .to_str()
425            .and_then(|name| name.strip_prefix(prefix))
426            .and_then(parse_run_index)
427        {
428            max_index = max_index.max(index);
429        }
430    }
431
432    max_index
433        .checked_add(1)
434        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "benchmark run index exhausted"))
435}
436
437fn parse_run_index(index: &str) -> Option<u32> {
438    if index.len() >= 4 && index.chars().all(|char| char.is_ascii_digit()) {
439        index.parse().ok()
440    } else {
441        None
442    }
443}
444
445fn run_directory_sort_key(name: &str) -> Option<(&str, u32)> {
446    let (prefix, index) = name.rsplit_once('-')?;
447    Some((prefix, parse_run_index(index)?))
448}
449
450fn short_commit_hash(hash: &str) -> String {
451    hash.chars().take(7).collect()
452}
453
454fn metadata_json_error(err: serde_json::Error) -> io::Error {
455    io::Error::new(
456        io::ErrorKind::InvalidData,
457        format!("invalid benchmark metadata JSON: {err}"),
458    )
459}
460
461const fn kind_str(kind: BenchmarkEventKind) -> &'static str {
462    match kind {
463        BenchmarkEventKind::Start => "start",
464        BenchmarkEventKind::End => "end",
465    }
466}
467
468fn csv_cell(value: &str) -> String {
469    if value.contains([',', '"', '\n', '\r']) {
470        format!("\"{}\"", value.replace('"', "\"\""))
471    } else {
472        value.to_string()
473    }
474}
475
476fn optional_u64_cell(value: Option<u64>) -> String {
477    value.map_or_else(String::new, |value| value.to_string())
478}
479
480fn markdown_cell(value: &str) -> String {
481    value.replace('|', "\\|")
482}