数据并行、ZeRO 与 FSDP

数据并行(data parallelism,DP)是多卡训练里最先学到、也最常用的一维并行:每张卡放一份完整模型,各自吃不同的数据,反向之后把梯度平均一下。听起来一句话就讲完了,但真要把它用好,有一串问题要回答:梯度同步能不能和反向计算同时进行?同步应该按参数一个个发还是攒成一批?梯度累积的时候要不要每次都同步?卡多到什么程度会被网络卡住?每张卡都存一份完整的优化器状态是不是太浪费?

最后一个问题的答案是 ZeRO 和 FSDP:它们在不增加多少通信的前提下,把每张卡上的冗余状态去掉。用到的 AllReduce、ReduceScatter、AllGather 及其代价公式,推导见 集合通信与分片矩阵乘法。

1. 先把一步训练拆开

单卡上,一个训练步(step)分三段:

  1. 前向:输入一批样本,逐层计算,得到 loss,同时把反向需要的中间结果(激活)存下来;
  2. 反向:从最后一层往前,逐层算出 loss 对激活和对参数的梯度;
  3. 优化器步:用梯度更新参数,例如 Adam 要先更新一阶、二阶动量,再更新参数。

记模型参数量为 \(\Psi\)。在常见的 bf16 混合精度 + Adam 训练里,每个参数要存:bf16 参数 2 字节、bf16 梯度 2 字节、fp32 主参数 4 字节、fp32 一阶动量 4 字节、fp32 二阶动量 4 字节,一共 16 字节;如果梯度也在 fp32 里累积,再加 4 字节。这些和激活一起就是训练时的显存大头,细节见 训练的账本。

另一个要用到的概念是梯度累积(gradient accumulation)。一次前向+反向处理的样本数叫 micro-batch size(mbs),两次优化器步之间总共处理的样本数叫 global batch size(gbs)。如果一张卡一次只能放下 mbs 个样本,但我们想要更大的 gbs,就连续做 grad_acc 次前向+反向,把梯度累加起来(实践中取平均,好让结果与累积步数无关),再做一次优化器步:

$$\text{gbs} = \text{mbs} \times \text{grad\_acc}$$

梯度累积用时间换显存:激活只需要放一个 micro-batch 的量,但要多跑几次前向反向,而且需要一块在整个训练步内常驻的梯度累加缓冲。注意这几次前向反向之间互不依赖,只是输入样本不同——既然如此,为什么不把它们放到不同的卡上同时算?这就是数据并行。

2. 朴素数据并行

2.1 做法

有 \(N_d\) 张卡(DP degree,也直接叫 DP size)。每张卡上放一份完全相同的模型副本("model instance"),每个训练步:

  1. 把这一步的 batch 切成 \(N_d\) 份,每张卡拿一份 micro-batch;
  2. 每张卡独立地做前向和反向,得到各自不同的梯度 \(g_r\);
  3. 对梯度做一次 AllReduce(求和再除以 \(N_d\),即求平均),之后每张卡上的梯度都相同;
  4. 每张卡用相同的梯度做相同的优化器步,模型副本保持一致。
一个 global batch,切成 4 份 AllReduce:ḡ = (g₀ + g₁ + g₂ + g₃) / 4,结果写回每张卡
每张卡一份完整模型,吃不同的数据,反向得到不同的梯度,AllReduce 之后梯度一致,优化器步也就一致。

2.2 为什么结果和单卡大 batch 一样

设整个 batch 有 \(B\) 个样本,loss 是平均值 \(L = \frac{1}{B}\sum_{i=1}^{B}\ell_i\)。把样本均分到 \(N_d\) 张卡,第 \(r\) 张卡上的局部 loss 是 \(L_r = \frac{N_d}{B}\sum_{i\in S_r}\ell_i\),于是

$$\nabla L = \frac{1}{B}\sum_{i=1}^{B}\nabla\ell_i = \frac{1}{N_d}\sum_{r=1}^{N_d}\Big(\frac{N_d}{B}\sum_{i\in S_r}\nabla\ell_i\Big) = \frac{1}{N_d}\sum_{r=1}^{N_d}\nabla L_r .$$

每张卡算自己的 \(\nabla L_r\),平均一下,就精确等于大 batch 的梯度。这里假设每张卡上的样本数相同;如果 loss 按 token 平均而各卡 token 数不同,需要按 token 数加权,否则会有偏差。所以数据并行在数学上就是"并行版的梯度累积",不改变优化过程,只改变墙钟时间。

2.3 用分片记号写出来

为了把通信算清楚,我们把 Transformer 近似成一串 MLP 块(大模型里注意力只占 FLOPs 的一小部分;门控矩阵的行为和 \(W_\text{in}\) 一样,先忽略),每层两个矩阵 \(W_\text{in}[D, F]\) 和 \(W_\text{out}[F, D]\),输入 \(\text{In}[B, D]\),这里 \(B\) 是这一步的总 token 数。数据并行就是沿 mesh 轴 X(参与数据并行的那组卡)切 batch 维、权重完全复制:

$$\text{In}[B_X, D] \cdot_D W_\text{in}[D, F] \cdot_F W_\text{out}[F, D] \rightarrow \text{Out}[B_X, D]$$

前向里收缩维 \(D\)、\(F\) 都没被切,是上一篇里的"情形 1",完全不需要通信。反向时:

dOut[B_X, D]      = ...
dW_out[F, D]{U_X} = Tmp[B_X, F] ·_B dOut[B_X, D]    # 沿被切开的 B 收缩:部分和
dW_out[F, D]      = AllReduce(dW_out[F, D]{U_X})    # 不在关键路径上,可异步
dTmp[B_X, F]      = dOut[B_X, D] ·_D W_out[F, D]
dW_in[D, F]{U_X}  = In[B_X, D] ·_B dTmp[B_X, F]
dW_in[D, F]       = AllReduce(dW_in[D, F]{U_X})     # 同上
dIn[B_X, D]       = dTmp[B_X, F] ·_F W_in[D, F]     # 传给前一层

权重梯度是沿 batch 维收缩出来的,而 batch 维被切开了,所以每张卡只得到一个"沿 X 未归约"的部分和 \(\{U_X\}\),需要 AllReduce,这就是上一篇的"情形 3"。关键的一点:这些 AllReduce 不在关键路径上。算前一层的 \(d\text{In}\) 只需要本地的 \(d\text{Tmp}\) 和 \(W\),不需要等 \(dW\) 同步完成。只要总通信时间不超过总计算时间,就可以完全藏在计算后面。张量并行就没有这个好性质。

2.4 一次 AllReduce 要多久

在 \(N\) 个 rank 上用环形算法 AllReduce 一个 \(V\) 字节的数组,每个 rank 收发 \(2\frac{N-1}{N}V\) 字节,带宽受限时

$$T_\text{AR} \approx \frac{2(N-1)}{N}\cdot\frac{V}{W},$$

其中 \(W\) 是每张卡(或每个节点)的单向出口带宽。H100 节点内 NVLink 是每卡 450 GB/s,跨节点每个节点出口是 8×400 Gbps = 400 GB/s。拿一个 8B 参数的模型来说,bf16 梯度 \(V = 16\) GB:节点内 8 卡约 \(2 \times \frac78 \times 16\text{e}9 / 450\text{e}9 \approx 62\) ms,跨节点约 \(2 \times 16\text{e}9/400\text{e}9 = 80\) ms(实际带宽通常只能跑到标称值的七八成)。藏不藏得住,取决于它和反向计算时间的比(第 7 节)。

2.5 朴素实现的问题

最朴素的写法是等反向全部结束,拿到所有梯度,再发起一次 AllReduce,结束后做优化器步。这样计算和通信是串行的:AllReduce 的那几十毫秒里 GPU 的计算单元完全闲着,而且每一步都要白等这么久。DDP 用三个办法解决这件事:重叠、分桶、与梯度累积配合。

3. 优化一:梯度同步与反向重叠

反向是从最后一层往前算的。最后一层的梯度一算完,它就已经是最终值了,后面再怎么算前面的层也不会改变它。所以没必要等所有层算完:某一层的梯度一就绪,就可以立刻开始对它做 AllReduce,同时 GPU 继续算更前面的层。

4 张卡同时从 L6 往 L1 做反向,每两层的梯度凑成一个桶。桶一满就在环上做 AllReduce,同时继续算前面的层;只有最后一个桶的通信露在反向之后。下方虚线是朴素做法:反向全部结束才开始同步(时间为示意)。

PyTorch 里的实现方式是给每个参数挂一个 hook,梯度累加完就触发:

def register_backward_hook(self, hook):
    """给所有需要梯度的参数注册 hook。"""
    for p in self.module.parameters():
        if p.requires_grad is True:
            p.register_post_accumulate_grad_hook(hook)

# hook 里对 p.grad 发起异步 AllReduce(async_op=True),
# 把返回的 work handle 存起来;优化器步之前统一 wait()。

通信 kernel 跑在单独的 CUDA stream 上,与计算 kernel 并发执行。"计算与通信重叠"这个思路,在张量并行、流水线并行、上下文并行里还会反复出现。

4. 优化二:梯度分桶

按参数逐个 AllReduce 有个问题:一个模型有成百上千个参数张量,很多很小(LayerNorm 的权重、偏置),每次集合通信都有固定开销——kernel 启动、同步、环上每一跳的延迟。上一篇的 α-β 模型里,环形 AllReduce 的时间是

$$T = 2(N-1)\,\alpha + \frac{2(N-1)}{N}\cdot\frac{V}{W}.$$

消息越小,延迟项占比越大;两项相等时 \(V^\ast = N\alpha W\)。量级上,8×H100 节点内 AllReduce 一个很小的张量,实测要 16 µs 左右;这段时间够 NVLink 传 7 MB 的数据。一个 4 kB 的 LayerNorm 权重单独发一次,几乎全是固定开销。再加上 GPU 上无论计算还是通信,大张量都比很多小张量高效。所以做法是:把若干个参数的梯度攒到一个"桶"(bucket)里,桶满了才发起一次 AllReduce。就像寄东西,几个大箱子比一堆小包裹省事。PyTorch DDP 默认的桶大小是 25 MB(bucket_cap_mb)。

桶也不是越大越好:桶越大,第一个桶要等越久才凑满,而且最后一个桶是在反向完全结束后才凑满的,它的通信一定露在外面。极端情况下桶 = 整个模型,就退化成了朴素的串行实现。

交互演示 · 反向计算与分桶 AllReduce 的时间线
模型: 带宽:
反向计算桶的 AllReduce竖线 = 反向结束
反向计算
通信总时长
桶个数
暴露的通信

每层 \(16h^2\) 个参数(注意力 \(4h^2\) + 中间维 \(4h\) 的 GLU MLP \(12h^2\),忽略 embedding 和 GQA),bf16 梯度每参数 2 字节。反向 FLOPs = \(4 \times \text{tokens} \times \text{参数量}\),按 H100 稠密 bf16 峰值 989 TFLOP/s、50% 利用率换算,并假设梯度在反向过程中匀速产生。每个桶的通信时间 = α′ + \(2\frac{N-1}{N}\frac{V}{W}\)。α′ 默认 16 µs,是 8×H100 节点内小消息 AllReduce 的实测耗时(NCCL ring / LL 协议);跨 2 个、4 个节点时实测约 25、35 µs。

拖一拖可以看到几种状态:桶很小时(几十 kB),桶的个数成千上万,固定开销累积起来让通信总时长远超带宽项;桶很大时(几 GB),只有一两个桶,最后一个桶整个露在外面;中间有一段宽的"甜区",通信几乎全部藏进反向里,只剩最后一个小桶的尾巴。再把每卡 token 数调小,反向计算变短而通信量不变(梯度大小只取决于参数量),无论桶怎么分都藏不住了——这就是通信瓶颈(第 7 节)。

实现上的细节。集合通信要求张量在内存里连续,否则就要先拷贝。所以 DDP 之类的实现会预先分配与桶对应的连续缓冲区,反向时直接把梯度写进去(或把参数的 .grad 设成缓冲区的视图)。这能加速通信,但这块缓冲区本身也计入峰值显存。

5. 优化三:与梯度累积配合

数据并行和梯度累积可以叠加使用:每张卡连续做几次 micro-batch 的前向反向,累加梯度,最后做一次同步和优化器步。问题是第 3 节的 hook 会在每一次反向时都触发 AllReduce。中间那几次同步完全是浪费:最终需要的只是累加完之后的梯度和,在最后一次反向时同步一次效果相同,开销却只有 1/grad_acc。

PyTorch 的做法是用 model.no_sync() 上下文管理器在不需要同步的反向里关掉 hook:

for i, batch in enumerate(micro_batches):
    last = (i == len(micro_batches) - 1)
    ctx = contextlib.nullcontext() if last else model.no_sync()
    with ctx:
        loss = model(batch).loss / len(micro_batches)
        loss.backward()        # 只有最后一次触发梯度 AllReduce
optimizer.step()
optimizer.zero_grad()

6. 重新计算全局 batch size

加上数据并行,全局 batch size 变成三个因子的乘积:

$$\text{gbs} = \text{mbs} \times \text{grad\_acc} \times \text{dp}$$

在 LLM 里我们更常用 token 数来计 batch:\(\text{gbs}_\text{tokens} = \text{gbs} \times \text{seq\_len}\)。给定一个目标 gbs,grad_acc 和 dp 可以互换:dp 翻倍、grad_acc 减半,gbs 不变。两者的区别在于数据并行是真并行,梯度累积是串行的,所以实践中尽量把 dp 开大,只有在卡不够用、dp 达不到目标 gbs 时才用梯度累积补齐。

6.1 一个配置流程

实际搭一个只用数据并行的训练,可以按这个顺序定参数:

  1. 先确定最优的全局 batch size(以 token 计):查文献,或者跑实验看收敛;
  2. 再确定训练序列长度:同样查文献或做实验。主预训练阶段一般取 4k–8k token(DeepSeek-V3 用 4K,Llama 3 405B 绝大部分时间用 8K),长上下文通常在训练末期再扩:网上比这更长的文档本来就很少,而注意力的开销随序列长度增长;
  3. 于是知道了 gbs(样本数)。在单卡上不断增大 mbs 直到显存放不下,得到最大 mbs;
  4. 根据可用卡数确定 dp,剩下的用 \(\text{grad\_acc} = \text{gbs}/(\text{mbs}\times\text{dp})\) 补齐。

举个例子:想要 gbs = 4M token、序列长度 4k,那么 gbs ≈ 1024 个样本(取最近的 2 的幂)。假设单卡只放得下 mbs = 2,手上有 128 张卡,那么 \(\text{grad\_acc} = 1024 / (2 \times 128) = 4\)。如果突然有了 512 张卡,就可以 mbs = 2、grad_acc = 1,同样的 gbs,一步快 4 倍。

如果算出来 grad_acc 小于 1,说明卡太多了(GPU-rich):可以不用完所有卡、试更大的 gbs,或者调小 mbs——后者以单卡效率为代价换总吞吐。

6.2 数据并行的天花板

dp 也不能无限加。环形 AllReduce 的延迟项随卡数线性增长,到了几百张卡(视网络而定,常见的量级是 512 张左右),信号绕环一圈的时间已经不可忽略,DP 通信不再能被完全重叠,吞吐开始下降。实测也是这样:超过某个规模后,再加 DP rank,每卡吞吐明显下降,而每卡显存一点不变。显存不变,是因为每张卡仍然存着完整的模型、梯度和优化器状态。

7. 什么时候被通信卡住

7.1 推导

继续用 2.3 节的 MLP 模型。设每张卡算力 \(C\)(FLOP/s),集合通信带宽 \(W\),DP 轴上 \(X\) 张卡,这一步全局 \(B\) 个 token。只看反向(前向没有通信)。

计算时间:每层反向有 4 个矩阵乘(每个矩阵的权重梯度和输入梯度各一个),每个 \(2 \cdot \frac{B}{X} \cdot D \cdot F\) FLOPs:

$$T_\text{math} = \frac{4 \cdot 2BDF}{X\,C} = \frac{8BDF}{XC}.$$

通信时间:每层两个权重矩阵,各 \(DF\) 个参数、bf16 下 \(2DF\) 字节,各做一次 AllReduce(代价是 2 × 字节数 / 带宽):

$$T_\text{comms} = 2 \cdot \frac{2 \cdot 2DF}{W} = \frac{8DF}{W}.$$

两者能重叠时,每层耗时 \(\approx \max(T_\text{math}, T_\text{comms}) = 8DF\cdot\max\!\big(\frac{B}{XC}, \frac{1}{W}\big)\)。计算受限的条件是 \(T_\text{math} \gt T_\text{comms}\):

$$\boxed{\ \frac{B}{X} \gt \frac{C}{W}\ }$$

左边是每张卡的 token 数,右边是硬件的"网络算术强度"——每通信一个字节,卡能算多少 FLOPs。\(D\)、\(F\) 都约掉了,模型多大都一样。直观上:计算量随每卡 batch 线性增长,通信量只取决于参数量、与 batch 无关;每卡 batch 太小,计算就盖不住通信。这和单卡 roofline 里"batch 要大于某个阈值才能计算受限"是同一个道理(见 Roofline 一篇)。

7.2 代入硬件

硬件 / 通信层级\(C\)\(W\)每卡至少多少 token
TPU v5p,一个 ICI 轴(双向带宽)4.59e141.8e11≈ 2550
TPU v5p,三个轴都做 DP4.59e143 × 1.8e11≈ 850
H100,节点内(NVLink)9.9e144.5e11≈ 2200
H100,跨节点(IB,每节点出口)9.9e144.0e11≈ 2475
H800,跨节点(瓶颈在节点内 NVLink)9.9e142.3e11≈ 4300

跨节点时,AllReduce 分两段:先在节点内走 NVLink,再在节点间走网卡,耗时取两段中慢的那段。H100 节点内每卡 450 GB/s,节点间每节点 400 GB/s,瓶颈在节点间。H800 的 NVLink 只有每卡单向 200 GB/s,节点内那段(\(V\cdot\frac78 / 200\text{e}9 = V/229\text{e}9\))反而比节点间(\(V/400\text{e}9\))慢,所以表里用 229 GB/s。拿几次公开的训练来对照:

7.3 两个修正

DP 很小时:环形 AllReduce 的精确代价带一个 \(\frac{X-1}{X}\) 因子,\(X=2\) 时只有一半。把它带进去,门槛变成 \(\frac{B}{X} \gt \frac{C}{W}\cdot\frac{X-1}{X}\)。跨节点时这个因子按节点数算:只跨 2 个节点,节点间那段只要 \(V/800\text{e}9\),瓶颈变回节点内的 NVLink(\(V\cdot\frac78/450\text{e}9 = V/514\text{e}9\)),H100 的门槛从 2475 降到约 \(9.9\text{e}14/5.14\text{e}11 \approx 1900\) token/卡。

MoE:每个 token 只激活 \(E\) 个 expert 里的 \(k\) 个,计算按 \(k\) 算,但每层要同步全部 \(E\) 个 expert 的梯度:

$$T_\text{math} = \frac{8kBDF}{XC},\quad T_\text{comms} = \frac{8EDF}{W}\ \Rightarrow\ \frac{B}{X} \gt \frac{E}{k}\cdot\frac{C}{W}.$$

门槛放大 \(E/k\) 倍。gpt-oss-120b 是 \(E=128,\ k=4\),在 H100 上跨节点需要 \(32 \times 2475 \approx 79{,}000\) token/卡;DeepSeek-V3 是 256 个 routed expert 选 8 个,同样是 32 倍,在 H800 上要 \(32 \times 4300 \approx 138{,}000\) token/卡,而它实际每卡只有约 31K token。所以 MoE 不能只靠数据并行同步 expert 的梯度,这是它要配合专家并行的原因之一。

7.4 换成整个模型来算

同一件事也可以不拆到每层,直接用整个模型来写:梯度总量 ≈ 参数量,反向计算 \(t_\text{compute} = 4\cdot\text{tokens}\cdot\Psi / \text{peak\_flops}\),按 bf16 每参数 2 字节,AllReduce 全部梯度 \(t_\text{comm} = 2\Psi \cdot 2\frac{N_d-1}{N_d} / \text{peak\_bw}\),于是

$$\frac{t_\text{comm}}{t_\text{compute}} = \frac{N_d-1}{N_d}\cdot\frac{\text{peak\_flops}}{\text{tokens}\cdot\text{peak\_bw}} \le 1 .$$

这里 tokens 指每卡的 token 数,和 7.1 节(带上 7.3 节的 \(\frac{N_d-1}{N_d}\) 修正)完全一致:参数量约掉了,只剩"每卡 token 数要大于 \(C/W\)"。

交互演示 · 数据并行 / FSDP 通信瓶颈计算器
硬件:
每卡 token B/X
临界值 (E/k)·C/W·(X−1)/X
每层反向计算
每层梯度通信
这个 batch 最多用几张卡

公式见 7.1–7.3:\(T_\text{math} = 8kBDF/(XC\,\cdot)\),\(T_\text{comms} = 8EDF\cdot\frac{X-1}{X}/W\)(TPU 双向环直接用 \(W_\text{bi}\cdot\)轴数)。假设完美重叠、峰值算力,所以是乐观的估计。MoE 时每层时间按 k = 1、E = E/k 显示,比值不变。"最多几张卡"由 \(B \gt \frac{E}{k}\frac{C}{W}(X-1)\) 解出。

7.5 这和"临界 batch size"有什么关系

上面的条件有一个反直觉的推论:卡数固定时,总 batch 越小,越容易被通信卡住。反过来,只要能不断加大 batch,DP / FSDP 就能扩到任意多的卡。问题在于 batch 不能无限加大:batch 大到一定程度,梯度噪声已经很小,再加大收益递减(这个拐点常被称为临界 batch size),有时还会不稳定。

所以在算力充足时,真实的问题往往是反过来的:batch size 由 scaling law 定死了,卡数也定了(很多),要找一种切分方式,让这么"小"的 batch 也能铺满这么多卡。举两个例子:DeepSeek-V2 的 batch 最大是 9216 条 4K 序列,约 38M token,在 TPU v5p 上三个轴都做 DP(850 token/卡),大约能铺到 44,000 张芯片;一个 16M token 的 batch(Llama 3 405B 最后阶段的大小)可以铺到约 18,800 张。再往上就需要换一种并行维度(张量并行、流水线并行),它们不靠 batch 维来分摊。

8. 数据并行的显存账

数据并行把激活按 batch 分摊到了各卡(每卡只存自己 micro-batch 的激活),这对激活占大头的训练很有帮助。但它对参数、梯度、优化器状态完全没有帮助:每张卡都存一整份。按第 1 节的计数,bf16 混合精度 + Adam,每参数 16 字节(不算 fp32 梯度累积):

$$\underbrace{2\Psi}_{\text{bf16 参数}} + \underbrace{2\Psi}_{\text{bf16 梯度}} + \underbrace{4\Psi + (4\Psi + 4\Psi)}_{\text{fp32 主参数 + Adam 动量}} = 16\Psi .$$

记优化器相关的那部分为 \(k\Psi\),Adam 下 \(k = 12\)。一个 7.5B 的模型就是 \(16 \times 7.5 = 120\) GB,单张 80 GB 的卡放不下,这还没算任何激活。一个好记的速算:光是 bf16 参数就是参数量 × 2 字节,70B → 140 GB。

就算省掉 fp32 主参数和梯度缓冲,只存 bf16 参数加 fp32 的 Adam 一阶、二阶动量(每参数 10 字节),纯数据并行能训练的最大模型也只有 HBM / 10:96 GB 的 TPU v5p 大约 9B。而这还是 batch 只有 1 个 token、不存任何激活时的绝对上界,实际远到不了。结论是:纯数据并行只适合几 B 以下的模型。要训更大的模型,至少要把参数或优化器状态切开。

切开有两条正交的路:一是"并行"——张量、上下文、流水线并行,把计算本身拆开;二是"分片"(sharding)——DeepSpeed ZeRO 或 PyTorch FSDP,计算仍按数据并行做,只是把各种状态分片存放、用时再取。两者可以组合。

9. ZeRO:把冗余去掉

ZeRO(Zero Redundancy Optimizer)的观察是:数据并行里每个 rank 存着完全相同的参数、梯度和优化器状态,这是纯粹的冗余。把它们沿 DP 维切成 \(N_d\) 份,每个 rank 只存 \(1/N_d\),需要完整数据时再通过通信临时拼回来。按切的范围分三级:

激活为什么不在列表里?每个 DP rank 吃不同的 micro-batch,激活本来就各不相同,不存在冗余,自然没什么可切的。

4 个 DP rank 上各自保存的内容(宽度按每参数字节数 2 / 2 / 12 画)。从 DP 到 ZeRO-3,优化器状态、梯度、参数依次切成 4 份,每张卡只留自己那 1/Nd;虚线框是 DP 下每卡的 16Ψ。

每张卡的显存(不含激活):

阶段每卡显存每步通信(单位:参数个数 Ψ)用到的集合通信
DP(ZeRO-0)\(2\Psi + 2\Psi + k\Psi\)\(2\Psi\)梯度 AllReduce
ZeRO-1\(2\Psi + 2\Psi + \frac{k\Psi}{N_d}\)\(2\Psi\)梯度 ReduceScatter + 参数 AllGather
ZeRO-2\(2\Psi + \frac{(2+k)\Psi}{N_d}\)\(2\Psi\)同上,梯度边算边 RS、边释放
ZeRO-3 / FSDP\(\frac{(2+2+k)\Psi}{N_d}\)\(3\Psi\)前向 AG + 反向 AG + 梯度 RS

如果在 ZeRO-2 / ZeRO-3 里用 fp32 做梯度累积,梯度项再加 \(\frac{4\Psi}{N_d}\)(在 DP 和 ZeRO-1 里是完整的 \(4\Psi\))。通信量这一列按每卡收发的元素数计:一次 ReduceScatter 或 AllGather 算 \(\Psi\),一次 AllReduce 算 \(2\Psi\)(都省略了 \(\frac{N-1}{N}\) 因子)。

9.1 ZeRO-1:切优化器状态

普通 DP 里,所有 rank 在梯度 AllReduce 之后拿着相同的梯度,各自做一模一样的优化器步——同样的计算做了 \(N_d\) 遍。ZeRO-1 让第 \(r\) 个 rank 只负责 \(1/N_d\) 的参数:只存这部分参数的 fp32 主参数和 Adam 动量,也只更新这部分。一个训练步变成:

  1. 前向:每个 rank 用同一份完整的 bf16 参数,算不同的 micro-batch;
  2. 反向:每个 rank 得到完整的(但各不相同的)梯度;
  3. 对梯度做 ReduceScatter:rank \(r\) 只拿到自己负责那 \(1/N_d\) 的梯度之和;
  4. 每个 rank 用本地的优化器状态更新自己那 \(1/N_d\) 的 fp32 参数,再转成 bf16;
  5. 对 bf16 参数做 AllGather,把别人更新好的那些片段拿回来,每个 rank 又有了完整的新参数。

显存从 \(16\Psi\) 降到 \(4\Psi + 12\Psi/N_d\)。通信呢?普通 DP 是一次 AllReduce;ZeRO-1 是一次 ReduceScatter 加一次 AllGather——而 AllReduce 本来就等于 ReduceScatter + AllGather。所以总通信量不变,只是 AllGather 从"梯度"挪到了"更新后的参数"上。ReduceScatter 本身只有 AllReduce 一半的代价。

新出现的 AllGather 也要想办法藏起来。两种思路:

这两种都需要精细地使用 hook 和分桶,自己实现不容易。实践中更省事的做法是直接用 PyTorch 原生的 FSDP,把 FSDP 单元设成整个模型(见 10.3 节),就能得到类似的效果。

9.2 ZeRO-2:再切梯度

ZeRO-1 里每个 rank 只用自己那 \(1/N_d\) 的梯度去更新,其他部分的梯度存着也没用。那就别存:反向时不做 AllReduce,直接对梯度做 ReduceScatter,每个 rank 只保留自己那份,其余的发出去后立刻释放。

显存变成 \(2\Psi + \frac{(2+k)\Psi}{N_d}\)。\(N_d\) 很大时趋于 \(2\Psi\),相对基线 \(16\Psi\) 最多省 8 倍。通信和 ZeRO-1 完全一样(梯度 RS + 参数 AG),也就和普通 DP 一样,区别只是边通信边释放内存。如果用 fp32 做梯度累积,只需要为自己那 \(1/N_d\) 保留 fp32 梯度,用来累加 ReduceScatter 送来的 bf16 梯度。

ZeRO-2 相对 ZeRO-1 基本没有额外开销,只是实现更复杂一些,所以通常 ZeRO-2 是更好的选择。

9.3 ZeRO-3 / FSDP:连参数也切

最后一步是把 bf16 参数也切了,每个 rank 只常驻 \(1/N_d\) 的参数。前向和反向需要某一层的完整参数时,临时 AllGather 过来,用完立刻丢掉:

ZeRO-3 一个训练步里每层的通信:前向一次 AllGather,反向一次 AllGather 加一次 ReduceScatter。每层的完整参数只在用到它的那段时间存在;预取(prefetch)让下一层的 AllGather 与当前层计算重叠(时间为示意)。

通信量:前向 AllGather 全部参数一遍,\(\Psi\);反向再 AllGather 一遍,\(\Psi\);梯度 ReduceScatter,\(\Psi\)。合计 \(3\Psi\),是 DP / ZeRO-2 的 \(2\Psi\) 的 1.5 倍。次数上,ZeRO-3 每一步要做 \(2L - 1\) 次逐层的 AllGather(\(L\) 为层数):前向每层一次;反向本来也是每层一次,但反向最先用到的是最后一层,它的参数刚在前向里用完,实现上会留着不释放、直接复用,于是反向只需 \(L-1\) 次。每次 AllGather 都带一段固定延迟,层数多、每层小的时候这笔开销不能忽略。

预取:多出来的通信听起来很多,但它可以藏起来。前向算第 \(n\) 层时,就开始 AllGather 第 \(n+1\) 层的参数;反向算第 \(n\) 层时,AllGather 第 \(n-1\) 层。只要单层的计算时间大于单层参数的 AllGather 时间,就藏得住(10.2 节会算这个条件)。和普通 DP 一样,DP 规模到 512 左右以后,延迟开始让重叠失效。

显存:\(\frac{(2+2+k)\Psi}{N_d}\),原则上 \(N_d\) 越大越省,可以无限降下去——但只限于模型相关的状态。外加两层临时的完整参数(正在用的和正在预取的)。激活一点没减少,那要靠激活重计算和梯度累积。

9.4 用计算器验证

ZeRO 论文给过一个表:\(\Psi = 7.5\text{B}\)、\(k = 12\)、\(N_d = 64\) 时,每卡显存基线 120 GB,ZeRO-1 31.4 GB,ZeRO-2 16.6 GB,ZeRO-3 1.88 GB。计算器的默认值就是这组参数。

交互演示 · ZeRO 每卡显存计算器
显存:
bf16 参数bf16 梯度fp32 梯度优化器状态(kΨ)竖线 = 单卡显存
各阶段相对基线
ZeRO-3 每步通信 / DP1.5×
放得下的最高"最低阶段"

按 9 节表格的公式计算,GB = 10⁹ 字节,不含激活、临时缓冲、通信缓冲和显存碎片;实际还要留出大量余量。k = 12 对应 bf16 混合精度 + Adam(fp32 主参数 + 两个动量)。

拖动时注意几件事:ZeRO-1、ZeRO-2 在 \(N_d\) 增大时很快饱和在 \(4\Psi\)、\(2\Psi\),因为它们没切的部分不随 \(N_d\) 下降;只有 ZeRO-3 一直按 \(1/N_d\) 下降。70B 模型在 64 卡上 ZeRO-2 也要 140 GB 以上,必须上 ZeRO-3 或者别的并行。

10. 用分片记号看 FSDP

用上一篇的分片记号,FSDP 就是:权重沿同一个 DP 轴 X 切开(切的是 \(D\) 维),激活照旧沿 batch 维切开:

$$\text{In}[B_X, D] \cdot_D W_\text{in}[D_X, F] \cdot_F W_\text{out}[F, D_X] \rightarrow \text{Out}[B_X, D]$$

这时 \(\text{In}[B_X, D]\cdot W_\text{in}[D_X, F]\) 是"一边的收缩维被切"(情形 2),而且 \(B_X\) 和 \(D_X\) 用的是同一个轴,所以必须先把权重 AllGather 回来。完整算法:

# 前向
W_in[D, F]   = AllGather(W_in[D_X, F])     # 不在关键路径:可在上一层计算时进行
Tmp[B_X, F]  = In[B_X, D] ·_D W_in[D, F]    # 之后可以扔掉 W_in[D, F]
W_out[F, D]  = AllGather(W_out[F, D_X])    # 同上
Out[B_X, D]  = Tmp[B_X, F] ·_F W_out[F, D]

# 反向
dW_out[F, D]{U_X} = Tmp[B_X, F] ·_B dOut[B_X, D]
dW_out[F, D_X]    = ReduceScatter(dW_out[F, D]{U_X})   # 可异步
W_out[F, D]       = AllGather(W_out[F, D_X])           # 可提前做
dTmp[B_X, F]      = dOut[B_X, D] ·_D W_out[F, D]        # 之后扔掉 W_out[F, D]
dW_in[D, F]{U_X}  = In[B_X, D] ·_B dTmp[B_X, F]
dW_in[D_X, F]     = ReduceScatter(dW_in[D, F]{U_X})    # 可异步
W_in[D, F]        = AllGather(W_in[D_X, F])            # 可提前做
dIn[B_X, D]       = dTmp[B_X, F] ·_F W_in[D, F]         # 传给前一层

和 2.3 节的纯 DP 对比,唯一的变化是:反向里的 AllReduce 被拆成了 ReduceScatter(立即做,得到切开的梯度)和 AllGather(推迟到下一次用权重之前做)。上一篇证明过 AllGather 的转置就是 ReduceScatter,所以前向 AllGather 权重、反向 ReduceScatter 梯度,是求导的必然结果。

ZeRO 名字的来历也在这里:不做任何多余的计算或存储。普通 DP 里每张卡 AllReduce 全部梯度、更新全部优化器状态、更新全部参数,都是重复劳动;ZeRO 分片后,ReduceScatter 梯度,只更新自己那片优化器状态和参数,用时再 AllGather 参数。

10.1 FSDP 的通信瓶颈和 DP 一样

前向和反向的计算 / 通信比例相同,看前向即可(前向 2 个矩阵乘,各一次 AllGather,AllGather 代价是 AllReduce 的一半):

$$T_\text{math} = \frac{2\cdot 2BDF}{XC},\qquad T_\text{comms} = \frac{2\cdot 2DF}{W}\ \Rightarrow\ \text{计算受限当且仅当}\ \frac{B}{X} \gt \frac{C}{W}.$$

门槛与纯 DP 完全相同。也就是说,只要每卡 batch 大到纯 DP 能计算受限,就可以无代价地升级到 FSDP,省下大量参数和优化器状态的显存。前向确实多了通信,但它与前向的计算按同样比例增长,不改变 roofline。总结一句:DP 和 FSDP 都在每卡 batch 小于 \(C/W\) 时变成带宽受限(TPU v5p 单轴 2550,用几个 mesh 轴做 DP 就除以几;H100 跨节点约 2475)。

10.2 逐层看预取藏不藏得住

预取能把 AllGather 藏起来的条件是单层计算比单层通信长。每层 \(16h^2\) 个参数(bf16 下 \(32h^2\) 字节),AllGather 一层参数需要 \(t_\text{comm} = 32h^2\cdot\frac{N_d-1}{N_d}/\text{peak\_bw}\);这一层前向计算 \(t_\text{compute} = 2\cdot\text{seq}\cdot\text{mbs}\cdot 16h^2/\text{peak\_flops}\)。比值:

$$\frac{t_\text{comm}}{t_\text{compute}} = \frac{N_d - 1}{N_d}\cdot\frac{\text{peak\_flops}}{\text{seq}\cdot\text{mbs}\cdot\text{peak\_bw}} \le 1 .$$

\(\text{seq}\cdot\text{mbs}\) 就是每卡 token 数,结论还是"每卡 token 数要大于 \(C/W\)"。H100 节点内 \(C/W \approx 2200\):seq = 4096、mbs = 1 就够;但如果为了省激活把 mbs 压到很小、序列又短,FSDP 的预取就藏不住了。

10.3 FSDP 单元

FSDP 不是逐个参数做 AllGather 的,而是把模型分成若干"单元"(通常一个 Transformer 块一个单元),以单元为粒度 gather、计算、释放、预取——和第 4 节的分桶是同一个权衡:单元太小,通信次数多、固定开销大;单元太大,临时的完整参数占显存多,而且第一个单元的 gather 无法与计算重叠。把单元设成整个模型,就退化成在前向开始前一次性 gather 全部参数,参数在前向之前整份 gather 回来、一直用到反向结束,梯度在反向结束后一次性 ReduceScatter;常驻显存的只有分片后的梯度和优化器状态,效果上就是 ZeRO-1 / 2。

11. 小结与局限

DPZeRO-1ZeRO-2ZeRO-3 / FSDP
切开的状态—优化器状态+ 梯度+ 参数
每卡显存\(16\Psi\)\(4\Psi + \frac{12\Psi}{N_d}\)\(2\Psi + \frac{14\Psi}{N_d}\)\(\frac{16\Psi}{N_d}\)
通信量\(2\Psi\)\(2\Psi\)\(2\Psi\)\(3\Psi\)
前向通信无无(参数 AG 可放这里)同左每层 AG
计算受限条件每卡 token 数 \(\gt C/W\)(MoE 再乘 \(E/k\))

要点:

但 ZeRO 有两个绕不过去的局限:

  1. 激活不能切。激活随序列长度和 batch 增长,ZeRO 一点也帮不上忙,只能靠激活重计算和梯度累积。我们并不想因为硬件限制就只能训短序列。
  2. 每层必须放得下。ZeRO-3 每次仍要把一整层的参数 gather 到一张卡上计算;而且计算仍然沿 batch 维分摊,batch 固定时卡数一多就会通信受限。

张量并行换了一个方向:沿模型的隐藏维切矩阵乘本身,参数、梯度、优化器状态和激活都被切开,而且完全不需要在卡之间传参数。

参考与延伸阅读