分布式训练的血肉纽带:深入解析昇腾CANN集合通信库hccl的设计哲学、核心原语AllReduce与大规模集群实战应用指南
2026/6/11 22:07:17 网站建设 项目流程

前言

当你第一次跑通一个单机单卡的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通信组rank=hccl.get_rank()world_size=hccl.get_world_size()# 每张卡上有一个梯度张量xx=torch.randn(1024,1024).npu()# 调用AllReduce,把所有卡上的x加起来,再分发给每张卡hccl.all_reduce(x,op=hccl.Sum)# 现在每张卡上的x都是全局汇总后的结果print(f"rank{rank}: x[0] ={x[0]}")

这段代码里,hccl.all_reduce(x, op=hccl.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 rank=hccl.get_rank()world_size=hccl.get_world_size()# 每张卡上的局部数据,形状 [256, 768]a=torch.randn(256,768).npu()# 准备接收拼起来后的大张量,形状 [1024, 768]b=torch.zeros(1024,768).npu()# 调用AllGatherhccl.all_gather(b,a)# 现在b里是4张卡上a的拼接结果print(f"rank{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 rank=hccl.get_rank()world_size=hccl.get_world_size()# 每张卡上的梯度,形状 [1024, 1024]g=torch.randn(1024,1024).npu()# 准备接收"自己那份"汇总结果,形状 [1024 // world_size, 1024]# 假设world_size=4,那out的形状就是 [256, 1024]out=torch.zeros(1024//world_size,1024).npu()# ReduceScatter:汇总梯度,但只把第rank片发给当前卡hccl.reduce_scatter(out,g,op=hccl.Sum)print(f"rank{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_group=hccl.create_group(["rank0","rank1","rank2","rank3"])# 在dp_group里做AllReducehccl.all_reduce(x,op=hccl.Sum,group=dp_group)# 再创建一个"流水线并行组",包含卡4/5pp_group=hccl.create_group(["rank4","rank5"])# 在pp_group里做AllGather(传递激活值)hccl.all_gather(y,local_y,group=pp_group)

WHY这样设计:如果只有一个全局通信组,那数据并行和流水线并行的通信会互相干扰。就像对讲机频道:你在1频道说数据并行的事,流水线并行的人也能听到,造成频道冲突。用子通信组,相当于给不同的人分配不同频道,互不干扰。hccl的通信组管理还支持"组间通信":比如数据并行组做完AllReduce后,需要把结果发给流水线并行组,这种跨组通信也能通过hccl完成。

底层传输层:数据是怎么传过去的?

hccl的底层传输层(transport layer)负责把数据从一张卡的NPU显存传到另一张卡的NPU显存。

这个传输过程看起来简单:把数据从内存A拷贝到内存B。但实际上,传输路径有很多种选法,每种的速度差异巨大。

如果两张卡在同一台机器上,数据可以通过HCCS(Huawei 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的DistributedDataParallel(DDP),它需要跟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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询