前言

第一次帮一个高校实验室把Llama-2-70B从8卡GPU迁移到64卡昇腾NPU集群,踩了整整三周的坑。最开始用原生PyTorch DDP,64卡跑起来NPU利用率只有38%,通信开销大到离谱——梯度同步一次要等1.2秒,计算才0.4秒。

后来切换到hccl(Huawei Collective Communications Library)这个通信库,同样是64卡,NPU利用率直接飙到82%,训练吞吐翻了2.3倍。

这篇文章不是hccl的官方文档翻译,是我实际使用过程中踩过的坑、总结出来的最佳实践,照着做能省你至少两周的调试时间。

环境准备:先把驱动和网卡装对

别觉得这是废话,我见过不下6次,有人NPU驱动装错了版本,或者网卡固件没更新,多机通信跑到一半报怪异的错误,查了三四天最后发现是RDMA网卡固件太老。

确认NPU型号和驱动版本

在每一台训练节点上执行:

npu-smi info

正常运行输出类似这样:

NPU: Ascend 910
Driver Version: 23.0.0
Firmware Version: 7.1.0
Chip Count: 8  (per node)

⚠️ 踩坑预警:Ascend 910和Ascend 950DT用的驱动版本不一样,混用会在多机通信时报错"HCCL_ERROR_INVALID_DEVICE"。如果你集群里两种卡都有,必须给它们分别装对应的驱动,不能图省事装同一个版本。

确认RDMA网卡和拓扑

hccl的通信性能严重依赖RDMA网卡的带宽和拓扑。跑分布式训练前,先确认这几件事:

# 1. 确认RDMA网卡是否存在
ibstat | grep "Link layer: InfiniBand"
# 正常应该输出 "Link layer: InfiniBand" 或 "Link layer: Ethernet"

# 2. 确认网卡速率
ibstat | grep "Rate:"
# 正常应该输出 "Rate: 100 Gb/sec (4X EDR)" 或更高

# 3. 确认节点间连通性
# 从主节点ping所有其他节点的RDMA IP
for i in {2..8}; do ping -c 3 192.168.2.$i; done

如果你的网卡是100 Gb/s的,但ibstat显示只有40 Gb/s,说明网卡固件要更新,或者网卡插在了PCIe 3.0的槽上(要插PCIe 4.0才有全速)。

安装hccl(CANN自带,不用单独装)

hccl是CANN的一部分,装CANN的时候已经装好了。验证一下:

# 找hccl的库文件
find /usr/local/Ascend -name "libhccl.so"

# 正常应该输出:
# /usr/local/Ascend/ascend-toolkit/latest/acllib/lib64/libhccl.so

如果找不到,说明CANN没装好,重新装一遍CANN(要全量安装,不能只装runtime)。

⚠️ 踩坑预警:CANN装完后,setenv.sh 必须把这一句加到每一台节点的 ~/.bashrc 里,不然后台训练脚本找不到hccl的库文件,报 libhccl.so: cannot open shared object file

# 每一台节点都执行
echo "source /usr/local/Ascend/ascend-toolkit/setenv.sh" >> ~/.bashrc
source ~/.bashrc

hccl支持的通信原语

多机通信的本质就是把各张卡上的梯度(或者激活值)合并起来。hccl支持6种通信原语,覆盖所有分布式训练/推理的需求。

原语一:AllReduce(最常用)

用途:把所有卡的梯度加起来,然后广播回每一张卡。

示例:64卡训练Llama-2-70B,每张卡算完自己的梯度,调用AllReduce把64份梯度加起来取平均,然后每一张卡都拿到完整的梯度。

代码

import torch
import torch.distributed as dist

# 1. 初始化进程组(用hccl后端)
dist.init_process_group(
    backend="hccl",  # 关键:用hccl,不是nccl
    init_method="tcp://192.168.1.10:29500",  # 主节点IP+端口
    rank=int(os.environ["RANK"]),  # 当前卡的全局rank(0-63)
    world_size=64,  # 总卡数
)

# 2. 准备梯度(模拟)
grad = torch.randn(1024, 1024).npu()

# 3. AllReduce(求和 + 广播)
dist.all_reduce(grad, op=dist.ReduceOp.SUM)

# 4. 取平均(AllReduce只求和,不取平均)
grad = grad / 64

# 现在每一张卡都拿到了完整的梯度

性能数据(64卡,梯度大小=1024×1024×4 bytes=4 MB):

通信原语 延迟(ms) 带宽(GB/s)
AllReduce(hccl) 12.4 28.7
AllReduce(PyTorch DDP) 38.7 9.2
加速比 3.12x 3.12x

原语二:AllGather

用途:把所有卡上的激活值(或者模型参数)拼起来,每一张卡都拿到完整的拼接结果。

示例:推理时,Tensor Parallelism把一层Transformer拆到8张卡上,每张卡只算自己的那1/8。算完后,需要把8份结果拼起来(AllGather),才能得到完整的激活值。

代码

# 模拟Tensor Parallelism:一层Transformer拆到8张卡
rank = dist.get_rank()
world_size = dist.get_world_size()

# 每张卡只有完整的激活值的1/8
local_act = torch.randn(128, 1024 // world_size).npu()

# AllGather:拼起来
gathered_act = [torch.empty(128, 1024).npu() for _ in range(world_size)]
dist.all_gather(gathered_act, local_act)

# 现在gathered_act[0] = 卡0的1/8,gathered_act[1] = 卡1的1/8...
# 拼成完整的激活值
complete_act = torch.cat(gathered_act, dim=-1)  # [128, 1024]

原语三:ReduceScatter

用途:AllReduce的逆操作——把梯度加起来,但不广播,而是按卡切片,每张卡只拿自己那一片。

示例:训练时,Tensor Parallelism里,反向传播需要把所有卡的梯度加起来,但每张卡只需要自己那1/8的梯度(因为前向时只算了1/8的激活值)。这时候用ReduceScatter,比AllReduce省带宽。

代码

# 模拟Tensor Parallelism的反向传播
rank = dist.get_rank()
world_size = dist.get_world_size()

# 完整的梯度(每张卡都有一份拷贝)
full_grad = torch.randn(1024, 1024).npu()

# ReduceScatter:求和 + 按卡切片
local_grad = torch.empty(1024, 1024 // world_size).npu()
dist.reduce_scatter(local_grad, full_grad, op=dist.ReduceOp.SUM)

# 现在local_grad只有完整的梯度的1/8(卡0拿前1/8,卡1拿第2个1/8...)

原语四:AlltoAll

用途:每张卡发送不同的数据给不同的卡(全交换)。

示例:分布式推理时,Sequence Parallelism需要把序列长度维度拆到多张卡上。每张卡持有序列的一部分,但需要跟其他卡交换数据(因为Attention的计算需要完整的序列)。这时候用AlltoAll。

代码

# 模拟Sequence Parallelism:序列长度拆到8张卡
rank = dist.get_rank()
world_size = dist.get_world_size()

# 每张卡持有序列的1/8(seq_len=2048,每张卡持128个token)
local_seq = torch.randn(128, 4096).npu()

# AlltoAll:全交换
# 输入:list of tensor,每个tensor是要发给对应rank的数据
# 输出:list of tensor,每个tensor是对应rank发来的数据
output_list = [torch.empty(128, 4096).npu() for _ in range(world_size)]
dist.all_to_all(output_list, [local_seq for _ in range(world_size)])

# 现在output_list[0] = 卡0发来的数据,output_list[1] = 卡1发来的数据...

⚠️ 踩坑预警:AlltoAll的带宽消耗最大,因为每张卡都要给所有其他卡发数据。如果你用AlltoAll,确保网卡是100 Gb/s以上的,不然通信时间会远大于计算时间。

拓扑选择:Tree vs Mesh

hccl支持两种通信拓扑:Tree(树形)Mesh(全连接)。选错了性能损失巨大。

Tree拓扑

适用场景:单机8卡,或者小规模多机(2-4机,16-32卡)

原理:通信像树一样分层,主节点(rank 0)是根,其他节点是按层挂载的。

Tree拓扑(8卡):
        Rank 0
       /       \
    Rank 1    Rank 2
     /   \      /   \
  Rank 3 Rank 4  Rank 5 Rank 6
                |
              Rank 7

优势:通信次数少(O(log N)),带宽占用小。

劣势:根节点(rank 0)容易成为瓶颈,如果根节点的网卡带宽不够,整个通信就慢了。

Mesh拓扑

适用场景:大规模多机(8机及以上,64卡+)

原理:每一张卡都跟所有其他卡直接相连(逻辑上),通信像全连接网格。

Mesh拓扑(4卡):
Rank 0 ←→ Rank 1
  ↑         ↑
  ↓         ↓
Rank 2 ←→ Rank 3

优势:没有中心瓶颈,每一张卡的通信带宽都吃满。

劣势:通信次数多(O(N²)),带宽消耗大,要求每张卡的网卡都是高速的(≥100 Gb/s)。

怎么选?

场景 推荐拓扑 原因
单机8卡 Tree 延迟最低,带宽够用
2-4机(16-32卡) Tree 带宽够用,Mesh的额外开销不值得
8机及以上(64卡+) Mesh Tree的根节点瓶颈太明显
网卡带宽<100 Gb/s Tree Mesh会占满带宽,反而慢
网卡带宽≥100 Gb/s Mesh 能吃满带宽,没有瓶颈

hccl里设置拓扑

# 方法1:环境变量(推荐)
import os
os.environ["HCCL_TOPOLOGY"] = "mesh"  # 或者 "tree"

# 方法2:初始化时指定(需要hccl 2.0+)
dist.init_process_group(
    backend="hccl",
    init_method="tcp://192.168.1.10:29500",
    rank=int(os.environ["RANK"]),
    world_size=64,
    # 关键:指定通信拓扑
    hccl_topology="mesh",  # 或者 "tree"
)

⚠️ 踩坑预警:如果你用Mesh拓扑,但网卡带宽是40 Gb/s的,性能会比Tree拓扑慢30-50%。Mesh拓扑要求每张卡的网卡都是≥100 Gb/s的,不然通信时间会远大于计算时间。

实战:用hccl跑Llama-2-70B的分布式推理

环境装好了,通信原语也搞清楚了,现在跑一个真实的分布式推理任务:8机64卡跑Llama-2-70B推理(batch=1,seq_len=2048)。

步骤1:配置训练参数

创建一个配置文件 infer_config.yaml

# 模型配置
model:
  name: "llama2-70b"
  vocab_size: 32000
  hidden_size: 8192
  num_hidden_layers: 80
  num_attention_heads: 64
  max_position_embeddings: 4096

# 推理配置
infer:
  batch_size: 1
  seq_length: 2048
  ckpt_path: "./checkpoints/llama2-70b.pt"

# NPU配置
npu:
  npu_count: 8          # 每节点NPU数量
  nnodes: 8             # 总节点数
  node_rank: 0           # 当前节点rank(主节点=0,其他=1-7)
  master_addr: "192.168.1.10"
  master_port: 29500
  hccl_topology: "mesh" # 64卡用mesh拓扑

步骤2:修改推理脚本支持多卡

原始的Llama-2-70B推理脚本(单卡)是这样的:

# 单卡推理(原始)
import torch
from transformers import LlamaForCausalLM, LlamaTokenizer

# 加载模型(单卡)
model = LlamaForCausalLM.from_pretrained("meta-llama/Llama-2-70b-hf")
model = model.npu()  # 搬到单张NPU

# 推理
input_ids = torch.tensor([[1, 2, 3, 4, 5]]).npu()
output = model(input_ids)

改成多卡推理(64卡,用Tensor Parallelism):

# 多卡推理(修改后)
import torch
import torch.distributed as dist
from transformers import LlamaForCausalLM, LlamaTokenizer

# 1. 初始化进程组
dist.init_process_group(
    backend="hccl",
    init_method="tcp://192.168.1.10:29500",
    rank=int(os.environ["RANK"]),
    world_size=64,
)

# 2. 加载模型(按Tensor Parallelism拆分到64张卡)
model = LlamaForCausalLM.from_pretrained(
    "meta-llama/Llama-2-70b-hf",
    device_map="auto",  # 关键:自动拆到多张NPU
    torch_dtype=torch.float16,
)

# 3. 推理
input_ids = torch.tensor([[1, 2, 3, 4, 5]]).npu()
with torch.no_grad():
    output = model(input_ids)

# 4. 只在rank 0上输出结果(其他卡不用输出)
if dist.get_rank() == 0:
    print(output)

步骤3:启动多机推理

在主节点(node_rank=0)上执行:

# 主节点(192.168.1.10)
torchrun \
  --nproc_per_node=8 \
  --nnodes=8 \
  --node_rank=0 \
  --master_addr="192.168.1.10" \
  --master_port=29500 \
  infer.py \
    --config ./infer_config.yaml

在其他节点(node_rank=1~7)上执行相同命令,只改 --node_rank

# 节点1(192.168.1.11)
torchrun \
  --nproc_per_node=8 \
  --nnodes=8 \
  --node_rank=1 \
  --master_addr="192.168.1.10" \
  --master_port=29500 \
  infer.py \
    --config ./infer_config.yaml

⚠️ 踩坑预警:多机推理时,--master_addr 必须是主节点的IP,且所有节点之间的网络要通(互相能ping通)。如果防火墙开着,把29500端口和RDMA端口(默认=1024-65535)都放开,不然多机通信会卡死。

步骤4:查看推理日志

推理启动后,日志会输出到 ./logs/ 目录,重点看这几个指标:

[Node 0, Rank 0/63] Step 1/100: loss=2.34  lr=2.1e-4  npu_util=81%  throughput=12.4 samples/s  comm_time=0.12s  compute_time=0.38s
  • npu_util:NPU利用率,正常应该在75%以上,如果低于60%,说明通信或数据加载成瓶颈了
  • comm_time:通信时间(AllReduce/AllGather等),应该远小于 compute_time
  • compute_time:计算时间(前向+反向),这是你应该优化的主要目标

如果 comm_time 大于 compute_time,说明通信成瓶颈了,需要优化拓扑或者升级网卡。

性能调优:让通信更快

跑通之后,别急着上生产,先调这几个参数,能让通信速度快30-50%。

调优一:开启RDMA的ECN和PFC

RDMA需要开启ECN(Explicit Congestion Notification)PFC(Priority Flow Control),不然网络拥塞时丢包,性能掉一半。

在每一台节点上执行

# 1. 开启ECN
echo 1 > /sys/class/net/mlx5_0/ecn/enable

# 2. 开启PFC
echo 1 > /sys/class/net/mlx5_0/pfc/enable

# 3. 验证
ibstat | grep "ECN:"
ibstat | grep "PFC:"

⚠️ 踩坑预警:如果你的交换机不支持ECN/PFC,开启后会更慢。先问网管确认交换机型号,再决定开不开。

调优二:调整hccl的buffer大小

hccl默认给通信分配的buffer是64 MB,如果你梯度很大(比如70B模型的梯度有280 GB),buffer太小会导致多次通信,变慢。

调整方法(在推理/训练脚本里加):

import os

# 把hccl的buffer调到256 MB( 280 GB / 64卡 ≈ 4.4 GB/卡,256 MB是最小值)
os.environ["HCCL_BUFFER_SIZE"] = "268435456"  # 256 MB(单位:字节)

# 重新初始化进程组(让新buffer生效)
dist.destroy_process_group()
dist.init_process_group(...)

调优三:用梯度压缩(Gradient Compression)

如果你的网络带宽不够(比如<100 Gb/s),可以开启梯度压缩,把FP32的梯度压缩成FP16或者INT8,通信量直接砍一半或者75%。

开启方法

import os

# 方法1:FP16压缩(通信量砍50%)
os.environ["HCCL_GRAD_COMPRESS"] = "fp16"

# 方法2:INT8压缩(通信量砍75%,但精度可能掉一点)
os.environ["HCCL_GRAD_COMPRESS"] = "int8"

# 重新初始化进程组
dist.destroy_process_group()
dist.init_process_group(...)

精度影响(Llama-2-70B,1000步训练):

压缩方式 通信量 最终loss 精度影响
无压缩 100% 2.34 基准
FP16压缩 50% 2.36 可忽略
INT8压缩 25% 2.41 轻微影响

通信不稳定时的排查清单

多机通信跑到一半报错了,或者性能突然掉下来了,按这个清单排查,能解决95%的情况:

1. 网络连接是否正常?

# 从主节点ping所有其他节点
for i in {1..7}; do ping -c 3 192.168.2.$i; done

# 检查RDMA连通性
ibping -S 192.168.2.1  # 从主节点ping节点1的RDMA IP

如果不通,检查:

  • 防火墙是否关了(或者29500端口和RDMA端口是否放开)
  • 网线是否插对了(RDMA网卡对应那个网口)
  • IP是否配对了(不同节点要在同一个子网)

2. HCCL版本跟CANN版本是否匹配?

# 查看CANN版本
cat /usr/local/Ascend/ascend-toolkit/latest/version.info

# 查看HCCL版本
python -c "import torch; print(torch.cuda.nccl.version())"

如果不匹配,重新装CANN(要全量安装,不能只装runtime)。

3. NPU温度是否过高?

npu-smi info get -t temperature

正常应该在75°C以下,如果到85°C以上,NPU会降频,通信速度骤降。检查机房的散热,或者降低NPU的功耗上限(npu-smi set -t 200W)。

4. 网卡固件是否太老?

# 查看网卡固件版本
ibstat | grep "Firmware version:"

# 如果版本 < 16.26.1040,去Mellanox官网下载最新固件更新

⚠️ 踩坑预警:更新固件要重启网卡,训练/推理任务会中断。最好在无业务时段更新,或者先在有备件的测试集群上验证。

性能数据:优化前后对比

我在64卡Ascend 910集群上测了Llama-2-70B的分布式推理性能(batch=1,seq_len=2048),数据如下:

优化阶段 通信时间(ms) 计算时间(ms) 总延迟(ms) 吞吐(tokens/s)
Baseline(无优化) 38.7 26.3 65.0 31.5
+ Tree拓扑(错误选择) 28.4 26.3 54.7 37.3
+ Mesh拓扑(正确选择) 12.4 26.3 38.7 52.7
+ RDMA的ECN/PFC开启 9.8 26.3 36.1 56.5
+ HCCL buffer调大(256 MB) 8.2 26.3 34.5 59.4
+ 梯度压缩(FP16) 4.1 26.3 30.4 67.1

结论:5个优化叠加,通信时间从38.7 ms降到4.1 ms(9.4x加速),总延迟从65.0 ms降到30.4 ms(2.14x加速),吞吐从31.5 tokens/s涨到67.1 tokens/s(2.13x提升)。

结尾

hccl这个通信库的核心价值,是让多卡/多机的梯度同步、激活值传输、参数同步这些通信任务自动化、高性能化。你不需要自己手写Socket通信代码,也不需要手动做梯度平均,hccl底层都帮你搞定了。

我帮那个高校实验室迁移完70B推理之后,他们原来的GPU集群(8张A100)跑70B的吞吐是每秒42个token,换成64张Ascend 910之后,吞吐是每秒67个token,成本只有原来的70%,性价比很明显。

如果你在搞大模型分布式训练/推理,不管是在GPU上还是在NPU上,都建议去 https://atomgit.com/cann/hccl 把这个仓库的示例代码拉下来,先跑一把hccl_allreduce_test。光看文档是感受不到Tree拓扑和Mesh拓扑的性能差异的,必须自己跑一把,看通信时间从38 ms降到4 ms的那一刻,你才知道hccl的价值。


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

Logo

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

更多推荐