ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

多卡训练变慢?从集合通信原语与AllReduce开始排查

多卡训练变慢?从集合通信原语与AllReduce开始排查 1. 多卡扩展性差先学会从通信层找原因上周帮一个做推理优化的朋友排查8卡训练掉速问题单卡A100跑得很稳扩到8卡反而只比单卡快了一点。他用的是常见的PyTorch DDP代码看起来也没问题我下意识看了一眼网卡占用AllReduce占了整个step的三分之一还多。那一刻我意识到很多人以为多卡加速的瓶颈在代码调度或数据加载其实真正卡住你的往往是通信层——而通信层里最容易被忽略的就是集合通信原语的选择与实现方式。这篇文章我就以第09讲的视角把网络通信基础与集合通信 Collective 原语这块内容彻底讲透。不堆公式但会把必要的直觉、推导和实际踩坑都放进去。适合正在做分布式训练、推理服务、高性能计算或者只是想把多卡程序跑快一点的人。看完你至少能回答两个问题AllReduce 为什么慢在什么地方面对一个通信任务到底该选哪个原语、怎么排查瓶颈。1.1 一次8卡掉速排查给我的“通信基础课”先说那次排查。朋友的程序是标准 DDP每张卡一个进程梯度算完以后要同步。单卡训练没问题4卡训练也还能接受但8卡时每步耗时反而比4卡还高。刚开始怀疑是数据加载导致但 profiling 显示数据加载占比很低GPU 利用率也普遍低于50%反倒是在 AllReduce 这一步上消耗异常。我用工具抓了一下通信耗时分布发现大部分时间不是花在 PCIe 传输上而是花在了等待上——各卡的计算结束时间不同先算完的卡在同步点等着后面的人等到大家都聚齐后AllReduce 本身又因为跨节点走的是低带宽网络把整体耗时拖长了。真正的问题不是“通信本身慢”而是“通信没有和计算重叠且跨节点链路的带宽被当成了机内带宽在用”。这件事给我的启发是任何多卡系统先把通信路径画出来再谈优化。通信路径不是一句“大家互相传数据”就完了它包含网络层次、缓冲区、协议栈、拓扑结构以及你调用的原语到底怎么把数据从一个节点搬到另一个节点。1.2 一张表看懂带宽与延迟的真实差距想理解集合通信先得知道底层网络通信的物理限制。很多人对带宽没有概念觉得“网卡很快”但放到分布式训练里你传输的数据动不动就是几百 MB 到几个 GB物理链路的吞吐就成了硬上限。我整理了一张常用链路的参考值表注意这里都是量级参考具体型号会有差异链路类型典型单向带宽传输1GB数据的理想耗时典型往返延迟机内内存/HBMTB/s量级约0.3~0.5ms数十nsNVLink (单机多卡)约300~900GB/s约1~3ms约1usPCIe 5.0 x16约64GB/s双向单向略低约20~30ms约1~2usInfiniBand NDR 400Gbps约50GB/s理论实际约35~45GB/s约25~30ms约1~2us25Gbps以太网理论3.125GB/s实际约2.5GB/s约400ms约10~50us受协议和负载影响看这张表你就能理解为什么多机训练时 AllReduce 那么痛机内显存到显存的延迟是纳秒微秒级但一旦跨节点你就要把上百 GB 的梯度通过几十 GB/s 的链路送出去。这就相当于你从小区里走到隔壁邻居家只需要几秒钟但从一个城市搬到另一个城市哪怕开了高速也得按小时计算。延迟的作用在小消息上尤其致命。一个 4KB 的消息如果链路延迟是 20us你传输它本身的时间几乎可以忽略但等待握手、排队、确认这些固定开销会让实际耗时远远大于数据搬运时间。这也是为什么底层会对不同大小的消息走不同路径后面讲实现细节时我会展开。1.3 为什么点对点通信在多卡场景里不够用有的初学者会问我直接让每张卡把数据发给其他卡不就行了吗理论上可以但工程上是灾难。假设有 P 个节点每个节点要把自己的数据发给其他 P-1 个节点那每个节点都必须发起 P-1 次发送、接收 P-1 次数据总通信操作数是 O(P^2)。当 P64 时每个节点要处理 126 条数据流整个集群要协调几千条流任何一条流的拥塞或延迟都会拖垮全局。更关键的是点对点通信无法表达“语义”。你告诉底层“大家把梯度加起来然后每个人拿一份”它可以用最优的算法去组织数据流但你只告诉它“A发给B、C发给D”它就只能按这种低效方式执行。集合通信原语存在的意义就是把这层语义抽象出来让通信库根据节点数、数据量、拓扑结构去选择最合适的实现方案。所以学集合通信千万不要把它当成“API 调用大全”要把它当成“对一类数据流模式的命名”。理解了这一点后面看源码也好、调优也好都会顺很多。2. 集合通信原语的直觉站在数据流模式上看问题原语的英文是 primitive中文也叫“元操作”意思是它是更复杂操作的基础构成块。集合通信Collective Communication则是把一组节点组织起来完成一个整体通信任务。它和点对点最大的区别在于它必须涉及一组节点而且通信模式是预先定义好的。你不需要一次记住所有原语但你必须掌握它们的直觉。下面我按数据流模式把它们分成三组讲每一组都有一个非常形象的记忆方式。2.1 Broadcast、Scatter、Gather三个搬运工的区别要刻进脑子里这三个原语最容易混淆因为看起来都是“一个节点和多个节点之间传数据”。它们的本质区别在于“数据是否相同”以及“数据是否被切分”。Broadcast 是一份数据复制给所有人。一个节点有完整数据其他节点最终也拥有这份完整数据。想象一下微信群发通知群主把同一段文字发到群里所有人都收到一模一样的副本。该过程每个节点的数据量是原始数据的一整份。Scatter 是把一份大数据的切片分给不同节点。一个节点持有完整数据但它把它切分成不重叠的块第 i 块分给第 i 个节点每个节点最终只拥有一块且所有节点加起来才拼回完整数据。类比是老板把整块蛋糕切成若干份分给每个员工员工各自只拿到自己那一份。Gather 和 Scatter 正好相反。每个节点分别持有一部分数据最终汇聚到一个节点上。这个目的节点收到的数据是所有节点数据的拼接。类比是部门员工把各自的周报汇总发给老板老板最后拿到的是一份完整汇总。这三个原语放在一起对比会更清楚原语数据是否相同是否切分方向特点典型语义Broadcast相同不切分一人到多人把同一份配置同步给所有节点Scatter不同切分一人分发多份不同数据把一个大矩阵按行分给各节点Gather不同拼接多人汇聚到一人把各节点计算结果收集到root我看到很多人学到这里会纠结一个问题Broadcast 和 Scatter 传输的总数据量不一样吗其实从 root 节点的发送量看都是完整数据量 M但从每个接收节点的视角看Broadcast 每个节点收到 MScatter 每个节点只收到 M/P。通信总线上实际流动的字节数取决于实现但语义上的差异很大——你在代码里调错一个 API后果可能是每个节点拿到了不该拿的整份数据显存直接爆掉。2.2 AllReduce和Reduce-Scatter训练场景的“高频词”走进分布式训练你会发现 AllReduce 几乎是绕不开的。DDP 每次迭代结束需要把所有 rank 计算出的梯度做平均然后每个 rank 都拿到平均后的完整梯度。这正是 AllReduce 的语义每个节点提供一份输入所有节点最终得到“对所有输入做某种规约运算sum、min、max、avg 等后的同一份结果”。你可以把 AllReduce 理解成 Gather 规约 Broadcast 的组合先把所有人的数据收齐再做规约运算再把结果分发给所有人。但工程上并不会真的这么实现因为那样会让 root 节点成为瓶颈。更优的做法是数据分块后在节点间流水线式地传输和计算这是第3章的重点。Reduce-Scatter 则可以理解为 AllReduce 的“半成品”版本。它执行规约运算但规约的结果不是完整数据在每个人手上而是数据被切分成 P 块每个节点只负责最终存储其中的一块。名义是“规约 分散”。为什么要关注它因为在很多高性能 AllReduce 算法中Reduce-Scatter 是第一阶段后面再接一个 AllGather 就完整了。举个例子帮你看直觉假设两个节点 A 和 B各有向量 [a1, a2] 和 [b1, b2]。Reduce-Scattersum 规约后A 得到 [a1b1, ]第一个位置的结果B 得到 [, a2b2]第二个位置的结果。AllReduce 则要求 A 和 B 都同时拿到 [a1b1, a2b2]。从这个例子也能看出AllReduce 的数据量是每个节点收发完整数据量而 Reduce-Scatter 只处理自己负责的部分总通信量更少。2.3 AllGather与AlltoAll全员参与的两种不同玩法AllGather 又叫全收集。每个节点都有一份属于自己的数据执行完以后每个节点都拥有所有人的完整数据。你可以把它理解成“每个节点都做一次 Gather但所有人都成为 root”。语义上类似 Gather Broadcast 的合并。AlltoAll 则复杂一些。它要求每个节点把自己的数据切成 P 块第 j 块发给第 j 个节点同时每个节点收到来自所有其他节点的第 i 块。最终每个节点拥有的数据是所有节点切片中的一块。这个操作更接近“矩阵转置”如果把所有节点的数据按行排列AlltoAll 执行完之后列变成了行。区别它们的直觉方式AllGather 是“我把我的一整块复制给你你也把你的一整块复制给我最后人人都有一份完整集合”它不做切分AlltoAll 是“我把我手里的牌按照约定拆开分发每个人按某个位置重新拼牌最后每个人收到的都是不同人的碎片组合”。AlltoAll 在 MoE混合专家模型里非常常见因为专家并行的本质就是把 token 按路由结果发送到对应专家所在的节点dispatch 阶段需要把 token 发给不同专家combine 阶段再把计算结果收回。这种“所有人都可能向所有人发不同数据”的模式正是 AlltoAll 的典型场景。2.4 选型判断先想清楚“谁给谁、要不要规约”掌握了原语文义之后实际选型时你不要背文档而是按下面两步走第一步判断数据流向。是单点发多人多点收单点还是多人互相交换数据是同一份还是切片第二步判断是否需要规约运算。如果最终结果不需要所有人共享只需要部分节点各拿一块就可以用 Reduce-Scatter如果还需要所有人共享完整结果那就 AllReduce。这里附一个快速决策表我需要的结果原语一个节点把一份数据发给所有人Broadcast一个节点把数据切片分给不同节点Scatter各节点数据汇到一个节点Gather各节点数据汇总规约所有人拿完整结果AllReduce各节点数据汇总规约但各拿一部分结果Reduce-Scatter各节点把自己的完整数据分享给所有人AllGather各节点按目标节点切片并全局重分配AlltoAll不要小看这张表。很多性能问题并不是网络不行而是你选错了原语导致通信量翻了好几倍。比如只需要各节点各拿一部分场景时你却用了 AllReduce那就相当于多传了 P-1 倍的数据这在节点数多时是致命的。3. 原语耗时不只是数据量的事拓扑和算法同样关键理解了原语语义很多人会误以为“反正都是传数据哪个实现都一样”。真不是。同一个 AllReduce在 8 张卡和一个节点内跑和跨 8 台机器跑耗时能差一个数量级同一个算法数据量大小不同最优实现也不同。这一章我们来拆一拆实现成本和拓扑的影响。3.1 朴素AllReduce的瓶颈root节点被流量淹没先看最直观的朴素 AllReduce 实现选一个节点做 root所有其他节点把自己的数据发给 rootroot 做规约再把结果广播给所有人。假设有 P 个节点每个节点初始数据量是 M那么 root 节点要接收 (P-1) 份 M 的数据加起来是 (P-1)M接收端带宽成了绝对瓶颈。更麻烦的是root 节点不仅带宽被占满它的 CPU/GPU 中断、内存带宽、协议栈处理也会被大量涌入的数据打满。当 P 为 64 甚至更大时root 节点会成为没有争议的故障点其他人的速度都取决于它的处理速度。这种“星型汇聚”的问题在分布式系统里太常见了所以实际通信库几乎不会用这种朴素算法做大数据量 AllReduce。3.2 Ring AllReduce为什么快通信量推导给你看Ring AllReduce 的核心思想是所有节点连成一个环每个节点只和相邻节点通信。也就是说每个节点的收发带宽被均匀地使用没有热节点。它分两个阶段。第一阶段是 Reduce-Scatter把每个节点的 M 数据切成 P 块每个节点负责一块数据沿环向后传每传一轮节点把收到的那块和本地对应块做规约然后把结果继续传给下一节点。经过 P-1 轮后每个节点都有了一块“完整规约结果”——但只是属于自己负责的那一块。第二阶段是 AllGather每个节点把规约好的那一块沿环继续转发经过 P-1 轮后所有节点都收到所有块于是每个节点拼出了完整结果。通信量怎么算第一阶段每轮每节点发送 M/P 大小的数据共 P-1 轮所以每节点发出约 (P-1)M/P。第二阶段同理也是 (P-1)M/P。两项相加每节点总通信量约为 2(P-1)M/P。当 P 很大时这个值趋近于 2M。作为对比朴素实现中 root 节点收发约 (P-1)M M其他节点要收发约 M M至少也是 2M 到 PM 的差异。所以你会看到 Ring AllReduce 在大数据量、节点数较多时非常高效因为每个节点的链路都被均匀使用没有热点。但它也不是没有代价它是严格串行的环上每一步都要等前一步完成小消息时延迟累积非常明显。为此实际库在小数据量时会切换到 Tree树形算法或分层算法而不是一味用 Ring。3.3 分层通信机内NVLink与机间网络不能一视同仁物理拓扑决定了算法的实际收益。假设你有一台 8 卡机器机内 NVLink 带宽可能有 600GB/s但跨节点走 IB 只有 50GB/s。如果通信库把所有节点当成平等节点用 Ring 把所有节点串成一个环会出现一种尴尬的情况某些“边”是高速机内链路某些“边”是低速机间链路整体耗时被最慢的那条边拖死。工程上的通用解法是分层hierarchical通信。以跨多机 AllReduce 为例先在每个节点内部做一次 AllReduce这时用的是高速 NVLink然后把每个节点的规约结果在节点间做一次 AllReduce这时走的是 IB最后再在节点内部做一次广播把完整结果分发给节点内的每张卡。这样低速的机间链路只传输每台机器上的聚合结果数据量从“所有卡的梯度”降到了“每台机器一个聚合梯度”通信压力大幅下降。这也是为什么主流的通信库里都有“拓扑检测”这个模块。它们需要知道哪些 GPU 在同一个 PCIe switch 下、哪些在同一台机器上、哪些跨了 NUMA 节点、哪些跨了机器然后根据这些信息选择最优的原语实现路径。你在使用通信库时如果发现它没开拓扑感知赶紧检查一下环境变量或初始化参数否则性能差距很大。4. 工程实现中的隐藏细节缓冲区、obuf原语与重叠执行很多时候代码没问题、拓扑也没问题性能还是上不去问题出在底层工程细节。尤其当你开始写自定义通信或者对接底层网络库时必须理解数据从应用到网线的过程中发生了什么以及那些不起眼的缓冲区管理原语在扮演什么角色。4.1 一次数据发送在底层要过哪些关卡一个发送操作不是“把数据指针交给网卡”就完事了。拿 GPU 通信举例大致要经过如下步骤应用把要发送的数据放在显存/内存缓冲区里通信库需要确保这块缓冲区在网卡可访问的地址空间内即完成内存注册memory registration通常涉及 pin memory 和地址映射通信库将发送请求包括数据地址、长度、目标等信息提交到发送队列网卡通过 DMA 从指定地址把数据搬到自己的发送缓冲区再切包、加头部、发送到链路接收端网卡校验、重组数据写入预置的接收缓冲区再通知上层。这中间最容易被忽视的是“内存注册”的代价。注册和注销内存是相对昂贵的操作如果每个消息都临时注册开销会非常惊人。所以通信库通常维护一个 buffer pool接口地址复用已经有注册信息的内存块。这也是为什么许多高性能通信代码要求你提前分配通信缓冲区而不是频繁 new/delete。4.2 obuf原语输出缓冲区管理的核心作用与常见坑在网络底层obuf 通常指 output buffer也就是发送侧缓冲区的管理原语。它负责管理“数据从哪块内存被网卡读走”“这块内存什么时候可以安全复用”这些核心问题。为什么需要单独管理因为异步通信场景下当你调用发送 API 返回时数据可能还在内存里没被网卡完整读走。如果你立刻改写这块缓冲区就可能出现“网卡读到一半数据已经被覆盖”的灾难。obuf 原语做的事就是通过引用计数、完成队列、回调机制让你知道“这块发送缓冲已经真正被网卡消费完毕可以回收或复用了”。在实际工程中obuf 管理常见的坑有四个缓冲区复用过早下一轮迭代的数据写进了上一轮还没发送完的 obuf导致发送内容错误。解决办法是等发送完成回调后或者用双缓冲/环形缓冲轮流使用。内存注册开销堆积频繁分配新缓冲区并注册会让 CPU 占用飙升。更好的方案是启动时预注册一个 buffer pool按需从池里取用完归还。zero-copy 的陷阱有些网卡支持直接从用户 buffer 做 DMA但这就要求该 buffer 必须是页对齐且固定内存否则底层还是要先拷贝到一个临时 obuf。你以为自己写了零拷贝实际上走了隐式拷贝。小消息的路径选择小消息如果也走“注册 DMA 完成队列”的完整路径开销反而比直接 memcpy 进一个预先注册好的 obuf 更大。所以底层通常根据消息大小决定走“直发路径”还是“缓冲路径”这个阈值在不同库中还不一样。这些细节在普通业务编程里遇不到但一旦你开始做通信库二次开发、定制集合通信或者在推理引擎里手工管理显存通信它们就是绕不开的坎。4.3 Collective原语的同步语义和“把等待藏起来”的工程手段Collective 原语在语义上不仅是数据传输它还是一个隐式的同步点。比如 AllReduce 意味着所有节点必须都“到达”这个调用后才能开始通信并且通常要等数据全部完成后该调用才返回。这种阻塞语义最容易导致性能问题某个节点计算稍慢其他节点就被迫空转等待。工程上解决这个问题的主流手段是“非阻塞通信”和“通信计算重叠”。以 NCCL 为例它提供了 groupStart/groupEnd 批量提交机制你可以把多个通信操作一起提交减少每次调用带来的内核态开销MPI 里对应的则是 MPI_Iallreduce 等非阻塞接口调用后立刻返回一个 request你在真正需要结果的时刻再去 wait。更进一步的思路是把通信拆碎让它和计算流水线交错。比如一个网络的梯度可以按层分好在第 i 层的反向计算完成后立刻发起第 i 层的 AllReduce等到后面的层算完再同步更大范围的梯度。这样通信时间被“藏”在计算时间里整体 step 的耗时可能只比纯计算多几个百分点。你也可以用 CUDA 的 stream 机制来表述这种重叠把通信 kernel 放到独立的 stream 上通过 event 和计算 stream 做依赖控制避免阻塞主计算流。这个技巧在自定义 pipeline 并行和梯度分段同步中非常有用但对事件顺序的把握要求很高稍有不慎就变成“伪重叠”反而增加同步开销。5. 集合通信调优与排障三个真实案例还原完整链路前面讲的都是原理和直觉但真正让人成长的是排障。我选三个真实项目中遇到的高频问题还原当时完整的排查链路希望对你有参考价值。5.1 案例一AllReduce很慢最终发现传输协议选错了现象8 台机器做 LLM 预训练每步通信耗时约 80ms占比超过 40%。我开始以为是数据量太大但对比同型号配置的另一个集群别人只在 20ms 左右。查了模型并行策略和数据量基本一致。排查过程先看网络监控发现 IB 网卡流量确实在跑但通过工具查看链路层协议时显示走的是 TCP 而非 RDMA。为什么会这样因为通信库选择传输协议的逻辑依赖驱动和库的编译选项如果 IB 驱动没有正确安装或者库编译时没启用 RDMA它就会自动回退到 TCP甚至回退到以太网 over IB。我进一步检查网卡状态发现 IB 的 active 速率正常但驱动版本与通信库要求的版本不匹配导致通信库不信任 RDMA 路径直接走了 TCP 回退。解决办法更新 IB 驱动并重新编译通信库启用 RDMA 传输后AllReduce 耗时从 80ms 降到 18ms。这个案例最值得记住的点是网络链路“通”不等于“快”你要确认上层协议栈用的到底是哪条路。5.2 案例二AlltoAll导致网卡中断打满CPU成了瓶颈现象MoE 模型训练GPU 利用率只有 40%但 CPU 有个核被打满softirq软中断和网络收包处理占用特别高。排查过程通过 profiling 工具看到热点集中在 AlltoAll 阶段。AlltoAll 和 AllReduce 不一样它天然会产生大量小消息每个节点要发给其他所有节点数据又被切得很碎。比如 64 个节点每个节点可能发出几千个小包网卡中断频率暴涨CPU 光是处理“包到了、包发出去了”这些通知就被打满了。解决办法分三层第一开启网卡的 busy poll 模式让收包轮询替代中断明显降低 softirq 开销第二启用 GPUDirect RDMA让数据直接在 GPU 显存和网卡之间流动避免先拷贝到 CPU 内存再拷贝到网卡第三调整 AlltoAll 的分块逻辑合并小消息尽量减少包数量。三层改完CPU 占用降下来GPU 利用率恢复到 80% 以上。这个案例告诉我们AlltoAll 的瓶颈经常不在网络上而在 CPU 处理小包的能力上。遇到 AlltoAll 慢先看 CPU 中断再看网络吞吐。5.3 案例三节点越多收益越少问题出在梯度分块太小现象4 卡扩展到 16 卡理论算力提升 4 倍实际只提升 1.5 倍通信占比直线上升。单看每个 AllReduce 的数据总量并不夸张但通信内部的 chunk 切分非常碎。排查过程我抓了通信库的调试日志发现整个梯度被拆成了几百个小 buffer 分别做 AllReduce。原因是我没有做梯度分桶bucket处理默认情况下通信库按照参数的注册顺序把梯度拆成了很多小块。每个小块的 AllReduce 都引入一次固定的启动开销和传输开销当每块数据只有几十 KB 时延迟开销就完全盖过了带宽收益。解决办法把梯度按大小和依赖关系合并成更大的 bucket把 300 次小 AllReduce 合并成 3~4 次大 AllReduce。合并后通信耗时降到了原来的四分之一扩展比明显改善。这里的经验是AllReduce 不是次数越少越好也不是越多越好而是要在“不必要的同步点”和“过大块的串行等待”之间找平衡。至少梯度过小时先把数据攒一攒再通信。5.4 保留一个诊断习惯先画数据流图再动手调参排查多了以后我有一个习惯拿到一个性能问题不先调参而是先在一张纸上画出整个迭代的数据流图。标记出哪些阶段是计算哪些阶段是通信通信用的是哪个原语、走哪个链路、传了多少数据哪个先哪个后。画完之后80% 的问题其实一眼就能看出来。最后再分享一个小技巧不管是 NCCL、RCCL 还是 MPI几乎都支持输出详细的通信 profiling 信息。打开后你能看到每个原语的实际耗时、传输量、算法选择这些是第一手资料。别凭感觉调参用数据把通信层“透明化”比改十次环境变量都管用。数据流图画清楚瓶颈定位准了剩下的调整往往是很小的改动。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进