sequenceDiagram
autonumber
participant DP_Ranks as DP 进程组 (Rank 0..N-1)
participant SMem as 本地切片显存 (1/N 参数)
participant TMem as 临时全量缓冲区 (全量参数)
Note over DP_Ranks,TMem: 前向传播 (Forward Pass: 单层即用即弃)
DP_Ranks->>TMem: AllGather 广播拼合当前 Layer l 全量参数
TMem->>TMem: 本地输入微批次执行前向矩阵乘与激活
TMem-->>TMem: 计算完毕,立即释放临时全量参数 (显存回落)
Note over DP_Ranks,TMem: 反向传播 (Backward Pass: 逆向二次拉取)
DP_Ranks->>TMem: 二次 AllGather 拼合 Layer l 全量参数
TMem->>TMem: 结合前向激活计算输入激活梯度与局部参数梯度
TMem-->>TMem: 参数使用完毕立即释放
TMem->>SMem: ReduceScatter 将梯度归约分片写回本地切片槽位
第 5 课:ZeRO 与 FSDP
参考答案版:包含完整代码实现与问答题深度参考解析。建议先独立完成练习题后再展开核对。
代码 4 分、Q1~Q3 各 2 分,通过线 8/10。必须解释正确性边界、显存/通信公式中的单位与分片维度;未实际运行的内容只能标记为静态审查。
- 对应官方章节:Zero Redundancy Optimizer (ZeRO)
- 前置:第 4 课(DP、all-reduce/reduce-scatter/all-gather)
- 状态:未开始
本课目标
完成后你需要能够:
- 推导 ZeRO-1/2/3 的每卡静态显存公式并解释每项来源。
- 计算各阶段每步的通信量(2Ψ 与 3Ψ)并说明它们从哪里来。
- 解释为什么激活不能被 ZeRO 分片。
- 说明 FSDP 就是 ZeRO-3 的 PyTorch 实现,以及 prefetch 如何隐藏参数 all-gather。
- 对比 ZeRO-3 与流水线并行(第 9 课展开细节)。
核心概念
1. 显存状态冗余的根源与 ZeRO 的核心洞见
在朴素数据并行(DDP)架构中,为了实现各 Rank 间前向与反向计算的完全解耦,整个集群在 张显卡上各自保存了一份完整的模型状态镜像。这构成了极大的存储冗余:
- 静态状态全量复制:每张显卡都必须同时持有 参数、 梯度以及 优化器状态(FP32 Master 权重、动量、方差),总计达 字节(以 BF16 + Adam 为例)。
- 扩展性悖论:随着训练集群从 8 卡扩展到 1024 卡,虽然算力呈线性增长,但每张卡上的静态显存开销完全没有任何下降。对于 70B 模型,单卡仅静态状态就需要 ,在任何现有机型上都会直接 OOM。
微软提出的 ZeRO(Zero Redundancy Optimizer) 技术提出了一个颠覆性的见解:既然各 Rank 本质上共同维护全局模型,那么完全可以沿数据并行(DP)维度将这些状态分片切分,用时动态重构,从而彻底消除冗余!
如图 5-1 与图 5-2 所示,ZeRO 将模型状态拆解为三个逐级深入的消除阶段:
图 5-1:ZeRO-3 (FSDP) 每训练步动态拉取与即时释放通信流转拓扑
2. ZeRO 阶段 1、2、3 的显存拆解与数学推导
设全模型参数量为 ,数据并行规模为 。在经典的 BF16/FP16 混合精度与 AdamW 优化器配置下(状态乘数 :4 字节 FP32 Master 权重 + 4 字节一阶动量 + 4 字节二阶方差 ):
ZeRO-0(朴素 DDP):无任何状态分片,每张卡承担全量开销:
ZeRO-1(优化器状态分片,):
- 优化器的动量和方差以及 FP32 Master 权重按 均分到各 Rank。
- 每张卡依然保留全量 BF16 参数与全量 BF16 梯度:
- 当 时,优化器状态显存从 骤降至 ,显存从 降至约 (节省约 74% 显存)。
ZeRO-2(优化器状态 + 梯度分片,):
- 在 ZeRO-1 的基础上,进一步将梯度切片。
- 反向传播计算过程中,各层梯度通过 Reduce-Scatter 同步后,每个 Rank 仅保留属于自己负责的 梯度切片,其余 份梯度被立刻释放:
- 当 时,显存进一步压缩至约 。
ZeRO-3(优化器状态 + 梯度 + 参数全分片,,即 PyTorch FSDP):
- 连前向与反向所需的模型参数本身也彻底分片!
- 每张卡常驻显存中仅持有 的参数切片。仅在计算某一层的前向或反向时,通过 All-Gather 临时动态拼出全量参数,计算完毕后立即销毁释放:
- 当 时,静态模型显存理论上趋近于 0(实际受限于单层参数峰值)。
注:若开启 FP32 梯度累积,ZeRO-1 因梯度全量驻留需额外增加 ;而 ZeRO-2/3 因梯度分片仅需额外增加 。
flowchart LR
subgraph DDP["传统 DDP: 无分片 (16Ψ)"]
direction TB
W0["模型参数 2Ψ (全量常驻)"]
G0["模型梯度 2Ψ (全量常驻)"]
O0["Adam 优化器 12Ψ (全量常驻)"]
end
subgraph ZeRO1["ZeRO-1: 优化器分片 (4Ψ + 12Ψ/Nd)"]
direction TB
W1["模型参数 2Ψ (全量常驻)"]
G1["模型梯度 2Ψ (全量常驻)"]
O1["优化器状态: 12Ψ / Nd (各卡切片)"]
end
subgraph ZeRO2["ZeRO-2: 梯度+优化器分片 (2Ψ + 14Ψ/Nd)"]
direction TB
W2["模型参数 2Ψ (全量常驻)"]
G2["模型梯度: 2Ψ / Nd (ReduceScatter 分片)"]
O2["优化器状态: 12Ψ / Nd (各卡切片)"]
end
subgraph ZeRO3["ZeRO-3: 参数全分片 (16Ψ/Nd)"]
direction TB
W3["参数: 2Ψ / Nd (按需 AllGather,即用即弃)"]
G3["梯度: 2Ψ / Nd (反向 ReduceScatter)"]
O3["优化器状态: 12Ψ / Nd (就地更新)"]
end
DDP -->|消除优化器冗余| ZeRO1
ZeRO1 -->|消除反向梯度冗余| ZeRO2
ZeRO2 -->|消除前向参数冗余| ZeRO3
图 5-2:ZeRO-1/2/3 显存状态逐级分片消除机制演进拓扑
图 5-3:ZeRO 各阶段单卡显存占用对比(7B 模型 8 卡环境)
3. 通信账本严密推导:为什么 ZeRO-1/2 是 ,而 ZeRO-3 是
许多工程师误以为分片必然导致网络通信量剧增,但严谨的集合通信分析揭示了惊人的结论:
ZeRO-1 与 ZeRO-2 的通信等价性(总通信量 = )
朴素 DDP:每个 step 反向传播结束后,发射一次全局 AllReduce 同步全量梯度。通信量为:
ZeRO-1/2:
- 反向传播产出各层梯度后,系统不再发射 AllReduce,而是发射 Reduce-Scatter。每个 Rank 只接收并聚合自己所负责的 梯度切片。其通信量恰好为 AllReduce 的一半:
- 各卡使用自己的 梯度切片,在本地独立更新各自持有的 FP32 优化器状态与 Master 权重,并将其就地转换为 BF16 格式。此时每张卡拥有更新后的 模型参数。
- 系统发射一次 All-Gather 算子,在所有卡之间互通有无,将各自的 新参数拼回全量模型参数副本。其通信量同样为:
- 总通信量计算:
- 关键结论:ZeRO-1 与 ZeRO-2 的总通信量与朴素 DDP 完全一致!且 ZeRO-2 比 ZeRO-1 额外节省了近 的梯度显存,却没有任何额外的网络传输开销。因此在工程实践中,ZeRO-2 总是无条件优于 ZeRO-1。
ZeRO-3(FSDP)的通信代价(总通信量 = )
ZeRO-3 为了将参数显存从 砍到 ,付出了额外的通信代价:
前向传播(All-Gather 参数):由于每卡仅存 参数,在计算第 层前向之前,必须先对该层权重执行一次 All-Gather,瞬时拼出完整的参数张量。整网前向累计通信量为: 该层前向计算完毕后,非本地参数被立即释放,显存回落。
反向传播(再次 All-Gather 参数):反向传播链式求导必须再次用到前向时的权重张量 。因此在反向逐层推进时,必须再次对第 层执行 All-Gather。整网反向参数通信量为: 计算完该层的输入导数与权重导数后,非本地参数再次被立即丢弃。
反向传播(Reduce-Scatter 梯度):计算出的梯度立即执行 Reduce-Scatter,各卡只留属于自己的 梯度切片。通信量为:
优化器更新(零通信):各卡直接在本地更新属于自己的 状态,没有任何网络通信。
总通信量计算:
ZeRO-3 的通信量相比 DDP / ZeRO-2 增加了 50%(从 升至 )。
4. 异步流水预取(Prefetching)与重叠原理
如图 5-2 的执行全景所示,PyTorch FSDP(ZeRO-3)在实际运行时依靠专用的 CUDA Stream 建立流水线,尽可能隐藏这额外的通信:
图 5-2:ZeRO-3 阶段 1(前向)、阶段 2(反向)、阶段 3(优化器更新)分块状态与通信算子流转全景
- 前向预取(Forward Prefetch):当 GPU 计算流在第 层执行 GEMM 矩阵乘时,通信流已经提前在后台对第 层参数发射异步
All-Gather。 - 反向预取(Backward Prefetch):当 GPU 计算流在计算第 层的反向导数时,通信流一方面异步对第 层的梯度发射
Reduce-Scatter,另一方面并行对上一层(Layer )的参数发射All-Gather预取。 - 重叠成功的先决条件:单层的计算时间必须大于通信时间()。如果网络总线较慢(如跨节点 PCIe 或以太网),或者 过大导致 Ring Latency 累积,计算流将被迫空转等待参数到齐,训练吞吐大幅滑坡。
5. 为什么激活显存(Activation)无法被 ZeRO 分片?
这是一个极为经典的面试陷阱:ZeRO-3 能否解决长文本(如 32k/128k 上下文)带来的显存爆炸?
答案是:绝对不能! - 状态的语义差异:ZeRO 的本质是消除数据并行副本间的“重复信息(Redundancy)”。模型参数、优化器状态在各 DP Rank 之间原本是完全相同的,所以切片后可以通过集合通信按需重构。 - 激活的独占性:每个 DP 节点处理的是互不重叠的 Micro-Batch 数据。Rank 0 上的输入样本与 Rank 1 上的输入样本完全不同,因此产生的中间层激活值 也各不相同。它们之间没有任何冗余可供消除! - 长序列的内存归宿:在长文本或大 Batch 场景下,激活显存随序列长度 呈二次方增长()。在 ZeRO-3 消除模型静态状态后,显存瓶颈将完全转移至激活显存。必须结合第 2 课的选择性激活重计算、第 6 课的张量并行(TP)以及第 8 课的序列/上下文并行(CP),才能彻底解开激活显存的束缚。
6. 工程选型准则:FSDP 与扩展边界
单层参数上限约束:ZeRO-3(FSDP)在执行某一层的计算时,必须临时容纳该层的全量参数与全量激活。如果单层的参数量本身就超过了单卡显存(例如某些超大 MoE 层的专家权重),ZeRO-3 将彻底无能为力,必须借助张量并行(TP)在机内切分单层矩阵。
512 卡扩展性红线:在大规模集群中,当 时,跨节点频繁发射小层的 All-Gather 与 Reduce-Scatter 会使通信延迟项 剧烈放大,Prefetch 无法完全掩盖通信开销。业内主流做法是采用 FSDP / ZeRO-3 与 TP / PP 构成的 3D 混合并行。
具体演示与数值推演
以一个 7B 模型( 参数)为例,在 BF16 混合精度与 AdamW 优化器()配置下,无 FP32 梯度累积时,各阶段单卡静态显存(以 GB 计,换算系数 )对比:
| 数据并行度 | ZeRO-0 (朴素 DP) | ZeRO-1 (分片优化器) | ZeRO-2 (分片优化器+梯度) | ZeRO-3 (全分片 FSDP) | 单步通信量 |
|---|---|---|---|---|---|
| (单卡基准) | |||||
| (单机 8 卡) | |||||
| (8 节点集群) |
- 公式验算:
- 时:
- ZeRO-1:
- ZeRO-2:
- ZeRO-3:
- 时:
- ZeRO-3 静态显存仅需 ,使得在 24GB 消费级显卡(如 RTX 4090)上微调 70B 大模型成为可能。
- 通信量对比:(BF16 下为 通信),(BF16 下为 通信)。
代码填空题
补全以下程序:各阶段每卡静态显存与通信量计算,并生成上表。
Code
def zero_memory_per_gpu(
num_params: int,
dp: int,
stage: int, # 0=朴素DP, 1, 2, 3
k: int = 12, # Adam 混合精度优化器状态乘数(4 master + 4 m + 4 v)
fp32_grad_accum: bool = False,
) -> float:
"""
返回每个 GPU 的静态显存(bytes)。
公式(BF16 参数/梯度 + FP32 master weights + FP32 Adam 状态):
ZeRO-0: 2Ψ + 2Ψ + kΨ
ZeRO-1: 2Ψ + 2Ψ + kΨ/dp
ZeRO-2: 2Ψ + (2Ψ + kΨ)/dp
ZeRO-3: (2Ψ + 2Ψ + kΨ)/dp
可选 FP32 梯度累积:
ZeRO-1 的梯度不分片,额外加 4Ψ;
ZeRO-2/3 梯度分片,额外加 4Ψ/dp。
"""
if num_params <= 0 or dp <= 0:
raise ValueError("num_params and dp must be positive")
if stage not in (0, 1, 2, 3):
raise ValueError("stage must be 0..3")
params_bf16 = 2 * num_params
grads_bf16 = 2 * num_params
opt_fp32 = k * num_params
extra = 0.0
if fp32_grad_accum:
extra = 4 * num_params if stage <= 1 else 4 * num_params / dp
if stage == 0:
per_gpu = params_bf16 + grads_bf16 + opt_fp32 + extra
elif stage == 1:
per_gpu = ______ # 填空:优化器状态分片
elif stage == 2:
per_gpu = ______ # 填空:优化器状态 + 梯度分片
else:
per_gpu = ______ # 填空:三者全分片
return per_gpu
def zero_communication_volume(num_params: int, stage: int) -> int:
"""
每训练步每卡通信量(数值个数)。
ZeRO-1/2: reduce-scatter(Ψ) + all-gather(Ψ) = 2Ψ
ZeRO-3: forward all-gather(Ψ) + backward all-gather(Ψ)
+ reduce-scatter(Ψ) = 3Ψ
"""
if stage in (1, 2):
return ______
if stage == 3:
return ______
if stage == 0:
return 2 * num_params # 朴素 DP:一次全量 all-reduce ≈ 2Ψ
raise ValueError("stage must be 0..3")
def make_table():
"""生成 7B 模型各 DP 度下的每卡静态显存表(GB)。"""
P = 7_000_000_000
print(f"{'dp':>4} {'Z1 GB':>8} {'Z2 GB':>8} {'Z3 GB':>8} {'comm 2Ψ/3Ψ (G)':>16}")
for dp in (1, 8, 64):
z1 = zero_memory_per_gpu(P, dp, 1) / 1e9
z2 = zero_memory_per_gpu(P, dp, 2) / 1e9
z3 = zero_memory_per_gpu(P, dp, 3) / 1e9
comm = zero_communication_volume(P, 3) / 1e9
print(f"{dp:>4} {z1:>8.1f} {z2:>8.1f} {z3:>8.1f} {comm:>16.1f}")
if __name__ == "__main__":
make_table()
# 交叉验证:dp=1 时三档必须相等
P = 1_000_000
assert zero_memory_per_gpu(P, 1, 1) == zero_memory_per_gpu(P, 1, 2) == zero_memory_per_gpu(P, 1, 3)
print("dp=1 时三档相等 ✓")三个问答题
Q1
为什么 ZeRO 不能分片激活?请从”冗余”的定义出发解释。既然不能分片激活,训练超长序列时 ZeRO-3 会把内存瓶颈留在哪里?第 2 课的哪些技术与它正交?
Q2
ZeRO-1 为什么把朴素 DP 的一次 all-reduce 换成 reduce-scatter + all-gather,而总通信量不变(都是 2Ψ)?“reduce-scatter 比 all-reduce 快两倍”在这里意味着什么?ZeRO-2 相比 ZeRO-1 又省了什么、通信为什么不变?
Q3
ZeRO-3 的 3Ψ 通信量由哪三部分组成?prefetch 为什么能隐藏其中两部分?为什么 DP 规模超过约 512 后这套隐藏会失效?结合第 4 课 Q3,说明”ZeRO-3 让模型装得下”与”ZeRO-3 让训练快”是两回事。
检查与通过标准
总分 10 分:代码正确 4 分(三档公式、FP32 累积分支、通信量、dp=1 交叉验证)、三题各 2 分、通过线 8 分。
一票否决项:
- 把 ZeRO 各档的分片对象搞错(如 ZeRO-2 分片了参数)。
- 说 ZeRO-3 激活也被分片。
- 通信量算错(不知道 2Ψ/3Ψ 从哪来)。
- 认为 ZeRO-3 的 N_d→∞ 可以把总显存(含激活与运行时)降到任意低。