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}