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}