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, CUstream};
36use oxicuda_driver::loader::{DriverApi, try_driver};
37use oxicuda_driver::stream::Stream;
38
39// ---------------------------------------------------------------------------
40// Legacy-default-stream synchronisation
41// ---------------------------------------------------------------------------
42
43/// Blocks until the **legacy default stream** has drained, and *only* that
44/// stream.
45///
46/// # Why this exists (and why it is not `cuCtxSynchronize`)
47///
48/// The non-async driver transfer primitives this module uses — `cuMemsetD8_v2`
49/// (see [`DeviceBuffer::zeroed`]) and `cuMemcpyHtoD_v2` from pageable host
50/// memory (see [`DeviceBuffer::copy_from_host`]) — are documented as
51/// *asynchronous with respect to the host*: they enqueue onto the legacy
52/// default stream (the `NULL` stream) and return before the device-side work
53/// has landed. Every OxiCUDA [`Stream`] is created `CU_STREAM_NON_BLOCKING`
54/// (see [`Stream::new`]), which by definition opts **out** of the legacy
55/// stream's implicit ordering, so a consumer stream can observe the buffer
56/// before the memset/DMA completes — a real data race, not a theoretical one.
57/// Something must block until the legacy stream has drained.
58///
59/// `cuCtxSynchronize()` closes that race, but far too widely: it is documented
60/// as blocking "until the device has completed all preceding requested tasks"
61/// in the *current context* — i.e. every stream in the context, including the
62/// non-blocking ones that have nothing to do with this buffer. In a
63/// multi-stream pipeline (independent models on independent streams) that turns
64/// every `zeroed` / `copy_from_host` into a full device barrier and serialises
65/// unrelated work.
66///
67/// `cuStreamSynchronize(hStream)` is documented as waiting "until the device
68/// has completed all operations in the stream specified by `hStream`" — one
69/// stream's queue, not the context's. Passing a `NULL` handle
70/// ([`CUstream::default`], a null pointer) selects the default stream, which
71/// for a driver-API symbol resolved by name (`cuStreamSynchronize`, never the
72/// `_ptsz` per-thread-default alias the CUDA *Runtime* substitutes under
73/// `--default-stream per-thread`) is the legacy default stream — precisely the
74/// stream the memset / DMA above was enqueued on.
75///
76/// That makes this strictly narrower than `cuCtxSynchronize` while closing the
77/// exact same gap. The legacy stream's *implicit synchronisation* rule (an
78/// operation **enqueued into** it first waits for all preceding operations in
79/// the context's *blocking* streams) does not widen this call: that rule
80/// governs enqueued operations, not a host-side wait — and in any case OxiCUDA
81/// creates no blocking streams at all, so the set of streams it could pull in
82/// is empty.
83///
84/// Proven on-device by `tests/legacy_stream_sync_gpu.rs`: the legacy stream is
85/// still awaited (so the race stays closed), and an unrelated non-blocking
86/// stream no longer is (the actual speedup).
87#[inline]
88fn sync_legacy_stream(api: &DriverApi) -> CudaResult<()> {
89    // SAFETY: `cu_stream_synchronize` was resolved from the loaded driver, and
90    // a `NULL` stream handle is the driver API's legacy default stream — always
91    // a valid argument, no allocation of ours is referenced.
92    oxicuda_driver::check(unsafe { (api.cu_stream_synchronize)(CUstream::default()) })
93}
94
95// ---------------------------------------------------------------------------
96// DeviceBuffer<T>
97// ---------------------------------------------------------------------------
98
99/// A contiguous buffer of `T` elements allocated in GPU device memory.
100///
101/// The buffer owns the underlying `CUdeviceptr` allocation and frees it on
102/// drop.  All copy operations validate that source and destination lengths
103/// match, returning [`CudaError::InvalidValue`] on mismatch.
104pub struct DeviceBuffer<T: Copy> {
105    /// Raw CUDA device pointer to the start of the allocation.
106    ptr: CUdeviceptr,
107    /// Number of `T` elements (not bytes).
108    len: usize,
109    /// Whether this buffer owns its allocation and must free it on drop.
110    ///
111    /// `true` for buffers created via [`DeviceBuffer::alloc`],
112    /// [`DeviceBuffer::zeroed`], or [`DeviceBuffer::from_host`]; `false` for
113    /// non-owning views created via [`DeviceBuffer::from_raw`], which borrow an
114    /// externally-owned device pointer and must NOT free it on drop.
115    owned: bool,
116    /// Marker to tie the generic parameter `T` to this struct.
117    _phantom: PhantomData<T>,
118}
119
120// SAFETY: Device memory is not bound to a specific host thread.  The raw
121// pointer is a `u64` handle managed by the CUDA driver, which is thread-safe
122// for memory operations when properly synchronised.
123unsafe impl<T: Copy + Send> Send for DeviceBuffer<T> {}
124unsafe impl<T: Copy + Sync> Sync for DeviceBuffer<T> {}
125
126impl<T: Copy> DeviceBuffer<T> {
127    /// Allocates a device buffer capable of holding `n` elements of type `T`.
128    ///
129    /// # Errors
130    ///
131    /// * [`CudaError::InvalidValue`] if `n` is zero.
132    /// * [`CudaError::OutOfMemory`] if the GPU cannot satisfy the request.
133    /// * Other driver errors propagated from `cuMemAlloc_v2`.
134    pub fn alloc(n: usize) -> CudaResult<Self> {
135        if n == 0 {
136            return Err(CudaError::InvalidValue);
137        }
138        let byte_size = n
139            .checked_mul(std::mem::size_of::<T>())
140            .ok_or(CudaError::InvalidValue)?;
141        let api = try_driver()?;
142        let mut ptr: CUdeviceptr = 0;
143        // SAFETY: `cu_mem_alloc_v2` writes a valid device pointer on success.
144        let rc = unsafe { (api.cu_mem_alloc_v2)(&mut ptr, byte_size) };
145        oxicuda_driver::check(rc)?;
146        Ok(Self {
147            ptr,
148            len: n,
149            owned: true,
150            _phantom: PhantomData,
151        })
152    }
153
154    /// Allocates a device buffer of `n` elements and zero-initialises every byte.
155    ///
156    /// This is equivalent to [`alloc`](Self::alloc) followed by a
157    /// `cuMemsetD8_v2` call that writes `0` to every byte.
158    ///
159    /// The zero-fill is **fully completed on the device before this function
160    /// returns**: `cuMemsetD8_v2` is issued on the legacy default stream and is
161    /// asynchronous with respect to the host for device memory, so the returned
162    /// buffer would otherwise not be guaranteed zeroed relative to work later
163    /// submitted on a `CU_STREAM_NON_BLOCKING` stream (which does *not*
164    /// implicitly synchronise with the default stream). Draining the legacy
165    /// stream after the memset makes the "every byte is 0" postcondition hold
166    /// for any consumer stream, closing a data race where a kernel on a
167    /// non-blocking stream could read/overwrite this buffer concurrently with
168    /// the pending zero-fill.
169    ///
170    /// The wait is scoped to the legacy default stream alone (see
171    /// `sync_legacy_stream`), so unrelated work in flight on other streams is
172    /// **not** waited on.
173    ///
174    /// # Errors
175    ///
176    /// Same as [`alloc`](Self::alloc), plus any error from `cuMemsetD8_v2` or
177    /// the legacy-stream synchronise.
178    pub fn zeroed(n: usize) -> CudaResult<Self> {
179        let buf = Self::alloc(n)?;
180        let api = try_driver()?;
181        // SAFETY: the buffer was just allocated with the correct byte size.
182        let rc = unsafe { (api.cu_memset_d8_v2)(buf.ptr, 0, buf.byte_size()) };
183        oxicuda_driver::check(rc)?;
184        // The non-async memset runs on the legacy default stream and is host
185        // asynchronous for device memory; block until it has actually landed so
186        // the buffer is zeroed with respect to every stream, not just the
187        // default one. Waits on the legacy stream specifically -- the one the
188        // memset was enqueued on -- not the whole context.
189        sync_legacy_stream(api)?;
190        Ok(buf)
191    }
192
193    /// Allocates a device buffer and copies the contents of `data` into it.
194    ///
195    /// The resulting buffer has the same length as the input slice.
196    ///
197    /// # Errors
198    ///
199    /// * [`CudaError::InvalidValue`] if `data` is empty.
200    /// * Other driver errors from allocation or the host-to-device copy.
201    pub fn from_host(data: &[T]) -> CudaResult<Self> {
202        let mut buf = Self::alloc(data.len())?;
203        buf.copy_from_host(data)?;
204        Ok(buf)
205    }
206
207    /// Wraps an externally-owned device pointer in a non-owning
208    /// [`DeviceBuffer`] view **without allocating**.
209    ///
210    /// The returned buffer points at the *existing* allocation described by
211    /// `ptr` and `len`, and exposes the full [`DeviceBuffer`] API (copies,
212    /// slicing, [`as_device_ptr`](Self::as_device_ptr), and use as a matrix
213    /// operand in `oxicuda-blas`) over that memory.  Because the view does not
214    /// own the allocation, its [`Drop`] is a no-op: it will **not** call
215    /// `cuMemFree_v2`.  Ownership and the lifetime of the underlying memory
216    /// remain entirely with the original owner (e.g. another CUDA library,
217    /// `cudarc`, or a foreign allocator).
218    ///
219    /// This enables zero-copy interop: a consumer that already holds a
220    /// resident device allocation can wrap it here and run OxiCUDA operations
221    /// in place, with no host round-trip and no extra device allocation.
222    ///
223    /// # Safety
224    ///
225    /// The caller must guarantee all of the following:
226    ///
227    /// * `ptr` is a valid CUDA device pointer into an allocation of at least
228    ///   `len * size_of::<T>()` bytes, correctly aligned for `T`, and
229    ///   associated with the CUDA context that subsequent OxiCUDA operations
230    ///   run under.
231    /// * The pointed-to memory contains a valid, initialised `[T; len]` (or is
232    ///   only used as a write target before being read).
233    /// * The underlying allocation **outlives** this `DeviceBuffer` view: the
234    ///   original owner must not free, reallocate, or invalidate `ptr` while
235    ///   this view (or any [`DeviceSlice`] borrowed from it) is alive.
236    /// * No other live `DeviceBuffer` owns the same `ptr` (to avoid a
237    ///   double-free) and aliasing rules are respected when the view is used
238    ///   mutably (e.g. as a [`MatrixDescMut`](../oxicuda_blas/struct.MatrixDescMut.html)
239    ///   output operand).
240    ///
241    /// A zero `len` is permitted (unlike [`alloc`](Self::alloc)) since no
242    /// allocation is performed; a `ptr` of `0` is also permitted for a
243    /// zero-length view, but pointer/length validity is the caller's
244    /// responsibility.
245    ///
246    /// # Example
247    ///
248    /// ```rust,no_run
249    /// # use oxicuda_memory::DeviceBuffer;
250    /// # use oxicuda_driver::ffi::CUdeviceptr;
251    /// // `raw` is a device pointer owned elsewhere (e.g. obtained from another
252    /// // CUDA library) pointing at `n` resident `f32` elements.
253    /// # let raw: CUdeviceptr = 0;
254    /// # let n: usize = 1024;
255    /// // SAFETY: `raw` is valid for `n` f32s and outlives `view`.
256    /// let view = unsafe { DeviceBuffer::<f32>::from_raw(raw, n) };
257    /// // `view` can now be used with oxicuda-blas / copies; dropping it does
258    /// // NOT free `raw`.
259    /// assert_eq!(view.len(), n);
260    /// ```
261    #[must_use]
262    pub unsafe fn from_raw(ptr: CUdeviceptr, len: usize) -> Self {
263        Self {
264            ptr,
265            len,
266            owned: false,
267            _phantom: PhantomData,
268        }
269    }
270
271    /// Copies data from a host slice into this device buffer (synchronous).
272    ///
273    /// The slice length must exactly match the buffer length.
274    ///
275    /// The upload is **fully landed on the device before this function
276    /// returns**, and the wait is scoped to the legacy default stream alone
277    /// (see `sync_legacy_stream`) rather than to the whole context.
278    ///
279    /// # Performance
280    ///
281    /// `src` is ordinary pageable host memory, so the driver must stage it
282    /// through an internal DMA buffer. For a hot path that uploads the same
283    /// tensor shape every frame, a reusable page-locked staging buffer
284    /// ([`crate::StagingBuffer`]) is measurably faster.
285    ///
286    /// # Errors
287    ///
288    /// * [`CudaError::InvalidValue`] if `src.len() != self.len()`.
289    /// * Other driver errors from `cuMemcpyHtoD_v2`.
290    pub fn copy_from_host(&mut self, src: &[T]) -> CudaResult<()> {
291        if src.len() != self.len {
292            return Err(CudaError::InvalidValue);
293        }
294        let api = try_driver()?;
295        // SAFETY: `src` is a valid host slice with the correct byte count.
296        let rc = unsafe {
297            (api.cu_memcpy_htod_v2)(self.ptr, src.as_ptr().cast::<c_void>(), self.byte_size())
298        };
299        oxicuda_driver::check(rc)?;
300        // `cuMemcpyHtoD_v2` is only "synchronous" in the sense that it returns
301        // once `src` (pageable memory) has been staged into the driver's DMA
302        // buffer -- the transfer to device memory itself completes later, on the
303        // legacy default stream. Every OxiCUDA `Stream` is created with
304        // `CU_STREAM_NON_BLOCKING`, which by definition does *not* implicitly
305        // synchronise with the default stream, so a kernel or copy issued on one
306        // can observe this buffer before the upload lands and silently read
307        // zeros. Block until the DMA has completed, mirroring `zeroed` -- on the
308        // legacy stream specifically, not the whole context.
309        sync_legacy_stream(api)
310    }
311
312    /// Copies this device buffer's contents into a host slice (synchronous).
313    ///
314    /// The slice length must exactly match the buffer length.
315    ///
316    /// # Errors
317    ///
318    /// * [`CudaError::InvalidValue`] if `dst.len() != self.len()`.
319    /// * Other driver errors from `cuMemcpyDtoH_v2`.
320    pub fn copy_to_host(&self, dst: &mut [T]) -> CudaResult<()> {
321        if dst.len() != self.len {
322            return Err(CudaError::InvalidValue);
323        }
324        let api = try_driver()?;
325        // SAFETY: `dst` is a valid host slice with the correct byte count.
326        let rc = unsafe {
327            (api.cu_memcpy_dtoh_v2)(
328                dst.as_mut_ptr().cast::<c_void>(),
329                self.ptr,
330                self.byte_size(),
331            )
332        };
333        oxicuda_driver::check(rc)
334    }
335
336    /// Copies the entire contents of another device buffer into this one.
337    ///
338    /// Both buffers must have the same length.
339    ///
340    /// # Errors
341    ///
342    /// * [`CudaError::InvalidValue`] if `src.len() != self.len()`.
343    /// * Other driver errors from `cuMemcpyDtoD_v2`.
344    pub fn copy_from_device(&mut self, src: &DeviceBuffer<T>) -> CudaResult<()> {
345        if src.len != self.len {
346            return Err(CudaError::InvalidValue);
347        }
348        let api = try_driver()?;
349        // SAFETY: both pointers are valid device allocations of the same size.
350        let rc = unsafe { (api.cu_memcpy_dtod_v2)(self.ptr, src.ptr, self.byte_size()) };
351        oxicuda_driver::check(rc)
352    }
353
354    /// Asynchronously copies data from a host slice into this device buffer.
355    ///
356    /// The copy is enqueued on `stream` and may not be complete when this
357    /// function returns.  The caller must ensure that `src` remains valid
358    /// (i.e., is not moved or dropped) until the stream has been
359    /// synchronised.  For guaranteed correctness, prefer using a
360    /// [`PinnedBuffer`](crate::PinnedBuffer) as the source.
361    ///
362    /// # Errors
363    ///
364    /// * [`CudaError::InvalidValue`] if `src.len() != self.len()`.
365    /// * Other driver errors from `cuMemcpyHtoDAsync_v2`.
366    pub fn copy_from_host_async(&mut self, src: &[T], stream: &Stream) -> CudaResult<()> {
367        if src.len() != self.len {
368            return Err(CudaError::InvalidValue);
369        }
370        let api = try_driver()?;
371        // SAFETY: the caller is responsible for keeping `src` alive until
372        // the stream completes.
373        let rc = unsafe {
374            (api.cu_memcpy_htod_async_v2)(
375                self.ptr,
376                src.as_ptr().cast::<c_void>(),
377                self.byte_size(),
378                stream.raw(),
379            )
380        };
381        oxicuda_driver::check(rc)
382    }
383
384    /// Asynchronously copies this device buffer's contents into a host slice.
385    ///
386    /// The copy is enqueued on `stream` and may not be complete when this
387    /// function returns.  The caller must ensure that `dst` remains valid
388    /// and is not read until the stream has been synchronised.  For
389    /// guaranteed correctness, prefer using a
390    /// [`PinnedBuffer`](crate::PinnedBuffer) as the destination.
391    ///
392    /// # Errors
393    ///
394    /// * [`CudaError::InvalidValue`] if `dst.len() != self.len()`.
395    /// * Other driver errors from `cuMemcpyDtoHAsync_v2`.
396    pub fn copy_to_host_async(&self, dst: &mut [T], stream: &Stream) -> CudaResult<()> {
397        if dst.len() != self.len {
398            return Err(CudaError::InvalidValue);
399        }
400        let api = try_driver()?;
401        // SAFETY: the caller is responsible for keeping `dst` alive until
402        // the stream completes.
403        let rc = unsafe {
404            (api.cu_memcpy_dtoh_async_v2)(
405                dst.as_mut_ptr().cast::<c_void>(),
406                self.ptr,
407                self.byte_size(),
408                stream.raw(),
409            )
410        };
411        oxicuda_driver::check(rc)
412    }
413
414    /// Returns the number of `T` elements in this buffer.
415    #[inline]
416    pub fn len(&self) -> usize {
417        self.len
418    }
419
420    /// Returns `true` if the buffer contains zero elements.
421    ///
422    /// In practice this is always `false` because [`alloc`](Self::alloc)
423    /// rejects zero-length allocations.
424    #[inline]
425    pub fn is_empty(&self) -> bool {
426        self.len == 0
427    }
428
429    /// Returns the total size of the allocation in bytes.
430    #[inline]
431    pub fn byte_size(&self) -> usize {
432        self.len * std::mem::size_of::<T>()
433    }
434
435    /// Returns the raw [`CUdeviceptr`] handle for this buffer.
436    ///
437    /// This is useful when passing the pointer to kernel launch parameters
438    /// or other low-level driver calls.
439    #[inline]
440    pub fn as_device_ptr(&self) -> CUdeviceptr {
441        self.ptr
442    }
443
444    /// Returns a borrowed [`DeviceSlice`] referencing a sub-range of this
445    /// buffer starting at element `offset` and spanning `len` elements.
446    ///
447    /// # Errors
448    ///
449    /// Returns [`CudaError::InvalidValue`] if the requested range exceeds
450    /// the buffer bounds (i.e., `offset + len > self.len()`).
451    pub fn slice(&self, offset: usize, len: usize) -> CudaResult<DeviceSlice<'_, T>> {
452        let end = offset.checked_add(len).ok_or(CudaError::InvalidValue)?;
453        if end > self.len {
454            return Err(CudaError::InvalidValue);
455        }
456        let byte_offset = offset
457            .checked_mul(std::mem::size_of::<T>())
458            .ok_or(CudaError::InvalidValue)?;
459        Ok(DeviceSlice {
460            ptr: self.ptr + byte_offset as u64,
461            len,
462            _phantom: PhantomData,
463        })
464    }
465}
466
467impl<T: Copy> Drop for DeviceBuffer<T> {
468    fn drop(&mut self) {
469        // Non-owning views (created via `from_raw`) borrow an externally-owned
470        // allocation and must never free it.
471        if !self.owned {
472            return;
473        }
474        if let Ok(api) = try_driver() {
475            // SAFETY: `self.ptr` was allocated by `cu_mem_alloc_v2` and has
476            // not yet been freed.
477            let rc = unsafe { (api.cu_mem_free_v2)(self.ptr) };
478            if rc != 0 {
479                tracing::warn!(
480                    cuda_error = rc,
481                    ptr = self.ptr,
482                    len = self.len,
483                    "cuMemFree_v2 failed during DeviceBuffer drop"
484                );
485            }
486        }
487    }
488}
489
490// ---------------------------------------------------------------------------
491// DeviceSlice<'a, T>
492// ---------------------------------------------------------------------------
493
494/// A borrowed, non-owning view into a sub-range of a [`DeviceBuffer`].
495///
496/// A `DeviceSlice` does not own the memory it points to — it borrows from
497/// the parent [`DeviceBuffer`] and is lifetime-bound to it.  This is useful
498/// for passing sub-regions of a buffer to kernels or copy operations without
499/// extra allocations.
500///
501/// `DeviceSlice` does **not** implement [`Drop`]; the parent buffer is
502/// responsible for freeing the allocation.
503pub struct DeviceSlice<'a, T: Copy> {
504    /// Raw device pointer to the start of this slice within the parent buffer.
505    ptr: CUdeviceptr,
506    /// Number of `T` elements in this slice.
507    len: usize,
508    /// Ties the lifetime to the parent buffer and the element type.
509    _phantom: PhantomData<&'a T>,
510}
511
512impl<T: Copy> DeviceSlice<'_, T> {
513    /// Returns the number of `T` elements in this slice.
514    #[inline]
515    pub fn len(&self) -> usize {
516        self.len
517    }
518
519    /// Returns `true` if the slice contains zero elements.
520    #[inline]
521    pub fn is_empty(&self) -> bool {
522        self.len == 0
523    }
524
525    /// Returns the total size of this slice in bytes.
526    #[inline]
527    pub fn byte_size(&self) -> usize {
528        self.len * std::mem::size_of::<T>()
529    }
530
531    /// Returns the raw [`CUdeviceptr`] handle for the start of this slice.
532    #[inline]
533    pub fn as_device_ptr(&self) -> CUdeviceptr {
534        self.ptr
535    }
536}
537
538// ---------------------------------------------------------------------------
539// Tests
540// ---------------------------------------------------------------------------
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    /// A `from_raw` view must be marked non-owning so that `Drop` skips the
547    /// `cuMemFree_v2` call. We construct over a dummy sentinel pointer; because
548    /// the view is non-owning, dropping it performs no driver call and is safe
549    /// even without a CUDA device present.
550    #[test]
551    fn from_raw_is_non_owning() {
552        let sentinel: CUdeviceptr = 0xDEAD_BEEF;
553        // SAFETY: this view is never dereferenced; we only inspect metadata and
554        // rely on the non-owning Drop being a no-op.
555        let view = unsafe { DeviceBuffer::<f32>::from_raw(sentinel, 16) };
556        assert!(!view.owned, "from_raw must produce a non-owning buffer");
557        assert_eq!(view.len(), 16);
558        assert_eq!(view.as_device_ptr(), sentinel);
559        assert_eq!(view.byte_size(), 16 * std::mem::size_of::<f32>());
560        // Dropping a non-owning view must NOT touch the driver / free memory.
561        // Reaching the end of scope here exercises that path without a GPU.
562        drop(view);
563    }
564
565    /// A zero-length `from_raw` view is permitted (no allocation occurs) and is
566    /// reported as empty.
567    #[test]
568    fn from_raw_zero_len_is_empty() {
569        // SAFETY: zero-length, pointer never dereferenced; Drop is a no-op.
570        let view = unsafe { DeviceBuffer::<u8>::from_raw(0, 0) };
571        assert!(!view.owned);
572        assert!(view.is_empty());
573        assert_eq!(view.len(), 0);
574        assert_eq!(view.byte_size(), 0);
575    }
576
577    /// Two non-owning views may share the same pointer without risking a
578    /// double-free, because neither frees on drop. This models a consumer
579    /// re-wrapping the same resident allocation.
580    #[test]
581    fn from_raw_aliasing_views_do_not_double_free() {
582        let ptr: CUdeviceptr = 0x1000;
583        // SAFETY: non-owning aliases, never dereferenced; both Drops are no-ops.
584        let a = unsafe { DeviceBuffer::<f64>::from_raw(ptr, 8) };
585        let b = unsafe { DeviceBuffer::<f64>::from_raw(ptr, 8) };
586        assert!(!a.owned);
587        assert!(!b.owned);
588        assert_eq!(a.as_device_ptr(), b.as_device_ptr());
589        drop(a);
590        drop(b);
591    }
592
593    /// A real owning allocation created via `alloc` is marked `owned` so that
594    /// its memory is freed on drop. This requires a CUDA device, so it is gated
595    /// behind a runtime driver check and skipped (passing) when no GPU/driver
596    /// is available — keeping the test green on macOS while still proving the
597    /// owned-flag wiring on real hardware.
598    #[test]
599    fn alloc_is_owning_when_driver_available() {
600        match DeviceBuffer::<f32>::alloc(32) {
601            Ok(buf) => {
602                assert!(buf.owned, "alloc must produce an owning buffer");
603                assert_eq!(buf.len(), 32);
604                // `buf` is dropped here and frees its allocation via the driver.
605            }
606            Err(_) => {
607                // No CUDA driver/device on this host (e.g. macOS CI): the
608                // owned-flag logic is covered by the non-GPU tests above.
609            }
610        }
611    }
612}