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};
12
13use wgpu;
14
15use crate::{
16    device::WebGpuDevice,
17    error::{WebGpuError, WebGpuResult},
18};
19
20// ─── Buffer bookkeeping ──────────────────────────────────────────────────────
21
22/// Internal record for a single allocated `wgpu::Buffer`.
23pub struct WebGpuBufferInfo {
24    /// The GPU-resident buffer.
25    pub buffer: wgpu::Buffer,
26    /// Byte size of the buffer.
27    pub size: u64,
28}
29
30// ─── Memory manager ──────────────────────────────────────────────────────────
31
32/// Manages a pool of device-resident `wgpu::Buffer` objects, returning opaque
33/// `u64` handles to callers.
34///
35/// All public methods are `&self` to allow shared references from the backend.
36pub struct WebGpuMemoryManager {
37    device: Arc<WebGpuDevice>,
38    buffers: Mutex<HashMap<u64, WebGpuBufferInfo>>,
39    next_handle: AtomicU64,
40}
41
42impl WebGpuMemoryManager {
43    /// Create a new memory manager backed by `device`.
44    pub fn new(device: Arc<WebGpuDevice>) -> Self {
45        Self {
46            device,
47            buffers: Mutex::new(HashMap::new()),
48            next_handle: AtomicU64::new(1),
49        }
50    }
51
52    /// Allocate a new device buffer of `bytes` bytes.
53    ///
54    /// Returns an opaque handle that identifies the buffer.
55    pub fn alloc(&self, bytes: usize) -> WebGpuResult<u64> {
56        let size = bytes as u64;
57        let buffer = self.device.device.create_buffer(&wgpu::BufferDescriptor {
58            label: Some("oxicuda-webgpu-buffer"),
59            size,
60            usage: wgpu::BufferUsages::STORAGE
61                | wgpu::BufferUsages::COPY_SRC
62                | wgpu::BufferUsages::COPY_DST,
63            mapped_at_creation: false,
64        });
65
66        let handle = self.next_handle.fetch_add(1, Ordering::Relaxed);
67
68        self.buffers
69            .lock()
70            .map_err(|_| WebGpuError::BufferMapping("mutex poisoned".into()))?
71            .insert(handle, WebGpuBufferInfo { buffer, size });
72
73        Ok(handle)
74    }
75
76    /// Release the buffer associated with `handle`.
77    ///
78    /// The handle is silently ignored if it is unknown (already freed).
79    pub fn free(&self, handle: u64) -> WebGpuResult<()> {
80        self.buffers
81            .lock()
82            .map_err(|_| WebGpuError::BufferMapping("mutex poisoned".into()))?
83            .remove(&handle);
84        Ok(())
85    }
86
87    /// Upload `src` (host bytes) into the device buffer identified by `handle`.
88    pub fn copy_to_device(&self, handle: u64, src: &[u8]) -> WebGpuResult<()> {
89        let buffers = self
90            .buffers
91            .lock()
92            .map_err(|_| WebGpuError::BufferMapping("mutex poisoned".into()))?;
93
94        let buf_info = buffers
95            .get(&handle)
96            .ok_or_else(|| WebGpuError::InvalidArgument(format!("unknown handle {handle}")))?;
97
98        // Reject oversize uploads before touching wgpu: `Queue::write_buffer`
99        // validates `offset + src.len() <= buffer.size` and, with no custom
100        // uncaptured-error handler installed, an overrun aborts the process via
101        // wgpu's default fatal handler.  Surface it as a clean typed error.
102        if src.len() as u64 > buf_info.size {
103            return Err(WebGpuError::InvalidArgument(format!(
104                "copy_to_device: source is {} bytes but buffer holds only {} bytes",
105                src.len(),
106                buf_info.size
107            )));
108        }
109
110        self.device.queue.write_buffer(&buf_info.buffer, 0, src);
111        Ok(())
112    }
113
114    /// Lock the internal buffer map and return a guard for direct access.
115    ///
116    /// Used by the backend to look up multiple buffers within a single lock scope
117    /// (e.g. when building wgpu bind groups for compute passes).
118    pub(crate) fn lock_buffers(
119        &self,
120    ) -> WebGpuResult<std::sync::MutexGuard<'_, HashMap<u64, WebGpuBufferInfo>>> {
121        self.buffers
122            .lock()
123            .map_err(|_| WebGpuError::BufferMapping("mutex poisoned".into()))
124    }
125
126    /// Download the device buffer identified by `handle` into `dst` (host bytes).
127    ///
128    /// Uses a temporary `MAP_READ` staging buffer and blocks until the GPU
129    /// work completes.
130    pub fn copy_from_device(&self, dst: &mut [u8], handle: u64) -> WebGpuResult<()> {
131        // Phase 1: acquire the lock, build a staging buffer + command encoder,
132        // and submit the copy.  The lock is dropped at the end of this block so
133        // that `device.poll()` (Phase 2) does not hold the mutex.
134        let staging = {
135            let buffers = self
136                .buffers
137                .lock()
138                .map_err(|_| WebGpuError::BufferMapping("mutex poisoned".into()))?;
139
140            let buf_info = buffers
141                .get(&handle)
142                .ok_or_else(|| WebGpuError::InvalidArgument(format!("unknown handle {handle}")))?;
143
144            let staging = self.device.device.create_buffer(&wgpu::BufferDescriptor {
145                label: Some("oxicuda-webgpu-staging"),
146                size: buf_info.size,
147                usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
148                mapped_at_creation: false,
149            });
150
151            let mut encoder =
152                self.device
153                    .device
154                    .create_command_encoder(&wgpu::CommandEncoderDescriptor {
155                        label: Some("oxicuda-webgpu-readback"),
156                    });
157
158            encoder.copy_buffer_to_buffer(&buf_info.buffer, 0, &staging, 0, buf_info.size);
159            self.device.queue.submit(std::iter::once(encoder.finish()));
160
161            staging
162            // Mutex guard dropped here — lock released before poll.
163        };
164
165        // Phase 2: map the staging buffer and read the data back to the host.
166        let slice = staging.slice(..);
167        let (tx, rx) = std::sync::mpsc::channel();
168        slice.map_async(wgpu::MapMode::Read, move |result| {
169            // Ignore send errors — the receiver may have been dropped.
170            let _ = tx.send(result);
171        });
172
173        // Block the calling thread until all submitted GPU work (including the
174        // copy) is complete.
175        let _ = self.device.device.poll(wgpu::PollType::wait_indefinitely());
176
177        rx.recv()
178            .map_err(|_| WebGpuError::BufferMapping("channel closed before map completed".into()))?
179            .map_err(|e| WebGpuError::BufferMapping(format!("{e:?}")))?;
180
181        let data = slice.get_mapped_range();
182        let data_len = data.len();
183        // Match the `copy_dtoh` contract of the CPU reference backend: an
184        // oversized destination is a sizing error, not something to silently
185        // paper over by truncating (which would leave the tail of `dst` stale
186        // while still reporting success).  A destination *smaller* than the
187        // buffer is the intended "sized-by-dst" read and remains permitted.
188        if dst.len() > data_len {
189            drop(data);
190            staging.unmap();
191            return Err(WebGpuError::InvalidArgument(format!(
192                "copy_from_device: destination is {} bytes but buffer holds only {data_len} bytes",
193                dst.len(),
194            )));
195        }
196        let copy_len = dst.len();
197        dst[..copy_len].copy_from_slice(&data[..copy_len]);
198        drop(data);
199        staging.unmap();
200
201        Ok(())
202    }
203}
204
205impl std::fmt::Debug for WebGpuMemoryManager {
206    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207        let count = self.buffers.lock().map(|b| b.len()).unwrap_or(0);
208        write!(f, "WebGpuMemoryManager(buffers={})", count)
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::device::WebGpuDevice;
216
217    fn try_get_device() -> Option<Arc<WebGpuDevice>> {
218        WebGpuDevice::new().ok().map(Arc::new)
219    }
220
221    #[test]
222    fn alloc_and_free_requires_device() {
223        let Some(dev) = try_get_device() else {
224            // No GPU — skip.
225            return;
226        };
227        let mm = WebGpuMemoryManager::new(dev);
228        let h = mm.alloc(256).expect("alloc 256 bytes");
229        assert!(h > 0);
230        mm.free(h).expect("free");
231        // Double-free is silently ignored.
232        mm.free(h).expect("double-free is a no-op");
233    }
234
235    #[test]
236    fn copy_roundtrip_requires_device() {
237        let Some(dev) = try_get_device() else {
238            return;
239        };
240        let mm = WebGpuMemoryManager::new(dev);
241
242        let src: Vec<u8> = (0u8..64).collect();
243        let h = mm.alloc(src.len()).expect("alloc");
244        mm.copy_to_device(h, &src).expect("copy_to_device");
245
246        let mut dst = vec![0u8; src.len()];
247        mm.copy_from_device(&mut dst, h).expect("copy_from_device");
248
249        assert_eq!(src, dst);
250        mm.free(h).expect("free");
251    }
252
253    #[test]
254    fn unknown_handle_returns_error() {
255        let Some(dev) = try_get_device() else {
256            return;
257        };
258        let mm = WebGpuMemoryManager::new(dev);
259        let err = mm.copy_to_device(9999, b"hello").unwrap_err();
260        assert!(matches!(err, WebGpuError::InvalidArgument(_)));
261    }
262
263    #[test]
264    fn copy_to_device_oversize_errors() {
265        let Some(dev) = try_get_device() else {
266            return;
267        };
268        let mm = WebGpuMemoryManager::new(dev);
269        let h = mm.alloc(16).expect("alloc 16 bytes");
270        // 64 bytes into a 16-byte buffer must return a clean error, not panic.
271        let err = mm.copy_to_device(h, &[0u8; 64]).unwrap_err();
272        assert!(matches!(err, WebGpuError::InvalidArgument(_)));
273        mm.free(h).expect("free");
274    }
275
276    #[test]
277    fn copy_from_device_oversize_dst_errors() {
278        let Some(dev) = try_get_device() else {
279            return;
280        };
281        let mm = WebGpuMemoryManager::new(dev);
282        let h = mm.alloc(16).expect("alloc 16 bytes");
283        // Destination larger than the source buffer must error rather than
284        // silently truncate and report success.
285        let mut dst = vec![0u8; 64];
286        let err = mm.copy_from_device(&mut dst, h).unwrap_err();
287        assert!(matches!(err, WebGpuError::InvalidArgument(_)));
288        mm.free(h).expect("free");
289    }
290}