
训练大模型这件事真正劝退人的往往不是数学而是“怎么把一堆卡用好”。我最早在单卡上折腾7B模型等模型再往上走、序列再拉长显存和算力就像两条缠在一起的绳子哪根不够都跑不动。这篇系列第四篇就把分布式训练里的几种并行方式一次聊透DDP怎么做数据并行、ZeRO怎么从显存里省出空间、张量并行怎么拆一个矩阵、流水线并行怎么把网络层串成流水线、上下文并行怎么应对超长序列以及它们在实际训练里怎么组合。这篇适合已经跑通过单卡训练、正要往多卡迁移或者想搞清楚DeepSpeed、Megatron那些并行参数到底什么意思的人。1. 先想清楚单卡训不动的时候分布式到底在分什么1.1 显存和算力两条腿单卡为什么跑不起来训练一个大模型卡上有两样东西永远不够用一个是显存容量一个是计算吞吐。显存要装的不只是参数还有反向传播要用的梯度、优化器维护的状态、每一层前向算完没释放的激活值。以Adam优化器为例一个FP16参数在训练时实际要占的参数、梯度、FP32主权重、动量和方差加起来差不多每个参数要16个字节。你听着可能没概念换算一下7B模型光这些就要约112GB单张A100 80G直接告急更别说还有激活值。算力这边也不轻松7B模型一个token的前向反向大概要几十GFLOPs训练几千亿token单卡要跑到地老天荒。所以分布式训练的本质就一句话想办法把模型、数据、序列这些大头切开让每张卡都只承担一部分工作。这里的“切”有四个维度对应四种并行策略。数据并行是每张卡都放一份完整模型把训练数据按batch切开张量并行是把一层里的权重矩阵按行或列切开多卡合算一层流水线并行是把Transformer一层一层切开不同GPU各管几层像工厂流水线一样接力上下文并行则是把超长序列沿长度方向切开每张卡处理序列的一段。搞清楚这四条线你再看各种训练框架的配置就不会被参数绕晕。1.2 四种并行方式的“切法”与一张定位图这四种并行对我来说就像切蛋糕的不同方式。数据并行好比同一份蛋糕模具复制好几份每份模具做不同的蛋糕胚子最后把成果凑一起。张量并行是拿一把刀把同一个模具横竖切几块几个人同时捏一块蛋糕。流水线并行是把做蛋糕的工序拆成好几段第一组人负责和面第二组人负责发酵第三组烘烤。上下文并行则是在“蛋糕很长”的情况下把长条蛋糕切成几段每段单独烤制最后拼起来。并行方式切分对象每张卡保存的内容典型通信模式数据并行 (DP / DDP)batch维度完整模型副本 切分后的数据梯度AllReduceZeRO-DP显存状态 (优化器状态/梯度/参数)模型分片 部分状态梯度规约 参数/梯度广播张量并行 (TP)层的权重矩阵行列一层权重的一部分前向/反向各两次AllReduce流水线并行 (PP)网络层数若干层完整的权重跨Stage的Point-to-Point传输上下文并行 (CP)序列长度完整模型权重 序列分片AllGather KV / Ring传递我建议你先记住这张表后面每个策略展开时都会对应回去。2. DDP数据并行是所有分布式训练的起点2.1 DDP的工作原理多卡复制模型梯度求平均DDP全称DistributedDataParallel它做的事情很朴素启动N个进程每个进程持有一份完整模型加载不同的batch数据各自前向反向算出梯度然后通过Ring AllReduce把N份梯度求和取平均再用平均后的梯度同步更新每张卡上的模型参数。这样训练结束后每张卡上的模型权重都是一样的相当于一个大的batch被拆到N张卡上并行训练。AllReduce这里值得展开讲因为它是通信开销的主要来源。Ring AllReduce不是简单地把N份梯度汇总到一张卡再广播而是把梯度张量切成N片每张卡只跟自己相邻的卡交换一片先做ReduceScatter每张卡得到部分梯度的规约结果再做AllGather每张卡把规约后的完整梯度广播回所有卡。两趟下来通信总量大约是2倍的模型参数量。以7B FP16模型为例梯度也有14GB每步迭代光梯度通信就要传28GB数据。这个数字决定了DDP的扩展效率也决定了它为什么解决不了大模型的门槛问题。用生活化的比喻DDP就像5个厨师各自照着同一本菜谱做一道菜每做完一步就凑在一起对一下火候和调料用量取个平均再继续做。结果大家做出来的菜风味一致但每碰一次面都要花时间。2.2 torchrun实操从单卡脚本改成多卡训练把训练脚本从单卡改成DDP改动其实非常小。最保险的做法是用PyTorch官方推荐的torchrun启动器它会帮你设置好环境变量你只需要在代码里做四件事初始化进程组、把模型包进DDP、每个进程绑到自己的GPU、修改dataloader的sampler确保每个进程分到不同的数据切片。torchrun --nproc_per_node8 --master_port29500 train.py --global_batch_size 2048import torch import torch.distributed as dist from torch.nn.parallel import DistributedDataParallel as DDP # 1. 初始化进程组NCCL是GPU训练首选后端 dist.init_process_group(backendnccl) local_rank int(os.environ[LOCAL_RANK]) torch.cuda.set_device(local_rank) # 2. 包装模型device_ids指定当前进程的GPU model MyModel().cuda() model DDP(model, device_ids[local_rank]) # 3. 重要Dataloader必须用DistributedSampler否则所有进程看到同一批数据 sampler torch.utils.data.DistributedSampler(dataset, num_replicasworld_size, rankrank) dataloader DataLoader(dataset, batch_sizeper_gpu_batch, samplersampler) # 4. 每个epoch要set_epoch保证shuffle效果 for epoch in range(epochs): sampler.set_epoch(epoch) for batch in dataloader: ...几个容易踩的点全局batch size是per_gpu_batch乘以卡数所以学习率也要相应放大我一般按线性缩放lr_global lr_base * world_size但要注意batch太大时不一定线性需要渐进式warmup。另一个坑是不同进程的数据加载顺序必须不同否则梯度同步等于没做这个问题一旦出现很难排查只会在loss曲线抖动上露出马脚。还有多机训练时每台机器的MASTER_ADDR、MASTER_PORT必须一致否则子进程找不到主节点。2.3 DDP的痛点和边界还是装不下大模型DDP解决的是“训练速度”问题它并没有解决“单卡装不下”的问题。每个进程都要保存一份完整模型前面算过7B模型配Adam训练就要100GB以上显存A100 80G根本放不下更别说还有激活值。就算勉强放进去梯度AllReduce带来的通信量也随模型尺寸线性增长跨机训练时节点间带宽往往远低于卡间NVLink通信很快就会成为瓶颈。所以我们后来在7B模型上会看到一种很有意思的动作先用梯度累积模拟大batch再用混合精度把显存压下来实在不行就换ZeRO。DDP这条路适合12B以下、单卡显存够装下模型梯度优化器状态的场景。一旦模型再大就要看ZeRO怎么把显存里的水分挤出来。3. ZeRO数据并行下面的显存焦虑解药3.1 ZeRO的三个阶段到底切了什么DeepSpeed提出的ZeROZero Redundancy Optimizer解决的核心问题是“冗余”。DDP下每张卡都保存了完整优化器状态和梯度明明用的是同一份平均梯度却重复存了N份。ZeRO的思路很简单既然冗余那就把状态切开每张卡只保存自己的那一份用的时候再通过通信拼起来。ZeRO分三个阶段。ZeRO-1只切分优化器状态把原来每卡一份的Adam动量和方差按数据并行度切成N份每卡只保存1/N。ZeRO-2在ZeRO-1基础上再切分梯度每个进程在反向传播后只保留和本地优化器状态对应的梯度分片其余梯度规约完就丢弃。ZeRO-3进一步把模型参数也切了前向反向时需要哪个参数就通过AllGather把它临时广播到所有卡上。存储内容普通DDPZeRO-1ZeRO-2ZeRO-3模型参数 (FP16)2B2B2B2B/N梯度 (FP16)2B2B2B/N2B/NAdam状态 (FP32主权重动量方差)12B12B/N12B/N12B/NB表示模型参数量。以7B模型为例普通DDP大约要112GBZeRO-2降到约31GBZeRO-3降到约19GB靠单卡80G就能跑起来。代价是通信量增加ZeRO-3因为参数也得AllGather比DDP多出约50%的通信。这就引出实际选择问题显存紧张选ZeRO-3通信优先、显存够用选ZeRO-2。3.2 DeepSpeed配置与Stage选择实战DeepSpeed的配置是我见过最直白的一个JSON文件就能控制所有行为。我通常的分类是模型量级在1B到10B之间、希望省显存又不想动代码选ZeRO-2模型超过15B或者想在单卡小显存上跑选ZeRO-3如果还想同时塞进更大的batch再开offload。{ train_batch_size: 1024, train_micro_batch_size_per_gpu: 8, optimizer: { type: AdamW, params: { lr: 3e-4 } }, zero_optimization: { stage: 2, allgather_partitions: true, allgather_bucket_size: 5e8, overlap_comm: true, reduce_scatter: true, reduce_bucket_size: 5e8, contiguous_gradients: true }, fp16: { enabled: true, loss_scale: 0, loss_scale_window: 1000, initial_scale_power: 16 } }这里面有几个参数值得说道。overlap_comm开启后梯度通信和反向计算能重叠吞吐提升明显我实测通常有10%-20%的收益。contiguous_gradients把梯度整理成连续内存块再通信能显著提升NCCL效率。allgather_bucket_size和reduce_bucket_size控制通信分桶大小太小会放大通信次数太大会占额外显存5e8到1e9是个比较稳的范围。代码端只需要用DeepSpeed引擎替换原来的训练循环模型代码不用改。这也是ZeRO最受欢迎的原因对于已经写好训练脚本的团队切到ZeRO几乎零成本。3.3 ZeRO-Offload与CPU卸载的取舍显存实在不够还可以把优化器状态、乃至模型参数卸载到CPU内存甚至NVMe磁盘。offload_optimizer_device: cpu表示Adam状态放CPUGPU只留参数和激活offload_param_device: cpu则是参数也卸载。这样做的好处是把GPU显存彻底解放出来让你能在单卡A100上训练几十B模型但坏处也很直接CPU与GPU之间的PCIe传输吞吐远低于显存带宽每次前向参数通过PCIe传进来、反向再传出去训练速度明显下降。我的实际体会是offload适合“先把模型跑起来验证想法”的场景不适合长时间正式训练。它更像一个兜底方案单卡能跑起来但别指望它快。如果一定要用offload建议把pin_memory打开再配合多线程DataLoader预取能在一定程度上掩盖传输延迟。另外NVMe卸载即使支持我也强烈不建议日常使用磁盘读写寿命和延迟都不适合高频训练读写。4. 张量并行把一个大矩阵劈成几块来算4.1 Megatron-LM的列并行与行并行当单卡显存连一个Transformer层都放不下时数据并行和ZeRO都救不了你了必须把模型结构拆开。张量并行就是把每一层里的矩阵运算拆到多张卡上最早由Megatron-LM系统化落地。以Transformer的MLP层为例核心计算是Y GELU(XW1)W2。W1按列切分让不同的卡计算不同的隐藏单元这叫列并行W2按行切分把各卡的部分结果拼成完整输出这叫行并行。更准确地说Megatron的做法是前向时输入X先广播到每张卡ColumnParallelLinear把W按列切成N块每卡用自己那块和X做矩阵乘得到N个部分结果然后通过AllReduce求和。RowParallelLinear则是输入本来就是分块的每卡用自己那块权重算完后拼接出完整输出。实际代码里Attention的QKV三条路径也会做列并行输出投影再做行并行。这样一来一个Transformer层就能均匀地摊到多张卡上。4.2 张量并行的通信开销为什么TP组不宜太大张量并行的通信开销是四种策略里最密集的。一个Transformer层里MLP和Attention每条路径都伴随两次AllReduce算下来每层前向大概有4次AllReduce反向还要对应再来一遍。虽然每次通信传输的不是全部权重而是切分后的激活规模大约是2 * batch * seq_len * hidden / TP_size但总通信量随batch、序列长度线性增长非常可观。我一般用“通信量/计算量”的比值来判断TP规模。当TP从2加到8计算量被摊薄到1/8通信量却变化不大比值迅速恶化增速反而下降。所以Megatron的推荐值是TP不超过8并且最好在同一个机架、走NVLink互联。跨机做TP我不是没试过RoCE和NVLink的带宽差距在AllReduce场景下会被无限放大性能和稳定性都难看。这也是为什么大厂做大规模训练时通常先保证TP组在单机内部再用其他并行走跨机。4.3 张量并行实操模型改动与注意点自己手写TP不是一个明智的选择除非你想搞清楚细节。实际生产我都用现成实现训练选Megatron-LM或NVIDIA的NeMo推理选vLLM的张量并行。Megatron-LM把你模型的Linear层替换成ColumnParallelLinear和RowParallelLinear并自动插入通信原语。框架层面的key是tensor_model_parallel_size模型维度会按这个值切分。如果你真的需要手写注意几个常见坑。第一输入X必须在TP组内保持一致数据加载时不要让不同TP rank拿到不同数据第二Column输出后一定记得AllReduce再接激活函数否则后面的dropout/layernorm会因为输入分布不一致而语义错乱第三LayerNorm和Dropout这类算子一般不做TP切分它们会让每张卡重复计算这时往往会配合序列并行Sequence Parallel把这些算子在序列维度分摊进一步压缩激活值显存。最后反向传播时通信原语要跟前向严格对应AllReduce的导数还是AllReduce一旦顺序错位训练loss会莫名起飞。5. 流水线并行让每一层GPU都有活干5.1 朴素流水线与微批次流水线张量并行走的是“一层拆多卡”流水线并行走的是“多卡分层”。4张卡就把模型42层按10、10、10、12切第1张卡算第1层第2张卡算第2层传下去。如果把整个batch当成一个整体最原始的做法就是等前一个stage算完整个batch再给下一个stage同一时刻永远只有一张卡在忙其他人干等这叫朴素流水线效率极低。改进办法是微批次micro-batch把全局batch切成更小的micro-batch像流水线一样连续推进。第1卡算完第1个micro-batch立刻传给第2卡自己马上算第2个micro-batch。这样多张卡能同时处理不同层的数据GPU利用率大幅提升。不过流水线有个天然逃不掉的气泡bubble第一个micro-batch从第1卡走到第4卡以及最后一个micro-batch从第4卡走完这两段时间里后面的卡或前面的卡要等待。气泡率可以用一个简单公式估算bubble (p-1) / (mp-1)p是流水线stage数m是micro-batch数。p4、m8时气泡率约27%m翻到16会降到16%。也就是说micro-batch越多流水线越“满”但m太大也会带来额外的通信和step次数开销所以要折中。5.2 1F1B调度与气泡率计算Megatron流水线并行的核心调度方式是all-forward-then-backward和one-forward-one-backward。前者把所有micro-batch前向算完再统一从最后一个stage开始反向显存峰值会非常大因为所有stage都要保存大量中间激活。后者严格交替执行前向和反向1个micro-batch前向后立刻安排一个反向显存峰值被压得很平这也是为什么训练大模型几乎只用1F1B。关于micro-batch数量我的经验是尽量满足m与p成比例至少m大于等于p的2到4倍否则气泡率会显著拖慢训练。举个例子p8m16气泡率约7/2330%这个数字就很扎心m64时降到约10%。但m增大也会改变全局batch组成如果不想改变全局batch大小可以通过增大gradient_accumulation_steps配合micro_batch_size调节。5.3 切分层的经验与Virtual Pipeline切分Transformer层时最常见的错误是按层数等分。实际上embedding层、最后的recompute层、以及每个Transformer里的attention和MLP计算量和显存占用并不相等。我一般会记录每个stage的实际显存峰值和耗时再动态调整把层数多的stage关掉activation checkpoint或者把运算较少的层塞给同一张卡让各stage耗时尽量接近。流水线总的训练时间受最慢的stage限制一个stage拖后腿其他stage全在等它。为了进一步压缩气泡Megatron提出了Virtual PipelineVPP把一个stage负责的若干层再切出多个虚拟段每个虚拟段独立参与流水线调度。这样每个stage能更细腻地安排计算气泡率比同p下下降不少代价是通信张数更多、代码复杂度更高。我建议新手先不要开VPP等真实吞吐数据出来后再尝试毕竟调一堆schedule参数的时间成本也不低。6. 上下文并行长序列训练的最后一公里6.1 序列太长怎么办注意力依赖的显存黑洞近年长文本需求激增模型动辄要处理128K甚至1M token。这个长度下batch维度变小、序列维度变长激活值的大头不再是batch项而是注意力相关的KV矩阵。标准注意力对序列长度平方增长哪怕用FlashAttention把中间矩阵压缩单卡显存放K、V、softmax的中间结果依然吃力。同时反向传播时注意力分数矩阵往往要重算又会占用巨额计算资源。上下文并行Context Parallel就是为了应对序列维度增长。它把一条长序列沿长度方向切成C块每块分给一张卡每张卡都保存完整的模型权重只负责自己这段序列的层内计算。它的巧妙之处在于不需要改动Transformer的基础结构只在Attention部分做跨卡通信来获取完整KV因此能和TP、PP灵活叠加。6.2 上下文并行的两种实现序列切分与Ring Attention实际工程里上下文并行的实现大致分两种。第一种是朴素序列切分每张卡拥有整条序列的一部分计算前先通过AllGather把其他卡的K、V拿过来然后本卡就能完成完整attention计算。这种方式的通信量约等于KV的全部大小对长序列来说可能很大实现简单但效率一般。第二种是Ring Attention风格把序列切成更小的块让注意力计算和通信重叠。每张卡只需要拿到相邻卡的KV块做“分块attention”最后通过约简得到与完整attention等价的输出。它能把通信量控制得更低而且理论上能降到与序列长度线性相关适合超长序列。两种方法各有适用场景。前者适合CP规模小2-4的场景比如把128K序列切成两段各64K后者适合CP规模大、单卡序列段很短的情况。实际框架里Megatron的Context Parallel和DeepSeek用的序列并行方案大多走朴素AllGather路线配合FlashAttention做分块计算效果已经很稳定。6.3 上下文并行与分布式注意力叠加进并行矩阵上下文并行跟TP、PP、DP可以同时开。DP负责把不同batch分到不同DP组TP负责把每一层权重拆开PP负责把不同层分给不同stageCP则把每条样本的长序列切到多个卡上。这四个维度要做成并行策略矩阵。比如8卡可以配成TP2, PP2, DP2全局等价于4D并行的缩小版其中CP在DP组内再切序列。我踩过的坑是CP的通信和TP的通信往往会挤在同一条链路。TP组内每层都做AllReduceCP又在同一批卡上做KV AllGather通信堵在一起吞吐就崩了。实践中的做法是让CP规模小于等于机内卡数并且让TP组和CP组共享同一物理节点避免跨机通信叠加。以DeepSeek-V3的架构看它们的并行配置大约就是按TP PP DP CP四维调度只是根据不同集群拓扑动态调整维度顺序。7. 并行策略组合3D并行与选型建议7.1 一张表看懂四种并行维度的关系四种并行不是二选一而是正交维度可以叠加。数据并行让多组样本并行张量并行让矩阵运算并行流水线并行让不同层并行上下文并行让不同序列片段并行。它们通信模式不同显存收益也不同。并行维度切分对象显存收益通信模式主要瓶颈DP / DDPbatch无每卡完整模型每步梯度AllReduce通信带宽、模型复制显存ZeRO-2/3优化器状态/参数显著梯度规约、参数AllGather通信量增加、延迟TP权重矩阵线性下降每层多次AllReduce通信密集受NVLink限制PP网络层每卡只存部分层少量张量传输气泡率、stage负载不均CP序列长度激活值下降KV AllGather长序列通信开销这也是我把它们排成顺序的原因先看batch维度能不能扩展再看显存能不能通过ZeRO解决再考虑模型结构拆分最后才考虑序列维度。7.2 从小规模到万卡并行策略的演进路线不同规模有不同最优组合。8卡以内我通常先用纯ZeRO-2跑不动再上TP8或TP4, DP2。32卡左右推荐经典3D并行DP4, TP4, PP2这是Megatron最熟的配置稳定性和吞吐都经过验证。数百卡以上才需要上下文并行介入并且要考虑把ZeRO的offload策略、activation checkpointing、重计算技术与并行维度组合起来。顺序的经验是先满足显存约束再谈吞吐。我的建议是每一步配置都只加一个变量跑通后再加下一个。上来就开满TP、PP、CP出了问题你根本不知道是哪个环节卡的。我见过一个团队在64卡上想一步到位开DP2, TP4, PP4, CP2结果训练速度比纯DP加ZeRO-3还慢最后发现是TP和CP的通信全都走了一对慢速NIC拓扑没优化。7.3 常见问题与排查技巧实录真心分享几个排障经验。遇到NCCL超时先别急着怀疑代码。优先看网络拓扑和IB/RoCE网卡是否正常用nvidia-smi topo -m看GPU与网卡的亲缘关系确认通信走的路径是不是最优。其次看不同rank的初始化顺序有没有rank挂死导致其他rank一直等。显存OOM时我一般会在模型代码的关键节点手动打印torch.cuda.memory_summary()或者在启动脚本里挂PYTORCH_DEBUG1。但真正长期训练我不建议开这些因为打印本身拖慢速度。更实用的方法是分段确认先关掉activation checkpointing确认激活占用再关掉ZeRO的offload确认状态占用逐段定位是谁在抢显存。通信慢的问题先区分卡间和后端。单机内卡间慢十有八九是没走NVLink检查容器启动参数和CPU亲和性跨机慢重点看NCCL的IB绑定和网卡多路径也可以试着调大NCCL_BUFFSIZE、开启NCCL_DEBUGINFO观察通信带宽。这些东西在日志里都有线索只是通常不会写成“NN slow”这样直白的话。整体来说我个人的体会是分布式训练从来不是一个“把参数配满就快”的过程它更像一个系统调优的平衡艺术。你加一个并行维度往往要在另一个维度上付出通信代价。真正的优化路径是要先找出你这个模型到底是算力不足、显存不足还是通信等待太多再对症下药。DDP解决不了模型放不下的问题ZeRO解决不了单卡算力不足的问题TP解决不了序列太长的问题它们之间是互补关系不是可选项。我最后养成的习惯是不管用什么框架每次启动训练前先写一段小的profiler脚本把前向耗时、反向耗时、各通信操作耗时分别打出来再决定要不要调并行策略。这个习惯帮我避开了不止一次“配置堆满、性能掉到脚踝”的尴尬。