Master Python from the inside out. Here, we don't just write code; we look under the hood at memory management, data types, and logic, all while applying the mindfulness and philosophy of the Bhagavad Gita to our development journey.
DISTRIBUTED DEEP LEARNING — PART 1/5
💥 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).
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!) |
In 2019, Microsoft Research introduced the Zero Redundancy Optimizer (ZeRO), which eliminates memory redundancy in DDP by sharding model states across $N$ GPUs:
Reduce-Scatter and discarded locally. Static memory drops to $2\Phi + \frac{14\Phi}{N}$. For $N=8$, memory drops to 43.2 GB.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:
All-Gather across 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.All-Gather to reconstruct the full weights to compute input gradients. Once gradients are calculated, FSDP executes a Reduce-Scatter on gradients to send the partial gradient sums to their corresponding owner GPU, and immediately discards both the full weights and full gradients.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."
Construct a reproducible multi-GPU memory benchmark comparing standard DDP against FSDP:
torch.cuda.max_memory_allocated().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%'/>