Skip to main content

oxicuda_webgpu/
wasm.rs

1//! WASM target support for browser-based GPU compute via WebGPU.
2//!
3//! This module is conditionally compiled on `wasm32` targets (or when the `wasm`
4//! feature is enabled for native testing) and provides browser-friendly wrappers
5//! around the `wgpu` WebGPU backend.
6//!
7//! # Architecture
8//!
9//! ```text
10//! +-------------------------------------------+
11//! |         JavaScript / Browser              |
12//! +-------------------+-----------------------+
13//!                     |
14//! +-------------------v-----------------------+
15//! |   WasmGpuDevice / WasmBackend (wasm32)    |
16//! +-------------------+-----------------------+
17//!                     |  delegates to
18//! +-------------------v-----------------------+
19//! |   WebGpuBackend (wgpu web-sys backend)    |
20//! +-------------------------------------------+
21//! ```
22//!
23//! # Usage
24//!
25//! The [`WasmBackend`] wraps the existing [`WebGpuBackend`]
26//! and adds browser-specific initialisation methods such as
27//! [`init_from_canvas`](WasmBackend::init_from_canvas).
28//!
29//! The [`WasmMemoryManager`] provides async-friendly buffer staging suited to the
30//! browser event loop.
31//!
32//! # Known limitation: `WasmBackend` does not actually use `WasmMemoryManager`
33//!
34//! Despite the name, **every [`WasmBackend`] compute and memory operation
35//! forwards to [`WebGpuBackend`]**, which is backed by
36//! [`WebGpuMemoryManager`](crate::memory::WebGpuMemoryManager) — not by
37//! [`WasmMemoryManager`] in this module.  `WasmMemoryManager` (and
38//! [`WasmGpuDevice`]) exist, are async-safe, and are unit-tested, but nothing
39//! in `WasmBackend` constructs or calls them.  This matters because:
40//!
41//! * `WebGpuBackend`'s compute methods end in a **blocking**
42//!   `Device::poll(PollType::wait_indefinitely())` (see `backend.rs`), and
43//!   `WebGpuMemoryManager::copy_from_device`'s readback blocks on an
44//!   `mpsc::channel` `recv()` that only resolves once that same poll drives
45//!   the `map_async` callback to completion. On the single-threaded browser
46//!   main thread, `Device::poll` cannot make progress without yielding back
47//!   to the event loop that would deliver that callback — so this **would
48//!   deadlock the tab**, exactly the failure `WasmMemoryManager::copy_dtoh`'s
49//!   own `#[cfg(target_arch = "wasm32")]` guard (in this file) already exists
50//!   to prevent, just on the wrong type.
51//! * Fixing this by swapping `WasmBackend`'s memory calls (`alloc`,
52//!   `copy_htod`, `copy_dtoh`) over to `WasmMemoryManager` while leaving the
53//!   compute calls (`gemm`, `unary`, …) on `self.inner: WebGpuBackend` is
54//!   **not a valid partial fix**: the two memory managers keep independent
55//!   `HashMap<u64, Buffer>` handle tables, each with its own
56//!   `next_handle`/`AtomicU64` counter starting at 1. A buffer allocated
57//!   through `WasmMemoryManager` would not exist in
58//!   `WebGpuMemoryManager`'s map (or worse, its handle number would collide
59//!   with an unrelated `WebGpuMemoryManager` buffer), so every compute call
60//!   would either fail with "unknown handle" or silently operate on the
61//!   wrong buffer.  Correctly fixing this requires `WasmBackend` to own one
62//!   coherent device + buffer table end-to-end and give every compute
63//!   dispatch (not just readback) an async, non-blocking form — a genuine
64//!   rework of this module and `backend.rs` together, out of scope here.
65//!
66//! Until that rework lands: `WasmBackend` is appropriate for **native
67//! testing** of the WASM code paths (via the `wasm` feature, where blocking
68//! is safe) and for **non-browser wasm32 hosts** (e.g. a WASI runtime driving
69//! its own event loop outside a browser tab). On an actual browser main
70//! thread, treat every `WasmBackend` compute/readback call as unsafe to call
71//! synchronously; [`WasmMemoryManager::copy_dtoh_async`] is the
72//! already-implemented pattern a real async rework would extend to the rest
73//! of the surface.
74//!
75//! # Deeper pre-existing gap: this crate does not compile for `wasm32-unknown-unknown` at all
76//!
77//! `cargo check -p oxicuda-webgpu --target wasm32-unknown-unknown` fails
78//! today (independent of anything in this module): `oxicuda_backend::
79//! ComputeBackend` requires `Send + Sync`, but on the real `wasm32` target
80//! `wgpu`'s WebGPU backend represents `Device`/`Buffer` using `Rc`/`RefCell`
81//! internally (browser JS handles are not thread-safe), which makes
82//! `WebGpuDevice`/`WebGpuBufferInfo` — and therefore `WebGpuBackend`, which
83//! every `WasmBackend` method forwards to — not `Send`. So `WasmBackend`
84//! (and `WebGpuBackend`) cannot implement `ComputeBackend` on that target
85//! today at all; this is a compile error, not a runtime one, so nothing in
86//! this crate has ever actually run compiled-for-wasm32. Fixing it needs
87//! either a `?Send` carve-out on `ComputeBackend` for wasm32 (a change to
88//! `oxicuda-backend`, out of scope for this crate) or a non-`ComputeBackend`
89//! wasm32-native entry point built directly on `WasmGpuDevice`; both are
90//! part of the same async rework noted above.
91
92use std::collections::HashMap;
93use std::sync::atomic::{AtomicU64, Ordering};
94use std::sync::{Arc, Mutex};
95
96use oxicuda_backend::{
97    BackendResult, BackendTranspose, BinaryOp, ComputeBackend, ReduceOp, UnaryOp,
98};
99
100use crate::WebGpuBackend;
101use crate::error::{WebGpuError, WebGpuResult};
102use crate::memory::WebGpuBufferInfo;
103
104// ---- WasmGpuDevice --------------------------------------------------------
105
106/// A WebGPU device obtained from the browser's `navigator.gpu` API.
107///
108/// Wraps the `wgpu` adapter and device objects and provides async construction
109/// methods appropriate for the browser environment.
110#[derive(Debug)]
111pub struct WasmGpuDevice {
112    /// The wgpu instance.
113    #[allow(dead_code)]
114    pub(crate) instance: wgpu::Instance,
115    /// The selected GPU adapter.
116    #[allow(dead_code)]
117    pub(crate) adapter: wgpu::Adapter,
118    /// The logical device.
119    pub(crate) device: wgpu::Device,
120    /// The queue for submitting command buffers.
121    pub(crate) queue: wgpu::Queue,
122    /// Human-readable adapter name.
123    pub adapter_name: String,
124}
125
126impl WasmGpuDevice {
127    /// Create a new [`WasmGpuDevice`] from an already-obtained adapter.
128    ///
129    /// This is the async path used by browser callers. On native targets this
130    /// may not be exercised directly, but it is the intended entry point for
131    /// WASM builds.
132    pub async fn from_adapter(
133        instance: wgpu::Instance,
134        adapter: wgpu::Adapter,
135    ) -> WebGpuResult<Self> {
136        let adapter_name = adapter.get_info().name.clone();
137
138        let (device, queue) = adapter
139            .request_device(&wgpu::DeviceDescriptor {
140                label: Some("oxicuda-webgpu-wasm"),
141                required_features: wgpu::Features::empty(),
142                required_limits: wgpu::Limits::default(),
143                memory_hints: wgpu::MemoryHints::default(),
144                ..Default::default()
145            })
146            .await
147            .map_err(|e| WebGpuError::DeviceRequest(e.to_string()))?;
148
149        Ok(Self {
150            instance,
151            adapter,
152            device,
153            queue,
154            adapter_name,
155        })
156    }
157}
158
159// ---- request_adapter -------------------------------------------------------
160
161/// Request a WebGPU adapter from the browser.
162///
163/// On `wasm32` this goes through the browser's `navigator.gpu` API via the
164/// `wgpu` web-sys backend.
165pub async fn request_adapter() -> WebGpuResult<wgpu::Adapter> {
166    let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
167
168    instance
169        .request_adapter(&wgpu::RequestAdapterOptions {
170            power_preference: wgpu::PowerPreference::HighPerformance,
171            compatible_surface: None,
172            force_fallback_adapter: false,
173        })
174        .await
175        .map_err(|e| WebGpuError::DeviceRequest(e.to_string()))
176}
177
178// ---- WasmMemoryManager -----------------------------------------------------
179
180/// Browser-side buffer manager that uses async `map_async` staging.
181///
182/// This mirrors [`WebGpuMemoryManager`](crate::memory::WebGpuMemoryManager) but
183/// is designed to work within the single-threaded browser event loop where
184/// blocking calls are not allowed.
185pub struct WasmMemoryManager {
186    device: Arc<WasmGpuDevice>,
187    buffers: Mutex<HashMap<u64, WebGpuBufferInfo>>,
188    next_handle: AtomicU64,
189}
190
191impl WasmMemoryManager {
192    /// Create a new WASM memory manager backed by `device`.
193    pub fn new(device: Arc<WasmGpuDevice>) -> Self {
194        Self {
195            device,
196            buffers: Mutex::new(HashMap::new()),
197            next_handle: AtomicU64::new(1),
198        }
199    }
200
201    /// Allocate a device buffer of `bytes` bytes.
202    pub fn alloc(&self, bytes: usize) -> WebGpuResult<u64> {
203        let size = bytes as u64;
204        let buffer = self.device.device.create_buffer(&wgpu::BufferDescriptor {
205            label: Some("oxicuda-wasm-buffer"),
206            size,
207            usage: wgpu::BufferUsages::STORAGE
208                | wgpu::BufferUsages::COPY_SRC
209                | wgpu::BufferUsages::COPY_DST,
210            mapped_at_creation: false,
211        });
212
213        let handle = self.next_handle.fetch_add(1, Ordering::Relaxed);
214
215        self.buffers
216            .lock()
217            .map_err(|_| WebGpuError::BufferMapping("mutex poisoned".into()))?
218            .insert(handle, WebGpuBufferInfo { buffer, size });
219
220        Ok(handle)
221    }
222
223    /// Free the buffer identified by `handle`.
224    pub fn free(&self, handle: u64) -> WebGpuResult<()> {
225        self.buffers
226            .lock()
227            .map_err(|_| WebGpuError::BufferMapping("mutex poisoned".into()))?
228            .remove(&handle);
229        Ok(())
230    }
231
232    /// Upload host bytes to the device buffer (host-to-device copy).
233    ///
234    /// Uses `Queue::write_buffer` which is available in both native and WASM.
235    pub fn copy_htod(&self, handle: u64, src: &[u8]) -> WebGpuResult<()> {
236        let buffers = self
237            .buffers
238            .lock()
239            .map_err(|_| WebGpuError::BufferMapping("mutex poisoned".into()))?;
240
241        let buf_info = buffers
242            .get(&handle)
243            .ok_or_else(|| WebGpuError::InvalidArgument(format!("unknown handle {handle}")))?;
244
245        // `Queue::write_buffer` validates `offset + src.len() <= buffer.size`;
246        // an overrun is delivered to wgpu's default uncaptured-error handler
247        // which panics.  Reject it up front with a typed error instead.
248        if src.len() as u64 > buf_info.size {
249            return Err(WebGpuError::InvalidArgument(format!(
250                "copy_htod: source is {} bytes but buffer holds only {} bytes",
251                src.len(),
252                buf_info.size
253            )));
254        }
255
256        self.device.queue.write_buffer(&buf_info.buffer, 0, src);
257        Ok(())
258    }
259
260    /// Submit a device→staging copy and return the mappable staging buffer.
261    ///
262    /// Shared by the synchronous [`copy_dtoh`](Self::copy_dtoh) and asynchronous
263    /// [`copy_dtoh_async`](Self::copy_dtoh_async) readback paths.
264    fn submit_readback(&self, handle: u64) -> WebGpuResult<wgpu::Buffer> {
265        let buffers = self
266            .buffers
267            .lock()
268            .map_err(|_| WebGpuError::BufferMapping("mutex poisoned".into()))?;
269
270        let buf_info = buffers
271            .get(&handle)
272            .ok_or_else(|| WebGpuError::InvalidArgument(format!("unknown handle {handle}")))?;
273
274        let staging = self.device.device.create_buffer(&wgpu::BufferDescriptor {
275            label: Some("oxicuda-wasm-staging"),
276            size: buf_info.size,
277            usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
278            mapped_at_creation: false,
279        });
280
281        let mut encoder =
282            self.device
283                .device
284                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
285                    label: Some("oxicuda-wasm-readback"),
286                });
287
288        encoder.copy_buffer_to_buffer(&buf_info.buffer, 0, &staging, 0, buf_info.size);
289        self.device.queue.submit(std::iter::once(encoder.finish()));
290
291        Ok(staging)
292    }
293
294    /// Copy the mapped staging data into `dst`, enforcing the sized-by-`dst`
295    /// contract: an oversized destination is rejected rather than silently
296    /// truncated (which would leave `dst`'s tail stale while reporting success).
297    fn drain_mapped(dst: &mut [u8], staging: wgpu::Buffer) -> WebGpuResult<()> {
298        let slice = staging.slice(..);
299        let data = slice.get_mapped_range();
300        let data_len = data.len();
301        if dst.len() > data_len {
302            drop(data);
303            staging.unmap();
304            return Err(WebGpuError::InvalidArgument(format!(
305                "copy_dtoh: destination is {} bytes but buffer holds only {data_len} bytes",
306                dst.len(),
307            )));
308        }
309        let copy_len = dst.len();
310        dst[..copy_len].copy_from_slice(&data[..copy_len]);
311        drop(data);
312        staging.unmap();
313        Ok(())
314    }
315
316    /// Download a device buffer to host bytes (device-to-host copy).
317    ///
318    /// This is a *blocking* readback: it maps a staging buffer and waits on the
319    /// map callback.  On a native target (including the `wasm` feature used for
320    /// testing) `Device::poll` drives completion, so this works.  On the real
321    /// `wasm32` browser main thread, however, blocking would starve the event
322    /// loop that delivers the map callback and freeze the tab — so there this
323    /// method returns [`WebGpuError::Unsupported`] and callers must use
324    /// [`copy_dtoh_async`](Self::copy_dtoh_async) instead.
325    #[cfg(not(target_arch = "wasm32"))]
326    pub fn copy_dtoh(&self, dst: &mut [u8], handle: u64) -> WebGpuResult<()> {
327        let staging = self.submit_readback(handle)?;
328
329        let slice = staging.slice(..);
330        let (tx, rx) = std::sync::mpsc::channel();
331        slice.map_async(wgpu::MapMode::Read, move |result| {
332            let _ = tx.send(result);
333        });
334
335        let _ = self.device.device.poll(wgpu::PollType::wait_indefinitely());
336
337        rx.recv()
338            .map_err(|_| WebGpuError::BufferMapping("channel closed before map completed".into()))?
339            .map_err(|e| WebGpuError::BufferMapping(format!("{e:?}")))?;
340
341        Self::drain_mapped(dst, staging)
342    }
343
344    /// Blocking readback is unavailable on the `wasm32` browser main thread — it
345    /// would deadlock the single event loop that delivers the buffer-map
346    /// callback.  Use [`copy_dtoh_async`](Self::copy_dtoh_async) instead.
347    #[cfg(target_arch = "wasm32")]
348    pub fn copy_dtoh(&self, _dst: &mut [u8], _handle: u64) -> WebGpuResult<()> {
349        Err(WebGpuError::Unsupported(
350            "synchronous copy_dtoh would deadlock the browser event loop; \
351             use copy_dtoh_async"
352                .into(),
353        ))
354    }
355
356    /// Asynchronously download a device buffer to host bytes.
357    ///
358    /// Unlike [`copy_dtoh`](Self::copy_dtoh) this never blocks the calling
359    /// thread: it awaits the buffer-map completion via a future resolved from
360    /// the `map_async` callback, making it the correct readback path on the
361    /// single-threaded browser event loop.  Like `copy_dtoh`, an oversized
362    /// destination is rejected with [`WebGpuError::InvalidArgument`].
363    pub async fn copy_dtoh_async(&self, dst: &mut [u8], handle: u64) -> WebGpuResult<()> {
364        let staging = self.submit_readback(handle)?;
365
366        {
367            let slice = staging.slice(..);
368            let state = Arc::new(Mutex::new(MapState::default()));
369            let cb_state = Arc::clone(&state);
370            slice.map_async(wgpu::MapMode::Read, move |result| {
371                let mut guard = match cb_state.lock() {
372                    Ok(g) => g,
373                    Err(poisoned) => poisoned.into_inner(),
374                };
375                guard.result = Some(result);
376                if let Some(waker) = guard.waker.take() {
377                    waker.wake();
378                }
379            });
380
381            // Nudge the device once so the callback can be delivered on native
382            // executors; in the browser the event loop drives this itself.
383            let _ = self.device.device.poll(wgpu::PollType::wait_indefinitely());
384
385            MapWait { state }
386                .await
387                .map_err(|e| WebGpuError::BufferMapping(format!("{e:?}")))?;
388        }
389
390        Self::drain_mapped(dst, staging)
391    }
392}
393
394/// Shared state between a `map_async` callback and the [`MapWait`] future that
395/// awaits it.
396#[derive(Default)]
397struct MapState {
398    result: Option<Result<(), wgpu::BufferAsyncError>>,
399    waker: Option<std::task::Waker>,
400}
401
402/// A minimal future that resolves once a `map_async` callback has stored its
403/// result.  Used by [`WasmMemoryManager::copy_dtoh_async`] to await a buffer
404/// map without blocking the browser event loop.
405struct MapWait {
406    state: Arc<Mutex<MapState>>,
407}
408
409impl std::future::Future for MapWait {
410    type Output = Result<(), wgpu::BufferAsyncError>;
411
412    fn poll(
413        self: std::pin::Pin<&mut Self>,
414        cx: &mut std::task::Context<'_>,
415    ) -> std::task::Poll<Self::Output> {
416        let mut guard = match self.state.lock() {
417            Ok(g) => g,
418            Err(poisoned) => poisoned.into_inner(),
419        };
420        if let Some(result) = guard.result.take() {
421            std::task::Poll::Ready(result)
422        } else {
423            guard.waker = Some(cx.waker().clone());
424            std::task::Poll::Pending
425        }
426    }
427}
428
429impl std::fmt::Debug for WasmMemoryManager {
430    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
431        let count = self.buffers.lock().map(|b| b.len()).unwrap_or(0);
432        write!(f, "WasmMemoryManager(buffers={count})")
433    }
434}
435
436// ---- WasmBackend -----------------------------------------------------------
437
438/// WebGPU compute backend for WASM (browser) targets.
439///
440/// Wraps [`WebGpuBackend`] and adds browser-specific initialisation paths.
441/// Implements [`ComputeBackend`] by delegating **every** operation —
442/// compute, allocation, and readback — to the inner [`WebGpuBackend`].
443///
444/// # Notes
445///
446/// See the [module-level documentation](self) for why this delegation makes
447/// every blocking call (readback, and every compute op via its trailing
448/// `Device::poll`) unsafe to call synchronously from a real browser main
449/// thread today, why `WasmMemoryManager` cannot simply be swapped in as a
450/// partial fix (it would split the buffer-handle table in two), and what a
451/// correct fix requires.
452#[derive(Debug)]
453pub struct WasmBackend {
454    inner: WebGpuBackend,
455}
456
457impl WasmBackend {
458    /// Create a new, uninitialised WASM backend.
459    pub fn new() -> Self {
460        Self {
461            inner: WebGpuBackend::new(),
462        }
463    }
464
465    /// Initialise the backend from an HTML canvas element by ID.
466    ///
467    /// This is the recommended browser entry point. The canvas is not used for
468    /// rendering but is required by some WebGPU implementations to obtain a
469    /// valid adapter.
470    ///
471    /// # Errors
472    ///
473    /// Returns an error if no WebGPU adapter is available or device creation fails.
474    pub async fn init_from_canvas(_canvas_id: &str) -> Result<Self, WebGpuError> {
475        // In the browser, wgpu's web-sys backend goes through navigator.gpu
476        // which does not actually require a canvas for compute-only usage.
477        // We accept the canvas_id for forward compatibility (e.g. surface-based
478        // adapters) but currently initialise via the standard path.
479        let mut backend = Self::new();
480        backend
481            .inner
482            .init()
483            .map_err(|e| WebGpuError::DeviceRequest(e.to_string()))?;
484        Ok(backend)
485    }
486}
487
488impl Default for WasmBackend {
489    fn default() -> Self {
490        Self::new()
491    }
492}
493
494// ---- ComputeBackend for WasmBackend ----------------------------------------
495
496impl ComputeBackend for WasmBackend {
497    fn name(&self) -> &str {
498        "webgpu-wasm"
499    }
500
501    fn init(&mut self) -> BackendResult<()> {
502        self.inner.init()
503    }
504
505    fn is_initialized(&self) -> bool {
506        self.inner.is_initialized()
507    }
508
509    #[allow(clippy::too_many_arguments)]
510    fn gemm(
511        &self,
512        trans_a: BackendTranspose,
513        trans_b: BackendTranspose,
514        m: usize,
515        n: usize,
516        k: usize,
517        alpha: f64,
518        a_ptr: u64,
519        lda: usize,
520        b_ptr: u64,
521        ldb: usize,
522        beta: f64,
523        c_ptr: u64,
524        ldc: usize,
525    ) -> BackendResult<()> {
526        self.inner.gemm(
527            trans_a, trans_b, m, n, k, alpha, a_ptr, lda, b_ptr, ldb, beta, c_ptr, ldc,
528        )
529    }
530
531    // `batched_gemm` MUST be forwarded explicitly (finding webgpu-7): without
532    // this override, `WasmBackend` inherits `ComputeBackend`'s default
533    // `batched_gemm` (`oxicuda-backend/src/lib.rs`), which loops calling
534    // `self.gemm(...)` with `a_ptr + b * stride_a * elem_bytes`-style pointer
535    // *arithmetic* on `a_ptr`/`b_ptr`/`c_ptr`.  Those are not addresses here —
536    // `WebGpuMemoryManager::alloc` (this backend's memory manager) hands out
537    // opaque monotonic `u64` handles from a `HashMap<u64, Buffer>`, so adding
538    // a stride offset to one either misses the map entirely (`batch_count >=
539    // 2` fails with "unknown handle") or, worse, silently collides with an
540    // unrelated live handle and multiplies the wrong buffers.  `batch_count
541    // == 1` happens to work by accident (offset 0), which is why this is easy
542    // to miss in ad hoc testing.  `WebGpuBackend::batched_gemm` (this
543    // backend's `self.inner`) already implements the real batched-strided
544    // dispatch correctly; this is purely a missing delegation, mirroring
545    // every other method in this `impl` block.
546    #[allow(clippy::too_many_arguments)]
547    fn batched_gemm(
548        &self,
549        trans_a: BackendTranspose,
550        trans_b: BackendTranspose,
551        m: usize,
552        n: usize,
553        k: usize,
554        alpha: f64,
555        a_ptr: u64,
556        lda: usize,
557        stride_a: usize,
558        b_ptr: u64,
559        ldb: usize,
560        stride_b: usize,
561        beta: f64,
562        c_ptr: u64,
563        ldc: usize,
564        stride_c: usize,
565        batch_count: usize,
566    ) -> BackendResult<()> {
567        self.inner.batched_gemm(
568            trans_a,
569            trans_b,
570            m,
571            n,
572            k,
573            alpha,
574            a_ptr,
575            lda,
576            stride_a,
577            b_ptr,
578            ldb,
579            stride_b,
580            beta,
581            c_ptr,
582            ldc,
583            stride_c,
584            batch_count,
585        )
586    }
587
588    #[allow(clippy::too_many_arguments)]
589    fn conv2d_forward(
590        &self,
591        input_ptr: u64,
592        input_shape: &[usize],
593        filter_ptr: u64,
594        filter_shape: &[usize],
595        output_ptr: u64,
596        output_shape: &[usize],
597        stride: &[usize],
598        padding: &[usize],
599    ) -> BackendResult<()> {
600        self.inner.conv2d_forward(
601            input_ptr,
602            input_shape,
603            filter_ptr,
604            filter_shape,
605            output_ptr,
606            output_shape,
607            stride,
608            padding,
609        )
610    }
611
612    #[allow(clippy::too_many_arguments)]
613    fn attention(
614        &self,
615        q_ptr: u64,
616        k_ptr: u64,
617        v_ptr: u64,
618        o_ptr: u64,
619        batch: usize,
620        heads: usize,
621        seq_q: usize,
622        seq_kv: usize,
623        head_dim: usize,
624        scale: f64,
625        causal: bool,
626    ) -> BackendResult<()> {
627        self.inner.attention(
628            q_ptr, k_ptr, v_ptr, o_ptr, batch, heads, seq_q, seq_kv, head_dim, scale, causal,
629        )
630    }
631
632    fn reduce(
633        &self,
634        op: ReduceOp,
635        input_ptr: u64,
636        output_ptr: u64,
637        shape: &[usize],
638        axis: usize,
639    ) -> BackendResult<()> {
640        self.inner.reduce(op, input_ptr, output_ptr, shape, axis)
641    }
642
643    fn unary(&self, op: UnaryOp, input_ptr: u64, output_ptr: u64, n: usize) -> BackendResult<()> {
644        self.inner.unary(op, input_ptr, output_ptr, n)
645    }
646
647    fn binary(
648        &self,
649        op: BinaryOp,
650        a_ptr: u64,
651        b_ptr: u64,
652        output_ptr: u64,
653        n: usize,
654    ) -> BackendResult<()> {
655        self.inner.binary(op, a_ptr, b_ptr, output_ptr, n)
656    }
657
658    fn synchronize(&self) -> BackendResult<()> {
659        self.inner.synchronize()
660    }
661
662    fn alloc(&self, bytes: usize) -> BackendResult<u64> {
663        self.inner.alloc(bytes)
664    }
665
666    fn free(&self, ptr: u64) -> BackendResult<()> {
667        self.inner.free(ptr)
668    }
669
670    fn copy_htod(&self, dst: u64, src: &[u8]) -> BackendResult<()> {
671        self.inner.copy_htod(dst, src)
672    }
673
674    fn copy_dtoh(&self, dst: &mut [u8], src: u64) -> BackendResult<()> {
675        self.inner.copy_dtoh(dst, src)
676    }
677}
678
679// ---- Tests -----------------------------------------------------------------
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684    use oxicuda_backend::BackendError;
685
686    /// Basic compilation test: the wasm module types exist and are constructible.
687    #[test]
688    fn wasm_module_compiles() {
689        let backend = WasmBackend::new();
690        assert!(!backend.is_initialized());
691        assert_eq!(backend.name(), "webgpu-wasm");
692
693        // Debug impl works.
694        let debug_str = format!("{backend:?}");
695        assert!(debug_str.contains("WasmBackend"));
696    }
697
698    /// Verify conditional compilation: wasm types implement expected traits.
699    #[test]
700    fn wasm_feature_flag_gating() {
701        // WasmBackend implements ComputeBackend.
702        let backend = WasmBackend::new();
703        let _: &dyn ComputeBackend = &backend;
704
705        // WasmBackend implements Default.
706        let _default = WasmBackend::default();
707    }
708
709    /// All public types and functions are accessible when `wasm` feature is enabled.
710    #[test]
711    fn wasm_public_api_accessible() {
712        // WasmGpuDevice is a public type.
713        fn _assert_wasm_gpu_device_exists(_: &WasmGpuDevice) {}
714
715        // WasmMemoryManager is a public type.
716        fn _assert_wasm_memory_manager_exists(_: &WasmMemoryManager) {}
717
718        // WasmBackend is a public type with new() and default().
719        let _b = WasmBackend::new();
720        let _b2 = WasmBackend::default();
721
722        // request_adapter is a public async fn (we can reference it).
723        let _fn_ptr: fn() -> _ = || request_adapter();
724    }
725
726    /// Not-initialised guards return proper errors.
727    #[test]
728    fn wasm_backend_not_initialized_guards() {
729        let b = WasmBackend::new();
730        assert_eq!(b.alloc(1024), Err(BackendError::NotInitialized));
731        assert_eq!(b.free(1), Err(BackendError::NotInitialized));
732        assert_eq!(b.copy_htod(1, b"hello"), Err(BackendError::NotInitialized));
733
734        let mut buf = [0u8; 4];
735        assert_eq!(b.copy_dtoh(&mut buf, 1), Err(BackendError::NotInitialized));
736        assert_eq!(b.synchronize(), Err(BackendError::NotInitialized));
737    }
738
739    /// Init may fail gracefully (no GPU) but must not panic.
740    #[test]
741    fn wasm_backend_init_graceful() {
742        let mut b = WasmBackend::new();
743        let _result = b.init();
744    }
745
746    /// Try to build a `WasmGpuDevice` for device-backed tests; returns `None`
747    /// when no adapter is available so the test skips gracefully.
748    fn try_wasm_device() -> Option<Arc<WasmGpuDevice>> {
749        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
750        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
751            power_preference: wgpu::PowerPreference::HighPerformance,
752            compatible_surface: None,
753            force_fallback_adapter: false,
754        }))
755        .ok()?;
756        let dev = pollster::block_on(WasmGpuDevice::from_adapter(instance, adapter)).ok()?;
757        Some(Arc::new(dev))
758    }
759
760    /// Oversized host→device upload must return a typed error, not panic via
761    /// wgpu's default uncaptured-error handler.
762    #[test]
763    fn wasm_copy_htod_oversize_errors() {
764        let Some(dev) = try_wasm_device() else {
765            return;
766        };
767        let mm = WasmMemoryManager::new(dev);
768        let h = mm.alloc(16).expect("alloc 16 bytes");
769        let err = mm.copy_htod(h, &[0u8; 64]).unwrap_err();
770        assert!(matches!(err, WebGpuError::InvalidArgument(_)));
771        mm.free(h).expect("free");
772    }
773
774    /// Device→host readback into an oversized destination must error rather than
775    /// silently truncate and report success.
776    #[cfg(not(target_arch = "wasm32"))]
777    #[test]
778    fn wasm_copy_dtoh_oversize_dst_errors() {
779        let Some(dev) = try_wasm_device() else {
780            return;
781        };
782        let mm = WasmMemoryManager::new(dev);
783        let h = mm.alloc(16).expect("alloc 16 bytes");
784        let mut dst = vec![0u8; 64];
785        let err = mm.copy_dtoh(&mut dst, h).unwrap_err();
786        assert!(matches!(err, WebGpuError::InvalidArgument(_)));
787        mm.free(h).expect("free");
788    }
789
790    /// Try to build an initialised `WasmBackend`; returns `None` when no GPU
791    /// is available so device-backed tests skip gracefully.
792    fn try_init_wasm_backend() -> Option<WasmBackend> {
793        let mut b = WasmBackend::new();
794        b.init().ok()?;
795        Some(b)
796    }
797
798    /// Regression for finding webgpu-7: before `batched_gemm` was forwarded
799    /// explicitly, `WasmBackend` inherited `ComputeBackend`'s default
800    /// implementation, which does pointer arithmetic
801    /// (`a_ptr + batch * stride_a * elem_bytes`) on what this backend's
802    /// memory manager hands out as *opaque* monotonic handles — not
803    /// addresses.  `batch_count == 1` (offset 0) happened to work by
804    /// accident; `batch_count >= 2` did not (either "unknown handle" or,
805    /// worse, a silent collision with a different live buffer).  Drive
806    /// `batch_count = 2` end-to-end through `WasmBackend` itself and check
807    /// the numeric result, proving the override is wired and correct — not
808    /// merely present.
809    #[test]
810    fn wasm_backend_batched_gemm_matches_reference() {
811        let Some(wasm_b) = try_init_wasm_backend() else {
812            return;
813        };
814
815        // 2 batches of 2×2 identity multiply, matching
816        // `backend_tests.rs::batched_gemm_identity_2x2`.
817        let a = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
818        let eye = [1.0f32, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0];
819        let c_init = [0.0f32; 8];
820        let to_bytes = |d: &[f32]| -> Vec<u8> { d.iter().flat_map(|v| v.to_le_bytes()).collect() };
821
822        let a_h = wasm_b.alloc(32).expect("alloc a");
823        let b_h = wasm_b.alloc(32).expect("alloc b");
824        let c_h = wasm_b.alloc(32).expect("alloc c");
825        wasm_b.copy_htod(a_h, &to_bytes(&a)).expect("htod a");
826        wasm_b.copy_htod(b_h, &to_bytes(&eye)).expect("htod b");
827        wasm_b.copy_htod(c_h, &to_bytes(&c_init)).expect("htod c");
828
829        let nt = BackendTranspose::NoTrans;
830        wasm_b
831            .batched_gemm(
832                nt, nt, 2, 2, 2, 1.0, a_h, 2, 4, b_h, 2, 4, 0.0, c_h, 2, 4,
833                2, // batch_count >= 2 — the case the missing override broke.
834            )
835            .expect("wasm batched_gemm");
836
837        let mut result_bytes = vec![0u8; 32];
838        wasm_b
839            .copy_dtoh(&mut result_bytes, c_h)
840            .expect("dtoh result");
841        let result: Vec<f32> = result_bytes
842            .chunks_exact(4)
843            .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
844            .collect();
845
846        // C = A * I = A for both batches.
847        for (r, e) in result.iter().zip(a.iter()) {
848            assert!((r - e).abs() < 1e-5, "got {r}, expected {e}");
849        }
850
851        wasm_b.free(a_h).expect("free");
852        wasm_b.free(b_h).expect("free");
853        wasm_b.free(c_h).expect("free");
854    }
855}