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