Skip to main content

oxicuda_webgpu/
memory.rs

1//! WebGPU buffer manager — allocates, copies, and frees `wgpu::Buffer` objects
2//! through an opaque `u64` handle interface that mirrors the CUDA device-pointer
3//! model used by the rest of OxiCUDA.
4
5use std::{
6    collections::HashMap,
7    sync::{
8        Arc, Mutex,
9        atomic::{AtomicU64, Ordering},
10    },
11    time::Duration,
12};
13
14use wgpu;
15
16use crate::{
17    device::WebGpuDevice,
18    error::{WebGpuError, WebGpuResult},
19};
20
21// ─── Buffer bookkeeping ──────────────────────────────────────────────────────
22
23/// Internal record for a single allocated `wgpu::Buffer`.
24pub struct WebGpuBufferInfo {
25    /// The GPU-resident buffer.
26    pub buffer: wgpu::Buffer,
27    /// Byte size of the buffer, rounded up to `wgpu::COPY_BUFFER_ALIGNMENT`
28    /// (4 bytes) by [`WebGpuMemoryManager::alloc`] — the *physical* size, not
29    /// necessarily the exact byte count the caller requested.
30    pub size: u64,
31}
32
33/// Convert a raw `Device::poll` result into our typed result, distinguishing a
34/// genuine timeout (device hung or lost) from any other poll failure.
35///
36/// Factored out as a free function so the mapping itself is unit-testable
37/// without a real GPU — see the `poll_*_maps_to_*` tests below. `pub(crate)`
38/// so [`crate::backend::WebGpuBackend::synchronize`] can reuse the same
39/// tested mapping instead of duplicating it.
40pub(crate) fn poll_result_to_webgpu_result(
41    result: Result<wgpu::PollStatus, wgpu::PollError>,
42) -> WebGpuResult<()> {
43    match result {
44        Ok(_status) => Ok(()),
45        Err(wgpu::PollError::Timeout) => Err(WebGpuError::Timeout),
46        Err(e) => Err(WebGpuError::BufferMapping(format!("poll failed: {e:?}"))),
47    }
48}
49
50// ─── Memory manager ──────────────────────────────────────────────────────────
51
52/// Manages a pool of device-resident `wgpu::Buffer` objects, returning opaque
53/// `u64` handles to callers.
54///
55/// All public methods are `&self` to allow shared references from the backend.
56pub struct WebGpuMemoryManager {
57    device: Arc<WebGpuDevice>,
58    buffers: Mutex<HashMap<u64, WebGpuBufferInfo>>,
59    next_handle: AtomicU64,
60}
61
62impl WebGpuMemoryManager {
63    /// Bounded wait applied to GPU readbacks: long enough not to trip on slow
64    /// (but legitimate) workloads or a loaded CI runner, short enough to
65    /// eventually convert a genuinely stuck or lost device into a typed
66    /// [`WebGpuError::Timeout`] instead of blocking the caller forever.
67    const READBACK_POLL_TIMEOUT: Duration = Duration::from_secs(60);
68
69    /// Create a new memory manager backed by `device`.
70    pub fn new(device: Arc<WebGpuDevice>) -> Self {
71        Self {
72            device,
73            buffers: Mutex::new(HashMap::new()),
74            next_handle: AtomicU64::new(1),
75        }
76    }
77
78    /// Return an error instead of attempting a GPU operation that can no
79    /// longer succeed, once wgpu has reported the device lost (GPU reset,
80    /// driver failure, or an external `Device::destroy()` call — see the
81    /// device-lost callback installed in [`WebGpuDevice::new`]).
82    fn ensure_device_alive(&self) -> WebGpuResult<()> {
83        if self.device.is_device_lost() {
84            return Err(WebGpuError::DeviceLost(
85                "device was lost before the operation could run".into(),
86            ));
87        }
88        Ok(())
89    }
90
91    /// Drain the device's uncaptured-error slot (see
92    /// [`WebGpuDevice::poll_error`]) and convert a recorded error into a
93    /// typed `Err` instead of letting the caller proceed as though the
94    /// (non-fatal, but real) wgpu error never happened.
95    fn check_uncaptured_error(&self) -> WebGpuResult<()> {
96        if let Some(msg) = self.device.poll_error() {
97            return Err(WebGpuError::UncapturedError(msg));
98        }
99        Ok(())
100    }
101
102    /// Block until the specific submission identified by `submission_index`
103    /// completes, bounded by [`Self::READBACK_POLL_TIMEOUT`].
104    ///
105    /// Replaces a bare `let _ = device.poll(wait_indefinitely())`, which
106    /// silently discarded a `PollError` (or an indefinite hang) and let the
107    /// caller read out of a staging buffer that may never have been written.
108    ///
109    /// Waiting on the *specific* [`wgpu::SubmissionIndex`] returned by the
110    /// copy's own `queue.submit(...)` — rather than `submission_index: None`
111    /// ("the most recent submission at the time of the poll") — ties this
112    /// wait to exactly the work this readback depends on, regardless of what
113    /// other threads submit concurrently in between. Because a `wgpu::Queue`
114    /// executes submissions in FIFO order, waiting for this copy's index is
115    /// also sufficient to guarantee every compute dispatch that produced the
116    /// data being read back (always submitted earlier, on the same queue) has
117    /// completed — those dispatches no longer poll themselves, see
118    /// `WebGpuBackend`'s compute-op methods.
119    fn wait_for_gpu(&self, submission_index: wgpu::SubmissionIndex) -> WebGpuResult<()> {
120        poll_result_to_webgpu_result(self.device.device.poll(wgpu::PollType::Wait {
121            submission_index: Some(submission_index),
122            timeout: Some(Self::READBACK_POLL_TIMEOUT),
123        }))
124    }
125
126    /// Allocate a new device buffer of at least `bytes` bytes.
127    ///
128    /// The physical buffer size is rounded up to `wgpu::COPY_BUFFER_ALIGNMENT`
129    /// (4 bytes): WebGPU requires `copy_buffer_to_buffer` sizes, `map_async`
130    /// ranges, and STORAGE-bound bindings to be multiples of 4, so an
131    /// odd-sized allocation (3 bytes, or an odd count of 2-byte f16 elements)
132    /// would otherwise pass `alloc()` cleanly and only fail later — fatally,
133    /// with no handler installed — on the first readback or bind. The
134    /// rounded-up size is what later `copy_to_device`/`copy_from_device`
135    /// calls validate against, which is strictly more permissive than the
136    /// caller's requested size, never less.
137    ///
138    /// Returns [`WebGpuError::InvalidArgument`] for a zero-byte request and
139    /// [`WebGpuError::OutOfMemory`] if the (rounded) size exceeds what this
140    /// device can bind — checked against the adapter-derived limits resolved
141    /// in [`WebGpuDevice::new`], instead of discovered via a fatal wgpu
142    /// validation abort inside `create_buffer`.
143    pub fn alloc(&self, bytes: usize) -> WebGpuResult<u64> {
144        self.ensure_device_alive()?;
145
146        if bytes == 0 {
147            return Err(WebGpuError::InvalidArgument(
148                "alloc: cannot allocate a zero-byte buffer".into(),
149            ));
150        }
151
152        let size = (bytes as u64).next_multiple_of(wgpu::COPY_BUFFER_ALIGNMENT);
153
154        let limits = self.device.limits();
155        if size > limits.max_buffer_size || size > limits.max_storage_buffer_binding_size {
156            return Err(WebGpuError::OutOfMemory);
157        }
158
159        let buffer = self.device.device.create_buffer(&wgpu::BufferDescriptor {
160            label: Some("oxicuda-webgpu-buffer"),
161            size,
162            usage: wgpu::BufferUsages::STORAGE
163                | wgpu::BufferUsages::COPY_SRC
164                | wgpu::BufferUsages::COPY_DST,
165            mapped_at_creation: false,
166        });
167
168        // Defensive: the checks above should make wgpu's own validation a
169        // no-op, but if some constraint we did not anticipate fires anyway,
170        // surface it as a typed error instead of handing back a handle to a
171        // buffer wgpu silently rejected.
172        self.check_uncaptured_error()?;
173
174        let handle = self.next_handle.fetch_add(1, Ordering::Relaxed);
175
176        self.buffers
177            .lock()
178            .map_err(|_| WebGpuError::BufferMapping("mutex poisoned".into()))?
179            .insert(handle, WebGpuBufferInfo { buffer, size });
180
181        Ok(handle)
182    }
183
184    /// Release the buffer associated with `handle`.
185    ///
186    /// The handle is silently ignored if it is unknown (already freed).
187    pub fn free(&self, handle: u64) -> WebGpuResult<()> {
188        self.buffers
189            .lock()
190            .map_err(|_| WebGpuError::BufferMapping("mutex poisoned".into()))?
191            .remove(&handle);
192        Ok(())
193    }
194
195    /// Upload `src` (host bytes) into the device buffer identified by `handle`.
196    pub fn copy_to_device(&self, handle: u64, src: &[u8]) -> WebGpuResult<()> {
197        self.ensure_device_alive()?;
198
199        let buffers = self
200            .buffers
201            .lock()
202            .map_err(|_| WebGpuError::BufferMapping("mutex poisoned".into()))?;
203
204        let buf_info = buffers
205            .get(&handle)
206            .ok_or_else(|| WebGpuError::InvalidArgument(format!("unknown handle {handle}")))?;
207
208        // Reject oversize uploads before touching wgpu: `Queue::write_buffer`
209        // validates `offset + src.len() <= buffer.size` and, with no custom
210        // uncaptured-error handler installed, an overrun aborts the process via
211        // wgpu's default fatal handler.  Surface it as a clean typed error.
212        if src.len() as u64 > buf_info.size {
213            return Err(WebGpuError::InvalidArgument(format!(
214                "copy_to_device: source is {} bytes but buffer holds only {} bytes",
215                src.len(),
216                buf_info.size
217            )));
218        }
219
220        // `Queue::write_buffer` separately validates the *copy size* itself
221        // against `wgpu::COPY_BUFFER_ALIGNMENT` (4 bytes) — independent of
222        // the destination buffer's own (already alignment-padded) size, a
223        // 3-byte write into a legally allocated 4-byte buffer is still
224        // rejected ("Copy size 3 does not respect COPY_BUFFER_ALIGNMENT").
225        // Pad the write up to the alignment with zero bytes when needed;
226        // `alloc()` guarantees the destination buffer is at least
227        // `src.len()` rounded up to that same alignment, so this never
228        // overruns it. The common (already-aligned) case takes the
229        // zero-copy path.
230        if src.len() as u64 % wgpu::COPY_BUFFER_ALIGNMENT == 0 {
231            self.device.queue.write_buffer(&buf_info.buffer, 0, src);
232        } else {
233            let padded_len =
234                (src.len() as u64).next_multiple_of(wgpu::COPY_BUFFER_ALIGNMENT) as usize;
235            let mut padded = vec![0u8; padded_len];
236            padded[..src.len()].copy_from_slice(src);
237            self.device.queue.write_buffer(&buf_info.buffer, 0, &padded);
238        }
239        drop(buffers);
240
241        self.check_uncaptured_error()?;
242        Ok(())
243    }
244
245    /// Lock the internal buffer map and return a guard for direct access.
246    ///
247    /// Used by the backend to look up multiple buffers within a single lock scope
248    /// (e.g. when building wgpu bind groups for compute passes).
249    pub(crate) fn lock_buffers(
250        &self,
251    ) -> WebGpuResult<std::sync::MutexGuard<'_, HashMap<u64, WebGpuBufferInfo>>> {
252        self.buffers
253            .lock()
254            .map_err(|_| WebGpuError::BufferMapping("mutex poisoned".into()))
255    }
256
257    /// Download the device buffer identified by `handle` into `dst` (host bytes).
258    ///
259    /// Only the bytes actually requested (`dst.len()`, rounded up to
260    /// `wgpu::COPY_BUFFER_ALIGNMENT`) are staged and copied — previously this
261    /// always staged and DMA'd the *entire* source buffer regardless of how
262    /// much the caller asked for, so e.g. reading a single scalar out of a
263    /// multi-MiB reduction output moved the whole buffer across the copy
264    /// engine to deliver 4 bytes.
265    ///
266    /// Uses a temporary `MAP_READ` staging buffer and blocks — bounded by a
267    /// generous internal timeout, see [`WebGpuError::Timeout`] — until the
268    /// GPU work completes.
269    pub fn copy_from_device(&self, dst: &mut [u8], handle: u64) -> WebGpuResult<()> {
270        self.ensure_device_alive()?;
271
272        // Phase 1: acquire the lock, build a staging buffer sized to exactly
273        // what the caller asked for, and submit the copy.  The lock is
274        // dropped at the end of this block so that `wait_for_gpu` (Phase 2)
275        // does not hold the mutex.
276        let (staging, submission_index) = {
277            let buffers = self
278                .buffers
279                .lock()
280                .map_err(|_| WebGpuError::BufferMapping("mutex poisoned".into()))?;
281
282            let buf_info = buffers
283                .get(&handle)
284                .ok_or_else(|| WebGpuError::InvalidArgument(format!("unknown handle {handle}")))?;
285
286            // Match the `copy_dtoh` contract of the CPU reference backend: an
287            // oversized destination is a sizing error, not something to
288            // silently paper over by truncating (which would leave the tail
289            // of `dst` stale while still reporting success). A destination
290            // *smaller* than the buffer is the intended "sized-by-dst" read,
291            // and is exactly the case this narrowed staging path optimises.
292            if dst.len() as u64 > buf_info.size {
293                return Err(WebGpuError::InvalidArgument(format!(
294                    "copy_from_device: destination is {} bytes but buffer holds only {} bytes",
295                    dst.len(),
296                    buf_info.size
297                )));
298            }
299
300            // Round up to the copy-alignment requirement. `.min(buf_info.size)`
301            // is a defensive bound that is a no-op today: `alloc()` always
302            // stores a 4-byte-aligned physical size, and the oversize check
303            // above already guarantees `dst.len() <= buf_info.size`, so the
304            // rounded value can never exceed it.
305            let copy_len = (dst.len() as u64)
306                .next_multiple_of(wgpu::COPY_BUFFER_ALIGNMENT)
307                .min(buf_info.size);
308
309            let staging = self.device.device.create_buffer(&wgpu::BufferDescriptor {
310                label: Some("oxicuda-webgpu-staging"),
311                size: copy_len,
312                usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
313                mapped_at_creation: false,
314            });
315
316            let mut encoder =
317                self.device
318                    .device
319                    .create_command_encoder(&wgpu::CommandEncoderDescriptor {
320                        label: Some("oxicuda-webgpu-readback"),
321                    });
322
323            encoder.copy_buffer_to_buffer(&buf_info.buffer, 0, &staging, 0, copy_len);
324            let submission_index = self.device.queue.submit(std::iter::once(encoder.finish()));
325
326            (staging, submission_index)
327            // Mutex guard dropped here — lock released before the wait.
328        };
329
330        // Phase 2: map the staging buffer and read the data back to the host.
331        let slice = staging.slice(..);
332        let (tx, rx) = std::sync::mpsc::channel();
333        slice.map_async(wgpu::MapMode::Read, move |result| {
334            // Ignore send errors — the receiver may have been dropped.
335            let _ = tx.send(result);
336        });
337
338        // Block the calling thread until this specific submission (the copy,
339        // and — by queue-FIFO-ordering — everything it depends on) completes,
340        // or the bounded timeout elapses.
341        self.wait_for_gpu(submission_index)?;
342        self.check_uncaptured_error()?;
343
344        rx.recv()
345            .map_err(|_| WebGpuError::BufferMapping("channel closed before map completed".into()))?
346            .map_err(|e| WebGpuError::BufferMapping(format!("{e:?}")))?;
347
348        let data = slice.get_mapped_range();
349        let data_len = data.len() as u64;
350        // Belt-and-braces: the staging buffer was created at exactly
351        // `copy_len` and `copy_len >= dst.len()` was established above, so
352        // this should never trip — but return a clean error rather than
353        // panic on an out-of-bounds slice if some future change violates
354        // that invariant.
355        if data_len < dst.len() as u64 {
356            drop(data);
357            staging.unmap();
358            return Err(WebGpuError::BufferMapping(format!(
359                "copy_from_device: mapped range is {data_len} bytes but the destination needs {} bytes",
360                dst.len(),
361            )));
362        }
363        dst.copy_from_slice(&data[..dst.len()]);
364        drop(data);
365        staging.unmap();
366
367        Ok(())
368    }
369}
370
371impl std::fmt::Debug for WebGpuMemoryManager {
372    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
373        let count = self.buffers.lock().map(|b| b.len()).unwrap_or(0);
374        write!(f, "WebGpuMemoryManager(buffers={})", count)
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use crate::device::WebGpuDevice;
382
383    fn try_get_device() -> Option<Arc<WebGpuDevice>> {
384        WebGpuDevice::new().ok().map(Arc::new)
385    }
386
387    #[test]
388    fn alloc_and_free_requires_device() {
389        let Some(dev) = try_get_device() else {
390            // No GPU — skip.
391            return;
392        };
393        let mm = WebGpuMemoryManager::new(dev);
394        let h = mm.alloc(256).expect("alloc 256 bytes");
395        assert!(h > 0);
396        mm.free(h).expect("free");
397        // Double-free is silently ignored.
398        mm.free(h).expect("double-free is a no-op");
399    }
400
401    #[test]
402    fn copy_roundtrip_requires_device() {
403        let Some(dev) = try_get_device() else {
404            return;
405        };
406        let mm = WebGpuMemoryManager::new(dev);
407
408        let src: Vec<u8> = (0u8..64).collect();
409        let h = mm.alloc(src.len()).expect("alloc");
410        mm.copy_to_device(h, &src).expect("copy_to_device");
411
412        let mut dst = vec![0u8; src.len()];
413        mm.copy_from_device(&mut dst, h).expect("copy_from_device");
414
415        assert_eq!(src, dst);
416        mm.free(h).expect("free");
417    }
418
419    #[test]
420    fn unknown_handle_returns_error() {
421        let Some(dev) = try_get_device() else {
422            return;
423        };
424        let mm = WebGpuMemoryManager::new(dev);
425        let err = mm.copy_to_device(9999, b"hello").unwrap_err();
426        assert!(matches!(err, WebGpuError::InvalidArgument(_)));
427    }
428
429    #[test]
430    fn copy_to_device_oversize_errors() {
431        let Some(dev) = try_get_device() else {
432            return;
433        };
434        let mm = WebGpuMemoryManager::new(dev);
435        let h = mm.alloc(16).expect("alloc 16 bytes");
436        // 64 bytes into a 16-byte buffer must return a clean error, not panic.
437        let err = mm.copy_to_device(h, &[0u8; 64]).unwrap_err();
438        assert!(matches!(err, WebGpuError::InvalidArgument(_)));
439        mm.free(h).expect("free");
440    }
441
442    #[test]
443    fn copy_from_device_oversize_dst_errors() {
444        let Some(dev) = try_get_device() else {
445            return;
446        };
447        let mm = WebGpuMemoryManager::new(dev);
448        let h = mm.alloc(16).expect("alloc 16 bytes");
449        // Destination larger than the source buffer must error rather than
450        // silently truncate and report success.
451        let mut dst = vec![0u8; 64];
452        let err = mm.copy_from_device(&mut dst, h).unwrap_err();
453        assert!(matches!(err, WebGpuError::InvalidArgument(_)));
454        mm.free(h).expect("free");
455    }
456
457    #[test]
458    fn alloc_rejects_zero_bytes() {
459        let Some(dev) = try_get_device() else {
460            return;
461        };
462        let mm = WebGpuMemoryManager::new(dev);
463        let err = mm.alloc(0).unwrap_err();
464        assert!(matches!(err, WebGpuError::InvalidArgument(_)));
465    }
466
467    /// A non-multiple-of-4 allocation (e.g. 3 f16 elements = 6 bytes) must
468    /// round up cleanly and round-trip a full copy_htod/copy_dtoh, instead of
469    /// hitting wgpu's fatal validation path on the first readback.
470    #[test]
471    fn alloc_odd_size_roundtrips() {
472        let Some(dev) = try_get_device() else {
473            return;
474        };
475        let mm = WebGpuMemoryManager::new(dev);
476
477        for &n in &[3usize, 6, 7] {
478            let src: Vec<u8> = (0..n as u8).collect();
479            let h = mm
480                .alloc(n)
481                .unwrap_or_else(|e| panic!("alloc({n}) failed: {e}"));
482            mm.copy_to_device(h, &src).expect("copy_to_device");
483
484            let mut dst = vec![0u8; n];
485            mm.copy_from_device(&mut dst, h).expect("copy_from_device");
486            assert_eq!(src, dst, "roundtrip mismatch for a {n}-byte allocation");
487
488            mm.free(h).expect("free");
489        }
490    }
491
492    #[test]
493    fn alloc_rejects_oversize_allocation() {
494        let Some(dev) = try_get_device() else {
495            return;
496        };
497        let too_big = dev.limits().max_buffer_size.saturating_add(4);
498        let mm = WebGpuMemoryManager::new(Arc::clone(&dev));
499        let err = mm.alloc(too_big as usize).unwrap_err();
500        assert!(matches!(err, WebGpuError::OutOfMemory));
501    }
502
503    /// A destination smaller than the source buffer is the intended
504    /// "sized-by-dst" read: only the first `dst.len()` bytes should come
505    /// back, correctly, even though only that narrower range is now staged
506    /// and DMA'd (previously the whole buffer was always copied).
507    #[test]
508    fn copy_from_device_reads_only_requested_prefix() {
509        let Some(dev) = try_get_device() else {
510            return;
511        };
512        let mm = WebGpuMemoryManager::new(dev);
513
514        let src: Vec<u8> = (0..=255u8).collect(); // 256 distinct bytes.
515        let h = mm.alloc(src.len()).expect("alloc 256 bytes");
516        mm.copy_to_device(h, &src).expect("copy_to_device");
517
518        let mut dst = vec![0u8; 8];
519        mm.copy_from_device(&mut dst, h).expect("copy_from_device");
520        assert_eq!(dst, src[..8]);
521
522        mm.free(h).expect("free");
523    }
524
525    #[test]
526    fn check_uncaptured_error_surfaces_recorded_error() {
527        let Some(dev) = try_get_device() else {
528            return;
529        };
530        let mm = WebGpuMemoryManager::new(Arc::clone(&dev));
531
532        // Trigger a real uncaptured wgpu error directly against the raw
533        // device (bypassing `alloc()`'s own guards) to prove
534        // `check_uncaptured_error` surfaces it as a typed `Err`.
535        let _bogus = dev.device.create_buffer(&wgpu::BufferDescriptor {
536            label: Some("oxicuda-webgpu-test-oversize"),
537            size: u64::MAX,
538            usage: wgpu::BufferUsages::STORAGE
539                | wgpu::BufferUsages::COPY_SRC
540                | wgpu::BufferUsages::COPY_DST,
541            mapped_at_creation: false,
542        });
543
544        let err = mm.check_uncaptured_error().unwrap_err();
545        assert!(matches!(err, WebGpuError::UncapturedError(_)));
546    }
547
548    #[test]
549    fn operations_fail_fast_once_device_is_lost() {
550        let Some(dev) = try_get_device() else {
551            return;
552        };
553        let mm = WebGpuMemoryManager::new(Arc::clone(&dev));
554
555        dev.device.destroy();
556        for _ in 0..20 {
557            if dev.is_device_lost() {
558                break;
559            }
560            let _ = dev.device.poll(wgpu::PollType::wait_indefinitely());
561            std::thread::sleep(Duration::from_millis(10));
562        }
563        assert!(dev.is_device_lost(), "precondition: device should be lost");
564
565        let err = mm.alloc(64).unwrap_err();
566        assert!(matches!(err, WebGpuError::DeviceLost(_)));
567    }
568
569    #[test]
570    fn poll_timeout_maps_to_webgpu_timeout_error() {
571        let err = poll_result_to_webgpu_result(Err(wgpu::PollError::Timeout)).unwrap_err();
572        assert!(matches!(err, WebGpuError::Timeout));
573    }
574
575    #[test]
576    fn poll_wrong_submission_index_maps_to_buffer_mapping_error() {
577        let err = poll_result_to_webgpu_result(Err(wgpu::PollError::WrongSubmissionIndex(2, 1)))
578            .unwrap_err();
579        assert!(matches!(err, WebGpuError::BufferMapping(_)));
580    }
581
582    #[test]
583    fn poll_ok_maps_to_ok() {
584        assert!(poll_result_to_webgpu_result(Ok(wgpu::PollStatus::QueueEmpty)).is_ok());
585    }
586}