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    print_bench_header(bench_name, counters, check_regression, threshold);
18
19    let output = run_cargo_bench(bench_name)?;
20    let stdout = String::from_utf8_lossy(&output.stdout);
21    let stderr = String::from_utf8_lossy(&output.stderr);
22
23    if !output.status.success() {
24        handle_bench_failure(bench_name, &stderr);
25        return Ok(());
26    }
27
28    print_bench_results(&stdout);
29    run_perf_overlay_if_requested(bench_name, counters);
30    if check_regression {
31        print_regression_check(&stdout, threshold);
32    }
33
34    println!();
35    Ok(())
36}
37
38/// Print the pre-run configuration banner (counters/regression/perf availability).
39fn print_bench_header(
40    bench_name: &str,
41    counters: Option<&str>,
42    check_regression: bool,
43    threshold: f64,
44) {
45    if counters.is_some() && which::which("perf").is_ok() {
46        println!("  Hardware counter overlay: enabled");
47    }
48    println!("  Running: cargo bench --bench {bench_name}");
49    if let Some(c) = counters {
50        println!("  Hardware counters: {c}");
51    }
52    if check_regression {
53        println!("  Regression check: threshold={threshold}%");
54    }
55}
56
57/// Invoke `cargo bench`, splitting `bench_name/filter` into a criterion filter if present.
58fn run_cargo_bench(bench_name: &str) -> Result<std::process::Output> {
59    let mut cmd = Command::new("cargo");
60    cmd.arg("bench");
61    if let Some((bench, filter)) = bench_name.split_once('/') {
62        cmd.arg("--bench").arg(bench).arg("--").arg(filter);
63    } else {
64        cmd.arg("--bench").arg(bench_name);
65    }
66    cmd.arg("--no-fail-fast");
67    cmd.output()
68        .with_context(|| format!("Failed to run cargo bench --bench {bench_name}"))
69}
70
71/// Handle a non-zero exit from cargo bench: list benches if missing, otherwise print stderr.
72fn handle_bench_failure(bench_name: &str, stderr: &str) {
73    if stderr.contains("no bench target") || stderr.contains("can't find") {
74        println!("  Benchmark '{bench_name}' not found.");
75        list_available_benches();
76        return;
77    }
78    eprintln!("  cargo bench failed:\n{stderr}");
79}
80
81/// Print cargo's list of available bench targets by triggering a deliberate miss.
82fn list_available_benches() {
83    println!("  Available benchmarks:");
84    let Ok(lo) = Command::new("cargo")
85        .args(["bench", "--bench", "nonexistent_xyz_123", "--", "--list"])
86        .output()
87    else {
88        return;
89    };
90    let lo_stderr = String::from_utf8_lossy(&lo.stderr);
91    for line in lo_stderr.lines() {
92        if line.contains("bench target") || line.contains("available") {
93            println!("    {line}");
94        }
95    }
96}
97
98/// Parse criterion output and print either structured results or a raw preview.
99fn print_bench_results(stdout: &str) {
100    let results = parse_bench_results(stdout);
101    if results.is_empty() {
102        println!("  Criterion output:");
103        for line in stdout.lines().take(30) {
104            if !line.trim().is_empty() {
105                println!("  {line}");
106            }
107        }
108        return;
109    }
110    println!("  Results:");
111    for r in &results {
112        let change_str = match &r.change {
113            Some(c) => format!("  ({c})"),
114            None => String::new(),
115        };
116        println!("    {:40} {}{}", r.name, r.timing, change_str);
117    }
118}
119
120/// Extract `name — time: X ns (change: ±Y%)` triples from criterion stdout.
121fn parse_bench_results(stdout: &str) -> Vec<BenchResult> {
122    let mut results: Vec<BenchResult> = Vec::new();
123    for line in stdout.lines() {
124        if !line.contains("time:") {
125            continue;
126        }
127        let parts: Vec<&str> = line.splitn(2, "time:").collect();
128        if parts.len() != 2 {
129            continue;
130        }
131        let name = parts[0].trim().to_string();
132        let timing = parts[1].trim().to_string();
133        let change = extract_change(line);
134        results.push(BenchResult {
135            name,
136            timing,
137            change,
138        });
139    }
140    results
141}
142
143/// Run perf stat overlay if counters were requested; fall back to an informative message.
144fn run_perf_overlay_if_requested(bench_name: &str, counters: Option<&str>) {
145    let Some(counter_list) = counters else {
146        return;
147    };
148    if which::which("perf").is_ok() {
149        println!("\n  --- perf stat overlay ---");
150        run_perf_overlay(bench_name, counter_list);
151    } else {
152        println!("\n  perf not available — skipping hardware counter overlay.");
153        println!("  Install linux-tools-common for hardware counter support.");
154    }
155}
156
157/// Scan stdout for criterion regression/improvement lines and print a summary.
158fn print_regression_check(stdout: &str, threshold: f64) {
159    println!("\n  Regression check (threshold={threshold}%):");
160    let mut regressions = 0;
161    for line in stdout.lines() {
162        if line.contains("regressed") || line.contains("Performance has regressed") {
163            println!("    \x1b[31mREGRESSION\x1b[0m: {line}");
164            regressions += 1;
165        } else if line.contains("improved") {
166            println!("    \x1b[32mIMPROVED\x1b[0m: {line}");
167        }
168    }
169    if regressions > 0 {
170        println!("\n  \x1b[31m{regressions} regression(s) detected!\x1b[0m");
171    } else {
172        println!("  No regressions detected.");
173    }
174}
175
176/// Benchmark result parsed from criterion output.
177struct BenchResult {
178    name: String,
179    timing: String,
180    change: Option<String>,
181}
182
183/// Extract performance change annotation from criterion line.
184fn extract_change(line: &str) -> Option<String> {
185    if line.contains("change:") {
186        line.split("change:").nth(1).map(|s| s.trim().to_string())
187    } else {
188        None
189    }
190}
191
192/// Run perf stat with specified counters alongside the benchmark.
193fn run_perf_overlay(bench_name: &str, counters: &str) {
194    let events = counters.replace(' ', "");
195
196    // Build the perf stat command wrapping cargo bench
197    let mut cmd = Command::new("perf");
198    cmd.arg("stat")
199        .arg("-e")
200        .arg(&events)
201        .arg("-x")
202        .arg(",")
203        .arg("cargo")
204        .arg("bench")
205        .arg("--bench")
206        .arg(bench_name)
207        .arg("--")
208        .arg("--quick"); // Use quick mode for perf overlay
209
210    match cmd.output() {
211        Ok(output) => {
212            let stderr = String::from_utf8_lossy(&output.stderr);
213
214            // Parse perf stat CSV output from stderr
215            for line in stderr.lines() {
216                let line = line.trim();
217                if line.is_empty() || line.starts_with('#') || line.starts_with("Performance") {
218                    continue;
219                }
220                if line.contains("seconds time elapsed") {
221                    println!("    Wall time: {}", line.trim());
222                    continue;
223                }
224
225                let fields: Vec<&str> = line.split(',').collect();
226                if fields.len() >= 3 {
227                    let value = fields[0].trim();
228                    let event = fields[2].trim();
229                    if !value.is_empty() && !event.is_empty() {
230                        println!("    {event:40} {value:>14}");
231                    }
232                }
233            }
234        }
235        Err(e) => {
236            println!("    perf stat failed: {e}");
237            println!("    Try: sudo sysctl kernel.perf_event_paranoid=2");
238        }
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn test_extract_change_none() {
248        assert!(extract_change("some time: [1.0 ns 2.0 ns]").is_none());
249    }
250
251    #[test]
252    fn test_extract_change_present() {
253        let change = extract_change("some time: [1.0 ns] change: +5.2%");
254        assert!(change.is_some());
255        assert!(change.unwrap().contains("+5.2%"));
256    }
257
258    #[test]
259    fn test_bench_result_struct() {
260        let r = BenchResult {
261            name: "test".to_string(),
262            timing: "1.0 ns".to_string(),
263            change: None,
264        };
265        assert_eq!(r.name, "test");
266    }
267}