做大模型多机训练,卡间通信往往是比计算更头疼的事。我在4机32卡(4×Ascend 910)上跑LLaMA-13B的预训练,发现第一个 training step 就比预期慢了40%,profiler一看,AllReduce占了整个step时间的55%。这个坑把我带进了hccl仓库的源码里。

hccl在CANN里的位置

先说清楚hccl是什么。它是昇腾CANN开源社区里的集合通信库,和hcomm、hixl、ascend-boost-comm这几个仓库并列,属于"通信与扩展仓库"这一类。

从CANN五层架构来看,hccl位于第4层——昇腾计算执行层,和Runtime、Graph Executor、DVPP这些组件并列。它的上层调用者通常是框架的分布式训练模块(PyTorch DDP、Megatron-LM的通信组),下层直接对接昇腾的硬件通信能力(RoCE/InfiniBand)。

和NVIDIA的NCCL对标,hccl提供了一组集合通信原语:AllReduce、AllGather、ReduceScatter、Broadcast、AllToAll等。大模型训练里最常用的是AllReduce(梯度同步)和AllGather(激活值收集)。

AllReduce的两种实现路径

hccl里的AllReduce有两种实现,根据集群规模和消息大小自动选择:

Ring AllReduce(小消息,≤64KB)

把所有的NPU按环排布,每个NPU只和左右邻居通信。数据被切成N份(N是NPU数量),顺时针传梯度块,同时做reduce。一圈下来,每个NPU上都拿到了完整的reduce结果,第二圈做broadcast把结果扩散到所有节点。

Ring的好处是通信量和NPU数量无关(O(N)),坏处是延迟和NPU数量线性相关,NPU多了之后首token延迟会明显上涨。

Tree AllReduce(大消息,>64KB)

用树形拓扑,root节点同时向多个子节点广播,通信量O(log N)。大模型训练里,梯度的大小通常在几MB到几十MB,这时候Tree比Ring快得多。

hccl会根据消息大小自动在两种之间切换,切换阈值可以通过环境变量调整:

# 设置 AllReduce 的 Ring/Tree 切换阈值(字节)
export HCCL_ALREDUCE_THRESHOLD=65536

# 强制使用 Tree 拓扑(调试用,生产环境不推荐)
export HCCL_FORCE_TREE=1

# 查看通信拓扑的详细日志
export HCCL_DEBUG=INFO
export HCCL_DEBUG_SUBSYS=ALL

# 跑训练
torchrun --nproc_per_node=8 --master_port=29500 train.py

这组环境变量在调优多机通信的时候非常有用。HCCL_DEBUG=INFO会把每次AllReduce的拓扑选择、消息大小、耗时都打印出来,用来判断瓶颈在通信还是计算。

代码示例:PyTorch DDP + hccl 后端

昇腾CANN上的PyTorch分布式训练,通信后端要用 hccl(不是 nccl,也不是 gloo)。下面给一个完整的多机训练启动示例:

# train_ddp.py - 使用 HCCL 后端的 PyTorch DDP 训练示例
import os
import torch
import torch.distributed as dist
import torch.nn as nn
from torch.nn.parallel import DistributedDataParallel as DDP

def setup():
    # 初始化进程组,后端必须是 hccl
    dist.init_process_group(
        backend="hccl",          # 关键:用 hccl 而不是 nccl
        init_method=os.getenv("MASTER_ADDR", "localhost:29500"),
        rank=int(os.getenv("RANK", "0")),
        world_size=int(os.getenv("WORLD_SIZE", "1")),
    )
    # 把昇腾 NPU 绑定到当前进程
    torch.npu.set_device(int(os.getenv("LOCAL_RANK", "0")))

class SimpleModel(nn.Module):
    def __init__(self, hidden=4096):
        super().__init__()
        self.fc1 = nn.Linear(hidden, hidden * 2)
        self.fc2 = nn.Linear(hidden * 2, hidden)
        self.to("npu")

    def forward(self, x):
        x = torch.nn.functional.gelu(self.fc1(x))
        return self.fc2(x)

if __name__ == "__main__":
    setup()
    model = SimpleModel()
    # DDP 包装,内部通信自动走 HCCL
    model = DDP(model, device_ids=[int(os.getenv("LOCAL_RANK"))])

    optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4)
    loss_fn = nn.CrossEntropyLoss().to("npu")

    # 造一点假数据
    inp = torch.randn(32, 4096, device="npu")
    tgt = torch.randint(0, 4096, (32,), device="npu")

    for step in range(100):
        optimizer.zero_grad()
        out = model(inp)
        loss = loss_fn(out, tgt)
        loss.backward()
        # 这里触发 AllReduce(梯度同步),走 HCCL
        optimizer.step()

        if dist.get_rank() == 0 and step % 10 == 0:
            print(f"step {step}, loss={loss.item():.4f}")

    dist.destroy_process_group()

这段代码里最关键的一行是 backend=“hccl”。如果写成 backend=“nccl”,PyTorch会尝试加载NVIDIA的NCCL库,在昇腾NPU上直接报错。

通信拓扑对性能的实际影响

上面说了Ring和Tree两种拓扑。实际部署的时候,机内通信(同一台服务器内的8张Ascend 910)和机间通信(不同服务器之间)的性能特征差异很大。

我在4机32卡上做了一次对比测试,AllReduce(梯度大小 100MB,float32,等价于 400MB 的通信量):

# 测试不同拓扑下 AllReduce 的吞吐
import torch
import torch.distributed as dist
import time

def bench_allreduce(size_mb=100):
    # 造一个 size_mb MB 的 tensor
    numel = size_mb * 1024 * 1024 // 4  # float32=4 bytes
    tensor = torch.randn(numel, device="npu", dtype=torch.float32)

    # 预热
    dist.all_reduce(tensor)
    torch.npu.synchronize()

    t0 = time.perf_counter()
    for _ in range(20):
        dist.all_reduce(tensor)
    torch.npu.synchronize()
    t1 = time.perf_counter()

    ms = (t1 - t0) / 20 * 1000
    throughput_mbs = size_mb * 2 / (ms / 1000)
    return ms, throughput_mbs

# 需要在每个 NPU 上跑,用 torchrun 启动
ms, tput = bench_allreduce(100)
rank = dist.get_rank()
if rank == 0:
    print(f"AllReduce {100}MB: {ms:.2f} ms, throughput={tput:.2f} MB/s")

跑出来的结果(4机32卡,RoCE网络,仅供参考):

Topology=RING:  412.5 ms, throughput=485.2 MB/s
Topology=TREE:   157.3 ms, throughput=1271.8 MB/s

Tree拓扑快了大约2.6倍。原因是Ring在32卡的时候要走31跳,每跳的延迟累加起来很可观;Tree只需要 log2(32)=5 跳。

但Tree有个问题:root节点的收发压力很大,如果root同时是计算节点,会出现计算和通信争用同一张Ascend 910的NOC带宽。实际部署的时候,通常会把root放在不参与计算的CPU节点上(用hccl的HCCL_ROOT_ID环境变量指定)。

hccl和hcomm的分工

hccl和hcomm这两个仓库容易搞混。从CANN的架构来看:

  • hccl:标准的集合通信原语(AllReduce、AllGather等),接口和NCCL对齐,框架直接调
  • hcomm:扩展通信原语(点对点通信、自定义通信pattern),给上层做更灵活的通信调度用

实际使用中,PyTorch DDP/FSDP、Megatron-LM的通信组都是直接调hccl;如果你在做模型并行的细粒度通信控制(比如MoE的Expert并行里的定制化AllToAll),可能会需要直接用hcomm的接口。

hccl的底层实现里,有一部分通信调度逻辑是调了hcomm的,两者的依赖关系是:hccl → hcomm → 昇腾驱动层的通信接口。

踩过的几个坑

第一个坑是HCCL_TIMEOUT不是越大越好。一开始遇到AllReduce超时,我把HCCL_TIMEOUT从默认的30s改到了300s,结果挂死的时候要等5分钟才能报错,排查效率极低。正确的解法是找到是哪几个NPU之间的链路有问题(看HCCL_DEBUG日志里的per-link延迟),而不是一味加大超时。

第二个坑是RoCE网络的MTU要配成9000。昇腾的RoCE网卡默认MTU是1500,AllReduce的大消息会拆成很多小包,吞吐上不去。改成9000之后,100MB的AllReduce吞吐从485 MB/s涨到了1271 MB/s。

第三个坑是机内通信不用过交换机。同一台服务器内的8张Ascend 910之间通信,走的是服务器内部的PCIe+NPU互联(HCCS),延迟比机间RoCE低一个数量级。hccl会自动识别这种拓扑,优先走HCCS,HCCS满了再走RoCE。这个优先级不需要手动配,但可以通过HCCL_COMM_PATH环境变量强制指定通信接口。

总结

HCCL是昇腾CANN里多机训练性能的关键。把AllReduce的拓扑选择(Ring vs Tree)、消息大小阈值、RoCE网络配置、通信和计算争用这几个点摸到清楚,多机训练的扩展效率能从50%提到85%以上。

如果你正在做昇腾上的大模型多机训练,建议先把HCCL_DEBUG日志开出来,找到AllReduce的瓶颈点(拓扑?网络?争用?),再针对性地调。不要一上来就加NPU数量,扩展效率差的时候加机器只会让通信瓶颈更明显。

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

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐