Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
A model can fit its weights on one GPU and still run out of memory at the first optimizer update. A 1.5-billion-parameter model needs 3 GB for two-byte weights alone. In the ZeRO paper's mixed-precision Adam accounting, training state takes 24 GB before activations or temporary buffers. Where did the other 21 GB come from?[1]
The Megatron lesson split computation across parallel axes. DeepSpeed also asks who must retain each tensor between computations. Its Zero Redundancy Optimizer (ZeRO) partitions optimizer state, gradients, and eventually parameters across data-parallel ranks. Each rank still processes its own examples; sharding state doesn't mean sharding the examples' computation as tensor parallelism would.
We'll follow the memory accounting into the engine, a gathered module, and a checkpoint. The source tour uses commit c455031422641a825588c926455458c82b27b6c0 (August 6, 2026, before the 0.19.4 release); the current documentation consulted identifies itself as 0.19.6. These are distinct version references, not evidence that every feature combination works on both.[2][3]
The marked examples run on a CPU with Python's standard library. They check ownership arithmetic and an Adam update, not DeepSpeed kernels, distributed execution, or GPU memory measurements.
Count state before choosing a stage
Let be the parameter count represented by a data-parallel group. If tensor or pipeline parallelism already splits the model, use the relevant local model partition, not the entire model's parameter count.
The paper's 16-byte recipe is:
| Tensor | Bytes per parameter | Role |
|---|---|---|
| Low-precision parameter | 2 | Forward and backward computation |
| Low-precision gradient | 2 | Gradient awaiting the update |
| FP32 master parameter | 4 | Higher-precision update state |
| FP32 first moment | 4 | Adam's running gradient average |
| FP32 second moment | 4 | Running average of squared gradients |
| Total | 16 | Model state only |
The second moment isn't an estimate of centered variance. Also, BF16 compute doesn't by itself determine the gradient-accumulation dtype or whether a master copy exists. For example, replacing the two-byte gradient with a four-byte accumulation array changes this simplified total to 18 bytes. Inspect the actual optimizer and buffer dtypes. DeepSpeed exposes data_types.grad_accum_dtype separately.[4]
Let , , and denote bytes per parameter for compute weights, gradients, and optimizer-plus-master state. For data-parallel ranks, idealized per-rank storage is:
With and , those are , , , and bytes. The formulas describe balanced persistent state, not peak allocator usage. Padding, persistent small parameters, gathered tensors, activations, communication buffers, workspaces, and fragmentation can add memory.[1]

Use exact fractions to compare recipes without confusing GB ( bytes) and GiB ( bytes). This estimator deliberately excludes padding and live working sets.
1from fractions import Fraction
2
3def state_bytes(parameters, ranks, param=2, grad=2, optimizer=12):
4 values = (parameters, ranks, param, grad, optimizer)
5 if any(type(x) is not int or x <= 0 for x in values):
6 raise ValueError("counts and byte widths must be positive integers")
7 shard = Fraction(parameters, ranks)
8 return {
9 "DP": parameters * (param + grad + optimizer),
10 "ZeRO-1": parameters * (param + grad) + shard * optimizer,
11 "ZeRO-2": parameters * param + shard * (grad + optimizer),
12 "ZeRO-3": shard * (param + grad + optimizer),
13 }
14
15for name, size in state_bytes(1_500_000_000, 4).items():
16 print(f"{name:6s} {float(size / 10**9):5.2f} GB | {float(size / 2**30):5.2f} GiB")
17assert set(state_bytes(17, 1).values()) == {17 * 16}
18assert state_bytes(1_000_000_000, 4, grad=4)["ZeRO-3"] == 4_500_000_0001DP 24.00 GB | 22.35 GiB
2ZeRO-1 10.50 GB | 9.78 GiB
3ZeRO-2 8.25 GB | 7.68 GiB
4ZeRO-3 6.00 GB | 5.59 GiBStage 1 partitions optimizer state and master weights; stage 2 also partitions reduced gradients; stage 3 also partitions compute parameters.[5] Higher stages aren't quality levels. If stage 2 fits, stage 3's additional gathers may or may not pay off. If saved activations dominate, investigate activation checkpointing, microbatch size, or sequence length before expecting parameter sharding to solve the problem.
Reduce first, then update the owned slice
ZeRO's central arithmetic is easiest to see with two ranks. Suppose their gradients for one parameter are 1 and 3. The averaged gradient is 2. The owner applies Adam once to that gradient and its local moment state. It mustn't apply Adam separately to each rank's gradient and then average the updated moments: the second moment involves a square, and , while .[6]
In an unsharded run, every rank can hold the reduced gradient and optimizer state. In a sharded run, the owning rank holds the relevant slice. The mathematical update can match, although different reduction orders and low-precision arithmetic can change floating-point results.
This scalar implementation runs five updates on eleven parameters, partitioned over four logical owners. The comparison covers every weight and moment, including uneven partitions. The reference uses ordinary Adam without weight decay, not the later AdamW configuration, and doesn't simulate NCCL's execution or DeepSpeed's padding layout.
1from math import isclose, sqrt
2
3def adam_step(weight, first, second, gradient, step, lr=0.01):
4 first = 0.9 * first + 0.1 * gradient
5 second = 0.999 * second + 0.001 * gradient * gradient
6 corrected_first = first / (1 - 0.9**step)
7 corrected_second = second / (1 - 0.999**step)
8 weight -= lr * corrected_first / (sqrt(corrected_second) + 1e-8)
9 return weight, first, second
10
11def compare_owned_updates(parameters, ranks, steps):
12 if any(type(x) is not int or x <= 0 for x in (parameters, ranks, steps)):
13 raise ValueError("parameters, ranks, and steps must be positive integers")
14 reference = [(0.1 * i, 0.0, 0.0) for i in range(parameters)]
15 # Contiguous ownership; some owners may be empty when ranks > parameters.
16 owners = [range(parameters * r // ranks, parameters * (r + 1) // ranks)
17 for r in range(ranks)]
18 sharded = [{i: reference[i] for i in ids} for ids in owners]
19 assert sorted(i for owner in sharded for i in owner) == list(range(parameters))
20 for step in range(1, steps + 1):
21 local = [[((i + 2 * r + step) % 7 - 3) / 10
22 for i in range(parameters)] for r in range(ranks)]
23 mean = [sum(local[r][i] for r in range(ranks)) / ranks
24 for i in range(parameters)]
25 reference = [adam_step(*state, mean[i], step)
26 for i, state in enumerate(reference)]
27 for rank, owner in enumerate(sharded):
28 # A reference reduce-scatter: reduce across ranks, retain owned indices.
29 reduced = {i: sum(row[i] for row in local) / ranks for i in owner}
30 sharded[rank] = {i: adam_step(*state, reduced[i], step)
31 for i, state in owner.items()}
32 reconstructed = [state for owner in sharded for state in owner.values()]
33 assert all(isclose(a, b, rel_tol=1e-12, abs_tol=1e-12)
34 for expected, actual in zip(reference, reconstructed)
35 for a, b in zip(expected, actual))
36 return reference
37
38result = compare_owned_updates(11, 4, 5)
39# Independently check the first-step scalar update for gradient 2.
40w, m, v = adam_step(1.0, 0.0, 0.0, 2.0, 1)
41assert isclose(w, 1.0 - 0.01 * 2.0 / (2.0 + 1e-8))
42assert isclose(m, 0.2) and isclose(v, 0.004)
43assert (1**2 + 3**2) / 2 != ((1 + 3) / 2)**2
44print(f"{len(result)} parameters, 4 owners, 5 updates: weights and moments match")111 parameters, 4 owners, 5 updates: weights and moments matchThe example averages equally weighted rank gradients. Variable microbatch sizes or different numbers of unmasked tokens need the appropriate loss weighting; “average each rank's average” isn't automatically the global token-average objective.
Let the engine own the microstep
deepspeed.initialize(...) selects and configures a DeepSpeedEngine, wrapped optimizer, optional data loader, and scheduler. The engine coordinates backward, accumulation, reduction, precision, stepping, and checkpoint state.[2]
Here is an integration sketch, not a standalone program. It assumes a model whose forward returns a scalar mean loss, a prepared batch on the correct device, and a distributed launcher. Under the default managed-accumulation behavior, call step() after every microbatch; the engine applies an optimizer update at its accumulation boundary. Don't divide the loss by the accumulation count again when using the default engine scaling.[3]
1engine, optimizer, _, scheduler = deepspeed.initialize(
2 model=model,
3 model_parameters=model.parameters(),
4 config="ds_config.json",
5)
6for batch in prepared_batches:
7 loss = engine(batch)
8 engine.backward(loss)
9 engine.step()This four-data-rank configuration uses BF16 model parameters and explicitly requests FP32 gradient accumulation. It's a starting configuration to validate on compatible GPUs, not a benchmarked recommendation. The optimizer settings belong to your training recipe; ZeRO doesn't determine a suitable learning rate.
1{
2 "train_batch_size": 64,
3 "train_micro_batch_size_per_gpu": 2,
4 "gradient_accumulation_steps": 8,
5 "bf16": { "enabled": true },
6 "data_types": { "grad_accum_dtype": "fp32" },
7 "zero_optimization": {
8 "stage": 2,
9 "contiguous_gradients": true,
10 "overlap_comm": true,
11 "reduce_bucket_size": 200000000
12 },
13 "optimizer": {
14 "type": "AdamW",
15 "params": {
16 "lr": 0.00002,
17 "betas": [0.9, 0.999],
18 "eps": 1e-8,
19 "weight_decay": 0.01
20 }
21 }
22}Check two different units. The batch identity is samples per optimizer update. The last factor is the data-parallel degree, not necessarily the total number of GPUs when model parallelism is present. Holding all three batch fields fixed while launching eight data ranks is inconsistent, not a harmless scale-out change.[4]
reduce_bucket_size is a count of elements, not bytes. Two hundred million elements occupy 400 MB at two bytes each or 800 MB at four bytes each, before other buffers. Communication dtype and accumulation dtype needn't match. Larger buckets can amortize collective overhead but increase temporary storage and delay the first reduction; inspect the timeline rather than copying a bucket size.[5]
For a real launch, record the installed DeepSpeed and PyTorch versions, accelerator and driver, the resolved configuration, and ds_report output. The report checks operator/environment compatibility; it doesn't prove that a multi-rank training step or offload path works.[7] Neither the integration sketch nor this GPU configuration was executed for this lesson.
Stage 3 needs a live working set
A sharded parameter can't directly supply a conventional full-layer matrix multiplication. ZeRO-3 gathers the parameter view needed for a module, executes it, and can release that full view afterward. Backward can require another gather. Prefetch, persistence, reuse, and module grouping affect the actual schedule.[5]

Don't subtract the local shard from a gathered allocation unless the implementation actually aliases that storage. In the pinned source's sequential gather path, param.ds_tensor is the input shard and a separate torch.empty(...) tensor receives the full padded gather.[2] Logical values and allocated bytes are different counts.
For four tensors of eight parameters on four ranks, one rank retains eight shard elements in total. A separate full eight-element gather brings the parameter-storage count to sixteen, not fourteen. Keeping a second gathered tensor for prefetch raises this example to twenty-four. These counts exclude gradients, optimizer state, activations, and communication-library workspace.

This storage model rounds each tensor's shard up to a whole element and allocates a separate padded gather buffer. It models one specified layout, not a universal DeepSpeed peak estimator.
1def parameter_storage(layer_sizes, ranks, live_layers):
2 if type(ranks) is not int or ranks <= 0:
3 raise ValueError("ranks must be a positive integer")
4 if not layer_sizes or any(type(n) is not int or n <= 0 for n in layer_sizes):
5 raise ValueError("tensor sizes must be positive integers")
6 if (any(type(i) is not int or not 0 <= i < len(layer_sizes) for i in live_layers)
7 or len(set(live_layers)) != len(live_layers)):
8 raise ValueError("live tensor indices must be distinct and in range")
9 partitions = [(n + ranks - 1) // ranks for n in layer_sizes]
10 resident = sum(partitions)
11 gathered = sum(partitions[i] * ranks for i in live_layers)
12 return resident, gathered, resident + gathered
13
14sizes = [8, 8, 8, 8]
15for live in ([], [0], [0, 1]):
16 resident, gathered, total = parameter_storage(sizes, 4, live)
17 print(f"{len(live)} gathered: shards {resident} + full buffers {gathered} = {total} elements")
18assert parameter_storage([5, 9, 2], 4, [1]) == (6, 12, 18)
19assert parameter_storage(sizes, 4, [0])[2] == 16
20assert parameter_storage(sizes, 4, [0, 1])[2] == 2410 gathered: shards 8 + full buffers 0 = 8 elements
21 gathered: shards 8 + full buffers 8 = 16 elements
32 gathered: shards 8 + full buffers 16 = 24 elementsThe largest module's forward/backward working set must still fit. deepspeed.zero.Init() can partition parameters during construction so initialization doesn't first materialize the entire model on every GPU, but it doesn't make an oversized live operator fit. Tiling or model parallelism may still be needed.[5]
External parameters and dynamic routing are different problems
A tied embedding reused outside its owning module is an external parameter. DeepSpeed can discover common cases automatically; otherwise use its explicit gathering/registration mechanisms. This coordinates access to an existing parameter.[5]
Dynamic routing is different. Two data ranks may select different existing experts and enter incompatible gather sequences. Marking the enclosing module as a ZeRO leaf gathers its descendants as a unit before executing that module, at the cost of a larger working set. This doesn't register newly created trainable parameters with an already-built optimizer.[3]
Communication volume isn't elapsed time
A simplified ring model makes the paper's communication comparison concrete. For communicated elements per full vector on ranks, one all-gather or reduce-scatter sends elements per rank. Count sends or receives consistently, not their sum on one side of the comparison.
With equal element widths and no extra gathers, the basic schedules are:
| Schedule | Collective work | Sent elements per rank |
|---|---|---|
| Replicated data parallel | Gradient reduce-scatter + all-gather | |
| ZeRO-2 | Gradient reduce-scatter + updated-parameter all-gather | |
| ZeRO-3 | Forward parameter gather + backward parameter gather + gradient reduce-scatter |
This explains the paper's approximately versus model for large .[1] It doesn't promise that stage 3 takes 1.5 times as long. Different gradient/parameter dtypes, repeated gathers during accumulation or recomputation, parameter persistence, bucket sizes, overlap, and collective algorithms change the comparison. In stage 1, gradient storage stays replicated; don't infer a particular reduce-scatter implementation from its ownership formula.
On NVIDIA GPUs, NCCL commonly executes these collectives; DeepSpeed chooses the groups and schedules their use. A slow gather could be an exposed dependency, a small-message problem, or a network route problem. Read the per-rank timeline and communication counters together.
Offload exchanges memory pressure for movement
ZeRO-Offload moved optimizer-related state and work to CPU memory; ZeRO-Infinity extends the hierarchy to CPU and NVMe storage.[8][9] In current configuration, optimizer offload is available for stages 1 through 3, parameter offload for stage 3. Optimizer computation remains on the CPU even when its state is stored on NVMe.[5]
This fragment shows a stage-3 choice, not a complete launch configuration. It requires suitable local storage, available space, compatible I/O operators, and host memory for staging.
1{
2 "zero_optimization": {
3 "stage": 3,
4 "offload_optimizer": { "device": "cpu", "pin_memory": true },
5 "offload_param": {
6 "device": "nvme",
7 "nvme_path": "/local_nvme/deepspeed"
8 }
9 }
10}A bandwidth bound is a useful first check. If a step must move 12 GB through a bottleneck sustaining 6 GB/s, that path needs at least two seconds of service time. Perfect overlap with three seconds of independent compute could hide it; a dependency requiring the data before compute can't. Multiple reads/writes and shared-link contention can make the real step slower. These are illustrative values, not measured NVMe performance.
Measure GPU memory, host memory, pinned-memory use, CPU optimizer time, transfer volume, and storage queueing. Compare tokens per second at the same effective batch and sequence lengths. “It fits” answers a capacity question, not whether the training run finishes sooner.
Read the engine path, not just the config
The pinned source organizes the mechanisms as follows:[2]
| Entry point | What to inspect |
|---|---|
deepspeed/__init__.py | Initialization and engine selection |
deepspeed/runtime/engine.py | Backward, accumulation boundaries, step, save/load |
deepspeed/runtime/zero/stage_1_and_2.py | Gradient and optimizer ownership |
deepspeed/runtime/zero/stage3.py | Sharded optimizer and parameter coordination |
deepspeed/runtime/zero/partition_parameters.py | Partition storage, gathers, and parameter status |
deepspeed/runtime/swap_tensor/ | Offload staging and state movement |
deepspeed/runtime/pipe/engine.py | Pipeline-specific scheduling and restrictions |

Those branches are cooperating responsibilities, not three sequential phases. When a parameter is unexpectedly resident, follow its gather/release code; when a step occurs too early, follow accumulation bookkeeping.
Compatibility also belongs to a specific path. The pinned PipelineEngine rejects ZeRO-2 and ZeRO-3. Current AutoTP training documentation separately supports stages 0 through 3, with model-specific partition rules and topology-aware checkpoint handling. AutoTP isn't PipelineEngine, and neither claim establishes arbitrary combinations with other wrappers.[2][3]
FSDP is another route to sharded training, using PyTorch-native interfaces rather than the same engine/config boundary. Megatron supplies model-parallel building blocks and schedules. Compare the integration your model requires, then test it; a feature list doesn't establish loss parity or checkpoint portability.
Resume training and export weights separately
A distributed checkpoint contains more than inference weights. Treat two outcomes separately:
| Outcome | State needed | Verification |
|---|---|---|
| Resume training | Parameters, optimizer, scheduler, counters, required RNG/data progress | Next update agrees with an uninterrupted reference |
| Export a model | Consolidated weights plus model/tokenizer configuration | Reference inputs produce matching outputs within tolerance |
Every participating process must call engine.save_checkpoint with a consistent tag. Calling it only on rank zero can hang. For a ZeRO-3 save/reload test, construct a fresh model and engine before loading; the pinned implementation warns against loading immediately into the same already-partitioned engine.[2]
Use client_state for application-owned progress that DeepSpeed doesn't automatically manage. Restore the sampler or data-loader position and any required per-rank RNG state deliberately. A saved step counter alone doesn't ensure the next microbatch is the same.
For supported dense ZeRO layouts, get_fp32_state_dict_from_zero_checkpoint reconstructs FP32 weights. Plan CPU memory for consolidation; lazy conversion changes when tensors are materialized, not the size of the final weights. A weights-only export doesn't contain enough state to reproduce the next Adam update.[10]
Universal Checkpointing adds a conversion/load path for supported topology changes, not arbitrary model conversion. The current tutorial requires compatible parameter names and shapes and documents separate AutoTP/AutoEP layouts. In particular, partition-native AutoEP checkpoints aren't supported by zero_to_fp32.py; the documented path uses ds_to_universal.py instead.[11]
Before a long run, save at the intended topology, stop every worker, reload into a fresh run, and compare the next loss, update, scheduler state, and data progress. Test export separately. These distributed recovery operations weren't executed here.
Project context and contribution path
The original ZeRO authors worked at Microsoft. The project's pinned governance defines technical oversight through a Technical Steering Committee and its relationship with Linux Foundation AI & Data.[1][12] The pinned committer roster includes contributors affiliated with Snowflake, Microsoft, Anyscale, AMD, Google, Intel, UIUC, and Argonne National Laboratory. Those affiliations describe the dated roster, not everyone's current employer.[13]
The code license is Apache-2.0. The charter specifies Developer Certificate of Origin sign-off for contributions and CC BY 4.0 for project documentation.[14][12] Check the current contribution instructions before submitting a change.
For a source-level investigation, reduce the failing run first. Include the version/commit, resolved config, smallest reproducer, topology, and earliest rank error. Report whether the failure is initialization, correctness, performance, or recovery; a peer's collective timeout can be a consequence of an earlier failure elsewhere.
Turn a failed run into a specific hypothesis
Common failure modes
| Observation | Next investigation |
|---|---|
| Longer sequences OOM despite a small state estimate | Separate saved activations and workspaces from model state |
| OOM during a stage-3 gather | Count simultaneous gathered buffers, padding, and prefetch |
| Loss changes after scaling out | Check effective batch, token weighting, precision, and update count |
| Hang with routed experts | Compare gather order and the configured leaf boundary |
| Lower GPU peak but slower steps after offload | Measure exposed transfers and CPU/storage service time |
| Saved directory exists but resume diverges | Restore optimizer, scheduler, RNG, and data progress in a fresh engine |
Evaluation rubric
- Derive each stage's storage from tensor ownership and dtype, then explain what the estimate excludes.
- Trace one parameter and its gradient through gather, compute, reduction, and the accumulation-boundary update.
- Separate CPU reference checks from GPU measurements, and distinguish a resumable checkpoint from a weights-only export.
Follow-up questions
The state estimate fits, but enabling prefetch causes an OOM. Why isn't that a contradiction?
Answer
The estimate counts persistent state. Prefetch can keep multiple full gathered tensors live alongside their shards. Add the simultaneous allocations, activations, and workspaces before comparing with available device memory.
Two ranks route to different experts. Should you create parameters lazily and register them as external parameters?
Answer
No. The routing problem concerns compatible gathers for existing submodules. A suitable leaf boundary gathers descendants together, with a memory cost. External-parameter coordination concerns access outside an existing parameter's owning module; it doesn't make post-initialization parameter creation safe for the optimizer.