DeepSpeed ZeRO: Source Code and Execution Flow

Liao Jiayi Liao Jiayi

How ZeRO reduces redundant GPU memory through sharding, with a walkthrough of DeepSpeed's initialization and training flow.

Translated from Chinese with AI · Read the original

ZeRO, the Zero Redundancy Optimizer, reduces redundant GPU-memory use through sharding, improving parallel training efficiency and resource utilization.

ZeRO and DeepSpeed

Background:

ZeRO, the Zero Redundancy Optimizer, shards training state to reduce redundant GPU-memory use and improve parallel efficiency or resource utilization.

GPU memory contains:

  • Gradients.
  • Activations, such as the locations of zero values in ReLU.
  • Optimizer state, such as Adam’s weighted averages. [Image]

ZeRO stages eliminate redundancy in different components: optimizer state, gradients, and parameters.

Stage Sharded Components Process
ZeRO-1 Optimizer state, such as Adam momentum and squared averages Each GPU handles only its optimizer partition, for example: m[p] = beta * m'[p] + (1-beta)*grad[p]
ZeRO-2 Optimizer state and gradients Gradients are reduced to their owning ranks instead of all-reduced, then updated parameters are all-gathered.
- Sharding changes communication from all-reduce toward reduce-scatter and all-gather:
  - Backward:
    - Each GPU computes local gradients.
    - Reduce-scatter distributes them among GPUs.
    - Each GPU retains its own portion.
  - Optimizer update:
    - Update the parameter partition using local gradients and optimizer state.
  - Forward:
    - Full parameters remain available, so layer-wise all-gather is unnecessary; that is required by ZeRO-3.
ZeRO-3 Optimizer state, gradients, and parameters; effectively data parallelism plus parameter partitioning Backward
  - Gather, reduce, and scatter layer by layer: collect needed data, aggregate gradients, and release nonlocal portions.
  - Parameter, gradient, and optimizer partitions align, so updates are local.
- Forward
  - Dynamically all-gather each layer, compute, then free its parameters. How is efficiency maintained?

Initialization

The main code is in DeepSpeedEngine.py. Initialization performs these operations:

  1. Initialize the data loader.
self.training_dataloader = self.deepspeed_io(training_data)

DeepSpeed’s loader differs from PyTorch’s as follows:

Aspect PyTorch DataLoader DeepSpeedDataLoader
Purpose General loader for one machine or multiple processes Wrapper optimized for distributed training
Distributed support Configure DistributedSampler and initialize each process manually Integrates distributed sampling and sharding with engine rank/world_size
Data partitioning Manually control samples per GPU Partitions by rank to avoid duplicates and omissions
Batch management Fixed batch_size, manually adjusted Dynamic batch sizes coordinated with gradient accumulation
Asynchrony and performance Primarily Python process/thread loading Asynchronous prefetch and pipelined loading reduce I/O waits
Recovery No built-in state restoration Integrates with DeepSpeed checkpoints for epoch/iteration state
Typical use Single-machine or basic DDP Large distributed training with ZeRO, MoE, or pipelines
  1. Initialize the optimizer.
if has_optimizer:
self._configure_optimizer(optimizer, model_parameters)

Configuration and optimizer parameters determine initialization. ZeRO is initialized here, with four enum values representing its modes:

class ZeroStageEnum(int, Enum):
""" Enum class for possible zero stages """
disabled = 0
optimizer_states = 1 # 只缓存 optimizer states,对应 ZeRO-1
gradients = 2 # 缓存 optimizer states + gradients,对应 ZeRO-2
weights = 3 # 缓存 optimizer states + gradients + weights,对应 ZeRO-3
max_stage = 3

These are the two main initialization areas. Other DeepSpeed optimizations are also initialized, including:

  • Torch AMP:
    • Mixed precision improves speed and reduces GPU memory without substantial accuracy loss. Different operators use different precision: FP16/BF16 for matrix multiplication and convolution, FP32 accumulation for additions, normalization, and losses. Dynamic loss scaling prevents FP16 underflow.
    1. Use different precision for different operators.
    • Matmul/Conv: Computationally expensive, with relatively small multiplication error.
    • Addition, normalization, and loss: Require greater precision, so use FP32.
    1. Use loss scaling during training.
  • ZenFlow: A DeepSpeed extension introduced in 2025 as a stall-free offloading engine. References. It addresses GPU stalls during CPU offloading. Built on ZeRO-Offload, it adds decoupling and asynchronous updates to reduce GPU waiting during synchronized CPU-GPU updates. References Important gradients update immediately on the GPU, while less critical ones accumulate and update asynchronously on the CPU. CPU work and PCIe transfers overlap GPU computation, reducing idle time.

ZeRO-1

Initialization

The code is in DeepSpeedZeroOptimizer. ZeRO-1 and ZeRO-2 share this optimizer, distinguished by partition_gradients, which controls gradient sharding.

# ZeRO stage 1 (False) or 2 (True)
self.partition_gradients = partition_grads
self.zero_stage_string = "ZeRO-2" if partition_grads else "ZeRO-1"

Core steps:

  1. Initialize parameter groups.
self.real_dp_process_group = [dp_process_group for i in range(len(self.optimizer.param_groups))]
self.partition_count = [dp_size for i in range(len(self.optimizer.param_groups))]

An optimizer can divide parameters into groups, each sharing a set of hyperparameters.

  1. Iterate through groups and preprocess them.

Redistribution

# 遍历所有的参数组
for i, param_group in enumerate(self.optimizer.param_groups):
# 当前进程在特定数据并行组中的 rank,用于确定当前进程负责哪一部分参数
partition_id = dist.get_rank(group=self.real_dp_process_group[i])
...
# 这里把需要 train 的参数都放进了 bit16_groups 里
if self.round_robin_gradients:
# 把第 i 个参数组的参数按照轮询的方式,拆到同一个进程组的不同进程里
round_robin_tensors, round_robin_indices = self._round_robin_reorder(self.bit16_groups[i],dist.get_world_size(group=self.real_dp_process_group[i]))
else:
round_robin_tensors = self.bit16_groups[i]
round_robin_indices = list(range(len(self.bit16_groups[i])))

round_robin_gradients spreads parameters across partitions because different Transformer layers may impose different update workloads.

Padding:

# 把 tensor 拍平,然后根据 NCCL 的对齐要求,对齐到边界,多余的补上 torch.zeros(..)
flattened_buffer = self.flatten_dense_tensors_aligned(
self.round_robin_bit16_groups[i],
self.nccl_start_alignment_factor * dist.get_world_size(group=self.real_dp_process_group[i]),
use_cpu_data=True)
...
# 尽可能等分作切割,每个 partition 里存的是一部分 flat tensor
data_parallel_partitions = self.get_data_parallel_partitions(self.bit16_groups_flat[i], i)
self.parallel_partitioned_bit16_groups.append(data_parallel_partitions)
...
# partition_id之前的所有 tensor 的元素数量,求一个 left bound,算一下当前的长度
left_boundary = sum([t.numel() for t in data_parallel_partitions[:partition_id]])
curr_partition_size = data_parallel_partitions[partition_id].numel()
# 计算 padding 的大小; 这里是为了确保每个 partition 的大小是 nccl_start_alignment_factor 的倍数
if orig_group_numel <= left_boundary:
padding = curr_partition_size
elif orig_group_numel < left_boundary + curr_partition_size:
padding = left_boundary + curr_partition_size - orig_group_numel
else:
padding = 0
self.groups_padding.append(padding)

The reordered tensors are flattened into one dimension and aligned to communication boundaries, padding with torch.zeros(…) as needed for NVLink.

Key variables

partition_size = len(self.bit16_groups_flat[i]) / dist.get_world_size(group=self.real_dp_process_group[i])
# 取到当前的 partition id 分片的 params
params_in_partition, params_not_in_partition, first_offset = self.get_partition_info(
self.round_robin_bit16_groups[i], partition_size, partition_id)
self.partition_size.append(partition_size)
self.params_in_partition.append(params_in_partition)
self.params_not_in_partition.append(params_not_in_partition)
self.first_offset.append(first_offset)

params_in_partition and params_not_in_partition identify ownership for backward and step operations.

Step()

ZeRO-1 mainly changes optimizer state, so most differences are in step(); see DeepSpeedZeroOptimizer.step:

  1. Check for overflow, possible with FP16’s limited exponent range. Broadcast it with all-reduce and reduce scaling or skip the parameter update.
# 这里是如何判断overflow的?
# 1. 先判断当前的 partition 是否 overflow
# 2. 如果当前的 partition 没有 overflow,再判断是否有其他的 partition 溢出
# 4. 做一个all reduce, 把所有的 partition 的 overflow 标志位都同步起来
def has_overflow(self, partition_gradients=True):
overflow = self.local_overflow if self.cpu_offload else self.has_overflow_partitioned_grads_serial()
overflow_gpu = get_accelerator().ByteTensor([overflow]) if self.cpu_offload else overflow.byte().to(
get_accelerator().current_device_name())
if partition_gradients:
'''This will capture overflow across all data parallel and expert parallel process
Since expert parallel process are a subset of data parallel process'''
dist.all_reduce(overflow_gpu, op=dist.ReduceOp.MAX, group=self.dp_process_group)
# Since each model parallel GPU carries only part of the model,
# make sure overflow flag is synced across all the model parallel GPUs
self._model_parallel_all_reduce(tensor=overflow_gpu, op=dist.ReduceOp.MAX)
overflow = overflow_gpu[0].item()
return bool(overflow)
  1. Calculate gradients.
# 释放掉所有不在当前 partition 中的 params 的梯度
self.free_grad_in_param_list(self.params_not_in_partition[i])
# 这里是取到当前 partition 中的 params 的梯度
single_grad_partition = self.flatten(self.averaged_gradients[i]).to(self.single_partition_of_fp32_groups[i].dtype)

The averaged_gradients computation has several configuration-dependent paths. Trace the source below; broadly, gradients are synchronized through all-reduce.

# ZeRO stage >= 2 communicates during non gradient accumulation boundaries as well
if self.zero_optimization_partition_gradients():
self.optimizer.overlapping_partition_gradients_reduce_epilogue()
# Communicate only at gradient accumulation boundaries
elif self.is_gradient_accumulation_boundary():
if self.zero_optimization_stage() == ZeroStageEnum.optimizer_states and hasattr(
self.optimizer, 'reduce_gradients'):
self.optimizer.reduce_gradients(pipeline_parallel=self.pipeline_parallelism)
else:
grads = None
self.buffered_allreduce_fallback(grads=grads, elements_per_buffer=bucket_size)
elif self.zenflow:
self.optimizer.reduce_gradients(pipeline_parallel=self.pipeline_parallelism)
  1. Optimize using the gradients.
# Step 3:- run the optimizer if no offloading
self.timers(OPTIMIZER_STEP_TIMER).start()
self._optimizer_step(i)
def _optimizer_step(self, group_no):
original_param_groups = self.optimizer.param_groups
self.optimizer.param_groups = [original_param_groups[group_no]]
...
self.optimizer.step()
self.optimizer.param_groups = original_param_groups

Temporarily save param_groups and replace them with the current partition’s parameters. The optimizer updates only its partition; all-gather retrieves parameters updated elsewhere. Restore the original groups afterward.

  1. All-gather the parameters.
# 这里是 all gather 所有的 params, 然后更新到当前的 partition 中
all_gather_dp_groups(groups_flat=self.bit16_groups_flat,
partitioned_param_groups=self.parallel_partitioned_bit16_groups,
dp_process_group=self.real_dp_process_group,
start_alignment_factor=self.nccl_start_alignment_factor,
allgather_bucket_size=self.allgather_bucket_size)

ZeRO-2

ZeRO-2 reuses the ZeRO-1 optimizer with partition_gradients enabled. Most code is shared; differences mainly occur in backward(), where gradients are computed and partitioned.

backward()

Call chain:

def backward(self, loss, retain_graph=False, scale_wrt_gas=True):
...
# 这里是直接diaoyong调用 optimizer 的 backward 方法
self._do_optimizer_backward(loss, retain_graph)
# 这里是对 backward() 后的数据做处理
self._backward_epilogue()
def _backward_epilogue(self):
self.allreduce_gradients()
def allreduce_gradients(self, bucket_size=MEMORY_OPT_ALLREDUCE_SIZE):
...
# ZeRO stage >= 2 communicates during non gradient accumulation boundaries as well
if self.zero_optimization_partition_gradients():
self.optimizer.overlapping_partition_gradients_reduce_epilogue()
def independent_gradient_partition_epilogue(self):
...
self.reduce_ipg_grads()
...

reduce_ipg_grads() is central. IPG means independent partitioned gradients. Bucketing and reductions implement partitioning/scattering indirectly, with average_tensor as the core method. Bucketing rules:

  • rank_and_offsets identifies ranks and offsets within flattened gradients.
    • dst is the destination rank, bucket_offset the starting offset, and numel the element count.
  • Output buckets use destination rank plus process group as keys, with gradient tensors as values.
buckets = {}
for i, (dst, bucket_offset, numel) in enumerate(rank_and_offsets):
grad_slice = tensor.narrow(0, int(bucket_offset), int(numel))
bucket_key = real_dp_process_group[i] if self.use_multi_rank_bucket_allreduce else (
dst, real_dp_process_group[i])
if bucket_key not in buckets:
buckets[bucket_key] = []
if self.use_multi_rank_bucket_allreduce:
buckets[bucket_key].append((dst, grad_slice))
else:
buckets[bucket_key].append(grad_slice)

Iterate over all buckets, including gradients destined for every rank. IPG code mainly does two things:

  1. Send each destination’s gradients through dist.reduce.
  2. Retain gradients owned by the local partition and clear the rest with bucket.clear().
for bucket_key in buckets:
self.allreduce_no_retain(buckets[bucket_key],
communication_data_type,
numel_per_bucket=self.reduce_bucket_size,
rank=dst,
divide=False,
process_group=process_group)

ZeRO-3

DeepSpeed hooks automatically manage ZeRO-3 parameter loading, release, and all-gather during training. Typical hooks include:

# Pre forward hook
self.forward_hooks.append(module.register_forward_pre_hook(_pre_forward_module_hook))
# Post forward hook
self.forward_hooks.append(module.register_forward_hook(_post_forward_module_hook))

PartitionedParameterCoordinator

PartitionedParameterCoordinator is central to ZeRO-3. Unlike stages 1 and 2, parameters themselves are sharded to minimize GPU memory, and this coordinator manages them throughout forward and backward passes.

The coordinator’s full responsibilities are listed here:

class PartitionedParameterCoordinator:
FORWARD_FETCH_SUBMIT = 'forward_fetch_submit'
FORWARD_FETCH_WAIT = 'forward_fetch_wait'
FORWARD_PREFETCH_SUBMIT = 'forward_prefetch_submit'
BACKWARD_FETCH_SUBMIT = 'backward_fetch_submit'
BACKWARD_FETCH_WAIT = 'backward_fetch_wait'
BACKWARD_PREFETCH_SUBMIT = 'backward_prefetch_submit'
FORWARD_ALL_GATHER = 'forward_all_gather'
BACKWARD_ALL_GATHER = 'backward_all_gather'
"""Handles partitioning and gathering of parameters."""

To offset the performance cost of parameter sharding, DeepSpeed provides:

  • Prefetch: Load the next layer’s parameters early.
  • Fast fetch: Skip some dependency checks and retrieve parameters directly. I will examine this later.
  • Tracing: Accelerate execution using traces. Also for later study.

Parameter availability has three states, mainly for consistency:

  • AVAILABLE: Available locally.
  • NOT_AVAILABLE: Not available locally.
  • INFLIGHT: Transfer in progress.
class ZeroParamStatus(Enum):
# parameters are fully present and ready for use on all processes
AVAILABLE = 1
# parameters are either partitioned or remote in some or all process
NOT_AVAILABLE = 2
# parameters are being gathered.
INFLIGHT = 3

Forward

  1. Pre Forward:

During forward execution, each layer or nn.Module fetches its required parameters while prefetching for subsequent modules:

def fetch_sub_module(self, current_submodule: Module, forward: bool) -> None:
...
params_to_fetch = set(iter_params(current_submodule, recurse=z3_leaf_module(current_submodule)))
fetch_numel = sum([p.partition_numel() for p in params_to_fetch if p.ds_status == ZeroParamStatus.NOT_AVAILABLE])
if fetch_numel > 0:
...
self.__all_gather_params(params_to_fetch, forward)
  1. Post Forward:

PreForward fetches parameters; PostForward releases them.

def release_sub_module(self, submodule: Module, forward=False) -> None:
"""release the parameters of a sub module, assuming they meet conditions to
be released."""
#print_rank_0(f"release_sub_module {'fwd' if forward else 'bwd'}: {debug_module2name_id(submodule)}", force=False)
params_to_release = (self.__params_to_release(submodule, self.__step_id) if self.is_complete_trace() else set(
p.ds_id for p in iter_params(submodule, recurse=z3_leaf_module(submodule))))
free_data = not z3_leaf_module(submodule) or not self.fast_sharding_for_leaf_module
if not free_data:
# wait for the computation to finish and launch as early as possible.
empty_buffer = torch.empty(1, device=get_accelerator().current_device())
for param in iter_params(submodule, recurse=z3_leaf_module(submodule)):
param.ds_active_sub_modules.discard(submodule.ds_id)
if param.ds_id in params_to_release and not param.is_external_param:
self.__release_param(param, free_data)
if not free_data:
if param.ds_id in params_to_release and not param.is_external_param:
# empty buffer ensures that all computations are complete
param.data = empty_buffer

Backward

Getting tired…