Skip to main content

cgp/
cli.rs

1//! Command-line surface for CGP.
2//!
3//! Lives in the library (not the binary target) so that any host binary can
4//! embed the full `cgp` command tree: parse with [`Cli`], then hand the parsed
5//! [`Commands`] to [`dispatch`]. The standalone `aprender-cgp` binary is a thin
6//! shim over [`run`].
7
8use anyhow::Result;
9use clap::{Parser, Subcommand};
10
11use crate::{analysis, doctor, profilers};
12
13/// Backends `cgp profile compare --backends` accepts.
14///
15/// Kept in lockstep with the `match` in `analysis::compare::measure_backend`:
16/// every arm there except the catch-all appears here, so a value that parses is
17/// a value that is actually measured.
18pub const CGP_BACKEND_VALUES: [&str; 7] =
19    ["scalar", "avx2", "avx512", "neon", "cuda", "cublas", "wgpu"];
20
21/// CGP: Compute-GPU-Profile — Unified Performance Analysis CLI
22///
23/// Own the Stack: One Binary, All Backends, Zero Blind Spots.
24/// Profiles scalar, SIMD (SSE2/AVX2/AVX-512/NEON/WASM SIMD128),
25/// wgpu (Vulkan/Metal/DX12/WebGPU), and CUDA workloads.
26#[derive(Parser, Clone, Debug)]
27#[command(name = "cgp", version, about, long_about = None)]
28pub struct Cli {
29    /// Output JSON instead of human-readable text
30    #[arg(long, global = true)]
31    pub json: bool,
32
33    #[command(subcommand)]
34    pub command: Commands,
35}
36
37/// Top-level `cgp` subcommands.
38#[derive(Subcommand, Clone, Debug)]
39pub enum Commands {
40    /// Profile a kernel or function (runtime execution)
41    Profile {
42        #[command(subcommand)]
43        target: ProfileTarget,
44    },
45    /// Enhanced criterion benchmarking with hardware counters
46    Bench {
47        /// Benchmark name
48        #[arg(long)]
49        bench: String,
50        /// Hardware counters to collect (comma-separated)
51        #[arg(long)]
52        counters: Option<String>,
53        /// Check regression against saved baseline
54        #[arg(long)]
55        check_regression: bool,
56        /// Regression threshold percentage
57        #[arg(long, default_value = "5")]
58        threshold: f64,
59        /// Overlay roofline model
60        #[arg(long)]
61        roofline: bool,
62    },
63    /// Generate roofline model for target hardware
64    Roofline {
65        /// Target backend (cuda, avx2, avx512, neon, wgpu)
66        #[arg(long)]
67        target: String,
68        /// Kernels to plot on roofline
69        #[arg(long)]
70        kernels: Option<String>,
71        /// Export to file
72        #[arg(long)]
73        export: Option<String>,
74        /// Use empirical measurement instead of spec values
75        #[arg(long)]
76        empirical: bool,
77    },
78    /// Compare two profiles (git integration)
79    Diff {
80        /// Baseline commit or profile path
81        #[arg(long)]
82        baseline: Option<String>,
83        /// Current commit or profile path
84        #[arg(long)]
85        current: Option<String>,
86        /// Before commit
87        #[arg(long)]
88        before: Option<String>,
89        /// After commit
90        #[arg(long)]
91        after: Option<String>,
92    },
93    /// Verify performance contracts (CI/CD gate)
94    Contract {
95        #[command(subcommand)]
96        action: ContractAction,
97    },
98    /// System-wide timeline (wraps nsys)
99    Trace {
100        /// Binary to trace
101        binary: String,
102        /// Trace duration
103        #[arg(long)]
104        duration: Option<String>,
105    },
106    /// Static code analysis (wraps trueno-explain)
107    Explain {
108        /// Analysis target (ptx, simd, wgsl)
109        target: String,
110        /// Kernel name
111        #[arg(long)]
112        kernel: Option<String>,
113    },
114    /// Interactive TUI exploration mode
115    Tui,
116    /// Save/load performance baselines
117    Baseline {
118        /// Save current profile as baseline
119        #[arg(long)]
120        save: Option<String>,
121        /// Load baseline from file
122        #[arg(long)]
123        load: Option<String>,
124    },
125    /// Check tool availability and hardware capabilities
126    Doctor,
127    /// Head-to-head competitor comparison
128    Compete {
129        /// Workload name (e.g., gemm)
130        workload: String,
131        /// Our command
132        #[arg(long)]
133        ours: String,
134        /// Competitor commands (can be repeated)
135        #[arg(long)]
136        theirs: Vec<String>,
137        /// Labels for each entry (comma-separated)
138        #[arg(long)]
139        label: Option<String>,
140    },
141}
142
143/// Targets accepted by `cgp profile`.
144#[derive(Subcommand, Clone, Debug)]
145pub enum ProfileTarget {
146    /// Profile a CUDA PTX kernel via ncu + CUPTI
147    Kernel {
148        /// Kernel name
149        #[arg(long)]
150        name: String,
151        /// Problem size (e.g., 512 for square matrix)
152        #[arg(long)]
153        size: u32,
154        /// Generate roofline overlay
155        #[arg(long)]
156        roofline: bool,
157        /// Specific ncu metrics to collect
158        #[arg(long)]
159        metrics: Option<String>,
160    },
161    /// Profile cuBLAS/cuBLASLt operations
162    Cublas {
163        /// Operation (gemm_f16, gemm_f32, etc.)
164        #[arg(long)]
165        op: String,
166        /// Problem size
167        #[arg(long)]
168        size: u32,
169    },
170    /// Profile wgpu compute shaders
171    Wgpu {
172        /// WGSL shader path
173        #[arg(long)]
174        shader: String,
175        /// Dispatch dimensions (e.g., 256,256,1)
176        #[arg(long)]
177        dispatch: Option<String>,
178        /// Target (native or web)
179        #[arg(long)]
180        target: Option<String>,
181    },
182    /// Profile Apple Metal compute kernels
183    Metal {
184        /// Metal shader name
185        #[arg(long)]
186        shader: String,
187        /// Dispatch size
188        #[arg(long)]
189        dispatch: Option<u32>,
190    },
191    /// Profile CPU SIMD functions
192    Simd {
193        /// Function name
194        #[arg(long)]
195        function: String,
196        /// Problem size
197        #[arg(long)]
198        size: u32,
199        /// Target architecture (avx2, avx512, neon, sse2)
200        #[arg(long)]
201        arch: String,
202    },
203    /// Profile WASM SIMD128 via wasmtime
204    Wasm {
205        /// Function name
206        #[arg(long)]
207        function: String,
208        /// Problem size
209        #[arg(long)]
210        size: u32,
211    },
212    /// Profile quantized CPU kernels (Q4K/Q6K)
213    Quant {
214        /// Kernel name (q4k_gemv, q6k_gemv, q5k_gemv, q8_gemv, nf4_gemv)
215        #[arg(long, required_unless_present = "all")]
216        kernel: Option<String>,
217        /// Dimensions (MxNxK format)
218        #[arg(long, required_unless_present = "all")]
219        size: Option<String>,
220        /// Profile all standard LLM layer sizes (ffn_up, ffn_down, attn_qkv, generic_4K)
221        #[arg(long)]
222        all: bool,
223    },
224    /// Profile scalar baseline
225    Scalar {
226        /// Function name
227        #[arg(long)]
228        function: String,
229        /// Problem size
230        #[arg(long)]
231        size: u32,
232    },
233    /// Profile Rayon parallel workloads
234    Parallel {
235        /// Function name
236        #[arg(long)]
237        function: String,
238        /// Problem size
239        #[arg(long)]
240        size: u32,
241        /// Thread count (or "auto")
242        #[arg(long)]
243        threads: Option<String>,
244    },
245    /// Cross-backend comparison
246    Compare {
247        /// Kernel name
248        #[arg(long)]
249        kernel: String,
250        /// Problem size
251        #[arg(long)]
252        size: u32,
253        /// Backends to compare (comma-separated)
254        ///
255        /// #2583: this was a free-form `String`, split on `,` and matched in
256        /// `analysis::compare::measure_backend`, whose `other =>` arm warns to
257        /// stderr and `continue`s. So `--backends avx2,cudaa` exited 0 and
258        /// printed a comparison table silently missing the CUDA row — and under
259        /// `--json` the omitted row is the only signal. Rejecting the typo at
260        /// parse time is the same fix `apr serve run --backend` got.
261        #[arg(long, value_delimiter = ',', value_parser = CGP_BACKEND_VALUES)]
262        backends: Vec<String>,
263    },
264    /// Parallel scaling sweep (thread count vs throughput)
265    Scaling {
266        /// Problem size
267        #[arg(long)]
268        size: u32,
269        /// Max threads to test (default: num_cpus)
270        #[arg(long)]
271        max_threads: Option<usize>,
272        /// Runs per thread count for min-of-N timing
273        #[arg(long, default_value = "3")]
274        runs: usize,
275    },
276    /// Profile an arbitrary binary
277    Binary {
278        /// Binary path
279        path: String,
280        /// Kernel name filter
281        #[arg(long)]
282        kernel_filter: Option<String>,
283        /// Enable system trace
284        #[arg(long)]
285        trace: bool,
286        /// Trace duration
287        #[arg(long)]
288        duration: Option<String>,
289    },
290    /// Profile a Python script
291    Python {
292        /// Arguments after --
293        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
294        args: Vec<String>,
295    },
296    /// Profile a shared library function
297    Library {
298        /// Path to .so file
299        #[arg(long)]
300        so: String,
301        /// Symbol name
302        #[arg(long)]
303        symbol: String,
304        /// Arguments (key=value pairs)
305        #[arg(long)]
306        args: Option<String>,
307    },
308}
309
310/// Actions accepted by `cgp contract`.
311#[derive(Subcommand, Clone, Debug)]
312pub enum ContractAction {
313    /// Verify performance contracts
314    Verify {
315        /// Directory containing contract YAML files
316        #[arg(long)]
317        contracts_dir: Option<String>,
318        /// Specific contract file
319        #[arg(long)]
320        contract: Option<String>,
321        /// Fail on any regression
322        #[arg(long)]
323        fail_on_regression: bool,
324        /// Verify cgp's own contracts
325        #[arg(long, name = "self")]
326        self_verify: bool,
327    },
328    /// Generate contract from current measurement
329    Generate {
330        /// Kernel name
331        #[arg(long)]
332        kernel: String,
333        /// Problem size
334        #[arg(long)]
335        size: u32,
336        /// Regression tolerance percentage
337        #[arg(long, default_value = "10")]
338        tolerance: f64,
339    },
340}
341
342/// Parse `std::env::args` as a [`Cli`] and run the selected command.
343///
344/// This is the whole body of the standalone `aprender-cgp` binary.
345///
346/// # Errors
347///
348/// Returns any error produced by the dispatched subcommand.
349///
350/// # Panics
351///
352/// Does not panic; clap exits the process directly on argument-parse failure,
353/// `--help`, and `--version`.
354pub fn run() -> Result<()> {
355    let cli = Cli::parse();
356    dispatch(cli.command, cli.json)
357}
358
359/// Run a parsed [`Commands`] value.
360///
361/// `json` carries the global `--json` flag so an embedding host can pass its
362/// own output-format decision through.
363///
364/// # Errors
365///
366/// Returns any error produced by the selected profiler or analysis routine.
367pub fn dispatch(command: Commands, json: bool) -> Result<()> {
368    match command {
369        Commands::Doctor => doctor::run_doctor(json),
370        Commands::Profile { target } => dispatch_profile(target, json),
371        Commands::Roofline {
372            target,
373            kernels,
374            export,
375            empirical,
376        } => analysis::roofline::run_roofline(
377            &target,
378            kernels.as_deref(),
379            export.as_deref(),
380            empirical,
381            json,
382        ),
383        Commands::Bench {
384            bench,
385            counters,
386            check_regression,
387            threshold,
388            roofline,
389        } => analysis::bench::run_bench(
390            &bench,
391            counters.as_deref(),
392            check_regression,
393            threshold,
394            roofline,
395        ),
396        Commands::Diff {
397            baseline,
398            current,
399            before,
400            after,
401        } => analysis::diff::run_diff(
402            baseline.as_deref(),
403            current.as_deref(),
404            before.as_deref(),
405            after.as_deref(),
406            json,
407        ),
408        Commands::Contract { action } => dispatch_contract(action),
409        Commands::Trace { binary, duration } => {
410            profilers::cuda::run_trace(&binary, duration.as_deref())
411        }
412        Commands::Explain { target, kernel } => {
413            analysis::explain::run_explain(&target, kernel.as_deref())
414        }
415        Commands::Tui => {
416            println!("cgp tui: interactive mode (requires presentar)");
417            println!("  (Not yet implemented — use stdout commands for now)");
418            Ok(())
419        }
420        Commands::Baseline { save, load } => {
421            analysis::baseline::run_baseline(save.as_deref(), load.as_deref())
422        }
423        Commands::Compete {
424            workload,
425            ours,
426            theirs,
427            label,
428        } => analysis::compete::run_compete(&workload, &ours, &theirs, label.as_deref(), json),
429    }
430}
431
432/// Run a parsed [`ProfileTarget`] (the `cgp profile` subtree).
433///
434/// # Errors
435///
436/// Returns any error produced by the selected profiler.
437pub fn dispatch_profile(target: ProfileTarget, json: bool) -> Result<()> {
438    match target {
439        ProfileTarget::Kernel {
440            name,
441            size,
442            roofline,
443            metrics,
444        } => profilers::cuda::profile_kernel(&name, size, roofline, metrics.as_deref()),
445        ProfileTarget::Cublas { op, size } => profilers::cuda::profile_cublas(&op, size),
446        ProfileTarget::Wgpu {
447            shader,
448            dispatch,
449            target,
450        } => {
451            profilers::wgpu_profiler::profile_wgpu(&shader, dispatch.as_deref(), target.as_deref())
452        }
453        ProfileTarget::Metal { shader, dispatch } => {
454            #[cfg(target_os = "macos")]
455            {
456                println!("cgp profile metal: shader={shader} dispatch={dispatch:?}");
457                Ok(())
458            }
459            #[cfg(not(target_os = "macos"))]
460            {
461                let _ = (&shader, dispatch);
462                anyhow::bail!("Metal backend requires macOS -- use --backend wgpu for Vulkan")
463            }
464        }
465        ProfileTarget::Simd {
466            function,
467            size,
468            arch,
469        } => profilers::simd::profile_simd(&function, size, &arch),
470        ProfileTarget::Wasm { function, size } => profilers::wasm::profile_wasm(&function, size),
471        ProfileTarget::Quant { kernel, size, all } => {
472            if all {
473                profilers::quant::profile_quant_all()
474            } else {
475                profilers::quant::profile_quant(
476                    kernel.as_deref().unwrap_or("q4k_gemv"),
477                    size.as_deref().unwrap_or("4096x1x4096"),
478                )
479            }
480        }
481        ProfileTarget::Scalar { function, size } => {
482            profilers::scalar::profile_scalar(&function, size)
483        }
484        ProfileTarget::Parallel {
485            function,
486            size,
487            threads,
488        } => profilers::rayon_parallel::profile_parallel(&function, size, threads.as_deref()),
489        ProfileTarget::Compare {
490            kernel,
491            size,
492            backends,
493        } => analysis::compare::run_compare(&kernel, size, &backends.join(","), json),
494        ProfileTarget::Scaling {
495            size,
496            max_threads,
497            runs,
498        } => profilers::rayon_parallel::profile_scaling(size, max_threads, runs, json),
499        ProfileTarget::Binary {
500            path,
501            kernel_filter,
502            trace,
503            duration,
504        } => profilers::cuda::profile_binary(
505            &path,
506            kernel_filter.as_deref(),
507            trace,
508            duration.as_deref(),
509        ),
510        ProfileTarget::Python { args } => profilers::cuda::profile_python(&args),
511        ProfileTarget::Library { so, symbol, args } => {
512            println!("cgp profile library: {so}::{symbol} args={args:?}");
513            Ok(())
514        }
515    }
516}
517
518/// Run a parsed [`ContractAction`] (the `cgp contract` subtree).
519///
520/// # Errors
521///
522/// Returns any error produced by contract verification or generation.
523pub fn dispatch_contract(action: ContractAction) -> Result<()> {
524    match action {
525        ContractAction::Verify {
526            contracts_dir,
527            contract,
528            fail_on_regression,
529            self_verify,
530        } => analysis::contracts::run_verify(
531            contracts_dir.as_deref(),
532            contract.as_deref(),
533            self_verify,
534            fail_on_regression,
535        ),
536        ContractAction::Generate {
537            kernel,
538            size,
539            tolerance,
540        } => analysis::contracts::run_generate(&kernel, size, tolerance),
541    }
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547    use clap::CommandFactory;
548
549    #[test]
550    fn cli_definition_is_valid() {
551        Cli::command().debug_assert();
552    }
553
554    #[test]
555    fn parses_doctor_subcommand() {
556        let cli = Cli::parse_from(["cgp", "doctor"]);
557        assert!(!cli.json);
558        assert!(
559            matches!(cli.command, Commands::Doctor),
560            "expected Commands::Doctor, got {:?}",
561            cli.command
562        );
563    }
564
565    #[test]
566    fn global_json_flag_is_captured() {
567        let cli = Cli::parse_from(["cgp", "doctor", "--json"]);
568        assert!(cli.json);
569    }
570
571    #[test]
572    fn commands_is_clone_and_debug() {
573        let cli = Cli::parse_from(["cgp", "tui"]);
574        let cloned = cli.command.clone();
575        assert!(
576            matches!(cloned, Commands::Tui),
577            "clone changed the variant: {cloned:?}"
578        );
579    }
580}