Device & Memory Management¶
Overview¶
PyTorch splits work across CPU (RAM + pinned host memory) and GPU (device memory). Getting tensors to the right device— and overlapping transfers with compute— is often the biggest, cheapest speedup available, especially with the caching allocator masking the truth about memory usage.
device='cpu'|'cuda[:i]'|'mps'|'meta'.to(device)allocates + copies;.to(device, non_blocking=True)overlaps with pinned host memory.- The CUDA caching allocator hoards freed blocks, so
nvidia-smishows more than Python statistics report. pin_memory+DataLoader(..., num_workers)hides data-loading I/O.
Device transfers are async; profile with
.cuda.synchronize()ortorch.profilerto see the true cost.
-
Device Types & Discovery¶
import torch
print("cuda available:", torch.cuda.is_available())
if torch.cuda.is_available():
print("count:", torch.cuda.device_count())
print("name:", torch.cuda.get_device_name(0))
print("current:", torch.cuda.current_device())
print("max mem (MiB):", torch.cuda.get_device_properties(0).total_memory // 2**20)
The meta device— allocations without memory¶
Perfect for measuring model sizes and running shape inference.
import torch.nn as nn
m = nn.Linear(1024, 1024)
meta = nn.Linear(1024, 1024).to('meta')
print("meta forward is shape-only:", (meta(torch.randn(8, 1024, device='meta'))).shape)
-
Moving Data: .to, cuda, cpu, clone¶
x = torch.randn(4, 4)
x_gpu = x.to('cuda') # copy to GPU
x_back = x_gpu.cpu() # back to CPU (returns copy)
x_clone = x.clone() # same device, new storage
x_shared = x_gpu.detach() # same device, same storage, no grad
dtype/device shorthand¶
y = x.to(device='cuda', dtype=torch.float16)
z = x.half().cuda()
.to()preserves shared storage when only dtype/device match but the tensor is contiguous; otherwise it copies. Prefer explicit.detach()when you need isolation.
-
Pinned Memory & non_blocking Transfers¶
Pageable (default) host memory requires a staging copy through pinned buffers during transfer. Pinned host memory can transfer directly to device— enabling real async overlap.
# Pin host tensors (future data stays on device-ready pages)
x = torch.randn(4096, 4096)
x_pinned = x.pin_memory()
# non_blocking -> transfer overlaps with compute already queued
x_gpu = x_pinned.to('cuda', non_blocking=True)
# Process / compute on other tensors here -> runs concurrently
y = x_pinned * 2
torch.cuda.synchronize()
DataLoader with pin_memory + workers¶
from torch.utils.data import DataLoader, TensorDataset
ds = TensorDataset(torch.randn(64, 8), torch.randint(0, 2, (64,)))
loader = DataLoader(
ds,
batch_size=16,
num_workers=4, # parallel CPU loading
pin_memory=True, # host allocations are pinned -> fast async H2D
persistent_workers=True,
)
for xb, yb in loader:
xb = xb.to('cuda', non_blocking=True) # fuse into training stream
break
-
The CUDA Caching Allocator— "Leaky" Is a Lie¶
import torch
def mem():
torch.cuda.synchronize()
a = torch.cuda.memory_allocated()
r = torch.cuda.memory_reserved()
return a // 2**20, r // 2**20
print("start (alloc, reserved) MiB:", mem())
x = torch.randn(10_000, 10_000, device='cuda') # ~800 MiB
print("after alloc (alloc, reserved):", mem())
del x
print("after del (alloc, reserved):", mem())
torch.cuda.empty_cache()
print("after empty_cache (reserved):", mem())
# reserved (driver allocations) stays high until empty_cache; that's normal.
The allocator reuses freed blocks to avoid driver round-trips. High "reserved" ≠ leak. Empty only before measuring peak or OOM triage.
Peak memory accounting¶
torch.cuda.reset_peak_memory_stats()
model(torch.randn(64, 64, device='cuda'))
peak = torch.cuda.max_memory_allocated() // 2**20
print("peak activation+param MiB:", peak)
-
Async Pitfall: Synchronization Is Implicit & Expensive¶
GPU ops are queued; many "gotchas" come from hidden sync points (.item(), .numpy(), .tolist(), comparisons in eager Python, len()).
x = torch.randn(10000, 10000, device='cuda')
s = x.sum()
print(s.item()) # forces a device->host sync (stalls pipeline)
In a hot loop, hoist
.item()/.numpy()out. Usetorch.cuda.Eventfor timing, not Pythontime.time()around each op.
def time_gpu(fn, n=20):
torch.cuda.synchronize()
s, e = torch.cuda.Event(True), torch.cuda.Event(True)
s.record()
for _ in range(n):
fn()
e.record(); torch.cuda.synchronize()
return s.elapsed_time(e) / n # ms
val = time_gpu(lambda: x @ x)
print(f"{val:.3f} ms/matmul")
Device Movement Best Practices¶
| Scenario | Pattern |
|---|---|
| Training loop | Keep model on GPU; pin data, non_blocking=True |
| Checkpoint save | Move to CPU (state_dict() stores CPU copies anyway) |
| Large cross-device transfer | Split into chunks + non_blocking=True |
| MPS/Apple Silicon | device='mps', same API, watch dtype support |
| Shape-only logic | device='meta' to skip memory |
Strategic device single-line pattern¶
DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
x = x.to(DEVICE, non_blocking=DEVICE.type != 'cpu')
model = model.to(DEVICE)
-
Key Takeaways¶
- The caching allocator hides the truth; use
memory_allocated/reservedandmax_memory_allocated. - Pinned memory +
non_blocking=Trueunlocks async H2D copies that overlap compute. - Async GPU queues + implicit syncs (
item()) are the classic perf killers— learntorch.cuda.Event. - Prefer
metadevice for shape/size inspection and peak-memory prediction. - Set a single
DEVICEvariable and passnon_blockingconditionally to stay portable.
-
Related Topics¶
- Precision & Numerics
- [Profiling & Benchmarking](/06-pytorch/04-performance-and-compilation/(04-profiling-benchmarking/)
- [Activation Checkpointing](/06-pytorch/08-advanced-training-techniques/(03-activation-checkpointing-memory-restructuring/)