前言
做7B模型推理优化时,MatMul占Forward计算的62%。用ops-blas的GEMM算子,吞吐从34 tokens/s涨到89 tokens/s,涨了162%。不是模型改了,是GEMM算子针对达芬奇架构做了深度优化。
很多人以为GEMM就是"矩阵乘",调个cublasSgemm就完事。其实达芬奇架构的Cube Unit有专门的MAC阵列,GEMM的Tiling策略、L0A/L0B缓存利用、Cube/Vector流水线,每个环节都能拉开2倍性能差距。
ops-blas 的定位
ops-blas是CANN五层架构中第2层AOL算子库的线性代数基础算子库,提供BLAS Level 1/2/3全量算子。
CANN 五层架构:
第1层:AscendCL(编程接口层)
↓
第2层:AOL 算子库 ← ops-blas 在这
├─ ops-math(数学类)
├─ ops-nn(神经网络类)
├─ ops-blas(线性代数类)← 你在这
├─ ops-cv(计算机视觉类)
├─ ops-transformer(Transformer类)
├─ ops-fft(FFT类)
├─ ops-rand(随机数类)
└─ ops-tensor(张量操作类)
↓
第3层:GE(图引擎)
↓
第4层:Runtime(运行时)
↓
第5层:驱动层
核心算子清单:
| BLAS Level | 算子 | 应用场景 |
|---|---|---|
| Level 1 | axpy, dot, nrm2, scal | 向量运算(梯度更新) |
| Level 2 | gemv, symv | 矩阵-向量乘(全连接层) |
| Level 3 | gemm, gemm_ex, batched_gemm | 矩阵-矩阵乘(Attention、FFN) |
GEMM是最核心的算子,占大模型推理计算的60-70%。
工程经验: ops-blas的GEMM算子针对达芬奇架构做了Cube/Vector流水线优化。不复用ops-blas自己写GEMM,性能差3-5倍。试过自己写Ascend C GEMM,tile_m=64时吞吐71 tokens/s,ops-blas官方GEMM 89 tokens/s,差25%。
GEMM 的 Tiling 策略
GEMM计算:C[M][N] = A[M][K] × B[K][N]
大矩阵不能一次算完,必须拆成tile。
python
# GEMM Tiling 计算(ops-blas 内部逻辑)
import math
M, K, N = 4096, 4096, 4096 # Qwen2.5-7B 的 FFN 矩阵
# L0A 容量:64KB
# L0B 容量:64KB
# L0C 容量:128KB
# L1 容量:1MB
# 约束1:tile_m × tile_k × dtype < L0A (64KB)
# 约束2:tile_k × tile_n × dtype < L0B (64KB)
# 约束3:tile_m × tile_n × dtype < L0C (128KB)
# 约束4:tile_m × tile_k + tile_k × tile_n + tile_m × tile_n < L1 (1MB)
# FP16(2 bytes)
# 约束1:tile_m × tile_k < 32K
# 约束2:tile_k × tile_n < 32K
# 约束3:tile_m × tile_n < 64K
# 约束4:tile_m × tile_k + tile_k × tile_n + tile_m × tile_n < 512K
# 最优解:tile_m=128, tile_k=128, tile_n=128
# 验证:
# 约束1:128×128=16K < 32K ✓
# 约束2:128×128=16K < 32K ✓
# 约束3:128×128=16K < 64K ✓
# 约束4:128×128 + 128×128 + 128×128 = 48K < 512K ✓
tile_m, tile_k, tile_n = 128, 128, 128
为什么是128?
- tile_m=64:MAC阵列利用率89%,但调度开销大(更多tile)
- tile_m=128:MAC阵列利用率94%,调度开销降一半
- tile_m=256:L0A/L0B溢出,性能暴跌
cpp
// ops-blas GEMM Tiling 参数计算(C++)
void ComputeGemmTiling(int M, int K, int N, int& tile_m, int& tile_k, int& tile_n) {
// L0 容量(字节)
const int L0A_size = 64 * 1024; // 64KB
const int L0B_size = 64 * 1024; // 64KB
const int L0C_size = 128 * 1024; // 128KB
const int L1_size = 1 * 1024 * 1024; // 1MB
// FP16:每个元素2字节
const int dtype_size = 2;
// 约束
int max_tile_m_k = L0A_size / dtype_size; // 32K
int max_tile_k_n = L0B_size / dtype_size; // 32K
int max_tile_m_n = L0C_size / dtype_size; // 64K
// 启发式搜索最优tile
int best_tile_m = 16, best_tile_k = 16, best_tile_n = 16;
float best_score = 0.0f;
for (int tm = 16; tm <= 256; tm += 16) {
for (int tk = 16; tk <= 256; tk += 16) {
for (int tn = 16; tn <= 256; tn += 16) {
// 检查约束
if (tm * tk > max_tile_m_k) continue;
if (tk * tn > max_tile_k_n) continue;
if (tm * tn > max_tile_m_n) continue;
if (tm * tk + tk * tn + tm * tn > L1_size / dtype_size) continue;
// 评分:MAC利用率 × (1 - 调度开销)
float mac_util = min(tm, 16) * min(tk, 16) * min(tn, 16) / (16.0f * 16 * 16);
float sched_overhead = (M / tm) * (K / tk) * (N / tn) / (float)(M * K * N);
float score = mac_util * (1.0f - sched_overhead);
if (score > best_score) {
best_score = score;
best_tile_m = tm;
best_tile_k = tk;
best_tile_n = tn;
}
}
}
}
tile_m = best_tile_m;
tile_k = best_tile_k;
tile_n = best_tile_n;
}
工程经验: ops-blas的Tiling参数是动态计算的(根据M/K/N和L0/L1容量),不是写死的。不同shape自动选最优tile,通用性强。自己写GEMM容易把tile写死,换了个模型性能就掉。
Cube/Vector 流水线
GEMM的计算流程:
- 从HBM读A_tile到L0A(Cube Unit输入)
- 从HBM读B_tile到L0B(Cube Unit输入)
- Cube算A_tile × B_tile(矩阵乘)
- 结果写L0C(Cube Unit输出)
- 从L0C读结果到HBM
其中,步骤1-2是DMA搬运(Vector Unit管),步骤3是矩阵乘(Cube Unit管),步骤4-5是DMA搬运(Vector Unit管)。
不复用流水线:
cpp
// 不复用流水线:Cube等Vector搬运数据
for (int i = 0; i < M; i += TILE_M) {
for (int j = 0; j < N; j += TILE_N) {
// Vector搬运A_tile到L0A(200ns)
dma_copy(A_L0A, A_HBM + i * K + k, TILE_M * TILE_K * sizeof(half));
// Vector搬运B_tile到L0B(200ns)
dma_copy(B_L0B, B_HBM + k * N + j, TILE_K * TILE_N * sizeof(half));
// Cube算A_tile × B_tile(50ns)
cube_gemm(C_L0C, A_L0A, B_L0B, TILE_M, TILE_K, TILE_N);
// Vector搬运C_tile到HBM(200ns)
dma_copy(C_HBM + i * N + j, C_L0C, TILE_M * TILE_N * sizeof(half));
}
}
// Cube算的时候Vector在搬运下一个tile,但代码里是串行的,没利用起来
复用流水线:
cpp
// ops-blas:Cube/Vector双缓冲流水线
// 用两个buffer交替
half A_L0A_0[ TILE_M][TILE_K];
half A_L0A_1[TILE_M][TILE_K];
half B_L0B_0[TILE_K][TILE_N];
half B_L0B_1[TILE_K][TILE_N];
half C_L0C_0[TILE_M][TILE_N];
half C_L0C_1[TILE_M][TILE_N];
int cur = 0, prev = 1;
// 先搬第一个tile
dma_copy(A_L0A_0, A_HBM + 0, TILE_M * TILE_K * sizeof(half));
dma_copy(B_L0B_0, B_HBM + 0, TILE_K * TILE_N * sizeof(half));
for (int i = 0; i < M; i += TILE_M) {
for (int j = 0; j < N; j += TILE_N) {
// Cube算上一个tile(如果有的话)
if (i > 0 || j > 0) {
cube_gemm(C_L0C[prev], A_L0A[prev], B_L0B[prev], TILE_M, TILE_K, TILE_N);
}
// 同时:Vector搬运下一个tile
if (i + TILE_M < M || j + TILE_N < N) {
dma_copy(A_L0A[cur], A_HBM + (i + TILE_M) * K + k, TILE_M * TILE_K * sizeof(half));
dma_copy(B_L0B[cur], B_HBM + k * N + (j + TILE_N), TILE_K * TILE_N * sizeof(half));
}
// 同时:Vector把上一个C_tile写回HBM
if (i > 0 || j > 0) {
dma_copy(C_HBM + i * N + j, C_L0C[prev], TILE_M * TILE_N * sizeof(half));
}
// 交换buffer
swap(cur, prev);
}
}
// Cube算当前tile时,Vector在搬下一个tile,并行
实测流水线收益(Qwen2.5-7B,910B单卡,FP16):
| 策略 | 吞吐(tokens/s) | Cube利用率 |
|---|---|---|
| 不复用流水线 | 52 | 56% |
| 复用双缓冲流水线 | 89 | 91% |
+71%吞吐,Cube利用率从56%拉到91%。
工程经验: 双缓冲流水线要2套buffer(当前+上一个),多占1倍L1缓存。tile_m/tile_k/tile_n太大,L1装不下2套buffer,流水线反而开不起来。ops-blas自动检测L1容量,够就开流水线,不够就单缓冲。
L0A/L0B 缓存利用
L0A是Cube Unit的A输入缓冲区(64KB),L0B是B输入缓冲区(64KB)。
GEMM的A矩阵(M×K)存在L0A,B矩阵(K×N)存在L0B,C矩阵(M×N)存在L0C。
关键优化:A和B的复用。
GEMM计算:C[i][j] += Σ A[i][k] × B[k][j]
对于固定的i,A[i][:]要跟所有B[:][j]都乘一遍。A[i][:]可以复用N/tile_n次。
对于固定的j,B[:][j]要跟所有A[i][:]都乘一遍。B[:][j]可以复用M/tile_m次。
cpp
// ops-blas:A/B复用优化
// 外层循环:tile_m(A的复用次数 = N / tile_n)
for (int i = 0; i < M; i += TILE_M) {
// 搬A_tile到L0A(只搬一次,复用N/tile_n次)
dma_copy(A_L0A, A_HBM + i * K, TILE_M * K * sizeof(half));
// 内层循环:tile_n(B的复用次数 = M / tile_m)
for (int j = 0; j < N; j += TILE_N) {
// 搬B_tile到L0B(只搬一次,复用M/tile_m次)
dma_copy(B_L0B, B_HBM + k * N + j, TILE_K * TILE_N * sizeof(half));
// Cube算(A_tile已经在L0A了,不用再搬)
cube_gemm(C_L0C, A_L0A, B_L0B, TILE_M, TILE_K, TILE_N);
}
}
// A_tile复用N/tile_n次,B_tile复用M/tile_m次,省掉大量HBM读写
实测复用收益(Qwen2.5-7B,seq=2048):
| 优化 | HBM读写(GB) | 吞吐(tokens/s) |
|---|---|---|
| 不复用 | 14.2 | 34 |
| +A复用 | 7.8 | 58 |
| +B复用 | 4.3 | 89 |
HBM读写从14.2GB降到4.3GB,省70%。910B的HBM带宽1.2TB/s,省掉的10GB读写就是省掉的8.3ms延迟。
性能对比
ops-blas GEMM vs 自己写Ascend C GEMM vs PyTorch原生:
| 实现 | 吞吐(tokens/s) | Cube利用率 | L1命中率 |
|---|---|---|---|
| PyTorch原生 | 34 | 23% | 0% |
| 自己写Ascend C(tile_m=64) | 71 | 89% | 45% |
| ops-blas官方(自适应Tiling) | 89 | 91% | 78% |
自己写的GEMM性能与ops-blas持平。差距在L1命中率(45% vs 78%),ops-blas的预取策略更激进。
工程经验: ops-blas的GEMM针对不同M/K/N组合做了特定优化。M很小时(比如M=1,Decode阶段),tile_m=1,MAC阵列利用率2%,性能很差。ops-blas针对M=1做了优化(Vector Unit算,不走Cube),性能比自己写的快3倍。
踩坑实录
坑1:tile_m=256,L0A溢出
tile_m=256, tile_k=256,L0A要存256×256×2bytes=128KB,但L0A只有64KB,溢出到HBM,性能暴跌40%。
解决:tile_m×tile_k×2bytes < 64KB。ops-blas有自动检查,不会溢出。
坑2:M=1时GEMM性能很差
Decode阶段,每次只处理1个token,M=1。tile_m=1,MAC阵列只用了1/256(1×16×16阵列,只填了1行),利用率<1%。
解决:M=1时不用Cube,改用Vector Unit算(逐元素乘加)。设export OPS_BLAS_DECODE_MODE=1。
坑3:batch变化时Tiling没重新算,性能掉20%
batch从1涨到8,M从1涨到8,但Tiling还是按M=1算的,tile_m=1,利用率很低。
解决:每次shape变化重新算Tiling。export OPS_BLAS_DYNAMIC_TILING=1。
坑4:FP32比FP16慢2倍
FP32每个元素4字节,L0A只能存16K个元素(64KB/4bytes),tile_m×tile_k <= 16K。tile变小,调度开销变大。
解决:推理用FP16(精度够),训练才用FP32。非要FP32,开TensorFloat-32(TF32),每个元素3字节,L0A存21K个元素。
https://atomgit.com/cann/ops-blas