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