Skip to main content

perfgate_ingest/
hyperfine.rs

1//! Parser for hyperfine benchmark results.
2//!
3//! hyperfine's `--export-json` output has a top-level `results` array,
4//! where each entry contains `command`, `times` (array of seconds),
5//! `mean`, `stddev`, `median`, `min`, `max`, etc.
6
7use anyhow::Context;
8use perfgate_types::{RunReceipt, Sample, Stats};
9use serde::Deserialize;
10
11use crate::{compute_u64_summary, make_receipt};
12
13/// A single result entry from hyperfine JSON output.
14#[derive(Debug, Deserialize)]
15struct HyperfineResult {
16    command: String,
17    /// Raw timing data in seconds.
18    times: Vec<f64>,
19    mean: f64,
20    stddev: f64,
21    median: f64,
22    min: f64,
23    max: f64,
24}
25
26/// Top-level hyperfine JSON structure.
27#[derive(Debug, Deserialize)]
28struct HyperfineOutput {
29    results: Vec<HyperfineResult>,
30}
31
32/// Parse a hyperfine JSON export into a `RunReceipt`.
33///
34/// If the export contains multiple results (multiple commands benchmarked),
35/// only the first result is used. Use the `name` parameter to override
36/// the benchmark name (defaults to the command string).
37pub fn parse_hyperfine(input: &str, name: Option<&str>) -> anyhow::Result<RunReceipt> {
38    let output: HyperfineOutput =
39        serde_json::from_str(input).context("failed to parse hyperfine JSON")?;
40
41    let result = output
42        .results
43        .first()
44        .context("hyperfine JSON contains no results")?;
45
46    let bench_name = name
47        .map(|n| n.to_string())
48        .unwrap_or_else(|| result.command.clone());
49
50    // hyperfine times are in seconds; convert to milliseconds.
51    let mut wall_values = Vec::new();
52    let mut samples = Vec::new();
53
54    for &t in &result.times {
55        let ms = seconds_to_ms(t);
56        wall_values.push(ms);
57        samples.push(Sample {
58            wall_ms: ms,
59            exit_code: 0,
60            warmup: false,
61            timed_out: false,
62            cpu_ms: None,
63            page_faults: None,
64            ctx_switches: None,
65            max_rss_kb: None,
66            io_read_bytes: None,
67            io_write_bytes: None,
68            network_packets: None,
69            energy_uj: None,
70            binary_bytes: None,
71            stdout: None,
72            stderr: None,
73        });
74    }
75
76    let mut stats = compute_u64_summary(&wall_values);
77    // Override with hyperfine's own statistics (more precise).
78    // median/min/max are u64 so integer seconds_to_ms() is fine.
79    stats.median = seconds_to_ms(result.median);
80    stats.min = seconds_to_ms(result.min);
81    stats.max = seconds_to_ms(result.max);
82    // IMPORTANT: Use f64 arithmetic here, NOT seconds_to_ms(). See the
83    // GOTCHA on seconds_to_ms — integer truncation would lose sub-ms
84    // precision that budget evaluation and significance testing rely on.
85    stats.mean = Some(result.mean * 1000.0);
86    stats.stddev = Some(result.stddev * 1000.0);
87
88    let full_stats = Stats {
89        wall_ms: stats,
90        cpu_ms: None,
91        page_faults: None,
92        ctx_switches: None,
93        max_rss_kb: None,
94        io_read_bytes: None,
95        io_write_bytes: None,
96        network_packets: None,
97        energy_uj: None,
98        binary_bytes: None,
99        throughput_per_s: None,
100    };
101
102    Ok(make_receipt(&bench_name, samples, full_stats))
103}
104
105/// Integer seconds-to-ms conversion for sample `wall_ms` values (u64).
106///
107/// GOTCHA: This intentionally truncates to integer milliseconds -- it is only
108/// appropriate for per-sample u64 fields where sub-ms precision is not needed.
109/// For stats fields (mean, stddev) use direct `f64` arithmetic (`value * 1000.0`)
110/// to preserve sub-millisecond precision. Using this function for stats would
111/// silently destroy the fractional component that downstream budget evaluation
112/// and significance testing depend on.
113fn seconds_to_ms(s: f64) -> u64 {
114    let ms = s * 1000.0;
115    if ms < 1.0 && ms > 0.0 {
116        1
117    } else {
118        ms.round() as u64
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use perfgate_types::RUN_SCHEMA_V1;
126
127    const HYPERFINE_JSON: &str = r#"{
128        "results": [
129            {
130                "command": "sleep 0.1",
131                "mean": 0.1023,
132                "stddev": 0.0015,
133                "median": 0.1020,
134                "user": 0.001,
135                "system": 0.002,
136                "min": 0.1001,
137                "max": 0.1056,
138                "times": [0.1001, 0.1015, 0.1020, 0.1030, 0.1056],
139                "exit_codes": [0, 0, 0, 0, 0]
140            }
141        ]
142    }"#;
143
144    #[test]
145    fn parse_hyperfine_basic() {
146        let receipt = parse_hyperfine(HYPERFINE_JSON, Some("sleep-bench")).unwrap();
147        assert_eq!(receipt.schema, RUN_SCHEMA_V1);
148        assert_eq!(receipt.bench.name, "sleep-bench");
149        assert_eq!(receipt.samples.len(), 5);
150        // 0.102 seconds = 102 ms
151        assert_eq!(receipt.stats.wall_ms.median, 102);
152        assert_eq!(receipt.stats.wall_ms.min, 100);
153        assert_eq!(receipt.stats.wall_ms.max, 106);
154    }
155
156    #[test]
157    fn parse_hyperfine_default_name() {
158        let receipt = parse_hyperfine(HYPERFINE_JSON, None).unwrap();
159        assert_eq!(receipt.bench.name, "sleep 0.1");
160    }
161
162    #[test]
163    fn parse_hyperfine_sample_wall_ms() {
164        let receipt = parse_hyperfine(HYPERFINE_JSON, None).unwrap();
165        // Each sample should have its own wall_ms from the times array
166        let wall_values: Vec<u64> = receipt.samples.iter().map(|s| s.wall_ms).collect();
167        assert_eq!(wall_values, vec![100, 102, 102, 103, 106]);
168    }
169
170    #[test]
171    fn parse_hyperfine_multiple_results() {
172        // Only the first result should be used
173        let input = r#"{
174            "results": [
175                {
176                    "command": "echo first",
177                    "mean": 0.005,
178                    "stddev": 0.001,
179                    "median": 0.005,
180                    "user": 0.001,
181                    "system": 0.001,
182                    "min": 0.004,
183                    "max": 0.006,
184                    "times": [0.004, 0.005, 0.006],
185                    "exit_codes": [0, 0, 0]
186                },
187                {
188                    "command": "echo second",
189                    "mean": 0.010,
190                    "stddev": 0.002,
191                    "median": 0.010,
192                    "user": 0.001,
193                    "system": 0.001,
194                    "min": 0.008,
195                    "max": 0.012,
196                    "times": [0.008, 0.010, 0.012],
197                    "exit_codes": [0, 0, 0]
198                }
199            ]
200        }"#;
201        let receipt = parse_hyperfine(input, None).unwrap();
202        assert_eq!(receipt.bench.name, "echo first");
203    }
204
205    #[test]
206    fn parse_hyperfine_empty_results() {
207        let input = r#"{"results": []}"#;
208        let result = parse_hyperfine(input, None);
209        assert!(result.is_err());
210    }
211
212    #[test]
213    fn parse_hyperfine_invalid_json() {
214        let result = parse_hyperfine("{bad json", None);
215        assert!(result.is_err());
216    }
217}