ZeRO 三阶段拆解:到底分了什么、什么时候 all-gather
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 时序
Stage-1 只切优化器状态,所以 reduce-scatter 的对象不变,只是 optimizer.step 时只动自己那片 m/v/master。
5.4 ZeRO-3 时序(最复杂)
所以 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 桶大小 | 5e8 或 auto |
zero_optimization.allgather_bucket_size | all-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_save | save 时 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 | 97 | submodule 访问序号;trace_prologue 用它比对录像 |
__trace_mode | 99 | 三态枚举值 |
__most_recent_step_id_param_fetched_for | 103 | 同一 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 | 113 | prefetch 批量大小(对应 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。
这是为什么:
- 你的 forward 完全不用知道自己在 ZeRO-3 下;
- 但写"外部参数"(一个 module 里访问另一个 module 的参数)时必须调
register_external_parameter(consumer_module, param)——否则 Coordinator 不知道该 prefetch 它,第一次访问会同步 all-gather,触发 INVALID trace; - HF Trainer 在保存 ZeRO-3 模型时之所以要
stage3_gather_16bit_weights_on_model_save,是因为它要绕过__getitem__直接拿完整参数。
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:82 的 ZenFlowZeroOptimizer.create() 里分发:Sequential 简单稳定,Parallel 更快但要求 PyTorch ≥ 2.1。前置:必须开 cpu_offload=true,ZenFlow 才有意义。
5.15 进阶:SuperOffload vs ZeRO-Infinity 的本质区别
初看 SuperOffload 像是"ZeRO-Infinity 的升级版",实际定位完全不同:
| 维度 | ZeRO-Infinity | SuperOffload |
|---|---|---|
| 把什么搬到 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:27 的 SuperOffloadOptimizer_Stage3 继承自 DeepSpeedZeroOptimizer_Stage3,只重写了 _optimizer_step() —— 不直接调 cpu_adam,而是把任务发到 worker 进程。superoffload_utils.py:19–36 定义 IPC 协议的 TaskKeys / ResultKeys / EventTypes。预分配 pinned buffer 通过 max_grad_numel 估算(避免运行时再 alloc)。
启用前置:
- 必须 ZeRO-3 +
offload_optimizer.device=cpu; - config 段加
"offload_optimizer": { ..., "super_offload": true, "cpuadam_cores_perc": 0.8 }(offload_config.py:96/99); - CPU 核心数要够——worker 进程会吃掉一部分 thread。
5.16 调 stage / 调字段的经验法则
- 能 ZeRO-1 不 ZeRO-2,能 ZeRO-2 不 ZeRO-3 —— stage 越高通信越重;
- ZeRO-3 必须开
overlap_comm: true; - 通信慢的网络(千兆以下)尽量不用 ZeRO-3;
- 显存边界一旦突破,立即降一档 batch / 升一档 stage / 加 offload,不要恋战;
- 能用 auto 字段就用
"auto",让 HF Trainer 或框架去填,减少自己出错。