很多人一听说多GPU训练,第一反应是:把我的模型放到好几张卡上,不就行了吗?但真正调过的人都知道,卡越多,坑越多。跑起来吞吐不升反降、显存不足报错、卡间通信卡死,这类问题我见过太多次。问题的根源通常不在“模型有多大”,而在“你用了什么方式把模型放到多张卡上”。放法决定了通信开销,通信开销决定了训练吞吐的天花板。
这次我把Hugging Face生态下最常用的四种多GPU并行方案——DP、PP、TP、ZeRO——逐个讲透。每种方案解决什么问题、付出什么代价、落地时容易踩哪些坑,我都会结合实操经验展开。这篇文章适合正在用Transformers训练模型、但发现单卡显存或速度不够的人;也适合看过一些并行概念、却始终没搞清DDP和DP区别、ZeRO三个Stage到底切了什么的人。看完之后,你应该能根据自己手里的GPU数量和模型大小,直接选出合适的方案。
1. DP和DDP:看似只差一个字母,性能差出一个数量级
很多初学者第一次接触多卡训练,用的都是torch.nn.DataParallel,也就是DP。后来看到各种博客说要用DDP,又不知道两者到底差在哪。这里先把这个基础问题彻底说清楚,因为后面对PP、TP、ZeRO的理解,都建立在“数据和梯度怎么在卡间流动”上。
1.1 DataParallel的硬伤:GPU0承载了所有“汇总工作”
DP的实现逻辑很直观:主进程把模型复制到每一张卡上,每次训练把一个batch拆成若干小份分发下去,各卡独立前向、反向。问题出在反向之后——每张卡算出的梯度需要汇聚到一起求平均,这个汇总是由GPU0统一完成的。也就是说,每一步训练,GPU0都要把所有卡的梯度收回来、算出平均值、再广播回去。如果模型有10亿参数,这个收发量会非常可观。
更糟糕的是,前向计算时模型输入也要从GPU0分发出去,损失也要汇总回GPU0。整张卡的显存被这些临时数据压得很满,同时GPU0的计算和通信负载远高于其他卡。实际训练时你会经常看到GPU0显存快满了,其他卡还有大量剩余。这种负载不均衡,导致DP在多卡场景下的加速比非常差,通常4张卡能跑出2.5倍左右的吞吐就已经不错了。
这里还要点名一个隐蔽问题:DP用的是多线程,受Python GIL和CUDA thread竞争影响,通信和计算更难重叠。这也是为什么社区一致建议:DP只适合用来验证代码逻辑,真正训练一律换成DDP。
1.2 DDP的梯度同步:通信量、bucket与常用配置
DDP的全称是DistributedDataParallel,它改用多进程方式:每个GPU对应一个独立进程,每个进程都持有一份完整模型副本。这带来一个关键变化——每张卡各自完成前向和反向,梯度算好后不需要把原始梯度发给某个中心节点,而是通过All-Reduce操作直接在所有卡之间求出平均梯度。每个进程得到的最终梯度是一致的,于是本地更新自己的参数副本即可。
All-Reduce要传多少数据?以最常见的Ring-AllReduce算法为例,假设模型参数量为P,梯度是fp32,每张卡的通信量大约是2 × P × 4字节。这个系数2来自在环上既能接收邻居数据、也能向后发送数据的过程。对于7B模型,一轮梯度同步要传约56GB数据,听起来吓人,但实际是通过多个通信桶并行执行的,而且现代NCCL在NVLink和InfiniBand上的带宽能跑到几十GB/s,所以瓶颈并没有想象中那么夸张,但也不能忽视。
DDP之所以比DP快,核心就两点:没有了“GPU0单点汇聚”的瓶颈;梯度通信被拆成很多小桶,和反向计算重叠进行,显卡在算梯度的时候,通信已经在传上一层算好的梯度了。
实际训练时,有几个配置直接影响DDP性能,我列在这里供参考:
--gradient_as_bucket_view=True:可以避免每次反向都重复创建梯度张量,减少显存碎片。find_unused_parameters=True:当模型里有参数不参与Loss计算时(比如某些辅助头),必须开启,否则会报错或同步卡死。static_graph=True:如果模型结构固定不变,告诉DDP不再检测计算图变化,能省下一些额外开销。
还有一个容易忽略的点:DDP每个进程是独立吃数据的,所以batch_size要理解为“单卡batch”。全局有效batch等于单卡batch × GPU数量 × 梯度累积步数。很多人调了半天学习率,其实只是没搞清这个换算关系。
2. 管线并行PP:按层切开后,最值得算清楚的一笔账是气泡
当模型大到单卡显存放不下的时候,DP/DDP就不成立了——DDP要求每张卡都有一份完整模型副本。这时候你有两条路:把模型按层切开放在不同GPU上,这是管线并行PP;把单个计算算子切开放在不同GPU上,这是张量并行TP。先看PP。
2.1 按层切分后,显存压力如何被均摊
PP的做法很直观:假设模型一共48层Transformer,你有4张卡,就把第1-12层放到GPU0,第13-24层放到GPU1,依此类推。每张卡只需要保存自己负责的那一段权重、梯度和优化器状态,显存压力瞬间降至原来的四分之一左右。
听起来很完美,但有一个致命问题:数据是顺序流过这些层的。GPU0算完第12层之前,GPU1只能干等;等GPU0把中间激活传给GPU1,GPU1开始算第13-24层,这时候GPU0又没事干了。如果只是朴素地一批一批过,整个训练过程里大部分GPU都处于闲置状态,利用率低到让人崩溃。
解决思路是引入微批次。把一个大的batch切分成若干个micro-batch,比如一个batch包含32个样本,就切成8个micro-batch,每个micro-batch包含4个样本。GPU0算完第一个micro-batch就传给GPU1,同时立刻开始算第二个micro-batch。这样不同GPU就能像流水线一样重叠工作。
2.2 微批次与气泡占比的计算
流水线重叠工作后,仍然存在“气泡”。所谓气泡,就是流水线启动阶段和排空阶段产生的空闲时间。一个经典公式可以帮我们估算气泡占比:
设P为管线阶段数,M为微批次数量,气泡率 ≈ (P - 1) / (M + P - 1)
这里给了我们两个直观结论:管道切得越深(P越大),气泡越大;微批次越多(M越大),气泡越小。假设4张卡切成4段,微批次16个,气泡率约 (4-1)/(16+3) ≈ 15.8%;如果把微批次增到32个,气泡率降到约8.6%。
所以PP不是简单“把模型切开就完事”,微批次数量要仔细调。但微批次也不是越大越好,因为每个micro-batch都会产生中间激活,这些激活在显存里要保留到反向计算时使用。micro-batch越多,同时存活的激活越多,显存压力越大。实践中需要配合梯度检查点(gradient checkpointing)来降低激活占用,或者把micro-batch控制在合理范围。
2.3 1F1B调度:让前向和反向重叠起来
上面讨论的还是“先全部前向、再全部反向”的朴素流水线,也就是GPU会把所有micro-batch的前向都跑完,才开始反向。这种方案显存峰值很高,因为反向需要用到所有micro-batch的中间激活。
更高效的做法是1F1B调度,即一个micro-batch前向完成后,马上开始另一个micro-batch的反向。这种“前向与反向交错执行”的调度,能让显存峰值显著下降,同时气泡率也和之前公式基本一致。目前主流框架(比如Megatron-LM)默认就是1F1B或类似变种。
我在第一次调PP时踩过一个典型的坑:手工切分模型层时,忽略Embedding和最后的输出层。有些模型的Embedding和输出头是共享权重的,如果这两个部分被分到不同GPU上,就不只是通信问题了,权重同步会变得非常麻烦,甚至导致精度对不上。遇到这种情况,建议把共享权重固定在某个GPU上,或者用同步机制保证两处参数一致。
3. 张量并行TP:算子内部分片,互联越强越能发挥威力
PP是把模型按“层”纵切,TP则是把每一层的计算“横切”——同一个线性层算子的权重,被切到多张卡上同时计算,最后再合并结果。
3.1 两个线性层的切分套路,以及为什么需要两次All-Reduce
Transformer里最核心的计算是线性层(Y = XW)。一个25B模型,权重绝大多数都集中在线性层上。TP的基本思路就是这样切分线性层:
- 若把权重矩阵按列切开,每张卡负责输出维度的其中一部分,那么每张卡算出的只是部分输出,必须在所有卡之间做一次All-Reduce,把部分输出拼成完整输出。
- 若把权重矩阵按行切开,每张卡拿到完整的输出维度,但是只吃输入的一部分,这种情况下前向计算里每个卡也会只处理部分输入,同样需要一次All-Reduce来汇总跨卡的部分和。
Megatron-LM的经典做法是:第一个线性层(比如QKV投影)按列切分,得到的输出不用立刻合并;接着做attention计算,最后需要输出时,再用第二个线性层按行切分来抵消通信。这样每个Transformer block里大约只需要做两次All-Reduce。这个安排不是随便定的,是为了让通信次数尽量少,同时保证功能完全等价。
3.2 通信是TP的生命线,NVLink决定了上限
TP最大的特点是通信频率极高。普通Transformer里几乎每个线性层都要伴随一次All-Reduce,而PP只需要在层边界通信。这意味着TP对卡间带宽的敏感度非常高,高到跨机器基本做不了。
我在实际项目里测试过,一台8卡A100服务器,卡间走NVLink,带宽约600GB/s,TP并行跑起来非常顺;一旦尝试把TP组跨到两台机器,走的是InfiniBand或RoCE,带宽可能降到几十GB/s,训练速度直接断崖下跌。原因是TP在每层计算里都有同步点,跨机延迟会被放大几十倍。
所以TP的使用边界非常明确:单机内、并且卡的互联是NVLink或者PCIe Switch这种高带宽低延迟的拓扑,才适合TP。如果你手里是多台机器,优先考虑PP或者ZeRO,把TP范围控制在单机内。
3.3 组合并行里的分工:DP、PP、TP各管哪一层
实际训练超大模型时,通常不是只用一种并行技术,而是三者组合。经典的分工方式是:
- TP负责单机内的算子切分,让模型单卡显存放不下也能跑;
- PP负责跨机/跨卡的层切分,进一步降低不同节点间的通信压力;
- DP则把上述并行组复制成多份,用来扩大总batch size,加速训练。
这样做的原因很简单:DP通信频率低,适合跨机;TP通信频率高,只放单机;PP介于两者之间,既跨机又不会太频繁通信。所以3D并行的顺序通常是:先TP(内部),再PP(节点间),最后DP(组间)。
4. ZeRO:把优化器状态拆出去,显存焦虑的真正解法
再来看ZeRO。说实话,现在用Hugging Face训练大多数人首选不是PP/TP,而是ZeRO,因为它的易用性和显存收益太明显了。
4.1 模型状态不止是模型参数,Adam的12字节开销要算清楚
很多人以为显存大头是模型参数,其实训练过程中,显存消耗主要来自四部分:模型参数、梯度、优化器状态、中间激活。其中优化器状态经常被忽略,但它恰恰是大模型训练的显存黑洞。
以最常见的AdamW为例,混合精度训练下每个参数需要维护的状态包括:
- fp32的master weight(4字节)
- 一阶动量m(4字节)
- 二阶动量v(4字节)
加上fp16模型参数本身(2字节)和梯度(2字节),一个参数量为P的模型,仅模型状态就需要约16P字节。也就是说70B的模型,光参数+梯度+优化器状态就要超过1.1TB显存。这还没算激活和临时buffer。所以70B模型在8卡80G的H100上全参数微调,纯靠DDP是不可行的,因为8卡总共只有640G,远不够。
4.2 ZeRO的三个Stage分别切掉了什么
ZeRO的出发点很聪明:DDP里每张卡都保存一份完整的参数、梯度和优化器状态,但训练时它们不一定需要同时存在。于是它把“模型状态”按三种维度切分,对应三个Stage:
- ZeRO-1:只切优化器状态。每张卡只保存一部分优化器状态,梯度算完后需要做Reduce-Scatter把梯度分片同步到对应卡上。显存约降到原来的1/4(不算激活时)。
- ZeRO-2:切优化器状态 + 梯度。反向计算时,梯度不再每张卡都存全量,而是按卡分片存。显存进一步降到1/8左右。
- ZeRO-3:切优化器状态 + 梯度 + 参数。前向/反向时,每层参数用All-Gather临时广播到所有卡上,用完再丢弃。显存随卡数线性下降。
为了直观,我列个表:
| ZeRO Stage | 切掉内容 | 每个参数的理论显存开销 | 需要的主要通信原语 |
|---|---|---|---|
| Stage 1 | 优化器状态 | 约2+2+P/N相关优化器状态 | Reduce-Scatter |
| Stage 2 | 优化器状态 + 梯度 | 约2+少量梯度 | Reduce-Scatter |
| Stage 3 | 优化器状态 + 梯度 + 参数 | 约2 + 每层临时参数 | All-Gather + Reduce-Scatter |
注意:通信量并不因为切了东西变小。Stage 2相比DDP,通信次数一样,只是每次传的数据从“全量梯度”变成了“分片后的梯度”。Stage 3则更糟,前向要All-Gather参数,反向要All-Gather梯度再来Reduce-Scatter更新分片,通信量大约是DDP的1.5倍。
4.3 ZeRO-3慢在哪,以及FSDP为什么还在流行
很多人开了ZeRO-3之后发现训练速度骤降,原因就在这里:每层计算前都要All-Gather一次完整权重,如果模型有50层,一个micro-batch就要发起50次以上集合通信。通信延迟成为主要瓶颈,尤其是在小batch和高微批次数量下。
PyTorch原生的FSDP(Fully Sharded Data Parallel)底层思想和ZeRO-3一致,但FSDP做了一些更细的优化,比如参数预抓取、按层手动切分、通信与计算重叠等。所以现在很多项目选择FSDP而不是DeepSpeed ZeRO-3,不是FSDP显存省得更多,而是它在通信重叠上更容易调到理想状态。
我的建议是:单机8卡以内、卡间带宽好,优先用ZeRO-2;显存实在不够、或卡数多到需要用ZeRO-3/FSDP时,再切过去,并且一定要配合开启梯度检查点,否则中间激活会先把你卡爆。
另外,ZeRO还有一个Offload的变体,把优化器状态和梯度放到CPU内存,GPU只留参数和计算流。这适合单卡显存小但CPU内存充足的机器。缺点是CPU与GPU之间PCIe拷贝会成为瓶颈,训练速度会下降,但至少能让你在16G消费级显卡上跑7B模型的全参数微调。
5. Hugging Face生态里的落地姿势:Trainer、DeepSpeed与实际选型
原理讲完,落到Hugging Face里到底怎么操作,这个更贴近日常。
5.1 Trainer与Accelerate:最省心的DDP入口
Hugging Face的Trainer已经内置了DDP支持。只要用torchrun启动,并设置--num_processes(或直接用Accelerate插件),Trainer就会自动把训练逻辑切成多进程。
一个最简起动命令是:
torchrun --nproc_per_node=4 train.py \ --model_name_or_path meta-llama/Llama-2-7b-chat-hf \ --per_device_train_batch_size 4 \ --gradient_accumulation_steps 8 \ --output_dir ./output配合Accelerate更省心。accelerate config可以交互式设置分布式参数,它会自动处理RANK、LOCAL_RANK、WORLD_SIZE这些环境变量。很多新人在裸写DDP时报错,80%是环境变量没设对,用Accelerate能直接规避。
注意一点:Trainer的--per_device_train_batch_size是单卡batch,不是总batch。我在项目里常看到有人以为设成8就是总batch,结果8卡变成总batch 64,学习率跟着就炸了。建议先算清楚:总batch=per_device_batch × num_gpus × grad_accum。
5.2 DeepSpeed配置实战:一个能直接跑的JSON
Transformers Trainer对DeepSpeed的支持很成熟,只要传入一个DeepSpeed配置文件,比如ds_config.json:
{ "zero_optimization": { "stage": 2, "offload_optimizer": { "device": "cpu", "pin_memory": true }, "allgather_partitions": true, "allgather_bucket_size": 5e8, "overlap_comm": true, "reduce_scatter": true, "reduce_bucket_size": 5e8, "contiguous_gradients": true }, "gradient_accumulation_steps": 8, "gradient_clipping": 1.0, "train_batch_size": 64, "train_micro_batch_size_per_gpu": 4, "fp16": { "enabled": true, "auto_cast": true, "loss_scale": 0, "initial_scale_power": 16 } }然后在启动命令里挂上:
deepspeed --num_gpus=4 train.py \ --deepspeed ds_config.json \ --model_name_or_path meta-llama/Llama-2-7b-chat-hf \ --per_device_train_batch_size 4这里面三个batch字段一定不能写错。train_batch_size是总batch,必须等于train_micro_batch_size_per_gpu × GPU数 × 梯度累积。很多人DFS某个字段没对齐,会莫名其妙的OOM或者收敛异常。另外,如果模型里有不参与Loss的额外参数,DeepSpeed配置里要加"zero_allow_untested_optimizer": true,否则会拒绝启动。
5.3 PP和TP在HF生态的现状:需要借助Megatron
不得不承认,Hugging Face Trainer对PP和TP的集成远不如ZeRO那么顺手。Trainer本身没有把Megatron-LM的TP切分逻辑完整封装进去,要真正跑起PP+TP,主流路线是:
- 用NVIDIA的Megatron-LM或NeMo框架做模型切分;
- 或者用
transformers加载已切分好的模型权重(比如Hugging Face Hub上很多大模型权重本身就是按TP切好发布的); - 或者借助
llama-recipes这类专门为LLaMA优化的工具。
如果你只是想快速对比效果,可以在Trainer之外配合pipeline_parallel模块手动切分,但工程复杂度会上升一个量级。我的观点是:小团队微调大模型,能用ZeRO/FSDP解决就别上PP/TP,否则调试成本和维护成本都很高。PP/TP更适合超大模型预训练场景,也就是你明确知道自己要跑数十B乃至上百B参数的那种情况。
5.4 一张表看懂选型逻辑
我自己在实践里,基本按下面这个决策逻辑走:
| 你的情况 | 推荐方案 | 原因 |
|---|---|---|
| 单卡放得下模型,想加快速度 | DDP(Trainer默认) | 最简单,加速比接近线性 |
| 单卡放不下,但有8卡单机,显存够 | ZeRO-2 / FSDP | 通信量低,显存省得多,稳定 |
| 单卡放不下,卡也不多(4卡),显存很紧 | ZeRO-3 + 梯度检查点 | 最大限度压显存 |
| 模型几十B以上,跨多节点 | PP + TP + DP(Megatron) | 3D并行是确定性出路 |
| 消费级显卡,只想微调 | ZeRO-Offload或QLoRA | 显存和速度折中 |
这个表省去了很多纠结。记住一句话:显存不够先开ZeRO,通信太慢再调并行度,别一上来就上最高配置。
关于加速比还有一个经验判断:如果你8卡训练比4卡快了不到1.5倍,先怀疑通信占据主要瓶颈,用nvidia-smi观察卡间通信是否长期跑满,或者用NCCL的NCCL_DEBUG=INFO日志看每个集合通信的平均耗时。多数情况是batch size太小、梯度同步频繁导致的。把梯度累积调整到合适范围、开启overlap_comm,通常能立竿见影。
从我个人的实操感受来说,多GPU训练选型最忌讳的是“贵就是好,全都要上”。大部分开源模型用单机8卡,配合DDP或者ZeRO-2,加上混合精度和梯度检查点,就能跑得非常顺。真正需要把PP/TP/ZeRO全部组合起来的场景,至少是百亿参数以上,普通项目千万不要轻易碰。
最后再分享一个实用技巧:无论你选哪种并行方案,在正式跑长训练之前,先用一个很小的数据子集、跑二三十个step,同时盯住nvidia-smi的显存和GPU利用率。如果显存曲线稳定、利用率高、日志里没有NCCL超时警告,再放心跑全量数据。这个“最小冒烟测试”能帮你提前暴露大多数分布式配置问题,省下的时间远超那几分钟测试开销。多GPU训练的核心从来不是方案听起来有多先进,而是它能在你的实际硬件上稳定跑出应有的吞吐。