Skip to main content

oxicuda_memory/
device_buffer.rs

1//! Type-safe device (GPU VRAM) memory buffer.
2//!
3//! [`DeviceBuffer<T>`] owns a contiguous allocation of `T` elements in device
4//! memory.  It supports synchronous and asynchronous copies to/from host
5//! memory, device-to-device copies, and zero-initialisation via `cuMemsetD8`.
6//!
7//! The buffer is parameterised over `T: Copy` so that only plain-old-data
8//! types can be stored — no heap pointers that would be meaningless on the
9//! GPU.
10//!
11//! # Ownership
12//!
13//! The allocation is freed automatically when the buffer is dropped.  If
14//! `cuMemFree_v2` fails during [`Drop`], the error is logged via
15//! [`tracing::warn`] rather than panicking.
16//!
17//! # Example
18//!
19//! ```rust,no_run
20//! # use oxicuda_memory::DeviceBuffer;
21//! let mut buf = DeviceBuffer::<f32>::alloc(1024)?;
22//! let host_data = vec![1.0_f32; 1024];
23//! buf.copy_from_host(&host_data)?;
24//!
25//! let mut result = vec![0.0_f32; 1024];
26//! buf.copy_to_host(&mut result)?;
27//! assert_eq!(result, host_data);
28//! # Ok::<(), oxicuda_driver::error::CudaError>(())
29//! ```
30
31use std::ffi::c_void;
32use std::marker::PhantomData;
33
34use oxicuda_driver::error::{CudaError, CudaResult};
35use oxicuda_driver::ffi::CUdeviceptr;
36use oxicuda_driver::loader::try_driver;
37use oxicuda_driver::stream::Stream;
38
39// ---------------------------------------------------------------------------
40// DeviceBuffer<T>
41// ---------------------------------------------------------------------------
42
43/// A contiguous buffer of `T` elements allocated in GPU device memory.
44///
45/// The buffer owns the underlying `CUdeviceptr` allocation and frees it on
46/// drop.  All copy operations validate that source and destination lengths
47/// match, returning [`CudaError::InvalidValue`] on mismatch.
48pub struct DeviceBuffer<T: Copy> {
49    /// Raw CUDA device pointer to the start of the allocation.
50    ptr: CUdeviceptr,
51    /// Number of `T` elements (not bytes).
52    len: usize,
53    /// Whether this buffer owns its allocation and must free it on drop.
54    ///
55    /// `true` for buffers created via [`DeviceBuffer::alloc`],
56    /// [`DeviceBuffer::zeroed`], or [`DeviceBuffer::from_host`]; `false` for
57    /// non-owning views created via [`DeviceBuffer::from_raw`], which borrow an
58    /// externally-owned device pointer and must NOT free it on drop.
59    owned: bool,
60    /// Marker to tie the generic parameter `T` to this struct.
61    _phantom: PhantomData<T>,
62}
63
64// SAFETY: Device memory is not bound to a specific host thread.  The raw
65// pointer is a `u64` handle managed by the CUDA driver, which is thread-safe
66// for memory operations when properly synchronised.
67unsafe impl<T: Copy + Send> Send for DeviceBuffer<T> {}
68unsafe impl<T: Copy + Sync> Sync for DeviceBuffer<T> {}
69
70impl<T: Copy> DeviceBuffer<T> {
71    /// Allocates a device buffer capable of holding `n` elements of type `T`.
72    ///
73    /// # Errors
74    ///
75    /// * [`CudaError::InvalidValue`] if `n` is zero.
76    /// * [`CudaError::OutOfMemory`] if the GPU cannot satisfy the request.
77    /// * Other driver errors propagated from `cuMemAlloc_v2`.
78    pub fn alloc(n: usize) -> CudaResult<Self> {
79        if n == 0 {
80            return Err(CudaError::InvalidValue);
81        }
82        let byte_size = n
83            .checked_mul(std::mem::size_of::<T>())
84            .ok_or(CudaError::InvalidValue)?;
85        let api = try_driver()?;
86        let mut ptr: CUdeviceptr = 0;
87        // SAFETY: `cu_mem_alloc_v2` writes a valid device pointer on success.
88        let rc = unsafe { (api.cu_mem_alloc_v2)(&mut ptr, byte_size) };
89        oxicuda_driver::check(rc)?;
90        Ok(Self {
91            ptr,
92            len: n,
93            owned: true,
94            _phantom: PhantomData,
95        })
96    }
97
98    /// Allocates a device buffer of `n` elements and zero-initialises every byte.
99    ///
100    /// This is equivalent to [`alloc`](Self::alloc) followed by a
101    /// `cuMemsetD8_v2` call that writes `0` to every byte.
102    ///
103    /// The zero-fill is **fully completed on the device before this function
104    /// returns**: `cuMemsetD8_v2` is issued on the legacy default stream and is
105    /// asynchronous with respect to the host for device memory, so the returned
106    /// buffer would otherwise not be guaranteed zeroed relative to work later
107    /// submitted on a `CU_STREAM_NON_BLOCKING` stream (which does *not*
108    /// implicitly synchronise with the default stream). A context synchronise
109    /// after the memset makes the "every byte is 0" postcondition hold for any
110    /// consumer stream, closing a data race where a kernel on a non-blocking
111    /// stream could read/overwrite this buffer concurrently with the pending
112    /// zero-fill.
113    ///
114    /// # Errors
115    ///
116    /// Same as [`alloc`](Self::alloc), plus any error from `cuMemsetD8_v2` or
117    /// the context synchronise.
118    pub fn zeroed(n: usize) -> CudaResult<Self> {
119        let buf = Self::alloc(n)?;
120        let api = try_driver()?;
121        // SAFETY: the buffer was just allocated with the correct byte size.
122        let rc = unsafe { (api.cu_memset_d8_v2)(buf.ptr, 0, buf.byte_size()) };
123        oxicuda_driver::check(rc)?;
124        // The non-async memset runs on the legacy default stream and is host
125        // asynchronous for device memory; block until it has actually landed so
126        // the buffer is zeroed with respect to every stream, not just the
127        // default one. Synchronises the context current on this thread (the
128        // same one `alloc`/memset targeted).
129        oxicuda_driver::check(unsafe { (api.cu_ctx_synchronize)() })?;
130        Ok(buf)
131    }
132
133    /// Allocates a device buffer and copies the contents of `data` into it.
134    ///
135    /// The resulting buffer has the same length as the input slice.
136    ///
137    /// # Errors
138    ///
139    /// * [`CudaError::InvalidValue`] if `data` is empty.
140    /// * Other driver errors from allocation or the host-to-device copy.
141    pub fn from_host(data: &[T]) -> CudaResult<Self> {
142        let mut buf = Self::alloc(data.len())?;
143        buf.copy_from_host(data)?;
144        Ok(buf)
145    }
146
147    /// Wraps an externally-owned device pointer in a non-owning
148    /// [`DeviceBuffer`] view **without allocating**.
149    ///
150    /// The returned buffer points at the *existing* allocation described by
151    /// `ptr` and `len`, and exposes the full [`DeviceBuffer`] API (copies,
152    /// slicing, [`as_device_ptr`](Self::as_device_ptr), and use as a matrix
153    /// operand in `oxicuda-blas`) over that memory.  Because the view does not
154    /// own the allocation, its [`Drop`] is a no-op: it will **not** call
155    /// `cuMemFree_v2`.  Ownership and the lifetime of the underlying memory
156    /// remain entirely with the original owner (e.g. another CUDA library,
157    /// `cudarc`, or a foreign allocator).
158    ///
159    /// This enables zero-copy interop: a consumer that already holds a
160    /// resident device allocation can wrap it here and run OxiCUDA operations
161    /// in place, with no host round-trip and no extra device allocation.
162    ///
163    /// # Safety
164    ///
165    /// The caller must guarantee all of the following:
166    ///
167    /// * `ptr` is a valid CUDA device pointer into an allocation of at least
168    ///   `len * size_of::<T>()` bytes, correctly aligned for `T`, and
169    ///   associated with the CUDA context that subsequent OxiCUDA operations
170    ///   run under.
171    /// * The pointed-to memory contains a valid, initialised `[T; len]` (or is
172    ///   only used as a write target before being read).
173    /// * The underlying allocation **outlives** this `DeviceBuffer` view: the
174    ///   original owner must not free, reallocate, or invalidate `ptr` while
175    ///   this view (or any [`DeviceSlice`] borrowed from it) is alive.
176    /// * No other live `DeviceBuffer` owns the same `ptr` (to avoid a
177    ///   double-free) and aliasing rules are respected when the view is used
178    ///   mutably (e.g. as a [`MatrixDescMut`](../oxicuda_blas/struct.MatrixDescMut.html)
179    ///   output operand).
180    ///
181    /// A zero `len` is permitted (unlike [`alloc`](Self::alloc)) since no
182    /// allocation is performed; a `ptr` of `0` is also permitted for a
183    /// zero-length view, but pointer/length validity is the caller's
184    /// responsibility.
185    ///
186    /// # Example
187    ///
188    /// ```rust,no_run
189    /// # use oxicuda_memory::DeviceBuffer;
190    /// # use oxicuda_driver::ffi::CUdeviceptr;
191    /// // `raw` is a device pointer owned elsewhere (e.g. obtained from another
192    /// // CUDA library) pointing at `n` resident `f32` elements.
193    /// # let raw: CUdeviceptr = 0;
194    /// # let n: usize = 1024;
195    /// // SAFETY: `raw` is valid for `n` f32s and outlives `view`.
196    /// let view = unsafe { DeviceBuffer::<f32>::from_raw(raw, n) };
197    /// // `view` can now be used with oxicuda-blas / copies; dropping it does
198    /// // NOT free `raw`.
199    /// assert_eq!(view.len(), n);
200    /// ```
201    #[must_use]
202    pub unsafe fn from_raw(ptr: CUdeviceptr, len: usize) -> Self {
203        Self {
204            ptr,
205            len,
206            owned: false,
207            _phantom: PhantomData,
208        }
209    }
210
211    /// Copies data from a host slice into this device buffer (synchronous).
212    ///
213    /// The slice length must exactly match the buffer length.
214    ///
215    /// # Errors
216    ///
217    /// * [`CudaError::InvalidValue`] if `src.len() != self.len()`.
218    /// * Other driver errors from `cuMemcpyHtoD_v2`.
219    pub fn copy_from_host(&mut self, src: &[T]) -> CudaResult<()> {
220        if src.len() != self.len {
221            return Err(CudaError::InvalidValue);
222        }
223        let api = try_driver()?;
224        // SAFETY: `src` is a valid host slice with the correct byte count.
225        let rc = unsafe {
226            (api.cu_memcpy_htod_v2)(self.ptr, src.as_ptr().cast::<c_void>(), self.byte_size())
227        };
228        oxicuda_driver::check(rc)
229    }
230
231    /// Copies this device buffer's contents into a host slice (synchronous).
232    ///
233    /// The slice length must exactly match the buffer length.
234    ///
235    /// # Errors
236    ///
237    /// * [`CudaError::InvalidValue`] if `dst.len() != self.len()`.
238    /// * Other driver errors from `cuMemcpyDtoH_v2`.
239    pub fn copy_to_host(&self, dst: &mut [T]) -> CudaResult<()> {
240        if dst.len() != self.len {
241            return Err(CudaError::InvalidValue);
242        }
243        let api = try_driver()?;
244        // SAFETY: `dst` is a valid host slice with the correct byte count.
245        let rc = unsafe {
246            (api.cu_memcpy_dtoh_v2)(
247                dst.as_mut_ptr().cast::<c_void>(),
248                self.ptr,
249                self.byte_size(),
250            )
251        };
252        oxicuda_driver::check(rc)
253    }
254
255    /// Copies the entire contents of another device buffer into this one.
256    ///
257    /// Both buffers must have the same length.
258    ///
259    /// # Errors
260    ///
261    /// * [`CudaError::InvalidValue`] if `src.len() != self.len()`.
262    /// * Other driver errors from `cuMemcpyDtoD_v2`.
263    pub fn copy_from_device(&mut self, src: &DeviceBuffer<T>) -> CudaResult<()> {
264        if src.len != self.len {
265            return Err(CudaError::InvalidValue);
266        }
267        let api = try_driver()?;
268        // SAFETY: both pointers are valid device allocations of the same size.
269        let rc = unsafe { (api.cu_memcpy_dtod_v2)(self.ptr, src.ptr, self.byte_size()) };
270        oxicuda_driver::check(rc)
271    }
272
273    /// Asynchronously copies data from a host slice into this device buffer.
274    ///
275    /// The copy is enqueued on `stream` and may not be complete when this
276    /// function returns.  The caller must ensure that `src` remains valid
277    /// (i.e., is not moved or dropped) until the stream has been
278    /// synchronised.  For guaranteed correctness, prefer using a
279    /// [`PinnedBuffer`](crate::PinnedBuffer) as the source.
280    ///
281    /// # Errors
282    ///
283    /// * [`CudaError::InvalidValue`] if `src.len() != self.len()`.
284    /// * Other driver errors from `cuMemcpyHtoDAsync_v2`.
285    pub fn copy_from_host_async(&mut self, src: &[T], stream: &Stream) -> CudaResult<()> {
286        if src.len() != self.len {
287            return Err(CudaError::InvalidValue);
288        }
289        let api = try_driver()?;
290        // SAFETY: the caller is responsible for keeping `src` alive until
291        // the stream completes.
292        let rc = unsafe {
293            (api.cu_memcpy_htod_async_v2)(
294                self.ptr,
295                src.as_ptr().cast::<c_void>(),
296                self.byte_size(),
297                stream.raw(),
298            )
299        };
300        oxicuda_driver::check(rc)
301    }
302
303    /// Asynchronously copies this device buffer's contents into a host slice.
304    ///
305    /// The copy is enqueued on `stream` and may not be complete when this
306    /// function returns.  The caller must ensure that `dst` remains valid
307    /// and is not read until the stream has been synchronised.  For
308    /// guaranteed correctness, prefer using a
309    /// [`PinnedBuffer`](crate::PinnedBuffer) as the destination.
310    ///
311    /// # Errors
312    ///
313    /// * [`CudaError::InvalidValue`] if `dst.len() != self.len()`.
314    /// * Other driver errors from `cuMemcpyDtoHAsync_v2`.
315    pub fn copy_to_host_async(&self, dst: &mut [T], stream: &Stream) -> CudaResult<()> {
316        if dst.len() != self.len {
317            return Err(CudaError::InvalidValue);
318        }
319        let api = try_driver()?;
320        // SAFETY: the caller is responsible for keeping `dst` alive until
321        // the stream completes.
322        let rc = unsafe {
323            (api.cu_memcpy_dtoh_async_v2)(
324                dst.as_mut_ptr().cast::<c_void>(),
325                self.ptr,
326                self.byte_size(),
327                stream.raw(),
328            )
329        };
330        oxicuda_driver::check(rc)
331    }
332
333    /// Returns the number of `T` elements in this buffer.
334    #[inline]
335    pub fn len(&self) -> usize {
336        self.len
337    }
338
339    /// Returns `true` if the buffer contains zero elements.
340    ///
341    /// In practice this is always `false` because [`alloc`](Self::alloc)
342    /// rejects zero-length allocations.
343    #[inline]
344    pub fn is_empty(&self) -> bool {
345        self.len == 0
346    }
347
348    /// Returns the total size of the allocation in bytes.
349    #[inline]
350    pub fn byte_size(&self) -> usize {
351        self.len * std::mem::size_of::<T>()
352    }
353
354    /// Returns the raw [`CUdeviceptr`] handle for this buffer.
355    ///
356    /// This is useful when passing the pointer to kernel launch parameters
357    /// or other low-level driver calls.
358    #[inline]
359    pub fn as_device_ptr(&self) -> CUdeviceptr {
360        self.ptr
361    }
362
363    /// Returns a borrowed [`DeviceSlice`] referencing a sub-range of this
364    /// buffer starting at element `offset` and spanning `len` elements.
365    ///
366    /// # Errors
367    ///
368    /// Returns [`CudaError::InvalidValue`] if the requested range exceeds
369    /// the buffer bounds (i.e., `offset + len > self.len()`).
370    pub fn slice(&self, offset: usize, len: usize) -> CudaResult<DeviceSlice<'_, T>> {
371        let end = offset.checked_add(len).ok_or(CudaError::InvalidValue)?;
372        if end > self.len {
373            return Err(CudaError::InvalidValue);
374        }
375        let byte_offset = offset
376            .checked_mul(std::mem::size_of::<T>())
377            .ok_or(CudaError::InvalidValue)?;
378        Ok(DeviceSlice {
379            ptr: self.ptr + byte_offset as u64,
380            len,
381            _phantom: PhantomData,
382        })
383    }
384}
385
386impl<T: Copy> Drop for DeviceBuffer<T> {
387    fn drop(&mut self) {
388        // Non-owning views (created via `from_raw`) borrow an externally-owned
389        // allocation and must never free it.
390        if !self.owned {
391            return;
392        }
393        if let Ok(api) = try_driver() {
394            // SAFETY: `self.ptr` was allocated by `cu_mem_alloc_v2` and has
395            // not yet been freed.
396            let rc = unsafe { (api.cu_mem_free_v2)(self.ptr) };
397            if rc != 0 {
398                tracing::warn!(
399                    cuda_error = rc,
400                    ptr = self.ptr,
401                    len = self.len,
402                    "cuMemFree_v2 failed during DeviceBuffer drop"
403                );
404            }
405        }
406    }
407}
408
409// ---------------------------------------------------------------------------
410// DeviceSlice<'a, T>
411// ---------------------------------------------------------------------------
412
413/// A borrowed, non-owning view into a sub-range of a [`DeviceBuffer`].
414///
415/// A `DeviceSlice` does not own the memory it points to — it borrows from
416/// the parent [`DeviceBuffer`] and is lifetime-bound to it.  This is useful
417/// for passing sub-regions of a buffer to kernels or copy operations without
418/// extra allocations.
419///
420/// `DeviceSlice` does **not** implement [`Drop`]; the parent buffer is
421/// responsible for freeing the allocation.
422pub struct DeviceSlice<'a, T: Copy> {
423    /// Raw device pointer to the start of this slice within the parent buffer.
424    ptr: CUdeviceptr,
425    /// Number of `T` elements in this slice.
426    len: usize,
427    /// Ties the lifetime to the parent buffer and the element type.
428    _phantom: PhantomData<&'a T>,
429}
430
431impl<T: Copy> DeviceSlice<'_, T> {
432    /// Returns the number of `T` elements in this slice.
433    #[inline]
434    pub fn len(&self) -> usize {
435        self.len
436    }
437
438    /// Returns `true` if the slice contains zero elements.
439    #[inline]
440    pub fn is_empty(&self) -> bool {
441        self.len == 0
442    }
443
444    /// Returns the total size of this slice in bytes.
445    #[inline]
446    pub fn byte_size(&self) -> usize {
447        self.len * std::mem::size_of::<T>()
448    }
449
450    /// Returns the raw [`CUdeviceptr`] handle for the start of this slice.
451    #[inline]
452    pub fn as_device_ptr(&self) -> CUdeviceptr {
453        self.ptr
454    }
455}
456
457// ---------------------------------------------------------------------------
458// Tests
459// ---------------------------------------------------------------------------
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    /// A `from_raw` view must be marked non-owning so that `Drop` skips the
466    /// `cuMemFree_v2` call. We construct over a dummy sentinel pointer; because
467    /// the view is non-owning, dropping it performs no driver call and is safe
468    /// even without a CUDA device present.
469    #[test]
470    fn from_raw_is_non_owning() {
471        let sentinel: CUdeviceptr = 0xDEAD_BEEF;
472        // SAFETY: this view is never dereferenced; we only inspect metadata and
473        // rely on the non-owning Drop being a no-op.
474        let view = unsafe { DeviceBuffer::<f32>::from_raw(sentinel, 16) };
475        assert!(!view.owned, "from_raw must produce a non-owning buffer");
476        assert_eq!(view.len(), 16);
477        assert_eq!(view.as_device_ptr(), sentinel);
478        assert_eq!(view.byte_size(), 16 * std::mem::size_of::<f32>());
479        // Dropping a non-owning view must NOT touch the driver / free memory.
480        // Reaching the end of scope here exercises that path without a GPU.
481        drop(view);
482    }
483
484    /// A zero-length `from_raw` view is permitted (no allocation occurs) and is
485    /// reported as empty.
486    #[test]
487    fn from_raw_zero_len_is_empty() {
488        // SAFETY: zero-length, pointer never dereferenced; Drop is a no-op.
489        let view = unsafe { DeviceBuffer::<u8>::from_raw(0, 0) };
490        assert!(!view.owned);
491        assert!(view.is_empty());
492        assert_eq!(view.len(), 0);
493        assert_eq!(view.byte_size(), 0);
494    }
495
496    /// Two non-owning views may share the same pointer without risking a
497    /// double-free, because neither frees on drop. This models a consumer
498    /// re-wrapping the same resident allocation.
499    #[test]
500    fn from_raw_aliasing_views_do_not_double_free() {
501        let ptr: CUdeviceptr = 0x1000;
502        // SAFETY: non-owning aliases, never dereferenced; both Drops are no-ops.
503        let a = unsafe { DeviceBuffer::<f64>::from_raw(ptr, 8) };
504        let b = unsafe { DeviceBuffer::<f64>::from_raw(ptr, 8) };
505        assert!(!a.owned);
506        assert!(!b.owned);
507        assert_eq!(a.as_device_ptr(), b.as_device_ptr());
508        drop(a);
509        drop(b);
510    }
511
512    /// A real owning allocation created via `alloc` is marked `owned` so that
513    /// its memory is freed on drop. This requires a CUDA device, so it is gated
514    /// behind a runtime driver check and skipped (passing) when no GPU/driver
515    /// is available — keeping the test green on macOS while still proving the
516    /// owned-flag wiring on real hardware.
517    #[test]
518    fn alloc_is_owning_when_driver_available() {
519        match DeviceBuffer::<f32>::alloc(32) {
520            Ok(buf) => {
521                assert!(buf.owned, "alloc must produce an owning buffer");
522                assert_eq!(buf.len(), 32);
523                // `buf` is dropped here and frees its allocation via the driver.
524            }
525            Err(_) => {
526                // No CUDA driver/device on this host (e.g. macOS CI): the
527                // owned-flag logic is covered by the non-GPU tests above.
528            }
529        }
530    }
531}