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::{device::WebGpuDevice, memory::WebGpuMemoryManager, shader};
15
16// ─── Op-mapping helpers ──────────────────────────────────────────────────────
17
18fn map_unary_op(op: UnaryOp) -> &'static str {
19    match op {
20        UnaryOp::Relu => "relu",
21        UnaryOp::Sigmoid => "sigmoid",
22        UnaryOp::Tanh => "tanh",
23        UnaryOp::Exp => "exp",
24        UnaryOp::Log => "log",
25        UnaryOp::Sqrt => "sqrt",
26        UnaryOp::Abs => "abs",
27        UnaryOp::Neg => "neg",
28    }
29}
30
31fn map_binary_op(op: BinaryOp) -> &'static str {
32    match op {
33        BinaryOp::Add => "add",
34        BinaryOp::Sub => "sub",
35        BinaryOp::Mul => "mul",
36        BinaryOp::Div => "div",
37        BinaryOp::Max => "max",
38        BinaryOp::Min => "min",
39    }
40}
41
42fn map_reduce_op(op: ReduceOp) -> &'static str {
43    match op {
44        ReduceOp::Sum => "sum",
45        ReduceOp::Max => "max",
46        ReduceOp::Min => "min",
47        ReduceOp::Mean => "mean",
48    }
49}
50
51/// Packed (minimum) leading dimensions for the row-major GEMM kernel.
52///
53/// The WGSL kernel stores `op(A)`'s physical buffer as `m×k` (or its transpose
54/// `k×m`), `op(B)` as `k×n` (or `n×k`), and `C` as `m×n`, all row-major.  The
55/// leading dimension is the physical row stride, so the tightly-packed value is
56/// the width of each stored row.  A caller may pass a larger `ld` (padded /
57/// sub-matrix view), which the shader honours; a smaller one is invalid.
58fn packed_gemm_lds(
59    trans_a: BackendTranspose,
60    trans_b: BackendTranspose,
61    m: usize,
62    n: usize,
63    k: usize,
64) -> (usize, usize, usize) {
65    let lda = if trans_a == BackendTranspose::NoTrans {
66        k
67    } else {
68        m
69    };
70    let ldb = if trans_b == BackendTranspose::NoTrans {
71        n
72    } else {
73        k
74    };
75    (lda, ldb, n)
76}
77
78/// Convert a leading dimension to `u32` for the shader uniform, erroring on
79/// overflow instead of silently wrapping.
80fn lead_dim_u32(name: &str, value: usize) -> BackendResult<u32> {
81    u32::try_from(value).map_err(|_| {
82        BackendError::InvalidArgument(format!("gemm: {name} {value} exceeds u32 range"))
83    })
84}
85
86// ─── Backend struct ──────────────────────────────────────────────────────────
87
88/// Cross-platform GPU compute backend backed by `wgpu`.
89///
90/// # Lifecycle
91///
92/// 1. `WebGpuBackend::new()` — create an uninitialised backend.
93/// 2. `init()` — select the best available adapter and create the device.
94/// 3. Use `alloc`, `copy_htod`, compute ops, `copy_dtoh`, `free`.
95/// 4. `synchronize()` — wait for all pending GPU work to finish.
96#[derive(Debug)]
97pub struct WebGpuBackend {
98    device: Option<Arc<WebGpuDevice>>,
99    memory: Option<Arc<WebGpuMemoryManager>>,
100    initialized: bool,
101    /// Cache of compiled compute pipelines keyed by a stable `(op, tile/size)`
102    /// string.  WGSL front-end parsing plus backend-ISA compilation is
103    /// heavyweight and depends only on the key, so every hot-path compute op
104    /// reuses its pipeline instead of rebuilding one per invocation.
105    pipeline_cache: Mutex<HashMap<String, wgpu::ComputePipeline>>,
106}
107
108impl WebGpuBackend {
109    /// Create a new, uninitialised WebGPU backend.
110    pub fn new() -> Self {
111        Self {
112            device: None,
113            memory: None,
114            initialized: false,
115            pipeline_cache: Mutex::new(HashMap::new()),
116        }
117    }
118
119    /// Return a compiled compute pipeline for `key`, building it from the WGSL
120    /// produced by `build` on the first request and caching it for reuse.
121    ///
122    /// `key` must uniquely identify the shader source (e.g. `"gemm:8"`,
123    /// `"unary:relu"`); `label` is the wgpu debug label.
124    fn cached_pipeline(
125        &self,
126        key: &str,
127        label: &str,
128        build: impl FnOnce() -> String,
129    ) -> BackendResult<wgpu::ComputePipeline> {
130        let mut cache = self
131            .pipeline_cache
132            .lock()
133            .map_err(|_| BackendError::DeviceError("pipeline cache mutex poisoned".into()))?;
134
135        if let Some(pipeline) = cache.get(key) {
136            return Ok(pipeline.clone());
137        }
138
139        let dev = self.device()?;
140        let wgsl = build();
141        let shader_mod = dev
142            .device
143            .create_shader_module(wgpu::ShaderModuleDescriptor {
144                label: Some(label),
145                source: wgpu::ShaderSource::Wgsl(wgsl.into()),
146            });
147        let pipeline = dev
148            .device
149            .create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
150                label: Some(label),
151                layout: None,
152                module: &shader_mod,
153                entry_point: Some("main"),
154                compilation_options: Default::default(),
155                cache: None,
156            });
157
158        cache.insert(key.to_string(), pipeline.clone());
159        Ok(pipeline)
160    }
161
162    /// Return an error if the backend is not yet initialised.
163    fn check_init(&self) -> BackendResult<()> {
164        if self.initialized {
165            Ok(())
166        } else {
167            Err(BackendError::NotInitialized)
168        }
169    }
170
171    /// Convenience accessor: get the memory manager or return `NotInitialized`.
172    fn memory(&self) -> BackendResult<&Arc<WebGpuMemoryManager>> {
173        self.memory.as_ref().ok_or(BackendError::NotInitialized)
174    }
175
176    /// Convenience accessor: get the device or return `NotInitialized`.
177    fn device(&self) -> BackendResult<&Arc<WebGpuDevice>> {
178        self.device.as_ref().ok_or(BackendError::NotInitialized)
179    }
180
181    /// Multi-dimensional reduce along a single axis.
182    ///
183    /// The tensor is logically reshaped to `[outer, dk, inner]`:
184    /// * `outer` = product of dimensions before the reduce axis,
185    /// * `dk`    = the reduce axis length,
186    /// * `inner` = product of dimensions after the reduce axis.
187    ///
188    /// One workgroup of 256 threads is dispatched per `(o, j)` output slot.
189    /// To stay within WebGPU's 65 535-per-axis dispatch limit a 2-D grid is
190    /// used and the workgroup decodes its linear slot internally.
191    ///
192    /// `Mean` is handled inside the shader (divide by `dk`); the host does
193    /// not need a post-pass.
194    fn reduce_nd(
195        &self,
196        op: ReduceOp,
197        input_ptr: u64,
198        output_ptr: u64,
199        shape: &[usize],
200        axis: usize,
201    ) -> BackendResult<()> {
202        // Caller (`reduce`) already validated `shape.is_empty()` and
203        // `axis < shape.len()`; assert in debug to catch regressions but
204        // recompute defensively in release as well.
205        debug_assert!(!shape.is_empty());
206        debug_assert!(axis < shape.len());
207
208        // Output shape = shape with `axis` removed; length = outer * inner.
209        let outer: usize = shape[..axis].iter().product();
210        let dk: usize = shape[axis];
211        let inner: usize = shape[axis + 1..].iter().product();
212
213        // Empty tensor — nothing to do.
214        if outer == 0 || dk == 0 || inner == 0 {
215            return Ok(());
216        }
217
218        let total = outer.checked_mul(inner).ok_or_else(|| {
219            BackendError::InvalidArgument("reduce: outer * inner overflows usize".into())
220        })?;
221
222        // Strides in elements: row-major (C order) layout.
223        let inner_stride: usize = 1;
224        let dk_stride: usize = inner;
225        let outer_stride: usize = dk
226            .checked_mul(inner)
227            .ok_or_else(|| BackendError::InvalidArgument("reduce: dk * inner overflows".into()))?;
228
229        // Cap each dispatch dimension below the WebGPU 65 535 limit.  We pick
230        // grid_x = min(total, 32 768) so grid_y stays modest for huge tensors.
231        const MAX_GRID_DIM: u32 = 32_768;
232        let total_u32: u32 = total.try_into().map_err(|_| {
233            BackendError::InvalidArgument(format!(
234                "reduce: output element count {total} exceeds u32 range"
235            ))
236        })?;
237        let grid_x: u32 = total_u32.clamp(1, MAX_GRID_DIM);
238        let grid_y: u32 = total_u32.div_ceil(grid_x);
239
240        let dev = self.device()?;
241        let mem = self.memory()?;
242        let op_str = map_reduce_op(op);
243
244        let pipeline =
245            self.cached_pipeline(&format!("reduce_nd:{op_str}"), "oxicuda-reduce-nd", || {
246                shader::reduction_nd_wgsl(op_str)
247            })?;
248
249        // Build the uniform buffer: 8 × u32 = 32 bytes (16-byte aligned).
250        let mut params_bytes = [0u8; 32];
251        let outer_u32: u32 = outer
252            .try_into()
253            .map_err(|_| BackendError::InvalidArgument("reduce: outer exceeds u32 range".into()))?;
254        let dk_u32: u32 = dk
255            .try_into()
256            .map_err(|_| BackendError::InvalidArgument("reduce: dk exceeds u32 range".into()))?;
257        let inner_u32: u32 = inner
258            .try_into()
259            .map_err(|_| BackendError::InvalidArgument("reduce: inner exceeds u32 range".into()))?;
260        let outer_stride_u32: u32 = outer_stride.try_into().map_err(|_| {
261            BackendError::InvalidArgument("reduce: outer_stride exceeds u32 range".into())
262        })?;
263        let dk_stride_u32: u32 = dk_stride.try_into().map_err(|_| {
264            BackendError::InvalidArgument("reduce: dk_stride exceeds u32 range".into())
265        })?;
266        let inner_stride_u32: u32 = inner_stride.try_into().map_err(|_| {
267            BackendError::InvalidArgument("reduce: inner_stride exceeds u32 range".into())
268        })?;
269        params_bytes[0..4].copy_from_slice(&outer_u32.to_le_bytes());
270        params_bytes[4..8].copy_from_slice(&dk_u32.to_le_bytes());
271        params_bytes[8..12].copy_from_slice(&inner_u32.to_le_bytes());
272        params_bytes[12..16].copy_from_slice(&outer_stride_u32.to_le_bytes());
273        params_bytes[16..20].copy_from_slice(&dk_stride_u32.to_le_bytes());
274        params_bytes[20..24].copy_from_slice(&inner_stride_u32.to_le_bytes());
275        params_bytes[24..28].copy_from_slice(&grid_x.to_le_bytes());
276        // bytes 28..32 are zero padding.
277
278        let uniform_buf = dev.device.create_buffer(&wgpu::BufferDescriptor {
279            label: Some("oxicuda-reduce-nd-params"),
280            size: 32,
281            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
282            mapped_at_creation: false,
283        });
284        dev.queue.write_buffer(&uniform_buf, 0, &params_bytes);
285
286        let bgl = pipeline.get_bind_group_layout(0);
287        let bind_group = {
288            let buffers = mem
289                .lock_buffers()
290                .map_err(|e| BackendError::DeviceError(e.to_string()))?;
291            let in_info = buffers.get(&input_ptr).ok_or_else(|| {
292                BackendError::InvalidArgument(format!("unknown handle {input_ptr}"))
293            })?;
294            let out_info = buffers.get(&output_ptr).ok_or_else(|| {
295                BackendError::InvalidArgument(format!("unknown handle {output_ptr}"))
296            })?;
297
298            dev.device.create_bind_group(&wgpu::BindGroupDescriptor {
299                label: Some("oxicuda-reduce-nd"),
300                layout: &bgl,
301                entries: &[
302                    wgpu::BindGroupEntry {
303                        binding: 0,
304                        resource: in_info.buffer.as_entire_binding(),
305                    },
306                    wgpu::BindGroupEntry {
307                        binding: 1,
308                        resource: out_info.buffer.as_entire_binding(),
309                    },
310                    wgpu::BindGroupEntry {
311                        binding: 2,
312                        resource: uniform_buf.as_entire_binding(),
313                    },
314                ],
315            })
316        };
317
318        let mut encoder = dev
319            .device
320            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
321                label: Some("oxicuda-reduce-nd"),
322            });
323        {
324            let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
325                label: Some("oxicuda-reduce-nd"),
326                timestamp_writes: None,
327            });
328            pass.set_pipeline(&pipeline);
329            pass.set_bind_group(0, &bind_group, &[]);
330            pass.dispatch_workgroups(grid_x, grid_y, 1);
331        }
332
333        dev.queue.submit(std::iter::once(encoder.finish()));
334        let _ = dev.device.poll(wgpu::PollType::wait_indefinitely());
335
336        Ok(())
337    }
338}
339
340impl WebGpuBackend {
341    /// FP16 GEMM: `C = alpha * A * B + beta * C` with half-precision storage.
342    ///
343    /// This is an inherent method (not on `ComputeBackend`) because FP16
344    /// support is WebGPU-specific and requires the `f16` WGSL extension.
345    ///
346    /// Buffers pointed to by `a_ptr`, `b_ptr`, `c_ptr` must contain `f16`
347    /// elements (2 bytes each).
348    #[allow(clippy::too_many_arguments)]
349    pub fn gemm_f16(
350        &self,
351        m: usize,
352        n: usize,
353        k: usize,
354        alpha: f64,
355        a_ptr: u64,
356        b_ptr: u64,
357        beta: f64,
358        c_ptr: u64,
359    ) -> BackendResult<()> {
360        self.check_init()?;
361        if m == 0 || n == 0 || k == 0 {
362            return Ok(());
363        }
364
365        let dev = self.device()?;
366        let mem = self.memory()?;
367
368        // The FP16 GEMM shader declares `enable f16;`; naga rejects that module
369        // unless the device enabled the SHADER_F16 feature.  Fail loudly with a
370        // typed error instead of emitting an invalid module (which surfaces as a
371        // process-fatal uncaptured validation error).
372        if !dev.supports_f16 {
373            return Err(BackendError::Unsupported(
374                "f16 GEMM requires the SHADER_F16 device feature, \
375                 which this adapter does not support"
376                    .into(),
377            ));
378        }
379
380        let tile_size: u32 = 8;
381        let pipeline = self.cached_pipeline("gemm_f16", "oxicuda-gemm-f16", || {
382            shader::gemm_wgsl_f16(tile_size)
383        })?;
384
385        let bgl = pipeline.get_bind_group_layout(0);
386
387        // Build uniform buffer for GemmParams { m, n, k, alpha, beta }.
388        let mut params_bytes = [0u8; 20];
389        params_bytes[0..4].copy_from_slice(&(m as u32).to_le_bytes());
390        params_bytes[4..8].copy_from_slice(&(n as u32).to_le_bytes());
391        params_bytes[8..12].copy_from_slice(&(k as u32).to_le_bytes());
392        params_bytes[12..16].copy_from_slice(&(alpha as f32).to_le_bytes());
393        params_bytes[16..20].copy_from_slice(&(beta as f32).to_le_bytes());
394
395        let uniform_buf = dev.device.create_buffer(&wgpu::BufferDescriptor {
396            label: Some("oxicuda-gemm-f16-params"),
397            size: 20,
398            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
399            mapped_at_creation: false,
400        });
401        dev.queue.write_buffer(&uniform_buf, 0, &params_bytes);
402
403        let bind_group = {
404            let buffers = mem
405                .lock_buffers()
406                .map_err(|e| BackendError::DeviceError(e.to_string()))?;
407            let a_info = buffers
408                .get(&a_ptr)
409                .ok_or_else(|| BackendError::InvalidArgument(format!("unknown handle {a_ptr}")))?;
410            let b_info = buffers
411                .get(&b_ptr)
412                .ok_or_else(|| BackendError::InvalidArgument(format!("unknown handle {b_ptr}")))?;
413            let c_info = buffers
414                .get(&c_ptr)
415                .ok_or_else(|| BackendError::InvalidArgument(format!("unknown handle {c_ptr}")))?;
416
417            dev.device.create_bind_group(&wgpu::BindGroupDescriptor {
418                label: Some("oxicuda-gemm-f16"),
419                layout: &bgl,
420                entries: &[
421                    wgpu::BindGroupEntry {
422                        binding: 0,
423                        resource: a_info.buffer.as_entire_binding(),
424                    },
425                    wgpu::BindGroupEntry {
426                        binding: 1,
427                        resource: b_info.buffer.as_entire_binding(),
428                    },
429                    wgpu::BindGroupEntry {
430                        binding: 2,
431                        resource: c_info.buffer.as_entire_binding(),
432                    },
433                    wgpu::BindGroupEntry {
434                        binding: 3,
435                        resource: uniform_buf.as_entire_binding(),
436                    },
437                ],
438            })
439        };
440
441        let mut encoder = dev
442            .device
443            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
444                label: Some("oxicuda-gemm-f16"),
445            });
446
447        {
448            let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
449                label: Some("oxicuda-gemm-f16"),
450                timestamp_writes: None,
451            });
452            pass.set_pipeline(&pipeline);
453            pass.set_bind_group(0, &bind_group, &[]);
454            let wg_x = (n as u32).div_ceil(tile_size);
455            let wg_y = (m as u32).div_ceil(tile_size);
456            pass.dispatch_workgroups(wg_x, wg_y, 1);
457        }
458
459        dev.queue.submit(std::iter::once(encoder.finish()));
460        let _ = dev.device.poll(wgpu::PollType::wait_indefinitely());
461
462        Ok(())
463    }
464}
465
466impl Default for WebGpuBackend {
467    fn default() -> Self {
468        Self::new()
469    }
470}
471
472// ─── ComputeBackend impl ─────────────────────────────────────────────────────
473
474impl ComputeBackend for WebGpuBackend {
475    fn name(&self) -> &str {
476        "webgpu"
477    }
478
479    fn init(&mut self) -> BackendResult<()> {
480        if self.initialized {
481            return Ok(());
482        }
483
484        match WebGpuDevice::new() {
485            Ok(dev) => {
486                let dev = Arc::new(dev);
487                tracing::info!("WebGPU backend initialised on: {}", dev.adapter_name);
488                let memory = WebGpuMemoryManager::new(Arc::clone(&dev));
489                self.device = Some(dev);
490                self.memory = Some(Arc::new(memory));
491                self.initialized = true;
492                Ok(())
493            }
494            Err(e) => Err(BackendError::from(e)),
495        }
496    }
497
498    fn is_initialized(&self) -> bool {
499        self.initialized
500    }
501
502    // ── Compute operations ────────────────────────────────────────────────────
503
504    fn gemm(
505        &self,
506        trans_a: BackendTranspose,
507        trans_b: BackendTranspose,
508        m: usize,
509        n: usize,
510        k: usize,
511        alpha: f64,
512        a_ptr: u64,
513        lda: usize,
514        b_ptr: u64,
515        ldb: usize,
516        beta: f64,
517        c_ptr: u64,
518        ldc: usize,
519    ) -> BackendResult<()> {
520        self.check_init()?;
521        // Zero-dimension matrices are trivially done.
522        if m == 0 || n == 0 || k == 0 {
523            return Ok(());
524        }
525
526        // The WGSL tiled GEMM kernel handles every NN / NT / TN / TT
527        // combination at runtime via the `trans_a` / `trans_b` uniforms.
528        // `ConjTrans` collapses to `Trans` because the f32 buffers are real.
529        let trans_a_flag: u32 = u32::from(trans_a != BackendTranspose::NoTrans);
530        let trans_b_flag: u32 = u32::from(trans_b != BackendTranspose::NoTrans);
531
532        let dev = self.device()?;
533        let mem = self.memory()?;
534
535        let tile_size: u32 = 8;
536        let pipeline =
537            self.cached_pipeline("gemm", "oxicuda-gemm", || shader::gemm_wgsl(tile_size))?;
538
539        let bgl = pipeline.get_bind_group_layout(0);
540
541        // The row-major WGSL kernel honours the leading dimensions carried in
542        // `GemmParams`; validate they are at least the packed extent (the same
543        // check the CPU reference backend performs) so a too-small stride is a
544        // clean error rather than an out-of-bounds read.
545        let (expected_lda, expected_ldb, expected_ldc) = packed_gemm_lds(trans_a, trans_b, m, n, k);
546        if lda < expected_lda || ldb < expected_ldb || ldc < expected_ldc {
547            return Err(BackendError::InvalidArgument(
548                "gemm: leading dimension smaller than matrix extent".into(),
549            ));
550        }
551        let lda_u32 = lead_dim_u32("lda", lda)?;
552        let ldb_u32 = lead_dim_u32("ldb", ldb)?;
553        let ldc_u32 = lead_dim_u32("ldc", ldc)?;
554
555        // Build uniform buffer for GemmParams { m, n, k, alpha, beta,
556        // trans_a, trans_b, lda, ldb, ldc, _pad } — 12 × 4 = 48 bytes.
557        let mut params_bytes = [0u8; 48];
558        params_bytes[0..4].copy_from_slice(&(m as u32).to_le_bytes());
559        params_bytes[4..8].copy_from_slice(&(n as u32).to_le_bytes());
560        params_bytes[8..12].copy_from_slice(&(k as u32).to_le_bytes());
561        params_bytes[12..16].copy_from_slice(&(alpha as f32).to_le_bytes());
562        params_bytes[16..20].copy_from_slice(&(beta as f32).to_le_bytes());
563        params_bytes[20..24].copy_from_slice(&trans_a_flag.to_le_bytes());
564        params_bytes[24..28].copy_from_slice(&trans_b_flag.to_le_bytes());
565        params_bytes[28..32].copy_from_slice(&lda_u32.to_le_bytes());
566        params_bytes[32..36].copy_from_slice(&ldb_u32.to_le_bytes());
567        params_bytes[36..40].copy_from_slice(&ldc_u32.to_le_bytes());
568        // bytes 40..48 are zero padding.
569
570        let uniform_buf = dev.device.create_buffer(&wgpu::BufferDescriptor {
571            label: Some("oxicuda-gemm-params"),
572            size: 48,
573            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
574            mapped_at_creation: false,
575        });
576        dev.queue.write_buffer(&uniform_buf, 0, &params_bytes);
577
578        // Create bind group while holding the buffer lock.
579        let bind_group = {
580            let buffers = mem
581                .lock_buffers()
582                .map_err(|e| BackendError::DeviceError(e.to_string()))?;
583            let a_info = buffers
584                .get(&a_ptr)
585                .ok_or_else(|| BackendError::InvalidArgument(format!("unknown handle {a_ptr}")))?;
586            let b_info = buffers
587                .get(&b_ptr)
588                .ok_or_else(|| BackendError::InvalidArgument(format!("unknown handle {b_ptr}")))?;
589            let c_info = buffers
590                .get(&c_ptr)
591                .ok_or_else(|| BackendError::InvalidArgument(format!("unknown handle {c_ptr}")))?;
592
593            dev.device.create_bind_group(&wgpu::BindGroupDescriptor {
594                label: Some("oxicuda-gemm"),
595                layout: &bgl,
596                entries: &[
597                    wgpu::BindGroupEntry {
598                        binding: 0,
599                        resource: a_info.buffer.as_entire_binding(),
600                    },
601                    wgpu::BindGroupEntry {
602                        binding: 1,
603                        resource: b_info.buffer.as_entire_binding(),
604                    },
605                    wgpu::BindGroupEntry {
606                        binding: 2,
607                        resource: c_info.buffer.as_entire_binding(),
608                    },
609                    wgpu::BindGroupEntry {
610                        binding: 3,
611                        resource: uniform_buf.as_entire_binding(),
612                    },
613                ],
614            })
615        };
616
617        let mut encoder = dev
618            .device
619            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
620                label: Some("oxicuda-gemm"),
621            });
622
623        {
624            let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
625                label: Some("oxicuda-gemm"),
626                timestamp_writes: None,
627            });
628            pass.set_pipeline(&pipeline);
629            pass.set_bind_group(0, &bind_group, &[]);
630            let wg_x = (n as u32).div_ceil(tile_size);
631            let wg_y = (m as u32).div_ceil(tile_size);
632            pass.dispatch_workgroups(wg_x, wg_y, 1);
633        }
634
635        dev.queue.submit(std::iter::once(encoder.finish()));
636        let _ = dev.device.poll(wgpu::PollType::wait_indefinitely());
637
638        Ok(())
639    }
640
641    #[allow(clippy::too_many_arguments)]
642    fn batched_gemm(
643        &self,
644        trans_a: BackendTranspose,
645        trans_b: BackendTranspose,
646        m: usize,
647        n: usize,
648        k: usize,
649        alpha: f64,
650        a_ptr: u64,
651        lda: usize,
652        stride_a: usize,
653        b_ptr: u64,
654        ldb: usize,
655        stride_b: usize,
656        beta: f64,
657        c_ptr: u64,
658        ldc: usize,
659        stride_c: usize,
660        batch_count: usize,
661    ) -> BackendResult<()> {
662        self.check_init()?;
663
664        if batch_count == 0 || m == 0 || n == 0 || k == 0 {
665            return Ok(());
666        }
667
668        // The WGSL batched tiled GEMM kernel handles every NN / NT / TN / TT
669        // combination at runtime via the `trans_a` / `trans_b` uniforms.
670        // `ConjTrans` collapses to `Trans` because the f32 buffers are real.
671        let trans_a_flag: u32 = u32::from(trans_a != BackendTranspose::NoTrans);
672        let trans_b_flag: u32 = u32::from(trans_b != BackendTranspose::NoTrans);
673
674        let dev = self.device()?;
675        let mem = self.memory()?;
676
677        let tile_size: u32 = 8;
678        let pipeline = self.cached_pipeline("batched_gemm", "oxicuda-batched-gemm", || {
679            shader::batched_gemm_wgsl(tile_size)
680        })?;
681
682        let bgl = pipeline.get_bind_group_layout(0);
683
684        // Validate leading dimensions against the packed extents (per-batch row
685        // strides) before threading them into the uniform.
686        let (expected_lda, expected_ldb, expected_ldc) = packed_gemm_lds(trans_a, trans_b, m, n, k);
687        if lda < expected_lda || ldb < expected_ldb || ldc < expected_ldc {
688            return Err(BackendError::InvalidArgument(
689                "batched_gemm: leading dimension smaller than matrix extent".into(),
690            ));
691        }
692        let lda_u32 = lead_dim_u32("lda", lda)?;
693        let ldb_u32 = lead_dim_u32("ldb", ldb)?;
694        let ldc_u32 = lead_dim_u32("ldc", ldc)?;
695
696        // BatchedGemmParams: m, n, k, alpha, beta, batch_count, stride_a,
697        // stride_b, stride_c, trans_a, trans_b, lda, ldb, ldc — 14 × 4 = 56
698        // bytes.  Uniform buffers need 16-byte alignment, so 56 rounds up to 64.
699        let mut params_bytes = [0u8; 64];
700        params_bytes[0..4].copy_from_slice(&(m as u32).to_le_bytes());
701        params_bytes[4..8].copy_from_slice(&(n as u32).to_le_bytes());
702        params_bytes[8..12].copy_from_slice(&(k as u32).to_le_bytes());
703        params_bytes[12..16].copy_from_slice(&(alpha as f32).to_le_bytes());
704        params_bytes[16..20].copy_from_slice(&(beta as f32).to_le_bytes());
705        params_bytes[20..24].copy_from_slice(&(batch_count as u32).to_le_bytes());
706        params_bytes[24..28].copy_from_slice(&(stride_a as u32).to_le_bytes());
707        params_bytes[28..32].copy_from_slice(&(stride_b as u32).to_le_bytes());
708        params_bytes[32..36].copy_from_slice(&(stride_c as u32).to_le_bytes());
709        params_bytes[36..40].copy_from_slice(&trans_a_flag.to_le_bytes());
710        params_bytes[40..44].copy_from_slice(&trans_b_flag.to_le_bytes());
711        params_bytes[44..48].copy_from_slice(&lda_u32.to_le_bytes());
712        params_bytes[48..52].copy_from_slice(&ldb_u32.to_le_bytes());
713        params_bytes[52..56].copy_from_slice(&ldc_u32.to_le_bytes());
714        // bytes 56..64 are padding zeros
715
716        let uniform_buf = dev.device.create_buffer(&wgpu::BufferDescriptor {
717            label: Some("oxicuda-batched-gemm-params"),
718            size: 64,
719            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
720            mapped_at_creation: false,
721        });
722        dev.queue.write_buffer(&uniform_buf, 0, &params_bytes);
723
724        let bind_group = {
725            let buffers = mem
726                .lock_buffers()
727                .map_err(|e| BackendError::DeviceError(e.to_string()))?;
728            let a_info = buffers
729                .get(&a_ptr)
730                .ok_or_else(|| BackendError::InvalidArgument(format!("unknown handle {a_ptr}")))?;
731            let b_info = buffers
732                .get(&b_ptr)
733                .ok_or_else(|| BackendError::InvalidArgument(format!("unknown handle {b_ptr}")))?;
734            let c_info = buffers
735                .get(&c_ptr)
736                .ok_or_else(|| BackendError::InvalidArgument(format!("unknown handle {c_ptr}")))?;
737
738            dev.device.create_bind_group(&wgpu::BindGroupDescriptor {
739                label: Some("oxicuda-batched-gemm"),
740                layout: &bgl,
741                entries: &[
742                    wgpu::BindGroupEntry {
743                        binding: 0,
744                        resource: a_info.buffer.as_entire_binding(),
745                    },
746                    wgpu::BindGroupEntry {
747                        binding: 1,
748                        resource: b_info.buffer.as_entire_binding(),
749                    },
750                    wgpu::BindGroupEntry {
751                        binding: 2,
752                        resource: c_info.buffer.as_entire_binding(),
753                    },
754                    wgpu::BindGroupEntry {
755                        binding: 3,
756                        resource: uniform_buf.as_entire_binding(),
757                    },
758                ],
759            })
760        };
761
762        let mut encoder = dev
763            .device
764            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
765                label: Some("oxicuda-batched-gemm"),
766            });
767
768        {
769            let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
770                label: Some("oxicuda-batched-gemm"),
771                timestamp_writes: None,
772            });
773            pass.set_pipeline(&pipeline);
774            pass.set_bind_group(0, &bind_group, &[]);
775            let wg_x = (n as u32).div_ceil(tile_size);
776            let wg_y = (m as u32).div_ceil(tile_size);
777            pass.dispatch_workgroups(wg_x, wg_y, batch_count as u32);
778        }
779
780        dev.queue.submit(std::iter::once(encoder.finish()));
781        let _ = dev.device.poll(wgpu::PollType::wait_indefinitely());
782
783        Ok(())
784    }
785
786    fn conv2d_forward(
787        &self,
788        input_ptr: u64,
789        input_shape: &[usize],
790        filter_ptr: u64,
791        filter_shape: &[usize],
792        output_ptr: u64,
793        output_shape: &[usize],
794        stride: &[usize],
795        padding: &[usize],
796    ) -> BackendResult<()> {
797        self.check_init()?;
798
799        if input_shape.len() != 4 {
800            return Err(BackendError::InvalidArgument(
801                "input_shape must have 4 elements (NCHW)".into(),
802            ));
803        }
804        if filter_shape.len() != 4 {
805            return Err(BackendError::InvalidArgument(
806                "filter_shape must have 4 elements (KCFHFW)".into(),
807            ));
808        }
809        if output_shape.len() != 4 {
810            return Err(BackendError::InvalidArgument(
811                "output_shape must have 4 elements (NKOhOw)".into(),
812            ));
813        }
814        if stride.len() != 2 {
815            return Err(BackendError::InvalidArgument(
816                "stride must have 2 elements [sh, sw]".into(),
817            ));
818        }
819        if padding.len() != 2 {
820            return Err(BackendError::InvalidArgument(
821                "padding must have 2 elements [ph, pw]".into(),
822            ));
823        }
824
825        let mem = self.memory()?;
826
827        let batch = input_shape[0];
828        let c_in = input_shape[1];
829        let h_in = input_shape[2];
830        let w_in = input_shape[3];
831        let k_out = filter_shape[0];
832        let fh = filter_shape[2];
833        let fw = filter_shape[3];
834        let oh = output_shape[2];
835        let ow = output_shape[3];
836        let sh = stride[0];
837        let sw = stride[1];
838        let ph = padding[0];
839        let pw = padding[1];
840
841        let in_elems: usize = input_shape.iter().product();
842        let f_elems: usize = filter_shape.iter().product();
843        let o_elems: usize = output_shape.iter().product();
844
845        // CPU fallback: download input + filter, compute, upload output.
846        let mut in_bytes = vec![0u8; in_elems * 4];
847        let mut f_bytes = vec![0u8; f_elems * 4];
848        mem.copy_from_device(&mut in_bytes, input_ptr)
849            .map_err(BackendError::from)?;
850        mem.copy_from_device(&mut f_bytes, filter_ptr)
851            .map_err(BackendError::from)?;
852
853        let in_f32 = bytes_to_f32_vec(&in_bytes);
854        let f_f32 = bytes_to_f32_vec(&f_bytes);
855        let mut out_f32 = vec![0.0f32; o_elems];
856
857        for b in 0..batch {
858            for kf in 0..k_out {
859                for oy in 0..oh {
860                    for ox in 0..ow {
861                        let mut acc = 0.0f32;
862                        for ci in 0..c_in {
863                            for fy in 0..fh {
864                                for fx in 0..fw {
865                                    let iy = (oy * sh + fy) as isize - ph as isize;
866                                    let ix = (ox * sw + fx) as isize - pw as isize;
867                                    if iy >= 0
868                                        && (iy as usize) < h_in
869                                        && ix >= 0
870                                        && (ix as usize) < w_in
871                                    {
872                                        let in_idx = ((b * c_in + ci) * h_in + iy as usize) * w_in
873                                            + ix as usize;
874                                        let f_idx = ((kf * c_in + ci) * fh + fy) * fw + fx;
875                                        acc += in_f32[in_idx] * f_f32[f_idx];
876                                    }
877                                }
878                            }
879                        }
880                        out_f32[((b * k_out + kf) * oh + oy) * ow + ox] = acc;
881                    }
882                }
883            }
884        }
885
886        let out_bytes = f32_slice_to_bytes(&out_f32);
887        mem.copy_to_device(output_ptr, &out_bytes)
888            .map_err(BackendError::from)?;
889
890        Ok(())
891    }
892
893    fn attention(
894        &self,
895        q_ptr: u64,
896        k_ptr: u64,
897        v_ptr: u64,
898        o_ptr: u64,
899        batch: usize,
900        heads: usize,
901        seq_q: usize,
902        seq_kv: usize,
903        head_dim: usize,
904        scale: f64,
905        causal: bool,
906    ) -> BackendResult<()> {
907        self.check_init()?;
908
909        if seq_q == 0 || seq_kv == 0 || head_dim == 0 {
910            return Err(BackendError::InvalidArgument(
911                "seq_q, seq_kv, and head_dim must all be > 0".into(),
912            ));
913        }
914        if scale <= 0.0 || !scale.is_finite() {
915            return Err(BackendError::InvalidArgument(format!(
916                "scale must be a positive finite number, got {scale}"
917            )));
918        }
919
920        let mem = self.memory()?;
921
922        let batch_heads = batch * heads;
923        let q_elems = batch_heads * seq_q * head_dim;
924        let kv_elems = batch_heads * seq_kv * head_dim;
925        let o_elems = q_elems;
926
927        // CPU fallback: download Q, K, V, compute attention, upload O.
928        let mut q_bytes = vec![0u8; q_elems * 4];
929        let mut k_bytes = vec![0u8; kv_elems * 4];
930        let mut v_bytes = vec![0u8; kv_elems * 4];
931
932        mem.copy_from_device(&mut q_bytes, q_ptr)
933            .map_err(BackendError::from)?;
934        mem.copy_from_device(&mut k_bytes, k_ptr)
935            .map_err(BackendError::from)?;
936        mem.copy_from_device(&mut v_bytes, v_ptr)
937            .map_err(BackendError::from)?;
938
939        let q_f32 = bytes_to_f32_vec(&q_bytes);
940        let k_f32 = bytes_to_f32_vec(&k_bytes);
941        let v_f32 = bytes_to_f32_vec(&v_bytes);
942        let mut o_f32 = vec![0.0f32; o_elems];
943
944        let scale_f32 = scale as f32;
945
946        for bh in 0..batch_heads {
947            let q_off = bh * seq_q * head_dim;
948            let k_off = bh * seq_kv * head_dim;
949            let v_off = k_off;
950
951            for sq in 0..seq_q {
952                let kv_limit = if causal { (sq + 1).min(seq_kv) } else { seq_kv };
953
954                // Pass 1: find max score for numerical stability
955                let mut max_score = f32::NEG_INFINITY;
956                for sk in 0..kv_limit {
957                    let mut dot = 0.0f32;
958                    for dd in 0..head_dim {
959                        dot +=
960                            q_f32[q_off + sq * head_dim + dd] * k_f32[k_off + sk * head_dim + dd];
961                    }
962                    let s = dot * scale_f32;
963                    if s > max_score {
964                        max_score = s;
965                    }
966                }
967
968                // Pass 2: exp(score - max), accumulate weighted V
969                let mut sum_exp = 0.0f32;
970                let mut acc = vec![0.0f32; head_dim];
971                for sk in 0..kv_limit {
972                    let mut dot = 0.0f32;
973                    for dd in 0..head_dim {
974                        dot +=
975                            q_f32[q_off + sq * head_dim + dd] * k_f32[k_off + sk * head_dim + dd];
976                    }
977                    let w = (dot * scale_f32 - max_score).exp();
978                    sum_exp += w;
979                    for dd in 0..head_dim {
980                        acc[dd] += w * v_f32[v_off + sk * head_dim + dd];
981                    }
982                }
983
984                // Normalise
985                let o_base = q_off + sq * head_dim;
986                if sum_exp > 0.0 {
987                    for dd in 0..head_dim {
988                        o_f32[o_base + dd] = acc[dd] / sum_exp;
989                    }
990                }
991            }
992        }
993
994        let o_bytes = f32_slice_to_bytes(&o_f32);
995        mem.copy_to_device(o_ptr, &o_bytes)
996            .map_err(BackendError::from)?;
997
998        Ok(())
999    }
1000
1001    fn reduce(
1002        &self,
1003        op: ReduceOp,
1004        input_ptr: u64,
1005        output_ptr: u64,
1006        shape: &[usize],
1007        axis: usize,
1008    ) -> BackendResult<()> {
1009        self.check_init()?;
1010
1011        if shape.is_empty() {
1012            return Err(BackendError::InvalidArgument(
1013                "shape must not be empty".into(),
1014            ));
1015        }
1016        if axis >= shape.len() {
1017            return Err(BackendError::InvalidArgument(format!(
1018                "axis {axis} is out of bounds for shape of length {}",
1019                shape.len()
1020            )));
1021        }
1022
1023        // 1-D shapes (or any shape that reduces to a single scalar) take the
1024        // optimised two-pass scalar path.  Higher-rank shapes go through the
1025        // batched N-D shader below.
1026        if shape.len() != 1 {
1027            return self.reduce_nd(op, input_ptr, output_ptr, shape, axis);
1028        }
1029
1030        let n_elements = shape[0];
1031        if n_elements == 0 {
1032            return Ok(());
1033        }
1034
1035        let dev = self.device()?;
1036        let mem = self.memory()?;
1037        let op_str = map_reduce_op(op);
1038
1039        // ── Pass 1: per-workgroup reduction ─────────────────────────────────
1040        let wg_count = (n_elements as u32).div_ceil(256);
1041
1042        let pass1_pipeline = self.cached_pipeline(
1043            &format!("reduce_pass1:{op_str}"),
1044            "oxicuda-reduce-pass1",
1045            || shader::reduction_wgsl(op_str),
1046        )?;
1047
1048        // Partial-sums buffer (temporary).
1049        let partial_buf = dev.device.create_buffer(&wgpu::BufferDescriptor {
1050            label: Some("oxicuda-reduce-partial"),
1051            size: (wg_count as u64) * 4, // f32 per workgroup
1052            usage: wgpu::BufferUsages::STORAGE
1053                | wgpu::BufferUsages::COPY_SRC
1054                | wgpu::BufferUsages::COPY_DST,
1055            mapped_at_creation: false,
1056        });
1057
1058        // Uniform for ReduceParams { n: u32 }.
1059        let mut p1_params = [0u8; 4];
1060        p1_params[0..4].copy_from_slice(&(n_elements as u32).to_le_bytes());
1061        let p1_uniform = dev.device.create_buffer(&wgpu::BufferDescriptor {
1062            label: Some("oxicuda-reduce-p1-params"),
1063            size: 4,
1064            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1065            mapped_at_creation: false,
1066        });
1067        dev.queue.write_buffer(&p1_uniform, 0, &p1_params);
1068
1069        let bgl1 = pass1_pipeline.get_bind_group_layout(0);
1070
1071        let bg1 = {
1072            let buffers = mem
1073                .lock_buffers()
1074                .map_err(|e| BackendError::DeviceError(e.to_string()))?;
1075            let in_info = buffers.get(&input_ptr).ok_or_else(|| {
1076                BackendError::InvalidArgument(format!("unknown handle {input_ptr}"))
1077            })?;
1078
1079            dev.device.create_bind_group(&wgpu::BindGroupDescriptor {
1080                label: Some("oxicuda-reduce-pass1"),
1081                layout: &bgl1,
1082                entries: &[
1083                    wgpu::BindGroupEntry {
1084                        binding: 0,
1085                        resource: in_info.buffer.as_entire_binding(),
1086                    },
1087                    wgpu::BindGroupEntry {
1088                        binding: 1,
1089                        resource: partial_buf.as_entire_binding(),
1090                    },
1091                    wgpu::BindGroupEntry {
1092                        binding: 2,
1093                        resource: p1_uniform.as_entire_binding(),
1094                    },
1095                ],
1096            })
1097        };
1098
1099        // ── Pass 2: final reduction of partial sums ─────────────────────────
1100        let pass2_pipeline = self.cached_pipeline(
1101            &format!("reduce_pass2:{op_str}"),
1102            "oxicuda-reduce-pass2",
1103            || shader::reduction_final_wgsl(op_str),
1104        )?;
1105
1106        // FinalReduceParams { num_groups: u32 }.
1107        let mut p2_params = [0u8; 4];
1108        p2_params[0..4].copy_from_slice(&wg_count.to_le_bytes());
1109        let p2_uniform = dev.device.create_buffer(&wgpu::BufferDescriptor {
1110            label: Some("oxicuda-reduce-p2-params"),
1111            size: 4,
1112            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1113            mapped_at_creation: false,
1114        });
1115        dev.queue.write_buffer(&p2_uniform, 0, &p2_params);
1116
1117        let bgl2 = pass2_pipeline.get_bind_group_layout(0);
1118
1119        let bg2 = {
1120            let buffers = mem
1121                .lock_buffers()
1122                .map_err(|e| BackendError::DeviceError(e.to_string()))?;
1123            let out_info = buffers.get(&output_ptr).ok_or_else(|| {
1124                BackendError::InvalidArgument(format!("unknown handle {output_ptr}"))
1125            })?;
1126
1127            dev.device.create_bind_group(&wgpu::BindGroupDescriptor {
1128                label: Some("oxicuda-reduce-pass2"),
1129                layout: &bgl2,
1130                entries: &[
1131                    wgpu::BindGroupEntry {
1132                        binding: 0,
1133                        resource: partial_buf.as_entire_binding(),
1134                    },
1135                    wgpu::BindGroupEntry {
1136                        binding: 1,
1137                        resource: out_info.buffer.as_entire_binding(),
1138                    },
1139                    wgpu::BindGroupEntry {
1140                        binding: 2,
1141                        resource: p2_uniform.as_entire_binding(),
1142                    },
1143                ],
1144            })
1145        };
1146
1147        // ── Encode both passes into one command buffer ──────────────────────
1148        let mut encoder = dev
1149            .device
1150            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1151                label: Some("oxicuda-reduce"),
1152            });
1153
1154        {
1155            let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
1156                label: Some("oxicuda-reduce-pass1"),
1157                timestamp_writes: None,
1158            });
1159            pass.set_pipeline(&pass1_pipeline);
1160            pass.set_bind_group(0, &bg1, &[]);
1161            pass.dispatch_workgroups(wg_count, 1, 1);
1162        }
1163        {
1164            let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
1165                label: Some("oxicuda-reduce-pass2"),
1166                timestamp_writes: None,
1167            });
1168            pass.set_pipeline(&pass2_pipeline);
1169            pass.set_bind_group(0, &bg2, &[]);
1170            pass.dispatch_workgroups(1, 1, 1);
1171        }
1172
1173        dev.queue.submit(std::iter::once(encoder.finish()));
1174        let _ = dev.device.poll(wgpu::PollType::wait_indefinitely());
1175
1176        // For "mean", divide the result by N on the host side.
1177        if op == ReduceOp::Mean && n_elements > 1 {
1178            let mut buf = [0u8; 4];
1179            mem.copy_from_device(&mut buf, output_ptr)
1180                .map_err(BackendError::from)?;
1181            let val = f32::from_le_bytes(buf);
1182            let mean = val / (n_elements as f32);
1183            mem.copy_to_device(output_ptr, &mean.to_le_bytes())
1184                .map_err(BackendError::from)?;
1185        }
1186
1187        Ok(())
1188    }
1189
1190    fn unary(&self, op: UnaryOp, input_ptr: u64, output_ptr: u64, n: usize) -> BackendResult<()> {
1191        self.check_init()?;
1192        if n == 0 {
1193            return Ok(());
1194        }
1195
1196        let dev = self.device()?;
1197        let mem = self.memory()?;
1198
1199        let op_str = map_unary_op(op);
1200        let pipeline = self.cached_pipeline(&format!("unary:{op_str}"), "oxicuda-unary", || {
1201            shader::elementwise_wgsl(op_str)
1202        })?;
1203
1204        let bgl = pipeline.get_bind_group_layout(0);
1205
1206        let bind_group = {
1207            let buffers = mem
1208                .lock_buffers()
1209                .map_err(|e| BackendError::DeviceError(e.to_string()))?;
1210            let in_info = buffers.get(&input_ptr).ok_or_else(|| {
1211                BackendError::InvalidArgument(format!("unknown handle {input_ptr}"))
1212            })?;
1213            let out_info = buffers.get(&output_ptr).ok_or_else(|| {
1214                BackendError::InvalidArgument(format!("unknown handle {output_ptr}"))
1215            })?;
1216
1217            dev.device.create_bind_group(&wgpu::BindGroupDescriptor {
1218                label: Some("oxicuda-unary"),
1219                layout: &bgl,
1220                entries: &[
1221                    wgpu::BindGroupEntry {
1222                        binding: 0,
1223                        resource: in_info.buffer.as_entire_binding(),
1224                    },
1225                    wgpu::BindGroupEntry {
1226                        binding: 1,
1227                        resource: out_info.buffer.as_entire_binding(),
1228                    },
1229                ],
1230            })
1231        };
1232
1233        let mut encoder = dev
1234            .device
1235            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1236                label: Some("oxicuda-unary"),
1237            });
1238
1239        {
1240            let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
1241                label: Some("oxicuda-unary"),
1242                timestamp_writes: None,
1243            });
1244            pass.set_pipeline(&pipeline);
1245            pass.set_bind_group(0, &bind_group, &[]);
1246            let workgroups = (n as u32).div_ceil(256);
1247            pass.dispatch_workgroups(workgroups, 1, 1);
1248        }
1249
1250        dev.queue.submit(std::iter::once(encoder.finish()));
1251        let _ = dev.device.poll(wgpu::PollType::wait_indefinitely());
1252
1253        Ok(())
1254    }
1255
1256    fn binary(
1257        &self,
1258        op: BinaryOp,
1259        a_ptr: u64,
1260        b_ptr: u64,
1261        output_ptr: u64,
1262        n: usize,
1263    ) -> BackendResult<()> {
1264        self.check_init()?;
1265        if n == 0 {
1266            return Ok(());
1267        }
1268
1269        let dev = self.device()?;
1270        let mem = self.memory()?;
1271
1272        let op_str = map_binary_op(op);
1273        let pipeline =
1274            self.cached_pipeline(&format!("binary:{op_str}"), "oxicuda-binary", || {
1275                shader::binary_wgsl(op_str)
1276            })?;
1277
1278        let bgl = pipeline.get_bind_group_layout(0);
1279
1280        let bind_group = {
1281            let buffers = mem
1282                .lock_buffers()
1283                .map_err(|e| BackendError::DeviceError(e.to_string()))?;
1284            let a_info = buffers
1285                .get(&a_ptr)
1286                .ok_or_else(|| BackendError::InvalidArgument(format!("unknown handle {a_ptr}")))?;
1287            let b_info = buffers
1288                .get(&b_ptr)
1289                .ok_or_else(|| BackendError::InvalidArgument(format!("unknown handle {b_ptr}")))?;
1290            let out_info = buffers.get(&output_ptr).ok_or_else(|| {
1291                BackendError::InvalidArgument(format!("unknown handle {output_ptr}"))
1292            })?;
1293
1294            dev.device.create_bind_group(&wgpu::BindGroupDescriptor {
1295                label: Some("oxicuda-binary"),
1296                layout: &bgl,
1297                entries: &[
1298                    wgpu::BindGroupEntry {
1299                        binding: 0,
1300                        resource: a_info.buffer.as_entire_binding(),
1301                    },
1302                    wgpu::BindGroupEntry {
1303                        binding: 1,
1304                        resource: b_info.buffer.as_entire_binding(),
1305                    },
1306                    wgpu::BindGroupEntry {
1307                        binding: 2,
1308                        resource: out_info.buffer.as_entire_binding(),
1309                    },
1310                ],
1311            })
1312        };
1313
1314        let mut encoder = dev
1315            .device
1316            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1317                label: Some("oxicuda-binary"),
1318            });
1319
1320        {
1321            let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
1322                label: Some("oxicuda-binary"),
1323                timestamp_writes: None,
1324            });
1325            pass.set_pipeline(&pipeline);
1326            pass.set_bind_group(0, &bind_group, &[]);
1327            let workgroups = (n as u32).div_ceil(256);
1328            pass.dispatch_workgroups(workgroups, 1, 1);
1329        }
1330
1331        dev.queue.submit(std::iter::once(encoder.finish()));
1332        let _ = dev.device.poll(wgpu::PollType::wait_indefinitely());
1333
1334        Ok(())
1335    }
1336
1337    // ── Synchronisation ───────────────────────────────────────────────────────
1338
1339    fn synchronize(&self) -> BackendResult<()> {
1340        self.check_init()?;
1341        if let Some(dev) = &self.device {
1342            let _ = dev.device.poll(wgpu::PollType::wait_indefinitely());
1343        }
1344        Ok(())
1345    }
1346
1347    // ── Memory management ─────────────────────────────────────────────────────
1348
1349    fn alloc(&self, bytes: usize) -> BackendResult<u64> {
1350        self.check_init()?;
1351        if bytes == 0 {
1352            return Err(BackendError::InvalidArgument(
1353                "cannot allocate 0 bytes".into(),
1354            ));
1355        }
1356        self.memory()?.alloc(bytes).map_err(BackendError::from)
1357    }
1358
1359    fn free(&self, ptr: u64) -> BackendResult<()> {
1360        self.check_init()?;
1361        self.memory()?.free(ptr).map_err(BackendError::from)
1362    }
1363
1364    fn copy_htod(&self, dst: u64, src: &[u8]) -> BackendResult<()> {
1365        self.check_init()?;
1366        if src.is_empty() {
1367            return Ok(());
1368        }
1369        self.memory()?
1370            .copy_to_device(dst, src)
1371            .map_err(BackendError::from)
1372    }
1373
1374    fn copy_dtoh(&self, dst: &mut [u8], src: u64) -> BackendResult<()> {
1375        self.check_init()?;
1376        if dst.is_empty() {
1377            return Ok(());
1378        }
1379        self.memory()?
1380            .copy_from_device(dst, src)
1381            .map_err(BackendError::from)
1382    }
1383}
1384
1385// ─── Byte ↔ f32 helpers ──────────────────────────────────────────────────────
1386
1387/// Convert a `&[u8]` (length must be a multiple of 4) to a `Vec<f32>`.
1388fn bytes_to_f32_vec(bytes: &[u8]) -> Vec<f32> {
1389    bytes
1390        .chunks_exact(4)
1391        .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
1392        .collect()
1393}
1394
1395/// Convert a `&[f32]` slice to its little-endian byte representation.
1396fn f32_slice_to_bytes(data: &[f32]) -> Vec<u8> {
1397    data.iter().flat_map(|v| v.to_le_bytes()).collect()
1398}
1399
1400// ─── Tests ───────────────────────────────────────────────────────────────────
1401//
1402// The test module lives in a sibling file (`backend_tests.rs`) so the
1403// production code in this file stays under the 2 000-line refactoring policy.
1404#[cfg(test)]
1405#[path = "backend_tests.rs"]
1406mod tests;