Chapter 05

ZeRO 三阶段拆解:到底分了什么、什么时候 all-gather

📌 源码引用基于 deepspeed/runtime/zero/stage_1_and_2.py 与 deepspeed/runtime/zero/stage3.py · commit 510ebe58e4e4 。

5.1 ZeRO 要解决的问题

普通 DDP 训练一个 $\Psi$ 参数的模型,每张卡都要存:

大小(bytes,含 fp16/fp32 混精)
fp16 参数 $2\Psi$
fp16 梯度 $2\Psi$
fp32 master 参数(Adam) $4\Psi$
fp32 Adam 一阶矩 $m$ $4\Psi$
fp32 Adam 二阶矩 $v$ $4\Psi$

合计单卡占用约 $16\Psi$(不算激活)。对 13B 模型就是 208 GB —— 一张 H100 80G 都装不下。DDP 里这些每张卡都是同样一份,浪费惊人。

ZeRO 的洞察:把这 $16\Psi$ 里"全卡都存"的部分,按 DP 大小 $N$ 切分。

$$ M_\text{DDP} = 16\Psi \quad\Longrightarrow\quad M_\text{ZeRO-1} = 4\Psi + \tfrac{12\Psi}{N} \quad\Longrightarrow\quad M_\text{ZeRO-2} = 2\Psi + \tfrac{14\Psi}{N} \quad\Longrightarrow\quad M_\text{ZeRO-3} = \tfrac{16\Psi}{N} $$

ZeRO-3 在 $N\to\infty$ 时单卡显存趋近 0(只剩激活)。这就是为什么 1024 卡能训万亿模型

5.2 三个 stage 切的是什么

Stage切谁每卡保留前向 / 反向通信
ZeRO-0啥也不切,等价 DDP所有 $16\Psi$梯度 all-reduce
ZeRO-1优化器状态($m, v$, master)$4\Psi + \tfrac{12\Psi}{N}$梯度 reduce-scatter + 参数广播
ZeRO-2+ 梯度 $2\Psi + \tfrac{14\Psi}{N}$梯度 reduce-scatter
ZeRO-3+ 参数本身 $\tfrac{16\Psi}{N}$ 参数 all-gather(前向时)+ 梯度 reduce-scatter(反向)

5.3 ZeRO-1 / ZeRO-2 时序

sequenceDiagram participant All as 全卡 participant R as rank i Note over All: Forward(所有卡都有完整参数) All->>All: x = model(input) Note over All: Backward All->>All: 每张卡算自己 batch 上的 grad Note over All: ZeRO-2 在这里做 reduce-scatter All->>R: grad shard 落到 rank i Note over R: rank i 只更新自己负责的那片参数 R->>R: optimizer.step() 用本片的 m,v,master Note over All: 下一轮 forward 前做 all-gather R->>All: 把更新后的 fp16 参数广播回去

Stage-1 只切优化器状态,所以 reduce-scatter 的对象不变,只是 optimizer.step 时只动自己那片 m/v/master。

5.4 ZeRO-3 时序(最复杂)

sequenceDiagram participant All as 全卡 participant R as rank i participant Co as PartitionedParameterCoordinator Note over R: 平时只持有 1/N 的参数 All->>Co: forward 第 L 层之前,请求 L 层参数 Co->>Co: 查 trace cache(命中=按预录序列预取下 K 层) Co->>All: 提交 all-gather(最多 2 个 inflight) All->>All: forward 该层 Co->>R: 触发释放:超 max_live_parameters 或 reuse_distance 的 param All->>Co: forward 第 L+1 层之前,请求 L+1 层参数 Note over Co: trace 不命中 → 退化为同步 all-gather(性能骤降) Co->>All: all-gather 下一层 Note over All: 反向同理,倒序遍历;同时 reduce-scatter 梯度 All->>R: gradient shard R->>R: optimizer.step() 更新本片参数 R->>All: 下一轮 forward 前广播本片 fp16 参数

所以 ZeRO-3 训练中每一层都有"取-用-丢"三步,通信非常密集。overlap_comm: true 让通信和计算并发,能藏掉大部分开销,开 ZeRO-3 一定要开这个

5.5 一组关键 JSON 字段对照

字段含义常用值
zero_optimization.stage 选 stage 0/1/2/3
zero_optimization.overlap_comm 通信和计算 overlap true(强烈建议)
zero_optimization.contiguous_gradients 梯度内存连续,提速 true
zero_optimization.reduce_bucket_size reduce-scatter 桶大小 5e8auto
zero_optimization.allgather_bucket_sizeall-gather 桶大小(仅 stage 1/2) 5e8
zero_optimization.stage3_prefetch_bucket_size stage 3 提前 all-gather 几个参数 auto
zero_optimization.stage3_param_persistence_threshold小于此 size 的参数不切(开销不划算)auto
zero_optimization.stage3_max_live_parameters 同时驻留显存的参数上限 1e9
zero_optimization.stage3_gather_16bit_weights_on_model_savesave 时 gather 完整 fp16 权重true

5.6 怎么选 stage

场景推荐 stage理由
2 卡,模型本来能装下 ZeRO-1 / 2就为省优化器显存,通信代价小
8 卡 13B 全参 ZeRO-2性价比最高
8 卡 70B 全参 / 显存吃紧 ZeRO-3必须把参数也切
1024 卡 千亿模型预训练 ZeRO-3 + 模型并行纯 ZeRO 通信会爆,要混 Megatron
QLoRA / LoRA 小可训参数 ZeRO-1 / 2 即可可训参数本来就少
跨卡 batch size 还不够大 ZeRO-1通信少,throughput 高

5.7 stage 3 的"参数生命周期"

stage3.py 会看到三个状态机:

状态含义
NOT_AVAILABLE 参数被切走了,本卡没有完整副本
INFLIGHT 正在 all-gather 中
AVAILABLE 已 gather 完,可参与 forward/backward

PartitionedParamCoordinator 是这个状态机的调度器,前向中按拓扑顺序 prefetch,反向中按相反顺序释放。看懂这部分基本就理解 ZeRO-3 的精髓。

5.8 ZeRO++ 与 SuperOffload(前沿)

README "Latest News" 里能看到几个新名字:

名字主张JSON 入口
ZeRO++ 分层通信 + 量化梯度,万卡训练通信省 4×zero_optimization.zero_hpz_partition_size
SuperOffload 专为 Grace Hopper 等"超级芯片"做的 offload 优化对应 runtime/superoffload/
ZenFlow 把 offload + compute 异步流水线化 新近 blog 解读
Muon Optimizer 2026/05 新接入的优化器 JSON optimizer.type=Muon

这些算是"进阶之路",初次入门不用动,先用 ZeRO-2/3 把基本盘吃透。

5.10 ZeRO-1 / 2 优化器源码导览

Stage 1 / 2 的实现都在 deepspeed/runtime/zero/stage_1_and_2.py,类名 DeepSpeedZeroOptimizer(line 126)。它内部用一个叫 IPGBucket 的小数据类管理"凑齐再发出"的桶:

# stage_1_and_2.py:110–124
@dataclass
class IPGBucket:
    buffer:    Tensor             # 拍平后的桶;所有梯度按字节连续放进来
    params:    List[Parameter]    # 桶里有谁
    grads:     List[Tensor]       # 对应梯度视图
    elements:  int                # 已填字节数
    index:     int                # 多桶 round-robin 时桶编号
    has_moe_params: bool          # 桶里是否有 MoE 专家参数(决定要不要走 expert DP group)

读这个文件时几个关键 flag 与设计选择:

字段含义
self.partition_gradients line 214:True → ZeRO-2,False → ZeRO-1。区别只在"梯度是否也切"
self.zero_stage_string line 215:日志友好的 "ZeRO-1" / "ZeRO-2" 字符串
self.reduce_scatter line 219:False 时退化为 all-reduce(不推荐,主要是兼容老 NCCL)
self.overlap_comm line 221:通信和计算 overlap。stage 2 默认 True;stage 1 默认 False(通信少,overlap 收益小)
self.contiguous_gradients line 246:CPU offload 时会被强制设为 True,因为 cpu_adam 需要梯度内存连续
self.partition_count line 241:每个 param group 各自的 partition 数;MoE 时不同专家组可以有不同 DP 数
self.real_dp_process_group line 240:跟上面配套——MoE 专家走自己的 expert DP group,普通参数走全局 DP group

如果你看到一段 DS 调试日志说"reduce-scatter to expert group X,bucket size Y",那就是上面这套 per-param-group 通信路径在发声。

5.11 ZeRO-3 状态机:PartitionedParameterCoordinator

ZeRO-3 真正的"大脑"在 deepspeed/runtime/zero/partitioned_param_coordinator.py。它跟踪每一个被切分参数当前在哪、谁正在 all-gather 它、它什么时候该被释放。理解这个类的状态枚举,是看懂 stage 3 hang / 性能波动的钥匙。

5.11.1 三种 trace 状态

# partitioned_param_coordinator.py:44–50
class ZeRoTraceMode(Enum):
    RECORD   = 1   # 第一个 forward + backward:把模块访问顺序录下来
    COMPLETE = 2   # 之后每步:按录像 prefetch,最大化 overlap
    INVALID  = 3   # 当前 forward 顺序和录像对不上:退化为同步 all-gather

三种状态的迁移:

迁移触发
初始 → RECORD 引擎构造时
RECORD → COMPLETE 第一个 backward 完成(拿到了完整的 forward→backward 路径)
COMPLETE → INVALID 当前一步执行到的 submodule 顺序与 trace 对不上(trace_prologue(),line ~187)
INVALID → COMPLETE 不会自动回去——这一整次训练都用退化的同步路径

这是为什么"第一个 step 慢、后面快",也是为什么"模型里加了 if rank == 0: x = some_module(x) 这种 rank 分支后整训慢 5 倍" —— trace 进了 INVALID 自己不会嚷嚷。打开 __log_trace_cache_warnings 才会看到 warning。

5.11.2 关键内部数据结构

字段行号含义
__inflight_param_registry 95正在 all-gather 中的参数 → 通信 handle 的映射;释放时要等 handle
__step_id 97submodule 访问序号;trace_prologue 用它比对录像
__trace_mode 99三态枚举值
__most_recent_step_id_param_fetched_for103同一 step 重复访问参数时去重
__max_n_available_params 107显存中允许同时存在的参数 numel 上限(对应 stage3_max_live_parameters
__max_reuse_dist_in_numel 109多少 numel 距离内的参数复用 cache(对应 stage3_max_reuse_distance
__param_queue 112待 fetch 的 deque;从左 pop、从右 prefetch
__prefetch_bucket_sz 113prefetch 批量大小(对应 stage3_prefetch_bucket_size
__max_ongoing_fetch_events 133硬编码为 2——同时挂飞的 all-gather event 最多 2 个,防显存暴涨。bucket 调到很大时常被这个 cap 反噬

5.11.3 八个 timer 名(调优时认这个)

# partitioned_param_coordinator.py:65–71
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"

wall_clock_breakdown: true 时这 8 个 timer 会逐步打印(详见 ch11.10)。看 timer 名你就能定位是"提交慢"还是"等慢",是 forward 还是 backward 在拖。

5.12 ZeROOrderedDict:参数自动 all-gather 的"魔法"

ZeRO-3 用户经常困惑:参数明明被切到别张卡了,为什么 forward 时直接写 self.weight 还能用?答案在 deepspeed/runtime/zero/partition_parameters.py 第 40–72 行的 ZeROOrderedDict

# partition_parameters.py:57–68(简化版)
class ZeROOrderedDict(OrderedDict):
    def __getitem__(self, key):
        param = super().__getitem__(key)
        if param is not None and param.ds_status == ZeroParamStatus.NOT_AVAILABLE:
            param.all_gather()   # ★ 触发同步 all-gather
        return param

parameter_offload.py 的 _inject_parameters()(line 158–161)会递归遍历所有 submodule,把它们的 _parameters dict 替换成 ZeROOrderedDict。于是每一次访问 module._parameters["weight"](也就是常规 PyTorch 写法的 self.weight 背后)都会过这个 __getitem__,状态是 NOT_AVAILABLE 就触发一次 all-gather。

这是为什么:

5.13 NCCL 4-byte alignment 与 padding

NCCL 的 collective 要求每张卡上的张量首地址按 4 字节对齐。BF16 / FP16 单元素 2 字节,所以"4 字节 = 2 个 element"。bf16_optimizer.py 第 87 行硬编码了:

self.nccl_start_alignment_factor = 2   # 4-byte / sizeof(bf16)

切分参数时如果某一片不是 2 的整数倍,DS 会在末尾 padding 几个元素,存到 self.group_paddings。这就是为什么 ZeRO 切分后单卡 partition 看起来"比 ⌈Ψ/N⌉ 多几个元素"——不是 bug,是 NCCL 要的对齐。

5.14 进阶:ZenFlow(选择性梯度更新)

ZenFlow 是 2025-10 引入的 stall-free offload 方案。核心 idea:CPU offload 真正的瓶颈不是带宽,而是GPU 等 CPU optimizer step 完才能走下一步。ZenFlow 用 top-k 选择把"重要梯度"留在 GPU 立刻更新,剩下的丢给 CPU 异步累积。代码在 deepspeed/runtime/zenflow/

关键配置zenflow_config.py 第 12 行附近的 ZenFlowConfig):

字段含义
topk_ratio 留在 GPU 立即更新的梯度比例,默认 0.1
select_strategy "auto" / "step" / "epoch"——多久重新选一次 top-k 索引
select_interval 选择间隔(与 strategy 配合)
update_interval CPU 批量回收更新的间隔
overlap_step True → 用 ZenFlowZeroOptimizerParallel(CPU 更新与 forward 并发);False → Sequential 工厂
offload 是否真把 CPU 备份偏移到 disk
full_warm_up_rounds 训练初期跑几个 "选择性失效" 的 full update,先校准
pt_reserved_cores_perc留给 PyTorch 的 CPU 核数百分比,默认 0.5

两种工厂在 zenflow_stage_1_and_2.py:82ZenFlowZeroOptimizer.create() 里分发:Sequential 简单稳定,Parallel 更快但要求 PyTorch ≥ 2.1。前置:必须开 cpu_offload=true,ZenFlow 才有意义。

5.15 进阶:SuperOffload vs ZeRO-Infinity 的本质区别

初看 SuperOffload 像是"ZeRO-Infinity 的升级版",实际定位完全不同:

维度ZeRO-InfinitySuperOffload
把什么搬到 CPU/NVMe 内存(参数/梯度/优化器状态)内存 + 优化器计算
GPU 在 step 时干什么 等:CPU 算完 optimizer step,把更新后的参数搬回不等:GPU 在 worker 进程算 step 的同时继续下一个 micro batch 的 forward/backward
IPC 机制 无(同进程内 H↔D 拷贝)跨进程 torch.multiprocessing.SimpleQueue
典型硬件 4090 / 老 V100 等"硬件不够先续命"场景Grace Hopper 等"CPU 内存巨大、PCIe 慢、用 worker 隐藏延迟"

源码在 deepspeed/runtime/superoffload/superoffload_stage3.py:27SuperOffloadOptimizer_Stage3 继承自 DeepSpeedZeroOptimizer_Stage3,只重写了 _optimizer_step() —— 不直接调 cpu_adam,而是把任务发到 worker 进程。superoffload_utils.py:19–36 定义 IPC 协议的 TaskKeys / ResultKeys / EventTypes。预分配 pinned buffer 通过 max_grad_numel 估算(避免运行时再 alloc)。

启用前置:

5.16 调 stage / 调字段的经验法则

  1. 能 ZeRO-1 不 ZeRO-2,能 ZeRO-2 不 ZeRO-3 —— stage 越高通信越重;
  2. ZeRO-3 必须开 overlap_comm: true
  3. 通信慢的网络(千兆以下)尽量不用 ZeRO-3;
  4. 显存边界一旦突破,立即降一档 batch / 升一档 stage / 加 offload,不要恋战;
  5. 能用 auto 字段就用 "auto",让 HF Trainer 或框架去填,减少自己出错。