Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
A 70-billion-parameter (70B) training job can fit its persistent model state across 24 80 GB GPUs and still fail during backward, when one layer's parameters are gathered again. Move to 32 GPUs, avoid the out-of-memory error, then lose throughput because ranks wait on the network. Same model, two different bottlenecks: transient memory and scheduled communication.
The mixed-precision chapter left you with a dtype recipe: cheap compute, careful update storage, and a communication dtype you record separately. That recipe doesn't shrink the model. It only says how many bytes each parameter still occupies.
Full-parameter supervised fine-tuning (SFT) on one GPU fails when those bytes, plus activations, no longer fit. Fully Sharded Data Parallel (FSDP) and ZeRO (Zero Redundancy Optimizer) attack the next bottleneck: replicated model states across data-parallel ranks. Communication becomes part of the training cost.
We'll keep this 70B route as a running example: count persistent states, watch a gathered unit, choose a sharding policy, then test topology and resume behavior.
Before choosing a sharding policy, predict which memory is actually missing. One useful mixed-precision sizing convention, used in the ZeRO analysis, assumes low-precision parameters and gradients plus an FP32 master parameter copy and FP32 Adam moments.[1] Under that specific recipe, each parameter costs 16 bytes of model-state memory before activations, temporary buffers, or allocator overhead:
- 2 bytes for the BF16 (
bfloat16, short for Brain Floating Point) / FP16 (16-bit half precision) weight - 2 bytes for the BF16 / FP16 gradient
- 4 bytes for the FP32 (32-bit single precision) master copy of the weight
- 4 bytes for Adam's first moment (momentum)
- 4 bytes for Adam's second moment (variance)
For a 1B model, that's 16 GB of model states alone. Change the optimizer, parameter dtype, master-weight policy, or offload policy and the byte count changes. Always write down the recipe before using a bytes-per-parameter number.
Sharding that 16 GB evenly across eight data-parallel ranks suggests 2 GB of steady-state model state per rank, not a 2 GB peak allocation. The currently gathered layer, activations, communication buffers, and allocator overhead still need their own memory budget.
Scale this recipe to 7B parameters and it reaches 112 GB of model states. At 70B parameters, it exceeds 1 TB before activations. If you need full-parameter training at that scale, replicated Distributed Data Parallel (DDP) alone won't make it fit.
The first figure tracks only model states. Activations and transient buffers stay outside the columns because sharding model states doesn't remove those costs.

The memory wall in numbers
Before reading the totals, predict which state dominates. The answer is usually the FP32 optimizer and master-weight inventory, not the low-precision parameter copy.
For a 70B parameter model under the FP16/BF16 parameter-and-gradient plus FP32-master-and-Adam accounting convention, the model-state math looks like this:
- Weights: 140 GB
- Gradients: 140 GB
- Adam state + FP32 master weights: 840 GB
That's about 1.12 TB of model states before activations, temporary buffers, or allocator overhead. It's an assumption-bound calculation, not a promise about every optimizer implementation.
The arithmetic is small enough to turn into a quick check. Use decimal GB so the computed numbers match this table.
1GB = 10**9
2
3def state_memory_gb(params_billions: float, bytes_per_param: int) -> float:
4 return params_billions * 1_000_000_000 * bytes_per_param / GB
5
6recipe_bytes = {
7 "low_precision_parameters": 2,
8 "low_precision_gradients": 2,
9 "fp32_master_and_adam": 12,
10}
11weights = state_memory_gb(70, recipe_bytes["low_precision_parameters"])
12gradients = state_memory_gb(70, recipe_bytes["low_precision_gradients"])
13optimizer_and_master = state_memory_gb(70, recipe_bytes["fp32_master_and_adam"])
14total_model_states = weights + gradients + optimizer_and_master
15zero3_per_gpu = total_model_states / 256
16
17print("bytes_per_parameter=", sum(recipe_bytes.values()))
18print(f"weights={weights:.0f} GB")
19print(f"optimizer_and_master={optimizer_and_master:.0f} GB")
20print(f"total_model_states={total_model_states:.0f} GB")
21print(f"ZeRO-3 model states per GPU on 256 GPUs: {zero3_per_gpu:.2f} GB")1bytes_per_parameter= 16
2weights=140 GB
3optimizer_and_master=840 GB
4total_model_states=1120 GB
5ZeRO-3 model states per GPU on 256 GPUs: 4.38 GB
Why DDP hits a limit
Use the running 70B example to test the first instinct: if you add more data-parallel ranks, does each rank's model-state copy get smaller? It doesn't. Replication increases the number of copies while splitting only the input batch.
Ordinary DDP (Distributed Data Parallel) places a full copy of the model on every GPU, splits the training batch across them, and synchronizes gradients with an all-reduce (a collective that sums values across GPUs and returns the result to every GPU) after each backward pass. For a model that already fits, extra GPUs buy throughput without changing per-GPU memory.
That stops helping when the model itself is the problem. DDP replicates the full 1.12 TB of 70B model states on every rank (process/GPU). Sixteen GPUs give you sixteen copies, not a smaller copy. FSDP and ZeRO attack that redundancy by sharding model states across the data-parallel group.
ZeRO partitions more of that state at each stage.[1] ZeRO-1 shards optimizer states, ZeRO-2 also shards gradients, and only ZeRO-3 shards parameters. Under ZeRO-3, each GPU keeps a fraction of the parameters and gathers the shards it needs for compute.
- DDP: Replicate everything. Simpler communication pattern, much higher memory use.
- ZeRO: Shard model states. Lower memory use, more communication.
ZeRO stages: removing redundancy one layer at a time
Each stage answers one storage question: which model state can stop being replicated while the rank still completes its update?
ZeRO (Zero Redundancy Optimizer) is easiest to understand as an incremental evolution. Each stage removes one more redundant copy of the model states.
ZeRO stage 1: shard optimizer states
Each GPU keeps optimizer state for only of the parameters. Parameters and gradients are still replicated.
- Memory savings: Optimizer state drops from 840 GB total to roughly GB per GPU.
- Communication: Same modeled volume as DDP. Gradient sync is still an all-reduce-class step ( is the parameter-element count). Ownership changes; the byte volume doesn't.
ZeRO stage 2: shard optimizer + gradients
Stage 2 also shards gradients, so each GPU owns only the gradient shard that matches its optimizer shard.
- Memory savings: Optimizer states and gradients both scale down by roughly .
- Communication: Gradient sync is typically implemented with reduce-scatter (a collective that sums values across GPUs and returns each GPU's shard) instead of all-reduce. The modeled volume stays , matching DDP: reduce-scatter the gradients, then all-gather the updated parameter partition. Each GPU receives only its shard.
ZeRO stage 3: shard everything
Stage 3 shards parameters as well. No rank owns a full persistent copy of the model states anymore.
- Memory savings: Parameters, gradients, and optimizer states are all partitioned. For the 70B example on 256 GPUs, the model-state footprint falls to about 4.4 GB per GPU before activations.
- Communication: Parameters now have to be materialized on demand for computation.
- Forward: all-gather (collect shards from all GPUs to rebuild the full tensor) the current layer or FSDP unit, run compute, then reshard.
- Backward: all-gather that unit again, compute gradients, then reduce-scatter gradient shards.
- Trade-off: Memory scales almost linearly with the data-parallel group size, but communication becomes a first-class bottleneck.
ZeRO memory trade-off: ZeRO doesn't reduce the aggregate model-state bytes; it eliminates persistent redundant copies. A 70B model still needs roughly 1.12 TB of model-state memory in aggregate, but ZeRO-3 partitions its steady-state storage across GPUs. Temporary materialized units and communication buffers still add to each rank's peak. You pay in network bandwidth for what you save in persistent per-GPU state.
Memory per GPU (70B model, 256 GPUs)
This table assumes BF16/FP16 weights and gradients with Adam states in FP32. The totals are for model states only. Activation memory is listed separately because it depends heavily on sequence length, hidden size, micro-batch size, checkpointing, and attention implementation.
| Component | DDP | ZeRO-1 | ZeRO-2 | ZeRO-3 |
|---|---|---|---|---|
| Weights | 140 GB | 140 GB | 140 GB | 0.55 GB |
| Gradients | 140 GB | 140 GB | 0.55 GB | 0.55 GB |
| Optimizer + FP32 master weights | 840 GB | 3.3 GB | 3.3 GB | 3.3 GB |
| Activations | Extra and workload-dependent | Extra and workload-dependent | Extra and workload-dependent | Extra and workload-dependent |
| Model states / GPU | 1120 GB | 283 GB | 144 GB | 4.4 GB |
ZeRO-3 or FULL_SHARD is often paired with activation checkpointing (recomputing activations during the backward pass to save memory, also called gradient checkpointing). Sharding fixes model-state memory, but activations can still dominate the actual training footprint.
The same table should be generated from a recipe rather than copied into a sizing document:
1state_gb = {"parameters": 140, "gradients": 140, "optimizer_and_master": 840}
2world_size = 256
3
4def per_rank_gb(stage):
5 sharded = {
6 0: set(),
7 1: {"optimizer_and_master"},
8 2: {"optimizer_and_master", "gradients"},
9 3: {"optimizer_and_master", "gradients", "parameters"},
10 }[stage]
11 return sum(value / world_size if name in sharded else value for name, value in state_gb.items())
12
13for stage in [0, 1, 2, 3]:
14 print(f"ZeRO-{stage}: {per_rank_gb(stage):.2f} GB/model-state rank")1ZeRO-0: 1120.00 GB/model-state rank
2ZeRO-1: 283.28 GB/model-state rank
3ZeRO-2: 143.83 GB/model-state rank
4ZeRO-3: 4.38 GB/model-state rankCommunication overhead analysis
Once state is sharded, the memory win has a matching communication bill. Follow one parameter shard through the collectives before comparing frameworks or networks.
ZeRO-3 doesn't delete those 1.12 TB. It spreads the persistent copies and pays the interconnect every time a block needs its full weights. The network is now part of the training system, not an afterthought.
Communication primitives you need to know
| Primitive | What it does | Where it shows up |
|---|---|---|
| All-reduce | Sum across ranks, then give the full result back to every rank | Classic DDP gradient sync |
| Reduce-scatter | Sum across ranks, but return only each rank's shard | ZeRO-2/3 gradient sync |
| All-gather | Collect shards from all ranks to rebuild the full tensor | ZeRO-3 and FSDP parameter materialization |
Read each panel left to right: same ranks, different "after" state. The two-rank numbers in the snippet below are the same story with fewer GPUs: all-reduce leaves [11, 22] on both ranks, reduce-scatter leaves one summed slice per rank, and all-gather rebuilds the parameter vector from shards.
![Two-rank comparison of collectives using the article vectors: all-reduce leaves [11, 22] on both ranks, reduce-scatter leaves [11] on rank 0 and [22] on rank 1, and all-gather rebuilds [p0, p1] on both ranks.](/cdn/content-image/training/distributed-training-fsdp-deepspeed-zero/illustrations/_generated/collective_primitives_map_dark.png?v=910ec5f5a117)
1rank_gradients = [[1, 2], [10, 20]]
2summed = [sum(values) for values in zip(*rank_gradients)]
3
4all_reduce_result = [summed.copy(), summed.copy()]
5reduce_scatter_result = [[summed[0]], [summed[1]]]
6
7parameter_shards = [["p0"], ["p1"]]
8full_parameters = [token for shard in parameter_shards for token in shard]
9all_gather_result = [full_parameters.copy(), full_parameters.copy()]
10
11print("all_reduce=", all_reduce_result)
12print("reduce_scatter=", reduce_scatter_result)
13print("all_gather=", all_gather_result)1all_reduce= [[11, 22], [11, 22]]
2reduce_scatter= [[11], [22]]
3all_gather= [['p0', 'p1'], ['p0', 'p1']]The 1.5x heuristic
People often summarize ZeRO-3 communication as "about 1.5x DDP." That's the paper's bound, not a throughput guarantee.[1]
For a model with parameter elements (or parameter bytes, same ratio):
- DDP implements gradient all-reduce as reduce-scatter plus all-gather, modeled as moved per rank per step.
- ZeRO-1 and ZeRO-2 stay at : they change who stores optimizer state and gradients, not the modeled byte volume.
- ZeRO-3 / FULL_SHARD adds a second parameter all-gather so backward can run after the forward copy was freed. Two all-gathers plus one gradient reduce-scatter land at .
That is the rule. The harder part in production isn't only byte volume, but how that volume is scheduled. DDP tends to use a smaller number of large collectives. ZeRO-3 and FSDP break communication into many layer-level or unit-level operations. If those collectives are small and your network has high latency, step time can fall off quickly even when the byte ratio looks modest.
ZeRO-3 is most attractive when memory is the hard constraint. If the model already fits and the network is weak, DDP or ZeRO-2 style sharding can be faster.
1parameter_bytes = 140 # GB of low-precision parameters in the 70B example
2ddp_modeled_gb = 2 * parameter_bytes
3full_shard_modeled_gb = 3 * parameter_bytes
4
5print("DDP_modeled_traffic_GB=", ddp_modeled_gb)
6print("FULL_SHARD_modeled_traffic_GB=", full_shard_modeled_gb)
7print("byte_ratio=", full_shard_modeled_gb / ddp_modeled_gb)
8print("warning=latency_and_overlap_still_determine_step_time")1DDP_modeled_traffic_GB= 280
2FULL_SHARD_modeled_traffic_GB= 420
3byte_ratio= 1.5
4warning=latency_and_overlap_still_determine_step_timeWhy can ZeRO-3 be slower than DDP even if the back-of-the-envelope byte ratio is only about 1.5x?
Answer
Because the real cost isn't total bytes alone. ZeRO-3 and FSDP break communication into many layer-level all-gathers and reduce-scatters. On a weak or high-latency interconnect, those smaller, more frequent collectives can hurt throughput more than the simple 3P versus 2P byte model suggests.
FSDP (Fully Sharded Data Parallel)
ZeRO names the sharding strategy; FSDP is PyTorch's native way to express much of that strategy in a model and training loop. The choice now becomes an API and composition decision, not another memory formula.
FSDP is PyTorch's native sharded data-parallel stack.[2] Current docs start new designs on torch.distributed.fsdp.fully_shard (FSDP2): per-parameter DTensor shards, no FlatParameter wrapper, and a migration guide from FullyShardedDataParallel (FSDP1). The tutorial marks FSDP1 deprecated. You'll still read FSDP1 in older repos; start new work on fully_shard.[3][4]
People pick FSDP when they want PyTorch-native profilers, checkpointing, and DeviceMesh composition. torch.compile guidance is still to compile the highest-level train or eval step, or the top-level module, and to fall back to the inner module if a distributed wrapper causes issues. FSDP1 compile also needs use_orig_params=True. FSDP2 always uses original parameters, so that flag is gone.[5][6]
FSDP solves model-state memory. It doesn't solve activation memory. Long sequences and large micro-batches still push activations up, which is why FSDP is often paired with activation checkpointing.
How full sharding materializes one unit
Predict the peak for one wrapped block: a small shard sits at rest, a full unit appears for forward, and another full unit may appear for backward when the first copy was freed.
For FULL_SHARD, or for an FSDP2 configuration that reshards after forward, one transformer-sized communication unit follows this lifecycle:
- Group: Parameters are grouped at transformer-block granularity.
- Shard: Each unit's parameters are sharded across the process group.
- Execution:
- Before forward pass of a unit: All-gather full parameters.
- After forward pass: Discard full parameters (keep only shard).
- Before backward pass: All-gather full parameters again.
- After backward pass: Reduce-scatter gradients and update the sharded optimizer state.

This sequence diagram shows one wrapped unit in a two-GPU FSDP job.

FSDP2: fully_shard on DTensors
If you construct the optimizer before sharding, it sees the wrong parameter representation. Predict the safe order: shard blocks, shard the root, then create the optimizer over the resulting DTensor parameters.
For new code, apply fully_shard bottom-up: each transformer block becomes one communication group, then the root call shards leftover embeddings and heads. Build the optimizer after that, over the resulting DTensor parameters.[3][4]
reshard_after_forward is the FSDP2 name for the old sharding-strategy trade-off. True frees the full unit after forward and all-gathers again in backward (ZeRO-3 / FULL_SHARD behavior). False keeps the unsharded parameters until backward finishes (ZeRO-2-like / SHARD_GRAD_OP). The default is True for non-root modules and False for the root, because backward starts at the root and that extra all-gather would be wasted.
MixedPrecisionPolicy continues the previous chapter's dtype split. param_dtype is the unsharded compute and all-gather dtype. reduce_dtype is the gradient reduce-scatter dtype, and it can differ. Persistent sharded parameters stay in the original dtype, which is what the optimizer steps. Current docs are explicit: this setup doesn't add a second FP32 master copy on top of already-high-precision shards.[3][4]
This snippet is structure, not a local notebook cell. fully_shard needs a process group on real accelerators. The learning rate is an SFT starting point to evaluate.
1import torch
2from torch.distributed.fsdp import MixedPrecisionPolicy, fully_shard
3
4mp_policy = MixedPrecisionPolicy(
5 param_dtype=torch.bfloat16,
6 reduce_dtype=torch.float32,
7)
8
9model = TransformerModel()
10for block in model.blocks:
11 fully_shard(block, mp_policy=mp_policy, reshard_after_forward=True)
12fully_shard(model, mp_policy=mp_policy, reshard_after_forward=False)
13
14optimizer = torch.optim.AdamW(model.parameters(), lr=2e-5)Write the two recipes down before you size a cluster. The ZeRO paper's 16-byte convention stores low-precision parameters and gradients plus an FP32 master. FSDP2 mixed precision keeps the high-precision shard as the optimizer parameter, so you don't add that extra master on top.
1params_billions = 70
2
3def state_gb(bytes_per_param: int) -> int:
4 return params_billions * bytes_per_param
5
6zero_paper = {
7 "stored_param": 2,
8 "stored_grad": 2,
9 "fp32_master": 4,
10 "adam_moments": 8,
11}
12fsdp2_persistent = {
13 "sharded_param_original_dtype": 4,
14 "adam_moments": 8,
15}
16
17print("zero_paper_bytes_per_param=", sum(zero_paper.values()))
18print("zero_paper_states_GB=", state_gb(sum(zero_paper.values())))
19print("fsdp2_persistent_param_and_adam_GB=", state_gb(sum(fsdp2_persistent.values())))
20print("fsdp2_extra_master_bytes=", 0)
21print("unsharded_bf16_copy=transient_around_compute")1zero_paper_bytes_per_param= 16
2zero_paper_states_GB= 1120
3fsdp2_persistent_param_and_adam_GB= 840
4fsdp2_extra_master_bytes= 0
5unsharded_bf16_copy=transient_around_computeThat 840 GB figure still isn't "fits on one GPU." It assumes the module was built in FP32, so the persistent shard is four bytes per parameter before gradients. The number collides with the earlier Adam+master column for a different reason: 4-byte parameters plus 8-byte moments, with no extra master on top. Sharded gradients still exist after backward; this comparison only isolates the extra-master question. Don't paste the ZeRO 16-byte recipe onto an FSDP2 MixedPrecisionPolicy run without checking which tensors are persistent.
| Feature | FSDP1 (FullyShardedDataParallel) | FSDP2 (fully_shard) |
|---|---|---|
| How it applies sharding | Wrapper module with flat parameters | In-place hooks on original modules |
| Sharding representation | FlatParameter groups | Per-parameter DTensor, usually Shard(0) |
| Parameter names | Flattened internals to reason about | Original FQNs stay intact |
| Composition with TP / frozen params | More awkward | Better fit with DeviceMesh and frozen params |
| Prefetch / rate limits | backward_prefetch, limit_all_gathers | Implicit prefetch by default; set_modules_to_forward_prefetch / set_modules_to_backward_prefetch when you need explicit control. limit_all_gathers is gone because FSDP2 dropped that CPU sync. |
| CPU offload | CPUOffload(offload_params=...) | CPUOffloadPolicy(): parameters, gradients, and optimizer states on host; optimizer step runs on CPU |
| Checkpointing style | Full and sharded state dict APIs | Sharded state dicts first; DCP can reshard to full when needed |
| Gradient clipping | fsdp_model.clip_grad_norm_() for sharded strategies | torch.nn.utils.clip_grad_norm_(model.parameters(), ...) works on DTensors |
FSDP2 also handles mixed frozen and trainable parameters in one communication group without the extra memory FSDP1 often spent on that case. That's the hinge into LoRA: most weights can stay frozen on purpose, and the sharded runtime shouldn't pretend they're all updating.
FSDP1 you'll still meet
Older code wraps FullyShardedDataParallel. The same three rules still matter:
- Wrap transformer blocks, not the whole model as one unit.
- Create the optimizer after wrapping.
- Don't treat
limit_all_gathers=Trueas an overlap knob.backward_prefetchis the overlap control;limit_all_gatherscaps in-flight all-gathers to control peak memory.[6]
This is distributed setup code, not a single-process notebook cell. It assumes torch.distributed.init_process_group() has already run under torchrun and that each rank has selected its local CUDA device.
1import torch
2import torch.nn as nn
3import functools
4from torch.distributed.fsdp import (
5 BackwardPrefetch,
6 FullyShardedDataParallel as FSDP,
7 MixedPrecision,
8 ShardingStrategy,
9)
10from torch.distributed.fsdp.wrap import transformer_auto_wrap_policy
11
12class TransformerDecoderLayer(nn.Module):
13 def __init__(self, d_model=1024, n_heads=16):
14 super().__init__()
15 self.self_attn = nn.MultiheadAttention(d_model, n_heads, batch_first=True)
16 self.ffn = nn.Sequential(
17 nn.Linear(d_model, 4 * d_model),
18 nn.GELU(),
19 nn.Linear(4 * d_model, d_model),
20 )
21 self.norm1 = nn.LayerNorm(d_model)
22 self.norm2 = nn.LayerNorm(d_model)
23
24 def forward(self, x, mask=None):
25 attn_out, _ = self.self_attn(x, x, x, attn_mask=mask)
26 x = self.norm1(x + attn_out)
27 ffn_out = self.ffn(x)
28 x = self.norm2(x + ffn_out)
29 return x
30
31my_auto_wrap_policy = functools.partial(
32 transformer_auto_wrap_policy,
33 transformer_layer_cls={TransformerDecoderLayer},
34)
35
36mixed_precision_policy = MixedPrecision(
37 param_dtype=torch.bfloat16,
38 reduce_dtype=torch.bfloat16,
39 buffer_dtype=torch.bfloat16,
40)
41
42model = nn.Sequential(*[
43 TransformerDecoderLayer(d_model=1024, n_heads=16)
44 for _ in range(8)
45])
46
47fsdp_model = FSDP(
48 model,
49 auto_wrap_policy=my_auto_wrap_policy,
50 sharding_strategy=ShardingStrategy.FULL_SHARD,
51 mixed_precision=mixed_precision_policy,
52 backward_prefetch=BackwardPrefetch.BACKWARD_PRE,
53 limit_all_gathers=True,
54 device_id=torch.cuda.current_device(),
55)
56
57optimizer = torch.optim.AdamW(fsdp_model.parameters(), lr=2e-5)The most important FSDP choice is usually sharding granularity. Use transformer blocks as communication units. If one root unit owns the entire model, every all-gather becomes too large and peak memory shoots back up.
This simplified calculation isolates why granularity matters. It models only the transient full-parameter materialization, not activations or prefetch overlap:
1block_parameter_gb = [3.5, 3.5, 3.5, 3.5]
2
3root_only_peak_full_materialization = sum(block_parameter_gb)
4blockwise_peak_full_materialization = max(block_parameter_gb)
5
6print("root_only_active_parameters_GB=", root_only_peak_full_materialization)
7print("blockwise_active_parameters_GB=", blockwise_peak_full_materialization)
8print("modeled_reduction=", root_only_peak_full_materialization / blockwise_peak_full_materialization)1root_only_active_parameters_GB= 14.0
2blockwise_active_parameters_GB= 3.5
3modeled_reduction= 4.0Sharding strategy names
| Behavior | FSDP1 | FSDP2 | What it does |
|---|---|---|---|
| Full shard | FULL_SHARD | reshard_after_forward=True | Shards parameters, gradients, and optimizer states. Frees the full unit after forward. |
| Keep params through backward | SHARD_GRAD_OP | reshard_after_forward=False | Unshards for forward, keeps them until backward finishes, then reshards. Higher peak, fewer all-gathers. |
| Hybrid shard | HYBRID_SHARD | 2D DeviceMesh plus reshard_after_forward=True | ZeRO-3 within the shard dimension, replication across the other. Fast intra-node links, tighter inter-node bandwidth. |
| Replicate | NO_SHARD | Don't shard; use DDP | Full model states on every rank. No sharded parameter materialization. |
Checkpointing, resume, and fault recovery
An out-of-memory fix that can't resume faithfully is still an incomplete training system. Treat the checkpoint as the state needed to take the next optimizer step, not as a copy of weights for inference.
Large training runs fail in the middle all the time. Nodes reboot, preemptible instances disappear, and jobs get rescheduled. If your checkpoint only contains model weights, you didn't save the training run. You saved an export artifact.[7]
For a real resume point, the checkpoint usually needs at least:
| Must-save state | Why it matters on resume |
|---|---|
| Model weights | Restores current parameters |
| Optimizer state | Preserves momentum and adaptive moments |
| Scheduler state | Keeps warmup and decay aligned with training step |
| Gradient-scaler / AMP state, if used | Preserves mixed-precision control state across resume |
| Consumed step or token count | Lets logs, schedulers, and stop rules stay honest |
| Sampler / dataloader cursor | Prevents replaying or skipping large data regions |
| RNG state | Keeps dropout, shuffling, and sampling reproducible when needed |
In a sharded system, the safest default is to save sharded checkpoints, not to gather everything onto rank 0 first. FSDP2 and distributed checkpoint APIs are pushing in that direction because the "collect all weights on one machine and write a giant file" pattern eventually becomes the bottleneck or the failure point.[3][7]
DeepSpeed goes one step further with Universal Checkpointing, which is meant to make checkpoint artifacts more portable across different parallelism layouts instead of tying restore logic to the exact original topology.[8] That matters once you start changing world size between runs or resume on a different cluster shape.
The operational rule is simple:
- Save checkpoints by training step and consumed tokens, not vague names like
latest-final. - Test one real resume path early in the run, before trusting a week-long job to it.
- Verify that resumed loss on the next batch is close to the non-resumed run.
If you can't answer "what exact state do we restore, and how do we know resume is faithful?", the training system isn't production-ready yet.
A distributed checkpoint manifest also has to record the sharding topology it was written under, even when a portable conversion path exists:
1required = {
2 "model_shards",
3 "optimizer_shards",
4 "scheduler_state",
5 "consumed_tokens",
6 "sampler_cursor",
7 "rng_state",
8 "gradient_scaler_state",
9 "template_tokenizer_version",
10 "data_manifest",
11 "evaluation_manifest",
12 "best_metric",
13 "world_size",
14 "sharding_strategy",
15}
16
17manifest = {
18 "model_shards": "ckpt/step_800/model/",
19 "optimizer_shards": "ckpt/step_800/optimizer/",
20 "scheduler_state": "ckpt/step_800/scheduler.pt",
21 "consumed_tokens": 104_857_600,
22 "sampler_cursor": {"epoch": 0, "batch": 800},
23 "rng_state": "ckpt/step_800/rng.pt",
24 "gradient_scaler_state": None, # BF16 run; store scaler state for FP16 when used
25 "template_tokenizer_version": "trace-evidence-v2",
26 "data_manifest": "manifests/sft-train-v7.json",
27 "evaluation_manifest": "manifests/supported-evidence-v4.json",
28 "best_metric": {"name": "supported_evidence_f1", "value": 0.93},
29 "world_size": 32,
30 "sharding_strategy": "FULL_SHARD",
31}
32
33missing = sorted(required - manifest.keys())
34print("resume_manifest_complete=", not missing)
35print("saved_topology=", manifest["sharding_strategy"], manifest["world_size"])
36print("gradient_scaler_state=", manifest["gradient_scaler_state"])
37print("best_metric=", manifest["best_metric"])1resume_manifest_complete= True
2saved_topology= FULL_SHARD 32
3gradient_scaler_state= None
4best_metric= {'name': 'supported_evidence_f1', 'value': 0.93}DeepSpeed ZeRO
If native sharding solves persistent GPU memory but the run still needs host or NVMe capacity, the unresolved question is where those states should live and what transfer cost the step can tolerate.
DeepSpeed is Microsoft's distributed training runtime that introduced ZeRO and provides heterogeneous-memory offload through ZeRO-Offload and ZeRO-Infinity.[1][9] It has a steeper learning curve than native PyTorch, but it remains a strong choice when memory pressure is the real blocker or when the rest of your stack already depends on Megatron-DeepSpeed style training.
One important DeepSpeed-specific detail is that ZeRO-3 depends on consistent module execution order across ranks. Dynamic routing patterns such as MoE can deadlock parameter all-gathers if different ranks enter different submodules, which is why DeepSpeed exposes the idea of ZeRO-3 leaf modules.[10]
This distributed setup snippet configures and initializes a model with DeepSpeed. Run it through the DeepSpeed launcher in a real training job. The configuration dictionary defines the ZeRO stage, offloading settings, optimizer, and training batch parameters. The learning rate is an example SFT starting point to evaluate, and the bucket sizes are tuning knobs rather than canonical values:
1import deepspeed
2import torch.nn as nn
3
4ds_config = {
5 "train_micro_batch_size_per_gpu": 1,
6 "gradient_accumulation_steps": 8,
7 "bf16": {
8 "enabled": True,
9 },
10 "optimizer": {
11 "type": "DeepSpeedCPUAdam",
12 "params": {
13 "lr": 2e-5,
14 "betas": [0.9, 0.999],
15 "eps": 1e-8,
16 "weight_decay": 0.01,
17 },
18 },
19 "zero_optimization": {
20 "stage": 3,
21 "offload_optimizer": {
22 "device": "cpu",
23 "pin_memory": True,
24 },
25 "offload_param": {
26 "device": "cpu",
27 "pin_memory": True,
28 },
29 "overlap_comm": True,
30 "contiguous_gradients": True,
31 "reduce_bucket_size": 500_000_000,
32 "stage3_prefetch_bucket_size": 50_000_000,
33 "stage3_param_persistence_threshold": 100_000,
34 }
35}
36
37class SimpleTransformer(nn.Module):
38 def __init__(self, d_model=1024, n_layers=4):
39 super().__init__()
40 self.layers = nn.ModuleList([
41 nn.TransformerEncoderLayer(d_model, nhead=16, batch_first=True)
42 for _ in range(n_layers)
43 ])
44 self.proj = nn.Linear(d_model, 1000)
45
46 def forward(self, x):
47 for layer in self.layers:
48 x = layer(x)
49 return self.proj(x)
50
51model = SimpleTransformer(d_model=1024, n_layers=4)
52
53model_engine, optimizer, _, _ = deepspeed.initialize(
54 model=model,
55 config=ds_config,
56 model_parameters=model.parameters(),
57)
58
59# for batch in dataloader:
60# outputs = model_engine(batch)
61# loss = compute_loss(outputs)
62# model_engine.backward(loss)
63# model_engine.step()ZeRO-Infinity: CPU and NVMe offloading
DeepSpeed extends ZeRO-3 with ZeRO-Infinity, which allows offloading sharded parameters and optimizer states to CPU RAM or even NVMe SSDs.[9]
| Tier | Memory Pool | Use Case |
|---|---|---|
| GPU | GPU HBM | Active compute, activations, temporary buffers |
| CPU | System RAM | Optimizer states or parameter shards when GPU memory is tight |
| NVMe | Local SSD / NVMe | Deep spillover tier when RAM still isn't enough |
This makes some otherwise impossible training runs feasible, but it doesn't make offload free. Once parameters or optimizer state spill into CPU or NVMe, throughput becomes constrained by PCIe, host memory bandwidth, storage latency, and how well the runtime can overlap data movement with compute.
DeepSpeed's feature matrix is broader, but it isn't "all combinations are valid." Current docs say AutoTP training supports ZeRO stages 0 through 3, while PipelineModule isn't compatible with ZeRO-2 or ZeRO-3.[10][11]
Turn compatibility constraints into a config gate, not a surprise after scheduling a large job:
1requested_runs = [
2 {"feature": "AutoTP", "zero_stage": 2},
3 {"feature": "AutoTP", "zero_stage": 3},
4 {"feature": "PipelineModule", "zero_stage": 2},
5]
6
7def supported(run):
8 if run["feature"] == "AutoTP":
9 return run["zero_stage"] in {0, 1, 2, 3}
10 if run["feature"] == "PipelineModule":
11 return run["zero_stage"] in {0, 1}
12 return False
13
14for run in requested_runs:
15 print(run["feature"], f"ZeRO-{run['zero_stage']}", "allowed=", supported(run))1AutoTP ZeRO-2 allowed= True
2AutoTP ZeRO-3 allowed= True
3PipelineModule ZeRO-2 allowed= FalseFSDP vs DeepSpeed comparison
Both frameworks solve the same core problem, but they optimize for different operational constraints. FSDP optimizes for staying close to standard PyTorch. DeepSpeed optimizes for a wider set of large-scale runtime features.
| Feature | FSDP | DeepSpeed ZeRO |
|---|---|---|
| Runtime model | Native PyTorch distributed stack | Separate DeepSpeed runtime |
| ZeRO stages | ZeRO-2-like and ZeRO-3-like sharding modes | Native ZeRO-1/2/3 |
| CPU offloading | FSDP2 CPUOffloadPolicy() moves parameters, gradients, and optimizer states to host memory and runs the optimizer step on CPU. FSDP1's CPUOffload(offload_params=True) stages parameters on CPU. Neither path is NVMe. | CPU and NVMe via ZeRO-Offload / ZeRO-Infinity |
| NVMe offloading | No | Yes |
| Compile story | Compile the train step or top-level module first; fall back to the inner module if the wrapper causes issues. FSDP1 needs use_orig_params=True | Depends on integration path |
| Pipeline parallel | External | Built-in, but PipelineModule excludes ZeRO-2/3 |
| Tensor parallel | FSDP2 composes with TP on a DeviceMesh; not a Megatron replacement | AutoTP or Megatron integration (AutoTP supports ZeRO-0/1/2/3) |
| Checkpointing / tooling | Standard PyTorch APIs | DeepSpeed-specific runtime APIs |
| Debugging | Standard PyTorch stack traces and profiler | More runtime-specific behavior |
Which stack to start with
- Use FSDP when you want the PyTorch-native path,
DeviceMeshcomposition, and standard profilers and checkpoints. GPU memory after sharding, or FSDP2's CPU offload, is enough. - Use DeepSpeed when NVMe spillover is non-negotiable, you need ZeRO-Infinity's host/storage hierarchy, or you're already in a DeepSpeed or Megatron-DeepSpeed stack whose TP/PP requirements fit the current stage-compatibility rules.
You already know the model-state math fits once sharded. What question decides whether you stay with FSDP or reach for DeepSpeed?
Answer
Ask what requirement remains unsolved after sharding. If you mainly want native PyTorch tooling and standard checkpointing, stay with FSDP. If you still need NVMe offload, ZeRO-Infinity, or the rest of the stack already depends on DeepSpeed-specific runtime features, DeepSpeed becomes the better operational fit.
Beyond data parallelism: 3D parallelism
When sharding alone leaves either one layer or one sequence too large, name the dimension that must be split before adding another parallel axis. That prediction keeps a topology from becoming a list of acronyms.
For very large jobs, data-parallel sharding alone isn't enough. As you push the data-parallel group wider, global batch size, optimizer dynamics, and cross-node communication all start fighting you. The standard answer is to combine multiple forms of parallelism in the same training topology.[12]
That's what people mean by 3D parallelism: data parallelism for replica groups, tensor parallelism (splitting weight matrices within a layer across GPUs) inside layers, and pipeline parallelism (splitting sequential layer groups across different devices) across layer ranges.
Solid arrows are activations moving through pipeline stages. Dotted arrows are the matching data-parallel replicas syncing. The figure is a topology, not a recommendation to turn every axis on.

Tensor parallelism (TP)
Tensor parallelism splits the work inside a layer. Instead of putting a full weight matrix on one GPU, it partitions the matrix across GPUs.
- How it works: In a transformer MLP, the first projection is often column-sharded and the second projection row-sharded. Each rank computes a partial result, then the ranks synchronize those partials.
- Bandwidth requirement: This synchronization happens inside the layer, so TP strongly prefers very fast intra-node links such as NVLink or NVSwitch (high-bandwidth GPU interconnects).
Pipeline parallelism (PP)
Pipeline parallelism splits the model across depth by assigning different layer ranges to different devices or nodes.
- How it works: Stage 0 might own layers 1-12, stage 1 owns 13-24, and activations flow from one stage to the next.
- Main trade-off: You reduce memory pressure per rank and avoid TP-style per-layer collectives across the whole model, but you introduce pipeline bubbles and need micro-batching to keep stages busy.
| Parallelism | Primary shard | Communication pattern | Best for |
|---|---|---|---|
| Data / ZeRO / FSDP | Batch across replicas, with model states optionally sharded | All-reduce, reduce-scatter, all-gather | General scaling and memory reduction |
| Tensor (TP) | Weight matrices within layers | Collectives inside each layer | Very wide layers on fast intra-node links |
| Pipeline (PP) | Sequential layer groups | Point-to-point activations and gradients | Very deep models and multi-node partitioning |
The escalation path should be deliberate.[13]
- Start with single-node or data-parallel sharding when the model mostly fits and you want the simplest debug story.
- Add tensor parallelism when single layers are too wide for one device or when matmul shards need to stay on NVLink-class links.
- Add pipeline parallelism when layer depth itself must be partitioned across devices.
A common placement keeps TP on fast intra-node links, uses PP to partition depth across broader topology boundaries, and applies data parallelism across replica groups. The right degrees still depend on available links, memory, model shape, and global-batch constraints.
In current PyTorch, a DeviceMesh is the abstraction used to express multiple device dimensions, which lets FSDP2 compose with tensor parallelism on the same hardware (often called FSDP + TP, or 2D parallelism). Megatron Core also documents context parallelism, which splits the sequence dimension for long-context workloads. Combining data, tensor, pipeline, and context parallelism creates a four-axis topology; runtimes don't always use the same "3D" or "4D" label for that composition.[3][13]
Each topology degree multiplies into the required world size. Calculate it before reserving hardware:
1topology = {
2 "data_parallel": 8,
3 "tensor_parallel": 4,
4 "pipeline_parallel": 2,
5 "context_parallel": 2,
6}
7
8world_size = 1
9for degree in topology.values():
10 world_size *= degree
11
12print("world_size=", world_size)
13print("ranks_per_data_replica=", topology["tensor_parallel"] * topology["pipeline_parallel"] * topology["context_parallel"])
14assert world_size == 1281world_size= 128
2ranks_per_data_replica= 16Debugging & profiling distributed training
Distributed training turns a local symptom into a coordination question. Before changing a config, decide whether the first failure is a rank mismatch, a memory peak, a data stall, or a communication schedule.
Distributed training fails in ways that single-GPU training doesn't. Jobs hang instead of crash. One bad rank can stall everybody else. Performance regressions often come from communication scheduling, not math kernels.
Common failure modes
- NCCL timeouts or hangs: Often one rank died earlier or entered a different collective pattern.
TORCH_NCCL_BLOCKING_WAIT=1can turn a silent hang into a visible failure. - Startup OOM: Large models can OOM before training starts if you materialize full parameters too early. Lazy or meta-device initialization matters.
- Wrong gradient clipping: With an FSDP1 sharded strategy, use
fsdp_model.clip_grad_norm_()because it computes across sharded gradients. FSDP2's DTensor-based parameters instead supporttorch.nn.utils.clip_grad_norm_(model.parameters(), ...)in the current tutorial.[6][4]
Profiling tools
Throughput (tokens/sec) only moves after you know whether the step is compute-bound or communication-bound. A credible comparison records accelerator model and count, topology, model and sequence shape, micro-batch and accumulation, dtype, sharding and prefetch settings, warmup and steady-state window, exact baseline, and correctness or convergence checks.
Report the boundary you changed with the result. "FSDP is faster" is not reproducible evidence until another engineer can reconstruct the workload and verify that both runs reached the same loss or task quality.
torch.profiler: Look atncclAllGather,ncclReduceScatter, and gaps where GPUs wait for communication. For FSDP1, tune wrapping granularity andbackward_prefetch; uselimit_all_gathers=Truefor peak-memory rate limiting, not as the overlap knob. For FSDP2, use its module prefetch APIs when implicit scheduling is insufficient.[6][3]- DeepSpeed Flops Profiler or DeepSpeed runtime logs: Use them to break step time into compute vs communication and to see whether offload is the bottleneck.
For hard distributed bugs, capture one clean repro run with NCCL_DEBUG=INFO and TORCH_DISTRIBUTED_DEBUG=DETAIL instead of trying to reason from high-level symptoms alone.
Training observability signals that matter
Once the job runs, you still need to know why it's slow or unstable. Good training dashboards separate data, compute, communication, and memory instead of showing only one loss curve.[13]
| Signal | What it answers | Bad symptom |
|---|---|---|
| tokens/sec | Is end-to-end throughput improving? | flat or falling throughput after adding GPUs |
| step time split | Is time spent in data load, forward, backward, optimizer, or checkpoint save? | one phase dominates without explanation |
| all-gather / reduce-scatter share | Is sharding traffic now bottlenecking the job? | communication time grows faster than compute time |
| dataloader wait time | Are GPUs starved by CPU preprocessing or storage? | GPU idle gaps before forward |
| peak HBM and reserved memory | Are you close to OOM or fragmenting memory? | retries, allocator spikes, sudden OOM at checkpoint save |
| MFU or FLOP utilization | Are expensive kernels doing useful math? | very low utilization despite full memory use |
| checkpoint save duration | Is fault tolerance becoming a throughput tax? | long pauses every save interval |
If you only watch training loss, a run can look healthy while throughput quietly collapses. Distributed training needs systems telemetry, not ML telemetry alone.
Training-system bottleneck map
Strong distributed-training debugging connects the abstraction to the bottleneck. A useful answer names the split, the communication primitive, and the symptom you would measure.
| Topic | What to explain | Debug signal |
|---|---|---|
| Data parallelism | each rank owns a batch shard and synchronizes gradients | all-reduce time rises with parameter size |
| Tensor parallelism | one layer is split across ranks | frequent collectives inside attention and MLP blocks |
| Pipeline parallelism | different ranks own different layer ranges | idle bubbles when micro-batching is too small |
| ZeRO / FSDP | optimizer states, gradients, and parameters are sharded | all-gather and reduce-scatter dominate step time |
| Activation checkpointing | save memory by recomputing activations in backward | lower peak memory, higher compute time |
| NCCL bottlenecks | collectives depend on rank order, topology, and matching calls | hangs, timeouts, or wide gaps in profiler traces |
| FlashAttention | reduce attention memory traffic with tiled kernels | better long-context throughput when attention is memory-bound |
A practical debugging loop is:
- Reproduce on the smallest rank count that still fails.
- Confirm every rank enters the same collective sequence.
- Capture profiler traces and separate compute, communication, and idle time.
- Check peak memory before and after forward, backward, and optimizer step.
- Change one axis at a time: sharding granularity, micro-batch size, checkpointing, or placement.
This is the bridge between ML and distributed systems. Weak answers stop at "use FSDP" or "use DeepSpeed." Stronger answers say "model states don't fit, so I shard them; then I measure whether communication or activation memory became the new bottleneck."
A worked sizing exercise
The earlier table explains why sharding helps. Now use the same recipe to answer a hardware question, then separate a mathematical lower bound from a runnable plan.
A common sizing prompt is: "How many 80 GB GPUs do you need to train a 70B model with ZeRO-3?" Walk through it step by step.
Step 1: count the model states
With mixed-precision Adam, each parameter carries:
- 2 bytes (BF16 weight)
- 2 bytes (BF16 gradient)
- 12 bytes (FP32 master weight + Adam moments)
Total: 16 bytes per parameter.
For 70B parameters: of model states.
Step 2: compute the model-state lower bound
If you looked only at model states, the absolute lower bound on 80 GB GPUs would be:
So 14 GPUs is the math-floor for model states alone.
The same calculation is worth making executable:
1import math
2
3total_state_gb = 70 * 16
4gpu_memory_gb = 80
5
6raw_floor = math.ceil(total_state_gb / gpu_memory_gb)
7print("raw_lower_bound_gpus=", raw_floor)
8
9for gpus in [14, 16, 24]:
10 per_gpu = total_state_gb / gpus
11 headroom = gpu_memory_gb - per_gpu
12 print(f"{gpus:>2} GPUs -> {per_gpu:5.1f} GB states/GPU, {headroom:5.1f} GB before activations")1raw_lower_bound_gpus= 14
214 GPUs -> 80.0 GB states/GPU, 0.0 GB before activations
316 GPUs -> 70.0 GB states/GPU, 10.0 GB before activations
424 GPUs -> 46.7 GB states/GPU, 33.3 GB before activationsStep 3: add overhead
The raw model-state math says a 70B-style run needs about 14 GPUs with ZeRO-3, but you only have sixteen 80 GB cards. Why is that still a shaky plan?
Answer
Because 14 GPUs is only the lower bound for model states. On 16 GPUs, model states still land around 70 GB per card, leaving little room for activations, communication buffers, and allocator overhead. ZeRO-3 shrinks memory by sharding optimizer states, gradients, and parameters, but it doesn't remove the need for headroom.
The math above is for model states only. Real training also needs:
- Activations (depends on sequence length and batch size)
- Communication buffers for all-gather and reduce-scatter
- CUDA allocator overhead and fragmentation
There isn't one honest fixed headroom percentage: a context-length or micro-batch change can dominate it. Measure or estimate the remaining peak memory for the specific workload, then compare candidate topologies:
1state_total_gb = 1120
2gpu_capacity_gb = 80
3measured_non_state_peak_gb = 18 # activations + buffers + allocator margin for this trial workload
4
5def fits(world_size):
6 state_per_gpu = state_total_gb / world_size
7 peak = state_per_gpu + measured_non_state_peak_gb
8 return state_per_gpu, peak, peak <= gpu_capacity_gb
9
10for world_size in [16, 24, 32]:
11 state, peak, ok = fits(world_size)
12 print(f"{world_size} GPUs -> states={state:.1f} GB peak_estimate={peak:.1f} GB fits={ok}")116 GPUs -> states=70.0 GB peak_estimate=88.0 GB fits=False
224 GPUs -> states=46.7 GB peak_estimate=64.7 GB fits=True
332 GPUs -> states=35.0 GB peak_estimate=53.0 GB fits=TrueStep 4: answer the question
The clean answer is: 14 GPUs is only the raw ZeRO-3 lower bound for model states under this 16-byte recipe. It isn't a runnable cluster recommendation. A practical count requires a measured or modeled activation/buffer peak and an acceptable throughput result. In the example above, an 18 GB non-state peak rules out 16 GPUs but fits at 24; a longer sequence could invalidate that answer again. Activation checkpointing, a smaller micro-batch, tensor parallelism, or pipeline parallelism may change the result.
Common pitfalls
Each failure below has a recovery path: identify what was split, observe the first failing boundary, then change one memory or topology axis and rerun the smallest faithful case.
Misidentifying which axis is split
-
Symptom: A team says they "added ZeRO-3 for tensor parallelism," but profiler traces still show whole-layer matmuls on each rank and the real change is only lower model-state memory.
-
Cause: ZeRO and FSDP shard model states across data-parallel ranks. They don't split the matrix multiply itself. Tensor parallelism splits layer computation, and pipeline parallelism splits model depth.
-
Fix: Check what moved in the trace or memory profile before naming the technique. If weights, gradients, and optimizer states shrank per rank, that's sharding. If one layer's matmul is split across devices, that's tensor parallelism.
Wrapping the whole model as one FSDP unit
-
Symptom: You enable ZeRO-3, but peak memory is still unexpectedly high and you get OOM during the forward pass.
-
Cause: You wrapped the entire model as a single FSDP unit instead of wrapping individual transformer layers. When the whole model is one unit, FSDP all-gathers every parameter at once. That defeats the purpose of layer-by-layer sharding.
-
Fix: Apply
fully_shardto each transformer block, then the root. In FSDP1 code, useauto_wrap_policythe same way. The code examples earlier show both.
Thinking FSDP1 limit_all_gathers=True creates overlap
-
Symptom: You set
limit_all_gathers=Trueand expect faster training, but throughput doesn't improve. -
Cause:
limit_all_gathersis a CPU-side rate limiter. It caps how many all-gathers are in flight at once to control peak memory. It isn't an overlap or performance knob. -
Fix: In FSDP1, use
backward_prefetchand communication-unit granularity for overlap. In FSDP2, use its prefetch methods when needed; it doesn't expose FSDP1'slimit_all_gathersargument.
Treating SHARD_GRAD_OP as identical to ZeRO-2
-
Symptom: You switch from ZeRO-2-style reasoning to FSDP
SHARD_GRAD_OP, then wonder why peak memory is higher than expected even though gradients are sharded. -
Cause: The names are similar, but the parameter lifetime differs. PyTorch's
SHARD_GRAD_OP, and FSDP2'sreshard_after_forward=False, keep parameters unsharded through forward and backward, then reshard after backward.[6][4] That changes peak memory even when the high-level mental model sounds like ZeRO-2. -
Fix: Confirm the actual parameter lifetime in docs and profiler traces before sizing memory. If you need the lowest parameter footprint, compare against
FULL_SHARDorreshard_after_forward=Truerather than assuming the ZeRO-2-like name matches DeepSpeed stage 2.
Ignoring activation memory
-
Symptom: The model-state math says everything should fit, but you still OOM.
-
Cause: Activations can be larger than model states for long sequences. A single forward pass with a long context can generate gigabytes of intermediate tensors.
-
Fix: Pair ZeRO-3 or FSDP with activation checkpointing. Trade compute for memory by recomputing activations during the backward pass instead of storing them.
Assuming every DeepSpeed feature composes with every ZeRO stage
-
Symptom: You configure
PipelineModulewith ZeRO-3 and the job fails during initialization. -
Cause: DeepSpeed has documented compatibility limits. AutoTP supports ZeRO stages 0 through 3, but
PipelineModuleisn't compatible with ZeRO-2 or ZeRO-3. -
Fix: Check the stage-compatibility matrix before you design your training topology. Don't assume that because DeepSpeed supports both features, they work together.