Overlapping Transfers and Compute#
Pageable host memory is convenient, but CUDA may stage it through internal
page-locked memory before a DMA transfer. That extra staging work limits
asynchronous copies. PinnedHostBuffer<T> gives CUDA a stable host address for
direct transfer. True copy/compute overlap also requires asynchronous copies
on non-default streams.
Run the complete demo with:
cargo oxide run pinned_overlap
The example uses a cheap in-place increment kernel, so its timing is dominated by data movement rather than computation.
Choosing a copy API#
The safe helper synchronizes the stream before returning. Use it when the host does not need to do other work while the copy is in flight:
device.copy_to_pinned_host(&stream, &mut host)?;
// `host` is safe to read because the helper synchronized `stream`.
The async helper returns before the copy finishes. The caller owns the synchronization and the buffer lifetime:
// SAFETY: `host` and `device` stay alive and are not reused until `done` fires.
unsafe { device.copy_to_pinned_host_async(&stream, &mut host)? };
let done = stream.record_event(None)?;
The caller must keep both buffers alive, must not read or mutate an in-flight
host buffer, and must wait for the stream work before dropping or reusing it.
The same rule applies to copy_from_pinned_host_async. Pre-allocate device
buffers and refill them; from_pinned_host still performs a synchronous device
allocation before enqueuing its copy.
Rotating pinned staging buffers#
Operations stay ordered within one stream, while independent streams can make progress concurrently on hardware that supports concurrent copy and compute:
stream 0: upload 0 -> kernel 0 -> download 0 -> upload 3 -> kernel 3
stream 1: upload 1 -> kernel 1 -> download 1 -> upload 4 -> kernel 4
stream 2: upload 2 -> kernel 2 -> download 2 -> upload 5 -> kernel 5
The example gives each stream a persistent DeviceBuffer, a pinned staging
buffer, and a completion event. Upload, kernel, and download are already
ordered by the stream, so the event is only needed to protect host-side slot
reuse. Before reusing a slot, the host waits for its event, copies out the
completed result, and then refills the staging buffer. The final stream join
drains all work before CUDA-owned memory is dropped.
The core enqueue sequence inside the example’s loop is shown below; surrounding setup and result handling are omitted:
// SAFETY: the slot buffers stay alive and are not reused until completion.
unsafe {
devices[slot].copy_from_pinned_host_async(&streams[slot], &stagers[slot])?;
module.increment(&streams[slot], config, &mut devices[slot])?;
devices[slot].copy_to_pinned_host_async(&streams[slot], &mut stagers[slot])?;
}
completions[slot] = Some(streams[slot].record_event(None)?);
The complete setup, slot-reuse wait, and final drain are in the
run_overlapped implementation.
Measured impact#
The bandwidth benchmark warms up three times, records ten CUDA-event samples, and reports their median. The pipeline benchmark measures host wall time for eight 32 MiB chunks. The serialized path allocates and synchronizes a pageable device buffer for each chunk. The overlapped path pre-allocates three device buffers and pinned staging buffers, then includes host staging copies, event waits, and result copies in its wall time. The result is therefore an end-to-end path comparison, not a pure copy-engine measurement.
On the reference RTX 4090 (sm_89) run:
Pipeline |
Time |
|---|---|
Serialized pageable |
174.996 ms |
Overlapped pinned |
99.332 ms |
Speedup |
1.76x |
The full transfer table is in the example README. Results vary with the GPU, PCIe topology, driver, and system load.
Practical tradeoffs#
Pinning consumes limited host memory, and copying pageable input into a staging buffer is not free. Reuse a small ring of buffers and profile the copy engines before increasing the number of streams.
For the lower-level stream model, see
Scheduling and Streams. For the complete runnable
implementation, see the pinned_overlap example.