分块矩阵与 GEMM 工程直觉

从 Tile、SM 与 Warp 到数据复用

Posted by Liu Mengxuan on August 11, 2026

Transformer 中的 QKV 投影、Attention 输出投影和 FFN 都包含大量矩阵乘法。数学上,它们可以统一写成 GEMM;工程上,性能却主要取决于矩阵怎样分块、数据怎样搬运,以及一个 SM 能同时容纳多少 block 和 warp。

本文从矩阵乘法公式出发,依次解释 tile、HBM、共享内存、寄存器、SM、block、thread、warp、算术强度、occupancy、尾块和浮点误差之间的关系。

1. GEMM 是什么?

GEMM 是 General Matrix Multiplication,即通用矩阵乘法。简化形式是:

$C=AB$

更一般的形式是:

$C\leftarrow\alpha AB+\beta C$

假设:

$A\in\mathbb{R}^{M\times K},\qquad B\in\mathbb{R}^{K\times N}$

那么:

$C\in\mathbb{R}^{M\times N}$

shape 规则是:

1
(M, K) @ (K, N) -> (M, N)

矩阵 CMN 个输出元素,每个元素都是 A 的一行与 B 的一列做点积:

$C_{i,j}=\sum_{k=1}^{K}A_{i,k}B_{k,j}$

每个输出元素大约需要 K 次乘法和 K 次加法,所以 GEMM 的计算量约为:

$\operatorname{FLOPs}\approx2MKN$

分块不会减少这个数量。它优化的是数据搬运和硬件利用率。

2. 为什么矩阵乘法可以分块?

AB 按相容的行列切成块:

$A=\begin{bmatrix}A_{11}&A_{12}\\A_{21}&A_{22}\end{bmatrix},\qquad B=\begin{bmatrix}B_{11}&B_{12}\\B_{21}&B_{22}\end{bmatrix}$

输出左上角的块是:

$C_{11}=A_{11}B_{11}+A_{12}B_{21}$

输出右上角的块是:

$C_{12}=A_{11}B_{12}+A_{12}B_{22}$

也就是说,每个输出块都可以通过若干局部矩阵乘法,沿归约维 K 不断累加得到。

假设一个 block 负责输出 tile:

$C_{tile}\in\mathbb{R}^{B_M\times B_N}$

每一轮 K 循环读取:

$A_{tile}\in\mathbb{R}^{B_M\times B_K},\qquad B_{tile}\in\mathbb{R}^{B_K\times B_N}$

并执行:

$C_{tile}\mathrel{+}=A_{tile}B_{tile}$

伪代码如下:

1
2
3
4
5
6
7
8
C_tile = 0

for k in range(0, K, BK):
    A_tile = A[m:m+BM, k:k+BK]
    B_tile = B[k:k+BK, n:n+BN]
    C_tile += A_tile @ B_tile

C[m:m+BM, n:n+BN] = C_tile

GEMM 分块与沿 K 方向累加的数据流

分块改变了计算顺序,却没有改变目标公式。

3. 分块为什么会更快?

GPU 的不同存储层级在容量和访问代价上差异很大。可以先用一个简化模型理解:

1
2
3
4
5
6
7
HBM / Global Memory:容量大,访问代价高
        ↓
L2 Cache
        ↓
Shared Memory / L1:每个 SM 上的片上存储
        ↓
Registers:每个线程直接使用的片上状态

朴素 GEMM 如果每次乘加都重新从 HBM 读取 A[i,k]B[k,j],同一元素会被反复搬运。

实际上:

  • A[i,k] 可以被同一输出行的多个列复用;
  • B[k,j] 可以被同一输出列的多个行复用;
  • C[i,j] 的中间累加值可以长时间保留在寄存器中。

分块 Kernel 通常会:

  1. 从 HBM 搬入一块 A 和一块 B
  2. 将它们放进共享内存;
  3. 让同一 block 的多个线程重复读取这些数据;
  4. 在寄存器或 Tensor Core 累加器中保存局部输出;
  5. 完成整个 K 循环后,将输出 tile 写回 HBM。

因此,高性能 GEMM 的核心通常不是“少做乘法”,而是:

让一次昂贵的数据搬运支撑尽可能多的计算。

4. 什么是 SM?

SM 是 Streaming Multiprocessor,通常翻译为流式多处理器,是 NVIDIA GPU 中执行线程块、调度 warp 和管理片上资源的主要计算单元。其他厂商有相似概念,例如 AMD GPU 中的 Compute Unit,但具体组织方式并不完全相同。

一块 GPU 包含多个 SM。每个 SM 内部通常包括:

  • warp scheduler;
  • CUDA Core 等标量/向量执行单元;
  • Tensor Core;
  • register file;
  • shared memory / L1;
  • load/store 等执行单元。

可以把 SM 理解成一个能够独立接收 block,并在多个 warp 之间切换执行的“小型并行处理器”。

不同 GPU 架构中,SM 的寄存器数量、共享内存容量、warp 槽位和执行单元数量都可能不同。因此本文后面的容量数字只用于说明资源约束,不代表某一款具体 GPU。

5. Grid、Block 和 Thread 是什么关系?

CUDA Kernel 启动时,需要指定 grid 中有多少个 block,以及每个 block 有多少个线程。

例如:

1
kernel<<<100, 256>>>(...);

表示:

1
2
3
Grid 中有 100 个 block
每个 block 有 256 个逻辑线程
总逻辑线程数为 100 × 256 = 25600

线程数量是 Kernel 启动配置分配给每个 block 的。在一次普通 Kernel 启动中,所有 block 的 blockDim 相同。

一个 block 通常会被完整调度到某一个 SM 上执行,不会把同一个 block 的线程拆到多个 SM。这样,同一个 block 内的线程才能:

  • 访问同一块 shared memory;
  • 通过 __syncthreads() 同步;
  • 合作计算同一个输出 tile。

不同 block 的 shared memory 相互隔离。一个 block 开始驻留后,其共享内存和线程寄存器通常会一直保留到该 block 执行结束。

需要注意:

256 个 CUDA 线程是 256 个逻辑执行上下文,不表示这个 block 永久独占 256 个物理计算核心。

6. 什么是 Warp?

在 NVIDIA GPU 中,一个 warp 包含 32 个线程。SM 的调度器通常以 warp 为基本线程组发射指令。

一个 256 线程的 block 会被划分为:

$256/32=8\text{ 个 warp}$

1
2
3
4
5
Warp 0:thread   0~31
Warp 1:thread  32~63
Warp 2:thread  64~95
...
Warp 7:thread 224~255

同一个 warp 中的线程执行同一条指令,但处理各自的数据。遇到条件分支时,如果一部分线程走 if,另一部分线程走 else,硬件可能需要分别执行两条路径,这就是 warp divergence。

Grid、Block、SM、Warp 和 Thread 的关系

warp scheduler 会从已经驻留且准备好的 warp 中选择下一条指令执行。如果一个 warp 正在等待内存或依赖,SM 可以切换到其他 ready warp。

这不是像 CPU 线程那样保存、恢复大量上下文,因为多个 resident warp 的寄存器状态已经同时保存在 SM 上。

7. 什么是 Resident Block、Resident Warp 和 Occupancy?

GPU 程序可以启动成千上万个 block,但同一时刻只有一部分 block 真正驻留在 SM 上。已经进入 SM、资源已经分配并且尚未执行结束的 block,称为 resident block。

这些 block 包含的 warp 就是 resident warp。

Occupancy 通常表示:

$\text{Occupancy}=\dfrac{\text{SM 上的 active/resident warps}}{\text{该 SM 支持的最大 active warps}}$

较多的 resident warp 有助于隐藏延迟。例如:

1
2
3
4
5
6
7
Warp 0 等待内存
    ↓
调度 Warp 1
    ↓
Warp 1 等待依赖
    ↓
调度 Warp 2

如果 resident warp 太少,就可能出现所有 warp 都在等待、执行单元暂时空闲的情况。

Occupancy 不是越高性能就一定越好,但过低通常会削弱延迟隐藏能力。

8. 为什么 tile 太大会减少驻留 block 和 warp?

每个 SM 的共享内存和寄存器总量固定,而 resident block 的资源需要同时放在 SM 上。

8.1 共享内存按 block 分配

假设一个 SM 有 64 KB 共享内存。

如果每个 block 使用 16 KB:

$\left\lfloor64/16\right\rfloor=4\text{ blocks}$

如果每个 block 使用 32 KB:

$\left\lfloor64/32\right\rfloor=2\text{ blocks}$

如果每个 block 使用 48 KB:

$\left\lfloor64/48\right\rfloor=1\text{ block}$

剩余 16 KB 因为放不下第二个完整 block,不能用来驻留半个 block。

8.2 寄存器按线程分配

假设一个 block 有 256 个线程,每个线程使用 32 个寄存器,那么一个 block 需要:

$256\times32=8192\text{ registers}$

如果 tile 变大,每个线程需要保存更多输出累加器,寄存器使用量增加到 128 个,那么一个 block 需要:

$256\times128=32768\text{ registers}$

在寄存器总量固定的情况下,同一个 SM 能放下的 block 数就会减少。

一个 SM 的 resident block 上限大致同时受到以下条件约束:

$N_{blocks}\le\min(N_{smem},N_{regs},\text{其他硬件上限})$

其中共享内存给出的上限是:

$N_{smem}=\left\lfloor\dfrac{S_{SM}}{S_{block}}\right\rfloor$

寄存器给出的上限是:

$N_{regs}=\left\lfloor\dfrac{R_{SM}}{R_{thread}T_{block}}\right\rfloor$

其他硬件上限还包括每个 SM 的最大线程数、warp 数和 block 数。

哪种资源最先耗尽,哪种资源就是当前 Kernel 的驻留限制。

由于一个 256 线程 block 包含 8 个 warp:

1
2
3
驻留 4 blocks -> 32 resident warps
驻留 2 blocks -> 16 resident warps
驻留 1 block  ->  8 resident warps

所以完整的因果关系是:

1
2
3
4
5
6
7
8
9
10
11
tile 增大
    ↓
A/B tile 和 C 累加器变大
    ↓
每 block 的 shared memory、每线程的 registers 增加
    ↓
每个 SM 能完整容纳的 block 减少
    ↓
resident warp 减少
    ↓
隐藏内存和指令延迟的能力可能下降

如果每个线程需要的寄存器超过硬件和编译器能够有效提供的范围,还可能发生 register spilling,把部分线程局部状态放到 local memory。这里的 local memory 位于设备内存体系,访问代价远高于寄存器。

9. 算术强度:每搬一个字节能做多少计算?

算术强度定义为:

$\text{Arithmetic Intensity}=\dfrac{\text{FLOPs}}{\text{Bytes transferred from target memory}}$

理想化地假设 FP16 GEMM 的 AB 只从 HBM 读取一次,C 只写一次,数据量约为:

$2(MK+KN+MN)\text{ bytes}$

于是:

$AI\approx\dfrac{2MKN}{2(MK+KN+MN)}$

  • 算术强度较低:更可能受内存带宽限制;
  • 算术强度较高:数据复用充分,更可能受计算吞吐限制。

实际情况还会受到 cache、对齐、读改写、融合操作和数据类型的影响,所以这只是帮助形成直觉的粗略估算。

10. Tile 为什么不是越大越好?

更大的 tile 通常意味着:

  • 同一块 AB 数据被更多输出元素复用;
  • HBM 访问相对减少;
  • 算术强度可能提高。

但同时也意味着:

  • shared memory 占用增加;
  • register accumulator 增加;
  • block 同步和边界处理更复杂;
  • resident block / warp 可能减少;
  • 某些 shape 下会产生更多无效计算。

因此:

1
2
tile 太小:数据复用不足,频繁访问 HBM
tile 太大:片上资源压力过高,驻留并行度下降

最优 tile 是数据复用、occupancy、指令效率、硬件结构和矩阵 shape 之间的折中。

11. 尾块与 Padding

如果 MNK 不是 tile size 的整数倍,最后一块就可能不完整。

例如:

1
2
3
4
矩阵长度 = 35
tile size = 16

35 = 16 + 16 + 3

最后 3 个元素构成尾块。常见处理方式包括:

  • 边界判断;
  • mask;
  • padding 到对齐尺寸;
  • 规则部分走快路径,尾块走通用路径。

Padding 会增加无效存储和计算,边界判断则会引入控制开销。哪种方式更快,需要结合实际 shape 和硬件测试。

12. 为什么不同分块结果可能略有不同?

实数加法满足结合律,但浮点加法不严格满足:

$(a+b)+c\ne a+(b+c)$

不同 tile、线程归约树和 Tensor Core 路径会改变累加顺序,因此优化前后的结果可能无法逐位一致。

正确性测试应该根据数据类型和归约长度使用合适的绝对、相对容差:

$|\hat{x}-x|\le\text{atol}+\text{rtol}\cdot|x|$

除了观察最大误差,还应关注误差是否随 K 的长度系统性放大。

13. 把完整工程逻辑串起来

分块 GEMM 的执行过程可以总结为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
大矩阵切成输出 tile
        ↓
Grid 中的 block 分别负责不同 tile
        ↓
block 被调度到 SM
        ↓
block 内线程组成多个 warp
        ↓
A/B tile 从 HBM 搬到 shared memory
        ↓
线程/warp 复用数据并执行乘加
        ↓
C tile 在 registers 中沿 K 累加
        ↓
完成后将输出写回 HBM

性能优化需要同时考虑两类目标:

1
2
数据复用:减少 HBM 搬运
并行调度:保留足够 resident warp 隐藏延迟

二者通过 tile size、每 block 线程数、shared memory 用量和每线程 register 用量联系起来。这也是后续理解 CUDA GEMM Kernel、Tensor Core、Triton、FlashAttention 和算子自动调优的基础。