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 // `cuMemcpyHtoD_v2` is only "synchronous" in the sense that it returns
230 // once `src` (pageable memory) has been staged into the driver's DMA
231 // buffer -- the transfer to device memory itself completes later, on the
232 // legacy default stream. Every OxiCUDA `Stream` is created with
233 // `CU_STREAM_NON_BLOCKING`, which by definition does *not* implicitly
234 // synchronise with the default stream, so a kernel or copy issued on one
235 // can observe this buffer before the upload lands and silently read
236 // zeros. Block until the DMA has completed, mirroring `zeroed`.
237 oxicuda_driver::check(unsafe { (api.cu_ctx_synchronize)() })
238 }
239
240 /// Copies this device buffer's contents into a host slice (synchronous).
241 ///
242 /// The slice length must exactly match the buffer length.
243 ///
244 /// # Errors
245 ///
246 /// * [`CudaError::InvalidValue`] if `dst.len() != self.len()`.
247 /// * Other driver errors from `cuMemcpyDtoH_v2`.
248 pub fn copy_to_host(&self, dst: &mut [T]) -> CudaResult<()> {
249 if dst.len() != self.len {
250 return Err(CudaError::InvalidValue);
251 }
252 let api = try_driver()?;
253 // SAFETY: `dst` is a valid host slice with the correct byte count.
254 let rc = unsafe {
255 (api.cu_memcpy_dtoh_v2)(
256 dst.as_mut_ptr().cast::<c_void>(),
257 self.ptr,
258 self.byte_size(),
259 )
260 };
261 oxicuda_driver::check(rc)
262 }
263
264 /// Copies the entire contents of another device buffer into this one.
265 ///
266 /// Both buffers must have the same length.
267 ///
268 /// # Errors
269 ///
270 /// * [`CudaError::InvalidValue`] if `src.len() != self.len()`.
271 /// * Other driver errors from `cuMemcpyDtoD_v2`.
272 pub fn copy_from_device(&mut self, src: &DeviceBuffer<T>) -> CudaResult<()> {
273 if src.len != self.len {
274 return Err(CudaError::InvalidValue);
275 }
276 let api = try_driver()?;
277 // SAFETY: both pointers are valid device allocations of the same size.
278 let rc = unsafe { (api.cu_memcpy_dtod_v2)(self.ptr, src.ptr, self.byte_size()) };
279 oxicuda_driver::check(rc)
280 }
281
282 /// Asynchronously copies data from a host slice into this device buffer.
283 ///
284 /// The copy is enqueued on `stream` and may not be complete when this
285 /// function returns. The caller must ensure that `src` remains valid
286 /// (i.e., is not moved or dropped) until the stream has been
287 /// synchronised. For guaranteed correctness, prefer using a
288 /// [`PinnedBuffer`](crate::PinnedBuffer) as the source.
289 ///
290 /// # Errors
291 ///
292 /// * [`CudaError::InvalidValue`] if `src.len() != self.len()`.
293 /// * Other driver errors from `cuMemcpyHtoDAsync_v2`.
294 pub fn copy_from_host_async(&mut self, src: &[T], stream: &Stream) -> CudaResult<()> {
295 if src.len() != self.len {
296 return Err(CudaError::InvalidValue);
297 }
298 let api = try_driver()?;
299 // SAFETY: the caller is responsible for keeping `src` alive until
300 // the stream completes.
301 let rc = unsafe {
302 (api.cu_memcpy_htod_async_v2)(
303 self.ptr,
304 src.as_ptr().cast::<c_void>(),
305 self.byte_size(),
306 stream.raw(),
307 )
308 };
309 oxicuda_driver::check(rc)
310 }
311
312 /// Asynchronously copies this device buffer's contents into a host slice.
313 ///
314 /// The copy is enqueued on `stream` and may not be complete when this
315 /// function returns. The caller must ensure that `dst` remains valid
316 /// and is not read until the stream has been synchronised. For
317 /// guaranteed correctness, prefer using a
318 /// [`PinnedBuffer`](crate::PinnedBuffer) as the destination.
319 ///
320 /// # Errors
321 ///
322 /// * [`CudaError::InvalidValue`] if `dst.len() != self.len()`.
323 /// * Other driver errors from `cuMemcpyDtoHAsync_v2`.
324 pub fn copy_to_host_async(&self, dst: &mut [T], stream: &Stream) -> CudaResult<()> {
325 if dst.len() != self.len {
326 return Err(CudaError::InvalidValue);
327 }
328 let api = try_driver()?;
329 // SAFETY: the caller is responsible for keeping `dst` alive until
330 // the stream completes.
331 let rc = unsafe {
332 (api.cu_memcpy_dtoh_async_v2)(
333 dst.as_mut_ptr().cast::<c_void>(),
334 self.ptr,
335 self.byte_size(),
336 stream.raw(),
337 )
338 };
339 oxicuda_driver::check(rc)
340 }
341
342 /// Returns the number of `T` elements in this buffer.
343 #[inline]
344 pub fn len(&self) -> usize {
345 self.len
346 }
347
348 /// Returns `true` if the buffer contains zero elements.
349 ///
350 /// In practice this is always `false` because [`alloc`](Self::alloc)
351 /// rejects zero-length allocations.
352 #[inline]
353 pub fn is_empty(&self) -> bool {
354 self.len == 0
355 }
356
357 /// Returns the total size of the allocation in bytes.
358 #[inline]
359 pub fn byte_size(&self) -> usize {
360 self.len * std::mem::size_of::<T>()
361 }
362
363 /// Returns the raw [`CUdeviceptr`] handle for this buffer.
364 ///
365 /// This is useful when passing the pointer to kernel launch parameters
366 /// or other low-level driver calls.
367 #[inline]
368 pub fn as_device_ptr(&self) -> CUdeviceptr {
369 self.ptr
370 }
371
372 /// Returns a borrowed [`DeviceSlice`] referencing a sub-range of this
373 /// buffer starting at element `offset` and spanning `len` elements.
374 ///
375 /// # Errors
376 ///
377 /// Returns [`CudaError::InvalidValue`] if the requested range exceeds
378 /// the buffer bounds (i.e., `offset + len > self.len()`).
379 pub fn slice(&self, offset: usize, len: usize) -> CudaResult<DeviceSlice<'_, T>> {
380 let end = offset.checked_add(len).ok_or(CudaError::InvalidValue)?;
381 if end > self.len {
382 return Err(CudaError::InvalidValue);
383 }
384 let byte_offset = offset
385 .checked_mul(std::mem::size_of::<T>())
386 .ok_or(CudaError::InvalidValue)?;
387 Ok(DeviceSlice {
388 ptr: self.ptr + byte_offset as u64,
389 len,
390 _phantom: PhantomData,
391 })
392 }
393}
394
395impl<T: Copy> Drop for DeviceBuffer<T> {
396 fn drop(&mut self) {
397 // Non-owning views (created via `from_raw`) borrow an externally-owned
398 // allocation and must never free it.
399 if !self.owned {
400 return;
401 }
402 if let Ok(api) = try_driver() {
403 // SAFETY: `self.ptr` was allocated by `cu_mem_alloc_v2` and has
404 // not yet been freed.
405 let rc = unsafe { (api.cu_mem_free_v2)(self.ptr) };
406 if rc != 0 {
407 tracing::warn!(
408 cuda_error = rc,
409 ptr = self.ptr,
410 len = self.len,
411 "cuMemFree_v2 failed during DeviceBuffer drop"
412 );
413 }
414 }
415 }
416}
417
418// ---------------------------------------------------------------------------
419// DeviceSlice<'a, T>
420// ---------------------------------------------------------------------------
421
422/// A borrowed, non-owning view into a sub-range of a [`DeviceBuffer`].
423///
424/// A `DeviceSlice` does not own the memory it points to — it borrows from
425/// the parent [`DeviceBuffer`] and is lifetime-bound to it. This is useful
426/// for passing sub-regions of a buffer to kernels or copy operations without
427/// extra allocations.
428///
429/// `DeviceSlice` does **not** implement [`Drop`]; the parent buffer is
430/// responsible for freeing the allocation.
431pub struct DeviceSlice<'a, T: Copy> {
432 /// Raw device pointer to the start of this slice within the parent buffer.
433 ptr: CUdeviceptr,
434 /// Number of `T` elements in this slice.
435 len: usize,
436 /// Ties the lifetime to the parent buffer and the element type.
437 _phantom: PhantomData<&'a T>,
438}
439
440impl<T: Copy> DeviceSlice<'_, T> {
441 /// Returns the number of `T` elements in this slice.
442 #[inline]
443 pub fn len(&self) -> usize {
444 self.len
445 }
446
447 /// Returns `true` if the slice contains zero elements.
448 #[inline]
449 pub fn is_empty(&self) -> bool {
450 self.len == 0
451 }
452
453 /// Returns the total size of this slice in bytes.
454 #[inline]
455 pub fn byte_size(&self) -> usize {
456 self.len * std::mem::size_of::<T>()
457 }
458
459 /// Returns the raw [`CUdeviceptr`] handle for the start of this slice.
460 #[inline]
461 pub fn as_device_ptr(&self) -> CUdeviceptr {
462 self.ptr
463 }
464}
465
466// ---------------------------------------------------------------------------
467// Tests
468// ---------------------------------------------------------------------------
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473
474 /// A `from_raw` view must be marked non-owning so that `Drop` skips the
475 /// `cuMemFree_v2` call. We construct over a dummy sentinel pointer; because
476 /// the view is non-owning, dropping it performs no driver call and is safe
477 /// even without a CUDA device present.
478 #[test]
479 fn from_raw_is_non_owning() {
480 let sentinel: CUdeviceptr = 0xDEAD_BEEF;
481 // SAFETY: this view is never dereferenced; we only inspect metadata and
482 // rely on the non-owning Drop being a no-op.
483 let view = unsafe { DeviceBuffer::<f32>::from_raw(sentinel, 16) };
484 assert!(!view.owned, "from_raw must produce a non-owning buffer");
485 assert_eq!(view.len(), 16);
486 assert_eq!(view.as_device_ptr(), sentinel);
487 assert_eq!(view.byte_size(), 16 * std::mem::size_of::<f32>());
488 // Dropping a non-owning view must NOT touch the driver / free memory.
489 // Reaching the end of scope here exercises that path without a GPU.
490 drop(view);
491 }
492
493 /// A zero-length `from_raw` view is permitted (no allocation occurs) and is
494 /// reported as empty.
495 #[test]
496 fn from_raw_zero_len_is_empty() {
497 // SAFETY: zero-length, pointer never dereferenced; Drop is a no-op.
498 let view = unsafe { DeviceBuffer::<u8>::from_raw(0, 0) };
499 assert!(!view.owned);
500 assert!(view.is_empty());
501 assert_eq!(view.len(), 0);
502 assert_eq!(view.byte_size(), 0);
503 }
504
505 /// Two non-owning views may share the same pointer without risking a
506 /// double-free, because neither frees on drop. This models a consumer
507 /// re-wrapping the same resident allocation.
508 #[test]
509 fn from_raw_aliasing_views_do_not_double_free() {
510 let ptr: CUdeviceptr = 0x1000;
511 // SAFETY: non-owning aliases, never dereferenced; both Drops are no-ops.
512 let a = unsafe { DeviceBuffer::<f64>::from_raw(ptr, 8) };
513 let b = unsafe { DeviceBuffer::<f64>::from_raw(ptr, 8) };
514 assert!(!a.owned);
515 assert!(!b.owned);
516 assert_eq!(a.as_device_ptr(), b.as_device_ptr());
517 drop(a);
518 drop(b);
519 }
520
521 /// A real owning allocation created via `alloc` is marked `owned` so that
522 /// its memory is freed on drop. This requires a CUDA device, so it is gated
523 /// behind a runtime driver check and skipped (passing) when no GPU/driver
524 /// is available — keeping the test green on macOS while still proving the
525 /// owned-flag wiring on real hardware.
526 #[test]
527 fn alloc_is_owning_when_driver_available() {
528 match DeviceBuffer::<f32>::alloc(32) {
529 Ok(buf) => {
530 assert!(buf.owned, "alloc must produce an owning buffer");
531 assert_eq!(buf.len(), 32);
532 // `buf` is dropped here and frees its allocation via the driver.
533 }
534 Err(_) => {
535 // No CUDA driver/device on this host (e.g. macOS CI): the
536 // owned-flag logic is covered by the non-GPU tests above.
537 }
538 }
539 }
540}