Skip to main content

cgp/analysis/
bench.rs

1//! `cgp bench` — Enhanced criterion benchmarking with hardware counters.
2//! Spec section 2.3: run cargo bench, capture criterion output,
3//! optionally overlay perf stat counters, check regression.
4
5use anyhow::{Context, Result};
6use std::process::Command;
7
8/// Run the `cgp bench` command.
9pub fn run_bench(
10    bench_name: &str,
11    counters: Option<&str>,
12    check_regression: bool,
13    threshold: f64,
14    _roofline: bool,
15) -> Result<()> {
16    println!("\n=== CGP Bench: {bench_name} ===\n");
17
18    // Build the cargo bench command
19    let mut cmd = Command::new("cargo");
20    cmd.arg("bench");
21
22    // Add criterion filter if the bench name contains a slash (bench_name/filter)
23    if let Some((bench, filter)) = bench_name.split_once('/') {
24        cmd.arg("--bench").arg(bench).arg("--").arg(filter);
25    } else {
26        cmd.arg("--bench").arg(bench_name);
27    }
28
29    cmd.arg("--no-fail-fast");
30
31    // If perf stat overlay requested and perf is available, wrap with perf stat
32    let use_perf = counters.is_some() && which::which("perf").is_ok();
33    if use_perf {
34        println!("  Hardware counter overlay: enabled");
35    }
36
37    println!("  Running: cargo bench --bench {bench_name}");
38    if let Some(c) = counters {
39        println!("  Hardware counters: {c}");
40    }
41    if check_regression {
42        println!("  Regression check: threshold={threshold}%");
43    }
44
45    let output = cmd
46        .output()
47        .with_context(|| format!("Failed to run cargo bench --bench {bench_name}"))?;
48
49    let stdout = String::from_utf8_lossy(&output.stdout);
50    let stderr = String::from_utf8_lossy(&output.stderr);
51
52    if !output.status.success() {
53        if stderr.contains("no bench target") || stderr.contains("can't find") {
54            println!("  Benchmark '{bench_name}' not found.");
55            println!("  Available benchmarks:");
56
57            let list_output = Command::new("cargo")
58                .args(["bench", "--bench", "nonexistent_xyz_123", "--", "--list"])
59                .output();
60            if let Ok(lo) = list_output {
61                let lo_stderr = String::from_utf8_lossy(&lo.stderr);
62                for line in lo_stderr.lines() {
63                    if line.contains("bench target") || line.contains("available") {
64                        println!("    {line}");
65                    }
66                }
67            }
68            return Ok(());
69        }
70        eprintln!("  cargo bench failed:\n{stderr}");
71        return Ok(());
72    }
73
74    // Parse criterion output for timing results
75    let mut results: Vec<BenchResult> = Vec::new();
76    for line in stdout.lines() {
77        if line.contains("time:") {
78            let parts: Vec<&str> = line.splitn(2, "time:").collect();
79            if parts.len() == 2 {
80                let name = parts[0].trim().to_string();
81                let timing = parts[1].trim().to_string();
82                let change = extract_change(line);
83                results.push(BenchResult {
84                    name,
85                    timing,
86                    change,
87                });
88            }
89        }
90    }
91
92    if results.is_empty() {
93        println!("  Criterion output:");
94        for line in stdout.lines().take(30) {
95            if !line.trim().is_empty() {
96                println!("  {line}");
97            }
98        }
99    } else {
100        println!("  Results:");
101        for r in &results {
102            let change_str = match &r.change {
103                Some(c) => format!("  ({c})"),
104                None => String::new(),
105            };
106            println!("    {:40} {}{}", r.name, r.timing, change_str);
107        }
108    }
109
110    // Run perf stat overlay if requested
111    if let Some(counter_list) = counters {
112        if which::which("perf").is_ok() {
113            println!("\n  --- perf stat overlay ---");
114            run_perf_overlay(bench_name, counter_list);
115        } else {
116            println!("\n  perf not available — skipping hardware counter overlay.");
117            println!("  Install linux-tools-common for hardware counter support.");
118        }
119    }
120
121    // Check regression
122    if check_regression {
123        println!("\n  Regression check (threshold={threshold}%):");
124        let mut regressions = 0;
125        for line in stdout.lines() {
126            if line.contains("regressed") || line.contains("Performance has regressed") {
127                println!("    \x1b[31mREGRESSION\x1b[0m: {line}");
128                regressions += 1;
129            } else if line.contains("improved") {
130                println!("    \x1b[32mIMPROVED\x1b[0m: {line}");
131            }
132        }
133        if regressions > 0 {
134            println!("\n  \x1b[31m{regressions} regression(s) detected!\x1b[0m");
135        } else {
136            println!("  No regressions detected.");
137        }
138    }
139
140    println!();
141    Ok(())
142}
143
144/// Benchmark result parsed from criterion output.
145struct BenchResult {
146    name: String,
147    timing: String,
148    change: Option<String>,
149}
150
151/// Extract performance change annotation from criterion line.
152fn extract_change(line: &str) -> Option<String> {
153    if line.contains("change:") {
154        line.split("change:").nth(1).map(|s| s.trim().to_string())
155    } else {
156        None
157    }
158}
159
160/// Run perf stat with specified counters alongside the benchmark.
161fn run_perf_overlay(bench_name: &str, counters: &str) {
162    let events = counters.replace(' ', "");
163
164    // Build the perf stat command wrapping cargo bench
165    let mut cmd = Command::new("perf");
166    cmd.arg("stat")
167        .arg("-e")
168        .arg(&events)
169        .arg("-x")
170        .arg(",")
171        .arg("cargo")
172        .arg("bench")
173        .arg("--bench")
174        .arg(bench_name)
175        .arg("--")
176        .arg("--quick"); // Use quick mode for perf overlay
177
178    match cmd.output() {
179        Ok(output) => {
180            let stderr = String::from_utf8_lossy(&output.stderr);
181
182            // Parse perf stat CSV output from stderr
183            for line in stderr.lines() {
184                let line = line.trim();
185                if line.is_empty() || line.starts_with('#') || line.starts_with("Performance") {
186                    continue;
187                }
188                if line.contains("seconds time elapsed") {
189                    println!("    Wall time: {}", line.trim());
190                    continue;
191                }
192
193                let fields: Vec<&str> = line.split(',').collect();
194                if fields.len() >= 3 {
195                    let value = fields[0].trim();
196                    let event = fields[2].trim();
197                    if !value.is_empty() && !event.is_empty() {
198                        println!("    {event:40} {value:>14}");
199                    }
200                }
201            }
202        }
203        Err(e) => {
204            println!("    perf stat failed: {e}");
205            println!("    Try: sudo sysctl kernel.perf_event_paranoid=2");
206        }
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn test_extract_change_none() {
216        assert!(extract_change("some time: [1.0 ns 2.0 ns]").is_none());
217    }
218
219    #[test]
220    fn test_extract_change_present() {
221        let change = extract_change("some time: [1.0 ns] change: +5.2%");
222        assert!(change.is_some());
223        assert!(change.unwrap().contains("+5.2%"));
224    }
225
226    #[test]
227    fn test_bench_result_struct() {
228        let r = BenchResult {
229            name: "test".to_string(),
230            timing: "1.0 ns".to_string(),
231            change: None,
232        };
233        assert_eq!(r.name, "test");
234    }
235}