Skip to main content

oxicuda_webgpu/
backend.rs

1//! [`WebGpuBackend`] — the main entry point for the oxicuda-webgpu crate.
2//!
3//! Implements the [`ComputeBackend`] trait from `oxicuda-backend` using
4//! `wgpu` for cross-platform GPU compute (Vulkan, Metal, DX12, WebGPU).
5
6use std::collections::HashMap;
7use std::sync::{Arc, Mutex};
8
9use oxicuda_backend::{
10    BackendError, BackendResult, BackendTranspose, BinaryOp, ComputeBackend, ReduceOp, UnaryOp,
11};
12use wgpu;
13
14use crate::{
15    device::WebGpuDevice,
16    memory::WebGpuMemoryManager,
17    planner::{self, Limits},
18    shader,
19};
20
21// GPU dispatch paths for conv2d_forward / attention, plus their CPU-reference
22// oracles — split into a sibling file (mirroring how `tests` below is split
23// into `backend_tests.rs`) purely to keep this file under the 2 000-line
24// refactoring policy.
25#[path = "backend_gpu_ops.rs"]
26mod gpu_ops;
27use gpu_ops::{
28    attention_cpu_reference, attention_gpu_dispatch_grid, conv2d_cpu_reference,
29    conv2d_gpu_dispatch_grid, conv2d_u32_dims,
30};
31
32// Pipeline + bind-group caching — split out for the same 2 000-line-policy
33// reason as `gpu_ops` above. See that file's module doc for the caching
34// design and its safety argument.
35#[path = "backend_cache.rs"]
36mod cache;
37use cache::{BindGroupCache, CachedPipeline};
38
39// ─── Op-mapping helpers ──────────────────────────────────────────────────────
40
41fn map_unary_op(op: UnaryOp) -> &'static str {
42    match op {
43        UnaryOp::Relu => "relu",
44        UnaryOp::Sigmoid => "sigmoid",
45        UnaryOp::Tanh => "tanh",
46        UnaryOp::Exp => "exp",
47        UnaryOp::Log => "log",
48        UnaryOp::Sqrt => "sqrt",
49        UnaryOp::Abs => "abs",
50        UnaryOp::Neg => "neg",
51    }
52}
53
54fn map_binary_op(op: BinaryOp) -> &'static str {
55    match op {
56        BinaryOp::Add => "add",
57        BinaryOp::Sub => "sub",
58        BinaryOp::Mul => "mul",
59        BinaryOp::Div => "div",
60        BinaryOp::Max => "max",
61        BinaryOp::Min => "min",
62    }
63}
64
65fn map_reduce_op(op: ReduceOp) -> &'static str {
66    match op {
67        ReduceOp::Sum => "sum",
68        ReduceOp::Max => "max",
69        ReduceOp::Min => "min",
70        ReduceOp::Mean => "mean",
71    }
72}
73
74/// Packed (minimum) leading dimensions for the row-major GEMM kernel.
75///
76/// The WGSL kernel stores `op(A)`'s physical buffer as `m×k` (or its transpose
77/// `k×m`), `op(B)` as `k×n` (or `n×k`), and `C` as `m×n`, all row-major.  The
78/// leading dimension is the physical row stride, so the tightly-packed value is
79/// the width of each stored row.  A caller may pass a larger `ld` (padded /
80/// sub-matrix view), which the shader honours; a smaller one is invalid.
81fn packed_gemm_lds(
82    trans_a: BackendTranspose,
83    trans_b: BackendTranspose,
84    m: usize,
85    n: usize,
86    k: usize,
87) -> (usize, usize, usize) {
88    let lda = if trans_a == BackendTranspose::NoTrans {
89        k
90    } else {
91        m
92    };
93    let ldb = if trans_b == BackendTranspose::NoTrans {
94        n
95    } else {
96        k
97    };
98    (lda, ldb, n)
99}
100
101/// Convert a `usize` dimension (leading dimension, matrix extent, stride,
102/// batch count, …) to `u32` for a shader uniform, erroring on overflow
103/// instead of silently wrapping.
104///
105/// `context` should read naturally as `"<context>: <name> <value> exceeds …"`,
106/// e.g. `dim_u32("gemm", "m", m)` or `dim_u32("batched_gemm", "batch_count",
107/// batch_count)`.
108fn dim_u32(context: &str, name: &str, value: usize) -> BackendResult<u32> {
109    u32::try_from(value).map_err(|_| {
110        BackendError::InvalidArgument(format!("{context}: {name} {value} exceeds u32 range"))
111    })
112}
113
114/// The compute-dispatch limits this backend plans against.
115///
116/// `WebGpuDevice::new_async` (`device.rs`) requests `required_limits:
117/// adapter.limits()` (not `wgpu::Limits::default()`), so the *device* itself
118/// may grant more than the WebGPU-guaranteed baseline — e.g. Apple Silicon
119/// adapters typically report up to 1024 invocations per workgroup, well
120/// above the 256-invocation / 16×16-tile / 65 535-workgroups-per-axis
121/// portable floor.
122///
123/// This function nonetheless still deliberately plans against the
124/// conservative [`Limits::portable_default`] rather than those real (often
125/// higher) adapter limits, for two independent reasons:
126///
127/// 1. Every GEMM / batched-GEMM / FP16-GEMM call site passes `preferred_tile
128///    = 16` into [`planner::plan_workgroup_square`], which treats that value
129///    as a *ceiling* — feeding in a more permissive `Limits` cannot grow the
130///    tile past 16 without also raising `preferred_tile` at the call site,
131///    which needs a larger, register-blocked kernel to stay efficient (a
132///    separate perf pass; "16×16, the portable max" was the explicit brief
133///    for this change).
134/// 2. The only other limit this module consults is `max_workgroups_per_dim`
135///    (via [`planner::plan_dispatch_1d`] / `plan_dispatch_2d`), and
136///    under-using a higher real value is safe by construction: it can only
137///    make an extremely large 1-D dispatch (tens of millions of elements and
138///    up) fold into, or get rejected as exceeding, a 2-D grid slightly
139///    sooner than the hardware strictly requires — never accept a dispatch
140///    size the device cannot actually run.
141///
142/// A future pass that raises `preferred_tile` for adapters with more
143/// headroom should replace this with a real `dev.limits()`-derived
144/// `planner::Limits` (see `WebGpuDevice::limits`) — the pipeline-cache keys
145/// already encode `tile_size` (`"gemm:{tile_size}"` etc.), so a per-adapter
146/// tile is safe to cache once that lands.
147fn gpu_limits() -> Limits {
148    Limits::portable_default()
149}
150
151// ─── Backend struct ──────────────────────────────────────────────────────────
152
153/// Cross-platform GPU compute backend backed by `wgpu`.
154///
155/// # Lifecycle
156///
157/// 1. `WebGpuBackend::new()` — create an uninitialised backend.
158/// 2. `init()` — select the best available adapter and create the device.
159/// 3. Use `alloc`, `copy_htod`, compute ops, `copy_dtoh`, `free`.
160/// 4. `synchronize()` — wait for all pending GPU work to finish.
161#[derive(Debug)]
162pub struct WebGpuBackend {
163    device: Option<Arc<WebGpuDevice>>,
164    memory: Option<Arc<WebGpuMemoryManager>>,
165    initialized: bool,
166    /// Cache of compiled compute pipelines (bundled with their group-0
167    /// bind-group layout) keyed by a stable `(op, tile/size)` string.  WGSL
168    /// front-end parsing plus backend-ISA compilation is heavyweight and
169    /// depends only on the key, so every hot-path compute op reuses its
170    /// pipeline instead of rebuilding one per invocation.  See `cache.rs`
171    /// (`WebGpuBackend::cached_pipeline`) for the implementation.
172    pipeline_cache: Mutex<HashMap<String, CachedPipeline>>,
173    /// Cache of bind groups (each backed by its own dedicated, reused
174    /// uniform buffer) keyed by `(pipeline, operand handles)`, so a call
175    /// with the same operand buffers as a recent call — the common case in a
176    /// training/inference loop — reuses both instead of allocating a fresh
177    /// uniform buffer and bind group every single dispatch.  See `cache.rs`
178    /// (`WebGpuBackend::cached_bind_group`) for the implementation and its
179    /// safety argument.
180    bind_group_cache: Mutex<BindGroupCache>,
181}
182
183impl WebGpuBackend {
184    /// Create a new, uninitialised WebGPU backend.
185    pub fn new() -> Self {
186        Self {
187            device: None,
188            memory: None,
189            initialized: false,
190            pipeline_cache: Mutex::new(HashMap::new()),
191            bind_group_cache: Mutex::new(BindGroupCache::new()),
192        }
193    }
194
195    /// Return an error if the backend is not yet initialised.
196    fn check_init(&self) -> BackendResult<()> {
197        if self.initialized {
198            Ok(())
199        } else {
200            Err(BackendError::NotInitialized)
201        }
202    }
203
204    /// Convenience accessor: get the memory manager or return `NotInitialized`.
205    fn memory(&self) -> BackendResult<&Arc<WebGpuMemoryManager>> {
206        self.memory.as_ref().ok_or(BackendError::NotInitialized)
207    }
208
209    /// Convenience accessor: get the device or return `NotInitialized`.
210    fn device(&self) -> BackendResult<&Arc<WebGpuDevice>> {
211        self.device.as_ref().ok_or(BackendError::NotInitialized)
212    }
213
214    /// Whether the initialised device enabled the `SHADER_F16` feature, i.e.
215    /// whether [`gemm_f16`](Self::gemm_f16) can run instead of returning
216    /// [`BackendError::Unsupported`].  Returns `false` (never errors) before
217    /// `init()` — callers that want to skip f16-only tests on an
218    /// uninitialised or non-f16 backend can check this directly.
219    #[must_use]
220    pub fn supports_f16(&self) -> bool {
221        self.device.as_ref().is_some_and(|d| d.supports_f16)
222    }
223
224    /// Multi-dimensional reduce along a single axis.
225    ///
226    /// The tensor is logically reshaped to `[outer, dk, inner]`:
227    /// * `outer` = product of dimensions before the reduce axis,
228    /// * `dk`    = the reduce axis length,
229    /// * `inner` = product of dimensions after the reduce axis.
230    ///
231    /// One workgroup of 256 threads is dispatched per `(o, j)` output slot.
232    /// To stay within WebGPU's 65 535-per-axis dispatch limit a 2-D grid is
233    /// used and the workgroup decodes its linear slot internally.
234    ///
235    /// `Mean` is handled inside the shader (divide by `dk`); the host does
236    /// not need a post-pass.
237    fn reduce_nd(
238        &self,
239        op: ReduceOp,
240        input_ptr: u64,
241        output_ptr: u64,
242        shape: &[usize],
243        axis: usize,
244    ) -> BackendResult<()> {
245        // Caller (`reduce`) already validated `shape.is_empty()` and
246        // `axis < shape.len()`; assert in debug to catch regressions but
247        // recompute defensively in release as well.
248        debug_assert!(!shape.is_empty());
249        debug_assert!(axis < shape.len());
250
251        // Output shape = shape with `axis` removed; length = outer * inner.
252        let outer: usize = shape[..axis].iter().product();
253        let dk: usize = shape[axis];
254        let inner: usize = shape[axis + 1..].iter().product();
255
256        // Empty tensor — nothing to do.
257        if outer == 0 || dk == 0 || inner == 0 {
258            return Ok(());
259        }
260
261        let total = outer.checked_mul(inner).ok_or_else(|| {
262            BackendError::InvalidArgument("reduce: outer * inner overflows usize".into())
263        })?;
264        let in_elems = outer
265            .checked_mul(dk)
266            .and_then(|v| v.checked_mul(inner))
267            .ok_or_else(|| {
268                BackendError::InvalidArgument("reduce: outer * dk * inner overflows usize".into())
269            })?;
270
271        // Strides in elements: row-major (C order) layout.
272        let inner_stride: usize = 1;
273        let dk_stride: usize = inner;
274        let outer_stride: usize = dk
275            .checked_mul(inner)
276            .ok_or_else(|| BackendError::InvalidArgument("reduce: dk * inner overflows".into()))?;
277
278        // Plan the dispatch grid: one workgroup per output slot, folded into a
279        // 2-D grid if `total` would otherwise exceed the per-axis workgroup
280        // cap (see [`planner::plan_dispatch_1d`]).  `grid_x` is threaded
281        // through the `ReduceNdParams` uniform so the shader can decode
282        // `wgid.y * grid_x + wgid.x` back to a linear slot.
283        let limits = gpu_limits();
284        let (grid, grid_x) = planner::plan_dispatch_1d(&limits, total as u64, 1)
285            .map_err(BackendError::InvalidArgument)?;
286
287        let dev = self.device()?;
288        let mem = self.memory()?;
289        let op_str = map_reduce_op(op);
290        let pipeline_key = format!("reduce_nd:{op_str}");
291
292        let cached = self.cached_pipeline(&pipeline_key, "oxicuda-reduce-nd", || {
293            shader::reduction_nd_wgsl(op_str)
294        })?;
295
296        // Build the uniform buffer: 8 × u32 = 32 bytes (16-byte aligned).
297        let mut params_bytes = [0u8; 32];
298        let outer_u32: u32 = outer
299            .try_into()
300            .map_err(|_| BackendError::InvalidArgument("reduce: outer exceeds u32 range".into()))?;
301        let dk_u32: u32 = dk
302            .try_into()
303            .map_err(|_| BackendError::InvalidArgument("reduce: dk exceeds u32 range".into()))?;
304        let inner_u32: u32 = inner
305            .try_into()
306            .map_err(|_| BackendError::InvalidArgument("reduce: inner exceeds u32 range".into()))?;
307        let outer_stride_u32: u32 = outer_stride.try_into().map_err(|_| {
308            BackendError::InvalidArgument("reduce: outer_stride exceeds u32 range".into())
309        })?;
310        let dk_stride_u32: u32 = dk_stride.try_into().map_err(|_| {
311            BackendError::InvalidArgument("reduce: dk_stride exceeds u32 range".into())
312        })?;
313        let inner_stride_u32: u32 = inner_stride.try_into().map_err(|_| {
314            BackendError::InvalidArgument("reduce: inner_stride exceeds u32 range".into())
315        })?;
316        params_bytes[0..4].copy_from_slice(&outer_u32.to_le_bytes());
317        params_bytes[4..8].copy_from_slice(&dk_u32.to_le_bytes());
318        params_bytes[8..12].copy_from_slice(&inner_u32.to_le_bytes());
319        params_bytes[12..16].copy_from_slice(&outer_stride_u32.to_le_bytes());
320        params_bytes[16..20].copy_from_slice(&dk_stride_u32.to_le_bytes());
321        params_bytes[20..24].copy_from_slice(&inner_stride_u32.to_le_bytes());
322        params_bytes[24..28].copy_from_slice(&grid_x.to_le_bytes());
323        // bytes 28..32 are zero padding.
324
325        // The shader trusts `shape`/`axis` to describe the buffers it is
326        // bound to; an undersized buffer would otherwise silently drop
327        // writes (WGSL robust access) or read stale/foreign data rather than
328        // error.  `in_elems`/`total` are exactly the element counts the
329        // kernel indexes into `input`/`output`.
330        let need_in = (in_elems as u64) * 4;
331        let need_out = (total as u64) * 4;
332        let bind_group = self.cached_bind_group(
333            dev,
334            mem,
335            &cached.bind_group_layout,
336            &pipeline_key,
337            &[input_ptr, output_ptr],
338            &[need_in, need_out],
339            &params_bytes,
340            "oxicuda-reduce-nd",
341        )?;
342
343        let mut encoder = dev
344            .device
345            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
346                label: Some("oxicuda-reduce-nd"),
347            });
348        {
349            let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
350                label: Some("oxicuda-reduce-nd"),
351                timestamp_writes: None,
352            });
353            pass.set_pipeline(&cached.pipeline);
354            pass.set_bind_group(0, &bind_group, &[]);
355            pass.dispatch_workgroups(grid.x, grid.y, grid.z);
356        }
357
358        dev.queue.submit(std::iter::once(encoder.finish()));
359        // No per-op poll: wgpu executes queue submissions in FIFO order, so a
360        // later dispatch reading this op's output, or a host readback via
361        // `WebGpuMemoryManager::copy_from_device` / `synchronize()`, is
362        // correctly ordered without a host-side wait here. Polling after
363        // every submit was a full CPU/GPU pipeline stall on every op (see
364        // "wgpu blocks on device.poll(wait_indefinitely()) after EVERY
365        // submit" in the performance audit); `copy_from_device` now waits on
366        // its own precise `SubmissionIndex` and `synchronize()` still waits
367        // for all outstanding work.
368
369        Ok(())
370    }
371}
372
373impl WebGpuBackend {
374    /// FP16 GEMM: `C = alpha * op(A) * op(B) + beta * C` with half-precision
375    /// storage (accumulated in f32).
376    ///
377    /// This is an inherent method (not on `ComputeBackend`) because FP16
378    /// support is WebGPU-specific and requires the `f16` WGSL extension.
379    ///
380    /// Buffers pointed to by `a_ptr`, `b_ptr`, `c_ptr` must contain `f16`
381    /// elements (2 bytes each).  `lda` / `ldb` / `ldc` and `trans_a` /
382    /// `trans_b` follow exactly the same convention as
383    /// [`gemm`](ComputeBackend::gemm) — see [`shader::gemm_wgsl_f16`].
384    #[allow(clippy::too_many_arguments)]
385    pub fn gemm_f16(
386        &self,
387        trans_a: BackendTranspose,
388        trans_b: BackendTranspose,
389        m: usize,
390        n: usize,
391        k: usize,
392        alpha: f64,
393        a_ptr: u64,
394        lda: usize,
395        b_ptr: u64,
396        ldb: usize,
397        beta: f64,
398        c_ptr: u64,
399        ldc: usize,
400    ) -> BackendResult<()> {
401        self.check_init()?;
402        if m == 0 || n == 0 || k == 0 {
403            return Ok(());
404        }
405
406        let dev = self.device()?;
407        let mem = self.memory()?;
408
409        // The FP16 GEMM shader declares `enable f16;`; naga rejects that module
410        // unless the device enabled the SHADER_F16 feature.  Fail loudly with a
411        // typed error instead of emitting an invalid module (which surfaces as a
412        // process-fatal uncaptured validation error).
413        if !dev.supports_f16 {
414            return Err(BackendError::Unsupported(
415                "f16 GEMM requires the SHADER_F16 device feature, \
416                 which this adapter does not support"
417                    .into(),
418            ));
419        }
420
421        let trans_a_flag: u32 = u32::from(trans_a != BackendTranspose::NoTrans);
422        let trans_b_flag: u32 = u32::from(trans_b != BackendTranspose::NoTrans);
423
424        let (expected_lda, expected_ldb, expected_ldc) = packed_gemm_lds(trans_a, trans_b, m, n, k);
425        if lda < expected_lda || ldb < expected_ldb || ldc < expected_ldc {
426            return Err(BackendError::InvalidArgument(
427                "gemm_f16: leading dimension smaller than matrix extent".into(),
428            ));
429        }
430        let m_u32 = dim_u32("gemm_f16", "m", m)?;
431        let n_u32 = dim_u32("gemm_f16", "n", n)?;
432        let k_u32 = dim_u32("gemm_f16", "k", k)?;
433        let lda_u32 = dim_u32("gemm_f16", "lda", lda)?;
434        let ldb_u32 = dim_u32("gemm_f16", "ldb", ldb)?;
435        let ldc_u32 = dim_u32("gemm_f16", "ldc", ldc)?;
436
437        let limits = gpu_limits();
438        let tile = planner::plan_workgroup_square(&limits, 16);
439        let tile_size = tile.x;
440        // Fail fast on a dispatch grid that would exceed the per-axis
441        // workgroup cap, before creating any pipeline/buffer/bind-group GPU
442        // state for a dispatch that could never legally run.
443        let grid = planner::plan_dispatch_2d(&limits, m_u32, n_u32, tile, 1)
444            .map_err(BackendError::InvalidArgument)?;
445        let pipeline_key = format!("gemm_f16:{tile_size}");
446        let cached = self.cached_pipeline(&pipeline_key, "oxicuda-gemm-f16", || {
447            shader::gemm_wgsl_f16(tile_size)
448        })?;
449
450        // Build uniform buffer for GemmParams { m, n, k, alpha, beta, trans_a,
451        // trans_b, lda, ldb, ldc, _pad0, _pad1 } — 12 × 4 = 48 bytes, mirroring
452        // the f32 `GemmParams` layout in `shader::gemm_wgsl`.
453        let mut params_bytes = [0u8; 48];
454        params_bytes[0..4].copy_from_slice(&m_u32.to_le_bytes());
455        params_bytes[4..8].copy_from_slice(&n_u32.to_le_bytes());
456        params_bytes[8..12].copy_from_slice(&k_u32.to_le_bytes());
457        params_bytes[12..16].copy_from_slice(&(alpha as f32).to_le_bytes());
458        params_bytes[16..20].copy_from_slice(&(beta as f32).to_le_bytes());
459        params_bytes[20..24].copy_from_slice(&trans_a_flag.to_le_bytes());
460        params_bytes[24..28].copy_from_slice(&trans_b_flag.to_le_bytes());
461        params_bytes[28..32].copy_from_slice(&lda_u32.to_le_bytes());
462        params_bytes[32..36].copy_from_slice(&ldb_u32.to_le_bytes());
463        params_bytes[36..40].copy_from_slice(&ldc_u32.to_le_bytes());
464        // bytes 40..48 are zero padding.
465
466        let bind_group = self.cached_bind_group(
467            dev,
468            mem,
469            &cached.bind_group_layout,
470            &pipeline_key,
471            &[a_ptr, b_ptr, c_ptr],
472            &[],
473            &params_bytes,
474            "oxicuda-gemm-f16",
475        )?;
476
477        let mut encoder = dev
478            .device
479            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
480                label: Some("oxicuda-gemm-f16"),
481            });
482
483        {
484            let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
485                label: Some("oxicuda-gemm-f16"),
486                timestamp_writes: None,
487            });
488            pass.set_pipeline(&cached.pipeline);
489            pass.set_bind_group(0, &bind_group, &[]);
490            pass.dispatch_workgroups(grid.x, grid.y, grid.z);
491        }
492
493        dev.queue.submit(std::iter::once(encoder.finish()));
494        // No per-op poll: wgpu executes queue submissions in FIFO order, so a
495        // later dispatch reading this op's output, or a host readback via
496        // `WebGpuMemoryManager::copy_from_device` / `synchronize()`, is
497        // correctly ordered without a host-side wait here. Polling after
498        // every submit was a full CPU/GPU pipeline stall on every op (see
499        // "wgpu blocks on device.poll(wait_indefinitely()) after EVERY
500        // submit" in the performance audit); `copy_from_device` now waits on
501        // its own precise `SubmissionIndex` and `synchronize()` still waits
502        // for all outstanding work.
503
504        Ok(())
505    }
506}
507
508impl Default for WebGpuBackend {
509    fn default() -> Self {
510        Self::new()
511    }
512}
513
514// ─── ComputeBackend impl ─────────────────────────────────────────────────────
515
516impl ComputeBackend for WebGpuBackend {
517    fn name(&self) -> &str {
518        "webgpu"
519    }
520
521    fn init(&mut self) -> BackendResult<()> {
522        if self.initialized {
523            return Ok(());
524        }
525
526        match WebGpuDevice::new() {
527            Ok(dev) => {
528                let dev = Arc::new(dev);
529                tracing::info!("WebGPU backend initialised on: {}", dev.adapter_name);
530                let memory = WebGpuMemoryManager::new(Arc::clone(&dev));
531                self.device = Some(dev);
532                self.memory = Some(Arc::new(memory));
533                self.initialized = true;
534                Ok(())
535            }
536            Err(e) => Err(BackendError::from(e)),
537        }
538    }
539
540    fn is_initialized(&self) -> bool {
541        self.initialized
542    }
543
544    // ── Compute operations ────────────────────────────────────────────────────
545
546    fn gemm(
547        &self,
548        trans_a: BackendTranspose,
549        trans_b: BackendTranspose,
550        m: usize,
551        n: usize,
552        k: usize,
553        alpha: f64,
554        a_ptr: u64,
555        lda: usize,
556        b_ptr: u64,
557        ldb: usize,
558        beta: f64,
559        c_ptr: u64,
560        ldc: usize,
561    ) -> BackendResult<()> {
562        self.check_init()?;
563        // Zero-dimension matrices are trivially done.
564        if m == 0 || n == 0 || k == 0 {
565            return Ok(());
566        }
567
568        // The WGSL tiled GEMM kernel handles every NN / NT / TN / TT
569        // combination at runtime via the `trans_a` / `trans_b` uniforms.
570        // `ConjTrans` collapses to `Trans` because the f32 buffers are real.
571        let trans_a_flag: u32 = u32::from(trans_a != BackendTranspose::NoTrans);
572        let trans_b_flag: u32 = u32::from(trans_b != BackendTranspose::NoTrans);
573
574        let dev = self.device()?;
575        let mem = self.memory()?;
576
577        // Mirror the lda/ldb/ldc validation onto every other dimension that
578        // feeds the shader uniform: a value above `u32::MAX` must be a clean
579        // typed error, never a silent wraparound into a small (wrong) count.
580        let m_u32 = dim_u32("gemm", "m", m)?;
581        let n_u32 = dim_u32("gemm", "n", n)?;
582        let k_u32 = dim_u32("gemm", "k", k)?;
583
584        let limits = gpu_limits();
585        let tile = planner::plan_workgroup_square(&limits, 16);
586        let tile_size = tile.x;
587        // Fail fast on a dispatch grid that would exceed the per-axis
588        // workgroup cap, before creating any pipeline/buffer/bind-group GPU
589        // state for a dispatch that could never legally run.
590        let grid = planner::plan_dispatch_2d(&limits, m_u32, n_u32, tile, 1)
591            .map_err(BackendError::InvalidArgument)?;
592        let pipeline_key = format!("gemm:{tile_size}");
593        let cached = self.cached_pipeline(&pipeline_key, "oxicuda-gemm", || {
594            shader::gemm_wgsl(tile_size)
595        })?;
596
597        // The row-major WGSL kernel honours the leading dimensions carried in
598        // `GemmParams`; validate they are at least the packed extent (the same
599        // check the CPU reference backend performs) so a too-small stride is a
600        // clean error rather than an out-of-bounds read.
601        let (expected_lda, expected_ldb, expected_ldc) = packed_gemm_lds(trans_a, trans_b, m, n, k);
602        if lda < expected_lda || ldb < expected_ldb || ldc < expected_ldc {
603            return Err(BackendError::InvalidArgument(
604                "gemm: leading dimension smaller than matrix extent".into(),
605            ));
606        }
607        let lda_u32 = dim_u32("gemm", "lda", lda)?;
608        let ldb_u32 = dim_u32("gemm", "ldb", ldb)?;
609        let ldc_u32 = dim_u32("gemm", "ldc", ldc)?;
610
611        // Build uniform buffer for GemmParams { m, n, k, alpha, beta,
612        // trans_a, trans_b, lda, ldb, ldc, _pad } — 12 × 4 = 48 bytes.
613        let mut params_bytes = [0u8; 48];
614        params_bytes[0..4].copy_from_slice(&m_u32.to_le_bytes());
615        params_bytes[4..8].copy_from_slice(&n_u32.to_le_bytes());
616        params_bytes[8..12].copy_from_slice(&k_u32.to_le_bytes());
617        params_bytes[12..16].copy_from_slice(&(alpha as f32).to_le_bytes());
618        params_bytes[16..20].copy_from_slice(&(beta as f32).to_le_bytes());
619        params_bytes[20..24].copy_from_slice(&trans_a_flag.to_le_bytes());
620        params_bytes[24..28].copy_from_slice(&trans_b_flag.to_le_bytes());
621        params_bytes[28..32].copy_from_slice(&lda_u32.to_le_bytes());
622        params_bytes[32..36].copy_from_slice(&ldb_u32.to_le_bytes());
623        params_bytes[36..40].copy_from_slice(&ldc_u32.to_le_bytes());
624        // bytes 40..48 are zero padding.
625
626        let bind_group = self.cached_bind_group(
627            dev,
628            mem,
629            &cached.bind_group_layout,
630            &pipeline_key,
631            &[a_ptr, b_ptr, c_ptr],
632            &[],
633            &params_bytes,
634            "oxicuda-gemm",
635        )?;
636
637        let mut encoder = dev
638            .device
639            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
640                label: Some("oxicuda-gemm"),
641            });
642
643        {
644            let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
645                label: Some("oxicuda-gemm"),
646                timestamp_writes: None,
647            });
648            pass.set_pipeline(&cached.pipeline);
649            pass.set_bind_group(0, &bind_group, &[]);
650            pass.dispatch_workgroups(grid.x, grid.y, grid.z);
651        }
652
653        dev.queue.submit(std::iter::once(encoder.finish()));
654        // No per-op poll: wgpu executes queue submissions in FIFO order, so a
655        // later dispatch reading this op's output, or a host readback via
656        // `WebGpuMemoryManager::copy_from_device` / `synchronize()`, is
657        // correctly ordered without a host-side wait here. Polling after
658        // every submit was a full CPU/GPU pipeline stall on every op (see
659        // "wgpu blocks on device.poll(wait_indefinitely()) after EVERY
660        // submit" in the performance audit); `copy_from_device` now waits on
661        // its own precise `SubmissionIndex` and `synchronize()` still waits
662        // for all outstanding work.
663
664        Ok(())
665    }
666
667    #[allow(clippy::too_many_arguments)]
668    fn batched_gemm(
669        &self,
670        trans_a: BackendTranspose,
671        trans_b: BackendTranspose,
672        m: usize,
673        n: usize,
674        k: usize,
675        alpha: f64,
676        a_ptr: u64,
677        lda: usize,
678        stride_a: usize,
679        b_ptr: u64,
680        ldb: usize,
681        stride_b: usize,
682        beta: f64,
683        c_ptr: u64,
684        ldc: usize,
685        stride_c: usize,
686        batch_count: usize,
687    ) -> BackendResult<()> {
688        self.check_init()?;
689
690        if batch_count == 0 || m == 0 || n == 0 || k == 0 {
691            return Ok(());
692        }
693
694        // The WGSL batched tiled GEMM kernel handles every NN / NT / TN / TT
695        // combination at runtime via the `trans_a` / `trans_b` uniforms.
696        // `ConjTrans` collapses to `Trans` because the f32 buffers are real.
697        let trans_a_flag: u32 = u32::from(trans_a != BackendTranspose::NoTrans);
698        let trans_b_flag: u32 = u32::from(trans_b != BackendTranspose::NoTrans);
699
700        let dev = self.device()?;
701        let mem = self.memory()?;
702
703        // Mirror the lda/ldb/ldc validation onto every other dimension that
704        // feeds the shader uniform or the dispatch grid.  Previously these
705        // were cast with a bare `as u32`: a `stride_*` above `u32::MAX` wrapped
706        // to a small value and the kernel silently read the wrong batch slice,
707        // and `batch_count` fed the Z dispatch dimension completely unchecked
708        // against the 65 535-per-axis limit (an oversized batch triggered a
709        // fatal wgpu validation error with no uncaptured-error handler
710        // installed).  `plan_dispatch_2d` below turns that overflow into a
711        // clean typed `Err` instead.
712        let m_u32 = dim_u32("batched_gemm", "m", m)?;
713        let n_u32 = dim_u32("batched_gemm", "n", n)?;
714        let k_u32 = dim_u32("batched_gemm", "k", k)?;
715        let batch_u32 = dim_u32("batched_gemm", "batch_count", batch_count)?;
716        let stride_a_u32 = dim_u32("batched_gemm", "stride_a", stride_a)?;
717        let stride_b_u32 = dim_u32("batched_gemm", "stride_b", stride_b)?;
718        let stride_c_u32 = dim_u32("batched_gemm", "stride_c", stride_c)?;
719
720        let limits = gpu_limits();
721        let tile = planner::plan_workgroup_square(&limits, 16);
722        let tile_size = tile.x;
723        // Fail fast — including the `batch_count > 65 535` case this
724        // validation exists for — before creating any pipeline/buffer/
725        // bind-group GPU state for a dispatch that could never legally run.
726        let grid = planner::plan_dispatch_2d(&limits, m_u32, n_u32, tile, batch_u32)
727            .map_err(BackendError::InvalidArgument)?;
728        let pipeline_key = format!("batched_gemm:{tile_size}");
729        let cached = self.cached_pipeline(&pipeline_key, "oxicuda-batched-gemm", || {
730            shader::batched_gemm_wgsl(tile_size)
731        })?;
732
733        // Validate leading dimensions against the packed extents (per-batch row
734        // strides) before threading them into the uniform.
735        let (expected_lda, expected_ldb, expected_ldc) = packed_gemm_lds(trans_a, trans_b, m, n, k);
736        if lda < expected_lda || ldb < expected_ldb || ldc < expected_ldc {
737            return Err(BackendError::InvalidArgument(
738                "batched_gemm: leading dimension smaller than matrix extent".into(),
739            ));
740        }
741        let lda_u32 = dim_u32("batched_gemm", "lda", lda)?;
742        let ldb_u32 = dim_u32("batched_gemm", "ldb", ldb)?;
743        let ldc_u32 = dim_u32("batched_gemm", "ldc", ldc)?;
744
745        // BatchedGemmParams: m, n, k, alpha, beta, batch_count, stride_a,
746        // stride_b, stride_c, trans_a, trans_b, lda, ldb, ldc — 14 × 4 = 56
747        // bytes.  Uniform buffers need 16-byte alignment, so 56 rounds up to 64.
748        let mut params_bytes = [0u8; 64];
749        params_bytes[0..4].copy_from_slice(&m_u32.to_le_bytes());
750        params_bytes[4..8].copy_from_slice(&n_u32.to_le_bytes());
751        params_bytes[8..12].copy_from_slice(&k_u32.to_le_bytes());
752        params_bytes[12..16].copy_from_slice(&(alpha as f32).to_le_bytes());
753        params_bytes[16..20].copy_from_slice(&(beta as f32).to_le_bytes());
754        params_bytes[20..24].copy_from_slice(&batch_u32.to_le_bytes());
755        params_bytes[24..28].copy_from_slice(&stride_a_u32.to_le_bytes());
756        params_bytes[28..32].copy_from_slice(&stride_b_u32.to_le_bytes());
757        params_bytes[32..36].copy_from_slice(&stride_c_u32.to_le_bytes());
758        params_bytes[36..40].copy_from_slice(&trans_a_flag.to_le_bytes());
759        params_bytes[40..44].copy_from_slice(&trans_b_flag.to_le_bytes());
760        params_bytes[44..48].copy_from_slice(&lda_u32.to_le_bytes());
761        params_bytes[48..52].copy_from_slice(&ldb_u32.to_le_bytes());
762        params_bytes[52..56].copy_from_slice(&ldc_u32.to_le_bytes());
763        // bytes 56..64 are padding zeros
764
765        let bind_group = self.cached_bind_group(
766            dev,
767            mem,
768            &cached.bind_group_layout,
769            &pipeline_key,
770            &[a_ptr, b_ptr, c_ptr],
771            &[],
772            &params_bytes,
773            "oxicuda-batched-gemm",
774        )?;
775
776        let mut encoder = dev
777            .device
778            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
779                label: Some("oxicuda-batched-gemm"),
780            });
781
782        {
783            let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
784                label: Some("oxicuda-batched-gemm"),
785                timestamp_writes: None,
786            });
787            pass.set_pipeline(&cached.pipeline);
788            pass.set_bind_group(0, &bind_group, &[]);
789            pass.dispatch_workgroups(grid.x, grid.y, grid.z);
790        }
791
792        dev.queue.submit(std::iter::once(encoder.finish()));
793        // No per-op poll: wgpu executes queue submissions in FIFO order, so a
794        // later dispatch reading this op's output, or a host readback via
795        // `WebGpuMemoryManager::copy_from_device` / `synchronize()`, is
796        // correctly ordered without a host-side wait here. Polling after
797        // every submit was a full CPU/GPU pipeline stall on every op (see
798        // "wgpu blocks on device.poll(wait_indefinitely()) after EVERY
799        // submit" in the performance audit); `copy_from_device` now waits on
800        // its own precise `SubmissionIndex` and `synchronize()` still waits
801        // for all outstanding work.
802
803        Ok(())
804    }
805
806    fn conv2d_forward(
807        &self,
808        input_ptr: u64,
809        input_shape: &[usize],
810        filter_ptr: u64,
811        filter_shape: &[usize],
812        output_ptr: u64,
813        output_shape: &[usize],
814        stride: &[usize],
815        padding: &[usize],
816    ) -> BackendResult<()> {
817        self.check_init()?;
818
819        if input_shape.len() != 4 {
820            return Err(BackendError::InvalidArgument(
821                "input_shape must have 4 elements (NCHW)".into(),
822            ));
823        }
824        if filter_shape.len() != 4 {
825            return Err(BackendError::InvalidArgument(
826                "filter_shape must have 4 elements (KCFHFW)".into(),
827            ));
828        }
829        if output_shape.len() != 4 {
830            return Err(BackendError::InvalidArgument(
831                "output_shape must have 4 elements (NKOhOw)".into(),
832            ));
833        }
834        if stride.len() != 2 {
835            return Err(BackendError::InvalidArgument(
836                "stride must have 2 elements [sh, sw]".into(),
837            ));
838        }
839        if padding.len() != 2 {
840            return Err(BackendError::InvalidArgument(
841                "padding must have 2 elements [ph, pw]".into(),
842            ));
843        }
844
845        let batch = input_shape[0];
846        let c_in = input_shape[1];
847        let h_in = input_shape[2];
848        let w_in = input_shape[3];
849        let k_out = filter_shape[0];
850        let fh = filter_shape[2];
851        let fw = filter_shape[3];
852        let oh = output_shape[2];
853        let ow = output_shape[3];
854        let sh = stride[0];
855        let sw = stride[1];
856        let ph = padding[0];
857        let pw = padding[1];
858
859        let in_elems: usize = input_shape.iter().product();
860        let f_elems: usize = filter_shape.iter().product();
861        let o_elems: usize = output_shape.iter().product();
862
863        // Prefer the GPU dispatch path; fall back to the CPU reference only
864        // for configurations `shader::conv2d_wgsl`'s fixed 2-D dispatch
865        // cannot address (see `conv2d_gpu_dispatch_grid`).
866        if let Some((wg_x, wg_y)) = conv2d_gpu_dispatch_grid(batch, k_out, oh, ow) {
867            if let Some(dims) = conv2d_u32_dims(
868                batch, c_in, h_in, w_in, k_out, fh, fw, oh, ow, sh, sw, ph, pw,
869            ) {
870                return self.conv2d_forward_gpu(
871                    input_ptr, filter_ptr, output_ptr, dims, in_elems, f_elems, o_elems, wg_x, wg_y,
872                );
873            }
874        }
875
876        // CPU fallback: download input + filter, compute, upload output.
877        let mem = self.memory()?;
878        let mut in_bytes = vec![0u8; in_elems * 4];
879        let mut f_bytes = vec![0u8; f_elems * 4];
880        mem.copy_from_device(&mut in_bytes, input_ptr)
881            .map_err(BackendError::from)?;
882        mem.copy_from_device(&mut f_bytes, filter_ptr)
883            .map_err(BackendError::from)?;
884
885        let in_f32 = bytes_to_f32_vec(&in_bytes);
886        let f_f32 = bytes_to_f32_vec(&f_bytes);
887        let out_f32 = conv2d_cpu_reference(
888            &in_f32, &f_f32, batch, c_in, h_in, w_in, k_out, fh, fw, oh, ow, sh, sw, ph, pw,
889        );
890
891        let out_bytes = f32_slice_to_bytes(&out_f32);
892        mem.copy_to_device(output_ptr, &out_bytes)
893            .map_err(BackendError::from)?;
894
895        Ok(())
896    }
897
898    fn attention(
899        &self,
900        q_ptr: u64,
901        k_ptr: u64,
902        v_ptr: u64,
903        o_ptr: u64,
904        batch: usize,
905        heads: usize,
906        seq_q: usize,
907        seq_kv: usize,
908        head_dim: usize,
909        scale: f64,
910        causal: bool,
911    ) -> BackendResult<()> {
912        self.check_init()?;
913
914        if seq_q == 0 || seq_kv == 0 || head_dim == 0 {
915            return Err(BackendError::InvalidArgument(
916                "seq_q, seq_kv, and head_dim must all be > 0".into(),
917            ));
918        }
919        if scale <= 0.0 || !scale.is_finite() {
920            return Err(BackendError::InvalidArgument(format!(
921                "scale must be a positive finite number, got {scale}"
922            )));
923        }
924
925        let batch_heads = batch * heads;
926        let q_elems = batch_heads * seq_q * head_dim;
927        let kv_elems = batch_heads * seq_kv * head_dim;
928        let o_elems = q_elems;
929        let scale_f32 = scale as f32;
930
931        // Prefer the GPU dispatch path; fall back to the CPU reference only
932        // for configurations `shader::attention_wgsl`'s fixed 1-D dispatch
933        // cannot address, or whose shape overflows u32 (the shader bakes
934        // every dimension as a literal).
935        if let (Some(wg), Some(bh_u32), Some(seq_q_u32), Some(seq_kv_u32), Some(head_dim_u32)) = (
936            attention_gpu_dispatch_grid(batch_heads, seq_q),
937            u32::try_from(batch_heads).ok(),
938            u32::try_from(seq_q).ok(),
939            u32::try_from(seq_kv).ok(),
940            u32::try_from(head_dim).ok(),
941        ) {
942            return self.attention_gpu(
943                q_ptr,
944                k_ptr,
945                v_ptr,
946                o_ptr,
947                bh_u32,
948                seq_q_u32,
949                seq_kv_u32,
950                head_dim_u32,
951                scale_f32,
952                causal,
953                q_elems,
954                kv_elems,
955                o_elems,
956                wg,
957            );
958        }
959
960        // CPU fallback: download Q, K, V, compute attention, upload O.
961        let mem = self.memory()?;
962        let mut q_bytes = vec![0u8; q_elems * 4];
963        let mut k_bytes = vec![0u8; kv_elems * 4];
964        let mut v_bytes = vec![0u8; kv_elems * 4];
965
966        mem.copy_from_device(&mut q_bytes, q_ptr)
967            .map_err(BackendError::from)?;
968        mem.copy_from_device(&mut k_bytes, k_ptr)
969            .map_err(BackendError::from)?;
970        mem.copy_from_device(&mut v_bytes, v_ptr)
971            .map_err(BackendError::from)?;
972
973        let q_f32 = bytes_to_f32_vec(&q_bytes);
974        let k_f32 = bytes_to_f32_vec(&k_bytes);
975        let v_f32 = bytes_to_f32_vec(&v_bytes);
976        let o_f32 = attention_cpu_reference(
977            &q_f32,
978            &k_f32,
979            &v_f32,
980            batch_heads,
981            seq_q,
982            seq_kv,
983            head_dim,
984            scale_f32,
985            causal,
986        );
987
988        let o_bytes = f32_slice_to_bytes(&o_f32);
989        mem.copy_to_device(o_ptr, &o_bytes)
990            .map_err(BackendError::from)?;
991
992        Ok(())
993    }
994
995    fn reduce(
996        &self,
997        op: ReduceOp,
998        input_ptr: u64,
999        output_ptr: u64,
1000        shape: &[usize],
1001        axis: usize,
1002    ) -> BackendResult<()> {
1003        self.check_init()?;
1004
1005        if shape.is_empty() {
1006            return Err(BackendError::InvalidArgument(
1007                "shape must not be empty".into(),
1008            ));
1009        }
1010        if axis >= shape.len() {
1011            return Err(BackendError::InvalidArgument(format!(
1012                "axis {axis} is out of bounds for shape of length {}",
1013                shape.len()
1014            )));
1015        }
1016
1017        // 1-D shapes (or any shape that reduces to a single scalar) take the
1018        // optimised two-pass scalar path.  Higher-rank shapes go through the
1019        // batched N-D shader below.
1020        if shape.len() != 1 {
1021            return self.reduce_nd(op, input_ptr, output_ptr, shape, axis);
1022        }
1023
1024        let n_elements = shape[0];
1025        if n_elements == 0 {
1026            return Ok(());
1027        }
1028
1029        let dev = self.device()?;
1030        let mem = self.memory()?;
1031        let op_str = map_reduce_op(op);
1032
1033        // ── Pass 1: per-workgroup reduction ─────────────────────────────────
1034        // `reduction_wgsl`'s `@workgroup_size(256)` kernel decodes only
1035        // `global_invocation_id.x` (no 2-D dispatch fold, unlike
1036        // `reduction_nd_wgsl`), so `wg_count` must itself fit in one dispatch
1037        // axis.  `plan_dispatch_1d` both computes it and turns an
1038        // over-capacity `n_elements` into a clean typed error instead of an
1039        // invalid `dispatch_workgroups` call.
1040        let limits = gpu_limits();
1041        let (wg_grid, _) = planner::plan_dispatch_1d(&limits, n_elements as u64, 256)
1042            .map_err(BackendError::InvalidArgument)?;
1043        if wg_grid.y != 1 {
1044            return Err(BackendError::InvalidArgument(format!(
1045                "reduce: {n_elements} elements need {} workgroups, which exceeds the \
1046                 single-axis dispatch capacity of this 1-D reduction kernel",
1047                wg_grid.x as u64 * wg_grid.y as u64
1048            )));
1049        }
1050        let wg_count = wg_grid.x;
1051
1052        let pass1_cached = self.cached_pipeline(
1053            &format!("reduce_pass1:{op_str}"),
1054            "oxicuda-reduce-pass1",
1055            || shader::reduction_wgsl(op_str),
1056        )?;
1057
1058        // Partial-sums buffer (temporary).
1059        let partial_buf = dev.device.create_buffer(&wgpu::BufferDescriptor {
1060            label: Some("oxicuda-reduce-partial"),
1061            size: (wg_count as u64) * 4, // f32 per workgroup
1062            usage: wgpu::BufferUsages::STORAGE
1063                | wgpu::BufferUsages::COPY_SRC
1064                | wgpu::BufferUsages::COPY_DST,
1065            mapped_at_creation: false,
1066        });
1067
1068        // Uniform for ReduceParams { n: u32 }.
1069        let mut p1_params = [0u8; 4];
1070        p1_params[0..4].copy_from_slice(&(n_elements as u32).to_le_bytes());
1071        let p1_uniform = dev.device.create_buffer(&wgpu::BufferDescriptor {
1072            label: Some("oxicuda-reduce-p1-params"),
1073            size: 4,
1074            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1075            mapped_at_creation: false,
1076        });
1077        dev.queue.write_buffer(&p1_uniform, 0, &p1_params);
1078
1079        let bgl1 = &pass1_cached.bind_group_layout;
1080
1081        let bg1 = {
1082            let buffers = mem
1083                .lock_buffers()
1084                .map_err(|e| BackendError::DeviceError(e.to_string()))?;
1085            let in_info = buffers.get(&input_ptr).ok_or_else(|| {
1086                BackendError::InvalidArgument(format!("unknown handle {input_ptr}"))
1087            })?;
1088
1089            let need_in = (n_elements as u64) * 4;
1090            if in_info.size < need_in {
1091                return Err(BackendError::InvalidArgument(format!(
1092                    "reduce: input buffer holds {} bytes, need {need_in} for {n_elements} f32 elements",
1093                    in_info.size
1094                )));
1095            }
1096
1097            dev.device.create_bind_group(&wgpu::BindGroupDescriptor {
1098                label: Some("oxicuda-reduce-pass1"),
1099                layout: bgl1,
1100                entries: &[
1101                    wgpu::BindGroupEntry {
1102                        binding: 0,
1103                        resource: in_info.buffer.as_entire_binding(),
1104                    },
1105                    wgpu::BindGroupEntry {
1106                        binding: 1,
1107                        resource: partial_buf.as_entire_binding(),
1108                    },
1109                    wgpu::BindGroupEntry {
1110                        binding: 2,
1111                        resource: p1_uniform.as_entire_binding(),
1112                    },
1113                ],
1114            })
1115        };
1116
1117        // ── Pass 2: final reduction of partial sums ─────────────────────────
1118        let pass2_cached = self.cached_pipeline(
1119            &format!("reduce_pass2:{op_str}"),
1120            "oxicuda-reduce-pass2",
1121            || shader::reduction_final_wgsl(op_str),
1122        )?;
1123
1124        // FinalReduceParams { num_groups: u32 }.
1125        let mut p2_params = [0u8; 4];
1126        p2_params[0..4].copy_from_slice(&wg_count.to_le_bytes());
1127        let p2_uniform = dev.device.create_buffer(&wgpu::BufferDescriptor {
1128            label: Some("oxicuda-reduce-p2-params"),
1129            size: 4,
1130            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1131            mapped_at_creation: false,
1132        });
1133        dev.queue.write_buffer(&p2_uniform, 0, &p2_params);
1134
1135        let bgl2 = &pass2_cached.bind_group_layout;
1136
1137        let bg2 = {
1138            let buffers = mem
1139                .lock_buffers()
1140                .map_err(|e| BackendError::DeviceError(e.to_string()))?;
1141            let out_info = buffers.get(&output_ptr).ok_or_else(|| {
1142                BackendError::InvalidArgument(format!("unknown handle {output_ptr}"))
1143            })?;
1144
1145            // The scalar output slot is a single f32.
1146            if out_info.size < 4 {
1147                return Err(BackendError::InvalidArgument(format!(
1148                    "reduce: output buffer holds {} bytes, need 4 for the scalar result",
1149                    out_info.size
1150                )));
1151            }
1152
1153            dev.device.create_bind_group(&wgpu::BindGroupDescriptor {
1154                label: Some("oxicuda-reduce-pass2"),
1155                layout: bgl2,
1156                entries: &[
1157                    wgpu::BindGroupEntry {
1158                        binding: 0,
1159                        resource: partial_buf.as_entire_binding(),
1160                    },
1161                    wgpu::BindGroupEntry {
1162                        binding: 1,
1163                        resource: out_info.buffer.as_entire_binding(),
1164                    },
1165                    wgpu::BindGroupEntry {
1166                        binding: 2,
1167                        resource: p2_uniform.as_entire_binding(),
1168                    },
1169                ],
1170            })
1171        };
1172
1173        // ── Encode both passes into one command buffer ──────────────────────
1174        let mut encoder = dev
1175            .device
1176            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1177                label: Some("oxicuda-reduce"),
1178            });
1179
1180        {
1181            let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
1182                label: Some("oxicuda-reduce-pass1"),
1183                timestamp_writes: None,
1184            });
1185            pass.set_pipeline(&pass1_cached.pipeline);
1186            pass.set_bind_group(0, &bg1, &[]);
1187            pass.dispatch_workgroups(wg_count, 1, 1);
1188        }
1189        {
1190            let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
1191                label: Some("oxicuda-reduce-pass2"),
1192                timestamp_writes: None,
1193            });
1194            pass.set_pipeline(&pass2_cached.pipeline);
1195            pass.set_bind_group(0, &bg2, &[]);
1196            pass.dispatch_workgroups(1, 1, 1);
1197        }
1198
1199        dev.queue.submit(std::iter::once(encoder.finish()));
1200        // No per-op poll: wgpu executes queue submissions in FIFO order, so a
1201        // later dispatch reading this op's output, or a host readback via
1202        // `WebGpuMemoryManager::copy_from_device` / `synchronize()`, is
1203        // correctly ordered without a host-side wait here. Polling after
1204        // every submit was a full CPU/GPU pipeline stall on every op (see
1205        // "wgpu blocks on device.poll(wait_indefinitely()) after EVERY
1206        // submit" in the performance audit); `copy_from_device` now waits on
1207        // its own precise `SubmissionIndex` and `synchronize()` still waits
1208        // for all outstanding work.
1209
1210        // For "mean", divide the result by N on the host side.
1211        if op == ReduceOp::Mean && n_elements > 1 {
1212            let mut buf = [0u8; 4];
1213            mem.copy_from_device(&mut buf, output_ptr)
1214                .map_err(BackendError::from)?;
1215            let val = f32::from_le_bytes(buf);
1216            let mean = val / (n_elements as f32);
1217            mem.copy_to_device(output_ptr, &mean.to_le_bytes())
1218                .map_err(BackendError::from)?;
1219        }
1220
1221        Ok(())
1222    }
1223
1224    fn unary(&self, op: UnaryOp, input_ptr: u64, output_ptr: u64, n: usize) -> BackendResult<()> {
1225        self.check_init()?;
1226        if n == 0 {
1227            return Ok(());
1228        }
1229        // `elementwise_wgsl`'s bind group declares `input` (binding 0) as
1230        // `read` and `output` (binding 1) as `read_write`; if the two
1231        // handles are the same buffer, wgpu's usage-scope validation rejects
1232        // the dispatch outright ("conflicting usages: STORAGE_READ_ONLY vs
1233        // STORAGE_READ_WRITE"). Reject it here with a typed, attributable
1234        // error instead of letting it surface later — as a generic
1235        // `DeviceError` from an unrelated caller's `alloc`/`copy_*`/
1236        // `synchronize()`, whichever happens to be the next call that drains
1237        // the recorded uncaptured error — which is what happened before this
1238        // check existed.
1239        if input_ptr == output_ptr {
1240            return Err(BackendError::InvalidArgument(
1241                "unary: input_ptr and output_ptr must not alias (wgpu rejects binding the \
1242                 same buffer as both `read` and `read_write` within one dispatch); allocate \
1243                 a separate output buffer"
1244                    .into(),
1245            ));
1246        }
1247
1248        let dev = self.device()?;
1249        let mem = self.memory()?;
1250
1251        // `elementwise_wgsl`'s `@workgroup_size(256)` kernel decodes only
1252        // `global_invocation_id.x` (no 2-D dispatch fold), so `n` must map to
1253        // a workgroup count that fits a single dispatch axis.
1254        let (wg_grid, _) = planner::plan_dispatch_1d(&gpu_limits(), n as u64, 256)
1255            .map_err(BackendError::InvalidArgument)?;
1256        if wg_grid.y != 1 {
1257            return Err(BackendError::InvalidArgument(format!(
1258                "unary: {n} elements exceed the single-axis dispatch capacity of this kernel"
1259            )));
1260        }
1261
1262        let op_str = map_unary_op(op);
1263        let pipeline_key = format!("unary:{op_str}");
1264        let cached = self.cached_pipeline(&pipeline_key, "oxicuda-unary", || {
1265            shader::elementwise_wgsl(op_str)
1266        })?;
1267
1268        // `elementwise_wgsl` has no uniform binding at all — `n` is derived
1269        // in-shader via `arrayLength`, so `uniform_bytes` is empty.
1270        let bind_group = self.cached_bind_group(
1271            dev,
1272            mem,
1273            &cached.bind_group_layout,
1274            &pipeline_key,
1275            &[input_ptr, output_ptr],
1276            &[],
1277            &[],
1278            "oxicuda-unary",
1279        )?;
1280
1281        let mut encoder = dev
1282            .device
1283            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1284                label: Some("oxicuda-unary"),
1285            });
1286
1287        {
1288            let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
1289                label: Some("oxicuda-unary"),
1290                timestamp_writes: None,
1291            });
1292            pass.set_pipeline(&cached.pipeline);
1293            pass.set_bind_group(0, &bind_group, &[]);
1294            pass.dispatch_workgroups(wg_grid.x, 1, 1);
1295        }
1296
1297        dev.queue.submit(std::iter::once(encoder.finish()));
1298        // No per-op poll: wgpu executes queue submissions in FIFO order, so a
1299        // later dispatch reading this op's output, or a host readback via
1300        // `WebGpuMemoryManager::copy_from_device` / `synchronize()`, is
1301        // correctly ordered without a host-side wait here. Polling after
1302        // every submit was a full CPU/GPU pipeline stall on every op (see
1303        // "wgpu blocks on device.poll(wait_indefinitely()) after EVERY
1304        // submit" in the performance audit); `copy_from_device` now waits on
1305        // its own precise `SubmissionIndex` and `synchronize()` still waits
1306        // for all outstanding work.
1307
1308        Ok(())
1309    }
1310
1311    fn binary(
1312        &self,
1313        op: BinaryOp,
1314        a_ptr: u64,
1315        b_ptr: u64,
1316        output_ptr: u64,
1317        n: usize,
1318    ) -> BackendResult<()> {
1319        self.check_init()?;
1320        if n == 0 {
1321            return Ok(());
1322        }
1323        // Same usage-scope hazard as `unary` (see the comment there):
1324        // `binary_wgsl` binds `lhs`/`rhs` (0/1) `read` and `output` (2)
1325        // `read_write`. `a_ptr == b_ptr` (both inputs aliased to each other)
1326        // is fine — two `read` usages of the same buffer do not conflict —
1327        // only the output aliasing either input is rejected.
1328        if a_ptr == output_ptr || b_ptr == output_ptr {
1329            return Err(BackendError::InvalidArgument(
1330                "binary: a_ptr/b_ptr must not alias output_ptr (wgpu rejects binding the \
1331                 same buffer as both `read` and `read_write` within one dispatch); allocate \
1332                 a separate output buffer"
1333                    .into(),
1334            ));
1335        }
1336
1337        let dev = self.device()?;
1338        let mem = self.memory()?;
1339
1340        // Same single-axis dispatch constraint as `unary` (see comment there).
1341        let (wg_grid, _) = planner::plan_dispatch_1d(&gpu_limits(), n as u64, 256)
1342            .map_err(BackendError::InvalidArgument)?;
1343        if wg_grid.y != 1 {
1344            return Err(BackendError::InvalidArgument(format!(
1345                "binary: {n} elements exceed the single-axis dispatch capacity of this kernel"
1346            )));
1347        }
1348
1349        let op_str = map_binary_op(op);
1350        let pipeline_key = format!("binary:{op_str}");
1351        let cached = self.cached_pipeline(&pipeline_key, "oxicuda-binary", || {
1352            shader::binary_wgsl(op_str)
1353        })?;
1354
1355        // `binary_wgsl` has no uniform binding either (see `unary` above).
1356        let bind_group = self.cached_bind_group(
1357            dev,
1358            mem,
1359            &cached.bind_group_layout,
1360            &pipeline_key,
1361            &[a_ptr, b_ptr, output_ptr],
1362            &[],
1363            &[],
1364            "oxicuda-binary",
1365        )?;
1366
1367        let mut encoder = dev
1368            .device
1369            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1370                label: Some("oxicuda-binary"),
1371            });
1372
1373        {
1374            let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
1375                label: Some("oxicuda-binary"),
1376                timestamp_writes: None,
1377            });
1378            pass.set_pipeline(&cached.pipeline);
1379            pass.set_bind_group(0, &bind_group, &[]);
1380            pass.dispatch_workgroups(wg_grid.x, 1, 1);
1381        }
1382
1383        dev.queue.submit(std::iter::once(encoder.finish()));
1384        // No per-op poll: wgpu executes queue submissions in FIFO order, so a
1385        // later dispatch reading this op's output, or a host readback via
1386        // `WebGpuMemoryManager::copy_from_device` / `synchronize()`, is
1387        // correctly ordered without a host-side wait here. Polling after
1388        // every submit was a full CPU/GPU pipeline stall on every op (see
1389        // "wgpu blocks on device.poll(wait_indefinitely()) after EVERY
1390        // submit" in the performance audit); `copy_from_device` now waits on
1391        // its own precise `SubmissionIndex` and `synchronize()` still waits
1392        // for all outstanding work.
1393
1394        Ok(())
1395    }
1396
1397    // ── Synchronisation ───────────────────────────────────────────────────────
1398
1399    fn synchronize(&self) -> BackendResult<()> {
1400        self.check_init()?;
1401        if let Some(dev) = &self.device {
1402            // This is now the *only* completion signal for a caller that
1403            // issues pure compute dispatches (`gemm`, `unary`, …) and skips
1404            // `copy_dtoh` — those ops no longer poll themselves (see the
1405            // comment on every `dev.queue.submit(...)` call site: wgpu's
1406            // queue-FIFO ordering makes a per-op poll unnecessary). A bare
1407            // `let _ = dev.device.poll(...)` would silently discard a
1408            // `PollError` (device hung or lost) exactly where a caller is
1409            // relying on this call to be the wait; propagate it as a typed
1410            // error instead, reusing the same mapping `copy_from_device`
1411            // uses for its own indexed wait.
1412            crate::memory::poll_result_to_webgpu_result(
1413                dev.device.poll(wgpu::PollType::wait_indefinitely()),
1414            )
1415            .map_err(BackendError::from)?;
1416
1417            // Drain any uncaptured wgpu error recorded by the work this wait
1418            // just observed completing (validation / OOM / internal errors
1419            // wgpu's non-fatal handler captured instead of aborting the
1420            // process — see `WebGpuDevice::poll_error`) so it reaches the
1421            // caller instead of being silently lost.
1422            if let Some(msg) = dev.poll_error() {
1423                return Err(BackendError::from(
1424                    crate::error::WebGpuError::UncapturedError(msg),
1425                ));
1426            }
1427        }
1428        Ok(())
1429    }
1430
1431    // ── Memory management ─────────────────────────────────────────────────────
1432
1433    fn alloc(&self, bytes: usize) -> BackendResult<u64> {
1434        self.check_init()?;
1435        if bytes == 0 {
1436            return Err(BackendError::InvalidArgument(
1437                "cannot allocate 0 bytes".into(),
1438            ));
1439        }
1440        self.memory()?.alloc(bytes).map_err(BackendError::from)
1441    }
1442
1443    fn free(&self, ptr: u64) -> BackendResult<()> {
1444        self.check_init()?;
1445        // Evict any cached bind group that still points at `ptr` *before*
1446        // actually freeing it: a cached `wgpu::BindGroup` retains a strong
1447        // reference to every buffer it binds, so leaving a stale entry in
1448        // place would keep this handle's GPU memory alive indefinitely
1449        // despite `free()` having (logically) released it. See `cache.rs`'s
1450        // module doc, "Freed-buffer memory".
1451        self.evict_bind_group_cache(ptr)?;
1452        self.memory()?.free(ptr).map_err(BackendError::from)
1453    }
1454
1455    fn copy_htod(&self, dst: u64, src: &[u8]) -> BackendResult<()> {
1456        self.check_init()?;
1457        if src.is_empty() {
1458            return Ok(());
1459        }
1460        self.memory()?
1461            .copy_to_device(dst, src)
1462            .map_err(BackendError::from)
1463    }
1464
1465    fn copy_dtoh(&self, dst: &mut [u8], src: u64) -> BackendResult<()> {
1466        self.check_init()?;
1467        if dst.is_empty() {
1468            return Ok(());
1469        }
1470        self.memory()?
1471            .copy_from_device(dst, src)
1472            .map_err(BackendError::from)
1473    }
1474}
1475
1476// ─── Byte ↔ f32 helpers ──────────────────────────────────────────────────────
1477
1478/// Convert a `&[u8]` (length must be a multiple of 4) to a `Vec<f32>`.
1479fn bytes_to_f32_vec(bytes: &[u8]) -> Vec<f32> {
1480    bytes
1481        .chunks_exact(4)
1482        .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
1483        .collect()
1484}
1485
1486/// Convert a `&[f32]` slice to its little-endian byte representation.
1487fn f32_slice_to_bytes(data: &[f32]) -> Vec<u8> {
1488    data.iter().flat_map(|v| v.to_le_bytes()).collect()
1489}
1490
1491// ─── Tests ───────────────────────────────────────────────────────────────────
1492//
1493// The test module lives in a sibling file (`backend_tests.rs`) so the
1494// production code in this file stays under the 2 000-line refactoring policy.
1495#[cfg(test)]
1496#[path = "backend_tests.rs"]
1497mod tests;