Skip to main content

qualia_core_db/wgsl_forge/execute/
wgpu.rs

1use std::cell::RefCell;
2use std::collections::HashMap;
3use std::sync::mpsc;
4use std::time::Instant;
5
6use super::compute::QualiaCompute;
7use super::memory::{
8    BindingUsage, BufferView, MemoryTopology, QualiaSlabAllocator, DEFAULT_BINDING_ALIGNMENT,
9};
10use super::oracle_ctx::OracleContext;
11#[cfg(not(target_arch = "wasm32"))]
12use crate::gpu_context::GpuAdapterCaps;
13use crate::wgsl_forge::{
14    emit_shader, AdapterConstraints, AdapterIdentity, ForgeError, HardwareProfile, KernelSpec,
15    Schedule, TargetBackend,
16};
17
18/// Create a wgpu instance respecting `QUALIA_WGPU_BACKEND`.
19///
20/// On Windows the default is DX12, but cooperative matrix (`VK_KHR_cooperative_matrix`)
21/// is only exposed on the Vulkan backend for NVIDIA hardware. Setting
22/// `QUALIA_WGPU_BACKEND=vulkan` routes the forge through Vulkan, un-gating coopmat.
23#[cfg(not(target_arch = "wasm32"))]
24fn create_instance() -> wgpu::Instance {
25    let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
26    if let Some(backends) = crate::gpu_context::qualia_backend_override() {
27        desc.backends = backends;
28    } else if cfg!(target_os = "windows") {
29        desc.backends = wgpu::Backends::DX12;
30    }
31    wgpu::Instance::new(desc)
32}
33
34/// Like [`create_instance`] but forces Vulkan when the default backend lacks coopmat.
35///
36/// This is the un-gating path: on Windows/DX12 where `EXPERIMENTAL_COOPERATIVE_MATRIX`
37/// is not advertised, we try Vulkan explicitly. NVIDIA Vulkan drivers expose
38/// `VK_KHR_cooperative_matrix` which wgpu maps to `EXPERIMENTAL_COOPERATIVE_MATRIX`.
39#[cfg(not(target_arch = "wasm32"))]
40fn create_instance_for_coopmat() -> wgpu::Instance {
41    // First try the user's explicit backend choice.
42    let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
43    if let Some(backends) = crate::gpu_context::qualia_backend_override() {
44        desc.backends = backends;
45    } else {
46        // No explicit override: try Vulkan first (coopmat is available there),
47        // falling back to all backends if Vulkan isn't present.
48        desc.backends = wgpu::Backends::VULKAN;
49    }
50    wgpu::Instance::new(desc)
51}
52
53/// Find the best adapter for cooperative matrix work.
54///
55/// Enumerates adapters on the given instance and returns the first that
56/// advertises `EXPERIMENTAL_COOPERATIVE_MATRIX`, preferring discrete GPUs.
57/// Returns `None` if no adapter has coopmat.
58#[cfg(not(target_arch = "wasm32"))]
59fn find_coopmat_adapter(instance: &wgpu::Instance) -> Option<wgpu::Adapter> {
60    let backends = if let Some(b) = crate::gpu_context::qualia_backend_override() {
61        b
62    } else {
63        wgpu::Backends::all()
64    };
65    let adapters = pollster::block_on(instance.enumerate_adapters(backends));
66    // First pass: look for a discrete GPU with coopmat.
67    for adapter in &adapters {
68        let info = adapter.get_info();
69        if info.device_type != wgpu::DeviceType::DiscreteGpu {
70            continue;
71        }
72        let features = adapter.features();
73        if features.contains(wgpu::Features::EXPERIMENTAL_COOPERATIVE_MATRIX) {
74            return Some(adapter.clone());
75        }
76    }
77    // Second pass: any adapter with coopmat.
78    for adapter in &adapters {
79        let features = adapter.features();
80        if features.contains(wgpu::Features::EXPERIMENTAL_COOPERATIVE_MATRIX) {
81            return Some(adapter.clone());
82        }
83    }
84    None
85}
86
87pub struct WgpuComputeContext {
88    pub device: wgpu::Device,
89    pub queue: wgpu::Queue,
90    pub adapter: AdapterIdentity,
91    pub constraints: AdapterConstraints,
92    /// Rich topology description for `profile-hardware` and cache keying.
93    pub profile: HardwareProfile,
94    pub allocator: QualiaSlabAllocator,
95    /// Backs read-only storage and uniform views (both non-exclusive usages,
96    /// so they may share one buffer).
97    pub slab: wgpu::Buffer,
98    /// Backs read-write storage outputs. wgpu treats read-write storage as an
99    /// exclusive usage, so it cannot share a buffer with the read-only inputs in
100    /// the same dispatch.
101    pub out_slab: wgpu::Buffer,
102    /// Backs the **persistent weight region** ([`BindingUsage::StorageReadResident`]): big,
103    /// upload-once matrices (a decode layer's projection / FFN weights) that are referenced by
104    /// offset across many `run`s instead of being re-uploaded each call. Separate buffer from the
105    /// transient ring so [`Self::clear_transient_allocations`] never recycles it.
106    pub weight_slab: wgpu::Buffer,
107    /// Write-once bump cursor into `weight_slab` (bytes, kept 256-aligned). Weights are never
108    /// freed individually; [`Self::clear_weights`] resets it to reuse the region for a new model.
109    weight_cursor: usize,
110    pub timestamp_supported: bool,
111    pub timestamp_period_ns: f32,
112    timestamp_resources: Option<TimestampResources>,
113    /// Process-lifetime cache of compiled compute pipelines, keyed by `entry\0source`.
114    /// Pipeline creation (shader compile + PSO build) is the dominant per-node cost once
115    /// the device + slab are reused; a graph re-run over the same kernels (e.g. one decode
116    /// block per generated token) then pays zero compile after the first. Survives
117    /// [`clear_transient_allocations`](Self::clear_transient_allocations) — pipelines reference
118    /// the shader + bind-group *layout*, never the slab buffers (bind groups are rebuilt per
119    /// call). `RefCell` because [`compile_pipeline_cached`](Self::compile_pipeline_cached) takes
120    /// `&self`; the context is used single-threaded per dispatch (shared dispatch is serialized
121    /// behind a `Mutex` by the dispatcher).
122    pipeline_cache: RefCell<HashMap<String, wgpu::ComputePipeline>>,
123}
124
125impl WgpuComputeContext {
126    pub fn new(capacity_bytes: usize) -> Result<Self, ForgeError> {
127        // Respect QUALIA_WGPU_BACKEND so the forge can use Vulkan (which exposes
128        // cooperative matrix on NVIDIA) instead of the Windows DX12 default.
129        let instance = create_instance();
130        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
131            power_preference: wgpu::PowerPreference::HighPerformance,
132            ..Default::default()
133        }))
134        .map_err(|error| ForgeError::GpuUnavailable(error.to_string()))?;
135        let info = adapter.get_info();
136        let available_features = adapter.features();
137        // Request timestamp queries plus subgroup + cooperative-matrix support
138        // when the adapter offers them (cooperative-matrix kernels need both).
139        let mut wanted =
140            wgpu::Features::TIMESTAMP_QUERY | wgpu::Features::SUBGROUP | wgpu::Features::SHADER_F16;
141        if crate::gpu_context::experimental_features_allowed() {
142            wanted |= wgpu::Features::EXPERIMENTAL_COOPERATIVE_MATRIX
143                | wgpu::Features::EXPERIMENTAL_RAY_QUERY;
144        }
145        let required_features = available_features & wanted;
146        let timestamp_supported = required_features.contains(wgpu::Features::TIMESTAMP_QUERY);
147        let limits = adapter.limits();
148        // Populate intrinsic-capability flags from the adapter's real feature set
149        // so the tuner can prune schedules that rely on absent hardware (plan §6).
150        let mut constraints = AdapterConstraints::from_wgpu_limits(&limits);
151        constraints.supports_subgroups = available_features.contains(wgpu::Features::SUBGROUP);
152        constraints.supports_coopmat =
153            available_features.contains(wgpu::Features::EXPERIMENTAL_COOPERATIVE_MATRIX);
154        constraints.supports_rt_cores =
155            available_features.contains(wgpu::Features::EXPERIMENTAL_RAY_QUERY);
156        // Warp/wavefront width by vendor: AMD wavefronts are 64, others 32.
157        constraints.warp_size = if info.vendor == 0x1002 { 64 } else { 32 };
158        // The ray-tracing acceleration-structure limits default to 0, which forbids
159        // BLAS/TLAS creation even with EXPERIMENTAL_RAY_QUERY enabled. Raise them to
160        // the adapter's supported values (a no-op on adapters that report 0).
161        let required_limits = wgpu::Limits {
162            max_blas_primitive_count: limits.max_blas_primitive_count,
163            max_blas_geometry_count: limits.max_blas_geometry_count,
164            max_tlas_instance_count: limits.max_tlas_instance_count,
165            max_acceleration_structures_per_shader_stage: limits
166                .max_acceleration_structures_per_shader_stage,
167            ..wgpu::Limits::default()
168        };
169        let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
170            required_features,
171            required_limits,
172            // The cooperative-matrix feature is gated behind wgpu's experimental
173            // token. Only requested (above) when the adapter advertises it; the
174            // token is harmless when no experimental feature is actually enabled.
175            // Safety: we only use it for the cooperative-matrix matmul kernel.
176            experimental_features: if required_features.intersects(
177                wgpu::Features::EXPERIMENTAL_COOPERATIVE_MATRIX
178                    | wgpu::Features::EXPERIMENTAL_RAY_QUERY,
179            ) {
180                unsafe { wgpu::ExperimentalFeatures::enabled() }
181            } else {
182                wgpu::ExperimentalFeatures::disabled()
183            },
184            ..Default::default()
185        }))
186        .map_err(|error| ForgeError::GpuUnavailable(error.to_string()))?;
187        let timestamp_period_ns = if timestamp_supported {
188            queue.get_timestamp_period()
189        } else {
190            0.0
191        };
192
193        // Determine topology (unified vs discrete)
194        let topology = if info.device_type == wgpu::DeviceType::IntegratedGpu
195            || info.device_type == wgpu::DeviceType::Cpu
196        {
197            MemoryTopology::Unified { zero_copy: true }
198        } else {
199            MemoryTopology::Discrete {
200                staging_required: true,
201            }
202        };
203        let memory_class = match topology {
204            MemoryTopology::Unified { .. } => "unified",
205            MemoryTopology::Discrete { .. } => "discrete",
206        }
207        .to_string();
208
209        let adapter = AdapterIdentity {
210            name: info.name,
211            vendor: info.vendor,
212            device: info.device,
213            device_type: format!("{:?}", info.device_type),
214            backend: format!("{:?}", info.backend),
215            driver: info.driver,
216            driver_info: info.driver_info,
217        };
218        let profile = HardwareProfile {
219            adapter: adapter.clone(),
220            constraints,
221            memory_class,
222            supports_timestamp_query: timestamp_supported,
223            max_compute_workgroup_storage_size: limits.max_compute_workgroup_storage_size,
224            max_storage_buffer_binding_size: limits.max_storage_buffer_binding_size,
225            min_storage_buffer_offset_alignment: limits.min_storage_buffer_offset_alignment,
226            min_uniform_buffer_offset_alignment: limits.min_uniform_buffer_offset_alignment,
227        };
228
229        let allocator = QualiaSlabAllocator::new(topology, capacity_bytes);
230
231        let slab = device.create_buffer(&wgpu::BufferDescriptor {
232            label: Some("forge-slab"),
233            size: capacity_bytes as u64,
234            usage: wgpu::BufferUsages::STORAGE
235                | wgpu::BufferUsages::UNIFORM
236                | wgpu::BufferUsages::COPY_DST
237                | wgpu::BufferUsages::COPY_SRC,
238            mapped_at_creation: false,
239        });
240
241        let out_slab = device.create_buffer(&wgpu::BufferDescriptor {
242            label: Some("forge-out-slab"),
243            size: capacity_bytes as u64,
244            usage: wgpu::BufferUsages::STORAGE
245                | wgpu::BufferUsages::COPY_DST
246                | wgpu::BufferUsages::COPY_SRC,
247            mapped_at_creation: false,
248        });
249
250        let weight_slab = device.create_buffer(&wgpu::BufferDescriptor {
251            label: Some("forge-weight-slab"),
252            size: capacity_bytes as u64,
253            usage: wgpu::BufferUsages::STORAGE
254                | wgpu::BufferUsages::COPY_DST
255                | wgpu::BufferUsages::COPY_SRC,
256            mapped_at_creation: false,
257        });
258        let timestamp_resources = timestamp_supported.then(|| TimestampResources::new(&device));
259
260        Ok(Self {
261            device,
262            queue,
263            adapter,
264            constraints,
265            profile,
266            allocator,
267            slab,
268            out_slab,
269            weight_slab,
270            weight_cursor: 0,
271            timestamp_supported,
272            timestamp_period_ns,
273            timestamp_resources,
274            pipeline_cache: RefCell::new(HashMap::new()),
275        })
276    }
277
278    /// Like [`new`] but tries the Vulkan backend first to find a cooperative-matrix-
279    /// capable adapter. On Windows/DX12, `EXPERIMENTAL_COOPERATIVE_MATRIX` is not
280    /// advertised, but the same NVIDIA GPU exposes `VK_KHR_cooperative_matrix` via
281    /// the Vulkan driver. This constructor:
282    ///
283    /// 1. Creates a Vulkan-only instance (unless `QUALIA_WGPU_BACKEND` overrides).
284    /// 2. Enumerates adapters, looking for one with `EXPERIMENTAL_COOPERATIVE_MATRIX`.
285    /// 3. If found, builds the context on that adapter (un-gating coopmat).
286    /// 4. If not found, falls back to [`new`] (which uses the default backend).
287    ///
288    /// This is the primary un-gating path for the HLSL WaveMatrix / WGSL coopmat
289    /// tensor-core emitters on NVIDIA hardware where DX12 doesn't expose coopmat.
290    #[cfg(not(target_arch = "wasm32"))]
291    pub fn new_for_coopmat(capacity_bytes: usize) -> Result<Self, ForgeError> {
292        // Step 1: Try Vulkan instance.
293        let vk_instance = create_instance_for_coopmat();
294
295        // Step 2: Find a coopmat-capable adapter.
296        if let Some(adapter) = find_coopmat_adapter(&vk_instance) {
297            let info = adapter.get_info();
298            log::info!(
299                "forge|coopmat_adapter|{}|backend={:?}|vendor=0x{:04x}:0x{:04x}",
300                info.name,
301                info.backend,
302                info.vendor,
303                info.device
304            );
305            let available_features = adapter.features();
306            let mut wanted = wgpu::Features::TIMESTAMP_QUERY
307                | wgpu::Features::SUBGROUP
308                | wgpu::Features::SHADER_F16;
309            if crate::gpu_context::experimental_features_allowed() {
310                wanted |= wgpu::Features::EXPERIMENTAL_COOPERATIVE_MATRIX
311                    | wgpu::Features::EXPERIMENTAL_RAY_QUERY;
312            }
313            let required_features = available_features & wanted;
314            let timestamp_supported = required_features.contains(wgpu::Features::TIMESTAMP_QUERY);
315            let limits = adapter.limits();
316            let mut constraints = AdapterConstraints::from_wgpu_limits(&limits);
317            constraints.supports_subgroups = available_features.contains(wgpu::Features::SUBGROUP);
318            constraints.supports_coopmat =
319                available_features.contains(wgpu::Features::EXPERIMENTAL_COOPERATIVE_MATRIX);
320            constraints.supports_rt_cores =
321                available_features.contains(wgpu::Features::EXPERIMENTAL_RAY_QUERY);
322            constraints.warp_size = if info.vendor == 0x1002 { 64 } else { 32 };
323            let required_limits = wgpu::Limits {
324                max_blas_primitive_count: limits.max_blas_primitive_count,
325                max_blas_geometry_count: limits.max_blas_geometry_count,
326                max_tlas_instance_count: limits.max_tlas_instance_count,
327                max_acceleration_structures_per_shader_stage: limits
328                    .max_acceleration_structures_per_shader_stage,
329                ..wgpu::Limits::default()
330            };
331            let (device, queue) =
332                pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
333                    required_features,
334                    required_limits,
335                    experimental_features: if required_features.intersects(
336                        wgpu::Features::EXPERIMENTAL_COOPERATIVE_MATRIX
337                            | wgpu::Features::EXPERIMENTAL_RAY_QUERY,
338                    ) {
339                        unsafe { wgpu::ExperimentalFeatures::enabled() }
340                    } else {
341                        wgpu::ExperimentalFeatures::disabled()
342                    },
343                    ..Default::default()
344                }))
345                .map_err(|error| ForgeError::GpuUnavailable(error.to_string()))?;
346            let timestamp_period_ns = if timestamp_supported {
347                queue.get_timestamp_period()
348            } else {
349                0.0
350            };
351            let topology = if info.device_type == wgpu::DeviceType::IntegratedGpu
352                || info.device_type == wgpu::DeviceType::Cpu
353            {
354                MemoryTopology::Unified { zero_copy: true }
355            } else {
356                MemoryTopology::Discrete {
357                    staging_required: true,
358                }
359            };
360            let memory_class = match topology {
361                MemoryTopology::Unified { .. } => "unified",
362                MemoryTopology::Discrete { .. } => "discrete",
363            }
364            .to_string();
365            let adapter_id = AdapterIdentity {
366                name: info.name,
367                vendor: info.vendor,
368                device: info.device,
369                device_type: format!("{:?}", info.device_type),
370                backend: format!("{:?}", info.backend),
371                driver: info.driver,
372                driver_info: info.driver_info,
373            };
374            let profile = HardwareProfile {
375                adapter: adapter_id.clone(),
376                constraints,
377                memory_class,
378                supports_timestamp_query: timestamp_supported,
379                max_compute_workgroup_storage_size: limits.max_compute_workgroup_storage_size,
380                max_storage_buffer_binding_size: limits.max_storage_buffer_binding_size,
381                min_storage_buffer_offset_alignment: limits.min_storage_buffer_offset_alignment,
382                min_uniform_buffer_offset_alignment: limits.min_uniform_buffer_offset_alignment,
383            };
384            let allocator = QualiaSlabAllocator::new(topology, capacity_bytes);
385            let slab = device.create_buffer(&wgpu::BufferDescriptor {
386                label: Some("forge-slab-coopmat"),
387                size: capacity_bytes as u64,
388                usage: wgpu::BufferUsages::STORAGE
389                    | wgpu::BufferUsages::UNIFORM
390                    | wgpu::BufferUsages::COPY_DST
391                    | wgpu::BufferUsages::COPY_SRC,
392                mapped_at_creation: false,
393            });
394            let out_slab = device.create_buffer(&wgpu::BufferDescriptor {
395                label: Some("forge-out-slab-coopmat"),
396                size: capacity_bytes as u64,
397                usage: wgpu::BufferUsages::STORAGE
398                    | wgpu::BufferUsages::COPY_DST
399                    | wgpu::BufferUsages::COPY_SRC,
400                mapped_at_creation: false,
401            });
402            let weight_slab = device.create_buffer(&wgpu::BufferDescriptor {
403                label: Some("forge-weight-slab-coopmat"),
404                size: capacity_bytes as u64,
405                usage: wgpu::BufferUsages::STORAGE
406                    | wgpu::BufferUsages::COPY_DST
407                    | wgpu::BufferUsages::COPY_SRC,
408                mapped_at_creation: false,
409            });
410            let timestamp_resources = timestamp_supported.then(|| TimestampResources::new(&device));
411            return Ok(Self {
412                device,
413                queue,
414                adapter: adapter_id,
415                constraints,
416                profile,
417                allocator,
418                slab,
419                out_slab,
420                weight_slab,
421                weight_cursor: 0,
422                timestamp_supported,
423                timestamp_period_ns,
424                timestamp_resources,
425                pipeline_cache: RefCell::new(HashMap::new()),
426            });
427        }
428
429        // Step 3: No coopmat adapter found — fall back to the default backend.
430        log::info!("forge|coopmat_adapter|none_found|falling_back_to_default");
431        Self::new(capacity_bytes)
432    }
433
434    /// Build a forge context on an **already-existing** `wgpu::Device` + `Queue` (e.g. the
435    /// process-wide [`crate::gpu_context::shared_gpu`]) instead of requesting a *second* adapter
436    /// and device the way [`Self::new`] does. wgpu `Device`/`Queue` are cheap `Arc` clones, so the
437    /// forge then runs on the **same** device that owns the resident LLM weights + KV cache — the
438    /// device-unification keystone for running decode on the forge (LLM-on-forge plan, Phase 1a).
439    ///
440    /// Adapter identity / constraints / hardware profile are reconstructed from the **live**
441    /// `device.limits()` + `device.features()` plus the caller's [`GpuAdapterCaps`] snapshot,
442    /// because the original `wgpu::Adapter` is consumed at shared-gpu init and not retained.
443    ///
444    /// Honest boundary: this inherits the *host* device's negotiated features and limits verbatim.
445    /// In particular, if the shared device was created without the ray-tracing acceleration-structure
446    /// limits raised (as `shared_gpu` currently does), RT-core Neighbor cannot create BLAS/TLAS on
447    /// this context even when `supports_rt_cores` is true — `from_device` does not silently widen the
448    /// host device. The decode path (matmul/elementwise/reduce) needs none of that.
449    #[cfg(not(target_arch = "wasm32"))]
450    pub fn from_device(
451        device: wgpu::Device,
452        queue: wgpu::Queue,
453        caps: &GpuAdapterCaps,
454        capacity_bytes: usize,
455    ) -> Result<Self, ForgeError> {
456        let features = device.features();
457        let limits = device.limits();
458        let timestamp_supported = features.contains(wgpu::Features::TIMESTAMP_QUERY);
459
460        // Mirror `new()`'s capability derivation, but from the live device + caps snapshot.
461        let mut constraints = AdapterConstraints::from_wgpu_limits(&limits);
462        constraints.supports_subgroups = features.contains(wgpu::Features::SUBGROUP);
463        constraints.supports_coopmat =
464            features.contains(wgpu::Features::EXPERIMENTAL_COOPERATIVE_MATRIX);
465        constraints.supports_rt_cores = features.contains(wgpu::Features::EXPERIMENTAL_RAY_QUERY);
466        // AMD wavefronts are 64-wide; others 32. Vendor id 0x1002 = AMD.
467        constraints.warp_size = if caps.vendor == 0x1002 { 64 } else { 32 };
468
469        let topology = if matches!(
470            caps.device_type,
471            wgpu::DeviceType::IntegratedGpu | wgpu::DeviceType::Cpu
472        ) {
473            MemoryTopology::Unified { zero_copy: true }
474        } else {
475            MemoryTopology::Discrete {
476                staging_required: true,
477            }
478        };
479        let memory_class = match topology {
480            MemoryTopology::Unified { .. } => "unified",
481            MemoryTopology::Discrete { .. } => "discrete",
482        }
483        .to_string();
484
485        let adapter = AdapterIdentity {
486            name: caps.name.clone(),
487            vendor: caps.vendor,
488            device: caps.device,
489            device_type: format!("{:?}", caps.device_type),
490            backend: format!("{:?}", caps.backend),
491            driver: caps.driver.clone(),
492            driver_info: caps.driver_info.clone(),
493        };
494        let timestamp_period_ns = if timestamp_supported {
495            queue.get_timestamp_period()
496        } else {
497            0.0
498        };
499        let profile = HardwareProfile {
500            adapter: adapter.clone(),
501            constraints,
502            memory_class,
503            supports_timestamp_query: timestamp_supported,
504            max_compute_workgroup_storage_size: limits.max_compute_workgroup_storage_size,
505            max_storage_buffer_binding_size: limits.max_storage_buffer_binding_size,
506            min_storage_buffer_offset_alignment: limits.min_storage_buffer_offset_alignment,
507            min_uniform_buffer_offset_alignment: limits.min_uniform_buffer_offset_alignment,
508        };
509
510        let allocator = QualiaSlabAllocator::new(topology, capacity_bytes);
511        let slab = device.create_buffer(&wgpu::BufferDescriptor {
512            label: Some("forge-slab"),
513            size: capacity_bytes as u64,
514            usage: wgpu::BufferUsages::STORAGE
515                | wgpu::BufferUsages::UNIFORM
516                | wgpu::BufferUsages::COPY_DST
517                | wgpu::BufferUsages::COPY_SRC,
518            mapped_at_creation: false,
519        });
520        let out_slab = device.create_buffer(&wgpu::BufferDescriptor {
521            label: Some("forge-out-slab"),
522            size: capacity_bytes as u64,
523            usage: wgpu::BufferUsages::STORAGE
524                | wgpu::BufferUsages::COPY_DST
525                | wgpu::BufferUsages::COPY_SRC,
526            mapped_at_creation: false,
527        });
528        let weight_slab = device.create_buffer(&wgpu::BufferDescriptor {
529            label: Some("forge-weight-slab"),
530            size: capacity_bytes as u64,
531            usage: wgpu::BufferUsages::STORAGE
532                | wgpu::BufferUsages::COPY_DST
533                | wgpu::BufferUsages::COPY_SRC,
534            mapped_at_creation: false,
535        });
536        let timestamp_resources = timestamp_supported.then(|| TimestampResources::new(&device));
537
538        Ok(Self {
539            device,
540            queue,
541            adapter,
542            constraints,
543            profile,
544            allocator,
545            slab,
546            out_slab,
547            weight_slab,
548            weight_cursor: 0,
549            timestamp_supported,
550            timestamp_period_ns,
551            timestamp_resources,
552            pipeline_cache: RefCell::new(HashMap::new()),
553        })
554    }
555
556    /// The physical buffer backing a view, chosen by its binding usage.
557    fn slab_for(&self, usage: BindingUsage) -> &wgpu::Buffer {
558        match usage {
559            BindingUsage::StorageReadWrite => &self.out_slab,
560            BindingUsage::StorageReadResident => &self.weight_slab,
561            BindingUsage::StorageRead | BindingUsage::Uniform => &self.slab,
562        }
563    }
564
565    /// Allocate a transient slab sub-range and upload `data` into it.
566    ///
567    /// # Topology note (honest scope, plan §2)
568    ///
569    /// This upload uses `queue.write_buffer` **uniformly on every topology**
570    /// (unified and discrete alike); readback in [`Self::read_buffer_f32`]
571    /// likewise uses `copy_buffer_to_buffer` uniformly. The
572    /// `MemoryTopology::{Unified, Discrete}` classification on the allocator is
573    /// *recorded but not yet acted upon here*: the plan-§2 differentiated paths
574    /// (zero-copy persistent-mapped slabs for unified memory; a pinned staging ring
575    /// with async `copy_buffer` for discrete PCIe) are NOT implemented. The current
576    /// uniform path is correct on both topologies but unoptimised; the unified
577    /// zero-copy benefit cannot be measured on this discrete-only host (RTX A2000),
578    /// so it is left as documented future work rather than shipped unverified. See
579    /// [`MemoryTopology`] for the full rationale.
580    pub fn allocate_and_write(
581        &mut self,
582        data: &[u8],
583        binding: u32,
584        group: u32,
585        usage: BindingUsage,
586    ) -> Result<BufferView, ForgeError> {
587        let view = self
588            .allocator
589            .allocate_transient(data.len(), binding, group, usage)?;
590        if !data.is_empty() {
591            let slab = self.slab_for(usage);
592            self.queue
593                .write_buffer(slab, view.offset as wgpu::BufferAddress, data);
594        }
595        Ok(view)
596    }
597
598    /// Bump-allocate `data` into the **persistent weight region** (`weight_slab`) and upload it
599    /// once, returning a [`BufferView`] tagged [`BindingUsage::StorageReadResident`]. Unlike
600    /// [`Self::allocate_and_write`] (transient ring), this view **survives**
601    /// [`Self::clear_transient_allocations`], so a decode layer's projection / FFN matrices are
602    /// uploaded a single time and referenced by offset across every token's `run` — eliminating
603    /// the per-call weight re-upload. Offsets are 256-aligned for direct bind-group use.
604    pub fn allocate_weight(
605        &mut self,
606        data: &[u8],
607        binding: u32,
608        group: u32,
609    ) -> Result<BufferView, ForgeError> {
610        let offset =
611            self.weight_cursor.div_ceil(DEFAULT_BINDING_ALIGNMENT) * DEFAULT_BINDING_ALIGNMENT;
612        let end = offset + data.len();
613        let cap = self.weight_slab.size() as usize;
614        if end > cap {
615            return Err(ForgeError::GpuValidation(format!(
616                "weight region overflow: need {end} bytes but weight slab is {cap} (raise capacity)"
617            )));
618        }
619        if !data.is_empty() {
620            self.queue
621                .write_buffer(&self.weight_slab, offset as wgpu::BufferAddress, data);
622        }
623        self.weight_cursor = end;
624        Ok(BufferView {
625            offset,
626            length_bytes: data.len(),
627            binding,
628            group,
629            usage: BindingUsage::StorageReadResident,
630        })
631    }
632
633    /// Reset the persistent weight region so it can be reused for a different model/layer set.
634    /// Any [`BufferView`]s previously returned by [`Self::allocate_weight`] become stale — drop
635    /// the corresponding handles and re-load. (Weights are write-once; no per-tensor free.)
636    pub fn clear_weights(&mut self) {
637        self.weight_cursor = 0;
638    }
639
640    /// Bytes currently consumed in the persistent weight region (for tests / introspection).
641    pub fn resident_weight_bytes(&self) -> usize {
642        self.weight_cursor
643    }
644
645    pub fn allocate_transient(
646        &mut self,
647        size_bytes: usize,
648        binding: u32,
649        group: u32,
650        usage: BindingUsage,
651    ) -> Result<BufferView, ForgeError> {
652        self.allocator
653            .allocate_transient(size_bytes, binding, group, usage)
654    }
655
656    pub fn advance_read_head(&mut self, offset: usize) {
657        self.allocator.advance_read_head(offset);
658    }
659
660    pub fn clear_transient_allocations(&mut self) {
661        self.allocator.clear();
662    }
663
664    /// Builds a bottom-level (BLAS) + top-level (TLAS) acceleration structure for a
665    /// triangle soup and returns both, ready to bind to a ray-query shader. `vertices`
666    /// is a flat list of `f32` triples (3 per vertex, 3 vertices per triangle),
667    /// row-major. The BLAS geometry is marked `OPAQUE` (required — naga's ray-query
668    /// has no candidate/any-hit path, so non-opaque geometry yields no committed hits),
669    /// and the single TLAS instance uses the identity transform. Both structures are
670    /// built and the queue drained before returning. Requires the adapter to support
671    /// (and the device to have enabled) `EXPERIMENTAL_RAY_QUERY`.
672    ///
673    /// The returned `Blas` must be kept alive alongside the `Tlas` for the lifetime of
674    /// any bind group referencing the TLAS (the `TlasInstance` borrows the BLAS).
675    pub fn build_triangle_scene(
676        &self,
677        vertices: &[f32],
678    ) -> Result<(wgpu::Blas, wgpu::Tlas), ForgeError> {
679        if !self.constraints.supports_rt_cores {
680            return Err(ForgeError::GpuUnavailable(
681                "adapter lacks ray-query (RT) support".to_string(),
682            ));
683        }
684        if vertices.is_empty() || vertices.len() % 9 != 0 {
685            return Err(ForgeError::GpuValidation(format!(
686                "triangle scene needs a non-empty multiple of 9 floats (3 verts x xyz); got {}",
687                vertices.len()
688            )));
689        }
690        let vertex_count = (vertices.len() / 3) as u32;
691
692        let vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
693            label: Some("forge-blas-vertices"),
694            size: (vertices.len() * size_of::<f32>()) as u64,
695            usage: wgpu::BufferUsages::BLAS_INPUT | wgpu::BufferUsages::COPY_DST,
696            mapped_at_creation: false,
697        });
698        self.queue
699            .write_buffer(&vertex_buffer, 0, bytemuck::cast_slice(vertices));
700
701        let size_desc = wgpu::BlasTriangleGeometrySizeDescriptor {
702            vertex_format: wgpu::VertexFormat::Float32x3,
703            vertex_count,
704            index_format: None,
705            index_count: None,
706            flags: wgpu::AccelerationStructureGeometryFlags::OPAQUE,
707        };
708        let blas = self.device.create_blas(
709            &wgpu::CreateBlasDescriptor {
710                label: Some("forge-blas"),
711                flags: wgpu::AccelerationStructureFlags::PREFER_FAST_TRACE,
712                update_mode: wgpu::AccelerationStructureUpdateMode::Build,
713            },
714            wgpu::BlasGeometrySizeDescriptors::Triangles {
715                descriptors: vec![size_desc.clone()],
716            },
717        );
718
719        let mut tlas = self.device.create_tlas(&wgpu::CreateTlasDescriptor {
720            label: Some("forge-tlas"),
721            max_instances: 1,
722            flags: wgpu::AccelerationStructureFlags::PREFER_FAST_TRACE,
723            update_mode: wgpu::AccelerationStructureUpdateMode::Build,
724        });
725        // 3x4 row-major identity (scene vertices are already in world space).
726        let identity: [f32; 12] = [
727            1.0, 0.0, 0.0, 0.0, //
728            0.0, 1.0, 0.0, 0.0, //
729            0.0, 0.0, 1.0, 0.0,
730        ];
731        tlas[0] = Some(wgpu::TlasInstance::new(&blas, identity, 0, 0xFF));
732
733        let geometry = wgpu::BlasTriangleGeometry {
734            size: &size_desc,
735            vertex_buffer: &vertex_buffer,
736            first_vertex: 0,
737            vertex_stride: (3 * size_of::<f32>()) as wgpu::BufferAddress,
738            index_buffer: None,
739            first_index: None,
740            transform_buffer: None,
741            transform_buffer_offset: None,
742        };
743        let entry = wgpu::BlasBuildEntry {
744            blas: &blas,
745            geometry: wgpu::BlasGeometries::TriangleGeometries(vec![geometry]),
746        };
747        let mut encoder = self
748            .device
749            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
750                label: Some("forge-build-accel"),
751            });
752        encoder.build_acceleration_structures(std::iter::once(&entry), std::iter::once(&tlas));
753        self.queue.submit(Some(encoder.finish()));
754        self.device
755            .poll(wgpu::PollType::wait_indefinitely())
756            .map_err(|e| {
757                ForgeError::DeviceLost(format!("device poll failed building accel: {e:?}"))
758            })?;
759
760        Ok((blas, tlas))
761    }
762
763    /// Compile a WGSL compute pipeline and return the **owned** `wgpu::ComputePipeline`
764    /// (no borrow of `self`), wrapped in a validation error scope. This is the building
765    /// block the multi-node graph executor uses to compile every node's kernel up front
766    /// before recording them into a single command encoder ([`Self::submit_graph`]).
767    /// [`WgpuPipeline::compile`] delegates here.
768    pub fn compile_pipeline(
769        &self,
770        source: &str,
771        entry_point: &str,
772    ) -> Result<wgpu::ComputePipeline, ForgeError> {
773        let error_scope = self.device.push_error_scope(wgpu::ErrorFilter::Validation);
774        let shader = self
775            .device
776            .create_shader_module(wgpu::ShaderModuleDescriptor {
777                label: Some("qualia-wgsl-forge"),
778                source: wgpu::ShaderSource::Wgsl(source.into()),
779            });
780        let pipeline = self
781            .device
782            .create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
783                label: Some("qualia-wgsl-forge-pipeline"),
784                layout: None,
785                module: &shader,
786                entry_point: Some(entry_point),
787                compilation_options: Default::default(),
788                cache: None,
789            });
790        if let Some(error) = pollster::block_on(error_scope.pop()) {
791            return Err(ForgeError::GpuValidation(error.to_string()));
792        }
793        Ok(pipeline)
794    }
795
796    /// Like [`compile_pipeline`] but accepts pre-compiled SPIR-V bytes instead of
797    /// WGSL source. This is the execution bridge for native shader profiles that
798    /// compile to SPIR-V (notably HLSL via DXC `–spirv`): the forge emits HLSL,
799    /// DXC produces a SPIR-V binary, and this method feeds it into the same wgpu
800    /// pipeline (bind groups, slab, dispatch — all unchanged).
801    pub fn compile_pipeline_spirv(
802        &self,
803        spirv: &[u8],
804        entry_point: &str,
805    ) -> Result<wgpu::ComputePipeline, ForgeError> {
806        let error_scope = self.device.push_error_scope(wgpu::ErrorFilter::Validation);
807        let spirv_words: &[u32] = bytemuck::cast_slice(spirv);
808        let shader = self
809            .device
810            .create_shader_module(wgpu::ShaderModuleDescriptor {
811                label: Some("qualia-forge-spirv"),
812                source: wgpu::ShaderSource::SpirV(std::borrow::Cow::Borrowed(spirv_words)),
813            });
814        let pipeline = self
815            .device
816            .create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
817                label: Some("qualia-forge-spirv-pipeline"),
818                layout: None,
819                module: &shader,
820                entry_point: Some(entry_point),
821                compilation_options: Default::default(),
822                cache: None,
823            });
824        if let Some(error) = pollster::block_on(error_scope.pop()) {
825            return Err(ForgeError::GpuValidation(error.to_string()));
826        }
827        Ok(pipeline)
828    }
829
830    /// [`compile_pipeline`](Self::compile_pipeline) with a process-lifetime cache keyed by
831    /// `entry\0source` — the same `(source, entry)` returns the previously-built pipeline
832    /// (a cheap `Arc`-clone) instead of recompiling. This is what makes a re-run of a fixed
833    /// graph (e.g. one decode block per generated token, via a held [`ForgeGraphExecutor`])
834    /// pay shader compilation **once**, not per call. The graph executor records its nodes
835    /// through this path; one-shot callers see a cold cache (built + dropped with the context).
836    pub fn compile_pipeline_cached(
837        &self,
838        source: &str,
839        entry_point: &str,
840    ) -> Result<wgpu::ComputePipeline, ForgeError> {
841        let key = format!("{entry_point}\u{0}{source}");
842        if let Some(pipeline) = self.pipeline_cache.borrow().get(&key) {
843            return Ok(pipeline.clone());
844        }
845        let pipeline = self.compile_pipeline(source, entry_point)?;
846        self.pipeline_cache
847            .borrow_mut()
848            .insert(key, pipeline.clone());
849        Ok(pipeline)
850    }
851
852    /// Number of distinct pipelines currently cached (for tests / introspection).
853    pub fn cached_pipeline_count(&self) -> usize {
854        self.pipeline_cache.borrow().len()
855    }
856
857    /// Build a bind group binding each [`BufferView`] at its `binding` slot, choosing the
858    /// physical slab per the view's usage ([`Self::slab_for`]). Shared by the per-node
859    /// [`WgpuPipeline::dispatch`] path and the deferred-submit graph path.
860    pub fn create_compute_bind_group(
861        &self,
862        pipeline: &wgpu::ComputePipeline,
863        buffers: &[BufferView],
864    ) -> wgpu::BindGroup {
865        let mut entries = Vec::with_capacity(buffers.len());
866        for view in buffers {
867            let size = wgpu::BufferSize::new(view.length_bytes as u64);
868            entries.push(wgpu::BindGroupEntry {
869                binding: view.binding,
870                resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
871                    buffer: self.slab_for(view.usage),
872                    offset: view.offset as wgpu::BufferAddress,
873                    size,
874                }),
875            });
876        }
877        self.device.create_bind_group(&wgpu::BindGroupDescriptor {
878            label: Some("forge-bind-group"),
879            layout: &pipeline.get_bind_group_layout(0),
880            entries: &entries,
881        })
882    }
883
884    /// Record **all** of a graph's node dispatches — and the GPU→GPU hand-off copies
885    /// between them — into ONE [`wgpu::CommandEncoder`] and submit it **once**, instead of
886    /// one `queue.submit()` per node. This is the single-encoder deferred-submit fusion
887    /// (plan §8.1 "Option B"): within one command buffer wgpu preserves command order and
888    /// inserts the necessary buffer hazard barriers, so a producer's compute pass, its
889    /// `copy_buffer_to_buffer` hand-off, and the consumer's dispatch are correctly ordered
890    /// with no host round-trip and no per-node submit latency. The caller (the executor)
891    /// has already encoded each node's data dependencies in `passes` (insertion/topological
892    /// order) and built each bind group, so this loop is pure recording. Blocks on device
893    /// completion and surfaces any validation error.
894    pub fn submit_graph(&self, passes: &[GraphPass]) -> Result<(), ForgeError> {
895        let error_scope = self.device.push_error_scope(wgpu::ErrorFilter::Validation);
896        let mut encoder = self
897            .device
898            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
899                label: Some("forge-graph-encoder"),
900            });
901        for pass in passes {
902            {
903                let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
904                    label: Some("forge-graph-pass"),
905                    timestamp_writes: None,
906                });
907                cpass.set_pipeline(&pass.pipeline);
908                cpass.set_bind_group(0, &pass.bind_group, &[]);
909                cpass.dispatch_workgroups(pass.workgroups, 1, 1);
910            }
911            // GPU→GPU hand-off: copy this node's read_write output into the fresh
912            // read-slab buffer a downstream node will bind read-only. Recorded in the
913            // SAME encoder, so it is ordered after the pass that produced it.
914            if let Some((src, dst)) = &pass.copy {
915                let len = src.length_bytes.min(dst.length_bytes) as u64;
916                if len > 0 {
917                    encoder.copy_buffer_to_buffer(
918                        self.slab_for(src.usage),
919                        src.offset as u64,
920                        self.slab_for(dst.usage),
921                        dst.offset as u64,
922                        len,
923                    );
924                }
925            }
926        }
927        self.queue.submit(Some(encoder.finish()));
928        self.device
929            .poll(wgpu::PollType::wait_indefinitely())
930            .map_err(|e| {
931                ForgeError::DeviceLost(format!("device poll failed submitting graph: {e:?}"))
932            })?;
933        if let Some(error) = pollster::block_on(error_scope.pop()) {
934            return Err(ForgeError::GpuValidation(error.to_string()));
935        }
936        Ok(())
937    }
938
939    /// Copy `src`'s bytes to `dst` on the device (GPU→GPU, no host readback), honouring
940    /// each view's slab. Used by the multi-node graph executor to move a node's output out
941    /// of the read_write slab into the read slab, so a downstream node can bind it as a
942    /// read-only input without aliasing its own read_write output (wgpu forbids the same
943    /// buffer being bound read-write and read-only within one dispatch). Submits on the
944    /// shared queue, so it is ordered before any later dispatch that reads `dst`.
945    pub fn copy_view(&self, src: &BufferView, dst: &BufferView) -> Result<(), ForgeError> {
946        let len = src.length_bytes.min(dst.length_bytes) as u64;
947        if len == 0 {
948            return Ok(());
949        }
950        let mut encoder = self
951            .device
952            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
953                label: Some("forge-view-copy"),
954            });
955        encoder.copy_buffer_to_buffer(
956            self.slab_for(src.usage),
957            src.offset as u64,
958            self.slab_for(dst.usage),
959            dst.offset as u64,
960            len,
961        );
962        self.queue.submit(Some(encoder.finish()));
963        Ok(())
964    }
965
966    pub fn read_buffer_f32(&self, view: &BufferView) -> Result<Vec<f32>, ForgeError> {
967        let size = view.length_bytes as u64;
968        let staging = self.device.create_buffer(&wgpu::BufferDescriptor {
969            label: Some("forge-output-staging"),
970            size,
971            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
972            mapped_at_creation: false,
973        });
974        let mut encoder = self
975            .device
976            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
977                label: Some("forge-output-copy"),
978            });
979        encoder.copy_buffer_to_buffer(
980            self.slab_for(view.usage),
981            view.offset as u64,
982            &staging,
983            0,
984            size,
985        );
986        self.queue.submit(Some(encoder.finish()));
987        let bytes = map_read(&self.device, &staging)?;
988
989        let elements = view.length_bytes / size_of::<f32>();
990        let output = bytemuck::cast_slice::<u8, f32>(&bytes)[..elements].to_vec();
991        drop(bytes);
992        staging.unmap();
993        Ok(output)
994    }
995}
996
997impl OracleContext for WgpuComputeContext {
998    fn allocate_and_write(
999        &mut self,
1000        data: &[u8],
1001        binding: u32,
1002        group: u32,
1003        usage: BindingUsage,
1004    ) -> Result<BufferView, ForgeError> {
1005        WgpuComputeContext::allocate_and_write(self, data, binding, group, usage)
1006    }
1007
1008    fn allocate_transient(
1009        &mut self,
1010        size_bytes: usize,
1011        binding: u32,
1012        group: u32,
1013        usage: BindingUsage,
1014    ) -> Result<BufferView, ForgeError> {
1015        WgpuComputeContext::allocate_transient(self, size_bytes, binding, group, usage)
1016    }
1017
1018    fn read_buffer_f32(&self, view: &BufferView) -> Result<Vec<f32>, ForgeError> {
1019        WgpuComputeContext::read_buffer_f32(self, view)
1020    }
1021
1022    fn clear_transient_allocations(&mut self) {
1023        WgpuComputeContext::clear_transient_allocations(self);
1024    }
1025
1026    fn adapter(&self) -> &AdapterIdentity {
1027        &self.adapter
1028    }
1029
1030    fn constraints(&self) -> &AdapterConstraints {
1031        &self.constraints
1032    }
1033
1034    fn timestamp_supported(&self) -> bool {
1035        self.timestamp_supported
1036    }
1037
1038    /// Emit the kernel's WGSL, compile it, then run the warmup + timed-sample
1039    /// dispatch loop — byte-for-byte the loop the wgpu oracle evaluators ran inline
1040    /// (warmups untimed, then `samples` timed dispatches via [`QualiaCompute::dispatch`]).
1041    fn run_kernel(
1042        &mut self,
1043        kernel: &KernelSpec,
1044        schedule: &Schedule,
1045        buffers: &[BufferView],
1046        element_count: usize,
1047        warmups: usize,
1048        samples: usize,
1049    ) -> Result<Vec<u64>, ForgeError> {
1050        let generated = emit_shader(kernel, *schedule, TargetBackend::Wgsl)?;
1051        let pipeline = WgpuPipeline::compile(self, &generated.source, &kernel.entry_point)?;
1052
1053        for _ in 0..warmups {
1054            pipeline.dispatch(buffers, schedule, element_count)?;
1055        }
1056        let mut timing_samples = Vec::with_capacity(samples);
1057        for _ in 0..samples {
1058            timing_samples.push(pipeline.dispatch(buffers, schedule, element_count)?);
1059        }
1060        Ok(timing_samples)
1061    }
1062}
1063
1064/// One recorded graph node, ready for [`WgpuComputeContext::submit_graph`] to play into a
1065/// shared command encoder: the compiled pipeline, its bind group, the workgroup count, and
1066/// the optional GPU→GPU hand-off copy (`src` in the read_write slab → `dst` in the read
1067/// slab) emitted after the node's compute pass. The pipeline and bind group are owned so the
1068/// executor can build them all up front and submit the whole graph in one go.
1069pub struct GraphPass {
1070    pub pipeline: wgpu::ComputePipeline,
1071    pub bind_group: wgpu::BindGroup,
1072    pub workgroups: u32,
1073    pub copy: Option<(BufferView, BufferView)>,
1074}
1075
1076pub struct WgpuPipeline<'a> {
1077    context: &'a WgpuComputeContext,
1078    pipeline: wgpu::ComputePipeline,
1079}
1080
1081impl<'a> WgpuPipeline<'a> {
1082    pub fn compile(
1083        context: &'a WgpuComputeContext,
1084        source: &str,
1085        entry_point: &str,
1086    ) -> Result<Self, ForgeError> {
1087        let pipeline = context.compile_pipeline(source, entry_point)?;
1088        Ok(Self { context, pipeline })
1089    }
1090
1091    /// Like [`compile`] but accepts pre-compiled SPIR-V bytes (from HLSL→DXC).
1092    pub fn compile_spirv(
1093        context: &'a WgpuComputeContext,
1094        spirv: &[u8],
1095        entry_point: &str,
1096    ) -> Result<Self, ForgeError> {
1097        let pipeline = context.compile_pipeline_spirv(spirv, entry_point)?;
1098        Ok(Self { context, pipeline })
1099    }
1100
1101    /// Compile pre-emitted MSL source through wgpu's Metal backend.
1102    ///
1103    /// wgpu does not expose `ShaderSource::Msl` — on macOS, wgpu transpiles
1104    /// WGSL→MSL internally via naga. For pre-emitted MSL, the caller should
1105    /// use a native Metal path (e.g. `metal-rs`). This method exists so the
1106    /// runtime can attempt MSL compilation on macOS; on other platforms it
1107    /// returns an error and the runtime falls back to WGSL.
1108    pub fn compile_msl(
1109        context: &'a WgpuComputeContext,
1110        _source: &str,
1111        _entry_point: &str,
1112    ) -> Result<Self, ForgeError> {
1113        // wgpu's ShaderSource enum does not have an Msl variant.
1114        // On macOS, the practical path is to transpile MSL→SPIR-V via
1115        // SPIRV-Cross and use compile_spirv, or use metal-rs directly.
1116        // For now, return an error so the runtime falls back to WGSL.
1117        let _ = context;
1118        Err(ForgeError::Emission(
1119            "MSL native compilation requires a metal-rs bridge (not yet implemented). \
1120             Falling back to WGSL."
1121                .to_string(),
1122        ))
1123    }
1124
1125    /// Dispatch a ray-query kernel, binding `tlas` as the `acceleration_structure`
1126    /// at binding 0 and the supplied buffer views (rays at binding 1, hits at
1127    /// binding 2) at their own bindings. The generic [`QualiaCompute::dispatch`]
1128    /// only binds buffers, so this is the dedicated path for the acceleration-
1129    /// structure binding. Returns wall-clock nanoseconds (ray-query passes skip the
1130    /// timestamp path). The caller must keep the TLAS (and its BLAS) alive.
1131    pub fn dispatch_rayprobe(
1132        &self,
1133        tlas: &wgpu::Tlas,
1134        buffers: &[BufferView],
1135        schedule: &Schedule,
1136        element_count: usize,
1137    ) -> Result<u64, ForgeError> {
1138        let mut entries = Vec::with_capacity(buffers.len() + 1);
1139        entries.push(wgpu::BindGroupEntry {
1140            binding: 0,
1141            resource: tlas.as_binding(),
1142        });
1143        for view in buffers {
1144            let size = wgpu::BufferSize::new(view.length_bytes as u64);
1145            entries.push(wgpu::BindGroupEntry {
1146                binding: view.binding,
1147                resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
1148                    buffer: self.context.slab_for(view.usage),
1149                    offset: view.offset as wgpu::BufferAddress,
1150                    size,
1151                }),
1152            });
1153        }
1154        let bind_group = self
1155            .context
1156            .device
1157            .create_bind_group(&wgpu::BindGroupDescriptor {
1158                label: Some("forge-rayprobe-bind-group"),
1159                layout: &self.pipeline.get_bind_group_layout(0),
1160                entries: &entries,
1161            });
1162        let dispatch_x = schedule.dispatch_workgroups(element_count);
1163
1164        let started = Instant::now();
1165        let mut encoder =
1166            self.context
1167                .device
1168                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1169                    label: Some("forge-rayprobe-dispatch"),
1170                });
1171        {
1172            let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
1173                label: Some("forge-rayprobe-pass"),
1174                timestamp_writes: None,
1175            });
1176            pass.set_pipeline(&self.pipeline);
1177            pass.set_bind_group(0, &bind_group, &[]);
1178            pass.dispatch_workgroups(dispatch_x, 1, 1);
1179        }
1180        self.context.queue.submit(Some(encoder.finish()));
1181        let _ = self
1182            .context
1183            .device
1184            .poll(wgpu::PollType::wait_indefinitely());
1185        Ok(started.elapsed().as_nanos().min(u64::MAX as u128) as u64)
1186    }
1187}
1188
1189impl<'a> QualiaCompute for WgpuPipeline<'a> {
1190    fn dispatch(
1191        &self,
1192        buffers: &[BufferView],
1193        schedule: &Schedule,
1194        element_count: usize,
1195    ) -> Result<u64, ForgeError> {
1196        let bind_group = self
1197            .context
1198            .create_compute_bind_group(&self.pipeline, buffers);
1199        let dispatch_x = schedule.dispatch_workgroups(element_count);
1200
1201        let started = Instant::now();
1202        let mut encoder =
1203            self.context
1204                .device
1205                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1206                    label: Some("forge-dispatch"),
1207                });
1208        {
1209            let timestamp_writes = self.context.timestamp_resources.as_ref().map(|resources| {
1210                wgpu::ComputePassTimestampWrites {
1211                    query_set: &resources.query_set,
1212                    beginning_of_pass_write_index: Some(0),
1213                    end_of_pass_write_index: Some(1),
1214                }
1215            });
1216            let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
1217                label: Some("forge-compute-pass"),
1218                timestamp_writes,
1219            });
1220            pass.set_pipeline(&self.pipeline);
1221            pass.set_bind_group(0, &bind_group, &[]);
1222            pass.dispatch_workgroups(dispatch_x, 1, 1);
1223        }
1224
1225        if let Some(resources) = &self.context.timestamp_resources {
1226            encoder.resolve_query_set(&resources.query_set, 0..2, &resources.resolve, 0);
1227            encoder.copy_buffer_to_buffer(&resources.resolve, 0, &resources.staging, 0, 16);
1228        }
1229        self.context.queue.submit(Some(encoder.finish()));
1230
1231        if let Some(resources) = &self.context.timestamp_resources {
1232            let bytes = map_read(&self.context.device, &resources.staging)?;
1233            let ticks: &[u64] = bytemuck::cast_slice(&bytes);
1234            let elapsed = ticks
1235                .get(1)
1236                .copied()
1237                .unwrap_or(0)
1238                .saturating_sub(ticks.first().copied().unwrap_or(0));
1239            drop(bytes);
1240            resources.staging.unmap();
1241            Ok((elapsed as f64 * self.context.timestamp_period_ns as f64) as u64)
1242        } else {
1243            self.context
1244                .device
1245                .poll(wgpu::PollType::wait_indefinitely())
1246                .map_err(|e| ForgeError::DeviceLost(format!("device poll failed: {e:?}")))?;
1247            Ok(started.elapsed().as_nanos().min(u64::MAX as u128) as u64)
1248        }
1249    }
1250}
1251
1252struct TimestampResources {
1253    query_set: wgpu::QuerySet,
1254    resolve: wgpu::Buffer,
1255    staging: wgpu::Buffer,
1256}
1257
1258impl TimestampResources {
1259    fn new(device: &wgpu::Device) -> Self {
1260        Self {
1261            query_set: device.create_query_set(&wgpu::QuerySetDescriptor {
1262                label: Some("forge-timestamps"),
1263                ty: wgpu::QueryType::Timestamp,
1264                count: 2,
1265            }),
1266            resolve: device.create_buffer(&wgpu::BufferDescriptor {
1267                label: Some("forge-timestamp-resolve"),
1268                size: 16,
1269                usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC,
1270                mapped_at_creation: false,
1271            }),
1272            staging: device.create_buffer(&wgpu::BufferDescriptor {
1273                label: Some("forge-timestamp-staging"),
1274                size: 16,
1275                usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
1276                mapped_at_creation: false,
1277            }),
1278        }
1279    }
1280}
1281
1282fn map_read(device: &wgpu::Device, buffer: &wgpu::Buffer) -> Result<wgpu::BufferView, ForgeError> {
1283    let slice = buffer.slice(..);
1284    let (sender, receiver) = mpsc::channel();
1285    slice.map_async(wgpu::MapMode::Read, move |result| {
1286        let _ = sender.send(result);
1287    });
1288    device
1289        .poll(wgpu::PollType::wait_indefinitely())
1290        .map_err(|e| ForgeError::DeviceLost(format!("device poll failed during map: {e:?}")))?;
1291    receiver
1292        .recv()
1293        .map_err(|error| ForgeError::GpuValidation(error.to_string()))?
1294        .map_err(|error| ForgeError::GpuValidation(error.to_string()))?;
1295    // wgpu 30: get_mapped_range() returns Result — propagate as ForgeError.
1296    slice
1297        .get_mapped_range()
1298        .map_err(|e| ForgeError::GpuValidation(format!("map_range failed: {e:?}")))
1299}