GPU Memory Sharding with FSDP
DISTRIBUTED DEEP LEARNING — PART 1/5
Day 1: Demystifying GPU Memory & The ZeRO Revolution — DeepSpeed, FSDP & State Sharding
💥 Context: You attempt to fine-tune or pretrain a 13-billion parameter dense transformer on an 80GB NVIDIA H100 or A100 GPU using standard PyTorch. You set your batch size to 1. You press enter. Within three seconds, your terminal explodes with the most dreaded error in artificial intelligence: torch.cuda.OutOfMemoryError: CUDA out of memory. How is this possible? In 16-bit precision, 13 billion parameters occupy only ~26 GB of disk space. Why can't an 80 GB state-of-the-art GPU train a 26 GB model? Because the naive mental model of machine learning memory is deeply flawed. Today, we demystify the true $16\Phi$ memory footprint of neural networks and master the architecture that made modern large language models possible: ZeRO (Zero Redundancy Optimizer) and PyTorch Fully Sharded Data Parallel (FSDP).
1. The True Anatomy of GPU VRAM: The $16\Phi$ Tax
When training with mixed precision (FP16 or BF16) using the standard AdamW optimizer, memory is divided into two broad categories: Model States and Residual Memory.
For a model with $\Phi$ parameters, static model states consume a staggering 16 bytes per parameter:
| Memory Component | Precision / Representation | Bytes per Parameter | Memory for 13B Model |
|---|---|---|---|
| Model Parameters ($\Phi$) | BF16 / FP16 | $2$ bytes | 26 GB |
| Gradients ($g$) | BF16 / FP16 | $2$ bytes | 26 GB |
| Adam Master Weights | FP32 (Numerical stability) | $4$ bytes | 52 GB |
| Adam Momentum ($m$) | FP32 First Moment | $4$ bytes | 52 GB |
| Adam Variance ($v$) | FP32 Second Moment | $4$ bytes | 52 GB |
| Total Static States | Parameters + Grads + Adam | $16\Phi$ bytes | 208 GB (OOM!) |
2. The ZeRO Paradigm: Sharding Without Communication Penalty
In 2019, Microsoft Research introduced the Zero Redundancy Optimizer (ZeRO), which eliminates memory redundancy in DDP by sharding model states across $N$ GPUs:
- ZeRO-Stage 1 (Optimizer State Sharding): The $12\Phi$ Adam optimizer states are partitioned across $N$ devices. Each GPU updates only $1/N$ of the parameters. Static memory reduces from $16\Phi \to 4\Phi + \frac{12\Phi}{N}$. For $N=8$, memory drops from 208 GB to 71.5 GB with zero additional communication overhead!
- ZeRO-Stage 2 (Gradient + Optimizer Sharding): Gradients are also sharded ($2\Phi/N$). As gradients are computed during the backward pass, they are immediately reduced into their assigned shard via
Reduce-Scatterand discarded locally. Static memory drops to $2\Phi + \frac{14\Phi}{N}$. For $N=8$, memory drops to 43.2 GB. - ZeRO-Stage 3 / PyTorch FSDP (Full Parameter Sharding): Every state—parameters, gradients, and optimizer states—is sharded across all GPUs. Each GPU holds only $\frac{16\Phi}{N}$ bytes! For $N=8$, the 13B model static footprint drops to 26 GB, comfortably fitting into standard GPUs.
3. How PyTorch FSDP Executes Under the Hood
If parameters are sharded across 8 GPUs, how does a GPU execute a matrix multiplication that requires the full layer weight matrix? FSDP implements an on-demand transient gather pipeline:
- Forward Pass: When execution reaches Layer $L$, FSDP issues an asynchronous
All-Gatheracross NVLink to reconstruct the full weights of Layer $L$ in temporary memory. The forward computation runs. Immediately upon completion, the full weights are discarded from memory, keeping only the local shard. - Backward Pass: When backpropagation reaches Layer $L$, FSDP again performs an
All-Gatherto reconstruct the full weights to compute input gradients. Once gradients are calculated, FSDP executes aReduce-Scatteron gradients to send the partial gradient sums to their corresponding owner GPU, and immediately discards both the full weights and full gradients. - Communication Cost: FSDP increases communication volume by exactly $1.5\times$ compared to standard DDP, but unlocks the ability to train models $N\times$ larger without complex model parallelism frameworks.
4. Production PyTorch FSDP Implementation
Here is an enterprise-grade PyTorch implementation using native FullyShardedDataParallel with custom transformer block auto-wrapping and mixed precision:
import os import functools import torch import torch.nn as nn import torch.distributed as dist from torch.distributed.fsdp import ( FullyShardedDataParallel as FSDP, ShardingStrategy, MixedPrecision, BackwardPrefetch, ) from torch.distributed.fsdp.wrap import transformer_auto_wrap_policy # 1. Define Standard Transformer Block class TransformerBlock(nn.Module): def __init__(self, dim: int): super().__init__() self.norm1 = nn.LayerNorm(dim) self.attn = nn.Linear(dim, dim, bias=False) self.norm2 = nn.LayerNorm(dim) self.mlp = nn.Sequential( nn.Linear(dim, 4 * dim, bias=False), nn.GELU(), nn.Linear(4 * dim, dim, bias=False), ) def forward(self, x): x = x + self.attn(self.norm1(x)) x = x + self.mlp(self.norm2(x)) return x # 2. Deep Transformer Model class DeepTransformerModel(nn.Module): def __init__(self, num_layers: int = 24, dim: int = 4096): super().__init__() self.layers = nn.ModuleList([TransformerBlock(dim) for _ in range(num_layers)]) self.head = nn.Linear(dim, 32000, bias=False) def forward(self, x): for layer in self.layers: x = layer(x) return self.head(x) # 3. Distributed Training Setup def train_fsdp(): dist.init_process_group(backend="nccl") local_rank = int(os.environ["LOCAL_RANK"]) torch.cuda.set_device(local_rank) # Precision Policy: Compute in BF16, gradients in FP32 mixed_precision_policy = MixedPrecision( param_dtype=torch.bfloat16, reduce_dtype=torch.float32, buffer_dtype=torch.bfloat16, ) # Auto-wrap each TransformerBlock independently auto_wrap_policy = functools.partial( transformer_auto_wrap_policy, transformer_layer_cls={TransformerBlock}, ) raw_model = DeepTransformerModel().cuda(local_rank) # Wrap with Fully Sharded Data Parallelism model = FSDP( raw_model, auto_wrap_policy=auto_wrap_policy, mixed_precision=mixed_precision_policy, sharding_strategy=ShardingStrategy.FULL_SHARD, backward_prefetch=BackwardPrefetch.BACKWARD_PRE, device_id=local_rank, ) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=0.1) # Synthetic Training Step inputs = torch.randn(2, 512, 4096, device=local_rank, dtype=torch.bfloat16) outputs = model(inputs) loss = outputs.sum() loss.backward() optimizer.step() optimizer.zero_grad() if local_rank == 0: max_mem_gb = torch.cuda.max_memory_allocated() / (1024**3) print(f"[+] FSDP Step Complete! Peak GPU VRAM: {max_mem_gb:.2f} GB") dist.destroy_process_group()
The Shareable Quote: "Do not restrict intelligence to the boundaries of a single silicon chip; shard state across the cluster and let parameters materialize only in the moment of action."
🛠️ Day 1 Actionable Project: FSDP vs. DDP Memory Profiling
Construct a reproducible multi-GPU memory benchmark comparing standard DDP against FSDP:
- Write a script initializing a 3-billion parameter transformer model.
- Run a forward and backward training step using standard PyTorch DDP and log peak VRAM using
torch.cuda.max_memory_allocated(). - Re-wrap the identical model using
FSDP(sharding_strategy=ShardingStrategy.FULL_SHARD)and measure the exact percentage of memory reduction across a 2-GPU or 4-GPU setup.
Tomorrow in Part 2, we tackle models too large to fit a single layer on a single GPU: Day 2: Tensor Parallelism (TP) & Sequence Parallelism — Megatron-LM Matrix Sharding (Vibhūti Yoga).
Comments
Post a Comment
?: "90px"' frameborder='0' id='comment-editor' name='comment-editor' src='' width='100%'/>