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}