想象一支 8 人的登山队去雪山露营,每人只背得动 40 升的背包(GPU 显存),但每个人手里都有一套完整的装备:冲锋衣(模型参数)、旅行手账(梯度)、以及沉甸甸的私人药箱和营养包(优化器状态)。一开始大家图省心,每人照搬整套——8 个背包、8 份一模一样的 120 斤行李,还没到山脚就压垮了。这时领队 ZeRO 站出来,提出“分背”三阶段:先拆最重的药箱,再拆手账,最后连衣服也分开背。谁要用哪件,就向带它的人临时借,用完立刻还回去。到了 PyTorch 时代,FSDP 就是那个拿着对讲机、精确安排“谁何时借哪件衣服、用完放回哪个包”的智能领队。这篇文章就用这条登山主线,把 ZeRO 三阶段的显存公式、通信代价和 FSDP 配置一次讲透。
掌握 ZeRO 三阶段与 FSDP,是突破大模型显存瓶颈、从单卡走向多卡集群的必修课,直接决定你能否在有限硬件上训练百亿级模型。
可视化演示
通过训练显存占用构成、ZeRO三阶段渐进分片对比、通信代价与显存节省权衡三个模式,展示ZeRO优化如何通过分片优化器状态/梯度/参数大幅降低单卡显存占用。
本节配有交互动效(进入视口后自动加载,约 3KB)。
训练显存占用四大构成 模型参数占20%每张卡完整持有前向反向计算必需 优化器状态占40%是显存大头Adam需存一阶二阶动量 Mem = Params(2P) + Grad(2P) + Optim(12P) + Act 优化器状态是显存消耗的主要来源
显存瓶颈探因
8 人登山队出发前清点装备:冲锋衣(参数)、旅行手账(梯度)、药箱和营养品(优化器状态)。每个人都想往 40 升的背包里塞下全套,结果拉链都拉不上。问题不是装备本身多到离谱——一套装备其实只有 120 斤——而是每个人都重复背负,8 份一模一样的 120 斤压在 8 个人身上,浪费得令人心疼。
训练深度模型时,GPU 显存里的情况如出一辙。混合精度训练下,围绕每个参数要维护三类“模型状态”:FP16 的模型参数、FP16 的梯度、以及 Adam 优化器为每个参数保存的 FP32 主权重、动量 m 和方差 v。前两类每参数各占 2 字节,第三类独占 12 字节。这就是“模型状态”的 16 字节总账。
一个 75 亿参数的 7.5B 模型,FP16“裸参数”只有 15GB,看起来不大;但加上梯度和 Adam 优化器全家桶后,模型状态膨胀到 120GB。这还没算前向计算产生的激活值、临时缓冲区和显存碎片。单卡 40GB 的 A100 根本装不下,这就是无数工程师卡在“模型太大”这道坎上的真正原因。
回到登山队:衣服 2 斤、手账 2 斤、药箱 12 斤——药箱竟是体量的 3/4!所以聪明的领队 ZeRO 第一眼就盯上了药箱,这也预示了优化的顺序。
- 参数身位(2Ψ)混合精度训练保留一份 FP16 的“随身版”参数用于前向和反向,每个参数占 2 字节。
- 梯度账本(2Ψ)反向传播得到的 FP16 梯度同样按每参数 2 字节记账,用于后续更新。
- 优化器百宝箱(12Ψ)Adam 需要维护 FP32 主权重、动量 m、方差 v,各占 4Ψ,合计 12Ψ。
- 总账(16Ψ)三类相加,每个参数平均 16 字节,模型状态的固有体积就此锁定。
基准方案对比
出发前没人敢轻信队友,于是决定各背各的:8 个背包,每包全套 120 斤。这是最省心也最浪费的方案,就像深度学习里最经典的 DDP(Distributed Data Parallel)——N 张 GPU 各持一份完整模型副本,前向各算各的数据,反向后通过 all-reduce 把梯度同步给所有人。
DDP 的单卡显存就是完整模型状态:16Ψ。用 7.5B 模型代入,120GB 的模型状态配上 40GB 的显存,结果只有一个——爆显存。更扎心的是这 120GB 在每张卡上都重复存了一份,8 卡就是 8×120GB,其中 7/8 是纯冗余。即便模型勉强塞进单卡,这种“人人全套”的背法也是巨大的浪费。
所有人都背着 120 斤还没走到营地就累垮了。队伍需要的不是更强的体力,而是一次彻底的重分配——这正是 ZeRO 诞生的起点。
- N 份全量数据并行下每张卡都保存一份完整模型状态,单卡占用即 16Ψ。
- 代入算例7.5B 参数代入,16×7.5 = 120,单位是 GB。
- 超限对比单卡 A100 显存 40GB,120GB 远超上限,训练直接失败。
阶段一:优化器状态分片
领队 ZeRO 清点后大吃一惊:药箱占 12 斤,衣服和手账各才 2 斤!于是宣布方案一:把药箱拆成 8 份,每人只背 1.5 斤,到营地再互相补给;衣服和手账仍人手一套。行李从 120 斤骤降到 41 斤,几乎省掉了 2/3 的重量。
对应到技术里,这就是 ZeRO Stage1(优化器状态分片)。每张卡仍然保留完整的参数(前向/反向都要用)和完整的梯度(all-reduce 之后每卡都有全量),但只负责更新自己那 1/N 参数对应的优化器状态。用公式表达,单卡显存为参数 2Ψ、梯度 2Ψ 加优化器状态的 1/N,即 4Ψ + 12Ψ/N。
以 7.5B 模型 8 卡为例:30GB + 90GB/8 = 41.25GB,从 120GB 直落到 41.25GB。关键在于通信量纹丝不动——梯度同步还是 DDP 那个 all-reduce,路线没变,只是“药箱里的药”换了个存放位置。
方案一立竿见影,人均负重几乎砍半。但手账(梯度)每一页大家还在重复誊写——既然药箱能拆,手账为什么不能拆?
- 全量保留参数 2Ψ 与梯度 2Ψ 每卡依旧完整,两者合计 4Ψ,是分片后单卡的“固定底数”。
- 拆分药箱优化器状态 12Ψ 由 N 张卡均分,每卡只留 12Ψ/N。
- 单卡公式固定底数加上分摊后的优化器状态,即 Stage1 的单卡占用。
阶段二:梯度分片
第二阶段,领队宣布手账也拆着写:每人只负责其中 1/8 的章节,每天写完后用“分工汇总”的方式合订——这个过程叫 reduce-scatter。衣服依旧每人都有一套,因为任何时候都可能要穿。
这就是 ZeRO Stage2(梯度分片)。反向传播算出的梯度不再每卡保存全量,而是通过 reduce-scatter 在聚合的同时完成分摊:每卡只拿到自己负责那 1/N 的梯度切片,并用它更新自己手上的优化器状态。前向计算需要的参数依然每卡完整,所以“前向时的显存”与 Stage1 相同,省下的部分来自梯度的去重。
单卡公式变为 2Ψ + 14Ψ/N。7.5B 模型 8 卡:15GB + 105GB/8 = 28.125GB,比 Stage1 又降了约 1/3。通信量依然维持在 2Ψ——reduce-scatter 把“同步”和“分摊”合并成了一次集体行动,没有额外喊话。
药箱和手账都拆完了,人均从 120 斤降到 28 斤。但每个人仍抱着一麻袋衣服——那是参数,下一步连衣服都要拆。
- 只留参数参数 2Ψ 每卡完整保留,这是前向计算和反向“借参数”的基础。
- 梯度摊分梯度 2Ψ 被切成 N 片,每卡只留 2Ψ/N。
- 优化器摊分优化器状态维持 Stage1 的均分结果 12Ψ/N。
- 合并公式三者相加,参数独占 2Ψ,其余 14Ψ 全部摊进分母。
阶段三:参数分片
终极方案来了:衣服也按 1/8 分给每个人。问题随之而来——今天要穿红羽绒服,带它的人却在队尾。于是队伍约定:谁需要哪件,就向持有者喊一嗓子借来穿(all-gather),穿完立刻还回去(reshard)。借还的喊话让通信变多了,但每个人背包只剩 15 斤。
这就是 ZeRO Stage3(参数分片):参数、梯度、优化器状态三类全部切分。前向计算某一层时,先 all-gather 取回该层完整参数,算完立刻释放;反向时再次取回该层参数计算梯度,再用 reduce-scatter 把梯度切片归并回去。每一层的“借—用—还”精确得像一场接力赛。
单卡公式简化为 16Ψ/N。7.5B 模型 8 卡:120GB/8 = 15GB——这是三个阶段中唯一能让 7.5B 模型塞进 24GB 消费级显卡的方案。代价明码标价:通信量从 DDP 的 2Ψ 涨到 3Ψ,整整 1.5 倍。这是一笔“用通信换显存”的生意。
三阶段全部完成:8 个人只带一套装备上山,谁要用什么就临时借。人均 15 斤,轻装上阵;但“借还”的喊话声此起彼伏——现场急需一个智能领队来打理。
- 全部摊分参数、梯度、优化器状态全部按 N 均分,每卡只留总量的 1/N。
- 借衣服前向或反向需要第 l 层参数时,先 all-gather 借回完整副本 Ψ_l。
- 还衣服计算完毕立即释放该副本,避免长期占用显存。
- 代入算例7.5B 模型在 8 卡下,120GB/8 = 15GB。
FSDP 工程实现
靠人肉管理借还衣服,队伍早就乱套。FSDP(Fully Sharded Data Parallel)就是那个拿着对讲机、精确安排“谁何时借哪件、用完放回哪个包”的领队。它把 ZeRO Stage3 变成了一套开箱即用的自动化流程。
FSDP 的第一招是“参数扁平化”:把嵌套模型里所有参数拉直成一根巨大的长绳(flat parameter),按 GPU 数切成 N 段分发。训练时执行 unshard(取回当前段)→ 前向/反向 → reshard(释放),像领队按时按点地拆包、发衣服、收衣服。
第二招是“自动包裹策略”(auto_wrap_policy):领队不会让全队 120 斤衣服一次性摊开,而是按 Transformer 层或参数规模把模型切成一个个 FSDP 单元,每次只 unshard 正在计算的那一层。配合“懒初始化”(先登记元数据、再分配真实显存),峰值显存被压得更加平滑;若开启 CPUOffload,最重且不常用的“备用补给”还能寄存在 CPU 内存里,随时取用。
有了领队,8 人的“分背—借还”变成一套整齐的行程表:谁的衣服何时拆包、何时归位、要不要寄放 CPU,全部自动执行。接下来只差一件事——选哪套方案上路。
- 常驻行李分片后的模型状态每卡 16Ψ/N,是背包里固定的重量。
- 当前层借用处理第 l 层时,该层参数 2Ψ_l 被临时 all-gather 到本卡,用完即还。
- 临时激活该层前向产生的激活值 A_l 只存在于本卡,属于“剩余状态”,不被分摊。
- 峰值合计峰值约等于常驻量加上所有层里最大的“借用+激活”,这正是 FSDP 按层管理的原因。
配置与实战
领队提供了四种负重方案:不改(NO_SHARD)、只拆药箱(SHARD_GRAD_OP)、全套拆光(FULL_SHARD)、以及“小组内拆光、小组间各背各的”(HYBRID_SHARD)。选哪套,取决于山路是宽是窄(网络带宽)和队员体质(GPU 显存)。
FSDP 的核心配置就落在三个旋钮上:sharding_strategy 控制分片策略,FULL_SHARD 对应 Stage3、SHARD_GRAD_OP 对应 Stage2、NO_SHARD 退化为 DDP、HYBRID_SHARD 在节点内 FULL_SHARD、跨节点保持整副本,适合机间带宽有限的多机训练;auto_wrap_policy 用 transformer_auto_wrap_policy 自动包裹每个 Transformer block;cpu_offload 开启后,优化器状态与参数可驻留 CPU 内存。
来一组 7.5B 模型 × 16 卡的实战账本:DDP 基线 120GB 直接出局;Stage1 为 30 + 90/16 = 35.6GB;Stage2 为 15 + 105/16 = 21.6GB;Stage3 直达 120/16 = 7.5GB,16GB 显卡也能从容训练。通信账同步算清:DDP 的 all-reduce 为 2Ψ,Stage3 因“借参数 + 还梯度”涨到 3Ψ。GPU 越多分得越散,通信占比越高,所以大集群常选 HYBRID_SHARD 折中。
选对方案,这支登山队才能在“显存”与“通信”两座山之间走出最优路线——而 FSDP 把这个选择权,装进了三行配置代码里。
- DDP 基线all-reduce 同步梯度,一次集体喊话的通信量约 2Ψ。
- Stage3 三项前向借参数 all-gather 花 Ψ、反向再借一次花 Ψ、梯度 reduce-scatter 花 Ψ。
- 倍率3Ψ 除以 2Ψ,通信量是 DDP 的 1.5 倍。
AI 应用场景
千亿参数大模型预训练
阶段:模型训练与分布式执行
解决的问题:传统数据并行下,70B 模型需要 1.2TB 模型状态,8×80GB 的 A100 集群也塞不下;用 FSDP 的 FULL_SHARD 把参数、梯度、优化器状态全部摊到 16 张卡上,单卡负担降到 1/16,训练才能跑起来。
具体案例:某 LLM 团队预训练 13B 模型,用 transformer_auto_wrap_policy 按层包裹,配合激活检查点,在 16×A100 上把单卡显存峰值压到 40GB 以内,吞吐较 DDP 提升 3 倍。
单卡微调大模型
阶段:微调与推理部署
解决的问题:个人开发者只有一张 24GB 的 RTX 4090,7B 模型裸参数就要 14GB,加上梯度和优化器必然爆显存;FSDP 即使只有 1 张卡,也能通过 CPU 卸载把优化器状态“寄放”到内存,腾出显存跑微调。
具体案例:用 FSDP(model, cpu_offload=CPUOffload(offload_params=True)) 包装 7B 模型,配合 LoRA 只训练少量参数,在单卡上完成指令微调,batch size 开到 4 而不爆显存。
多机多卡集群训练平台
阶段:平台架构与资源调度
解决的问题:机间带宽(如 25Gbps RDMA)远低于机内 NVLink,FULL_SHARD 的全局 all-gather 会把跨机通信打满;HYBRID_SHARD 在节点内做完整分片、节点间保留整副本,把通信压回可控范围。
具体案例:某推荐系统团队用 HYBRID_SHARD 在 4 机 32 卡上训练千亿级 CTR 模型,单卡显存峰值下降 70%,有效吞吐提升 1.8 倍。
Python 演示
import osimport torchimport torch.nn as nnimport torch.distributed as distfrom torch.distributed.fsdp import ( FullyShardedDataParallel as FSDP, CPUOffload, ShardingStrategy,)from torch.distributed.fsdp.wrap import size_based_auto_wrap_policy def init_process_group(): os.environ.setdefault("MASTER_ADDR", "127.0.0.1") os.environ.setdefault("MASTER_PORT", "29500") os.environ.setdefault("WORLD_SIZE", "2") os.environ.setdefault("RANK", "0") dist.init_process_group(backend="gloo") class ToyNet(nn.Module): def __init__(self): super(ToyNet, self).__init__() self.layers = nn.Sequential( nn.Linear(1024, 2048), nn.ReLU(), nn.Linear(2048, 2048), nn.ReLU(), nn.Linear(2048, 16), ) def forward(self, x): return self.layers(x) def train(): init_process_group() model = ToyNet() wrap_policy = size_based_auto_wrap_policy(min_num_params=2_000_000) model = FSDP( model, sharding_strategy=ShardingStrategy.FULL_SHARD, auto_wrap_policy=wrap_policy, cpu_offload=CPUOffload(offload_params=True), ) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) loss_fn = nn.MSELoss() for step in range(3): data = torch.randn(32, 1024) target = torch.randn(32, 16) optimizer.zero_grad() output = model(data) loss = loss_fn(output, target) loss.backward() optimizer.step() if dist.get_rank() == 0: print("step=%d loss=%.4f" % (step, loss.item())) dist.destroy_process_group() if __name__ == "__main__": train()
from torch.distributed.fsdp import (引入 FSDP、CPU 卸载和分片策略组件,相当于领取领队的对讲机。model = FSDP(model,用 FSDP 包装模型,把普通模型改造成“分背版”,参数自动按卡切分。sharding_strategy=ShardingStrategy.FULL_SHARD,选择 ZeRO 阶段三:参数、梯度、优化器全部摊分。auto_wrap_policy=wrap_policy,超过 200 万参数的内层子模块会被单独包裹,实现按层取回,避免整模型一把梭。cpu_offload=CPUOffload(offload_params=True),允许把暂时不用的参数与优化器状态寄放到 CPU 内存,进一步腾空显存。loss.backward()反向时 FSDP 自动完成“借参数”和 reduce-scatter 梯度切片,代码层面零感知。optimizer.step()每张卡只更新自己手上那片优化器状态,与 Stage1 的“各管各的药箱”完全一致。
常见误区
误区很多人看到单卡显存从 16Ψ 降到 16Ψ/N,就以为 ZeRO 像量化一样把每个参数占的字节变小了,模型被压缩了。
正解ZeRO 没有改变任何数值精度,它做的是“存储布局重规划”。模型状态的固有体积仍然是 16Ψ,但不再让每张卡各存一份(DDP 的 N×16Ψ),而是全队只背一套(16Ψ)。如同 8 人不再各背全套 120 斤,改成共背一套 120 斤——装备没变小,只是不再重复携带。单卡从 16Ψ 降到 16Ψ/N,集群总存储也从 N×16Ψ 降到 16Ψ,降的是“冗余复制”而非“数据体积”。
为何常见:显存数字下降得过于直观,让人误以为是数据被压缩;“分摊”与“压缩”一字之差,本质却完全不同。
误区觉得 FSDP 也是数据并行,梯度同步该怎么做还怎么做,通信量应该和 DDP 差不多。
正解DDP 一次 all-reduce 的通信量约 2Ψ;FSDP 的 Stage3 除了梯度 reduce-scatter(Ψ),还要在前向和反向各做一次参数 all-gather(各 Ψ),合计约 3Ψ,是 DDP 的 1.5 倍。这多出来的 1 倍就是“借衣服”的喊话成本。正因为通信涨了,工程上才需要 auto_wrap_policy 按层取回,把通信打散到计算中隐藏。
为何常见:只看到“都是数据并行”的标签,没细算 all-gather 与 all-reduce 的次数差异。
误区以为参数、梯度、优化器都分片了,单卡显存只剩 16Ψ/N,长序列大模型就能随便跑了。
正解16Ψ/N 只覆盖“模型状态”;激活值(activation)、临时缓冲区和显存碎片仍由每卡独享,长序列训练时激活显存可能比模型状态更大。FSDP 的显存峰值公式里专门加了 max(2Ψ_l + A_l) 一项,正是提醒激活值这个“隐藏的重量”。实际训练仍需配合激活检查点(activation checkpointing)来压峰值。
为何常见:公式只算了模型状态,忽略了“剩余状态”里的激活开销,把分片当成了万能药。
误区既然单卡显存随 N 线性下降,那就不停加卡,显存省了速度肯定也上去了。
正解Stage3 的通信量固定在 3Ψ,不随 N 变化。N 增大后单卡计算量变小,通信占比迅速上升,最终被互连带宽卡死。所以大集群常用 HYBRID_SHARD:节点内 FULL_SHARD、节点间整副本,用局部冗余换全局通信的下降。显存优化是必要不充分条件,速度还要看计算/通信重叠做得好不好。
为何常见:把“显存装得下”直接等同于“训练跑得快”,忽略了分布式系统里通信才是常客。
面试题
Q1. ZeRO 的第一个阶段首先在 GPU 之间分片哪类状态?
- A.模型参数分片
- B.梯度信息分片
- C.优化器状态分片← 正确答案
- D.激活值压缩存储
答案:C。ZeRO 按“从重到轻”的顺序优化:先分片最重的优化器状态(12Ψ),再分梯度(2Ψ),最后分参数(2Ψ)。A 是阶段三、B 是阶段二的职责;激活值不在 ZeRO 的分片范围内。类比登山队先拆 12 斤的药箱,而不是 2 斤的衣服。
Q2. 使用 Adam 优化器时,一个参数对应的模型状态总字节数是?
- A.大约12Ψ字节
- B.大约14Ψ字节
- C.大约16Ψ字节← 正确答案
- D.大约20Ψ字节
答案:C。模型状态 = 参数 2Ψ + 梯度 2Ψ + 优化器状态 12Ψ = 16Ψ。12Ψ 来自 Adam 的 FP32 主权重、动量 m、方差 v 各 4Ψ。14Ψ 和 20Ψ 都是混淆了分片后单卡公式与总账公式的产物。
Q3. N 卡训练下,ZeRO 阶段二每卡的模型状态显存表达式是?
- A.4Ψ+12Ψ/N
- B.2Ψ+14Ψ/N← 正确答案
- C.4Ψ+14Ψ/N
- D.2Ψ+12Ψ/N
答案:B。阶段二只保留参数 2Ψ 每卡完整,梯度和优化器状态合计 2Ψ+12Ψ=14Ψ 被 N 分摊,故为 2Ψ+14Ψ/N。A 是阶段一表达式;C 和 D 分别混淆了参数是否完整、优化器是否分片。
Q4. ZeRO 阶段三在计算某一层前向时,需要先做什么?
- A.AllGather 借回该层参数← 正确答案
- B.ReduceScatter 同步该层梯度
- C.Broadcast 广播该层参数
- D.Alltoall 交换该层参数
答案:A。阶段三的参数分散在各卡,前向计算某层前必须 all-gather(借衣服)取回该层完整参数,用完即 reshard。ReduceScatter 是反向时的动作;Broadcast 和 Alltoall 都不是 FSDP 的取数方式。
Q5. 以下哪些属于 FSDP 的关键工程机制?
- A.参数扁平化为一维张量← 正确答案
- B.前向取回用完即弃← 正确答案
- C.自动包裹策略按层切分← 正确答案
- D.激活值跨卡动态共享
答案:ABC。FSDP 的三个核心机制正是参数扁平化、unshard/reshard 循环、auto_wrap_policy 按层包裹。激活值不属于模型状态,FSDP 不分片激活值,D 错误。
Q6. 使用 8 张 GPU 训练 7.5B 参数模型,ZeRO 阶段二的单卡模型状态约为?
- A.约 15 GB
- B.约 28 GB← 正确答案
- C.约 41 GB
- D.约 120 GB
答案:B。Stage2 单卡 = 2Ψ + 14Ψ/N = 2×7.5 + 14×7.5/8 = 15 + 13.125 ≈ 28GB。15GB 是 Stage3(16Ψ/N),41GB 是 Stage1(4Ψ+12Ψ/N),120GB 是 DDP 基线。
Q7. 相比 DDP 的通信量 2Ψ,ZeRO 阶段三通信量约为?
- A.与 DDP 完全一致
- B.约为一点五倍← 正确答案
- C.约为两倍数量
- D.约为三倍数量
答案:B。Stage3 通信 = 前向 all-gather Ψ + 反向 all-gather Ψ + 梯度 reduce-scatter Ψ = 3Ψ,是 DDP(2Ψ)的 1.5 倍。这正是“用通信换显存”的代价,也是 HYBRID_SHARD 存在的理由。
Q8. 下列哪些做法可以进一步压降 FSDP 训练的显存峰值?
- A.开启 CPUOffload 卸载← 正确答案
- B.使用激活检查点技术← 正确答案
- C.按小粒度自动包裹分片← 正确答案
- D.成倍增大训练批量大小
答案:ABC。CPUOffload 把优化器状态寄放 CPU,激活检查点省去大量激活值,小粒度自动包裹让“借用”更精细——三者都能降峰值。增大 batch size 只会在单位时间内累积更多激活值,反而推高峰值显存。
Q9. FSDP 的 FULL_SHARD 分片策略对应 ZeRO 的哪个阶段?
- A.阶段零无任何分片
- B.阶段一优化器分片
- C.阶段二梯度分片
- D.阶段三全状态分片← 正确答案
答案:D。FULL_SHARD 对三类模型状态全部切分,即 ZeRO 阶段三;SHARD_GRAD_OP 对应阶段二;NO_SHARD 等价于 DDP。命名里的 FULL 指“全状态分片”,不是“全参数”。
Q10. 关于 ZeRO 显存优化,下列说法正确的是?
- A.总存储下降源于消除冗余← 正确答案
- B.单卡显存负载明显下降← 正确答案
- C.集群通信量略有增长← 正确答案
- D.激活值也会被一并分片
答案:ABC。ZeRO 消除的是 DDP 里 N 份重复副本,集群总存储从 N×16Ψ 降到 16Ψ,单卡从 16Ψ 降到 16Ψ/N,代价是通信从 2Ψ 涨到 3Ψ。激活值不属于模型状态,ZeRO 不分片激活值,D 错误。