Skip to main content

oxicuda_memory/
staging.rs

1//! Reusable pinned host staging buffer for hot-path H2D / D2H transfers.
2//!
3//! [`StagingBuffer`] owns **one** page-locked ([`PinnedBuffer`]) host allocation
4//! that is allocated on first use, grown only when a larger transfer arrives,
5//! and reused for every subsequent transfer. It exists for the workload where
6//! per-call transfer overhead dominates: the same few tensor shapes moved
7//! host↔device hundreds or thousands of times (a video inference pipeline
8//! running the same ONNX models once per frame), where a fresh
9//! `cuMemAllocHost_v2` per call would cost more than the transfer it enables.
10//!
11//! # Which API to use
12//!
13//! The crate offers two shapes of staged transfer, and the difference between
14//! them is worth understanding because it is the difference between a 2.7x
15//! speedup and a 2x *slowdown*:
16//!
17//! * [`upload_with`](StagingBuffer::upload_with) /
18//!   [`download_into`](StagingBuffer::download_into) — the producer writes its
19//!   data **directly into** page-locked memory (or reads results directly out
20//!   of it). No intermediate host copy exists at all, so the DMA engine
21//!   transfers straight from/to the caller's working memory. **This is the fast
22//!   path, and it wins at every size** (measured 1.55x–2.67x, see below).
23//!
24//! * [`upload`](StagingBuffer::upload) / [`download`](StagingBuffer::download) —
25//!   convenience wrappers taking an ordinary `&[T]` / `&mut [T]`. These must
26//!   `memcpy` between the caller's pageable slice and the pinned buffer, and
27//!   that extra host copy is **not free**: a single-threaded host memcpy runs
28//!   at roughly 10 GB/s, while the CUDA driver's own pageable path pipelines
29//!   its chunked staging copy *against* the DMA and so sustains more. Past
30//!   about 1 MiB the wrapper therefore loses badly to just letting the driver
31//!   do it. Rather than expose that as a footgun, these two methods
32//!   **auto-select**: they stage through pinned memory only while the transfer
33//!   is at or below [`StagingBuffer::auto_stage_max_bytes`], and hand larger
34//!   transfers to the driver's pageable path. They are never slower than not
35//!   using a `StagingBuffer` at all.
36//!
37//! # Measured (NVIDIA RTX A4000, driver 550.144.03, CUDA 12.4, sm_86)
38//!
39//! Median of 100–400 repetitions, release build. "copy-in" is the convenience
40//! wrapper (pageable slice → pinned → DMA); "resident" is
41//! [`upload_with`](StagingBuffer::upload_with) (producer fills pinned memory
42//! directly).
43//!
44//! | transfer          | size    | pageable | copy-in       | resident      |
45//! |-------------------|---------|----------|---------------|---------------|
46//! | H2D 112×112×3 f32 | 150 KiB | 18.2 µs  | 15.4 µs 1.18x | 10.7 µs 1.71x |
47//! | H2D 128×128×3 f32 | 196 KiB | 21.9 µs  | 18.7 µs 1.17x | 12.5 µs 1.75x |
48//! | H2D 640×640×3 f32 | 4.7 MiB | 311 µs   | 681 µs  0.46x | 201 µs  1.55x |
49//! | D2H 112×112×3 f32 | 150 KiB | 17.8 µs  | 14.2 µs 1.25x | 10.1 µs 1.77x |
50//! | D2H 128×128×3 f32 | 196 KiB | 28.1 µs  | 18.2 µs 1.54x | 11.9 µs 2.37x |
51//! | D2H 640×640×3 f32 | 4.7 MiB | 509 µs   | 676 µs  0.75x | 191 µs  2.67x |
52//!
53//! The `copy-in` column is exactly why the auto-select threshold exists.
54//!
55//! # Stream semantics
56//!
57//! Every method here is **synchronous from the caller's thread**: it returns
58//! only once the transfer has fully landed. That deliberately matches
59//! [`DeviceBuffer::copy_from_host`] / [`DeviceBuffer::copy_to_host`], so a
60//! `StagingBuffer` is a drop-in replacement, and it is *required* for a reused
61//! staging buffer — the next call overwrites the same pinned bytes, so the DMA
62//! reading them must have finished.
63//!
64//! All transfers are ordered against `stream`: an upload is enqueued after work
65//! already queued on `stream`, and a download observes the results of work
66//! already queued on `stream`. This holds on both sides of the auto-select
67//! threshold (the pageable fallback synchronises `stream` explicitly), so
68//! changing tensor size can never silently change ordering.
69//!
70//! # Example
71//!
72//! ```rust,no_run
73//! # use std::sync::Arc;
74//! # use oxicuda_driver::{Context, Device, Stream};
75//! # use oxicuda_memory::{DeviceBuffer, StagingBuffer};
76//! # fn main() -> Result<(), oxicuda_driver::error::CudaError> {
77//! # let dev = Device::get(0)?;
78//! # let ctx = Arc::new(Context::new(&dev)?);
79//! # let stream = Stream::new(&ctx)?;
80//! let mut staging = StagingBuffer::new();
81//! let mut d_input = DeviceBuffer::<f32>::alloc(3 * 640 * 640)?;
82//!
83//! // Per frame: write preprocessed pixels straight into pinned memory.
84//! staging.upload_with(&mut d_input, 3 * 640 * 640, &stream, |dst: &mut [f32]| {
85//!     for (i, v) in dst.iter_mut().enumerate() {
86//!         *v = i as f32; // real code: normalised pixel data
87//!     }
88//! })?;
89//! # Ok(())
90//! # }
91//! ```
92
93use std::ffi::c_void;
94
95use oxicuda_driver::error::{CudaError, CudaResult};
96use oxicuda_driver::loader::try_driver;
97use oxicuda_driver::stream::Stream;
98
99use crate::device_buffer::DeviceBuffer;
100use crate::host_buffer::PinnedBuffer;
101
102// ---------------------------------------------------------------------------
103// StagingPod
104// ---------------------------------------------------------------------------
105
106/// Types for which **every** bit pattern is a valid value.
107///
108/// [`StagingBuffer`] hands out `&mut [T]` / `&[T]` views over page-locked bytes
109/// that were either zero-filled at allocation time or last written by a DMA
110/// from the device. Materialising such a view is only sound when no bit pattern
111/// those bytes could hold is invalid for `T` — which rules out types with
112/// niches (`bool`, `char`, `NonZeroU32`, enums, references) even though they
113/// are `Copy`.
114///
115/// # Safety
116///
117/// Implementors must guarantee that `T` has no invalid bit patterns, no padding
118/// whose contents could be observed as uninitialised, and no interior
119/// pointers/references that would be meaningless after a device round trip.
120/// Numeric primitives and `#[repr(transparent)]` wrappers over them qualify.
121pub unsafe trait StagingPod: Copy {}
122
123macro_rules! impl_staging_pod {
124    ($($t:ty),* $(,)?) => {
125        $(
126            // SAFETY: every bit pattern of this primitive numeric type is a
127            // valid value of the type, and it has no padding or interior
128            // pointers.
129            unsafe impl StagingPod for $t {}
130        )*
131    };
132}
133
134impl_staging_pod!(
135    u8, i8, u16, i16, u32, i32, u64, i64, u128, i128, usize, isize, f32, f64
136);
137
138// SAFETY: `half::f16` is `#[repr(transparent)]` over `u16`; every 16-bit
139// pattern denotes a valid IEEE half (some are NaN, which is still a value).
140#[cfg(feature = "half")]
141unsafe impl StagingPod for half::f16 {}
142
143// SAFETY: `half::bf16` is `#[repr(transparent)]` over `u16`; every 16-bit
144// pattern denotes a valid bfloat16.
145#[cfg(feature = "half")]
146unsafe impl StagingPod for half::bf16 {}
147
148// ---------------------------------------------------------------------------
149// Constants
150// ---------------------------------------------------------------------------
151
152/// Default value of [`StagingBuffer::auto_stage_max_bytes`]: 512 KiB.
153///
154/// At or below this size, copying a pageable slice into the pinned buffer and
155/// DMA-ing from there beats handing the slice to the driver; above it, the
156/// driver's pipelined pageable path wins. 512 KiB is the largest measured size
157/// at which staging wins in **both** directions on the reference device (H2D
158/// 1.12x, D2H 1.57x); at 1 MiB H2D has already turned negative (0.91x). See the
159/// module-level table.
160///
161/// This is a heuristic calibrated on one machine, not a hardware invariant —
162/// [`StagingBuffer::set_auto_stage_max_bytes`] overrides it (`0` disables
163/// staging for the slice wrappers entirely, `usize::MAX` always stages).
164pub const DEFAULT_AUTO_STAGE_MAX_BYTES: usize = 512 * 1024;
165
166/// Capacity granularity: growth requests are rounded up to a multiple of this
167/// (64 KiB).
168///
169/// `cuMemAllocHost_v2` pins whole pages regardless, and rounding up absorbs
170/// small shape jitter (e.g. a detector whose input varies by a few rows)
171/// without re-pinning — a re-pin is a millisecond-scale driver call, far more
172/// expensive than the bounded ≤64 KiB of slack it avoids.
173const CAPACITY_GRANULARITY: usize = 64 * 1024;
174
175// ---------------------------------------------------------------------------
176// StagingStats
177// ---------------------------------------------------------------------------
178
179/// Counters describing how a [`StagingBuffer`] has been used.
180///
181/// Chiefly useful for asserting in tests (and in production tracing) that the
182/// buffer really is being *reused* rather than silently re-pinned every call —
183/// the entire point of the type. A healthy hot loop shows `allocations == 1`
184/// and a large `staged_transfers`.
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
186pub struct StagingStats {
187    /// Number of times a pinned host allocation was performed (initial + grows).
188    pub allocations: u64,
189    /// Transfers that were staged through the pinned buffer.
190    pub staged_transfers: u64,
191    /// Transfers routed straight to the driver's pageable path because they
192    /// exceeded [`StagingBuffer::auto_stage_max_bytes`].
193    pub direct_transfers: u64,
194    /// Total bytes uploaded host→device through this buffer (both paths).
195    pub bytes_uploaded: u64,
196    /// Total bytes downloaded device→host through this buffer (both paths).
197    pub bytes_downloaded: u64,
198}
199
200// ---------------------------------------------------------------------------
201// StagingBuffer
202// ---------------------------------------------------------------------------
203
204/// A grow-on-demand, reused page-locked host buffer used to stage transfers.
205///
206/// See the [module documentation](self) for the performance model and for which
207/// method to reach for.
208pub struct StagingBuffer {
209    /// The pinned allocation, absent until the first transfer sizes it.
210    pinned: Option<PinnedBuffer<u8>>,
211    /// Transfers larger than this bypass staging (see
212    /// [`DEFAULT_AUTO_STAGE_MAX_BYTES`]).
213    auto_stage_max_bytes: usize,
214    /// Usage counters.
215    stats: StagingStats,
216}
217
218impl Default for StagingBuffer {
219    fn default() -> Self {
220        Self::new()
221    }
222}
223
224impl StagingBuffer {
225    /// Creates an empty staging buffer. **No** host memory is pinned until the
226    /// first transfer (or an explicit [`reserve`](Self::reserve)).
227    #[must_use]
228    pub const fn new() -> Self {
229        Self {
230            pinned: None,
231            auto_stage_max_bytes: DEFAULT_AUTO_STAGE_MAX_BYTES,
232            stats: StagingStats {
233                allocations: 0,
234                staged_transfers: 0,
235                direct_transfers: 0,
236                bytes_uploaded: 0,
237                bytes_downloaded: 0,
238            },
239        }
240    }
241
242    /// Creates a staging buffer with `bytes` of pinned host memory already
243    /// allocated.
244    ///
245    /// Pre-sizing at start-up (to the largest tensor a pipeline will move) keeps
246    /// the millisecond-scale `cuMemAllocHost_v2` out of the steady-state loop
247    /// entirely.
248    ///
249    /// # Errors
250    ///
251    /// * [`CudaError::InvalidValue`] if `bytes` is zero.
252    /// * Other driver errors from `cuMemAllocHost_v2`.
253    pub fn with_capacity(bytes: usize) -> CudaResult<Self> {
254        let mut this = Self::new();
255        this.reserve(bytes)?;
256        Ok(this)
257    }
258
259    /// Returns the current pinned capacity in bytes (`0` before first use).
260    #[inline]
261    #[must_use]
262    pub fn capacity(&self) -> usize {
263        self.pinned.as_ref().map_or(0, PinnedBuffer::len)
264    }
265
266    /// Returns the usage counters. See [`StagingStats`].
267    #[inline]
268    #[must_use]
269    pub fn stats(&self) -> StagingStats {
270        self.stats
271    }
272
273    /// Returns the size above which [`upload`](Self::upload) /
274    /// [`download`](Self::download) bypass staging.
275    #[inline]
276    #[must_use]
277    pub fn auto_stage_max_bytes(&self) -> usize {
278        self.auto_stage_max_bytes
279    }
280
281    /// Overrides the auto-select threshold (see
282    /// [`DEFAULT_AUTO_STAGE_MAX_BYTES`] for how it was calibrated).
283    ///
284    /// Affects only the slice-taking wrappers; [`upload_with`](Self::upload_with)
285    /// and [`download_into`](Self::download_into) always stage, because for
286    /// those the pinned buffer *is* the caller's working memory and there is no
287    /// extra copy to regret.
288    #[inline]
289    pub fn set_auto_stage_max_bytes(&mut self, bytes: usize) {
290        self.auto_stage_max_bytes = bytes;
291    }
292
293    /// Ensures at least `bytes` of pinned host memory are available.
294    ///
295    /// Grow-only: a request smaller than the current capacity is a no-op, so a
296    /// pipeline cycling through several tensor shapes settles at the high-water
297    /// mark and never re-pins again. Growth rounds up to
298    /// `CAPACITY_GRANULARITY`.
299    ///
300    /// Any previously staged contents are discarded when the buffer grows.
301    ///
302    /// # Errors
303    ///
304    /// * [`CudaError::InvalidValue`] if `bytes` is zero, or if rounding
305    ///   overflows `usize`.
306    /// * Other driver errors from `cuMemAllocHost_v2`.
307    pub fn reserve(&mut self, bytes: usize) -> CudaResult<()> {
308        if bytes == 0 {
309            return Err(CudaError::InvalidValue);
310        }
311        if self.capacity() >= bytes {
312            return Ok(());
313        }
314        let rounded = bytes
315            .checked_next_multiple_of(CAPACITY_GRANULARITY)
316            .ok_or(CudaError::InvalidValue)?;
317        // Drop the old allocation *before* pinning the new one: page-locked
318        // memory is a scarce, system-wide resource, and holding two copies of a
319        // large buffer at once could fail an allocation that would otherwise
320        // succeed.
321        self.pinned = None;
322        self.pinned = Some(PinnedBuffer::<u8>::alloc(rounded)?);
323        self.stats.allocations += 1;
324        Ok(())
325    }
326
327    /// Releases the pinned allocation, returning capacity to `0`.
328    ///
329    /// Counters are preserved. The next transfer re-pins.
330    pub fn shrink_to_fit(&mut self) {
331        self.pinned = None;
332    }
333
334    // -- Fast path: fill / read page-locked memory in place -------------------
335
336    /// Uploads `n` elements to `dst`, letting `fill` write them **directly into
337    /// page-locked memory**.
338    ///
339    /// This is the fastest host→device path this crate offers (measured
340    /// 1.55x–1.75x over [`DeviceBuffer::copy_from_host`] across 150 KiB–4.7 MiB)
341    /// because the bytes `fill` writes are the exact bytes the DMA engine reads:
342    /// there is no pageable source slice and no driver bounce buffer.
343    ///
344    /// `fill` receives a mutable slice of exactly `n` elements and is expected
345    /// to write **all** of them; anything left untouched keeps whatever the
346    /// previous transfer through this buffer left there (or zeroes, on a
347    /// freshly pinned allocation), and that content is uploaded as-is.
348    ///
349    /// The copy is enqueued on `stream` — so it is ordered after work already
350    /// queued there — and this call returns only once it has landed.
351    ///
352    /// # Errors
353    ///
354    /// * [`CudaError::InvalidValue`] if `n` is zero, if `n != dst.len()`, if the
355    ///   byte size overflows, or if the pinned allocation is not aligned for `T`.
356    /// * Other driver errors from `cuMemAllocHost_v2` or `cuMemcpyHtoDAsync_v2`.
357    pub fn upload_with<T, F>(
358        &mut self,
359        dst: &mut DeviceBuffer<T>,
360        n: usize,
361        stream: &Stream,
362        fill: F,
363    ) -> CudaResult<()>
364    where
365        T: StagingPod,
366        F: FnOnce(&mut [T]),
367    {
368        let byte_size = Self::check_extent::<T>(n, dst.len())?;
369        self.reserve(byte_size)?;
370        fill(self.typed_mut::<T>(n)?);
371        self.enqueue_htod(dst.as_device_ptr(), byte_size, stream)?;
372        stream.synchronize()?;
373        self.stats.staged_transfers += 1;
374        self.stats.bytes_uploaded += byte_size as u64;
375        Ok(())
376    }
377
378    /// Downloads `n` elements from `src` into page-locked memory and returns a
379    /// borrowed view of them, with **no copy out**.
380    ///
381    /// This is the fastest device→host path this crate offers (measured
382    /// 1.77x–2.67x over [`DeviceBuffer::copy_to_host`]): the DMA engine writes
383    /// straight into the memory the caller then reads. The returned slice
384    /// borrows `self` and stays valid until the next call that touches the
385    /// staging buffer.
386    ///
387    /// The copy is enqueued on `stream`, so it observes the results of work
388    /// already queued there; this call returns only once the data has landed and
389    /// is safe to read.
390    ///
391    /// # Errors
392    ///
393    /// * [`CudaError::InvalidValue`] if `n` is zero, if `n != src.len()`, if the
394    ///   byte size overflows, or if the pinned allocation is not aligned for `T`.
395    /// * Other driver errors from `cuMemAllocHost_v2` or `cuMemcpyDtoHAsync_v2`.
396    pub fn download_into<T: StagingPod>(
397        &mut self,
398        src: &DeviceBuffer<T>,
399        n: usize,
400        stream: &Stream,
401    ) -> CudaResult<&[T]> {
402        let byte_size = Self::check_extent::<T>(n, src.len())?;
403        self.reserve(byte_size)?;
404        self.enqueue_dtoh(src.as_device_ptr(), byte_size, stream)?;
405        stream.synchronize()?;
406        self.stats.staged_transfers += 1;
407        self.stats.bytes_downloaded += byte_size as u64;
408        Ok(self.typed_mut::<T>(n)?)
409    }
410
411    // -- Convenience path: ordinary slices, size-aware ------------------------
412
413    /// Uploads `src` into `dst`, staging through pinned memory when that is
414    /// actually faster.
415    ///
416    /// A drop-in replacement for [`DeviceBuffer::copy_from_host`] with the same
417    /// postcondition (the data has landed on the device when this returns) that
418    /// is never slower: transfers up to
419    /// [`auto_stage_max_bytes`](Self::auto_stage_max_bytes) go through the
420    /// pinned buffer, larger ones go straight to the driver's pageable path,
421    /// which pipelines better than a host memcpy into pinned memory can (see the
422    /// module table).
423    ///
424    /// If you control how `src` is produced, prefer
425    /// [`upload_with`](Self::upload_with) — writing the data into pinned memory
426    /// in the first place removes this method's memcpy and wins at every size.
427    ///
428    /// Ordered against `stream` in both paths.
429    ///
430    /// # Errors
431    ///
432    /// * [`CudaError::InvalidValue`] if `src` is empty or `src.len() != dst.len()`.
433    /// * Other driver errors from the allocation or copy.
434    pub fn upload<T: StagingPod>(
435        &mut self,
436        dst: &mut DeviceBuffer<T>,
437        src: &[T],
438        stream: &Stream,
439    ) -> CudaResult<()> {
440        let byte_size = Self::check_extent::<T>(src.len(), dst.len())?;
441        if byte_size > self.auto_stage_max_bytes {
442            // Keep ordering identical to the staged path: the staged upload
443            // would have been enqueued behind whatever is already on `stream`,
444            // so drain it before a legacy-stream copy overtakes that work.
445            stream.synchronize()?;
446            dst.copy_from_host(src)?;
447            self.stats.direct_transfers += 1;
448            self.stats.bytes_uploaded += byte_size as u64;
449            return Ok(());
450        }
451        let n = src.len();
452        self.upload_with(dst, n, stream, |staged: &mut [T]| {
453            staged.copy_from_slice(src);
454        })
455    }
456
457    /// Downloads `src` into `dst`, staging through pinned memory when that is
458    /// actually faster.
459    ///
460    /// A drop-in replacement for [`DeviceBuffer::copy_to_host`] that is never
461    /// slower; the mirror of [`upload`](Self::upload), including the auto-select
462    /// threshold. Prefer [`download_into`](Self::download_into) when the caller
463    /// can consume the results in place.
464    ///
465    /// Unlike a bare [`DeviceBuffer::copy_to_host`], **both** paths here are
466    /// ordered against `stream`, so results produced by kernels on `stream` are
467    /// guaranteed visible without the caller synchronising first.
468    ///
469    /// # Errors
470    ///
471    /// * [`CudaError::InvalidValue`] if `dst` is empty or `dst.len() != src.len()`.
472    /// * Other driver errors from the allocation or copy.
473    pub fn download<T: StagingPod>(
474        &mut self,
475        dst: &mut [T],
476        src: &DeviceBuffer<T>,
477        stream: &Stream,
478    ) -> CudaResult<()> {
479        let byte_size = Self::check_extent::<T>(dst.len(), src.len())?;
480        if byte_size > self.auto_stage_max_bytes {
481            // `copy_to_host` runs on the legacy stream, which does NOT wait for
482            // a `CU_STREAM_NON_BLOCKING` stream; drain `stream` first so this
483            // path observes the same work the staged path would have.
484            stream.synchronize()?;
485            src.copy_to_host(dst)?;
486            self.stats.direct_transfers += 1;
487            self.stats.bytes_downloaded += byte_size as u64;
488            return Ok(());
489        }
490        let n = dst.len();
491        let staged = self.download_into(src, n, stream)?;
492        dst.copy_from_slice(staged);
493        Ok(())
494    }
495
496    // -- Internals -----------------------------------------------------------
497
498    /// Validates a transfer extent and returns its size in bytes.
499    fn check_extent<T>(n: usize, device_len: usize) -> CudaResult<usize> {
500        if n == 0 || n != device_len {
501            return Err(CudaError::InvalidValue);
502        }
503        n.checked_mul(std::mem::size_of::<T>())
504            .ok_or(CudaError::InvalidValue)
505    }
506
507    /// Reinterprets the first `n` elements of the pinned allocation as `&mut [T]`.
508    ///
509    /// Returns [`CudaError::InvalidValue`] rather than risking undefined
510    /// behaviour if the allocation is somehow under-aligned for `T`.
511    /// `cuMemAllocHost_v2` returns page-aligned memory in practice, so this
512    /// check never fires for any realistic `T` — it is a guard, not a code path.
513    fn typed_mut<T: StagingPod>(&mut self, n: usize) -> CudaResult<&mut [T]> {
514        let buf = self.pinned.as_mut().ok_or(CudaError::InvalidValue)?;
515        let byte_size = n
516            .checked_mul(std::mem::size_of::<T>())
517            .ok_or(CudaError::InvalidValue)?;
518        if byte_size > buf.len() {
519            return Err(CudaError::InvalidValue);
520        }
521        let ptr = buf.as_mut_ptr();
522        if !ptr.cast::<T>().is_aligned() {
523            return Err(CudaError::InvalidValue);
524        }
525        // SAFETY: `ptr` addresses `buf.len() >= byte_size` bytes of live pinned
526        // host memory that we hold `&mut` to for the returned lifetime; the
527        // pointer is aligned for `T` (checked above); and `T: StagingPod`
528        // guarantees every bit pattern of those bytes -- zero-filled by
529        // `PinnedBuffer::alloc`, or written by a previous fill/DMA -- is a valid
530        // `T`, so no invalid value can be materialised.
531        Ok(unsafe { std::slice::from_raw_parts_mut(ptr.cast::<T>(), n) })
532    }
533
534    /// Enqueues `byte_size` bytes from the pinned buffer to `dst_ptr` on `stream`.
535    fn enqueue_htod(
536        &self,
537        dst_ptr: oxicuda_driver::ffi::CUdeviceptr,
538        byte_size: usize,
539        stream: &Stream,
540    ) -> CudaResult<()> {
541        let buf = self.pinned.as_ref().ok_or(CudaError::InvalidValue)?;
542        if byte_size > buf.len() {
543            return Err(CudaError::InvalidValue);
544        }
545        let api = try_driver()?;
546        // SAFETY: `buf` is page-locked host memory holding at least `byte_size`
547        // valid bytes, `dst_ptr` is a live device allocation of at least that
548        // size (its `DeviceBuffer` length was checked against `n`), and the
549        // caller synchronises `stream` before the pinned bytes are reused.
550        let rc = unsafe {
551            (api.cu_memcpy_htod_async_v2)(
552                dst_ptr,
553                buf.as_ptr().cast::<c_void>(),
554                byte_size,
555                stream.raw(),
556            )
557        };
558        oxicuda_driver::check(rc)
559    }
560
561    /// Enqueues `byte_size` bytes from `src_ptr` into the pinned buffer on `stream`.
562    fn enqueue_dtoh(
563        &mut self,
564        src_ptr: oxicuda_driver::ffi::CUdeviceptr,
565        byte_size: usize,
566        stream: &Stream,
567    ) -> CudaResult<()> {
568        let buf = self.pinned.as_mut().ok_or(CudaError::InvalidValue)?;
569        if byte_size > buf.len() {
570            return Err(CudaError::InvalidValue);
571        }
572        let api = try_driver()?;
573        // SAFETY: `buf` is page-locked host memory with room for `byte_size`
574        // bytes and is exclusively borrowed here, `src_ptr` is a live device
575        // allocation of at least that size, and the caller synchronises
576        // `stream` before reading the result.
577        let rc = unsafe {
578            (api.cu_memcpy_dtoh_async_v2)(
579                buf.as_mut_ptr().cast::<c_void>(),
580                src_ptr,
581                byte_size,
582                stream.raw(),
583            )
584        };
585        oxicuda_driver::check(rc)
586    }
587}
588
589// ---------------------------------------------------------------------------
590// Tests
591// ---------------------------------------------------------------------------
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596
597    #[test]
598    fn new_pins_nothing() {
599        let staging = StagingBuffer::new();
600        assert_eq!(staging.capacity(), 0);
601        assert_eq!(staging.stats().allocations, 0);
602        assert_eq!(staging.auto_stage_max_bytes(), DEFAULT_AUTO_STAGE_MAX_BYTES);
603    }
604
605    #[test]
606    fn default_matches_new() {
607        assert_eq!(
608            StagingBuffer::default().auto_stage_max_bytes(),
609            StagingBuffer::new().auto_stage_max_bytes()
610        );
611        assert_eq!(StagingBuffer::default().capacity(), 0);
612    }
613
614    #[test]
615    fn threshold_is_overridable() {
616        let mut staging = StagingBuffer::new();
617        staging.set_auto_stage_max_bytes(0);
618        assert_eq!(staging.auto_stage_max_bytes(), 0);
619        staging.set_auto_stage_max_bytes(usize::MAX);
620        assert_eq!(staging.auto_stage_max_bytes(), usize::MAX);
621    }
622
623    #[test]
624    fn reserve_rejects_zero() {
625        let mut staging = StagingBuffer::new();
626        assert_eq!(staging.reserve(0), Err(CudaError::InvalidValue));
627    }
628
629    #[test]
630    fn check_extent_rejects_mismatch_and_zero() {
631        assert_eq!(
632            StagingBuffer::check_extent::<f32>(4, 5),
633            Err(CudaError::InvalidValue)
634        );
635        assert_eq!(
636            StagingBuffer::check_extent::<f32>(0, 0),
637            Err(CudaError::InvalidValue)
638        );
639        assert_eq!(StagingBuffer::check_extent::<f32>(4, 4), Ok(16));
640    }
641
642    #[test]
643    fn check_extent_rejects_byte_overflow() {
644        assert_eq!(
645            StagingBuffer::check_extent::<u64>(usize::MAX, usize::MAX),
646            Err(CudaError::InvalidValue)
647        );
648    }
649
650    /// Growth must round up to the granularity so shape jitter does not re-pin.
651    #[test]
652    fn capacity_granularity_is_a_power_of_two_page_multiple() {
653        assert!(CAPACITY_GRANULARITY.is_power_of_two());
654        assert_eq!(CAPACITY_GRANULARITY % 4096, 0);
655    }
656
657    #[test]
658    fn stats_start_zeroed() {
659        assert_eq!(StagingBuffer::new().stats(), StagingStats::default());
660    }
661
662    #[cfg(feature = "gpu-tests")]
663    mod gpu_tests {
664        use super::*;
665        use std::sync::Arc;
666
667        /// A live context + stream, or `None` when no GPU is present.
668        fn fixture() -> Option<(Arc<oxicuda_driver::Context>, Stream)> {
669            oxicuda_driver::init().ok()?;
670            if oxicuda_driver::Device::count().ok()? == 0 {
671                return None;
672            }
673            let dev = oxicuda_driver::Device::get(0).ok()?;
674            let ctx = Arc::new(oxicuda_driver::Context::new(&dev).ok()?);
675            let stream = Stream::new(&ctx).ok()?;
676            Some((ctx, stream))
677        }
678
679        /// The fill-in-place upload and read-in-place download must round-trip
680        /// exactly -- this is the fast path, so it is the one that most needs a
681        /// correctness proof.
682        #[test]
683        fn upload_with_download_into_round_trip() {
684            let Some((_ctx, stream)) = fixture() else {
685                eprintln!("skipping: no CUDA driver/device");
686                return;
687            };
688            let n = 128 * 128 * 3;
689            let mut staging = StagingBuffer::new();
690            let mut d = DeviceBuffer::<f32>::alloc(n).expect("alloc");
691
692            staging
693                .upload_with(&mut d, n, &stream, |dst: &mut [f32]| {
694                    for (i, v) in dst.iter_mut().enumerate() {
695                        *v = (i % 977) as f32 * 0.25;
696                    }
697                })
698                .expect("upload_with");
699
700            let got = staging
701                .download_into(&d, n, &stream)
702                .expect("download_into");
703            for (i, &v) in got.iter().enumerate() {
704                assert_eq!(v, (i % 977) as f32 * 0.25, "element {i}");
705            }
706        }
707
708        /// The buffer must be allocated once and then *reused*: that is the
709        /// whole reason the type exists, so assert it rather than assume it.
710        #[test]
711        fn repeated_transfers_reuse_one_allocation() {
712            let Some((_ctx, stream)) = fixture() else {
713                eprintln!("skipping: no CUDA driver/device");
714                return;
715            };
716            let n = 4096;
717            let mut staging = StagingBuffer::new();
718            let mut d = DeviceBuffer::<f32>::alloc(n).expect("alloc");
719            let src = vec![1.5f32; n];
720            let mut dst = vec![0.0f32; n];
721
722            for _ in 0..64 {
723                staging.upload(&mut d, &src, &stream).expect("upload");
724                staging.download(&mut dst, &d, &stream).expect("download");
725            }
726            assert_eq!(dst, src);
727            let stats = staging.stats();
728            assert_eq!(
729                stats.allocations, 1,
730                "staging buffer re-pinned host memory instead of reusing it"
731            );
732            assert_eq!(stats.staged_transfers, 128);
733            assert_eq!(stats.direct_transfers, 0);
734        }
735
736        /// Growing to a larger shape re-pins exactly once, then shrinking back
737        /// reuses the high-water allocation without pinning again.
738        #[test]
739        fn growth_is_high_water_marked() {
740            let Some((_ctx, stream)) = fixture() else {
741                eprintln!("skipping: no CUDA driver/device");
742                return;
743            };
744            let mut staging = StagingBuffer::new();
745            let small = 1024usize;
746            let large = 64 * 1024usize;
747
748            let mut d_small = DeviceBuffer::<f32>::alloc(small).expect("alloc small");
749            let mut d_large = DeviceBuffer::<f32>::alloc(large).expect("alloc large");
750
751            staging
752                .upload(&mut d_small, &vec![1.0f32; small], &stream)
753                .expect("small");
754            assert_eq!(staging.stats().allocations, 1);
755            let cap_after_small = staging.capacity();
756
757            staging
758                .upload(&mut d_large, &vec![2.0f32; large], &stream)
759                .expect("large");
760            assert_eq!(staging.stats().allocations, 2, "growth should re-pin once");
761            assert!(staging.capacity() > cap_after_small);
762            let cap_after_large = staging.capacity();
763
764            // Back to the small shape: must not re-pin, must not shrink.
765            staging
766                .upload(&mut d_small, &vec![3.0f32; small], &stream)
767                .expect("small again");
768            assert_eq!(staging.stats().allocations, 2, "shrinking must not re-pin");
769            assert_eq!(staging.capacity(), cap_after_large);
770        }
771
772        /// Transfers above the threshold must bypass staging (that is the whole
773        /// point of the threshold) while still producing correct data.
774        #[test]
775        fn oversized_transfers_bypass_staging_and_stay_correct() {
776            let Some((_ctx, stream)) = fixture() else {
777                eprintln!("skipping: no CUDA driver/device");
778                return;
779            };
780            let n = 256 * 1024; // 1 MiB of f32 -- above the 512 KiB default.
781            let mut staging = StagingBuffer::new();
782            let mut d = DeviceBuffer::<f32>::alloc(n).expect("alloc");
783            let src: Vec<f32> = (0..n).map(|i| (i % 613) as f32).collect();
784            let mut dst = vec![0.0f32; n];
785
786            staging.upload(&mut d, &src, &stream).expect("upload");
787            staging.download(&mut dst, &d, &stream).expect("download");
788
789            assert_eq!(dst, src);
790            let stats = staging.stats();
791            assert_eq!(stats.direct_transfers, 2, "should have bypassed staging");
792            assert_eq!(stats.staged_transfers, 0);
793            assert_eq!(
794                stats.allocations, 0,
795                "bypassed transfers must not pin host memory"
796            );
797            assert_eq!(stats.bytes_uploaded, (n * 4) as u64);
798            assert_eq!(stats.bytes_downloaded, (n * 4) as u64);
799
800            // Raising the threshold routes the same transfer through staging,
801            // and it must still be correct.
802            staging.set_auto_stage_max_bytes(usize::MAX);
803            dst.fill(0.0);
804            staging
805                .upload(&mut d, &src, &stream)
806                .expect("upload staged");
807            staging
808                .download(&mut dst, &d, &stream)
809                .expect("download staged");
810            assert_eq!(dst, src);
811            assert_eq!(staging.stats().staged_transfers, 2);
812        }
813
814        /// Length mismatches must be rejected before any driver call.
815        #[test]
816        fn length_mismatch_is_rejected() {
817            let Some((_ctx, stream)) = fixture() else {
818                eprintln!("skipping: no CUDA driver/device");
819                return;
820            };
821            let mut staging = StagingBuffer::new();
822            let mut d = DeviceBuffer::<f32>::alloc(64).expect("alloc");
823            let src = vec![0.0f32; 32];
824            assert_eq!(
825                staging.upload(&mut d, &src, &stream),
826                Err(CudaError::InvalidValue)
827            );
828            let mut dst = vec![0.0f32; 32];
829            assert_eq!(
830                staging.download(&mut dst, &d, &stream),
831                Err(CudaError::InvalidValue)
832            );
833            assert_eq!(
834                staging.upload_with(&mut d, 32, &stream, |_: &mut [f32]| {}),
835                Err(CudaError::InvalidValue)
836            );
837        }
838
839        /// `with_capacity` must pre-pin so the steady-state loop never allocates.
840        #[test]
841        fn with_capacity_preallocates() {
842            let Some((_ctx, stream)) = fixture() else {
843                eprintln!("skipping: no CUDA driver/device");
844                return;
845            };
846            let n = 8192usize;
847            let mut staging = StagingBuffer::with_capacity(n * 4).expect("with_capacity");
848            assert!(staging.capacity() >= n * 4);
849            assert_eq!(staging.stats().allocations, 1);
850
851            let mut d = DeviceBuffer::<f32>::alloc(n).expect("alloc");
852            for _ in 0..16 {
853                staging
854                    .upload(&mut d, &vec![7.0f32; n], &stream)
855                    .expect("upload");
856            }
857            assert_eq!(
858                staging.stats().allocations,
859                1,
860                "pre-sized buffer must never re-pin in the hot loop"
861            );
862        }
863    }
864}