准备面试时我被问住过一个问题:如果片上存储放得下所有数据,还需要 tiling 吗?想清楚之后才发现,"装得下"只是 tiling 的理由之一。这一篇整理 tiling 的六个理由、寄存器 tiling 的复用原理,以及矩阵计算单元对输入的要求和它与"加法"的关系。
第一部分:为什么要 tiling
一、六个理由
1. 容量:装得下。 最直观的理由。片上存储放得下整个问题时,这一条就不成立了。
2. 带宽:每一级存储的带宽都有限,要靠复用来弥补。 最核心的理由,见第二节。
3. 延迟:需要足够多的独立计算。 只有一个累加器时,每条 FMA 都要等上一条的结果;多个独立累加器才能让 FMA 流水起来。
4. 并行:把工作分给多个计算单元。 即使数据都放得下,GPU 有多个 SM、CPU 有多个核、NPU 可能有多个矩阵单元,总要把问题切开分下去。
5. 重叠:让搬运和计算同时进行。 整个矩阵一次搬完再算,搬运期间计算单元全在等。切成块后,第一块搬完就能开始算,其余边算边搬。
6. 硬件形状要求。 矩阵计算单元每条指令处理的块尺寸固定,必须切成它要求的形状(见第三部分)。
一个踩过的坑:理由要和场景对上
我在模拟面试里解释 CPU 上 matmul 的 tiling 收益时,说了"tiling 让开始计算的时间提前了"。这其实是第 5 条,在有 DMA 搬运的场景下是对的;但我的场景是 CPU 上 512³ 的标量版本,数据早就在缓存里,没有搬运需要重叠,真正的瓶颈是第 3 条------依赖链。
同一个理由放在不同场景下,对错不同。先判断瓶颈在哪,再选解释。
二、为什么片上存储装得下,还要寄存器 tiling
计算单元不能直接从片上存储(或 CPU 的 L1 缓存)高速取数。关键是:每从存储读一个数,能做多少次计算。
以 AVX2 上的 matmul 外积微内核为例,每走一步 k,读入 A 的几个数(广播)和 B 的一段(向量读取),然后做一批 FMA:
| 寄存器块 | 每步 k 的读取 | 每步 k 的 FMA | 每次读取换来的 FMA |
|---|---|---|---|
| 1×8(一行、一个向量) | 1 次广播 + 1 次向量读 = 2 | 1 条 | 0.5 |
| 4×16 | 4 次广播 + 2 次向量读 = 6 | 4 行 × 2 个向量 = 8 条 | 约 1.3 |
规律:寄存器块为 m×n 时,每步读 m + n 份数据,做 m × n 份计算。块越大,每份数据的复用次数越多。
1×8 的块每读一次只能做半条 FMA,读取先成为瓶颈,FMA 单元在等数据;4×16 的块每次读取能喂好几条 FMA,瓶颈才转到计算上。
所以寄存器 tiling 同时解决两件事:提高复用,让读取跟得上;提供独立的累加器,让 FMA 不互相等待。
上限是寄存器数量。AVX2 有 16 个 ymm:4×16 的累加器占 8 个,B 的一段占 2 个,A 的广播值占 1 到 4 个,刚好放得下。再大就会 spill(把值临时存到栈上),前面的收益全被抵消。
GPU 上是同一回事
即使数据都在 shared memory 里,Tensor Core 消耗数据的速度也远快于 shared memory 的供给速度。所以高性能 HGEMM 里每个 warp 都要用 ldmatrix 把数据读进寄存器,组成 fragment,反复喂给 mma.sync------这就是 GPU 上的寄存器 tiling。
shared memory 的块也不能随便放大:每个线程块占用越多,一个 SM 上能同时驻留的线程块就越少,隐藏延迟的能力就越差。
三、分层的视角
DRAM / DDR ── 块大小由"装得下片上存储"和"搬运与计算重叠"决定
↓
片上存储 / L1 ── 块大小由"复用以弥补带宽"和"并行分配"决定
↓
寄存器 ── 块大小由"复用以喂饱计算单元"和"独立累加器隐藏延迟"决定,上限是寄存器数量
↓
计算单元
每一级存储一个块,每一级的块解决的都是"上一级供数据太慢"的问题。 片上存储装得下,只是让第一级可以不切,后面几级照样要切。
第二部分:矩阵计算单元
四、输入是固定的吗
基本是的。每条矩阵指令处理的块大小固定,只是不同芯片的灵活程度不同。
- GPU Tensor Core :提供一份可选形状的"菜单",形状与数据类型绑定。例如 FP16 的
mma.sync常用 m16n8k16;换成 FP8、INT8,k 方向会变长。Hopper 的 wgmma 中 N 可以按 8 的倍数在一定范围内选择,但仍是有限几种。 - CPU SIMD:向量宽度固定,AVX2 为 8 个 f32,AVX-512 为 16 个。Intel AMX 可以配置块的行数和每行字节数,但有上限。
- NPU:通常是固定尺寸的脉动阵列(如 16×16、32×32),许多还要求数据按特定的分块格式存放。
三种"固定"
| 要求 | 含义 | 例子 |
|---|---|---|
| 形状 | 每条指令处理的块大小 | m16n8k16,16×16 阵列 |
| 数据类型 | 支持哪些精度,形状常随之变化 | FP16、BF16、FP8、INT8 |
| 布局 | 数据在寄存器或片上存储中的排法 | mma.sync 规定每个线程持有哪些元素;NPU 的分块格式 |
布局最容易被忽视。mma.sync 规定了 warp 中每个线程必须在寄存器里拿着哪几个元素,ldmatrix 的存在意义就是一条指令把数据按这个排法分好。
编译器怎么应对
- 切成指令形状的整数倍 :一个 warp 负责的 64×64 块,是用许多条 16×8 的
mma拼出来的。 - 边界补零:不是整数倍时补零凑满,代价是浪费部分算力。
- 布局转换 :权重离线转好(如 Marlin 的权重重排);激活在搬运时顺带转换,或用
ldmatrix这类指令完成。 - 形状太小时利用率很低 :decode 时 M=1,而
mma.sync的 M 方向是 16,每条指令有 15/16 的行在算零。常见做法:- 不用 Tensor Core,改用 CUDA core 做矩阵向量乘;
- 对调操作数,计算 Cᵀ = Bᵀ·Aᵀ,让很大的 N 维落到 16 那一边。
利用率估算:有效计算量 ÷ 补齐到指令形状后的计算量。M=1 用 m16 指令,最多 1/16。
五、矩阵单元能做加法吗
要区分两种加法。
累加:矩阵单元自己做
矩阵单元做的是乘加:
D = A × B + C
mma.sync 就是这个形式,C 是累加器。分块矩阵乘沿 K 方向切块后,每块结果都加到同一个 C 块上,这个累加由指令本身完成。
累加器精度通常高于输入:FP16 输入用 FP32 累加,INT8 输入用 INT32 累加,因为 K 很长时要加成百上千次,低精度累加误差会积累。
逐元素加法:交给别的部件
加偏置、残差连接、激活函数、缩放、归一化等,交给 NPU 的向量单元、GPU 的 CUDA core 或 CPU 的 SIMD 指令。
两者的配合:收尾阶段融合
矩阵单元在 K 方向累加完后,结果还在寄存器或片上存储里,这时就地加偏置、做激活,再写回内存,称为收尾阶段融合(epilogue fusion)。有些 NPU 甚至在矩阵单元的输出通路上直接带有简单的后处理能力。
一个小技巧 :把累加器 C 的初值设成偏置而不是清零,D = A×B + C 算完就自带了偏置,矩阵单元"免费"完成。ReLU 这类非线性运算则不行。
面试一句话
- 为什么要 tiling:不只是装得下。每一级存储带宽有限,块让数据在这一级被复用足够多次;同时提供并行度、让搬运与计算重叠、提供独立累加器隐藏延迟。寄存器块为 m×n 时每读 m+n 份数据做 m×n 份计算,上限是寄存器数量。
- 矩阵单元的输入:每条指令处理固定形状的块,形状与数据类型绑定,对布局有要求;编译器负责切块、补零、布局转换。形状远小于指令尺寸时利用率很低。
- 矩阵单元与加法:K 方向的累加是乘加指令的一部分,累加器精度高于输入;逐元素运算交给向量单元,最好在收尾阶段融合。