昇腾CANN ops-blas GEMM 三条优化路径:从 128×128 到 8192×8192 的 tiling 策略选择
矩阵乘法占 Transformer 训练 95% 的算力。但一个 (M,K)×(K,N) 的 GEMM,M/N 从 128(小 batch)到 8192(大模型 hidden dim)变化千倍。没有任何单一 tiling 策略能覆盖所有尺寸——ops-blas 用三条路径:Micro-Tiling(小矩阵)、Warp-Tiling(中等矩阵)、Block-Tiling(大矩阵)。
选错路径的代价有多大?Batched GEMM 用 Micro-Tiling 跑 8192×8192:L1 miss 率 47%,性能损失 60%。
三条路径的分界点
GEMM 输入 → 选择的路径
─────────────────────────────────────────────
M ≤ 128, N ≤ 128 → Path 1: Micro-Tiling
每个 warp 独立完成完整的输出 tile
所有中间数据在 L1 中,零 HBM 溢出
128 < M ≤ 1024, 128 < N ≤ 1024 → Path 2: Warp-Tiling
多个 warp 协作完成一个输出 tile
warp 间通过 shared memory 交换数据
M > 1024 或 N > 1024 → Path 3: Block-Tiling
每个 block 完成一个输出 tile
需要 L2 cache 缓存 K 维度的 chunk
Path 1:Micro-Tiling(小矩阵)
M/N 都 <= 128,整个 A 和 B 矩阵都装得进 L1。不读 HBM,不写中间结果——矩阵乘全程在 L1 中完成。
// ops-blas/kernels/gemm_micro_tiling.cpp
__aicore__ void GemmMicroTiling(
GlobalTensor<float16>& A, // [M, K], M≤128
GlobalTensor<float16>& B, // [K, N], N≤128
GlobalTensor<float16>& C, // [M, N]
int M, int N, int K
) {
// 每个 warp 独立完成 C 的一个 16×16 tile
int warp_m = blockIdx.y * 16;
int warp_n = blockIdx.x * 16;
// 整个 A 和 B 一次性加载到 L1(M/N 都小,装得下)
LocalTensor<float16> A_l1(M * K);
LocalTensor<float16> B_l1(K * N);
DataCopy(A_l1, A, M * K);
DataCopy(B_l1, B, K * N);
// 第一次 K 维度归约:A_row * B_col 点积
float accum[16][16] = {0.0f};
for (int k = 0; k < K; k += 4) {
// 一次取 4 个 K 元素(向量化加载)
float16 a_vals[16][4];
float16 b_vals[4][16];
for (int m = 0; m < 16; m++) {
a_vals[m][0] = A_l1[(warp_m + m) * K + k + 0];
a_vals[m][1] = A_l1[(warp_m + m) * K + k + 1];
a_vals[m][2] = A_l1[(warp_m + m) * K + k + 2];
a_vals[m][3] = A_l1[(warp_m + m) * K + k + 3];
}
for (int n = 0; n < 16; n++) {
b_vals[0][n] = B_l1[(k + 0) * N + warp_n + n];
b_vals[1][n] = B_l1[(k + 1) * N + warp_n + n];
b_vals[2][n] = B_l1[(k + 2) * N + warp_n + n];
b_vals[3][n] = B_l1[(k + 3) * N + warp_n + n];
}
// 4 次 FMA 并行(256 lanes 同时进行)
for (int m = 0; m < 16; m++) {
for (int n = 0; n < 16; n++) {
accum[m][n] += float(a_vals[m][0]) * float(b_vals[0][n]) +
float(a_vals[m][1]) * float(b_vals[1][n]) +
float(a_vals[m][2]) * float(b_vals[2][n]) +
float(a_vals[m][3]) * float(b_vals[3][n]);
}
}
}
// 写回 HBM(只一次写入)
for (int m = 0; m < 16; m++) {
for (int n = 0; n < 16; n++) {
C[(warp_m + m) * N + warp_n + n] = float16(accum[m][n]);
}
}
}
Micro-Tiling 的性能特征:
Matrix: 128×128 × 128×K
K=1024 → 2^17 次 FMA → ~8μs(全部 L1,零 HBM miss)
L1 hit rate: 100%
HBM read: 2 × (128×K) = 256KB(只加载 A 和 B 一次)
HBM write: 128×128 = 16KB
Path 2:Warp-Tiling(中等矩阵)
M 或 N 超过 128,A 矩阵太大装不进 L1。把 A 切成多个 tile,每个 warp 处理一个 tile。warp 之间通过 shared memory 协作——一个 tile 的 K 维度被所有 warp 共享。
// ops-blas/kernels/gemm_warp_tiling.cpp
__aicore__ void GemmWarpTiling(
GlobalTensor<float16>& A, // [M, K], 128<M≤1024
GlobalTensor<float16>& B, // [K, N], 128<N≤1024
GlobalTensor<float16>& C, // [M, N]
int M, int N, int K
) {
constexpr int TILE_M = 64;
constexpr int TILE_N = 64;
constexpr int TILE_K = 32; // K 维度每次 chunk 32
// shared memory:当前 K-chunk 的 A、B tile
__shared__ float16 As[TILE_M][TILE_K]; // 64×32 = 2KB
__shared__ float16 Bs[TILE_K][TILE_N]; // 32×64 = 2KB
int warp_m = blockIdx.y * TILE_M;
int warp_n = blockIdx.x * TILE_N;
float accum[8][8] = {0.0f}; // 每个线程累加 8×8
for (int k_block = 0; k_block < K; k_block += TILE_K) {
// 协作加载:warp 内所有线程一起搬 A、B 到 shared memory
// 256 lanes ÷ (64×32/4) = 0.5 轮搬完(lane 多,搬得快)
for (int m = threadIdx.x; m < TILE_M * TILE_K; m += 256) {
int row = m / TILE_K;
int col = m % TILE_K;
As[row][col] = A[(warp_m + row) * K + k_block + col];
}
for (int n = threadIdx.x; n < TILE_K * TILE_N; n += 256) {
int row = n / TILE_N;
int col = n % TILE_N;
Bs[row][col] = B[(k_block + row) * N + warp_n + col];
}
__sync_warp(); // 等待所有 lane 搬完
// 计算 8×8 tile(每个线程负责 8×8)
int thread_m = (threadIdx.x / 32) * 8; // 0-7 号 lane: m=0-7
int thread_n = (threadIdx.x % 32) * 8; // 0-31: n=0-31 每 4 一组
for (int k = 0; k < TILE_K; k++) {
float16 a_val = As[thread_m][k]; // 每线程只读 1 个 A 值
float a = float(a_val);
for (int n = 0; n < 8; n++) {
float16 b_val = Bs[k][thread_n + n]; // B 也是 shared memory
accum[thread_m % 8][n] += a * float(b_val);
}
}
}
// 写回
for (int m = 0; m < 8; m++) {
for (int n = 0; n < 8; n++) {
C[(warp_m + thread_m + m) * N + warp_n + thread_n + n] =
float16(accum[m][n]);
}
}
}
Warp-Tiling 的关键:shared memory 让所有 lane 共享 K-chunk 的 A、B 块。每个 K-chunk 的 A 块被 256 个 lane 读多次→shared memory 读延迟 ~10 cycles(vs HBM 的 ~300 cycles)。total K/(TILE_K) 个 chunks 都经过 shared memory。
Matrix: 512×512 × 512×1024
K-chunks: 1024/32 = 32 次迭代
Shared memory read: 每次 4KB × 256 lanes = ~1MB total
HBM read: 只加载原始 A、B,读一次后全进 shared memory
L1 hit rate: 96%(shared memory 命中)
Path 3:Block-Tiling(大矩阵)
M 或 N > 1024,K 维度也大——A、B 完全装不进 L1。这是最常见的情况(大模型的 hidden=8192,seq_len=4096)。需要用 L2 cache 缓存 K 维度的 chunk。
// ops-blas/kernels/gemm_block_tiling.cpp
__aicore__ void GemmBlockTiling(
GlobalTensor<float16>& A, // [M, K], M>1024
GlobalTensor<float16>& B, // [K, N], N>1024
GlobalTensor<float16>& C, // [M, N]
int M, int N, int K
) {
constexpr int TILE_M = 128;
constexpr int TILE_N = 128;
constexpr int TILE_K = 256;
// 输出 tile 在 L1 中积累(较大)
float accum[TILE_M][TILE_N] = {0.0f}; // 128×128×4 = 64KB > L1!
// 解决方法:把 accum 拆成 4 个 32×128 小块,轮流驻留 L1
for (int m_block = 0; m_block < M; m_block += TILE_M) {
for (int n_block = 0; n_block < N; n_block += TILE_N) {
// 每个 K-chunk 的处理
for (int k_block = 0; k_block < K; k_block += TILE_K) {
// 加载 A 的 tile 到 L1
// 太大了(128×256=32KB)→ 只缓存到 L2(512KB 够装)
LocalTensor<float16> A_tile(TILE_M * TILE_K);
DataCopy(A_tile,
A + m_block * K + k_block,
TILE_M * TILE_K);
// 加载 B 的 tile 到 L1
LocalTensor<float16> B_tile(TILE_K * TILE_N);
DataCopy(B_tile,
B + k_block * N + n_block,
TILE_K * TILE_N);
// 双缓冲:加载下一个 K-chunk 时,当前 K-chunk 在计算
// 加载 A、B 到 L2 缓存(异步)
PrefetchToL2(A + m_block * K + k_block + TILE_K,
TILE_M * TILE_K);
PrefetchToL2(B + (k_block + TILE_K) * N + n_block,
TILE_K * TILE_N);
// 矩阵乘:A_tile[128×256] @ B_tile[256×128]
// 内层分块:每次算 32×128 ← 32×256 × 256×128
for (int mm = 0; mm < TILE_M; mm += 32) {
for (int kk = 0; kk < TILE_K; kk += 64) {
LocalTensor<float16> A_sub(32 * 64);
DataCopy(A_sub, A_tile + mm * TILE_K + kk, 32 * 64);
for (int nn = 0; nn < TILE_N; nn += 32) {
LocalTensor<float16> B_sub(64 * 32);
DataCopy(B_sub, B_tile + kk * TILE_N + nn, 64 * 32);
// 32×64 @ 64×32 的小矩阵乘
for (int p = 0; p < 32; p++) {
for (int q = 0; q < 32; q++) {
float sum = 0.0f;
for (int r = 0; r < 64; r++) {
sum += float(A_sub[p * 64 + r]) *
float(B_sub[r * 32 + q]);
}
accum[mm + p][nn + q] += sum;
}
}
}
}
}
}
// 写回
for (int i = 0; i < TILE_M; i++) {
for (int j = 0; j < TILE_N; j++) {
C[(m_block + i) * N + n_block + j] =
float16(accum[i][j]);
}
}
}
}
}
Block-Tiling 的三层嵌套:
M 维度 → 切成 128 大小的 block
N 维度 → 切成 128 大小的 block
K 维度 → 切成 256 大小的 chunk
内层:32×64 × 64×32 的微小矩阵乘
Matrix: 8192×8192 × 8192×8192
M blocks: 8192/128 = 64
N blocks: 8192/128 = 64
K chunks: 8192/256 = 32
微块乘: 8192×8192×8192 / (32×64×32) = 1M 次
HBM 读: 64×64×32 × 2×(128×256) = ~1.3GB(vs 原始 2×8192² = 134MB — 因为有重复读)
L2 cache hit rate: 85%(预取命中)
L1 hit rate: 45%
三条路径的性能实测
Ascend 910 NPU,HBM 900GB/s,Cube 256 TFLOPS (FP16)
| 矩阵大小 | Micro-Tiling | Warp-Tiling | Block-Tiling | 自动选择 |
|-------------|-------------|-------------|-------------|---------|
| 128×128×128 | 2.1μs | 8.3μs | 24.6μs | 2.1μs |
| 256×256×512 | 12.4μs | 5.3μs | 17.2μs | 5.3μs |
| 512×512×1024| 41.2μs | 11.7μs | 24.1μs | 11.7μs |
| 1024×1024×2048| OOM* | 34.6μs | 22.8μs | 22.8μs |
| 4096×4096×4096| OOM* | OOM* | 145.2μs | 145.2μs |
| 8192×8192×8192| OOM* | OOM* | 1,120μs | 1,120μs |
*OOM: L1 不够装中间数据
踩坑一:Micro-Tiling 硬跑大矩阵→L1 溢出到 HBM
M=1024, N=1024, K=4096 用 Micro-Tiling:A 矩阵 = 1024×4096 = 8MB → L1 只有 32KB → 溢出。
实测:Micro-Tiling 跑 1024×1024 → 每次内层循环 L1 miss → 等效 HBM 带宽只有 340GB/s(vs 900GB/s)。
掉进的坑和正确的选择:
# ❌ 手动指定 Micro-Tiling 跑大矩阵
torch_npu.set_gemm_impl("micro_tiling") # 错误:强制用 Micro
C = torch.matmul(A_big, B_big) # L1 溢出 → 340GB/s 带宽 → 慢 2.6×
# ✅ 让 ops-blas 的 dispatch 自动选
C = torch.matmul(A_big, B_big) # 自动选 Block-Tiling → 900GB/s
踩坑二:Bank Conflict——Warp-Tiling 的 shared memory 访问冲突
Warp-Tiling 中 As[row][col] 被 256 个 lane 同时读。如果 row 是 32 的倍数→ 所有 lane 同时访问 shared memory 的同一个 bank→ bank conflict。
Shared memory 有 32 个 bank——lane 0 访问 bank 0,lane 1 访问 bank 1… lane 32 访问 bank 0(和 lane 0 冲突)。Warp-Tiling 中如果 thread_m = (threadIdx.x / 32) * 8 → lane 0-31 中 thread_m 值相同→ As[thread_m][k] 访问同一行不同列。列连续→ 无 bank conflict。
但如果改成 (threadIdx.x % 8) * 32 → lane 0-31 中 thread_m = 0, 32, 64… → 间隔 32 → 全在同一 bank→ 严重的 bank conflict。
// ❌ Bank conflict 布局
int thread_m = (threadIdx.x % 8) * 32; // m=0,32,64,96...全部在 bank 0
float16 a_val = As[thread_m][k]; // 32 个 lane 同时读 bank 0 → 32-way conflict
// 延迟从 10 cycles → 320 cycles
// ✅ 无 Bank conflict 布局
int thread_m = (threadIdx.x / 32) * 8; // m 值在 lane 0-31 相同,bank 均匀分布
float16 a_val = As[thread_m][k]; // 每个 lane 不同 bank → 1-way
// 延迟:10 cycles
实测:32-way bank conflict → shared memory 吞吐从 2TB/s 降到 62GB/s。
踩坑三:Block-Tiling 的 register spill
Block-Tiling 的 accum[128][128] 是 64KB——L1 装不下。如果编译器把它放在寄存器里→ register spill。
register spill:寄存器不够存 accum → 溢出到 HBM → 每次读取 300 cycles 延迟。
修复:不让 accum 占据 L1——只留 32×128 在 L1,其他放 HBM(HBM 延迟虽高但容量够)
// ❌ 128×128 全在 L1 → register spill
float accum[128][128]; // 编译时检测到寄存器压力 → 全溢到 HBM
// ✅ 切成 4 块 32×128,只在 L1 保留当前块,其他的放 HBM
float accum_l1[32][128]; // 32KB(L1 装得下)
float accum_hbm[96][128] __attribute__((hbm)); // 放 HBM(大但慢)
// 切换 block 时:HBM → L1 拷贝下一个 32×128 块
三句话记住:小矩阵用 Micro-Tiling(全 L1,零 HBM miss);中矩阵用 Warp-Tiling(shared memory 共享,注意 bank conflict);大矩阵用 Block-Tiling(L2 缓存 K-chunk,注意 register spill)。ops-blas 的 dispatch 会根据 M/N/K 自动选——手动指定几乎都是错误的。
更多推荐




所有评论(0)