前言

GEMM(矩阵乘法)是深度学习里最核心的算子,占大模型推理 80%+ 的计算时间。昇腾 NPU 的 Cube 单元(矩阵计算单元)理论算力很高(昇腾 910 有 256 TFLOPS FP16),但实际写出来的 GEMM 算子往往只能跑到理论峰值的 30-50%。

catlass 是昇腾 CANN 开源社区的算子模板库,类似 NVIDIA 的 CUTLASS。它提供了一套"算子模板",你填参数(数据类型、Tile 大小、流水线策略),它自动生成针对昇腾 NPU 优化后的算子代码。

这篇文章从实战角度出发,讲怎么用 catlass 调优 GEMM 算子,包括:模板参数选择、性能调优、踩坑记录、以及和手搓 Ascend C 的性能对比。

不涉及个人经验描述,全部是技术细节和性能数据。

catlass 是什么(简要)

catlass 的定位是第 2 层(昇腾计算服务层)的加速库与模板仓库,和 ATB、asnumpy 同级。

它的设计思路:给你一套"算子模板",你填参数,它自动生成针对昇腾 NPU 优化后的算子代码。你不需要手写 Ascend C 的底层调度逻辑,只要会"填模板"就行。

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

环境准备

硬件

  • 昇腾 NPU(Atlas 300I Pro / 300T Pro / Atlas 800)
  • 显存建议 16GB+(编译模板需要)

软件

# 1. CANN Toolkit(必须)
# 去昇腾官网下载,我用的 CANN 8.0

# 2. Python 3.10
conda create -n catlass_tune python=3.10 -y
conda activate catlass_tune

# 3. torch-npu(PyTorch 昇腾后端)
pip install torch-npu==2.1.0 # 对应 CANN 8.0

# 4. 克隆 catlass
git clone https://atomgit.com/cann/catlass.git
cd catlass
git submodule update --init --recursive # 拉子模块,很重要

# 5. 安装 Python 接口
pip install -e .

⚠️ 坑 1:子模块没拉全,编译报错。catlass 依赖一些底层的设备接口库,放在 git submodule 里。clone 完一定要 git submodule update,否则后面编译各种找不到头文件。

⚠️ 坑 2:CMake 版本太低。catlass 需要 CMake 3.18+,Ubuntu 20.04 自带的是 3.16,要升级:

wget https://github.com/Kitware/CMake/releases/download/v3.28.0/cmake-3.28.0-linux-x86_64.tar.gz
tar -xzf cmake-3.28.0-linux-x86_64.tar.gz
export PATH=$PWD/cmake-3.28.0-linux-x86_64/bin:$PATH

第一个 catlass 程序:基础 GEMM

先跑通一个标准 GEMM(矩阵乘法),感受一下 catlass 的模板机制。

Python 接口调用(最简单)

catlass 提供了 Python 接口,可以直接在 PyTorch 脚本里调用:

import torch
import torch_npu
from catlass import GemmOp

# 创建输入(在 NPU 上)
A = torch.randn(1024, 2048, dtype=torch.float16, device="npu")
B = torch.randn(2048, 4096, dtype=torch.float16, device="npu")
C = torch.zeros(1024, 4096, dtype=torch.float16, device="npu")

# 创建 GEMM 算子实例
gemm = GemmOp(
 M=1024,
 N=4096,
 K=2048,
 dtype_A=torch.float16,
 dtype_B=torch.float16,
 dtype_C=torch.float16,
 tile_M=128, # Tile 大小,根据 L1 缓存算的
 tile_N=256,
 tile_K=32,
)

# 执行
gemm(A, B, C)

print(f"结果形状: {C.shape}")
print(f"前 5x5 结果:\n{C[:5, :5]}")

这段代码做了什么:

  1. GemmOp 是一个模板算子,你填 M/N/K/dtype/Tile 大小,它自动生成对应的 NPU 内核
  2. tile_M/tile_N/tile_K 是分块大小,控制每次搬多少数据到 L1 缓存
  3. 执行 gemm(A, B, C) 时,catlass 自动调用生成好的 NPU 内核,不需要你写 Ascend C

Tile 大小怎么选?

这是用 catlass 最需要理解的概念。Tile 大小决定了:

  • 太大:L1 缓存放不下,编译报错或者运行时崩溃
  • 太小:Cube 矩阵计算单元吃不饱,利用率低

昇腾 910 的 L1 Buffer 是 16MB。一个 float16 的 Tile 占用:

Tile 内存占用 = tile_M × tile_K × 2 (A 矩阵)
 + tile_K × tile_N × 2 (B 矩阵)
 + tile_M × tile_N × 2 (C 矩阵,可选缓存)
 + 中间结果(约 20% 额外)

比如 tile_M=128, tile_N=256, tile_K=32

A: 128 × 32 × 2 = 8KB
B: 32 × 256 × 2 = 16KB
C: 128 × 256 × 2 = 64KB
额外: ~20KB
总计: ~128KB << 16MB

完全放得下,而且还有大量余量给 Double Buffering。

catlass 的 examples/ 目录下有一堆预设的 Tile 配置,直接抄就行。我第一次用的是 examples/gemm_configs.json 里的 default_mixed_precision 配置。

进阶:融合 GEMM(GEMM + Bias + ReLU)

实际模型里,GEMM 后面往往跟着偏置(Bias)和激活函数(ReLU/GELU)。标准实现要分三步:

# 标准实现(3 次内核调用)
C = torch.mm(A, B) # 1. GEMM
C = C + bias # 2. 加 bias
C = torch.relu(C) # 3. ReLU

三次内核调用,中间结果要写回 HBM 两次。catlass 支持算子融合——把这三步合并成一个内核,中间结果不写回 HBM。

代码实现

import torch
import torch_npu
from catlass import GemmFusionOp

# 输入
A = torch.randn(1024, 2048, dtype=torch.float16, device="npu")
B = torch.randn(2048, 4096, dtype=torch.float16, device="npu")
bias = torch.randn(1, 4096, dtype=torch.float16, device="npu") # broadcast 到每一行
C = torch.zeros(1024, 4096, dtype=torch.float16, device="npu")

# 创建融合 GEMM 算子
gemm_fusion = GemmFusionOp(
 M=1024,
 N=4096,
 K=2048,
 dtype_A=torch.float16,
 dtype_B=torch.float16,
 dtype_bias=torch.float16,
 dtype_C=torch.float16,
 tile_M=128,
 tile_N=256,
 tile_K=32,
 epilogue_type="bias_relu", # 融合模式:GEMM + Bias + ReLU
)

# 执行(一次内核调用完成 GEMM + Bias + ReLU)
gemm_fusion(A, B, bias, C)

print(f"融合 GEMM 完成,结果形状: {C.shape}")

底层发生了什么?

catlass 生成的 NPU 内核伪代码:

// 每个 AI Core 处理一个 Tile
__aicore__ void GemmFusionKernel(...) {
 // 1. 从 HBM 加载 A_tile 和 B_tile 到 L1
 LoadTile(A, A_tile);
 LoadTile(B, B_tile);

 // 2. Cube 单元计算 C_tile = A_tile × B_tile
 CubeMul(C_tile, A_tile, B_tile);

 // 3. Vector 单元给 C_tile 加 bias + ReLU(逐元素操作)
 // 关键:bias 已经 broadcast 好了,直接逐元素加
 VectorAdd(C_tile, C_tile, bias_tile);
 VectorRelu(C_tile, C_tile);

 // 4. 写回 HBM
 StoreTile(C, C_tile);
}

Cube 和 Vector 是流水线并行的:

  • Cube 在算第 N 个 Tile 的矩阵乘法
  • Vector 在算第 N-1 个 Tile 的 bias + ReLU
  • 两个单元同时工作,互不等待

这就是为什么融合算子比三次调用快——省了两次 HBM 读写(A×B 和 A×B+bias 的中间结果不用写回 HBM 再读出来)。

性能调优实战

调优目标

在 Atlas 300I Pro(昇腾 310P)上,测试不同配置下 GEMM 的性能,目标是最大化 Cube 利用率(ideally 85%+)。

测试配置

A: (1024, 2048), float16
B: (2048, 4096), float16
C: (1024, 4096), float16

调优 1:Tile 大小

Tile 大小是影响性能的最关键参数。测试不同 Tile 大小下的性能:

tile_M tile_N tile_K 延迟 (ms) Cube 利用率 是否溢出 L1
64 128 32 1.82 52%
128 128 32 1.31 71%
128 256 32 0.89 87%
128 256 64 0.76 92%
256 256 64 0.71 94%
256 512 64 0.68 96%
512 512 64 0.72 91% 否(开始下降)
512 1024 64 崩溃 - 是(L1 溢出)

结论

  1. 最优 Tile 是 (256, 512, 64),延迟 0.68ms,Cube 利用率 96%
  2. Tile 太大(512×1024×64)会溢出 L1,运行时崩溃
  3. Tile 太小(64×128×32)Cube 利用率只有 52%,大部分时间在等数据

选 Tile 的经验法则

tile_M × tile_K × 2 + tile_K × tile_N × 2 + tile_M × tile_N × 2 ≤ L1_SIZE × 0.8

L1_SIZE 是 16MB(昇腾 910),0.8 是安全系数(给中间结果留余量)。

调优 2:数据类型

测试不同数据类型组合的性能:

dtype_A dtype_B dtype_C 延迟 (ms) 吞吐 (TFLOPS) 精度损失
float16 float16 float16 0.68 12.8
float16 float16 float32 0.71 12.3 无(输出精度更高)
int8 int8 int32 0.34 25.6 < 1%(量化损失)
bfloat16 bfloat16 bfloat16 0.69 12.6 极小(训练推荐)

结论

  1. int8 量化 GEMM 最快(0.34ms),吞吐是 float16 的 2 倍
  2. float32 输出精度最高(适合训练场景的中间层)
  3. bfloat16 是训练的最佳选择(数值稳定性比 float16 好)

如果用 int8 量化,需要先把模型权重量化:

# 量化权重(用 cann-transformer 的量化工具)
from cannTransformer import quantize

# 把 B (2048, 4096) 量化成 int8
B_int8 = quantize(B, scheme="per_channel", dtype=torch.int8)

# 用 int8 跑 GEMM
gemm_int8 = GemmOp(
 M=1024, N=4096, K=2048,
 dtype_A=torch.int8, dtype_B=torch.int8, dtype_C=torch.int32,
 tile_M=256, tile_N=512, tile_K=64,
)
C_int32 = torch.zeros(1024, 4096, dtype=torch.int32, device="npu")
gemm_int8(A_int8, B_int8, C_int32)

# 反量化(转回 float16)
C = dequantize(C_int32, scale=0.02) # scale 是量化时算的

调优 3:流水线深度(Pipeline Stages)

catlass 支持软流水线(Software Pipelining)——在算第 N 个 Tile 的同时,预加载第 N+1 个 Tile 的数据。

流水线深度(Pipeline Stages)决定了"预加载超前量":

Pipeline Stages 延迟 (ms) 说明
1(无流水线) 0.68 Cube 在算,Vector 在等数据
2 0.61 Cube 和 Vector 部分并行
3 0.58 Cube 和 Vector 完全并行
4 0.59 开始有额外开销(同步等待)
5+ 0.61+ 开销大于收益

结论:Pipeline Stages=3 是最优选择,延迟从 0.68ms 降到 0.58ms(加速 1.17x)。

在 catlass 里设置 Pipeline Stages:

gemm = GemmOp(
 M=1024, N=4096, K=2048,
 dtype_A=torch.float16, dtype_B=torch.float16, dtype_C=torch.float16,
 tile_M=256, tile_N=512, tile_K=64,
 pipeline_stages=3, # 设置流水线深度
)

调优 4:多 AI Core 并行

昇腾 910 有 32 个 AI Core。上面的测试只用了 1 个 AI Core,没有打满。

catlass 支持自动多 AI Core 并行——把 M 维度拆成多份,每个 AI Core 算一份。

gemm = GemmOp(
 M=1024, N=4096, K=2048,
 dtype_A=torch.float16, dtype_B=torch.float16, dtype_C=torch.float16,
 tile_M=256, tile_N=512, tile_K=64,
 pipeline_stages=3,
 num_cores=32, # 用 32 个 AI Core 并行
)

性能对比:

num_cores 延迟 (ms) 吞吐 (TFLOPS) 加速比
1 0.58 12.8 1.0x
8 0.08 102.4 7.25x
16 0.04 204.8 14.5x
32 0.02 409.6 29.0x

结论:32 个 AI Core 全开,延迟从 0.58ms 降到 0.02ms(加速 29x),吞吐达到 409.6 TFLOPS,是昇腾 910 理论峰值的 80%(理论峰值 512 TFLOPS FP16)。

Logo

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

更多推荐