☰
Tiled GEMM性能模型实战:从算力边界到tile size选型
2026/10/26 16:33:13 网站建设 项目流程

算力再大,喂不饱也是白搭。做 AI Infra 这两年,我越来越觉得,搞懂 Tiled GEMM 的人很多,但真正会“算账”的人很少。网上教程一抓一大把,教你怎么配参数、怎么用 shared memory 的,但很少有人讲清楚:你凭什么选这个 tile size?这个数字背后的性能模型长什么样?

这篇笔记我打算换个角度,沿着标题里的思路走:先把性能模型这个工具立起来,再用它回头指导 Tiled GEMM 的设计决策。不堆公式,不抄 CUDA 手册,全部是我实际调 kernel 时踩出来的经验。你如果是刚接触 CUDA 优化的小白,这篇能帮你建立从“会写”到“会调”之间的那座桥;如果你是老手,这篇里的几个模型修正项,大概率也能对得上你踩过的坑。

先说结论放着:性能模型的意义,不是帮你精确预测跑多快,而是帮你在写代码之前,就把烂方案毙掉。它能把你从“玄学调参”里解放出来,让每个设计决策都有据可依。

1. 从头捋一遍:为什么 Tiled GEMM 需要性能模型

1.1 教科书里的 GEMM 优化,少了最关键的一环

大部分人入门 Tiled GEMM,看到的套路基本是:把大矩阵切成 block,每个 block 塞进 shared memory,再用 register 做累加,减少全局内存访问。

这套路对不对?对。但问题是,它只告诉你怎么做,没告诉你为什么这样做能做到最优。你要是照着这个套路写个 naive 版本,跑一次 profiling,发现性能只有理论峰值的 15%,你就懵了——每一步都照着做了,怎么还这么慢?

我最初调 kernel 的时候就是这个状态。填代码之前,我对要踩多少内存带宽、要发多少条指令、SM 里能塞多少 block,脑子里完全没数。结果就是一轮一轮地试参数,今天改 BM=64 快一点,明天改 BN=128 又慢回去,完全靠玄学。

后来我意识到,问题的根源不是参数没调好,而是缺了一个“计算器”——一个能提前告诉你某个设计大概能跑多少的性能模型。

1.2 性能模型到底在算什么:RoofLine 视角

先建立一个最简单的分析框架。任何一个 kernel 跑在 GPU 上,它最终的性能一定卡在两端:算力边界(Compute Bound)或者带宽边界(Memory Bound)。

  • 如果我们让 SM 一直满负荷算乘法,那瓶颈就是 FLOPS。
  • 如果 SM 一直在等数据从显存搬过来,那瓶颈就是带宽。

所以性能模型的第一步,就是判断你的 kernel 到底属于哪一种。这又回到了经典的算术强度(Arithmetic Intensity)概念,也就是:每个字节的数据从显存搬到 SM 上,能换来多少次浮点运算。

Tiled GEMM 的妙处就在于,tile 切得越大,一次搬进来的数据被复用的次数就越多。一个不太严谨但是很好用的理解方式:把显存想象成仓库,把 SM 上的 shared memory 想象成工位旁边的临时储物柜。你不切 tile,就得没完没了跑仓库搬料;切了 tile 之后,大件材料先一次性备齐放在储物柜里,然后工位上反复使用。

算术强度一高,kernel 就偏向算力边界,这时堆计算资源才有意义。

1.3 用模型做设计决策,而不是做“事后解释”

这里有个容易混淆的点。很多人把性能模型当成 profiling 工具用,跑完了看数据,然后套个模型解释“哦我懂了,这个是 compute bound”。我说实话,这叫马后炮,一点用没有。

真正有用的性能模型,是在写代码之前、或者调参改代码的过程中用的。它的价值是预测,不是解释。

举个例子。你手上有两版设计:A 方案用 BM=128, BN=128,B 方案用 BM=64, BN=256。理论上你可以两个都写出来跑一遍对比,但在真实工程里,每改一版配置要连带调整 shared memory 布局、bank conflict 处理、寄存器分配策略,工作量不是按小时算的,是按天算的。

性能模型在这里的价值,就是让你先花十分钟把两种方案的带宽需求量、寄存器压力、SM 占用率算清楚,然后用这些数字做判断——哪个方案有潜力,哪个方案一开始就注定低效。

我做 kernel 优化的习惯是:先算后写,写完再测,测完对照模型修正。这套流程走顺之后,你会发现很多调优过程中的死胡同,根本不需要亲自走进去再爬出来。

2. Tiled GEMM 的核心参数与性能模型公式拆解

2.1 一个经典的 Tiled GEMM 长什么样

这里我快速交代一下标准形态。假设我们要算 C[M,N] = A[M,K] × B[K,N],GPU 的线程块布局是一个 2D 网格,

  • 每个 block 负责计算一块大小为 BM × BN 的输出子矩阵,
  • 计算过程中,这个 block 需要从全局内存加载 A 的一个 BM × BK 分块,以及 B 的一个 BK × BN 分块,
  • 这两个分块进入 shared memory 后,block 内的所有线程协作完成累加。

整个 kernel 的核心循环,就是依次遍历 K 方向上的每一个分块,不断重复“加载 → 计算”这个动作。这个结构里的关键参数就三个:BM、BN、BK。

这三个参数的大小,直接决定了三件事:

  1. 每个 block 需要多少 shared memory;
  2. 每个线程的寄存器占用;
  3. 数据在各级存储之间的复用率和搬运总量。

2.2 模型的第一步:算清“每 block 发多少回全局内存请求”

我现在做设计,第一个算的就是“全局内存流量”。这个数字能告诉你,这个 kernel 的负载到底是被什么卡死的。

对于一个负责 BM × BN 输出的 block,它完整遍历一遍 K 维度,需要加载的 A 分块总量是 BM × K,B 分块总量是 BN × K。但等等,如果继续往下推导,把“算数强度”和“总流量”算出来,你会发现一个很有意思的结论:

block 内部,A 矩阵和 B 矩阵在共享内存中可以和后续的 BK 迭代直接复用,而真正从全局内存搬进来的,是每个 K 步的 BM×BK 和 BK×BN 小块。于是全局内存上的搬移总量是:

每个 block:A 加载 BM×K + B 加载 BN×K 个元素(这里按 K 遍历完整算)

如果我们把它除以这个 block 做的计算量 BM×BN×K(乘加各算一次,也就是 2×BM×BN×K FLOPs),就得到了算术强度。你会发现 BM 和 BN 越大、K 维度复用越好,算术强度就越高。

一个实用的做法是:先假定 L2 cache 命中率某个值,估算出“有效带宽”,然后用下面这个核心公式判断 kernel 处于哪个边界:

预估耗时 ≈ max( 总计算量 / 峰值算力, 总显存搬运量 / 有效带宽 )

哪边数值大,哪边就是主瓶颈。我实测下来,大部分初学者编写的 Tiled GEMM,瓶颈都是后者——数据搬运量太大,SM 的算力全在空转等数据。

2.3 参数 BM、BN、BK 各自的“压迫力”不同

这三个参数不是等价的,它们在模型里贡献的压力方向完全不同。理解这一点,你就能很容易地解释很多调参现象。

  • BM 和 BN 决定数据复用。它们是提升算术强度的主力,越大,从全局内存看数据复用越友好。
  • BK 决定流水线深度和 shared memory 用量。BK 加大,shared memory 用量线性增长,同时每个 K 步能计算的中间结果变多,流水线填充效率更高。
  • BM × BN 决定寄存器压力。因为每个线程最终要持有的累加器数量,和输出 tile 的分配策略有关,而寄存器是有限的,这一点我在下一节会说。

所以在真实设计里,这三个值是被“算力、带宽、存储容量、寄存器数量”四堵墙同时框住的。性能模型的核心任务,就是在这些约束下找一套合理的取值组合。

3. 算给新手看:一个 M=N=K=8192 的算例全推演

到这里,光讲公式显得干巴巴的,我直接拉一组真实算例,手把手算一遍。就以最近在调的 M=N=K=8192 的规模为例,float16 输入,float32 累加。

3.1 理论峰值的“天花板”先立起来

参考一张常见消费级 GPU(比如 4090)的规格,FP16 算力大约 330 TFLOPS 左右,显存带宽大约 1 TB/s。咱就先拿这个算。

一个 8192³ 的 GEMM,总计算量 = 8192³ × 2 ≈ 1.1 TFLOPs。如果完全跑在算力边界上,理论耗时大概是:

1.1e12 / 330e12 ≈ 3.3 ms

如果完全跑在带宽边界上,情况就复杂了。假设一个 block 的 BM=128, BN=128,grid 规模就是 (8192/128)² = 64×64 = 4096 个 block。把所有 block 的全量搬运加起来,大概一共要读 8192×8192×2 个元素 × 2 字节 ≈ 268 MB。除以带宽 1 TB/s,耗时 ≈ 0.27 ms。

看到没?理论带宽边界远小于理论算力边界。这说明,如果数据复用做得好,这个规模的 GEMM 是有很大机会逼近算力上限的。反过来说,如果你的 kernel 实测跑了 10 ms,那说明数据搬运环节必然有问题——要么 tile 切得太小,要么 L2 命中率太低。

3.2 不同 tile 尺寸的“算力利用率”对照

为了让你直观感受不同设计方案的差距,我按模型粗算了一遍(假设 L2 命中率理想,shared memory 访问无冲突),结果如表:

方案BMBNBK每 block shared memory理论最大 block 数/SM预估耗时(ms)相对最优损
A646416约 4 KB较高5.8落后较多
B12812816约 8 KB中等3.9可接受
C12812832约 16 KB较低3.4接近最优
D25625616约 32 KB更低3.3理论最优候选

这里一个很重要的现象是:从 A 到 C,性能提升肉眼可见;从 C 到 D,性能基本持平甚至可能倒退。原因很简单,D 方案里 BM=256 意味着每个 block 的共享内存占用量很大,SM 上同时驻留的 block 数量下降,延迟隐藏能力变差,最终可能抵消了数据复用带来的好处。

这就是性能模型的价值——你还没写代码,就能预判 C 和 D 差别不大,不值得为 D 去调一套新的 bank conflict 方案。

3.3 一个反直觉的经验:BK 不是越大越好

很多人一看 BK 参与数据复用,就觉得 BK 是灵丹妙药,越大越好。实际跑一圈你会发现,BK 翻倍,shared memory 里 A 分块和 B 分块都翻倍,bank conflict 的概率也直线上升。

我踩过一个具体的坑:BK 从 16 改成 32 之后,shared memory 使用量翻倍,但因为 A 分块的存储 stride 变了,同一行数据落到同一个 bank 的情况明显增多,load 指令直接多了 30% 的冲突延迟。跑出来的时间不仅没缩短,还长了 15%。

后来我总结出一条原则:BK 的大小应该和 shared memory 的可容纳量以及寄存器流水线深度协同设计,而不是单独最大化。你在模型里一定要把 bank conflict 的影响作为一个修正项,否则算出来的预估时间会乐观到失真。

4. 真正的战场:寄存器压力与 occupancy 怎么算

4.1 累加器数量:寄存器压力的来源

Tiled GEMM 里,每个线程不是算一个输出元素,而是算一整个微 tile(通常会分配 TM × TN 个输出)。这些微 tile 的累加器要一直放在寄存器里,不能频繁搬去 local memory,否则性能直接崩溃。

先算一笔账。假设你的 block 是 BM=128, BN=128, 线程数是 256,每个线程负责的微 tile 大小是:

(128/16) × (128/16) = 8×8 = 64 个 float 累加器

也就是每个线程至少 64 个寄存器用来存累加器。再算上 A 分块和 B 分块的寄存器缓存、地址计算、循环变量,一个线程的寄存器用量很快就奔着 128 以上去了。

CUDA 编译器给每个线程的寄存器上限通常是 255 个。如果超了,寄存器就会 spill 到 local memory,性能暴跌。所以模型里必须加上这条约束:

线程可用寄存器数 ≥ TM × TN(累加器) + 临时变量开销

这个约束直接限定了你在一个大 tile 下的线程数量配置。你不能无限扩大 BM 和 BN,因为输出大了,线程数不够就只能让每个线程多做更多输出元素,寄存器压力随之爆炸。

4.2 Occupancy 和“墙”之间的权衡

Occupancy 指的是 SM 上驻留的 warp 数量占总容量的比例。很多人有个误解,觉得 occupancy 越高越好。在 Tiled GEMM 里,这个判断要打折扣。

因为 GEMM 是典型的高算术强度负载,每个线程手上都有大量寄存器缓存的数据。太高的 occupancy 反而会让每个线程分到的寄存器变少,导致累加器 spill。你为了把 occupancy 从 50% 拉到 75%,付出的代价是每个线程的微 tile 变小、数据复用率下降,得不偿失。

性能模型在 occupancy 这里的用法是设一个下限,不是设上限:

occupancy ≥ 某个阈值(比如 25%~30%)就够用了,这个阈值取决于你能否用足够多的异步拷贝和计算重叠来隐藏延迟。

换句话说,与其追求 block 塞满 SM,不如把每个 block 做大、让每个线程手里多握点数据,靠高复用率来赢。

4.3 把计算过程“重叠”起来,效果比硬拉 occupancy 更明显

如果只调 tile 尺寸到头了,性能还是上不去,我的下一步是把加载和计算重叠。Tiled GEMM 里有一个非常成熟的武器,就是 CUDA 的异步拷贝指令,把 shared memory 的加载和上一次循环里的计算并行起来。

这里有个关键点:异步加载需要双缓冲(或者多缓冲)。也就是 shared memory 里要准备两份 A 分块和 B 分块的副本,一份正在被当前计算单元消费,另一份正在被异步接口从全局内存搬运。

双缓冲会让 shared memory 用量翻倍,这对 tile 尺寸的选择是一个直接约束。我在设计时经常要用模型重算一遍:如果加了双缓冲之后,BM=128, BN=128, BK=16 的 shared memory 用量从 8 KB 涨到 16 KB,是否还能保证至少 4 个 block 驻留在 SM 上?如果不行,那说明当前这个配置必须缩水,或者放弃双缓冲改用单缓冲靠 occupancy 硬扛。

5. 实测对比:模型预测和真机数据的“误差修正”

5.1 一个典型的实测过程

我拿上面表格里的 C 方案(BM=128, BN=128, BK=32)实际写了一个 kernel,去掉所有多余的同步和边界判断(在 M、N、K 都能被 tile 整除时)。跑下来实测大约 4.2 ms,比模型预估的 3.4 ms 少了约 20% 的效率。

这个差距不奇怪。模型的理想假设在于:

  • 忽略了 bank conflict;
  • 假设异步加载不占用额外指令周期;
  • 假设所有全局内存访问都能命中 L2。

实际这些都不可能完美。所以我在模型里会引入一个修正系数,或者叫“效率系数”,一般取 0.7~0.85。用这个修正后的模型再看不同方案,排序依然有效,但绝对数值会更有参考价值。

5.2 模型最大的坑:低估了 L2 cache 影响

做性能模型时最容易失真的部分,是全局内存流量的估算,因为 L2 到底能命中多少,和数据访问模式高度相关。

按我实测的经验,当 GEMM 分块足够大时,B 矩阵的分块在不同 block 之间是有一定复用机会的——特别是沿 N 方向相邻的 block,它们加载的 B 分块完全相同。如果你的 block 调度顺序正好让这些相邻 block 邻近执行,L2 命中率会显著上升。

模型里怎么处理?我会给全局内存流量乘一个“实际搬运效率”,比如默认按 80% 打了折,然后把这个打折后的数字代入模型。这不是严谨的推导,但我实测下来,这比完全按理论流量算靠谱得多。

5.3 最终给出的可参考配置公式

你要让我一句话总结 Tiled GEMM 的配置选择逻辑,我会说:

选一个能让全局内存流量最小化、又不会让寄存器 spill、且 shared memory 双缓冲后还能保证 SM 上至少驻留 2~4 个 block 的组合。

然后在这个组合内,优先选大的 BM 和 BN,再根据 L2 命中率毛估和实测微调 BK。

我自己常用的一组基线是:

  • 中小矩阵(如 4096 以下):BM=64, BN=64, BK=16;
  • 大矩阵(如 8192 以上):BM=128, BN=128, BK=32;
  • 追求极致吞吐时:考虑 BM=256, BN=128,并搭配更精细的寄存器级分配。

这组基线不一定适合你的 GPU,但它作为起点,会让你的第一版 kernel 不至于烂到没法看。

6. 几个我踩过的坑,提前给你排掉

6.1 不要盲目套用“最大 tile”方案

网上流传的很多极致优化教程,用的都是实验室里挑好的参数组合。但换一张卡、换一个矩阵形状,可能直接性能崩盘,原因多半出在寄存器溢出或者 shared memory 容量不足。

我的建议是:性能模型跑一遍,把约束条件列清楚,再决定是不是要追求极端 tile。卡与卡之间,寄存器和共享内存的容量差得很多,甚至同一代不同 SKU 都有差别。

6.2 写完先看编译器的寄存器分配报告

我强烈建议你在编译时加上--ptxas-options=-v,看最后的寄存器使用数和 shared memory 使用数。这一步等于直接验证你模型里的寄存器假设是否成立。

如果你模型里假设每线程 128 个寄存器,但编译器报告用了 168 个,那说明寄存器压力已经超过预期,必须缩小微 tile 尺寸或者增加线程数。坚持跑下去,只会看到 local memory 的 load/store 在 profiling 里刷屏。

6.3 边界条件会吃掉的性能远超你想象

有时候你以为模型算得没问题,但实测还是慢,最后发现是 K 方向上整除判断里的分支和循环展开没做干净。GEMM 里边界判断如果写不好,编译器没法很好地进行循环展开,流水线直接断裂,性能损失可能高达 20%。

所以在算模型时,我会先按“能整除”的理想情况估算上限;如果真实场景有边界,我会直接压缩一部分理想预期,为这些烂摊子留出余量。

7. 这套方法怎么扩展到更复杂的 GEMM 变体

写到这里,Tiled GEMM 自身的模型基本立起来了。这套思路很自然的延伸方向有三个,我估计你在实际项目里迟早会遇到:

  • Split-K:把 K 方向拆到多个 block 上并行算,最后做 reduce。这个场景下的模型要在“每个 block 搬运的数据量变小了”和“reduce 阶段多了一次全局读写”之间做权衡,用流量模型特别直观。
  • Grouped GEMM:多个独立的小 GEMM 捆绑成一个 kernel 跑。这时每个 group 的 tile 配置可能是不同的,性能模型要做的是分别计算每种 shape 的最优配置,再算合并后的占用冲突。
  • Flash Attention 类融合 kernel:本质上就是一个“带 softmax 的 GEMM 变体”,但多了一维在线 softmax 的状态存储。模型上,你要额外计算每轮迭代需要多读写的 attention 中间量,这些流量在某些情况下会让 kernel 从 compute bound 掉回 memory bound,所以最好提前预判一下。

每一次扩展,我都会回到最开始讲的那个动作:先算流量,再判断边界,最后定参数。这个方法论一旦熟练,基本可以覆盖绝大多数高性能算子开发场景。

我做 Tiled GEMM 优化这段时间,最大的体会是:不要跟玄学参数较劲,要跟数学模型较劲。你花一晚上盲目试参,不如花半小时把流量和压力算清楚。算清楚之后,你会发现那些“别人调了三天”的参数,你一把就能定位到最优范围。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询