The step is short. The bus isn’t.
You profile a training step. Compute is short, but the GPU waits between batches while the CPU prepares and transfers the next tensor. pin_memory=True changes how those host bytes reach the device and can make overlap possible.
Two numbers
1. Path · staging plus H2D, or direct H2D
The pageable path stages data in pinned host memory before the host-to-device transfer. The diagram counts one CPU staging copy plus one H2D transfer. An already pinned tensor can take the direct H2D path; this count excludes the earlier work needed to prepare or pin it.
2. Overlap · the win is density, not µs
non_blocking=True lets the host proceed without waiting for each transfer to finish. Overlapping a copy with a GPU kernel also requires pinned source memory, a separate CUDA stream and an available DMA engine. See PyTorch's transfer guide.
Compare both paths, then step through the authored overlap schedule. These figures illustrate dependencies; they do not report hardware timings.
Path: two copies vs one
Pageable is the default. Staging is invisible until you watch the path. Pinned skips the middle box.
batch = next(loader) # pageablebatch = batch.to('cuda') # sync path# hidden: pageable → staging → GPUBatch sits in pageable host RAM. OS may have swapped pages.
Pageable tensors force a staging memcpy before DMA. The sketch counts one CPU staging copy and one host-to-device transfer.
What pin_memory() actually does
Under the hood you are asking for page-locked host memory
(cudaHostAlloc / pool reuse): pages stay in physical RAM and the
driver can issue DMA against them. That allocation is slower than a normal
malloc, which is why DataLoader pins on a side thread instead of
on the critical training path.
Overlap: why the flag exists
An input pipeline can hide some transfer time when loading, H2D and compute overlap. The tape assumes the stream, DMA and synchronization conditions above are satisfied; pinning alone does not establish that overlap.
Loaders work, then stop. Transfer and compute take turns instead of stacking.
Without pinning, the transfer path holds the CPU. Loaders and the next batch cannot stack under the current H2D — the tape stays hollow.
DataLoader defaults that pair with pinning
Workers fill the queue; the pin thread page-locks batches; your loop issues
non-blocking H2D and keeps the GPU busy. Drop prefetch_factor before
you kill pin_memory if RAM is tight.
DataLoader(
dataset,
batch_size=64,
num_workers=4,
pin_memory=True,
persistent_workers=True,
prefetch_factor=2,
)
images = batch["images"].to("cuda", non_blocking=True)
Dictionary: same bytes, different contract
Pageable pages can leave RAM. Pinned pages cannot. That is the whole OS-side trade.
OS may write pages to disk under pressure. DMA cannot trust the physical address.
mlock / page-locked. Physical frames stay put. Costs locked footprint.
Same tensor bytes. Different contract with the OS and the GPU. Pinning is a footprint trade for a simpler, async H2D path.
When pinning hurts
Pinned memory cannot be swapped. Footprint roughly:
pinned ≈ num_workers × prefetch_factor × batch_bytes
On a machine with headroom, locking a few hundred MB is free performance. On a 16 GB box already full of model + optimizer + Python, those locked pages push other processes into swap — worse than the staging copy you were trying to avoid.
# if Swap used climbs during training, shrink the pin footprint
watch -n 1 'free -h | grep Swap'
# 1) lower prefetch_factor or num_workers
# 2) smaller batch
# 3) pin_memory=False last
On multi-socket hosts, pinned allocations follow the NUMA node of the allocating thread. If the GPU lives on the other socket, DMA crosses the interconnect. Bind with numactl when that shows up in profiles — DataLoader will not fix topology for you.
What to do
- Enable
pin_memory=Truefor GPU training unless RAM is already critical. - Always pair with
.to(device, non_blocking=True)— pinning without async leaves half the win on the table. - Keep workers + prefetch so the pin thread has batches ready; tune those before disabling pin.
- Watch swap if the box is small; locked pages are not free.
- Confirm in the profiler — look for
cudaMemcpyAsyncfrom host pinned, not a double host path.
train_loader = DataLoader(
train_dataset,
batch_size=64,
num_workers=4,
pin_memory=True,
persistent_workers=True,
prefetch_factor=2,
)
for batch in train_loader:
images = batch['images'].to('cuda', non_blocking=True)
labels = batch['labels'].to('cuda', non_blocking=True)
outputs = model(images)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
Further Reading
- PyTorch DataLoader —
pin_memoryand multi-process loading - CUDA C Programming Guide: Page-Locked Host Memory —
cudaHostAllocand DMA rules - PyTorch Performance Tuning Guide — data pipeline knobs
Related concepts
A fine kernel can still profile poorly: warp divergence taxes throughput ~1/N; alone on a long scoreboard wait, SM issue util can hit 0%. Interactive demos.
Master GPU memory hierarchy from registers to global memory, understand coalescing patterns, bank conflicts, and optimization strategies for maximum performance
Structure of Arrays vs Array of Structures as instruments: cache-line fill, SIMD gather vs contiguous load, GPU coalescing, and AoSoA hybrids — when layout is a 10× decision.
Why DataLoader num_workers matters: processes hide load latency behind GPU work, how to find the sweet spot, and the memory/GIL pitfalls that come with the pool.
Follow PyTorch batches through worker queues, collation, ordered delivery and GPU transfer, then measure loading throughput and budget process memory.
Deep dive into the CUDA context object: control vs data plane, inventory (memory, modules, streams, events, graphs), push/pop/setCurrent stacks, primary retain/release, flags and limits, isolation, cost, and traps.
