Skip to main content

trueno/backends/gpu/device/
mod.rs

1//! GPU device initialization and management
2//!
3//! This module provides cross-platform GPU compute via wgpu (WebGPU).
4//!
5//! # Platform differences
6//!
7//! - **Native**: Sync wrappers available using `pollster::block_on`
8//! - **WASM**: Sync wrappers unavailable (can't block main thread); use `*_async` methods
9//!
10//! Use `runtime::sync_available()` to check at runtime.
11
12mod activations;
13mod backward;
14mod eigen;
15pub(crate) mod linalg;
16mod reductions;
17
18#[cfg(any(feature = "gpu", feature = "gpu-wasm"))]
19use super::runtime;
20
21/// Process-global lock serializing native wgpu instance/adapter/device creation.
22///
23/// PMAT-778: On hosts whose Vulkan ICD is unsafe to initialize concurrently
24/// (notably the NVIDIA GB10 / aarch64, where the Mesa freedreno "Turnip" ICD
25/// probes `/dev/dri/renderD128` and **segfaults** when multiple threads create
26/// `wgpu::Instance`s and request adapters/devices simultaneously), every sync
27/// device-creation entry point takes this lock. Device creation is a rare,
28/// one-time-per-scheduler operation off the compute hot path, so serializing it
29/// is free on healthy GPUs and turns a concurrent-init crash into ordered,
30/// correct initialization. This is the companion to the adapter-probe `OnceLock`
31/// cache (PMAT-773): the probe is memoized once, and actual device acquisition
32/// is serialized here.
33#[cfg(all(feature = "gpu", not(target_arch = "wasm32")))]
34static DEVICE_INIT_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
35
36/// Process-global, lazily-created shared `wgpu::Instance`.
37///
38/// PMAT-778: Creating a fresh `wgpu::Instance` enumerates every installed Vulkan
39/// ICD. On the NVIDIA GB10 / aarch64 the host ships ~11 Mesa ICDs (freedreno
40/// "Turnip", panfrost, asahi, …) that are irrelevant to this hardware; the
41/// freedreno ICD spawns its own background Vulkan threads and **segfaults** when
42/// several instances enumerate it concurrently (each open of
43/// `/dev/dri/renderD128` returns `VK_ERROR_INCOMPATIBLE_DRIVER`). Sharing one
44/// instance for the whole process means the broken ICD is enumerated exactly
45/// once, eliminating the concurrent-init race entirely. `wgpu::Instance` is
46/// `Clone`/`Send`/`Sync`, so every adapter/device request can cheaply reuse it.
47#[cfg(all(feature = "gpu", not(target_arch = "wasm32")))]
48pub(crate) fn shared_instance() -> wgpu::Instance {
49    use std::sync::OnceLock;
50    static INSTANCE: OnceLock<wgpu::Instance> = OnceLock::new();
51    INSTANCE
52        .get_or_init(|| {
53            // Serialize the one-time enumeration against any other GPU init.
54            let _guard = DEVICE_INIT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
55            wgpu::Instance::default()
56        })
57        .clone()
58}
59
60/// GPU device manager
61#[derive(Clone)]
62pub struct GpuDevice {
63    pub device: wgpu::Device,
64    pub queue: wgpu::Queue,
65}
66
67impl GpuDevice {
68    /// Initialize GPU device (sync, native only)
69    #[cfg(all(feature = "gpu", not(target_arch = "wasm32")))]
70    pub fn new() -> Result<Self, String> {
71        // PMAT-778: serialize concurrent native device creation (freedreno ICD
72        // segfaults on the GB10 when instances/devices are created in parallel).
73        let _guard = DEVICE_INIT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
74        runtime::block_on(async { Self::new_async().await })
75    }
76
77    /// Initialize GPU device (async, works on all platforms)
78    pub async fn new_async() -> Result<Self, String> {
79        // Create instance
80        let instance = shared_instance();
81
82        // Request adapter (GPU)
83        let adapter = instance
84            .request_adapter(&wgpu::RequestAdapterOptions {
85                power_preference: wgpu::PowerPreference::HighPerformance,
86                compatible_surface: None,
87                force_fallback_adapter: false,
88            })
89            .await
90            .map_err(|e| format!("Failed to find GPU adapter: {}", e))?;
91
92        // Request device and queue with adapter's actual max buffer size
93        // Default wgpu limits cap buffers at 256MB, which is too small for
94        // 7B+ model weight matrices (e.g., FFN [18944, 3584] x f32 = 271MB)
95        let mut limits = wgpu::Limits::default();
96        limits.max_buffer_size = adapter.limits().max_buffer_size;
97        limits.max_storage_buffer_binding_size = adapter.limits().max_storage_buffer_binding_size;
98
99        let (device, queue) = adapter
100            .request_device(&wgpu::DeviceDescriptor {
101                label: Some("Trueno GPU Device"),
102                required_features: wgpu::Features::empty(),
103                required_limits: limits,
104                memory_hints: wgpu::MemoryHints::Performance,
105                experimental_features: Default::default(),
106                trace: Default::default(),
107            })
108            .await
109            .map_err(|e| format!("Failed to create device: {}", e))?;
110
111        Ok(Self { device, queue })
112    }
113
114    /// Initialize GPU device with a specific adapter index (sync, native only)
115    ///
116    /// Use this to select a specific GPU when multiple are available.
117    /// Adapter indices correspond to `Instance::enumerate_adapters()` ordering.
118    #[cfg(all(feature = "gpu", not(target_arch = "wasm32")))]
119    pub fn new_with_adapter_index(index: u32) -> Result<Self, String> {
120        // PMAT-778: serialize concurrent native device creation (see DEVICE_INIT_LOCK).
121        let _guard = DEVICE_INIT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
122        runtime::block_on(async { Self::new_with_adapter_index_async(index).await })
123    }
124
125    /// Initialize GPU device with a specific adapter index (async, all platforms)
126    ///
127    /// Use this to select a specific GPU when multiple are available.
128    /// Adapter indices correspond to `Instance::enumerate_adapters()` ordering.
129    pub async fn new_with_adapter_index_async(index: u32) -> Result<Self, String> {
130        let instance = shared_instance();
131        let adapters = instance.enumerate_adapters(wgpu::Backends::all());
132
133        if adapters.is_empty() {
134            return Err("No GPU adapters found".to_string());
135        }
136
137        let adapter = adapters
138            .into_iter()
139            .nth(index as usize)
140            .ok_or_else(|| format!("GPU adapter index {} out of range", index))?;
141
142        let mut limits = wgpu::Limits::default();
143        limits.max_buffer_size = adapter.limits().max_buffer_size;
144        limits.max_storage_buffer_binding_size = adapter.limits().max_storage_buffer_binding_size;
145
146        let (device, queue) = adapter
147            .request_device(&wgpu::DeviceDescriptor {
148                label: Some(&format!("Trueno GPU Device [{}]", index)),
149                required_features: wgpu::Features::empty(),
150                required_limits: limits,
151                memory_hints: wgpu::MemoryHints::Performance,
152                experimental_features: Default::default(),
153                trace: Default::default(),
154            })
155            .await
156            .map_err(|e| format!("Failed to create device at index {}: {}", index, e))?;
157
158        Ok(Self { device, queue })
159    }
160
161    /// List all available GPU adapters (sync, native only)
162    ///
163    /// Returns a list of (index, name, backend) tuples for each adapter.
164    #[cfg(all(feature = "gpu", not(target_arch = "wasm32")))]
165    pub fn list_adapters() -> Vec<(u32, String, String)> {
166        // PMAT-778: serialize concurrent native instance/adapter enumeration
167        // (freedreno ICD segfaults on the GB10 under concurrent init).
168        let _guard = DEVICE_INIT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
169        runtime::block_on(Self::list_adapters_async())
170    }
171
172    /// List all available GPU adapters (async, all platforms)
173    pub async fn list_adapters_async() -> Vec<(u32, String, String)> {
174        let instance = shared_instance();
175        let adapters = instance.enumerate_adapters(wgpu::Backends::all());
176
177        adapters
178            .iter()
179            .enumerate()
180            .map(|(idx, adapter)| {
181                let info = adapter.get_info();
182                (idx as u32, info.name, format!("{:?}", info.backend))
183            })
184            .collect()
185    }
186
187    /// Check if GPU is available (sync, native only)
188    ///
189    /// PMAT-773: The adapter probe is cached for the lifetime of the process via a
190    /// [`std::sync::OnceLock`]. On hosts WITHOUT a wgpu-compatible adapter (e.g.
191    /// headless NVIDIA / Jetson / Blackwell GB10, where opening
192    /// `/dev/dri/renderD128` fails with `VK_ERROR_INCOMPATIBLE_DRIVER`), the
193    /// underlying `request_adapter` call is expensive and was being re-attempted by
194    /// every test/caller, adding diffuse latency across a test suite. Caching the
195    /// result means the failing Vulkan device open is attempted at most once per
196    /// process; subsequent callers short-circuit.
197    ///
198    /// Behavior on hosts WITH a usable GPU is unchanged: the first probe succeeds,
199    /// `true` is cached, and callers proceed to acquire a real device via
200    /// [`Self::new`] exactly as before. The cache only memoizes whether an adapter
201    /// is obtainable — it never holds a device, so real-GPU acquisition is never
202    /// short-circuited.
203    #[cfg(all(feature = "gpu", not(target_arch = "wasm32")))]
204    pub fn is_available() -> bool {
205        use std::sync::OnceLock;
206        static AVAILABLE: OnceLock<bool> = OnceLock::new();
207        *AVAILABLE.get_or_init(|| runtime::block_on(Self::is_available_async()))
208    }
209
210    /// Check if GPU is available (async, works on all platforms)
211    pub async fn is_available_async() -> bool {
212        let instance = shared_instance();
213        instance
214            .request_adapter(&wgpu::RequestAdapterOptions {
215                power_preference: wgpu::PowerPreference::HighPerformance,
216                compatible_surface: None,
217                force_fallback_adapter: false,
218            })
219            .await
220            .is_ok()
221    }
222
223    /// Generic helper for element-wise GPU operations
224    ///
225    /// This helper eliminates code duplication between element-wise operations
226    /// (relu, clip, sigmoid, tanh, etc.) by abstracting the common GPU compute pattern.
227    ///
228    /// # Arguments
229    ///
230    /// * `op_name` - Operation name for labels (e.g., "ReLU", "Clip")
231    /// * `shader_source` - WGSL shader source code
232    /// * `input` - Input data
233    /// * `result` - Output buffer
234    /// * `uniform_data` - Optional uniform buffer data (e.g., clip parameters)
235    pub(super) async fn execute_element_wise_op(
236        &self,
237        op_name: &str,
238        shader_source: &str,
239        input: &[f32],
240        result: &mut [f32],
241        uniform_data: Option<&[u8]>,
242    ) -> Result<(), String> {
243        let len = input.len();
244
245        // Create shader module
246        let shader = self.device.create_shader_module(wgpu::ShaderModuleDescriptor {
247            label: Some(&format!("{} Shader", op_name)),
248            source: wgpu::ShaderSource::Wgsl(shader_source.into()),
249        });
250
251        // Create input buffer
252        let input_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
253            label: Some(&format!("{} Input", op_name)),
254            size: std::mem::size_of_val(input) as u64,
255            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
256            mapped_at_creation: false,
257        });
258
259        // Create output buffer
260        let output_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
261            label: Some(&format!("{} Output", op_name)),
262            size: std::mem::size_of_val(result) as u64,
263            usage: wgpu::BufferUsages::STORAGE
264                | wgpu::BufferUsages::COPY_SRC
265                | wgpu::BufferUsages::COPY_DST,
266            mapped_at_creation: false,
267        });
268
269        // Write input data
270        self.queue.write_buffer(&input_buffer, 0, bytemuck::cast_slice(input));
271
272        // Create optional uniform buffer
273        let uniform_buffer = uniform_data.map(|data| {
274            let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
275                label: Some(&format!("{} Uniform", op_name)),
276                size: data.len() as u64,
277                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
278                mapped_at_creation: false,
279            });
280            self.queue.write_buffer(&buffer, 0, data);
281            buffer
282        });
283
284        // Create bind group layout entries (input + output + optional uniform)
285        let mut bind_group_entries = vec![
286            wgpu::BindGroupLayoutEntry {
287                binding: 0,
288                visibility: wgpu::ShaderStages::COMPUTE,
289                ty: wgpu::BindingType::Buffer {
290                    ty: wgpu::BufferBindingType::Storage { read_only: true },
291                    has_dynamic_offset: false,
292                    min_binding_size: None,
293                },
294                count: None,
295            },
296            wgpu::BindGroupLayoutEntry {
297                binding: 1,
298                visibility: wgpu::ShaderStages::COMPUTE,
299                ty: wgpu::BindingType::Buffer {
300                    ty: wgpu::BufferBindingType::Storage { read_only: false },
301                    has_dynamic_offset: false,
302                    min_binding_size: None,
303                },
304                count: None,
305            },
306        ];
307
308        // Add uniform buffer binding if present
309        if uniform_buffer.is_some() {
310            bind_group_entries.push(wgpu::BindGroupLayoutEntry {
311                binding: 2,
312                visibility: wgpu::ShaderStages::COMPUTE,
313                ty: wgpu::BindingType::Buffer {
314                    ty: wgpu::BufferBindingType::Uniform,
315                    has_dynamic_offset: false,
316                    min_binding_size: None,
317                },
318                count: None,
319            });
320        }
321
322        // Create bind group layout
323        let bind_group_layout =
324            self.device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
325                label: Some(&format!("{} Bind Group Layout", op_name)),
326                entries: &bind_group_entries,
327            });
328
329        // Create bind group entries
330        let mut bind_entries = vec![
331            wgpu::BindGroupEntry { binding: 0, resource: input_buffer.as_entire_binding() },
332            wgpu::BindGroupEntry { binding: 1, resource: output_buffer.as_entire_binding() },
333        ];
334
335        // Add uniform buffer binding if present
336        if let Some(ref uniform_buf) = uniform_buffer {
337            bind_entries.push(wgpu::BindGroupEntry {
338                binding: 2,
339                resource: uniform_buf.as_entire_binding(),
340            });
341        }
342
343        // Create bind group
344        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
345            label: Some(&format!("{} Bind Group", op_name)),
346            layout: &bind_group_layout,
347            entries: &bind_entries,
348        });
349
350        // Create pipeline
351        let pipeline_layout = self.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
352            label: Some(&format!("{} Pipeline Layout", op_name)),
353            bind_group_layouts: &[&bind_group_layout],
354            push_constant_ranges: &[],
355        });
356
357        let pipeline = self.device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
358            label: Some(&format!("{} Pipeline", op_name)),
359            layout: Some(&pipeline_layout),
360            module: &shader,
361            entry_point: Some("main"),
362            compilation_options: Default::default(),
363            cache: None,
364        });
365
366        // Create staging buffer for reading results
367        let staging_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
368            label: Some(&format!("{} Staging Buffer", op_name)),
369            size: std::mem::size_of_val(result) as u64,
370            usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
371            mapped_at_creation: false,
372        });
373
374        // Create command encoder
375        let mut encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
376            label: Some(&format!("{} Encoder", op_name)),
377        });
378
379        {
380            let mut compute_pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
381                label: Some(&format!("{} Pass", op_name)),
382                timestamp_writes: None,
383            });
384            compute_pass.set_pipeline(&pipeline);
385            compute_pass.set_bind_group(0, &bind_group, &[]);
386
387            // Dispatch workgroups (256 threads per workgroup)
388            let workgroup_size = 256;
389            let num_workgroups = (len as u32).div_ceil(workgroup_size);
390
391            compute_pass.dispatch_workgroups(num_workgroups, 1, 1);
392        }
393
394        // Copy result to staging buffer
395        encoder.copy_buffer_to_buffer(
396            &output_buffer,
397            0,
398            &staging_buffer,
399            0,
400            std::mem::size_of_val(result) as u64,
401        );
402
403        // Submit commands
404        self.queue.submit(Some(encoder.finish()));
405
406        // Read back results
407        let buffer_slice = staging_buffer.slice(..);
408        let (sender, receiver) = futures_intrusive::channel::shared::oneshot_channel();
409        buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
410            sender.send(result).ok();
411        });
412
413        // Poll device to ensure GPU work completes and callbacks are invoked
414        self.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None }).ok();
415
416        receiver
417            .receive()
418            .await
419            .ok_or("Failed to receive mapping result")?
420            .map_err(|e| format!("Buffer mapping failed: {:?}", e))?;
421
422        {
423            let data = buffer_slice.get_mapped_range();
424            result.copy_from_slice(bytemuck::cast_slice(&data));
425        }
426
427        staging_buffer.unmap();
428
429        Ok(())
430    }
431}
432
433#[cfg(all(test, feature = "gpu", not(target_arch = "wasm32")))]
434mod tests {
435    use super::*;
436
437    #[test]
438    fn test_is_available_consistency() {
439        // EXTREME TDD: Kill mutant that replaces is_available() with hardcoded false
440        // Test that is_available() is consistent with GpuDevice::new()
441        let available = GpuDevice::is_available();
442        let device_result = GpuDevice::new();
443
444        if available {
445            // If is_available() returns true, device creation should succeed
446            assert!(
447                device_result.is_ok(),
448                "is_available() returned true, but GpuDevice::new() failed"
449            );
450        } else {
451            // If is_available() returns false, we can't make assertions about new()
452            // (it might still succeed in some edge cases, but typically should fail)
453            // The key test is: mutant always returns false, so on GPU systems this fails
454            eprintln!(
455                "GPU not available (is_available=false), device creation result: {:?}",
456                device_result.is_err()
457            );
458        }
459    }
460
461    #[test]
462    fn test_reduce_sum_not_hardcoded() {
463        // EXTREME TDD: Kill mutant that replaces reduce_sum with Ok(-1.0)
464        if !GpuDevice::is_available() {
465            eprintln!("GPU not available, skipping test");
466            return;
467        }
468
469        let device = GpuDevice::new().expect("Failed to create GPU device");
470        let input = vec![1.0, 2.0, 3.0, 4.0, 5.0]; // sum = 15.0
471
472        // reduce_sum is async, so we use runtime::block_on
473        let result = runtime::block_on(device.reduce_sum(&input)).expect("reduce_sum failed");
474
475        // Kill mutant: verify result is NOT -1.0
476        assert_ne!(result, -1.0, "reduce_sum returned hardcoded -1.0 (mutant not killed)");
477
478        // Verify correct computation
479        let expected: f32 = input.iter().sum();
480        assert!(
481            (result - expected).abs() < 1e-4,
482            "reduce_sum({:?}) = {} (expected {})",
483            input,
484            result,
485            expected
486        );
487    }
488}