想象一支搬家队:几十位工人(GPU)要合力搬运一整栋楼的家具。老规矩是每个工人都背一整套图纸、一叠搬运贴纸和一本写满经验的“小本子”,于是每个人都在重复背相同的重物,背包很快见底。DeepSpeed 的 ZeRO 引擎就像一位精明的搬家队长,它把重复负重拆开分给大家:每位工人只背一部分,谁需要就向同伴借。这样每个人的背包都变轻了,但彼此之间的“喊话”变多了。更进一步,队长还能把不常用的小本子寄放到仓库(CPU/内存),让工人轻装上阵。ZeRO 的核心,就是用通信换显存,在背包重量与喊话频率之间找平衡。
学会 ZeRO,你能在大模型训练时准确判断“爆显存”到底该切分还是该卸载,并合理配置通信参数,把训练吞吐和 GPU 利用率真正提上去。
可视化演示
动态演示DeepSpeed ZeRO的核心机制,通过基础/核心/应用/扩展四种模式展示关键概念与交互流程。
本节配有交互动效(进入视口后自动加载,约 3KB)。
DeepSpeed ZeRO:ZeRO ZeRO引擎内部实现机制 显存切分与通信调度策略 核心机制可视化 DeepSpeed ZeRO整体架构与关键组件
显存四巨头
搬家队出发前,队长先把每个人背包里的东西清点了一遍。旧式搬家队每个工人身上有四样东西:要搬运的家具(模型参数)、贴在门上的搬迁贴纸(梯度)、写满户型经验的小本子(优化器状态),以及随时展开坐一下的折叠椅(激活值)。最重的是小本子,因为它记录了这家人所有物件的来历和摆放偏好。
训练大模型时,显存也是由这几类东西组成的。模型参数是网络的权重;梯度是反向传播时算出来的修正量;优化器状态则是 Adam 这类优化器用来记录历史信息的动量与方差,体积往往比参数本身还大好几倍。激活值是前向计算时产生的中间结果,虽然不常被提起,但也会在 moment 上占不少位置。
在混合精度训练下,我们设模型参数量为 \(\Psi\)。一个参数平均要消耗:FP16 参数 2 字节、FP16 梯度 2 字节、优化器状态 12 字节(包括 FP32 主权重 4 字节、Adam 动量 4 字节、Adam 方差 4 字节)。因此,每张 GPU 上的静态显存下限大约为:
所以,训练大模型时最该先处理的,不是家具本身,而是那本厚重的小本子。ZeRO 的切入点,正是从这里开始的。
- 参数量\(\Psi\) 表示模型总共有多少个可学习参数。
- 半精度参数\(2\Psi\) 表示 FP16 前向反向用的参数,每个参数 2 字节。
- 梯度存储反向传播得到的梯度同样用 FP16 保存,每个参数再占 2 字节。
- 优化器状态主权重、动量、方差加起来每个参数对应的份量,共 \(12\Psi\)。
- 静态总量四项相加,得到每卡 \(16\Psi\) 字节的基线显存。
数据并行之痛
如果搬家队继续沿用老规矩,每位工人都背一整套一模一样的图纸和小本子,那么 N 个工人身上就有 N 份完全相同的重复负重。更麻烦的是,大家经常要停下来互相核对同一本图纸,谁改了一个数字,所有人都得停下来重新对齐。
数据并行(DDP)就是这个老规矩。每张 GPU 上都放一份完整模型,每步算完梯度后,使用 all-reduce 把所有卡的梯度加起来再同步。模型规模不大时,这套方法简单有效;但模型一旦变大,问题立刻爆发:每卡要背 \(16\Psi\) 字节,N 张卡就等于:
这就是所谓的“显存墙”:原本想通过加卡省钱,结果每张卡都成了装不下完整模型的瓶颈。而且,卡数越多,梯度同步的通信量也越大,训练吞吐反而可能下降。队长看到这里明白了:必须打破“人人全量”的老规矩,把重复的负重真正拆开。
- 单卡基线每张卡有全量参数、梯度和优化器状态,共 \(16\Psi\)。
- 副本倍数数据并行下,每张卡各存一份完整副本,显存乘以卡数 \(N\)。
- 总显存需求两者相乘,总显存需求随卡数线性增长。
ZeRO三阶段
队长开始重新分工。ZeRO 把显存里的三大巨头——优化器状态、梯度、参数——按 GPU 数量 \(N\) 切成 \(N\) 份,每个工人只保管其中一份。第一阶段先切最重的优化器状态,第二阶段连同梯度一起切,第三阶段连参数也切。每多切一类,工人背包就轻一大截。
从技术上看,三个阶段分别对应:
- ZeRO-Stage 1:只切分优化器状态,每卡仍需保存完整参数和梯度,显存约 \(4\Psi + \frac{12\Psi}{N}\)。
- ZeRO-Stage 2:切分优化器状态和梯度,每卡只需一份完整参数,显存约 \(2\Psi + \frac{14\Psi}{N}\)。
- ZeRO-Stage 3:三者全部切分,每卡只持有全模型的 \(1/N\),显存约:
显存省下来了,代价是通信变多。前向计算需要临时收集被切分的参数,反向传播需要对梯度做 reduce-scatter,参数更新后还要再次广播。阶段越高,每个工人的背包越轻,但互相喊话的次数也越多。因此,实际选择哪一阶段,要看集群网络是否足够快。
- 全量混合精度状态分子 \(16\Psi\) 是传统方案下单卡所需的全部静态显存。
- GPU 数量分母 \(N\) 表示把三类状态均匀切给 \(N\) 张卡。
- 切分后每卡理想情况下,每卡只需负担原来的 \(1/N\),这就是 ZeRO-3 提供的极致显存节省。
混合精度
搬家队为了让背包更轻,决定把实木家具换成轻便的折叠家具(FP16),但为了完整保留每件家具的历史记录,队长还让每个工人保留一本完整的登记簿(FP32 主权重)。家具轻了,账本依旧完整,这样搬家既省力又不会弄丢关键信息。
在混合精度训练中,前向传播和反向传播都使用 FP16 参数与激活值,这样计算更快、显存更省。但优化器更新参数时,必须使用 FP32 主权重,否则精度会快速恶化。FP16 的数值范围较小,梯度在反向传播中很容易变小到“下溢”成 0,为此需要先放大损失:
与 ZeRO 配合时,FP32 主权重和 Adam 状态加起来正好是显存四巨头中的“优化器状态”。ZeRO 把这部分切分到各卡,既保留了混合精度的精度优势,又进一步吃到了显存红利。折叠家具压低体积,登记簿又交给专人分管,两者配合起来效果远超单用其中任意一种。
- 原始梯度\(\nabla L\) 是损失函数对参数的梯度。
- 缩放因子\(s\) 是一个较大常数,例如 1024,将梯度放大到 FP16 可表示的范围内。
- 缩放后梯度\(g_{\text{scaled}}\) 是放大后的梯度,更新参数前再除以 \(s\) 还原真实更新量。
卸载策略
就算把背包切分到极致,有些工人还是装不下。队长于是启动终极方案:把不常用的“小本子”寄放到公司仓库(CPU 内存甚至 NVMe 磁盘),工人只带当天需要用的几页纸,用完了再回去取。代价是取件时间变长,但背包确实轻了。
这就是 ZeRO-Offload 的思路。它把 Adam 优化器状态甚至梯度、参数从 GPU 显存搬到 CPU 内存或 NVMe 磁盘上,GPU 只保留当前计算必需的碎片。由于 CPU 内存价格远低于 GPU 显存,这让小显存机器也能挑战大模型。但 PCIe 传输速度比 GPU 显存慢得多,每一步训练的总耗时受制于最慢的环节:
所以,卸载策略本质上是“用时间换空间”。在单机多卡或显存非常吃紧的场景下,它能让训练跑起来;但如果网络或 PCIe 带宽不够,训练吞吐会明显下降。仓库能借,但别把喊话通道塞满。
- GPU 计算时间\(T_{\text{GPU}}\) 表示 GPU 执行前向反向和参数更新的时间。
- CPU 更新时间\(T_{\text{CPU}}\) 表示 CPU 上更新优化器状态所花的时间。
- 传输时间\(T_{\text{PCIe}}\) 表示参数在 GPU 与 CPU 之间搬运的耗时。
- 短板决定总时长整个训练步骤的耗时近似等于计算与卸载往返中更慢的那一方。
通信优化
工人之间互相借东西时,如果每借一块木板就喊一嗓子,整栋楼都会吵翻天。队长规定:把几十个小物件先装进一个大箱子,再统一喊话搬运。这样一来,喊话次数从几十次变成一次,通讯效率大大提高。
深度学习反向传播会生成成百上千个小梯度张量。如果每个梯度都单独走一次 all-reduce,通信的固定开销会淹没计算本身。通信总耗时可以写成:
DeepSpeed 通过把梯度放进一个个“桶”(bucket),攒到一定大小后再发起通信,从而大幅减少 \(n\)。配置项 reduce_bucket_size 和 allgather_bucket_size 控制桶的大小,overlap_comm 则让通信与反向计算重叠,把等待时间“藏”进计算里。喊话次数少了,搬家速度自然就快了。
- 消息数量\(n\) 是参与通信的消息条数。
- 单条固定开销\(\alpha\) 表示每次通信握手、延迟带来的固定时间成本。
- 传输数据量\(B\) 是需要传输的字节总数。
- 单位带宽成本\(\beta\) 表示每传输一个字节所花的时间。
- 总通信时间固定开销与数据量传输时间之和,就是一次通信的总耗时。
调优与排查
一切准备就绪后,队长要检查三件事:背包有没有超限、喊话频道堵不堵、仓库有没有拖后腿。训练调优也是一样,先判断瓶颈到底在显存、通信还是计算,再针对性下药。
调优时,显存不足可以用 zero_optimization.stage 逐步升级,从 1 到 3;仍然不够就开 offload_optimizer 或激活重计算。吞吐下降时,优先确认通信桶大小、NCCL 带宽利用率,再调整 batch size 和梯度累积步数。每一步训练的总时间,大致由三部分组成:
实际操作中,可以先用 nvidia-smi 看显存峰值,用 ds_report 检查 DeepSpeed 版本,再在日志里观察 throughput 与 latency。如果切到更高 ZeRO 阶段后吞吐反而下降,优先怀疑通信瓶颈,而不是盲目加卡。搬家队的目标不是让每班搬得最重,而是让总时间最短:算、传、等三端平衡。
- 计算时间\(T_{\text{compute}}\) 是 GPU 前向反向的纯计算耗时。
- 通信时间\(T_{\text{comm}}\) 是梯度同步与参数广播的耗时。
- 同步时间\(T_{\text{sync}}\) 是卡间等待、锁竞争等额外延迟。
- 训练步长三者相加,得到完成一个训练 step 的总时间。
AI 应用场景
大规模预训练:用 8 卡训练 13B 模型
阶段:分布式预训练
解决的问题:传统数据并行下,每张 GPU 都要保存完整模型参数、梯度和优化器状态,13B 模型在单卡 40GB 显存上根本放不下。ZeRO Stage 2 将优化器状态和梯度按卡切分,让 8 卡集群共同分担显存压力,同时保持接近线性扩展的训练吞吐。
具体案例:某团队在 8 张 A100-80GB 上预训练一个 13B 的对话模型。他们在 deepspeed_config.json 中设置 zero_optimization.stage=2,并开启 overlap_comm=true,使梯度通信与反向计算重叠。最终把单卡显存占用从接近 80GB 压到 52GB,训练吞吐提升约 1.6 倍。
成本敏感型微调:消费级显卡上的 7B 模型微调
阶段:大模型微调
解决的问题:很多算法工程师只有几块 24GB 消费级显卡,无法完整微调 7B 模型。ZeRO-Offload 将优化器状态放到 CPU 内存,GPU 只保留参数和梯度,用时间换空间,让单机小显存也能完成微调。
具体案例:一位算法工程师在一台 4 卡 RTX 4090 工作站上,用 ZeRO Stage 2 + CPU offload 微调 7B 模型。他把 offload_optimizer.device 设为 cpu,将 reduce_bucket_size 调大到 5e8,减少通信次数。虽然每步训练时间比纯 GPU 方案慢约 30%,但模型终于“跑得起来”,整体项目成本下降了 70%。
Python 演示
import numpy as np def make_params(sizes): params = {} for i, size in enumerate(sizes): params['w' + str(i)] = np.random.randn(size).astype(np.float32) return params class ZeROStage1: def __init__(self, rank_gpu, world_size_gpu, params): self.rank_gpu = rank_gpu self.world_size_gpu = world_size_gpu self.params = params self.grads = {name: np.zeros_like(p) for name, p in params.items()} self.local_names = list(params.keys())[rank_gpu::world_size_gpu] self.moments = {} self.velocities = {} for name in self.local_names: self.moments[name] = np.zeros_like(params[name]) self.velocities[name] = np.zeros_like(params[name]) self.memory = self.estimate_memory() def estimate_memory(self): total = 0 for name, p in self.params.items(): total += p.nbytes * 2 for name in self.local_names: total += self.params[name].nbytes * 4 return total def set_grad(self, name, grad): self.grads[name] += grad def all_reduce_grad(self): for name in self.grads: self.grads[name] /= self.world_size_gpu def optimizer_step(self, lr=0.01, beta1=0.9, beta2=0.999): for name in self.local_names: g = self.grads[name] self.moments[name] = beta1 * self.moments[name] + (1 - beta1) * g self.velocities[name] = beta2 * self.velocities[name] + (1 - beta2) * g * g self.params[name] -= lr * self.moments[name] / (np.sqrt(self.velocities[name]) + 1e-8) if __name__ == '__main__': world_size_gpu = 4 params = make_params([1024, 2048, 4096]) workers = [ZeROStage1(rank, world_size_gpu, params) for rank in range(world_size_gpu)] for worker in workers: for name in params: worker.set_grad(name, np.ones_like(params[name])) worker.all_reduce_grad() worker.optimizer_step() print('每个GPU只保存本卡分片的优化器状态:', [w.local_names for w in workers])
self.local_names = list(params.keys())[rank_gpu::world_size_gpu]按 GPU 编号切片,决定本卡负责哪些参数的优化器状态。total += p.nbytes * 2每个参数仍保留 FP16 参数和 FP16 梯度的全量副本。total += self.params[name].nbytes * 4只对本卡负责的参数额外加上主权重与 Adam 状态。self.grads[name] /= self.world_size_gpu模拟 all-reduce 后的平均梯度。self.moments[name] = beta1 * self.moments[name] + (1 - beta1) * gAdam 第一动量更新,与 DeepSpeed 内部计算一致。self.velocities[name] = beta2 * self.velocities[name] + (1 - beta2) * g * g第二动量更新,用于缩放参数修正量。self.params[name] -= lr * self.moments[name] / (np.sqrt(self.velocities[name]) + 1e-8)使用分片优化器状态更新参数。
常见误区
误区我用了 ZeRO Stage 3,显存就一定变成原来的 1/N,激活值那些也都自动省下来了。
正解ZeRO 切分的是参数、梯度和优化器状态,这些是静态冗余;激活值是在前向过程中动态产生的,并不会因此自动减少。一个 7B 模型即使参数被切到各卡,长序列训练时的激活值仍然可能把显存占满。所以 ZeRO 通常要搭配激活重计算、梯度检查点一起使用,才能真正压住显存峰值。
为何常见:因为 ZeRO 的宣传材料总强调“显存随卡数近似线性下降”,人们容易把适用范围扩大,忽略了激活值这个变量。
误区既然 Stage 3 最省显存,那就无脑开 Stage 3,省下来的显存不香吗?
正解Stage 3 把参数也切分了,前向和反向都要按需 all-gather 参数,通信量远大于 Stage 1。在千兆网络或高延迟集群里,模型反而可能训练得更慢。选择阶段要在显存和通信之间做权衡:低阶段能满足显存需求时,不要盲目追高。
为何常见:面试和博客都强调“显存节约”,却很少对比通信开销,初学者容易只看单一指标。
误区显存不够就把东西全放到 CPU 内存里,反正 CPU 内存便宜,总归比跑不起来强。
正解offload 会把 PCIe 带宽变成新瓶颈。参数和状态频繁往返于 GPU 与 CPU 之间,每一步训练都要等待传输完成,吞吐可能下降 50% 以上。它适合训练速度不敏感但显存极其紧张的场景;如果集群有多张 NVLink 互联的 GPU,优先用 ZeRO Stage 3 切分而不是 offload。
为何常见:人们默认“显存不够 = 内存来凑”,但忽略了 GPU 通信链路的物理速度差距极大。
误区混合精度训练中,模型参数和优化器状态全部用 FP16 保存,所以显存减半。
正解FP16 只在前向、反向计算中使用,优化器更新时的主权重仍然是 FP32。直接在优化器里用 FP16 会导致数值溢出和收敛不稳。真正的显存节省来自参数与梯度用半精度保存,但主权重、动量和方差必须按 FP32 保存或交由 ZeRO 切分。
为何常见:因为很多框架自动开启了混合精度,用户只看到“显存少了”,并不知道后台还保留了 FP32 副本。
误区ZeRO 是 DeepSpeed 独有的黑科技,自己的 PyTorch 代码必须大改才能用。
正解DeepSpeed 提供 deepspeed.initialize 接口,普通 PyTorch 模型只需几十行改动就能接入 ZeRO。你也可以参考 PyTorch 官方的 FSDP,它实现了类似 ZeRO-3 的分片逻辑。ZeRO 是一套思想,而不是被绑死在某个框架里的私有功能。
为何常见:看到 ZeRO 与 DeepSpeed 绑定出现,就容易误以为二者是同一件事,而忽略了它作为一种架构模式的通用性。
面试题
Q1. ZeRO 引擎主要解决的问题是什么?
- A.减少训练噪声
- B.消除显存冗余← 正确答案
- C.提升核心主频
- D.生成训练数据
答案:B。ZeRO 的核心是消除数据并行中参数、梯度、优化器状态的多卡冗余副本。它不会改变训练数据,也不会直接提高硬件主频。选 B 才符合“以通信换显存”的设计初衷。
Q2. ZeRO 三阶段依次切分哪三类对象?
- A.优化器状态、梯度、参数← 正确答案
- B.激活值、梯度、参数
- C.参数、优化器状态、损失值
- D.梯度、激活值、学习率
答案:A。ZeRO 的设计顺序是:先切最重的优化器状态,再切梯度,最后切参数。激活值和损失值并不是切分对象,因此 B、C、D 都不符合 ZeRO 的原始定义。
Q3. 混合精度训练中,主权重通常保存在什么格式?
- A.FP16 格式
- B.INT8 格式
- C.FP32 格式← 正确答案
- D.BF16 格式
答案:C。混合精度虽然用 FP16 做前向和反向,但优化器更新时需要使用 FP32 主权重以保证数值稳定。BF16 也是半精度的一种,但主权重一般仍保留 FP32 副本,因此选 C。
Q4. ZeRO-Offload 的主要代价是什么?
- A.GPU算力下降
- B.传输带宽变慢← 正确答案
- C.模型精度提升
- D.显存直接翻倍
答案:B。Offload 把数据从 GPU 搬到 CPU 或 NVMe,PCIe 与磁盘的带宽远低于显存带宽,导致通信耗时增加。它不是算力下降,也不提高精度,更不会让显存翻倍。
Q5. 梯度分桶主要减少了哪一项开销?
- A.传输字节总量
- B.通信交互次数← 正确答案
- C.梯度计算次数
- D.模型存储空间
答案:B。分桶把许多小梯度合并成一个大张量,再统一通信,显著降低消息数量。固定握手和延迟开销随消息次数减少而下降,但传输的字节总量基本不变。
Q6. 以下哪些项属于显存四巨头?
- A.优化器状态← 正确答案
- B.训练日志
- C.梯度张量← 正确答案
- D.激活值← 正确答案
答案:ACD。显存四巨头是参数、梯度、优化器状态和激活值。训练日志保存在 CPU 文件系统,不占 GPU 显存,因此 B 错误。
Q7. 4 卡 ZeRO-1 下,每张卡的优化器状态约保留多少?
- A.完整四份状态
- B.完整一份状态
- C.四分之一份状态← 正确答案
- D.完全不保留状态
答案:C。ZeRO-1 把优化器状态均匀切分到 4 张卡上,每卡只维护其中 1/4。完整一份是未启用 ZeRO 的 DDP 行为,不保留状态则无法进行 Adam 更新。
Q8. 关于 ZeRO-3 的说法正确的是?
- A.参数在需要时全量聚集← 正确答案
- B.显存随卡数增加而下降← 正确答案
- C.通信量一定比 DDP 更少
- D.只能配合 FP16 使用
答案:AB。ZeRO-3 将参数切分,前向和反向时需要 all-gather 临时恢复完整参数,因此 A 正确;由于五份状态都按卡切分,显存确实随卡数增加而下降,B 正确。但通信量通常反而增加,C 错;混合精度只是可选项,D 错。
Q9. 训练吞吐不升反降,最先排查哪一项?
- A.网络通信瓶颈← 正确答案
- B.数据集大小
- C.模型参数量
- D.学习率设置
答案:A。从低阶 ZeRO 切到高阶后吞吐下降,最常见原因是通信开销过大。数据集大小和模型参数量在训练过程中一般不突然变化,学习率影响收敛而非显存或吞吐。
Q10. 下列关于 DeepSpeed 配置的说法正确的是?
- A.分桶大小可配置← 正确答案
- B.卸载可到 CPU 磁盘← 正确答案
- C.ZeRO 三阶段均可启用← 正确答案
- D.主权重无需保留
答案:ABC。reduce_bucket_size 可以控制梯度分桶大小;offload_optimizer.device 支持 cpu 和 nvme,因此 A、B 正确。DeepSpeed 支持 stage 1、2、3 三档配置,C 正确。主权重在混合精度训练中必须保留 FP32 副本,D 错误。