张量加速核性能调优实战 - 第2节

一、Tensor Core 硬件微架构:矩阵乘累加单元

各位同学,咱们今天聊聊 Tensor Core 的硬件微架构。说白了,就是那个专门做矩阵乘法的“小钢炮”。

我记得第一次接触 Tensor Core 时,心里想的是:“这不就是个加强版的 ALU 吗?”后来被现实狠狠教育了一顿。Tensor Core 的设计哲学,跟传统的 CUDA Core 完全不是一回事。

1.1 从 SIMT 到 Tensor Core 的进化

传统的 CUDA Core 走的是 SIMT 路线——单指令多线程。每个线程处理一个元素,适合做 element-wise 操作。但矩阵乘法呢?它天然就是“块”操作。

Tensor Core 的思路很直接:一次指令,完成一个 4x4 矩阵乘法。你想想看,如果用 CUDA Core 做 4x4 矩阵乘,得发多少条指令?16 个线程各算各的,还得同步。Tensor Core 一条指令搞定。

核心差异:

  • CUDA Core:线程级并行,每个线程算一个元素
  • Tensor Core:Warp 级并行,一个 Warp 算一个 Tile

1.2 微架构细节:那个 4x4 的小方块

Tensor Core 内部到底长什么样?我画个图你就明白了。

Tensor Core 4x4 矩阵乘累加单元微架构 矩阵 A (4x4) a00 a01 a02 a03 a10 a11 a12 a13 a20 a21 a22 a23 a30 a31 a32 a33 矩阵 B (4x4) b00 b01 b02 b03 b10 b11 b12 b13 b20 b21 b22 b23 b30 b31 b32 b33 乘累加阵列 (16个MAC) MAC MAC MAC MAC MAC MAC MAC MAC MAC MAC MAC MAC 累加器 (4x4 矩阵 C) 输出: C = A × B + C 一条指令完成: 16个乘加运算 + 累加 数据精度: FP16/FP32/TF32/INT8 等 吞吐量: 每个时钟周期完成 4x4x4 矩阵乘

看到这个图了吗?Tensor Core 内部有 16 个 MAC(乘累加单元),排成 4x4 的阵列。矩阵 A 的每一行广播到所有列,矩阵 B 的每一列广播到所有行。结果直接累加到 C 矩阵上。

我刚开始做优化时,总想手动控制这些 MAC 单元。后来发现,硬件已经帮你安排好了数据流,你只需要告诉它“我要做矩阵乘”就行。

二、Warp 级矩阵运算:32 个线程的集体舞

Tensor Core 不是给单个线程用的。它服务的对象是 Warp——32 个线程的集体。

2.1 为什么是 Warp 级?

你想想看,一个 4x4 矩阵乘需要 16 个 MAC。一个 Warp 有 32 个线程。每个线程负责一部分计算,大家协同完成一个更大的 Tile。

在 Volta 架构中,一个 Warp 通过 Tensor Core 指令完成 16x16x16 的矩阵乘。怎么做到的?

  • 每个线程持有 4 个元素(来自矩阵 A 的一行)
  • 每个线程持有 4 个元素(来自矩阵 B 的一列)
  • 32 个线程合起来,刚好覆盖 16x16 的矩阵

我的经验:刚开始写 Tensor Core 代码时,我总想用 threadIdx.x 去索引数据。后来发现,你根本不需要关心每个线程具体算哪个元素。硬件会帮你做数据分发。你只需要把数据准备好,放在寄存器里,然后发一条指令就行。

2.2 指令级细节

CUDA 里调用 Tensor Core 的指令长这样:

// 伪代码:WMMA API (Warp Matrix Multiply Accumulate)
wmma::fragment<matrix_a, 16, 16, 16, half, row_major> a_frag;
wmma::fragment<matrix_b, 16, 16, 16, half, col_major> b_frag;
wmma::fragment<accumulator, 16, 16, 16, float> c_frag;

// 加载数据到 fragment
wmma::load_matrix_sync(a_frag, A_ptr, lda);
wmma::load_matrix_sync(b_frag, B_ptr, ldb);
wmma::load_matrix_sync(c_frag, C_ptr, ldc);

// 执行矩阵乘累加
wmma::mma_sync(c_frag, a_frag, b_frag, c_frag);

// 存储结果
wmma::store_matrix_sync(C_ptr, c_frag, ldc, wmma::mem_row_major);

看到没?没有显式的循环,没有线程索引计算。你只需要定义 fragment(数据片段),然后调用 mma_sync。剩下的交给硬件。

注意:fragment 的大小是固定的。16x16x16 意味着 A 是 16x16,B 是 16x16,C 是 16x16。你不能随意改这个尺寸。这是硬件决定的。

三、数据复用与流水线:别让 Tensor Core 闲着

Tensor Core 算得飞快,但数据搬运跟不上怎么办?这就是数据复用和流水线要解决的问题。

3.1 数据复用的层次

我画个数据流图,帮你理解数据是怎么复用的:

Tensor Core 数据复用与流水线 全局内存 (HBM/GDDR) 共享内存 (L1/Shared Memory) 寄存器 (Register File) Tensor Core 数据从全局内存 加载到共享内存 共享内存中 数据被多次复用 寄存器中 数据直接喂给Tensor Core Stage 1: 加载数据 Stage 2: 计算 Stage 3: 写回

数据复用的核心思想:一次加载,多次使用。

  • 全局内存 → 共享内存:加载一个 Tile 的数据到共享内存
  • 共享内存 → 寄存器:每个线程从共享内存读取自己需要的元素
  • 寄存器 → Tensor Core:数据在寄存器中,直接喂给 Tensor Core

我遇到过最典型的性能问题:共享内存加载次数太多。本来一个 Tile 的数据可以复用 16 次,结果代码写成了每次计算都重新加载。性能直接腰斩。

3.2 流水线:让加载和计算重叠

流水线的思路很简单:计算当前 Tile 的同时,加载下一个 Tile 的数据。

在 CUDA 里,可以用 __pipeline 指令或者手动双缓冲来实现:

// 双缓冲流水线示例
__shared__ half A_buff[2][BLOCK_SIZE][BLOCK_SIZE];
__shared__ half B_buff[2][BLOCK_SIZE][BLOCK_SIZE];

int write_idx = 0;  // 当前写入的缓冲区
int read_idx = 1;   // 当前读取的缓冲区

// 预加载第一个 Tile
load_tile(A_buff[write_idx], B_buff[write_idx]);
__syncthreads();

for (int k = 0; k < K_TILES; k++) {
    // 启动下一个 Tile 的加载(异步)
    write_idx = (write_idx + 1) % 2;
    if (k + 1 < K_TILES) {
        load_tile_async(A_buff[write_idx], B_buff[write_idx]);
    }
    
    // 计算当前 Tile
    compute_tile(A_buff[read_idx], B_buff[read_idx], C_frag);
    
    // 等待异步加载完成
    if (k + 1 < K_TILES) {
        wait_for_load();
        __syncthreads();
    }
    
    read_idx = (read_idx + 1) % 2;
}

我的建议:流水线的深度不是越深越好。2 级流水线(双缓冲)通常就够用了。太深的流水线会增加寄存器压力,反而拖慢速度。

四、Tile(分块)策略基础:把大问题切成小方块

矩阵乘法本质上是 O(N³) 的复杂度。对于大矩阵,你不能一次性算完。得切成小块——这就是 Tile 策略。

4.1 为什么需要 Tile?

原因很简单:寄存器不够用。

一个 SM 的寄存器文件大概 64K 到 128K 字节。如果矩阵是 1024x1024,光存一个矩阵就要 2MB(FP16)。根本放不下。

所以,我们把大矩阵切成小 Tile,每个 Tile 刚好能塞进共享内存和寄存器。

4.2 Tile 尺寸的选择

Tile 尺寸不是随便选的。它受限于:

  • 共享内存大小:每个 SM 的共享内存有限(通常 48KB 到 164KB)
  • 寄存器数量:每个线程能用的寄存器有限(通常 255 个)
  • Tensor Core 的硬件约束:fragment 尺寸是固定的(16x16)

常见的 Tile 尺寸组合:

Tile 尺寸 (MxNxK) 共享内存占用 寄存器占用/线程 适用场景
16x16x16 1KB 32 个 小矩阵、延迟敏感
32x32x16 4KB 64 个 中等矩阵、平衡型
64x64x16 16KB 128 个 大矩阵、吞吐优先
128x128x16 64KB 256 个 超大矩阵、极致吞吐

避坑指南:我曾经在一个项目里选了 128x128 的 Tile,结果共享内存爆了。每个 SM 只能跑一个 Block,occupancy 掉到 12.5%。后来改成 64x64,occupancy 回到 50%,性能反而提升了 2 倍。

4.3 Tile 策略的黄金法则

我总结了几条经验法则:

  1. Tile 越大越好,但不能超过共享内存的一半。留点空间给其他数据。
  2. M 和 N 维度要平衡。别让一个维度特别大,另一个特别小。
  3. K 维度用 Tensor Core 的倍数。16、32、64 都行,别用奇数。
  4. 考虑 Bank Conflict。共享内存的 bank 数量是 32,Tile 的 stride 别是 32 的倍数。

嗯,这些规则不是死的。实际调优时,还是要用 profiler 跑一下,看看瓶颈在哪。

五、总结:从硬件到策略的思维转变

今天的内容,说白了就是一句话:理解硬件,才能用好硬件。

  • Tensor Core 是 Warp 级的矩阵乘累加单元,不是线程级的
  • 数据复用和流水线是性能的关键,别让 Tensor Core 闲着
  • Tile 策略要匹配硬件资源,不是越大越好

我刚开始做 Tensor Core 优化时,总想着“怎么让代码跑得更快”。后来发现,先理解硬件怎么工作,优化自然水到渠成。

下一节我们会深入具体的调优技巧,包括 Bank Conflict 的避免、寄存器压力的控制、以及如何用 profiler 找到瓶颈。今天的内容先到这里,有问题随时交流。

课后思考:如果你有一个 1024x1024 的矩阵乘法,你会怎么选择 Tile 尺寸?为什么?

可可精品资料库