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