Skip to main content

Pinned Memory and DMA Transfers in PyTorch

How page-locked memory changes CPU-to-GPU transfers, the extra staging in pageable paths, and the conditions needed to overlap copies with GPU work.

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.

H2D transfer path
memory path
Step 1 / 4
2×
copies in this authored transfer path
CPU idle
beat 1/4
Host RAM
pageable
Staging
pinned temp
GPU VRAM
device
pageable_transfer.py
batch = next(loader)          # pageablebatch = batch.to('cuda')      # sync path# hidden: pageable → staging → GPU

Batch 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.

Pipeline overlap
transfer mode
Step 1 / 12
33%
GPU busy in this authored schedule
now 1 lanes
t0
Who is working each step · amber = CPU tax on H2D
load
H2D
GPU

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.

python
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.

Pageable vs pinned
topic
Pageable
P
P
P
P
P
P
Can leave RAM

OS may write pages to disk under pressure. DMA cannot trust the physical address.

OS
Pinned
L
L
L
L
L
L
Locked in RAM

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:

text
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.

bash
# 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

  1. Enable pin_memory=True for GPU training unless RAM is already critical.
  2. Always pair with .to(device, non_blocking=True) — pinning without async leaves half the win on the table.
  3. Keep workers + prefetch so the pin thread has batches ready; tune those before disabling pin.
  4. Watch swap if the box is small; locked pages are not free.
  5. Confirm in the profiler — look for cudaMemcpyAsync from host pinned, not a double host path.
python
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

If you found this explanation helpful, consider sharing it with others.

Mastodon