Skip to main content

cgp/profilers/
wgpu_profiler.rs

1//! Cross-platform GPU profiling via wgpu timestamp queries. Spec section 4.3.
2//! Supports Vulkan, Metal, DX12, and WebGPU.
3
4use anyhow::Result;
5use std::path::Path;
6
7/// Detect available wgpu backend on the current system.
8pub fn detect_wgpu_backend() -> &'static str {
9    #[cfg(target_os = "linux")]
10    {
11        "Vulkan"
12    }
13    #[cfg(target_os = "macos")]
14    {
15        "Metal"
16    }
17    #[cfg(target_os = "windows")]
18    {
19        "DX12"
20    }
21    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
22    {
23        "Unknown"
24    }
25}
26
27/// Validate that a WGSL shader file exists and has valid syntax.
28pub fn validate_shader(shader_path: &str) -> Result<ShaderInfo> {
29    let path = Path::new(shader_path);
30    if !path.exists() {
31        anyhow::bail!("Shader file not found: {shader_path}");
32    }
33
34    let content = std::fs::read_to_string(path)?;
35    let lines = content.lines().count();
36
37    // Extract @compute workgroup_size if present
38    let workgroup_size = content.lines().find_map(|line| {
39        if line.contains("@workgroup_size") {
40            let start = line.find('(')? + 1;
41            let end = line.find(')')?;
42            Some(line[start..end].to_string())
43        } else {
44            None
45        }
46    });
47
48    // Check for @compute entry point
49    let has_compute = content.contains("@compute");
50
51    Ok(ShaderInfo {
52        path: shader_path.to_string(),
53        lines,
54        workgroup_size,
55        has_compute,
56    })
57}
58
59/// Information about a WGSL shader.
60#[derive(Debug)]
61pub struct ShaderInfo {
62    pub path: String,
63    pub lines: usize,
64    pub workgroup_size: Option<String>,
65    pub has_compute: bool,
66}
67
68/// Parse dispatch dimensions from "X,Y,Z" format.
69pub fn parse_dispatch(dispatch: &str) -> Result<[u32; 3]> {
70    let parts: Vec<&str> = dispatch.split(',').collect();
71    if parts.len() != 3 {
72        anyhow::bail!("Dispatch must be X,Y,Z format (got: {dispatch})");
73    }
74    Ok([
75        parts[0].trim().parse()?,
76        parts[1].trim().parse()?,
77        parts[2].trim().parse()?,
78    ])
79}
80
81/// Profile a wgpu compute shader.
82pub fn profile_wgpu(shader: &str, dispatch: Option<&str>, target: Option<&str>) -> Result<()> {
83    let target_str = target.unwrap_or("native");
84    let backend = detect_wgpu_backend();
85    println!("\n=== CGP wgpu Profile: {shader} (target={target_str}) ===\n");
86    println!("  Shader: {shader}");
87    println!("  Backend: wgpu ({backend})");
88
89    if let Some(d) = dispatch {
90        let dims = parse_dispatch(d)?;
91        println!("  Dispatch: {}x{}x{}", dims[0], dims[1], dims[2]);
92        let total_invocations = dims[0] as u64 * dims[1] as u64 * dims[2] as u64;
93        println!("  Total workgroups: {total_invocations}");
94    }
95
96    println!("  Method: TIMESTAMP_QUERY for GPU-side timing (~1ns resolution)");
97
98    // Validate shader if it's a file path
99    if Path::new(shader).exists() {
100        match validate_shader(shader) {
101            Ok(info) => {
102                println!("  Shader lines: {}", info.lines);
103                if let Some(ws) = &info.workgroup_size {
104                    println!("  Workgroup size: {ws}");
105                }
106                if !info.has_compute {
107                    println!("  \x1b[33m[WARN]\x1b[0m No @compute entry point found in shader");
108                }
109            }
110            Err(e) => println!("  \x1b[33m[WARN]\x1b[0m Shader validation: {e}"),
111        }
112    }
113
114    if target_str == "web" {
115        let has_chrome = which::which("google-chrome").is_ok()
116            || which::which("chromium").is_ok()
117            || which::which("chromium-browser").is_ok();
118        if !has_chrome {
119            println!("  No browser found -- falling back to wgpu native (Vulkan/Metal)");
120        } else {
121            println!("  Browser: headless Chrome (Chrome DevTools Protocol)");
122        }
123    }
124
125    println!();
126    Ok(())
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    /// FALSIFY-CGP-079: Must fall back if no browser available for WebGPU.
134    #[test]
135    fn test_wgpu_profile_runs() {
136        let result = profile_wgpu("test.wgsl", Some("256,256,1"), Some("native"));
137        assert!(result.is_ok());
138    }
139
140    #[test]
141    fn test_detect_backend() {
142        let backend = detect_wgpu_backend();
143        assert!(!backend.is_empty());
144        #[cfg(target_os = "linux")]
145        assert_eq!(backend, "Vulkan");
146    }
147
148    #[test]
149    fn test_parse_dispatch() {
150        let dims = parse_dispatch("256,256,1").unwrap();
151        assert_eq!(dims, [256, 256, 1]);
152    }
153
154    #[test]
155    fn test_parse_dispatch_bad() {
156        assert!(parse_dispatch("256,256").is_err());
157    }
158
159    #[test]
160    fn test_validate_shader_missing() {
161        assert!(validate_shader("/tmp/nonexistent_shader.wgsl").is_err());
162    }
163
164    /// FALSIFY-CGP-079: WebGPU fallback when no browser.
165    #[test]
166    fn test_web_target_graceful() {
167        let result = profile_wgpu("test.wgsl", None, Some("web"));
168        assert!(result.is_ok());
169    }
170}