FSDP › Code Implementation

Michael BrenndoerferJanuary 22, 202616 min read
Language AI HandbookAll chapters
Part 31 of 67Training Infrastructure
Chapter 226FSDP › Code Implementation
Reading section 9 of 13 · Code Implementation · 16 minSection 9 of 13 · 16 min

Book contents

Section 9 of 13 · 16 min read

FSDP › Code ImplementationOne section · Sections in this chapter9 / 13 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

This section walks through a complete FSDP training setup, starting from a small model definition and building up to a full distributed training loop.

Setup and Imports

We start with the necessary imports and a simple model definition.

Defining a Simple Transformer Model

We define a small transformer model with a configurable number of layers and dimensions. This is purely for demonstration; in practice you would use a model from Hugging Face or your own architecture.

In[6]:
Code
import torch.nn as nn


class TransformerLayer(nn.Module):
    """A single transformer encoder layer."""

    def __init__(self, d_model=256, nhead=4, dim_feedforward=1024):
        super().__init__()
        self.self_attn = nn.MultiheadAttention(d_model, nhead, batch_first=True)
        self.ff = nn.Sequential(
            nn.Linear(d_model, dim_feedforward),
            nn.GELU(),
            nn.Linear(dim_feedforward, d_model),
        )
        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)

    def forward(self, x):
        attn_out, _ = self.self_attn(x, x, x)
        x = self.norm1(x + attn_out)
        x = self.norm2(x + self.ff(x))
        return x


class SimpleTransformer(nn.Module):
    """A stack of transformer layers with an embedding and output head."""

    def __init__(
        self,
        vocab_size=10000,
        d_model=256,
        nhead=4,
        dim_feedforward=1024,
        num_layers=6,
    ):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, d_model)
        self.layers = nn.ModuleList(
            [
                TransformerLayer(d_model, nhead, dim_feedforward)
                for _ in range(num_layers)
            ]
        )
        self.output_head = nn.Linear(d_model, vocab_size)

    def forward(self, input_ids):
        x = self.embedding(input_ids)
        for layer in self.layers:
            x = layer(x)
        return self.output_head(x)

Counting Parameters

Let's inspect the parameter count to understand the memory requirements.

In[7]:
Code
def count_parameters(model):
    total = sum(p.numel() for p in model.parameters())
    trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
    return total, trainable


model = SimpleTransformer(
    vocab_size=10000,
    d_model=256,
    nhead=4,
    dim_feedforward=1024,
    num_layers=6,
)
total_params, trainable_params = count_parameters(model)
Out[8]:
Console
Total parameters:     9,868,560
Trainable parameters: 9,868,560
Memory (FP32):        39.5 MB
Memory (with Adam):   157.9 MB

The output shows parameter count and estimated memory footprint. The multiplier of 16 accounts for parameters (4 bytes), gradients (4 bytes), and Adam optimizer states (8 bytes: first moment plus second moment, both in float32).

FSDP Initialization

This function wraps a model with FSDP. In a real distributed job, it must be called after dist.init_process_group() so that the process group is available for communication.

In[9]:
Code
import functools

from torch.distributed.fsdp import (
    FullyShardedDataParallel as FSDP,
)
from torch.distributed.fsdp import (
    MixedPrecision,
    ShardingStrategy,
)
from torch.distributed.fsdp.wrap import transformer_auto_wrap_policy


def wrap_model_with_fsdp(model, sharding_strategy=ShardingStrategy.FULL_SHARD):
    """
    Wrap a transformer model with FSDP.

    Parameters:
    - model: The PyTorch model to wrap
    - sharding_strategy: The FSDP sharding strategy to use

    Returns the FSDP-wrapped model.
    """
    # Define the auto-wrap policy to wrap each TransformerLayer separately
    auto_wrap_policy = functools.partial(
        transformer_auto_wrap_policy,
        transformer_layer_cls={TransformerLayer},
    )

    # Mixed precision configuration
    mp_policy = MixedPrecision(
        param_dtype=torch.float16,
        reduce_dtype=torch.float32,
        buffer_dtype=torch.float16,
    )

    # Wrap the model
    fsdp_model = FSDP(
        model,
        auto_wrap_policy=auto_wrap_policy,
        sharding_strategy=sharding_strategy,
        mixed_precision=mp_policy,
        device_id=torch.cuda.current_device(),
    )
    return fsdp_model

Simulating Memory Usage per Strategy

Since we cannot run distributed training in this notebook, we can compute the theoretical memory savings analytically for different sharding strategies.

In[10]:
Code
def memory_per_gpu(total_params, num_gpus, strategy):
    """
    Compute estimated GPU memory in bytes for each sharding strategy.

    Parameters:
    - total_params: total number of model parameters
    - num_gpus: number of GPUs in the training job
    - strategy: one of 'full_shard', 'shard_grad_op', 'no_shard'

    Returns a dict with breakdown by component.
    """
    bytes_per_param = 4  # float32

    if strategy == "no_shard":
        params = total_params * bytes_per_param
        grads = total_params * bytes_per_param
        optimizer = total_params * 2 * bytes_per_param  # Adam: 2x
    elif strategy == "shard_grad_op":
        params = total_params * bytes_per_param  # not sharded
        grads = total_params * bytes_per_param / num_gpus
        optimizer = total_params * 2 * bytes_per_param / num_gpus
    elif strategy == "full_shard":
        params = total_params * bytes_per_param / num_gpus
        grads = total_params * bytes_per_param / num_gpus
        optimizer = total_params * 2 * bytes_per_param / num_gpus
    else:
        raise ValueError(f"Unknown strategy: {strategy}")

    total = params + grads + optimizer
    return {
        "params_mb": params / 1e6,
        "grads_mb": grads / 1e6,
        "optimizer_mb": optimizer / 1e6,
        "total_mb": total / 1e6,
    }


strategies = ["no_shard", "shard_grad_op", "full_shard"]
num_gpus_options = [1, 4, 8, 16]
Out[11]:
Console
Strategy         GPUs   Params (MB)    Grads (MB)    Optim (MB)    Total (MB)  
--------------------------------------------------------------------------------
no_shard         1      39.5           39.5          78.9          157.9       
no_shard         4      39.5           39.5          78.9          157.9       
no_shard         8      39.5           39.5          78.9          157.9       
no_shard         16     39.5           39.5          78.9          157.9       

shard_grad_op    1      39.5           39.5          78.9          157.9       
shard_grad_op    4      39.5           9.9           19.7          69.1        
shard_grad_op    8      39.5           4.9           9.9           54.3        
shard_grad_op    16     39.5           2.5           4.9           46.9        

full_shard       1      39.5           39.5          78.9          157.9       
full_shard       4      9.9            9.9           19.7          39.5        
full_shard       8      4.9            4.9           9.9           19.7        
full_shard       16     2.5            2.5           4.9           9.9

The table reveals the progressive memory reduction across strategies. With full_shard on 16 GPUs, each GPU holds just 1/16 of each component, a 16x reduction in steady-state memory. Notice that shard_grad_op achieves a substantial reduction too because the optimizer states dominate: even though parameters are replicated, sharding the optimizer states (8P8P bytes) alone saves significantly more memory than sharding parameters alone (4P4P bytes).

Visualizing Memory Savings

Out[12]:
Visualization
Line chart comparing GPU memory usage vs number of GPUs for FULL_SHARD, SHARD_GRAD_OP, and NO_SHARD strategies.
Estimated GPU memory per device (MB) for three FSDP sharding strategies across different GPU counts. FULL_SHARD achieves the steepest reduction because it shards all three memory components: parameters, gradients, and optimizer states. SHARD_GRAD_OP achieves moderate savings by keeping full parameters but sharding gradients and optimizer states. NO_SHARD (equivalent to DDP) shows no reduction as GPU count increases, confirming that replication alone does not reduce per-device memory.

Visualizing Communication Volume

With FULL_SHARD, each forward pass triggers an all-gather for every FSDP unit. The total communication volume per training step is larger than with standard DDP. Let's quantify this difference.

The communication overhead is the fundamental trade-off of FSDP: you gain memory capacity in exchange for more communication. Whether this trade-off is favorable depends entirely on your hardware's interconnect speed relative to compute speed. On a cluster where GPUs are connected by slow Ethernet, the extra communication turns a compute-bound problem into a communication-bound one. On an NVLink-connected server, the all-gather operations happen at memory speed and the overhead is negligible.

In[13]:
Code
def communication_volume_gb(total_params, num_gpus, num_fsdp_units, strategy):
    """
    Estimate communication volume per training step in GB.

    For FULL_SHARD:
    - Forward: num_fsdp_units all-gathers, each moving total_params/num_fsdp_units params
    - Backward: num_fsdp_units all-gathers + num_fsdp_units reduce-scatters

    For SHARD_GRAD_OP:
    - No all-gathers during forward/backward
    - One reduce-scatter at end of backward

    For NO_SHARD (DDP):
    - One all-reduce at end of backward
    """
    bytes_per_param = 4

    if strategy == "full_shard":
        # Forward: M all-gathers, each reconstructing P/M params across N GPUs
        # Communication volume per all-gather = P * (N-1)/N * 2 (send + receive)
        # Simplified as P * 2 bytes (one full copy across ring)
        fwd_volume = (
            total_params * bytes_per_param * 2
        )  # all-gathers in forward
        # Backward: M all-gathers + M reduce-scatters
        bwd_volume = (
            total_params * bytes_per_param * 2
            + total_params * bytes_per_param * 2
        )
        total_volume = fwd_volume + bwd_volume
    elif strategy == "shard_grad_op":
        # Only one reduce-scatter at end of backward
        total_volume = total_params * bytes_per_param * 2
    elif strategy == "no_shard":
        # All-reduce = 2 * total_params * bytes (ring algorithm)
        total_volume = total_params * bytes_per_param * 2
    else:
        raise ValueError(f"Unknown strategy: {strategy}")

    return total_volume / 1e9  # GB
Out[14]:
Console
Communication volume per training step (model with 9,868,560 params):

Strategy             Volume (GB)
-----------------------------------
no_shard             0.0789 GB
shard_grad_op        0.0789 GB
full_shard           0.2368 GB

The output illustrates the communication cost trade-off: FULL_SHARD incurs more communication per step because of the all-gather operations during both forward and backward passes. For this small demonstration model, the absolute volumes are tiny. For a 70B model on 8 GPUs, FULL_SHARD would move approximately 70×109×4×6≈1.6870 \times 10^9 \times 4 \times 6 \approx 1.68 TB per step (before counting the reduce-scatters), while SHARD_GRAD_OP and NO_SHARD would each move about 560560 GB. This is why interconnect bandwidth is the deciding factor: on high-bandwidth networks, even the larger communication volumes complete in a fraction of the compute time.

Visualizing Communication vs. Memory Trade-off

Out[15]:
Visualization
Scatter plot showing communication volume vs memory per GPU for three FSDP strategies, illustrating the memory-communication trade-off.
Trade-off between communication volume per step and steady-state model-state memory per GPU for FSDP sharding strategies with 8 GPUs. FULL_SHARD minimizes memory at the cost of higher communication volume. SHARD_GRAD_OP matches DDP's communication volume while saving optimizer and gradient memory. The preferred strategy is nearest to the lower-left corner given your hardware constraints.

Sharding Strategy Selection Guide

To help you choose the right strategy for a given scenario, here is a decision framework based on model size and hardware.

In[16]:
Code
def recommend_strategy(model_params, gpu_memory_gb, num_gpus):
    """
    Recommend an FSDP sharding strategy based on model and hardware.

    Parameters:
    - model_params: number of model parameters
    - gpu_memory_gb: memory per GPU in GB
    - num_gpus: number of available GPUs

    Returns a strategy recommendation with reasoning.
    """
    # Memory requirements in GB (FP32 + Adam)
    mem_no_shard_gb = model_params * 16 / 1e9
    mem_shard_grad_op_gb = (
        model_params * (4 + 4 / num_gpus + 8 / num_gpus) / 1e9
    )
    mem_full_shard_gb = model_params * 16 / (num_gpus * 1e9)

    results = {
        "NO_SHARD fits": mem_no_shard_gb <= gpu_memory_gb,
        "SHARD_GRAD_OP fits": mem_shard_grad_op_gb <= gpu_memory_gb,
        "FULL_SHARD fits": mem_full_shard_gb <= gpu_memory_gb,
        "mem_no_shard_gb": mem_no_shard_gb,
        "mem_shard_grad_op_gb": mem_shard_grad_op_gb,
        "mem_full_shard_gb": mem_full_shard_gb,
    }

    if results["NO_SHARD fits"]:
        recommendation = "NO_SHARD (use DDP for simplicity)"
    elif results["SHARD_GRAD_OP fits"]:
        recommendation = "SHARD_GRAD_OP (moderate savings, less communication)"
    elif results["FULL_SHARD fits"]:
        recommendation = "FULL_SHARD (maximum memory reduction)"
    else:
        recommendation = "FULL_SHARD + CPU offload (model may still not fit)"

    results["recommendation"] = recommendation
    return results
Out[17]:
Console
Scenario: 7B model, 8x A100 80GB
  NO_SHARD:      112 GB/GPU  -> fits: False
  SHARD_GRAD_OP: 38.5 GB/GPU  -> fits: True
  FULL_SHARD:    14.0 GB/GPU  -> fits: True
  Recommendation: SHARD_GRAD_OP (moderate savings, less communication)

Scenario: 13B model, 8x A100 40GB
  NO_SHARD:      208 GB/GPU  -> fits: False
  SHARD_GRAD_OP: 71.5 GB/GPU  -> fits: False
  FULL_SHARD:    26.0 GB/GPU  -> fits: True
  Recommendation: FULL_SHARD (maximum memory reduction)

Scenario: 70B model, 8x A100 80GB
  NO_SHARD:      1120 GB/GPU  -> fits: False
  SHARD_GRAD_OP: 385.0 GB/GPU  -> fits: False
  FULL_SHARD:    140.0 GB/GPU  -> fits: False
  Recommendation: FULL_SHARD + CPU offload (model may still not fit)

Scenario: 70B model, 32x A100 80GB
  NO_SHARD:      1120 GB/GPU  -> fits: False
  SHARD_GRAD_OP: 306.2 GB/GPU  -> fits: False
  FULL_SHARD:    35.0 GB/GPU  -> fits: True
  Recommendation: FULL_SHARD (maximum memory reduction)

Scenario: 175B model, 64x A100 80GB
  NO_SHARD:      2800 GB/GPU  -> fits: False
  SHARD_GRAD_OP: 732.8 GB/GPU  -> fits: False
  FULL_SHARD:    43.8 GB/GPU  -> fits: True
  Recommendation: FULL_SHARD (maximum memory reduction)

The scenarios illustrate how FULL_SHARD becomes necessary as models grow. A 70B model still needs CPU offloading or more than 8 GPUs of 80 GB each because even full sharding leaves 140 GB of model state per GPU. At 32 GPUs, FULL_SHARD reduces that footprint to 35 GB per GPU. The 175B model on 64 GPUs (175×16/64=43.75175 \times 16 / 64 = 43.75 GB per GPU for the model state) fits within 80 GB with FULL_SHARD, leaving room for activation buffers.

Visualizing Strategy Recommendations

Out[18]:
Visualization
Grouped bar chart showing memory per GPU for three FSDP strategies across five model sizes with a GPU memory limit line.
FSDP strategy feasibility across model sizes (7B to 175B parameters) with 8 GPUs of 80 GB each. Bars show model-state memory per GPU, and the red dashed line marks the 80 GB limit. FULL_SHARD fits the 7B, 13B, and 30B models; none of the strategies fits 70B or 175B without CPU offloading or additional GPUs.

Complete Training Loop

Here is a self-contained training loop that demonstrates how FSDP fits into the standard PyTorch training workflow. In a real distributed setting, this function would run on each GPU process.

In[19]:
Code
def create_synthetic_batch(batch_size=4, seq_len=128, vocab_size=10000):
    """Generate a synthetic batch of token IDs and targets."""
    input_ids = torch.randint(0, vocab_size, (batch_size, seq_len))
    targets = torch.randint(0, vocab_size, (batch_size, seq_len))
    return input_ids, targets


def compute_loss(logits, targets):
    """Cross-entropy loss for language modeling."""
    # logits: (batch, seq_len, vocab_size) -> reshape for cross-entropy
    batch_size, seq_len, vocab_size = logits.shape
    return nn.functional.cross_entropy(
        logits.view(batch_size * seq_len, vocab_size),
        targets.view(batch_size * seq_len),
    )
In[20]:
Code
def simulate_training_step(model, optimizer, num_steps=20, seed=42):
    """
    Overfit one fixed synthetic batch without distributed setup.
    In practice this runs on each GPU process with an FSDP-wrapped model.
    """
    torch.manual_seed(seed)
    model.train()
    losses = []
    input_ids, targets = create_synthetic_batch()

    for step in range(num_steps):
        optimizer.zero_grad()
        logits = model(input_ids)
        loss = compute_loss(logits, targets)
        loss.backward()
        optimizer.step()
        losses.append(loss.item())

    return losses


# Run simulation on the (non-distributed) model for demonstration
optimizer = torch.optim.Adam(model.parameters(), lr=3e-4)
training_losses = simulate_training_step(model, optimizer, num_steps=30)
Out[21]:
Console
Training completed: 30 steps
Initial loss: 9.3501
Final loss:   1.9261
Loss reduction: 79.4%

The training loop is identical in structure whether you are using a plain model, DDP, or FSDP. The only differences in a real FSDP job are the process group initialization, the FSDP() wrapping call, and the checkpoint saving procedure. This is a deliberate design choice: FSDP should be a drop-in replacement for DDP in the training loop, with complexity hidden in the wrapper.

Training Loss Visualization

Out[22]:
Visualization
Line chart showing cross-entropy loss steadily decreasing while a transformer overfits one fixed synthetic batch for 30 steps.
Cross-entropy loss while deliberately overfitting one fixed synthetic batch for 30 steps with the SimpleTransformer model. The steady decline verifies that the optimizer updates the model and gradients flow through the training loop; it demonstrates mechanics, not generalization to new data.

Checkpointing with FSDP

Checkpointing is more complex with FSDP than with standard models because parameters are sharded across GPUs. In a standard PyTorch model, model.state_dict() returns a dictionary of all parameter tensors. With FSDP, each GPU only holds shards, so a naive state_dict() call would return a dictionary of shards rather than complete tensors. PyTorch provides two checkpointing modes to handle this correctly.

Full state dict mode gathers all parameter shards to rank 0 before saving, producing a standard checkpoint file identical to what you would get from a non-sharded model. The checkpoint is universally loadable: you can load it into a non-FSDP model, into an FSDP model with a different number of GPUs, or into any framework that understands standard PyTorch checkpoints. The downside is that rank 0 must temporarily hold the entire model in memory to write the checkpoint, creating a brief memory spike.

Local state dict mode saves each GPU's local parameter shard separately. This is faster and avoids memory pressure on rank 0, but the resulting checkpoint files are only loadable with the exact same FSDP configuration (same number of GPUs, same wrapping policy). This mode is useful for frequent checkpointing during training where you care about training continuity rather than portability.

In[23]:
Code
# Full state dict checkpointing (rank 0 saves, others wait)
# In a real distributed job:
#
# from torch.distributed.fsdp import FullStateDictConfig, StateDictType
#
# cfg = FullStateDictConfig(offload_to_cpu=True, rank0_only=True)
# with FSDP.state_dict_type(fsdp_model, StateDictType.FULL_STATE_DICT, cfg):
#     state = fsdp_model.state_dict()
#     if dist.get_rank() == 0:
#         torch.save(state, "checkpoint.pt")

# For this notebook, demonstrate saving the non-distributed model
checkpoint = {
    "model_state": model.state_dict(),
    "optimizer_state": optimizer.state_dict(),
    "step": len(training_losses),
}
# torch.save(checkpoint, "model_checkpoint.pt")  # commented out to avoid file creation
Out[24]:
Console
Checkpoint contents:
  model_state: dict with 75 tensors
  optimizer_state: dict with 0 tensors
  step: 30

The offload_to_cpu=True flag in the full state dict config is important: it moves gathered tensors to CPU memory as they are assembled, preventing the GPU memory from doubling up. Without it, rank 0 would need to hold both its shard (from normal FSDP operation) and the full model (being assembled for saving) simultaneously in GPU memory.

Key Parameters

The key FSDP configuration parameters are:

  • sharding_strategy: Controls what is sharded. FULL_SHARD for maximum memory reduction, SHARD_GRAD_OP for moderate savings with less communication, NO_SHARD to disable sharding.
  • auto_wrap_policy: Determines how submodules are grouped into FSDP units. Use transformer_auto_wrap_policy for transformer architectures.
  • mixed_precision: A MixedPrecision object specifying dtypes for parameters, gradients, and buffers. BF16 is the standard choice for large model training.
  • device_id: The GPU to place the model on. Should be torch.cuda.current_device() after setting the appropriate device.
  • cpu_offload: A CPUOffload object that, when enabled, keeps parameters on CPU and only moves them to GPU when needed. Reduces GPU memory further at the cost of PCIe bandwidth.