Skip to main content

cgp/analysis/
compare.rs

1//! `cgp profile compare` — Cross-backend comparison.
2//! Spec section 2.2: run the same workload across multiple backends
3//! and produce a comparison table with TFLOP/s, bandwidth, and speedup ratios.
4
5use crate::analysis::roofline::{Precision, RooflineModel};
6use anyhow::Result;
7use serde::Serialize;
8
9/// Supported backends for comparison.
10#[derive(Debug, Clone, Serialize)]
11pub struct BackendResult {
12    pub name: String,
13    pub wall_time_us: f64,
14    pub tflops: f64,
15    pub bandwidth_gbps: f64,
16    pub available: bool,
17    /// Whether data comes from actual measurement vs estimation
18    #[serde(skip_serializing_if = "std::ops::Not::not")]
19    pub measured: bool,
20}
21
22/// Compute TFLOP/s for GEMM: 2*M*N*K / time.
23fn gemm_tflops(size: u32, time_us: f64) -> f64 {
24    if time_us <= 0.0 {
25        return 0.0;
26    }
27    let flops = 2.0 * (size as f64).powi(3);
28    flops / (time_us * 1e-6) / 1e12
29}
30
31/// Try to get actual GEMM timing from benchmark_matrix_suite binary.
32/// Returns (time_us, gflops) if the binary exists and the size is benchmarked.
33fn get_actual_gemm_timing(size: u32) -> Option<(f64, f64)> {
34    let stdout = run_benchmark_suite()?;
35    let pattern = format!("Matrix Multiplication ({size}x{size}x{size})");
36    stdout
37        .lines()
38        .find(|line| line.contains(&pattern))
39        .and_then(parse_benchmark_line)
40}
41
42/// Locate the cargo target directory the same way `apr_bin.sh` does: ask
43/// cargo, never construct. `/mnt/nvme-raid0/targets/trueno` was the
44/// pre-APR-MONO name and is empty on every checkout since the consolidation;
45/// `./target` assumes CWD-relative resolution, which the dev box's
46/// `.cargo/config.toml` target-dir redirect breaks (it is gitignored, so it
47/// exists in the main checkout and not in a worktree).
48pub(crate) fn cargo_target_dir() -> Option<String> {
49    let output =
50        std::process::Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()))
51            .args(["metadata", "--no-deps", "--format-version", "1"])
52            .output()
53            .ok()?;
54    if !output.status.success() {
55        return None;
56    }
57    let stdout = String::from_utf8_lossy(&output.stdout);
58    let json: serde_json::Value = serde_json::from_str(&stdout).ok()?;
59    json.get("target_directory")?.as_str().map(str::to_string)
60}
61
62/// Locate and execute the benchmark_matrix_suite binary; returns stdout on success.
63fn run_benchmark_suite() -> Option<String> {
64    let target_dir = cargo_target_dir();
65    let owned_candidate =
66        target_dir.map(|d| format!("{d}/release/examples/benchmark_matrix_suite"));
67    let candidates: Vec<&str> = owned_candidate
68        .as_deref()
69        .into_iter()
70        .chain(["./target/release/examples/benchmark_matrix_suite"])
71        .collect();
72    let binary_path = candidates
73        .iter()
74        .find(|p| std::path::Path::new(p).exists())?;
75    let output = std::process::Command::new(*binary_path)
76        .stdout(std::process::Stdio::piped())
77        .stderr(std::process::Stdio::piped())
78        .output()
79        .ok()?;
80    if !output.status.success() {
81        return None;
82    }
83    Some(String::from_utf8_lossy(&output.stdout).into_owned())
84}
85
86/// Parse a single line of the form
87/// `  Matrix Multiplication (NxNxN)...     X.XX ms  (Y.YY GFLOPS)`.
88fn parse_benchmark_line(line: &str) -> Option<(f64, f64)> {
89    let after_dots = line.split("...").nth(1)?;
90    let time_ms = after_dots.split("ms").next()?.trim().parse::<f64>().ok()?;
91    let gflops = after_dots
92        .split('(')
93        .nth(1)?
94        .split(" GFLOPS")
95        .next()?
96        .trim()
97        .parse::<f64>()
98        .ok()?;
99    Some((time_ms * 1000.0, gflops))
100}
101
102/// Estimate scalar GEMM time from measured data on Threadripper 7960X.
103/// Reference GEMM: 256→11.7ms, cubic scaling.
104fn estimate_scalar_time_us(size: u32) -> f64 {
105    // Calibrated: 11.7ms at 256x256 on Threadripper 7960X
106    let ratio = (size as f64 / 256.0).powi(3);
107    11_700.0 * ratio
108}
109
110/// Estimate AVX2 BLIS single-thread GEMM from measured data.
111/// Calibrated: 256→0.57ms, 512→3.75ms, 1024→30.1ms (71 GFLOPS).
112fn estimate_avx2_time_us(size: u32) -> f64 {
113    // BLIS GEMM single-thread: ~72 GFLOPS sustained
114    let flops = 2.0 * (size as f64).powi(3);
115    let gflops = 72.0; // measured on Threadripper 7960X
116    flops / (gflops * 1e9) * 1e6
117}
118
119/// Estimate AVX-512 BLIS GEMM (slightly faster than AVX2, but clock throttle).
120/// ~80 GFLOPS measured single-thread (AVX-512 downclocking limits gains).
121fn estimate_avx512_time_us(size: u32) -> f64 {
122    let flops = 2.0 * (size as f64).powi(3);
123    let gflops = 80.0; // AVX-512 with downclocking ~10% faster than AVX2
124    flops / (gflops * 1e9) * 1e6
125}
126
127/// Estimate CUDA CTA WMMA GEMM from measured data on RTX 4090.
128/// Calibrated: 23.2us at 512x512 = 11.6 TFLOP/s.
129fn estimate_cuda_time_us(size: u32) -> f64 {
130    let ratio = (size as f64 / 512.0).powi(3);
131    23.2 * ratio
132}
133
134/// Estimate cuBLAS GEMM from measured RTX 4090 data.
135/// cuBLAS achieves ~35 TFLOP/s FP16 on RTX 4090 (~3x pure PTX).
136fn estimate_cublas_time_us(size: u32) -> f64 {
137    estimate_cuda_time_us(size) / 3.0
138}
139
140/// Measure actual cuBLAS FP16 GEMM throughput via trueno-gpu driver.
141/// Returns (time_us, tflops) or None if CUDA unavailable.
142#[cfg(feature = "cuda")]
143fn measure_cublas_gemm(size: u32) -> Option<(f64, f64)> {
144    use trueno_gpu::driver::{CublasHandle, CudaContext, CudaStream, GemmOp, GpuBuffer};
145
146    let ctx = CudaContext::new(0).ok()?;
147    let stream = CudaStream::new(&ctx).ok()?;
148    let handle = CublasHandle::new(&ctx).ok()?;
149    handle.set_stream(&stream).ok()?;
150
151    let n = size as usize;
152    let a_data = vec![0x3C00u16; n * n]; // 1.0 in FP16
153    let b_data = vec![0x3C00u16; n * n];
154    let c_data = vec![0u16; n * n];
155
156    let a_buf = GpuBuffer::from_host(&ctx, &a_data).ok()?;
157    let b_buf = GpuBuffer::from_host(&ctx, &b_data).ok()?;
158    let c_buf = GpuBuffer::from_host(&ctx, &c_data).ok()?;
159
160    // Warmup
161    for _ in 0..5 {
162        let _ = handle.gemm_f16(
163            GemmOp::NoTrans,
164            GemmOp::NoTrans,
165            n as i32,
166            n as i32,
167            n as i32,
168            1.0,
169            a_buf.as_ptr(),
170            n as i32,
171            b_buf.as_ptr(),
172            n as i32,
173            0.0,
174            c_buf.as_ptr(),
175            n as i32,
176        );
177    }
178    stream.synchronize().ok()?;
179
180    let iters: u32 = if n <= 512 {
181        200
182    } else if n <= 1024 {
183        100
184    } else {
185        30
186    };
187    let start = std::time::Instant::now();
188    for _ in 0..iters {
189        let _ = handle.gemm_f16(
190            GemmOp::NoTrans,
191            GemmOp::NoTrans,
192            n as i32,
193            n as i32,
194            n as i32,
195            1.0,
196            a_buf.as_ptr(),
197            n as i32,
198            b_buf.as_ptr(),
199            n as i32,
200            0.0,
201            c_buf.as_ptr(),
202            n as i32,
203        );
204    }
205    stream.synchronize().ok()?;
206    let elapsed = start.elapsed();
207
208    let per_call_us = elapsed.as_micros() as f64 / iters as f64;
209    let flops = 2.0 * (n as f64).powi(3);
210    let tflops = flops / (per_call_us * 1e6);
211
212    Some((per_call_us, tflops))
213}
214
215/// Measure our best PTX GEMM kernel (64×128 pipeline) on GPU.
216/// Returns (time_us, tflops) or None if CUDA unavailable.
217#[cfg(feature = "cuda")]
218fn measure_ptx_gemm(size: u32) -> Option<(f64, f64)> {
219    use std::ffi::c_void;
220    use trueno_gpu::driver::{CudaContext, CudaModule, CudaStream, GpuBuffer, LaunchConfig};
221    use trueno_gpu::kernels::build_cta64x128_mma_pipeline_fp16;
222    use trueno_gpu::ptx::PtxModule;
223
224    let ctx = CudaContext::new(0).ok()?;
225    let stream = CudaStream::new(&ctx).ok()?;
226
227    let n = size as usize;
228    let a16 = vec![0x3C00u16; n * n];
229    let b16 = vec![0x3C00u16; n * n];
230    let c32 = vec![0.0f32; n * n];
231
232    let a_buf = GpuBuffer::from_host(&ctx, &a16).ok()?;
233    let b_buf = GpuBuffer::from_host(&ctx, &b16).ok()?;
234    let c_buf = GpuBuffer::from_host(&ctx, &c32).ok()?;
235
236    let kernel = build_cta64x128_mma_pipeline_fp16(n as u32, n as u32, n as u32);
237    let ptx = PtxModule::new().target("sm_80").add_kernel(kernel).emit();
238    let mut module = CudaModule::from_ptx(&ctx, &ptx).ok()?;
239
240    let cfg = LaunchConfig {
241        grid: (((n + 127) / 128) as u32, ((n + 63) / 64) as u32, 1),
242        block: (512, 1, 1),
243        shared_mem: 18432,
244    };
245
246    let mut a_ptr = a_buf.as_ptr();
247    let mut b_ptr = b_buf.as_ptr();
248    let mut c_ptr = c_buf.as_ptr();
249    let mut m_v = n as u32;
250    let mut n_v = n as u32;
251    let mut k_v = n as u32;
252    let mut args: Vec<*mut c_void> = vec![
253        &mut a_ptr as *mut _ as *mut c_void,
254        &mut b_ptr as *mut _ as *mut c_void,
255        &mut c_ptr as *mut _ as *mut c_void,
256        &mut m_v as *mut _ as *mut c_void,
257        &mut n_v as *mut _ as *mut c_void,
258        &mut k_v as *mut _ as *mut c_void,
259    ];
260
261    // Warmup
262    for _ in 0..5 {
263        unsafe {
264            stream
265                .launch_kernel(
266                    &mut module,
267                    "gemm_cta64x128_mma_pipeline_fp16",
268                    &cfg,
269                    &mut args,
270                )
271                .ok()?;
272        }
273    }
274    stream.synchronize().ok()?;
275
276    let iters: u32 = if n <= 512 {
277        100
278    } else if n <= 1024 {
279        50
280    } else {
281        20
282    };
283    let start = std::time::Instant::now();
284    for _ in 0..iters {
285        unsafe {
286            stream
287                .launch_kernel(
288                    &mut module,
289                    "gemm_cta64x128_mma_pipeline_fp16",
290                    &cfg,
291                    &mut args,
292                )
293                .ok()?;
294        }
295    }
296    stream.synchronize().ok()?;
297    let per_call_us = start.elapsed().as_micros() as f64 / iters as f64;
298    let flops = 2.0 * (n as f64).powi(3);
299    let tflops = flops / (per_call_us * 1e6);
300
301    Some((per_call_us, tflops))
302}
303
304/// Run cross-backend comparison.
305pub fn run_compare(kernel: &str, size: u32, backends_str: &str, json: bool) -> Result<()> {
306    let backends: Vec<&str> = backends_str.split(',').map(|s| s.trim()).collect();
307
308    if !json {
309        println!("\n=== CGP Cross-Backend Comparison: {kernel} ({size}x{size}x{size}) ===\n");
310    }
311
312    let actual = get_actual_gemm_timing(size);
313    let mut results = collect_backend_results(&backends, size, actual);
314    results.sort_by(|a, b| {
315        a.wall_time_us
316            .partial_cmp(&b.wall_time_us)
317            .unwrap_or(std::cmp::Ordering::Equal)
318    });
319
320    if json {
321        println!("{}", serde_json::to_string_pretty(&results)?);
322        return Ok(());
323    }
324
325    render_comparison_table(&results);
326    render_source_legend(&results);
327    render_best_summary(&results);
328    render_cpu_gpu_gap(&results);
329
330    println!();
331    Ok(())
332}
333
334/// Measure each requested backend and build a `BackendResult` list.
335fn collect_backend_results(
336    backends: &[&str],
337    size: u32,
338    actual: Option<(f64, f64)>,
339) -> Vec<BackendResult> {
340    let mut results: Vec<BackendResult> = Vec::new();
341    for backend in backends {
342        let Some((time_us, available, measured)) = measure_backend(backend, size, actual) else {
343            continue;
344        };
345        results.push(BackendResult {
346            name: (*backend).to_string(),
347            wall_time_us: time_us,
348            tflops: gemm_tflops(size, time_us),
349            bandwidth_gbps: 0.0,
350            available,
351            measured,
352        });
353    }
354    results
355}
356
357/// Dispatch to the backend-specific measurement routine; None for unknown backends.
358fn measure_backend(
359    backend: &str,
360    size: u32,
361    actual: Option<(f64, f64)>,
362) -> Option<(f64, bool, bool)> {
363    match backend {
364        "scalar" => Some((estimate_scalar_time_us(size), true, false)),
365        "avx2" => Some(measure_avx_backend(size, actual, false)),
366        "avx512" => Some(measure_avx_backend(size, actual, true)),
367        "neon" => Some((
368            estimate_scalar_time_us(size) / 4.0,
369            cfg!(target_arch = "aarch64"),
370            false,
371        )),
372        "cuda" => Some(measure_cuda_backend(size)),
373        "cublas" => Some(measure_cublas_backend(size)),
374        "wgpu" => Some((
375            estimate_cuda_time_us(size) * 2.0,
376            which::which("nvidia-smi").is_ok(),
377            false,
378        )),
379        other => {
380            eprintln!("  Warning: unknown backend '{other}', skipping");
381            None
382        }
383    }
384}
385
386/// Measure AVX2/AVX512 backends: prefer actual CPU bench, else fall back to estimate.
387fn measure_avx_backend(size: u32, actual: Option<(f64, f64)>, avx512: bool) -> (f64, bool, bool) {
388    #[cfg(target_arch = "x86_64")]
389    let avail = if avx512 {
390        std::arch::is_x86_feature_detected!("avx512f")
391    } else {
392        std::arch::is_x86_feature_detected!("avx2")
393    };
394    #[cfg(not(target_arch = "x86_64"))]
395    let avail = false;
396
397    if let Some((actual_us, _)) = actual {
398        return (actual_us, avail, true);
399    }
400    if avx512 {
401        (estimate_avx512_time_us(size), avail, false)
402    } else {
403        (estimate_avx2_time_us(size), avail, false)
404    }
405}
406
407/// Measure CUDA PTX backend when available + `cuda` feature is enabled.
408fn measure_cuda_backend(size: u32) -> (f64, bool, bool) {
409    let avail = which::which("nvidia-smi").is_ok();
410    if avail {
411        if let Some((time_us, _)) = try_measure_ptx(size) {
412            return (time_us, true, true);
413        }
414    }
415    (estimate_cuda_time_us(size), avail, false)
416}
417
418/// Measure cuBLAS backend when available + `cuda` feature is enabled.
419fn measure_cublas_backend(size: u32) -> (f64, bool, bool) {
420    let avail = which::which("nvidia-smi").is_ok();
421    if avail {
422        if let Some((time_us, _)) = try_measure_cublas(size) {
423            return (time_us, true, true);
424        }
425    }
426    (estimate_cublas_time_us(size), avail, false)
427}
428
429/// Shim that returns `measure_ptx_gemm` under `cuda` feature, `None` otherwise.
430#[cfg(feature = "cuda")]
431fn try_measure_ptx(size: u32) -> Option<(f64, f64)> {
432    measure_ptx_gemm(size)
433}
434
435#[cfg(not(feature = "cuda"))]
436fn try_measure_ptx(_size: u32) -> Option<(f64, f64)> {
437    None
438}
439
440/// Shim that returns `measure_cublas_gemm` under `cuda` feature, `None` otherwise.
441#[cfg(feature = "cuda")]
442fn try_measure_cublas(size: u32) -> Option<(f64, f64)> {
443    measure_cublas_gemm(size)
444}
445
446#[cfg(not(feature = "cuda"))]
447fn try_measure_cublas(_size: u32) -> Option<(f64, f64)> {
448    None
449}
450
451/// Render the main comparison table (header + one row per backend).
452fn render_comparison_table(results: &[BackendResult]) {
453    let best_time = results.first().map_or(1.0, |r| r.wall_time_us);
454    println!(
455        "  {:12} {:>12} {:>12} {:>10} {:>10} {:>8} {:>5}",
456        "Backend", "Time (us)", "TFLOP/s", "Efficiency", "vs Best", "Avail", "Src"
457    );
458    println!("  {}", "-".repeat(75));
459    let (cpu_peak, gpu_peak) = peak_performance_limits();
460    for r in results {
461        render_comparison_row(r, best_time, cpu_peak, gpu_peak);
462    }
463}
464
465/// Print a single comparison row with efficiency, ratio, availability, and source tag.
466fn render_comparison_row(r: &BackendResult, best_time: f64, cpu_peak: f64, gpu_peak: f64) {
467    let peak_tflops = if r.name.contains("cuda") || r.name.contains("cublas") || r.name == "wgpu" {
468        gpu_peak / 1e12
469    } else {
470        cpu_peak / 1e12
471    };
472    let efficiency = if peak_tflops > 0.0 {
473        r.tflops / peak_tflops * 100.0
474    } else {
475        0.0
476    };
477    let ratio = format!("{:.2}x", r.wall_time_us / best_time);
478    let avail = if r.available { "yes" } else { "no" };
479    let time_str = if r.wall_time_us >= 1000.0 {
480        format!("{:.1} ms", r.wall_time_us / 1000.0)
481    } else {
482        format!("{:.1}", r.wall_time_us)
483    };
484    let src = if r.measured { "M" } else { "E" };
485    println!(
486        "  {:12} {:>12} {:>12.1} {:>9.1}% {:>10} {:>8} {:>5}",
487        r.name, time_str, r.tflops, efficiency, ratio, avail, src
488    );
489}
490
491/// Roofline-derived peak CPU/GPU FLOP/s (used to compute per-row efficiency).
492fn peak_performance_limits() -> (f64, f64) {
493    let model = RooflineModel::rtx_4090();
494    let gpu_peak = model
495        .peak_compute
496        .get(&Precision::Fp16)
497        .copied()
498        .unwrap_or(330.0e12);
499    let cores = num_cpus::get_physical();
500    #[allow(clippy::cast_precision_loss)]
501    let cpu_peak = 2.0 * 8.0 * 2.0 * 3.5e9 * cores as f64; // AVX2 peak
502    (cpu_peak, gpu_peak)
503}
504
505/// Show the "Src: M=measured E=estimated" legend when any results were produced.
506fn render_source_legend(results: &[BackendResult]) {
507    let has_measured = results.iter().any(|r| r.measured);
508    let has_estimated = results.iter().any(|r| !r.measured);
509    if !(has_measured || has_estimated) {
510        return;
511    }
512    print!("  Src: ");
513    if has_measured {
514        print!("M=measured ");
515    }
516    if has_estimated {
517        print!("E=estimated ");
518    }
519    println!();
520}
521
522/// Print the "Best: X (Ny faster than Z)" summary line when results are present.
523fn render_best_summary(results: &[BackendResult]) {
524    let Some(best) = results.first() else {
525        return;
526    };
527    let Some(worst) = results.last() else {
528        return;
529    };
530    let speedup = worst.wall_time_us / best.wall_time_us;
531    println!(
532        "\n  Best: {} ({:.1}x faster than {})",
533        best.name, speedup, worst.name
534    );
535}
536
537/// Print "CPU→GPU gap: Nx" when both CPU and GPU backends were measured.
538fn render_cpu_gpu_gap(results: &[BackendResult]) {
539    let has_cpu = results
540        .iter()
541        .any(|r| matches!(r.name.as_str(), "scalar" | "avx2" | "avx512"));
542    let has_gpu = results
543        .iter()
544        .any(|r| matches!(r.name.as_str(), "cuda" | "cublas" | "wgpu"));
545    if !(has_cpu && has_gpu) {
546        return;
547    }
548    let best_cpu = results
549        .iter()
550        .filter(|r| matches!(r.name.as_str(), "scalar" | "avx2" | "avx512"))
551        .map(|r| r.wall_time_us)
552        .fold(f64::INFINITY, f64::min);
553    let best_gpu = results
554        .iter()
555        .filter(|r| matches!(r.name.as_str(), "cuda" | "cublas" | "wgpu"))
556        .map(|r| r.wall_time_us)
557        .fold(f64::INFINITY, f64::min);
558    if best_gpu > 0.0 {
559        println!(
560            "  CPU→GPU gap: {:.0}x (expected for large GEMM)",
561            best_cpu / best_gpu
562        );
563    }
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569
570    #[test]
571    fn test_gemm_tflops() {
572        // 512^3 GEMM at 23.2us = 2*512^3 / 23.2e-6 / 1e12
573        let tflops = gemm_tflops(512, 23.2);
574        assert!(
575            (tflops - 11.56).abs() < 0.1,
576            "Expected ~11.6 TFLOP/s, got {tflops:.2}"
577        );
578    }
579
580    #[test]
581    fn test_scalar_slower_than_avx2() {
582        let scalar = estimate_scalar_time_us(512);
583        let avx2 = estimate_avx2_time_us(512);
584        assert!(scalar > avx2 * 3.0, "Scalar should be >3x slower than AVX2");
585    }
586
587    #[test]
588    fn test_cuda_faster_than_cpu() {
589        let cpu = estimate_avx2_time_us(4096);
590        let cuda = estimate_cuda_time_us(4096);
591        assert!(
592            cpu > cuda * 10.0,
593            "CPU should be >10x slower than CUDA for 4096"
594        );
595    }
596
597    /// FALSIFY-CGP-040: CUDA must be faster than scalar for GEMM >= 256.
598    #[test]
599    fn test_cuda_faster_than_scalar_at_256() {
600        let scalar = estimate_scalar_time_us(256);
601        let cuda = estimate_cuda_time_us(256);
602        assert!(cuda < scalar, "CUDA should be faster than scalar at 256");
603    }
604
605    /// FALSIFY-CGP-041: SIMD must be faster than scalar (>= 3x at 1024).
606    #[test]
607    fn test_simd_faster_than_scalar() {
608        let scalar = estimate_scalar_time_us(1024);
609        let avx2 = estimate_avx2_time_us(1024);
610        assert!(
611            scalar / avx2 >= 3.0,
612            "AVX2 speedup {:.1}x should be >= 3x",
613            scalar / avx2
614        );
615    }
616
617    /// FALSIFY-CGP-042: cuBLAS must be faster than pure PTX for large GEMM.
618    #[test]
619    fn test_cublas_faster_than_ptx() {
620        let ptx = estimate_cuda_time_us(4096);
621        let cublas = estimate_cublas_time_us(4096);
622        assert!(cublas < ptx, "cuBLAS should be faster than PTX at 4096");
623    }
624
625    #[test]
626    fn test_run_compare_basic() {
627        let result = run_compare("gemm", 256, "scalar,avx2", false);
628        assert!(result.is_ok());
629    }
630
631    #[test]
632    fn test_run_compare_json() {
633        let result = run_compare("gemm", 256, "scalar,avx2", true);
634        assert!(result.is_ok());
635    }
636
637    /// FALSIFY-CGP-ACTUAL-001: Actual benchmark data is available and parseable.
638    #[test]
639    fn test_get_actual_gemm_timing() {
640        if let Some((time_us, gflops)) = get_actual_gemm_timing(1024) {
641            assert!(time_us > 0.0, "time should be positive");
642            assert!(gflops > 10.0, "GFLOPS should be > 10 for 1024 GEMM");
643            assert!(gflops < 2000.0, "GFLOPS should be < 2000");
644            eprintln!(
645                "Actual GEMM 1024: {:.1} us = {:.0} GFLOPS [MEASURED]",
646                time_us, gflops
647            );
648        } else {
649            eprintln!("benchmark_matrix_suite binary not found — actual data unavailable");
650        }
651    }
652}