Distributed training, section 4 of 5, 4h

Sharding a model with FSDP

By the end: You can shard a model too large for plain data parallelism, choose what to wrap, and save and load sharded checkpoints.

  • Sharding per block
  • Mixed precision settings
  • Activation checkpointing alongside
  • Checkpoints for a sharded model
  • When it is slow

New to distributed training? A GPU has a fixed amount of memory, and a large model plus everything training needs beside it does not fit in one. Distributed training spreads the work across many GPUs, often across many machines. There are two basic moves: give every GPU a complete copy of the model and a different slice of the data, then average what each of them learned; or cut the model itself into pieces so that each GPU holds only part of it. Why it matters

FSDP is the API for the sharding described in the previous section: each device owns a slice of the parameters, gradients and optimiser state, and the pieces are gathered back for the moment they are used. The mental model that makes it behave predictably is that sharding happens per wrapped unit — you choose the units, and that choice sets both the memory saving and the communication cost. This section covers choosing them, the mixed-precision settings, combining it with activation checkpointing, saving a model whose weights are spread across eight machines, and what to look at when it is slower than you expected.

These blocks are not executed here

Unlike the DDP section, whose code was run on this machine with two processes, FSDP could not be: it fails on this laptop inside a Metal backend check unrelated to the code, and multi-GPU behaviour cannot be simulated honestly on CPU anyway. Every API name, argument and signature below was checked against the installed PyTorch 2.14 rather than written from memory, and the numbers quoted are the arithmetic from the previous sections, not timings. Treat the shapes as correct and the performance claims as things to measure on your own hardware.

Sharding per block#

Wrap the repeating unit of the model — a transformer block — rather than the whole thing. Wrapping only the root gathers every parameter at once, which defeats the purpose, since peak memory then includes the entire model. Wrapping each block means only one block's parameters are gathered at a time, and the next block's gather can overlap with the current block's compute.

import torch, torch.nn as nn
from torch.distributed.fsdp import fully_shard

for block in model.transformer_blocks:   # the repeating unit, innermost first
    fully_shard(block)
fully_shard(model)                       # then the root, for anything left over

# Peak memory now holds one block's worth of gathered parameters, not the model.
Order matters: shard the blocks before the root. Doing it the other way leaves the root owning all the parameters and the per-block calls with nothing left to shard.

The one tuning knob worth knowing is reshard_after_forward. Left on, parameters are freed after the forward pass and gathered again for the backward, which is the lowest-memory setting and costs one extra all-gather. Turned off, they stay resident between the two, which is faster and uses more memory. The usual configuration is to reshard everywhere except the last block, which is gathered again almost immediately.

Mixed precision settings#

from torch.distributed.fsdp import MixedPrecisionPolicy

policy = MixedPrecisionPolicy(
    param_dtype=torch.bfloat16,     # what the forward and backward compute in
    reduce_dtype=torch.float32,     # what gradients are reduced in
)
for block in model.transformer_blocks:
    fully_shard(block, mp_policy=policy)
The two dtypes are separate on purpose. Computing in bf16 halves the memory and the communication; reducing in fp32 keeps the gradient sum from losing small contributions when it is averaged across many devices, which is where bf16's short mantissa actually hurts.

That split is the answer to a common interview question about why bf16 is safe for training when fp16 needs loss scaling. bf16 has the same exponent range as fp32, so it does not underflow the way fp16 does and needs no scaler; what it has instead is fewer mantissa bits, so the place to be careful is accumulation — which is exactly what reduce_dtype controls.

Activation checkpointing alongside#

Sharding and checkpointing solve different problems and are almost always used together: sharding handles everything that scales with parameter count, checkpointing handles activations, which scale with batch and sequence length. For the 7B-shaped model in the previous section that was 16 GiB of activations reduced to 1 GiB, for 33 per cent more compute.

from torch.distributed.algorithms._checkpoint.checkpoint_wrapper import (
    checkpoint_wrapper, CheckpointImpl,
)

for i, block in enumerate(model.transformer_blocks):
    if i % 2 == 0:                       # every block, or every second one
        model.transformer_blocks[i] = checkpoint_wrapper(
            block, checkpoint_impl=CheckpointImpl.NO_REENTRANT,
        )
# Apply checkpointing BEFORE fully_shard, so FSDP shards the wrapped module.
Use the non-reentrant implementation: the reentrant one does not compose with parameters that require grad in some passes and not others, which is exactly what sharding produces. Checkpointing every second block is the dial when full checkpointing costs more compute than you need to spend.

Checkpoints for a sharded model#

This is where people lose a training run. Calling state_dict on a sharded model gives you that rank's shard, so saving it from rank zero saves an eighth of the model, and the failure is only discovered at load time. There are two correct approaches and the right one depends on what the checkpoint is for.

# For resuming: every rank writes its own shard, in parallel. Fast, and the
# only sane option at scale -- no rank ever holds the whole model.
import torch.distributed.checkpoint as dcp
from torch.distributed.checkpoint.state_dict import get_state_dict, set_state_dict

model_sd, optim_sd = get_state_dict(model, optimizer)
dcp.save({"model": model_sd, "optim": optim_sd}, checkpoint_id="step_1000")

# Resuming: load into the same shape, then push back into the live objects.
state = {"model": model_sd, "optim": optim_sd}
dcp.load(state, checkpoint_id="step_1000")
set_state_dict(model, optimizer, model_state_dict=state["model"],
               optim_state_dict=state["optim"])
get_state_dict and set_state_dict are the pair that handle the sharded optimiser state correctly. Saving the model without the optimiser means a resumed run restarts Adam's moments from zero, which shows up as a loss spike at exactly the resume point.

For the other purpose — a single file to hand to inference — you need the full unsharded weights gathered onto one rank, which requires that rank to have enough memory for the whole model. Do that once at the end of training rather than every few hundred steps, and keep it separate from the resume checkpoints, which are the ones that must be fast.

When it is slow#

FSDP can run correctly and slowly, and the symptom is always the same — the memory fits, the loss falls, and the throughput is a fraction of what the hardware should give. The causes are a short list, in the order worth checking.

symptom / cause                          what to do
wrapped only the root                    wrap each repeating block
wrapped too finely, every Linear         wrap blocks, not layers
stage 3 on a slow interconnect           try stage 2, or accumulate gradients
reshard_after_forward on everywhere      leave the last block resident
gathering full state_dict every save     use dcp.save for resume checkpoints
CPU offload left on                      turn it off unless you truly need it
The first two are the common ones and they fail in opposite directions: one unit means no saving, and hundreds of tiny units means the communication never stops.

Measure it the way the previous section measures DDP: samples per second on one device against samples per second on all of them. Sharding that has taken throughput below what a smaller batch on plain data parallelism would have given is a configuration to abandon, not to tune further — and knowing that boundary is most of what the FSDP question in an interview is really asking.

Where people go wrong

Saving an FSDP model by gathering the full state dict onto rank 0 and running out of memory at the end of a two-day run.

If this comes up in an interview

When would you use FSDP instead of DDP?

When the model's weights, gradients and optimiser state do not fit on one GPU with plain replication. FSDP shards all three and gathers each layer's weights just in time, at the cost of extra communication.

Then they push further

Your FSDP job fits in memory but is much slower than expected across two machines. Why?

Parameter gathers and gradient reduce-scatters in every layer cross the slower network link. Shard within each machine and replicate across machines, increase the work per GPU, and check that communication overlaps with compute.

What gets marked up. Marked up for knowing what is sharded, the per-layer gather cost, and hybrid sharding as the fix across nodes.

Build this: Take a model that does not fit with DDP on your GPUs, shard it per block with mixed precision, add activation checkpointing, and record peak memory and throughput for each change. Save a sharded checkpoint and load it on a different GPU count.

This is one section of Distributed training, 22 hours across 5 sections. Read it in full to get the whole topic in order.