前言

第一次在昇腾 910 上跑千卡训练的时候,通信开销直接把算力优势全吃掉了。8 张卡训练 ResNet-50,epoch 时间比单机还慢——这不是卡的问题,是通信没跑通。

这个问题后来在昇腾 CANN 的 HCCL 层找到了答案。HCCL 是昇腾 CANN 异构计算架构中专门负责集合通信的库,位于执行层,跟 Runtime、Graph Executor 并列。它解决的不是"能不能通信",而是"怎么在昇腾 NPU 集群里把通信开销压到最低"。

这篇文章把 HCCL 的架构拆开讲清楚:它为什么这么设计、核心模块怎么协作、通信数据流怎么走、以及在实际分布式训练里怎么调优。文章里所有代码示例都基于 PyTorch NPU 插件的公开接口,不编造 API。性能数据标注了"仅供参考",因为实际数值跟集群拓扑、NPU 型号、网络带宽强相关。


HCCL 在 CANN 架构中的位置

昇腾 CANN 的五层架构里,HCCL 位于第四层——昇腾计算执行层

第1层:AscendCL(应用开发接口 / 图开发接口 / Ascend C 算子开发接口)
第2层:Ascend 计算服务层(AOL 算子库 / AOE 调优引擎 / Framework Adaptor)
第3层:Ascend 计算编译层(Graph Compiler / BiSheng / ATC 编译器)
第4层:Ascend 计算执行层  ← HCCL 在这里
  ├─ Runtime 运行时
  ├─ Graph Executor 图执行器
  ├─ HCCL 集合通信库        ← 本文主角
  ├─ DVPP 数字视觉预处理
  └─ AIPP AI 预处理
第5层:Ascend 计算基础层(RMS/CMS/DMS/DRV/SVM/VM/HDC)
硬件层:Ascend 910(达芬奇架构)

HCCL 的上游是框架适配层(Framework Adaptor),下游直接对接 NPU 驱动和 RDMA 网络。训练框架(PyTorch、MindSpore、TensorFlow)通过适配层调用 HCCL 的接口,HCCL 再把集合通信操作翻译成 NPU 之间的数据传输。

这个位置决定了 HCCL 的两个核心约束:必须跟硬件拓扑强绑定(达芬奇架构的片上互联和片间互联不一样),以及必须跟推理/训练的执行流无缝衔接(不能因为等通信把计算流卡住)。


为什么需要专门的集合通信库

很多人第一次接触 HCCL 会问:为什么不用 NCCL 或者直接走 MPI?

答案在硬件差异和通信语义两个层面。

硬件差异:Ascend 910 的卡间互联是私有的 HCCS 协议,跟 NVLink 完全不一样。带宽特性、拓扑发现、错误恢复机制都是定制的。通用通信库没法充分利用这些特性。

通信语义:分布式训练的通信模式是高度结构化的——AllReduce、AllGather、ReduceScatter 这些操作在训练里有固定的调用时机(每步反向传播后同步梯度)。HCCL 针对这些固定模式做了流水线优化,把多个小通信合并成一次大通信,减少协议开销。

直接对比一下:

方案 硬件适配 训练通信语义优化 昇腾 NPU 支持
MPI 通用 能用但不优化
NCCL NVIDIA GPU 有(但针对 GPU) 不支持
HCCL Ascend 910 专用 有(针对训练/推理) 原生支持

所以 HCCL 的定位很清晰:它是昇腾 NPU 分布式训练的通信原语层,上层框架不用关心底层是用 HCCS 还是 PCIe 还是 RoCE,直接调 HCCL 的接口就行。


HCCL 的三层架构

HCCL 的内部架构可以拆成三层,从顶到底分别是接口层、调度层、传输层

接口层

接口层对接上层框架。PyTorch NPU 插件通过 torch.distributed 的 ProcessGroupHCCL 后端调用 HCCL。这一层做的事情很直接:把框架的通信语义翻译成 HCCL 的内部数据结构。

代码片段(PyTorch NPU 分布式初始化,注释解释 WHY):

import torch
import torch.distributed as dist
import os

# 初始化进程组,backend='hccl' 告诉 PyTorch 用 HCCL 做集合通信
# WHY: 不用 'nccl' 因为这是昇腾 NPU,底层通信协议是 HCCS 不是 NVLink
dist.init_process_group(
    backend='hccl',
    init_method='tcp://127.0.0.1:23456',  # 用 TCP 做初始握手,确定 rank 数量和拓扑
    rank=int(os.environ['RANK']),
    world_size=int(os.environ['WORLD_SIZE'])
)

# 把模型参数搬到 NPU 上,后续的 AllReduce 在 NPU 之间直接走 HCCS
model = MyModel().npu()

接口层的关键设计决策是懒初始化:通信资源(通信域、缓冲区、流)在实际发生通信时才分配,而不是在 init_process_group 时全部分配。这个设计省内存,因为很多训练脚本里并不是每个 rank 都会参与所有通信操作。

调度层

调度层是 HCCL 的核心。它负责把一次集合通信操作拆成若干次点对点传输,并根据集群拓扑选择最优的传输路径。

核心概念是通信域(Communicator)。每次 dist.all_reduce(tensor) 调用时,HCCL 先查这个 tensor 所在的通信域是哪个(默认是 world,也可以自定义子通信域),然后根据通信域的 rank 拓扑决定用哪种算法:

  • Ring AllReduce:适合带宽对称的小集群(8 卡及以下),每个 rank 只跟左右邻居通信
  • Tree AllReduce:适合大集群(16 卡以上),用树形结构减少通信步数
  • Mesh AllReduce:适合非对称拓扑(比如跨机柜场景),动态选择邻居

代码片段(自定义通信域,注释解释 WHY):

# 把 8 张卡分成两组,每组 4 卡做独立的 AllReduce
# WHY: 模型并行时,tensor parallel 组内的通信频率远高于数据并行组
#       分开两个通信域可以让两组通信并行跑,不互相等待
tp_group = dist.new_group(ranks=[0, 1, 2, 3], backend='hccl')
dp_group = dist.new_group(ranks=[4, 5, 6, 7], backend='hccl')

# 只在 tp_group 内做 AllReduce(tensor parallel 的梯度同步)
dist.all_reduce(tensor, group=tp_group)

调度层还有一个重要机制:通信-计算流水线。HCCL 不要求计算完全停下来等通信完成,而是允许把 tensor 的梯度分成若干小块,算完一块就触发一块的 AllReduce,后一块的计算和前一块的通信重叠。这个特性在 HCCL 里叫 “异步通信流水”,是调优分布式训练性能的关键。

代码片段(手动触发通信-计算重叠,注释解释 WHY):

# 把梯度 tensor 按最后一个维度切成 4 块,逐块做 AllReduce
# WHY: 反向传播算完第 1 块梯度的时候,第 2/3/4 块还在算
#       趁这个时间先把第 1 块的 AllReduce 发出去,等全部梯度算完,通信也快结束了
chunk_size = tensor.shape[-1] // 4
for i in range(4):
    chunk = tensor[..., i*chunk_size:(i+1)*chunk_size]
    dist.all_reduce(chunk, async_op=True)  # async_op=True 立即返回,不阻塞计算

传输层

传输层负责把调度层生成的传输计划变成实际的数据搬运。这一层直接跟 Ascend 910 的硬件打交道。

传输层支持三种传输路径,自动根据可用硬件选择:

  1. HCCS(片间互联):Ascend 910 卡之间的专用高速互联,带宽最高。同一台服务器内多卡通信走这条路。
  2. RoCE v2(远程 DMA):跨服务器的通信走 RDMA over Converged Ethernet。需要网卡和交换机支持 RoCE。
  3. PCIe(兜底):以上都不通的时候走 PCIe,带宽最低,一般只出现在配置错误的环境里。

传输层的核心数据结构是传输通道(Channel)。每个 Channel 绑定一个 NPU 的物理通信端口(HCCS 端口或 RoCE 端口)。HCCL 在初始化时会做拓扑探测:发探测包确定每个 rank 的物理位置(同机/跨机/跨机柜),然后建立 Channel 映射表。

这个探测过程在 init_process_group 内部自动完成,用户不需要手动配置。但如果你发现通信性能远低于预期,第一步应该查的就是拓扑探测结果——很多时候是机房接线跟软件预期不一致。

代码片段(检查 HCCL 通信状态,基于公开接口,注释解释 WHY):

# 用 torch.distributed 的 get_rank() 和 get_world_size() 确认通信域初始化成功
# WHY: 如果这两行能正常打印,说明 HCCL 的接口层和调度层都通了
#       如果卡住或报错,问题通常在传输层(HCCS/RoCE 配置)
print(f"Rank {dist.get_rank()} / World Size {dist.get_world_size()}")

# 做一个小 tensor 的 AllReduce 测试实际带宽
# WHY: 接口层通了不代表传输层带宽正常,这一步测的是实际通信性能
test_tensor = torch.ones(1024, 1024, dtype=torch.float32).npu()
dist.all_reduce(test_tensor)
print("通信测试通过,test_tensor 的和 =", test_tensor.sum().item())

通信数据流完整走读

把一个 dist.all_reduce(grad_tensor) 调用从头到尾走一遍,能看清 HCCL 每一层在做什么。

第 1 步:框架层(PyTorch)

PyTorch 的 dist.all_reduce() 根据 backend='hccl' 找到 ProcessGroupHCCL 的实现,把 grad_tensor 的 NPU 指针和 tensor 的 shape/dtype 封装成 HCCL 的 HcclComm 结构体。

第 2 步:接口层(HCCL)

HCCL 拿到 HcclComm 后,先查这个 tensor 所在的通信域。如果是第一次在这个通信域里做 AllReduce,HCCL 会做一次算法选择:跑一个轻量的 benchmark(发几个探测包测带宽和延迟),决定用 Ring、Tree 还是 Mesh。

第 3 步:调度层(HCCL)

算法确定后,调度层把 AllReduce 拆成若干次 Send/Recv 操作。以 Ring AllReduce 为例,拆成的步骤是:

  1. 每个 rank 把 tensor 分成 N 块(N = world_size)
  2. 第一轮:相邻 rank 之间做 Scatter-Reduce,把每块的部分和传递到下一个 rank
  3. 第二轮:相邻 rank 之间做 AllGather,把完整的和广播到所有 rank

这两轮加起来,每个 rank 只需要发/收 2×(N-1) 次,总数据量是 2×(N-1)×|tensor| / N。这是 Ring AllReduce 的经典结论。

第 4 步:传输层(HCCL + 驱动)

调度层生成的每次 Send/Recv 被翻译成硬件指令。如果目标 rank 在同一台机器,指令走 HCCS 驱动;如果跨机器,指令走 RoCE 驱动。传输层保证数据传输的原子性:一次 Send 要么完整到达,要么报错,不会出现半截数据。

第 5 步:远端接收(对端 HCCL)

对端 NPU 收到数据后,HCCL 的传输层把数据写到预先分配好的接收缓冲区,然后触发调度层的回调,通知调度层"这块数据到了"。所有块都到齐后,调度层做 reduce 操作(求和/平均/最大值,取决于 all_reduce 的 op 参数),然后把结果写回 grad_tensor

第 6 步:返回框架层(PyTorch)

dist.all_reduce() 返回,PyTorch 继续执行后续的计算图。如果用了 async_op=True,这一步在通信完成前就返回了,真正的同步发生在后续第一次访问 grad_tensor 的时候。


实际调优:把通信开销压下去

HCCL 的默认配置在大部分场景下能用,但要跑满昇腾 910 的算力,需要手动调几个关键参数。

调优点 1:通信域划分

大模型训练通常混用数据并行(DP)和模型并行(TP/PP)。DP 的通信域是全球的(所有 rank),TP 的通信域是局部的(同一台机器内的 8 张卡)。如果这两个通信域用同一套 HCCL 配置,TP 的通信会被 DP 的通信拖慢。

正确的做法是给不同的通信域设置不同的通信算法:TP 组用 Ring(通信域小、带宽高),DP 组用 Tree(通信域大、需要少步数)。

代码片段(混合并行的通信域配置,注释解释 WHY):

# 假设有 2 台机器,每台 8 卡,共 16 个 rank
# TP 组:每台机器内的 8 卡做 tensor parallel
tp_ranks_per_node = [[0,1,2,3,4,5,6,7], [8,9,10,11,12,13,14,15]]
# DP 组:跨机器的对应卡做数据并行
dp_ranks = [[0,8], [1,9], [2,10], [3,11], [4,12], [5,13], [6,14], [7,15]]

# 为 TP 组创建通信域(Ring 算法适合 8 卡以内的小域)
for ranks in tp_ranks_per_node:
    dist.new_group(ranks=ranks, backend='hccl')

# 为 DP 组创建通信域(Tree 算法适合跨机器的场景)
for ranks in dp_ranks:
    dist.new_group(ranks=ranks, backend='hccl')

调优点 2:梯度累积 + 大通信

小 batch 训练时,每步都做 AllReduce 的通信开销占比会很高(因为通信的固定开销摊销不了)。梯度累积(Gradient Accumulation)是标准的应对方法:攒 K 步的梯度,再做一次 AllReduce。

代码片段(梯度累积,注释解释 WHY):

accumulation_steps = 4  # 攒 4 步梯度再同步

for step, (x, y) in enumerate(dataloader):
    out = model(x.npu())
    loss = criterion(out, y.npu())
    loss = loss / accumulation_steps  # WHY: 除以 K,让累积的梯度等价于大 batch 的梯度
    loss.backward()
    
    if (step + 1) % accumulation_steps == 0:
        # 每 K 步做一次 AllReduce,把通信开销摊销到 K 步上
        dist.all_reduce(model.parameters(), op=dist.ReduceOp.SUM)
        optimizer.step()
        optimizer.zero_grad()

调优点 3:检查传输层瓶颈

如果调了通信域和梯度累积还不够,问题可能在传输层。用 hccl_test(HCCL 自带的带宽测试工具)跑一下实际带宽,跟硬件规格对比:

  • Ascend 910 的 HCCS 单向带宽规格是 56 GB/s(仅供参考,实际值跟服务器型号有关)
  • 如果实测只有 20-30 GB/s,检查 BIOS 里是否开启了 IOMMU(开了会降带宽)
  • 如果跨机器带宽远低于 RoCE 网卡规格,检查交换机的 MTU 设置(建议开 Jumbo Frame,MTU=9000)

这些检查不通过代码做,而是通过网络配置和 BIOS 设置调整。HCCL 的性能上限是硬件决定的,软件层能做的是别让配置问题把上限压下去


HCCL 跟其他 CANN 组件的协作

HCCL 不是孤立的,它跟 CANN 执行层的其他组件有紧密的协作关系。

HCCL + Runtime:Runtime 管理 NPU 的执行流(Stream)和上下文(Context)。HCCL 的通信操作提交到 Runtime 创建的 Stream 上执行。如果用户的训练脚本里用了多个 Stream(比如计算用一个 Stream,数据预处理用另一个),需要保证通信 Stream 和计算 Stream 之间的依赖关系正确,否则会出现计算读到半截通信数据的问题。dist.all_reduce() 内部会自动做 Stream 同步,但如果你手动调 HCCL 的 C API,需要自己调 aclrtSynchronizeStream()

HCCL + graph-autofusion:graph-autofusion 是 CANN 的算子自动融合框架,它可以在构图阶段把"计算 + 通信"融合成一个大算子。比如把 MatMul + AllReduce + ReLU 融合成一次调度,省掉中间结果的显存读写。这个特性在 CANN 8.0 之后可用,需要在构图时开启 enable_communication_fusion=True(具体参数名以官方文档为准,此处为示意)。

HCCL + hixl:hixl 是 CANN 的单边通信库,支持零拷贝的 put/get 语义。跟 HCCL 的区别是:HCCL 做集合通信(所有 rank 都参与,同步语义强),hixl 做单边通信(一个 rank 写,另一个 rank 读,不需要远端 CPU 参与)。在 PD 分离(Prefill-Decode 分离)推理场景里,hixl 负责把 Prefill 节点的 KV Cache 直接写到 Decode 节点的内存里,比走 HCCL 的 AllGather 延迟更低。


常见问题排查

实际用 HCCL 的时候,有几个问题出现频率很高,值得单独说一下。

问题 1:init_process_group 卡住不返回

原因几乎是唯一的:各个 rank 的 init_method 指定的主节点(通常是 rank 0)没有正确监听,或者其他 rank 连不上主节点。检查防火墙、检查 IP 是否写错、检查 RANK 和 WORLD_SIZE 环境变量是否在所有节点上正确设置。

问题 2:AllReduce 结果不对(数值错误)

先确认所有 rank 的 grad_tensor 在 AllReduce 之前是不是真的需要同步。有些实现里,Loss Scaling 或者 Gradient Clipping 会在 AllReduce 之前修改梯度,导致各 rank 的数据不一致。正确的顺序是:反向传播 → 梯度裁剪/缩放 → AllReduce → 优化器步进。

问题 3:通信带宽上不去

按照前面的传输层检查清单走一遍:HCCS 带宽、RoCE MTU、BIOS IOMMU 设置。还有一个容易忽略的点:NPU 的功耗模式。如果 NPU 被设成了低功耗模式(比如通过 npu-smi set 设置了功耗上限),HCCS 的链路速率也会跟着降。用 npu-smi info 看一下当前功耗模式。


结尾

HCCL 是昇腾 CANN 里最容易被人忽视、但对分布式训练性能影响最大的组件之一。它不像算子库那样直接决定"能不能算",但它决定"算完了能不能高效把结果同步出去"。

把 HCCL 的架构理清楚之后,调优方向就很明确了:通信域划分对不对、梯度累积够不够、传输层配置有没有拖后腿。这三件事做好了,千卡训练的通信开销压到计算时间的 5% 以内是可以期待的(具体数值跟模型和集群有关,此处为经验估算,仅供参考)。

最后说一个容易被忽略的点:HCCL 的拓扑探测结果是可以导出的。在初始化完成后,把探测结果存下来,跟机房的实际拓扑图对比,能提前发现接线错误。这个操作在 CANN 的官方文档里有描述,建议每次部署新集群都做一遍。

仓库地址: https://atomgit.com/cann/hccl

Logo

鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。

更多推荐