
前言当你第一次跑通一个单机单卡的PyTorch模型时那种成就感是真实的。loss在下降accuracy在上升一切都那么美好。直到你尝试把同样的代码放到8卡服务器上发现loss不降反升或者干脆卡死在某个epoch不动了。问题出在哪在分布式训练里通信就是那个看不见的瓶颈。昇腾NPU计算再快如果卡与卡之间传数据的速度跟不上整个集群的算力就会被浪费在等待上。这就像一个人包饺子手脚麻利但旁边负责擀皮的人跟不上节奏最终整个流水线还是慢。昇腾CANN的hccl仓库就是解决这个问题的集合通信库。它提供AllReduce、AllGather、ReduceScatter等通信原语让多卡之间的数据同步变得高效。但hccl不是简单的封装了socket API。它的设计里有很多值得拆解的地方为什么要有AllReduce这个操作为什么不能直接用MPI为什么在NPU上跑的hccl跟GPU上的NCCL不一样这篇文章会用层层递进的方式把hccl的设计哲学拆开来讲。不堆砌术语不用首先其次最后的套路就像跟同事在白板前讨论那样把问题聊透。昇腾CANN作为昇腾NPU的异构计算架构hccl是其计算执行层的核心组件。在分布式训练场景中hccl的性能直接决定了多卡训练的扩展效率。从包饺子的流水线到集合通信先不说技术想一个生活场景。你们宿舍四个人一起包饺子。每个人负责一个步骤和面、擀皮、包馅、下锅。这看起来是个流水线但如果擀皮的那个人动作慢后面包馅的人就只能干等着。解决办法有两种。一种是让擀皮的人加快速度这受限于人的体力。另一种是把饺子皮提前擀好放一边包馅的人随时有皮可用。这第二种思路就是通信优化的本质——让数据在需要它的人那里提前准备好。分布式训练里的集合通信做的就是这件事。假设你有4张昇腾NPU卡每张卡上跑着同一个神经网络的一份数据。每个epoch结束后需要把所有卡上的梯度gradient汇总起来取个平均值再分发给每张卡。这样每张卡下次迭代时用的就是全局一致的梯度。这个汇总-分发的过程就是AllReduce。如果不做这个汇总每张卡只用自己的局部梯度那4张卡就等于在训练4个独立的模型根本不是分布式训练。AllReduce不只是把数加起来AllReduce听起来很简单把所有卡上的某个张量tensor加起来再把结果发回给每张卡。但真要高效实现这个操作没那么容易。最naive的做法是把所有卡上的数据都发给卡0卡0加总后再把结果发回给卡1、卡2、卡3。这种方式叫中心化Reduce卡0既是计算瓶颈也是通信瓶颈。如果有64张卡卡0的带宽会被撑爆。hccl里的AllReduce用的是环形算法Ring AllReduce。这个算法的精妙之处在于它把数据切成很多小块让每个卡只跟左右邻居通信通过多轮数据交换最终每张卡都拿到完整的汇总结果。整个过程就像接力赛数据块在卡与卡之间绕圈每绕一圈就有一部分数据完成汇总。用hccl写AllReduce的代码长这样importtorchimporthccl# 初始化hccl通信组rankhccl.get_rank()world_sizehccl.get_world_size()# 每张卡上有一个梯度张量xxtorch.randn(1024,1024).npu()# 调用AllReduce把所有卡上的x加起来再分发给每张卡hccl.all_reduce(x,ophccl.Sum)# 现在每张卡上的x都是全局汇总后的结果print(frank{rank}: x[0] {x[0]})这段代码里hccl.all_reduce(x, ophccl.Sum)做的事情就是把所有卡上的x逐元素加起来结果写回每张卡的x。WHY这样设计直接用torch.distributed.all_reduce也行但hccl的AllReduce是针对昇腾NPU的硬件特性优化的。NPU的互联拓扑HCCS有自己的带宽特性hccl的环形算法会根据实际拓扑调整数据切分策略和通信次序减少等待时间。这就好比接力赛里不是所有人都用同样的起跑节奏而是根据每个人的速度调整接棒时机。AllGather把分散的数据拼起来AllReduce是汇总后分发AllGather则是把每个人的数据拼起来大家共享。典型场景是每张卡上有一段不同的数据比如不同样本的特征向量你需要把这些数据拼成一个完整的大张量每张卡都要拿到这个完整张量。举个具体例子。你有4张卡每张卡上有一个形状为[256, 768]的张量256个token每个768维。你想把4张卡上的数据拼起来得到一个[1024, 768]的大张量每张卡都要有这个大张量。这就是AllGather做的事情。代码实现importtorchimporthccl rankhccl.get_rank()world_sizehccl.get_world_size()# 每张卡上的局部数据形状 [256, 768]atorch.randn(256,768).npu()# 准备接收拼起来后的大张量形状 [1024, 768]btorch.zeros(1024,768).npu()# 调用AllGatherhccl.all_gather(b,a)# 现在b里是4张卡上a的拼接结果print(frank{rank}: b shape {b.shape})WHY这样设计AllGather的通信量是O(N)N是卡数因为最终每张卡都要拿到全部数据。hccl在实现时用了渐进式拼装策略不是等所有卡都发完再拼而是收到一块就拼一块这样通信和计算可以部分重叠。对于大模型训练里的activation收集比如序列并行里的激活值收集这个优化能省不少时间。ReduceScatter先汇总再分片ReduceScatter是AllReduce的一半操作。AllReduce做的事情分两步第一步把所有卡上的数据汇总Reduce第二步把汇总结果发回给每张卡Broadcast。ReduceScatter只做第一步和分片分发汇总后不把完整结果发给所有人而是把结果切成N片第i片发给第i张卡。这在什么地方有用典型场景是分布式优化器的第二步。假设你在做数据并行训练每张卡上有一份完整的模型参数。前向反向传播后每张卡算出自己那份梯度。你需要把所有梯度汇总AllReduce然后用汇总后的梯度更新参数。但参数更新这件事其实可以分片做把模型参数切成N片第i张卡只负责更新第i片参数更新完后再把更新后的参数片发给大家AllGather。这种分片更新策略里梯度汇总那步用的就是ReduceScatter每张卡贡献自己的梯度汇总后按参数片分发给对应卡。代码importtorchimporthccl rankhccl.get_rank()world_sizehccl.get_world_size()# 每张卡上的梯度形状 [1024, 1024]gtorch.randn(1024,1024).npu()# 准备接收自己那份汇总结果形状 [1024 // world_size, 1024]# 假设world_size4那out的形状就是 [256, 1024]outtorch.zeros(1024//world_size,1024).npu()# ReduceScatter汇总梯度但只把第rank片发给当前卡hccl.reduce_scatter(out,g,ophccl.Sum)print(frank{rank}: 拿到了第{rank}片汇总梯度形状{out.shape})WHY这样设计ReduceScatter的通信量是O(N)汇总需要全量通信但分发时只发一片所以总通信量是AllReduce的一半。对于参数规模巨大的大模型比如70B、180B这个一半的节省非常可观。hccl的ReduceScatter实现里汇总和分片是流水线执行的汇总完前几块数据就开始分发给对应卡不用等全部汇总完。hccl在CANN架构里的位置回到CANN的五层架构。hccl位于第4层昇腾计算执行层。同层的还有Runtime运行时、Graph Executor图执行器、DVPP数字视觉预处理、AIPP AI预处理。这意味着hccl是在计算执行这个阶段生效的。你的模型在前向反向传播时产生梯度这些梯度在NPU的计单元Cube/AIV里算完然后交给hccl去做多卡同步。hccl的底层依赖第5层昇腾计算基础里的通信驱动也依赖硬件层的HCCS互联拓扑。为什么这个位置很重要因为通信和计算的重叠overlap是在这一层实现的。hccl可以把通信任务交给专门的通信协处理器去跑让计单元继续算下一个batch的数据。这种计算通信重叠是大模型训练提速的关键手段之一。如果你打开hccl的源码仓库https://atomgit.com/cann/hccl会看到它分为几个核心模块通信原语实现primitives/、通信组管理group/、底层传输层transport/、以及与NPU驱动的交互层driver/。这种分层设计让hccl可以支持多种通信后端可以是NPU之间的HCCS高速互联也可以是跨机器的TCP/IP网络用于多机训练。使用前vs使用后的效率对比下面这个表格概括了使用hccl进行集合通信优化前后的效率差异。注意表格里用的是概括性描述因为具体的加速比取决于模型结构、卡数、网络拓扑等多种因素。维度使用前手工MPI/单机模拟分布式使用后hccl集合通信库多卡梯度同步需要自己写MPI通信代码或者用手工socket实现容易写错带宽利用率低直接调用hccl的AllReduce等原语底层针对NPU硬件优化带宽利用率显著提升通信计算重叠计算和通信串行执行通信期间计单元空闲hccl支持通信计算重叠通信交给协处理器计单元继续算下个batch扩展能力卡数超过8张后中心化通信的瓶颈显现扩展效率急剧下降环形算法让通信复杂度与卡数近似线性关系64卡甚至更多卡时仍能保持较高扩展效率代码复杂度需要自己管理通信组、节点发现、错误处理代码量大且容易出bughccl封装了通信组管理几行代码完成集合通信代码简洁且不易出错跨机训练支持需要自己处理TCP/IP通信、数据序列化、网络异常恢复hccl内置了跨机传输层支持多机多卡训练网络异常时自动重连这个表格里的对比不是hccl比别的技术快3倍那种具体数字因为具体加速比真的取决于太多因素。但可以确定的是用手工MPI做多卡同步代码复杂度和维护成本都高用hccl这些底层细节都被封装好了你只需要关心模型本身。通信组管理谁跟谁通信集合通信不是所有卡一起通信就完事了。实际训练里你可能会有多种通信模式。数据并行里所有卡需要同步梯度一个通信组。流水线并行里相邻stage的卡需要传递激活值另一个通信组。张量并行里同一层的不同卡需要同步中间结果又一个通信组。hccl用通信组communication group来管理这些不同的通信模式。每个通信组有一个唯一的ID组内的卡通过某种方式比如配置文件、环境变量、或者动态发现知道我跟谁是一组的。代码里的典型用法importhccl# 初始化默认通信组所有卡hccl.init_comm_group()# 创建一个子通信组比如数据并行组包含卡0/1/2/3dp_grouphccl.create_group([rank0,rank1,rank2,rank3])# 在dp_group里做AllReducehccl.all_reduce(x,ophccl.Sum,groupdp_group)# 再创建一个流水线并行组包含卡4/5pp_grouphccl.create_group([rank4,rank5])# 在pp_group里做AllGather传递激活值hccl.all_gather(y,local_y,grouppp_group)WHY这样设计如果只有一个全局通信组那数据并行和流水线并行的通信会互相干扰。就像对讲机频道你在1频道说数据并行的事流水线并行的人也能听到造成频道冲突。用子通信组相当于给不同的人分配不同频道互不干扰。hccl的通信组管理还支持组间通信比如数据并行组做完AllReduce后需要把结果发给流水线并行组这种跨组通信也能通过hccl完成。底层传输层数据是怎么传过去的hccl的底层传输层transport layer负责把数据从一张卡的NPU显存传到另一张卡的NPU显存。这个传输过程看起来简单把数据从内存A拷贝到内存B。但实际上传输路径有很多种选法每种的速度差异巨大。如果两张卡在同一台机器上数据可以通过HCCSHuawei Compute Interconnect System高速互联直接传输带宽通常在几百GB/s级别。这就像同一栋楼里的人说话直接敲门就行不用打电话。如果两张卡在不同机器上数据就得走TCP/IP网络带宽受限于网卡和交换机通常是10Gbps~400Gbps。这就像跨城市打电话得经过基站和光缆。hccl的传输层会自动选择最优路径能走HCCS的就走HCCS不行再降级到TCP/IP。这个选择过程对使用者是透明的你调用hccl.all_reduce时不用关心底层是HCCS还是TCP。但有一个地方需要你关心数据格式。NPU上的张量数据在显存里是以特定的内存布局存储的比如NC1HWC0布局这是昇腾达芬奇架构的特性。如果直接把这种布局的数据通过网络传出去接收端的NPU可能不支持同样的布局导致数据解析错误。hccl在传输前会做内存布局转换把NPU特有布局转成通用的行主序row-major布局传完后再转回目标NPU的布局。这个转换是有开销的但避免了数据错误。你在写代码时不用手动做这个转换hccl自动处理了。但如果你发现通信延迟比预期高可以检查一下是不是频繁触发了布局转换——这种情况下统一所有NPU的计算配置比如都用相同的立方体单元配置能减少转换次数。大规模集群的通信拓扑当卡数从8张扩展到64张、128张甚至更多时通信拓扑的设计就变得关键。最简单的拓扑是全连接每张卡都跟其他所有卡直接相连。这种拓扑的通信带宽是理论上最高的但硬件成本也最高——你需要给每张卡提供N-1个通信端口这在实际部署中几乎不可行。实际部署里用的是分层拓扑。比如同一台机器内的8张卡通过HCCS全连接或者环形连接不同机器之间通过以太网或InfiniBand连接。这样机器内的通信走HCCS快机器间的通信走以太网慢。hccl的环形AllReduce算法在分层拓扑下需要做特殊处理机器内的环形通信和机器间的环形通信要协调好避免机器内的卡在等机器间的卡这种木桶效应。这部分优化在hccl里是自动完成的。你在调用hccl.all_reduce时hccl会根据实际的拓扑信息从NPU驱动那里拿到动态调整通信策略。但如果你的集群拓扑比较特殊比如混合了不同型号的NPU或者网络带宽不均匀默认的通信策略可能不是最优的。这种情况下hccl提供了通信策略配置接口允许你手动指定某些参数比如机器间的通信用2个并行通道之类的。这部分配置比较底层大多数用户用不到。但如果你在做超大规模集群训练比如256卡以上了解这些配置选项是有价值的。实战中的常见坑写分布式训练代码时有几个常见坑跟hccl相关。第一个坑是通信组初始化顺序不一致。如果你在用PyTorch的DistributedDataParallelDDP它需要跟hccl的通信组同步初始化。如果某些卡先做了初始化另一些卡后做就可能卡死在等待同步的地方。解决办法是确保所有卡在同一个地方调用hccl.init_comm_group()而且调用顺序一致。第二个坑是梯度累积跟AllReduce的配合。如果你在做梯度累积gradient accumulation比如累积4个batch的梯度再更新参数那AllReduce应该在累积完之后做而不是每个batch都做。如果每个batch都做AllReduce通信开销会被放大4倍而且梯度是半吊子的只有1/4的真实梯度。第三个坑是通信计算重叠的正确姿势。hccl支持通信计算重叠但需要你用对API。如果你用的是hccl.all_reduce的同步版本它会阻塞直到通信完成这时重叠就没发生。你需要用异步版本hccl.all_reduce_async它立即返回真正的通信在后台跑你可以接着跑计算然后通过hccl.wait(all_reduce_handle)等待通信完成。这个异步API的使用需要小心如果你在通信还没完成时就去读那个张量的值会读到脏数据。就像你点了外卖但还没送到就打开门去看当然看不到。仓库地址https://atomgit.com/cann/hccl