3.3.6Deep Learning Frameworks

GPU acceleration and device management

2,867 words13 min readdifficulty · medium

Overview

GPU acceleration moves tensor computations from the CPU to specialized parallel processors (GPUs) that can perform thousands of operations simultaneously. Device management is the practice of explicitly controlling where tensors and models live (CPU vs GPU) and how data moves between devices.

Figure — GPU acceleration and device management

Core Concepts


The Physics of Why GPUs Win

Derivation: Speedup Factor

The time to compute a matrix multiplication C=ABC = AB where ARm×kA \in \mathbb{R}^{m \times k}, BRk×nB \in \mathbb{R}^{k \times n}:

TCPU=2mknfCPUcCPUT_{\text{CPU}} = \frac{2mkn}{f_{\text{CPU}} \cdot c_{\text{CPU}}}

where fCPUf_{\text{CPU}} is clock frequency (Hz), cCPUc_{\text{CPU}} is cores (~16).

For GPU with cGPUc_{\text{GPU}} cores (~10,000):

TGPU=2mknfGPUcGPU+TtransferT_{\text{GPU}} = \frac{2mkn}{f_{\text{GPU}} \cdot c_{\text{GPU}}} + T_{\text{transfer}}

Speedup: S=TCPUTGPUfGPUcGPUfCPUcCPUS = \frac{T_{\text{CPU}}}{T_{\text{GPU}}} \approx \frac{f_{\text{GPU}} \cdot c_{\text{GPU}}}{f_{\text{CPU}} \cdot c_{\text{CPU}}}

Pluging in typical values: fGPU1.5f_{\text{GPU}} \approx 1.5 GHz, cGPU=10000c_{\text{GPU}} = 10000, fCPU=3f_{\text{CPU}} = 3 GHz, cCPU=16c_{\text{CPU}} = 16:

S1.5×100003×16=1500048312×S \approx \frac{1.5 \times 10000}{3 \times 16} = \frac{15000}{48} \approx 312\times

But: This assumes TtransferTGPUT_{\text{transfer}} \ll T_{\text{GPU}}. If you transfer data every operation, speedup colapses.


PyTorch Device Management: From First Principles

How Tensors Know Their Location

Every PyTorch tensor has a .device property. Under the hood, it's a pointer to memory in either:

  • CPU DRAM (accessible by CPU cores)
  • GPU VRAM (accessible by GPU cores, isolated from CPU)

The Movement Tax


Training Loop: Device-Aware Pattern

The Standard Recipe

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
 
# 1. Move model to device (once)
model = MyModel().to(device)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters())
 
# 2. In training loop, move batches to device
for epoch in range(num_epochs):
    for batch_idx, (data, target) in enumerate(train_loader):
        # Move batch to GPU
        data, target = data.to(device), target.to(device)
        optimizer.zero_grad()
        output = model(data)  # Computed on GPU
        loss = criterion(output, target)  # Loss on GPU
        loss.backward()  # Gradients computed on GPU
        optimizer.step()  # Parameter update on GPU
        
        # Only move to CPU for logging
        if batch_idx % 100 == 0:
            print(f'Loss: {loss.item():.4f}')  # .item() copies scalar to CPU

Why each step?

  1. .to(device) on model moves all parameters/buffers to GPU memory (one-time cost)
  2. Moving batches to GPU: unavoidable (data loader reads from disk → CPU RAM → GPU VRAM)
  3. .item() for logging: scalars are tiny (~4 bytes), transfer is negligible
  4. Everything else stays on GPU: no unnecessary round-trips

Multi-GPU: Data Parallelism Derivation

The Problem

One GPU has limited memory (~24 GB). Large models or batches don't fit.

The Solution: Split the Batch


Common Mistakes


Mixed Precision Training: The 2× Speedup Trick

Why FP16?

Modern GPUs (Tensor Cores) can do FP16 matrix multiplies at 2× the FLOPS of FP32.

The problem: FP16 range is ±65,504\pm 65,504. Gradients often need FP32 precision (tiny values).

The solution: Automatic Mixed Precision (AMP)


Memory Management: The Hidden Enemy

GPU Memory Lifecycle

Allocate → Use → Free (maybe)

PyTorch uses a caching allocator:

  • First allocation: cudaMalloc (slow, 1ms)
  • Subsequent: reuse cached blocks (fast, 1µs)
  • .empty_cache() returns memory to CUDA but doesn't free to OS

Gradient Accumulation for Large Batches

When batch size 512 doesn't fit, use gradient accumulation:

accumulation_steps = 4  # Effective batch size = 128× 4 = 512
 
for i, (data, target) in enumerate(train_loader):
    data, target = data.to(device), target.to(device)
    
    output = model(data)
    loss = criterion(output, target) / accumulation_steps  # Scale loss
    loss.backward()  # Accumulate gradients
    if (i + 1) % accumulation_steps == 0:
        optimizer.step()
        optimizer.zero_grad()

Derivation:

  • Normal: θL=1Bi=1Bθi\nabla_\theta L = \frac{1}{B}\sum_{i=1}^B \nabla_\theta \ell_i
  • Accumulated: θL=14j=14[1B/4ibatchjθi]=1Bi=1Bθi\nabla_\theta L = \frac{1}{4} \sum_{j=1}^4 \left[\frac{1}{B/4}\sum_{i \in \text{batch}_j} \nabla_\theta \ell_i \right] = \frac{1}{B}\sum_{i=1}^B \nabla_\theta \ell_i

Why divide loss? .backward() sums gradients. Without /accumulation_steps, final gradients would be 4× too large.


Recall Explain to a 12-Year-Old

Imagine you have math homework:1000 multiplication problems. CPU (your brain): You solve them one-by-one. You're smart and fast, but it takes time because you do one thing at a time.

GPU (a classroom of1000 kids): Each kid solves ONE problem. They're not geniuses, but together they finish in the time it takes one kid to solve one problem!

Neural networks are like this homework—millions of simple multiplications. GPUs have thousands of "kids" (cores) working in parallel.

The catch: Moving the homework from your desk (CPU memory) to the classroom (GPU memory) takes time. So we move all problems at once (batches), not one-by-one.

Mixed precision: Some problems can be solved with simpler math (FP16 = using fewer decimal places). It's faster but sometimes makes mistakes, so we keep a "master copy" of the answer in high precision (FP32).


Connections

  • 3.3.05-Computational-graphsand-autograd: GPU accelerates both forward and backward passes through the computational graph
  • 3.2.03-Backpropagation: Gradient computation is embarrassingly parallel, perfect for GPUs
  • 3.4.02-Batch-normalization: Requires all-reduce in multi-GPU (sync mean/variance)
  • 4.1.01-CNN-architectures: Convolutions are GPU bottleneck—optimizing kernels is critical
  • 3.3.07-Distributed-training-strategies: Multi-node extends device management across machines

#flashcards/ai-ml

Why do GPUs outperform CPUs for deep learning? :: GPUs have thousands of cores optimized for parallel arithmetic (low clock speed but massive parallelism), while CPUs have ~16 complex cores for sequential tasks. Neural network operations (matrix multiplies) are embarrassingly parallel—each element computed independently—matching GPU architecture perfectly.

What is the GPU memory transfer bottleneck?
PCIe bandwidth (~16 GB/s) is 50× slower than GPU memory bandwidth (~900 GB/s). Frequent CPU↔GPU transfers destroy speedup. Solution: move data once, keep computations on GPU, only transfer results.
What does torch.cuda.synchronize() do and why is it needed?
GPU operations are asynchronous—Python continues while GPU computes. synchronize() blocks until all GPU kernels finish. Needed for accurate timing and when CPU must read GPU results. Without it, timing measures queue time, not compute time.

Explain data parallelism's all-reduce step :: Each GPU computes gradients on its batch partition independently. All-reduce synchronizes gradients: each GPU gets the average gradient across all GPUs. Formula: θ=1Ni=1Nθi\nabla\theta = \frac{1}{N}\sum_{i=1}^N \nabla\theta_i. Implemented efficiently via ring all-reduce (NCCL library), bandwidth cost O(2P/N)O(2P/N) where PP is parameter count, NN is GPUs.

Why does mixed precision training use loss scaling?
FP16 range is ±65,504\pm 65,504 with min 6×1086 \times 10^{-8}. Gradients in deep networks can be 101010^{-10} (underflow to zero in FP16). Scaling loss by 2162^{16} before backward pass scales gradients into FP16 range. After backward, unscale gradients before optimizer step. Preserves small gradients without overflow.
What is gradient accumulation and when to use it?
Simulating large batch sizes on limited GPU memory. Compute forward/backward on mini-batches, accumulate gradients (don't step optimizer), then step after N accumulations. Effective batch size = mini-batch × N. Critical: divide loss by N or optimizer sees N× larger gradients. Use when batch size B doesn't fit in memory but B/N does.
Why does appending GPU tensors to a list cause OOM?
Each tensor reference keeps GPU memory allocated. List of1000 tensors = 1000 separate allocations in VRAM. PyTorch's garbage collector doesn't free until Python object deleted. Solution: .item() for scalars (copies to CPU as Python float), or .detach().cpu() for larger tensors, severing GPU reference.

What's the difference between memory_allocated and memory_reserved? :: memory_allocated(): currently live tensor memory. memory_reserved(): memory PyTorch holds from CUDA (includes cache). Gap = cached free blocks. After del tensor, memory_allocated drops but reserved stays high (cache). .empty_cache() returns reserved memory to CUDA but not to OS (CUDA driver still holds it).

Concept Map

is

motivates

uses

speed up

few complex cores

programmed via

compiles ops into

has

needs

places tensors on

requires

bottlenecked by

reduces

maximized by

Deep Learning

Embarrassingly Parallel

GPU Acceleration

Thousands of Cores

Matrix Multiplication

CPU

Sequential Compute

CUDA Platform

GPU Kernels

Tensor Cores

Device Management

cpu or cuda

Host-to-Device Transfer

PCIe ~16 GBs

Real Speedup

Keep Data on GPU

Hinglish (regional understanding)

Intuition Hinglish mein samjho

Hinglish (regional understanding)

Intuition Hinglish mein samjho

Dekho, yahan core baat samajhne ki ye hai ki GPU aur CPU dono alag-alag tarah ke workers hain. CPU ek master chef ki tarah hai — bahut fast, par ek time pe thode se hi kaam kar sakta hai kyunki uske paas sirf 8-16 powerful cores hote hain. GPU thousands of simple cooks ki tarah hai — har ek chhota kaam simultaneously karta hai. Ab neural networks mein jo matrix multiplications hote hain, wo millions of independent multiply-add operations hote hain — matlab "embarrassingly parallel" kaam. Isiliye GPU pe ye kaam 100x se 300x tak fast ho jaata hai, kyunki saare operations ek saath parallel mein chal sakte hain.

Lekin ek catch hai jo bahut zaroori hai — data ko CPU (RAM) se GPU (VRAM) tak move karna padta hai, aur ye transfer SLOW hota hai (PCIe bottleneck ~16 GB/s), jabki GPU ki apni memory bandwidth ~900 GB/s hoti hai. To agar tum har chhoti operation ke liye baar-baar data idhar-udhar bhejte raho, to tumhara saara speedup advantage khatam ho jaata hai. Isiliye "Golden Rule" ye hai: data ko GPU pe hi rakho, transfers minimize karo, aur operations ko batch karke ek saath bhejo. PyTorch mein har tensor ka ek .device property hota hai jo batata hai wo CPU pe hai ya GPU pe, aur .to(device) se tum use move kar sakte ho.

Ye baat kyun matter karti hai? Kyunki jab tum real projects mein deep learning models train karoge, to sahi device management ke bina tumhara training bahut slow rahega ya crash bhi ho sakta hai. Bahut se beginners galti karte hain ki wo model GPU pe daal dete hain par data CPU pe chhod dete hain, ya baar-baar transfer karte hain — aur phir socha hua speed nahi milta. Agar tum ye intuition samajh loge — GPU ki power parallelism mein hai, aur transfer ek "tax" hai jise avoid karna hai — to tum efficient aur fast ML systems bana paoge, jo industry mein ek bahut valuable skill hai.

Go deeper — visual, from zero

Test yourself — Deep Learning Frameworks

Connections