Skip to main content

ic_testkit/benchmark/
report.rs

1//! Native benchmark metadata, CSV and Markdown report 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::{fmt::Write as _, fs, io, path::Path};
7
8use serde_json::Value;
9
10use super::{
11    BenchmarkAggregateRow, BenchmarkAverages, BenchmarkComparisonReport, BenchmarkEventKind,
12    BenchmarkRunMetadata, BenchmarkRunReport, BenchmarkScope, BenchmarkSpan, InvalidBenchmarkSpan,
13    MalformedBenchmarkMarker, RawBenchmarkEvent,
14};
15
16const ALL_SUITES_LABEL: &str = "ALL";
17
18impl BenchmarkScope {
19    fn label(&self) -> &str {
20        match self {
21            Self::Suite(suite) => suite,
22            Self::All => ALL_SUITES_LABEL,
23        }
24    }
25}
26
27pub fn read_benchmark_run_metadata(path: impl AsRef<Path>) -> io::Result<BenchmarkRunMetadata> {
28    let input = fs::read_to_string(path)?;
29    let value = serde_json::from_str::<Value>(&input).map_err(metadata_json_error)?;
30
31    if !value.is_object() {
32        return Err(io::Error::new(
33            io::ErrorKind::InvalidData,
34            "benchmark metadata JSON must be an object",
35        ));
36    }
37    serde_json::from_value(value).map_err(metadata_json_error)
38}
39
40/// Write one complete benchmark report into the requested directory.
41///
42/// The directory is caller-owned. Concurrent writers must use unique paths or
43/// synchronize access to the same path outside this function.
44pub fn write_benchmark_report_dir(
45    report: &BenchmarkRunReport,
46    path: impl AsRef<Path>,
47) -> io::Result<()> {
48    let path = path.as_ref();
49    let suite_aggregates = aggregates_csv(
50        report
51            .aggregates
52            .rows
53            .iter()
54            .filter(|row| matches!(row.scope, BenchmarkScope::Suite(_))),
55    )?;
56    let all_aggregates = aggregates_csv(
57        report
58            .aggregates
59            .rows
60            .iter()
61            .filter(|row| matches!(row.scope, BenchmarkScope::All)),
62    )?;
63    let summary = benchmark_summary_markdown(report)?;
64    fs::create_dir_all(path)?;
65
66    fs::write(
67        path.join("raw-events.csv"),
68        raw_events_csv(&report.parse.events),
69    )?;
70    fs::write(
71        path.join("malformed-markers.csv"),
72        malformed_markers_csv(&report.parse.malformed_markers),
73    )?;
74    fs::write(path.join("spans.csv"), spans_csv(&report.spans.spans))?;
75    fs::write(
76        path.join("unpaired-markers.csv"),
77        unpaired_markers_csv(&report.spans.unpaired_markers),
78    )?;
79    fs::write(
80        path.join("invalid-spans.csv"),
81        invalid_spans_csv(&report.spans.invalid_spans),
82    )?;
83    fs::write(path.join("suite-aggregates.csv"), suite_aggregates)?;
84    fs::write(path.join("all-aggregates.csv"), all_aggregates)?;
85    fs::write(
86        path.join("comparison.csv"),
87        comparison_csv(report.comparison.as_ref()),
88    )?;
89    fs::write(path.join("bench-summary.md"), summary)?;
90    fs::write(path.join("metadata.json"), metadata_json(&report.metadata))?;
91
92    Ok(())
93}
94
95fn raw_events_csv(events: &[RawBenchmarkEvent]) -> String {
96    let mut out = String::from(
97        "source_line,source,prefix,suite,label,span_label,kind,instructions,heap_bytes,memory_bytes,total_allocation\n",
98    );
99    for event in events {
100        let _ = writeln!(
101            out,
102            "{},{},{},{},{},{},{},{},{},{},{}",
103            event.source_line,
104            event.source.as_str(),
105            csv_cell(&event.prefix),
106            csv_cell(&event.suite),
107            csv_cell(&format!("{}:{}", event.span_label, kind_str(event.kind))),
108            csv_cell(&event.span_label),
109            kind_str(event.kind),
110            event.counters.instructions,
111            event.counters.heap_bytes,
112            event.counters.memory_bytes,
113            event.counters.total_allocation
114        );
115    }
116    out
117}
118
119fn malformed_markers_csv(markers: &[MalformedBenchmarkMarker]) -> String {
120    let mut out = String::from("source_line,source,reason,line\n");
121    for marker in markers {
122        let _ = writeln!(
123            out,
124            "{},{},{},{}",
125            marker.source_line,
126            marker.source.as_str(),
127            csv_cell(&marker.reason),
128            csv_cell(&marker.line)
129        );
130    }
131    out
132}
133
134fn spans_csv(spans: &[BenchmarkSpan]) -> String {
135    let mut out = String::from(
136        "suite,span_label,start_line,end_line,instructions_delta,heap_bytes_delta,memory_bytes_delta,total_allocation_delta\n",
137    );
138    for span in spans {
139        let _ = writeln!(
140            out,
141            "{},{},{},{},{},{},{},{}",
142            csv_cell(&span.suite),
143            csv_cell(&span.span_label),
144            span.start_line,
145            span.end_line,
146            span.delta.instructions,
147            span.delta.heap_bytes,
148            span.delta.memory_bytes,
149            span.delta.total_allocation
150        );
151    }
152    out
153}
154
155fn unpaired_markers_csv(markers: &[RawBenchmarkEvent]) -> String {
156    let mut out = String::from("source_line,source,kind,suite,span_label,label\n");
157    for marker in markers {
158        let kind = kind_str(marker.kind);
159        let _ = writeln!(
160            out,
161            "{},{},{},{},{},{}",
162            marker.source_line,
163            marker.source.as_str(),
164            kind,
165            csv_cell(&marker.suite),
166            csv_cell(&marker.span_label),
167            csv_cell(&format!("{}:{kind}", marker.span_label))
168        );
169    }
170    out
171}
172
173fn invalid_spans_csv(spans: &[InvalidBenchmarkSpan]) -> String {
174    let mut out = String::from("start_line,end_line,suite,span_label,reason\n");
175    for span in spans {
176        let _ = writeln!(
177            out,
178            "{},{},{},{},{}",
179            span.start.source_line,
180            span.end.source_line,
181            csv_cell(&span.start.suite),
182            csv_cell(&span.start.span_label),
183            csv_cell(&span.reason)
184        );
185    }
186    out
187}
188
189fn aggregates_csv<'a>(rows: impl Iterator<Item = &'a BenchmarkAggregateRow>) -> io::Result<String> {
190    let mut out = String::from(
191        "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",
192    );
193    for row in rows {
194        let average = row.average(1).map_err(io::Error::other)?;
195        let _ = writeln!(
196            out,
197            "{},{},{},{},{},{},{},{},{},{},{}",
198            csv_cell(row.scope.label()),
199            csv_cell(&row.span_label),
200            row.runs,
201            row.total.instructions,
202            average.instructions,
203            row.total.heap_bytes,
204            average.heap_bytes,
205            row.total.memory_bytes,
206            average.memory_bytes,
207            row.total.total_allocation,
208            average.total_allocation
209        );
210    }
211    Ok(out)
212}
213
214fn comparison_csv(comparison: Option<&BenchmarkComparisonReport>) -> String {
215    let mut out = String::from(
216        "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",
217    );
218    if let Some(comparison) = comparison {
219        for row in &comparison.rows {
220            let current = row.current.map(|measurement| measurement.average);
221            let previous = row.previous.map(|measurement| measurement.average);
222            let value = |mean: Option<BenchmarkAverages>, field: fn(BenchmarkAverages) -> u128| {
223                mean.map_or_else(String::new, |mean| field(mean).to_string())
224            };
225            let _ = writeln!(
226                out,
227                "{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}",
228                if matches!(row.scope, BenchmarkScope::All) {
229                    "all"
230                } else {
231                    "suite"
232                },
233                csv_cell(row.scope.label()),
234                csv_cell(&row.span_label),
235                optional_u64_cell(row.current.map(|measurement| measurement.runs)),
236                optional_u64_cell(row.previous.map(|measurement| measurement.runs)),
237                current.map_or_else(String::new, |mean| mean.scale.to_string()),
238                previous.map_or_else(String::new, |mean| mean.scale.to_string()),
239                value(current, |m| m.instructions),
240                value(previous, |m| m.instructions),
241                value(current, |m| m.heap_bytes),
242                value(previous, |m| m.heap_bytes),
243                value(current, |m| m.memory_bytes),
244                value(previous, |m| m.memory_bytes),
245                value(current, |m| m.total_allocation),
246                value(previous, |m| m.total_allocation)
247            );
248        }
249    }
250    out
251}
252
253fn benchmark_summary_markdown(report: &BenchmarkRunReport) -> io::Result<String> {
254    let mut out = String::from(
255        "# 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",
256    );
257    for row in report
258        .aggregates
259        .rows
260        .iter()
261        .filter(|row| matches!(row.scope, BenchmarkScope::Suite(_)))
262    {
263        let average = row.average(1).map_err(io::Error::other)?;
264        let _ = writeln!(
265            out,
266            "| {} | {} | {} | {} | {} | {} |",
267            markdown_cell(&row.span_label),
268            row.runs,
269            average.instructions,
270            average.heap_bytes,
271            average.memory_bytes,
272            average.total_allocation
273        );
274    }
275    Ok(out)
276}
277
278fn metadata_json(metadata: &BenchmarkRunMetadata) -> String {
279    let mut output = serde_json::to_string_pretty(metadata).expect("metadata JSON must serialize");
280    output.push('\n');
281    output
282}
283
284fn metadata_json_error(err: serde_json::Error) -> io::Error {
285    io::Error::new(
286        io::ErrorKind::InvalidData,
287        format!("invalid benchmark metadata JSON: {err}"),
288    )
289}
290
291const fn kind_str(kind: BenchmarkEventKind) -> &'static str {
292    match kind {
293        BenchmarkEventKind::Start => "start",
294        BenchmarkEventKind::End => "end",
295    }
296}
297
298fn csv_cell(value: &str) -> String {
299    if value.contains([',', '"', '\n', '\r']) {
300        format!("\"{}\"", value.replace('"', "\"\""))
301    } else {
302        value.to_string()
303    }
304}
305
306fn optional_u64_cell(value: Option<u64>) -> String {
307    value.map_or_else(String::new, |value| value.to_string())
308}
309
310fn markdown_cell(value: &str) -> String {
311    value.replace('|', "\\|")
312}