Skip to main content

🏛️ 第24讲:从 1% 到 95% 算力利用率的九重天跃迁——CUDA GEMM 分块(Tiling)与 Tensor Core 思维深度解构

主讲人:👓 Ringi(大厂 AI Infrastructure 工程师)
所属模块:Module 02: CUDA 编程与高性能算子优化
篇章范式:⚡ CUDA 编程与高性能算子优化篇(Kernel & Operator Optimization Paradigm)
核心导读:矩阵乘法(GEMM)被称为“现代人工智能算力王冠上的明珠”。无论是大模型中占据 80% 计算耗时的全连接层(Linear/MLP),还是注意力机制中的 QKTQK^T 与 PVPV,最终全部落盘在 GEMM 之上。然而,许多工程师第一次手写 CUDA 矩阵乘时,往往直接写出三重 for 循环,满心欢喜地扔到价值几十万的 NVIDIA A100 上跑,实测算力却只有可怜的 0.25 TFLOPS(理论峰值的 1.2%)!本讲我们将从 Roofline 模型的第一性原理出发,手算 GEMM 的数据复用极限;随后沿着 Block 级共享内存分块 →\rightarrow Thread 级 2D 寄存器外积复用 →\rightarrow 向量化访存 →\rightarrow Bank Conflict 消除 →\rightarrow 双缓冲异步流水线 的九重台阶步步登顶;最后推开现代张量计算圣殿的大门,彻底击穿 Tensor Core 的 Warp 级协同矩阵乘微架构。
Ringi 导师解构:核心全景工坊

📑 目录导航


0. Ringi 开场:生产真实现场与痛点冲突

0.1 真实工程矛盾:为什么直接用三重循环写矩阵乘,算力利用率只有 1.2%?

在任何一本通用编程教科书里,矩阵乘法 C=A×BC = A \times B( A∈RM×K,B∈RK×NA \in \mathbb{R}^{M \times K}, B \in \mathbb{R}^{K \times N} )的代码实现都是如此平易近人:
当你刚学会 CUDA 编程,把最外层的两重循环直接映射为 GPU 的 2D 线程网格:
你开了几十万个线程,把它扔上单卡售价数十万元的 NVIDIA A100-SXM4-80GB(理论单精度峰值算力高达 19.5 TFLOPS)。你满心期待它能在几十微秒内跑完,结果实测报告打印出来:
  • 计算规模:
M=N=K=4096M = N = K = 4096
  • 算子耗时:约 548 毫秒;
  • 实测有效算力:仅有 0.25 TFLOPS!
算力利用率只有理论峰值的 1.28%!整整 98.7% 的物理晶体管在彻底睡大觉! 为什么会这样?如果你去查看硬件指标,你会发现计算单元(ALU)根本没有全负荷工作,它整整 99% 的时钟周期都在苦苦等待数据从显存搬运过来。 每个线程为了计算 1 次乘加(2 个 FLOPs),就要从全局显存读取 1 个 AA 元素(4 字节)和 1 个 BB 元素(4 字节),总共搬运 8 个字节! 它的算术强度被锁死在 2/8=0.25 FLOPs/Byte2 / 8 = 0.25 \text{ FLOPs/Byte}。 在 A100 那 2039 GB/s 的带宽上限下,硬件能支撑的算力极限被死死按在 2039×0.25≈510 GFLOPS2039 \times 0.25 \approx \mathbf{510 \text{ GFLOPS}}! 你不做数据分块与寄存器复用,再昂贵的 GPU 也会被活活当成低速拖拉机开。

0.2 线上事故复盘:某大模型微调团队手写自定义 LoRA 线性层性能雪崩

2024 年秋,某大厂算法团队在对百亿参数稠密大模型进行特定下游任务的高阶 LoRA 微调。为了在一个算子中把低秩矩阵乘与特定的量化激活函数融合(Fuse),一位工程师信心满满地参考了某开源简单代码,手写了一个定制化的 GEMM Kernel 替换掉原生的 torch.matmul(底层调用 cuBLAS)。 在小规模单测(矩阵为 128×128128 \times 128 )下,数值完全对齐,测试耗时似乎也“挺快”。然而,一旦上到真实分布式微调集群(Batch 增大,隐藏层维度 M=4096,N=4096,K=4096M=4096, N=4096, K=4096 ),整个训练集群的 GPU 利用率(GPU-Util)瞬间从 92% 暴跌到 8%!单步 Iteration 耗时拉长了整整 11 倍!原本计划 3 天跑完的模型微调任务,进度条显示需要耗时 33 天! 架构团队迅速介入,使用 Nsight Compute (NCU) 对该 Kernel 进行了深入剖析,发现了三重严重的体系结构级硬伤:
  1. 未做 2D Register Tiling(寄存器级分块):每个线程只计算 1×11 \times 1 的输出,导致每个循环步长内,所有线程都在拼命读取片上 Shared Memory,将 Shared Memory 的带宽(19 TB/s)彻底打崩,SM 内部大量报出 Stall MIO Throttle(内存指令管线拥堵);
  2. 严重的 Shared Memory Bank Conflict:在将 AA 矩阵从共享内存读入时,跨步寻址引发了满额的 32-way Bank Conflict,原本 1 个周期的片内读取被硬件强行拖长为 32 个周期;
  3. 完全没有利用 Tensor Core:在 A100 上依然采用 FP32 纯标量 FMA 指令发射,放弃了算力高达 312 TFLOPS 的张量核心(相差 16 倍!)。
最终,架构组通过重构为标准的三级分块体系(Block Tile + Warp Tile + 2D Register Tile)并启用 Tensor Core 原语,算子耗时直接从 540ms 暴降至 0.88ms,整整提速 613 倍!

0.3 AI Infra GEMM 各演进阶段速查表


1. 算术强度与 Roofline 极限:GEMM 为什么是“算力王冠上的明珠”?

为了在深入具体细节前建立完整的物理心智模型,下方给出了 GEMM 算术强度模型、分层数据复用金字塔、2D 寄存器外积流水线与 Tensor Core Warp 协同计算的工业级全景架构拓扑: CUDA GEMM 九重分块天梯与 Tensor Core Warp 协同计算全景图

1.1 GEMM 运算量与访存量的代数账本: 2MNK2MNK vs 存储搬运

要优化一个算子,首先必须在草稿纸上算清它的“理论账本”。 对于标准通用矩阵乘法: C=A×B,A∈RM×K,B∈RK×N,C∈RM×NC = A \times B, \quad A \in \mathbb{R}^{M \times K}, B \in \mathbb{R}^{K \times N}, C \in \mathbb{R}^{M \times N}
  1. 计算量账本(FLOPs): 矩阵 CC 共有 M×NM \times N 个元素。每个元素的产生,都需要将 AA 的一行( KK 个数)与 BB 的一列( KK 个数)做点积内积。 每个数参与 1 次乘法和 1 次加法(FMA,Fused Multiply-Add),共计 2 次浮点运算。
Total FLOPs=2×M×N×K\text{Total FLOPs} = 2 \times M \times N \times K 当 M=N=K=4096M = N = K = 4096 时: Total FLOPs=2×40963≈1.374×1011 FLOPs (137.4 GFLOPs)\text{Total FLOPs} = 2 \times 4096^3 \approx \mathbf{1.374 \times 10^{11} \text{ FLOPs (137.4 GFLOPs)}}
  1. 存储量账本(Bytes): 输入矩阵 AA 包含 M×KM \times K 个数,矩阵 BB 包含 K×NK \times N 个数,输出矩阵 CC 包含 M×NM \times N 个数。 采用单精度 FP32(每个元素 4 字节):
Data Volume=4×(M⋅K+K⋅N+M⋅N) Bytes\text{Data Volume} = 4 \times (M \cdot K + K \cdot N + M \cdot N) \text{ Bytes} 当 M=N=K=4096M = N = K = 4096 时: Data Volume=4×(3×40962)=4×50,331,648 Bytes≈201.3 MB\text{Data Volume} = 4 \times (3 \times 4096^2) = 4 \times 50,331,648 \text{ Bytes} \approx \mathbf{201.3 \text{ MB}}

1.2 No Naked Formula 2.0:GEMM 算术强度模型与 A100 平衡点

请盯紧上面两组数据:运算量是 O(N3)O(N^3) 级别,而物理存储量仅仅是 O(N2)O(N^2) 级别! 随着矩阵维度从 128 增长到 4096,运算量放大了 323=3276832^3 = 32768 倍,而数据量只放大了 322=102432^2 = 1024 倍! 这意味着:在理论极限下,矩阵乘法拥有极其庞大的数据复用空间!每个数据在理论上可以被复用数千次! 然而,如果代码写得烂,这种理论复用就会瞬间化为乌有。我们严格使用 No Naked Formula 2.0 五步穿透算术强度模型:
① 为什么需要算它?
定位当前算子究竟是被显存带宽卡死(Memory-Bound),还是已经被计算单元吃满(Compute-Bound),明确优化方向。
② Mental Model(物理直觉比喻)
想象你在后厨炒菜。
  • 朴素实现的做法:炒一盘肉丝,你就穿过长长的走廊跑去菜市场(HBM 显存)买一两肉和一根葱(4 字节);炒下一盘肉丝,你又跑去菜市场买一两肉和一根葱。整整一天,你 99% 的时间都在走廊上跑步,锅里的火(ALU)全是冷的!
  • 工业级分块的做法:你开了一辆小推车(Shared Memory / 寄存器),一次性从菜市场批发一大箱肉和葱搬进厨房;然后在砧板上大火爆炒几百盘肉丝,彻底把锅烧红,最后只把炒好的成品菜送出去一次!
③ Tiny Calculator(极简数字手算)
设 M=N=K=4M = N = K = 4 的微型矩阵:
  • 朴素实现(无复用): 计算每个 C[i][j]C[i][j],读取 AA 的 4 个数和 BB 的 4 个数(共 8×4B=32B8 \times 4\text{B} = 32\text{B} ),完成 2×4=82 \times 4 = 8 次计算。
算术强度=8 FLOPs32 Bytes=0.25 FLOPs/Byte\text{算术强度} = \frac{8 \text{ FLOPs}}{32 \text{ Bytes}} = \mathbf{0.25 \text{ FLOPs/Byte}}
  • 理想完全分块(全部放入片上复用): 总共把 A(16数)+B(16数)A(16 \text{数}) + B(16 \text{数}) 搬进片上(共 32×4B=128B32 \times 4\text{B} = 128\text{B} ),算出 C(16数)C(16 \text{数}) 写出( 16×4B=64B16 \times 4\text{B} = 64\text{B} ),总流量 192 Bytes。 总运算量为 2×43=1282 \times 4^3 = 128 FLOPs。
算术强度=128 FLOPs192 Bytes≈0.67 FLOPs/Byte\text{算术强度} = \frac{128 \text{ FLOPs}}{192 \text{ Bytes}} \approx \mathbf{0.67 \text{ FLOPs/Byte}} 如果规模扩大到 4096×40964096 \times 4096,理想算术强度将直接飙升至: Iideal=2×409634×3×40962=40966≈682.6 FLOPs/ByteI_{\text{ideal}} = \frac{2 \times 4096^3}{4 \times 3 \times 4096^2} = \frac{4096}{6} \approx \mathbf{682.6 \text{ FLOPs/Byte}}
④ Formal Model(算术强度与硬件平衡点)
硬件平台的 Roofline 平衡点(Balance Point) 定义为: Balance Point=Peak Compute Throughput (FLOPS)Peak Memory Bandwidth (Bytes/s)\text{Balance Point} = \frac{\text{Peak Compute Throughput (FLOPS)}}{\text{Peak Memory Bandwidth (Bytes/s)}} 以 NVIDIA A100-SXM4-80GB 为例:
  • CUDA Core FP32 峰值: 19.5 TFLOPS19.5 \text{ TFLOPS},HBM 带宽: 2039 GB/s2039 \text{ GB/s}。
Balance PointFP32=19.5×10122039×109≈9.56 FLOPs/Byte\text{Balance Point}_{\text{FP32}} = \frac{19.5 \times 10^{12}}{2039 \times 10^9} \approx \mathbf{9.56 \text{ FLOPs/Byte}}
  • Tensor Core FP16 峰值: 312 TFLOPS312 \text{ TFLOPS},HBM 带宽: 2039 GB/s2039 \text{ GB/s}。
Balance PointTensorCore=312×10122039×109≈153.0 FLOPs/Byte\text{Balance Point}_{\text{TensorCore}} = \frac{312 \times 10^{12}}{2039 \times 10^9} \approx \mathbf{153.0 \text{ FLOPs/Byte}}
⑤ Sanity Check(残酷的数量级校验)
  • 朴素 GEMM 的算术强度只有 0.25 FLOPs/Byte0.25 \text{ FLOPs/Byte};
  • 它比 FP32 平衡点(9.56)整整低了 38 倍,比 Tensor Core 平衡点(153)低了整整 612 倍!
  • 这就是为什么朴素实现绝无可能跑快。要想打满 Tensor Core,你的算法必须把数据在片上反复复用至少 150 次以上!

1.3 现代 GPU 存储层级金字塔与数据搬运代价

要实现百倍的数据复用,我们必须深刻理解 GPU 内部由硅片物理决定的四级存储金字塔: 优化 GEMM 的终极奥义,就是将原本发生在底层 HBM 的绝大多数访存流量,逐级“拦截”并转移到顶层的 Shared Memory 和 Registers 中!

2. Thread Block 级分块(Block Tiling):利用 Shared Memory 实现第一次数据复用跃迁

2.1 朴素 GEMM 的死穴:对全局内存的狂轰滥炸

在朴素实现中,每个线程负责独立计算矩阵 CC 中的一个元素 C[i][j]C[i][j]:
让我们考察同一个 Block 内的相邻线程:
  • 线程 (i,0)(i, 0) 和 线程 (i,1)(i, 1),计算的是同一行。它们在整个 KK 维循环中,把矩阵 AA 的第 ii 行完全重复读取了 2 次!
  • 如果该行有 4096 列,矩阵 AA 的这一行就被全局所有列线程重复从 HBM 读取了整整 4096 次! 同理,矩阵 BB 的每一列也被重复读取了 4096 次!这等同于向 HBM 总线倾倒了无数吨毫无意义的垃圾重复流量。

2.2 Block Tile 物理切分: BM×BNBM \times BN 与沿 KK 维度的滑动窗口

解决这一死穴的第一道防线,就是 Thread Block 级分块(Block Tiling)。 我们将输出矩阵 CC 划分为多个大小为 BM×BNBM \times BN 的子块(Block Tile,例如 128×128128 \times 128 )。 每个 Thread Block 专门负责计算其中一个子块。 为了完成这个子块的计算,我们需要 AA 矩阵中对应的 BM×KBM \times K 条带,以及 BB 矩阵中对应的 K×BNK \times BN 条带。 但片上 Shared Memory 放不下整个条带( K=4096K=4096 太长),因此我们将长条带沿 KK 维度切分成一个个步长为 BKBK(例如 BK=8BK = 8 或 1616 )的小窗口:

2.3 协作搬运(Cooperative Fetching):Block 内所有线程的集体搬运

在每个 BKBK 窗口内部:
  1. 集体搬家(Cooperative Load): Block 内的所有线程协同合作,将 AA 的 BM×BKBM \times BK 块和 BB 的 BK×BNBK \times BN 块,从慢速全局显存整体拷贝到片上快速的 As[BM][BK] 和 Bs[BK][BN] 共享内存数组中;
  2. 栅障同步(__syncthreads()): 等待所有线程搬运完成,确保 Shared Memory 里的数据完全就绪;
  3. 片上计算(Compute from SRAM): 所有线程从 Shared Memory 中读取数据,做局部的乘加累积;
  4. 栅障同步(__syncthreads()): 等待计算完毕,确保 Shared Memory 可以被下一个 BKBK 窗口的数据覆盖。

2.4 数据复用收益倍数精准推导

让我们算一算引入 Shared Memory 分块后的真实收益:
  • 在这个 BM×BNBM \times BN 的输出块中,完成一个 BKBK 步长所需的浮点运算量为:
FLOPs=2×BM×BN×BK\text{FLOPs} = 2 \times BM \times BN \times BK
  • 从全局显存读取的数据量仅为:
Global Read Bytes=4×(BM×BK+BK×BN)\text{Global Read Bytes} = 4 \times (BM \times BK + BK \times BN)
  • 此时全局显存的算术强度提升为:
Itiled=2⋅BM⋅BN⋅BK4⋅BK⋅(BM+BN)=BM⋅BN2⋅(BM+BN)I_{\text{tiled}} = \frac{2 \cdot BM \cdot BN \cdot BK}{4 \cdot BK \cdot (BM + BN)} = \frac{BM \cdot BN}{2 \cdot (BM + BN)}
极简数字代入:
若设 BM=BN=128BM = BN = 128: Itiled=128×1282×(128+128)=16384512=32.0 FLOPs/ByteI_{\text{tiled}} = \frac{128 \times 128}{2 \times (128 + 128)} = \frac{16384}{512} = \mathbf{32.0 \text{ FLOPs/Byte}} 从原本的 0.25 骤增至 32.0!算术强度整整放大了 128 倍! 全局显存的带宽不再是致命瓶颈,算子性能直接从 1.2% 跃升到 20% 以上!

3. Thread 级分块与寄存器复用(2D Register Tiling):突破 Shared Memory 带宽瓶颈

3.1 共享内存的带宽危机:19 TB/s 依然喂不饱 300 TFLOPS

当 Block Tiling 把全局内存的压力卸掉后,新的性能高墙在 Shared Memory 上拔地而起。 让我们计算 SM 内部的吞吐瓶颈: 在上一节的 Block Tiling 中,如果每个线程只计算 CC 的 1 个元素( 1×11 \times 1 ): 在内部的 KK 维循环中,为了完成 1 次乘加(2 FLOPs),线程必须从 Shared Memory 读取 1 个 AA 元素(4 字节)和 1 个 BB 元素(4 字节),共消耗 8 字节的 Shared Memory 带宽!
  • 算术强度在共享内存层面依然被按在 0.25 FLOPs/Byte;
  • A100 单 SM 的共享内存聚合带宽约为 180 GB/s(全卡 19 TB/s);
  • 180 GB/s 能够喂饱的片上算力只有: 180×0.25=45 GFLOPS/SM180 \times 0.25 = \mathbf{45 \text{ GFLOPS/SM}}(全卡仅约 4.8 TFLOPS)! 共享内存的 Crossbar 总线被打到冒烟,算子性能卡死在 20%~25% 无法寸进!

3.2 2D Register Tiling:单线程负责 TM×TNTM \times TN 输出子块

要想彻底解放 Shared Memory,必须迈出体系结构最关键的一步——2D 寄存器分块(Thread-level 2D Register Tiling)! 核心思想极其精妙: 绝对不能让一个线程只算一个元素!必须让每个线程同时负责计算输出矩阵中的一个小矩阵块( TM×TNTM \times TN,例如 8×8=648 \times 8 = 64 个元素)! 并且,这 64 个累加和直接保存在该线程的**私有通用寄存器堆(Registers)**中,全程不写任何内存!

Ringi 导师解构:从 HBM 到寄存器的外积复用数据流

3.3 外积计算模型(Outer Product Engine):以小搏大的数学艺术

请观察为什么外积能够产生恐怖的数据复用:
  • 如果用传统的内积计算,两个长度为 8 的向量点乘,读取 16 个数,只产生 16 次 FLOPs;
  • 但如果将 AA 的 8×18 \times 1 列向量与 BB 的 1×81 \times 8 行向量做外积(Outer Product):
Caccum[i][j]+=ra[i]×rb[j],∀i∈[0,7],j∈[0,7]C_{\text{accum}}[i][j] += r_a[i] \times r_b[j], \quad \forall i \in [0, 7], j \in [0, 7] 只从 Shared Memory 读取了 16 个数,就在片上寄存器里瞬间爆发了 64 次乘加(128 FLOPs)!

3.4 算术强度的二次飞跃:寄存器级的极致压榨

我们推导寄存器级分块带来的复用比: 每个单步内,从 Shared Memory 读取的数据字节数为 4×(TM+TN)4 \times (TM + TN); 完成的计算量为 2×TM×TN2 \times TM \times TN FLOPs。 共享内存的算术强度提升为: Ireg=2⋅TM⋅TN4⋅(TM+TN)=TM⋅TN2⋅(TM+TN)I_{\text{reg}} = \frac{2 \cdot TM \cdot TN}{4 \cdot (TM + TN)} = \frac{TM \cdot TN}{2 \cdot (TM + TN)}
  • 当 TM=TN=8TM = TN = 8 时:
Ireg=642×16=2.0 FLOPs/ByteI_{\text{reg}} = \frac{64}{2 \times 16} = \mathbf{2.0 \text{ FLOPs/Byte}}
  • 相比原本 1×11 \times 1 的 0.25,共享内存的数据读取量被直接砍掉了 87.5%! 原本被共享内存带宽卡死的计算核心瞬间彻底松绑,实测算力直接冲上 14~18 TFLOPS(理论峰值的 75%~90%)!

4. 向量化加载、Bank Conflict 消除与双缓冲(Double Buffering)流水线

在完成了两级分块后,我们已经拿到了 75% 的性能。要将剩下的 20% 性能榨干,必须攻克微架构层面的三大隐形损耗:

4.1 向量化访存:强制使用 float4 压榨指令发射通道

在从全局显存向 Shared Memory 搬运数据时,绝不使用标量 float 读取: 强制将指针重新解释为 float4*,发射硬件指令 LDG.128:
  • 指令发射开销减少 75%;
  • 单条指令直接打满 128-bit 显存总线事务,极大提升了 Memory-Level Parallelism (MLP)。

4.2 共享内存 Bank Conflict 消除:矩阵转置与 Padding 策略

在将 AA 矩阵与 BB 矩阵放入共享内存时,存在严重的访存方向冲突:
  • 矩阵 BB 在计算时是按行读取(连续线程读取连续列),天然与 32 个 Bank 对应,无 Bank 冲突;
  • 矩阵 AA 在计算时是按列读取(一个线程沿 KK 维度垂直读取不同行),如果每行元素为 32 的倍数,相邻行的同一个列元素将落在同一个 Bank 上,直接触发灾难性的 32-way Bank Conflict!
工业级解决方案:
  1. 在将 AA 写入共享内存时直接做转置存储:将 AA 的切片以转置形式保存在 As[BK][BM] 中,使得读取时变为沿行连续读取;
  2. 增加列 Padding:声明 __shared__ float As[BK][BM + 4],错开 Bank 索引映射,彻底粉碎 Bank Conflict。

4.3 软流水线(Software Pipelining)与双缓冲(Ping-Pong Buffer)

在上述分块计算中,主循环存在严格的“停顿同步”: ⋯→读第 k 块数据→同步等待→计算第 k 块→读第 k+1 块→同步等待→…\dots \rightarrow \text{读第 } k \text{ 块数据} \rightarrow \text{同步等待} \rightarrow \text{计算第 } k \text{ 块} \rightarrow \text{读第 } k+1 \text{ 块} \rightarrow \text{同步等待} \rightarrow \dots 在读全局显存的 400 个周期里,ALU 是完全停工发呆的! 双缓冲(Double Buffering) 彻底打破了这一依赖: 我们为 Shared Memory 开辟两套缓冲区(Buffer 0 和 Buffer 1): 通过乒乓交替,加载第 k+1k+1 块显存数据的长延迟,被第 kk 块海量的寄存器外积计算时间完全掩盖! 全局访存延迟被真正压缩到了 0!

4.4 Ampere 异步拷贝指令 cp.async:直通 Shared Memory

在 NVIDIA Ampere 架构(A100)以前,双缓冲依然需要占用通用寄存器作为中转; 而 A100 首次引入了硬件级异步拷贝原语 cp.async: 数据直接由专用的芯片内部 DMA 控制器从 L2/HBM 搬运进 Shared Memory,完全绕过通用寄存器,完全不占用 ALU 算力,使得双缓冲流水线的排布效率达到了硅片物理极境。

5. Tensor Core 硬件革命与思维升维:从标量 FMA 到 Warp 级矩阵乘

Ringi 导师解构:Tensor Core Warp 级协同矩阵乘工坊

5.1 体系结构断代差:为什么 Tensor Core 能甩开 CUDA Core 16 倍?

即便利大师将 CUDA Core 优化到极限,A100 的 FP32 单精度算力上限也就是 19.5 TFLOPS。 但在深度学习的大规模训练与推理中,我们需要的是数十倍于此的算力爆发。 NVIDIA 从 Volta 架构开始引入、在 Ampere/Hopper 上发扬光大的 Tensor Core(张量核心),代表了处理器的完全代际革命:
  • 传统 CUDA Core:纯标量执行单元。每个周期、每个线程发射 1 条指令,处理 1 对标量的乘加( a×b+ca \times b + c );
  • Tensor Core:微观脉动阵列(Systolic-like Tensor Array)。它直接以矩阵乘累加作为单条硬件指令的执行基元:
D=A×B+CD = A \times B + C 在 A100 上,一个 SM 内部包含 4 个独立的第三代 Tensor Core。在每个时钟周期内,每个 Tensor Core 可以完成高达 8×4×88 \times 4 \times 8 的矩阵乘累加,单周期直接吞吐 256 次浮点运算!全卡半精度(FP16/BF16)算力高达 312 TFLOPS!

5.2 Warp 级协同计算哲学:没有“单个线程的 Tensor Core”

初学者学习 Tensor Core 时最容易犯的致命错误,就是试图在单线程里调用张量指令。 请刻在脑海里:GPU 硬件上根本不存在属于“单个线程”的 Tensor Core! Tensor Core 属于整个 Warp(32 线程): 一条 Tensor Core 指令(如 mma.sync.aligned.m16n8k16),必须由 同一个 Warp 内的全部 32 个线程同步联合发射!
  • 矩阵 AA( 16×1616 \times 16 )和矩阵 BB( 16×1616 \times 16 )并不是保存在某一个线程的内存里;
  • 它们被硬件切碎成很多微小的碎片(Fragments),均匀分散打桩在 Warp 内 32 个线程的各个私有寄存器中!

5.3 WMMA API (nvcuda::wmma) vs PTX 原语 (mma.sync) 深度对比

在 CUDA 软件生态中,使用 Tensor Core 有两种主流范式:

5.4 Fragment 寄存器映射机制:32 线程如何瓜分一个矩阵?

以 Ampere 架构最常用的 mma.sync.aligned.m16n8k16 指令为例: 它完成一个 16×1616 \times 16 的矩阵 AA 与 16×816 \times 8 的矩阵 BB 相乘,累加到 16×816 \times 8 的矩阵 CC 中。
  • 矩阵 AA 的碎片分布: 16×16=25616 \times 16 = 256 个半精度元素(512 字节)。分配给 32 个线程,每个线程持有 8 个 FP16 元素(刚好保存在 4 个 32-bit 寄存器中);
  • 矩阵 BB 的碎片分布: 16×8=12816 \times 8 = 128 个半精度元素。每个线程持有 4 个 FP16 元素(保存在 2 个 32-bit 寄存器中);
  • 矩阵 CC 的累加碎片: 16×8=12816 \times 8 = 128 个单精度 float 元素。每个线程持有 4 个 FP32 寄存器。
当 32 个线程同时发射这条指令时,Tensor Core 硬件矩阵乘法器如同魔术一般,瞬间将 32 个线程寄存器中的碎片接入片内脉动阵列,在一个时钟周期内完成所有的交叉乘加,并将结果写回各自的累加寄存器中!这就是现代 AI 硬件算力爆发的终极真相。

6. 动手实战与代码实验室(Minimal Runnable Code)

本章提供 4 个生产级、由浅入深递进的完整 CUDA 微基准测试程序。所有代码遵循 Full-Output Enforcement 原则,绝无任何省略号与伪代码,自带 checkCuda 错误校验与微秒级计时,可以直接使用 nvcc 编译运行并输出清晰的对比证据链。

实验 1:Naive GEMM vs Block Tiling 基准测试(gemm_naive_vs_block.cu)

本实验直接对比没有复用的朴素实现与基于 Shared Memory 的 Block Tiling 实现,直观展现 128 倍数据复用带来的 10 倍以上算力飙升。

实验 2:2D Register Tiling 高性能 SGEMM 实现(gemm_2d_register_tiling.cu)

本实验实现单个线程计算 8×88 \times 8 输出子块、并在通用寄存器中通过**外积计算模型(Outer Product Engine)**极速运转的完整高性能 SGEMM 核心代码。

实验 3:双缓冲流水线与 float4 向量化 SGEMM 压测(gemm_double_buffer_vectorized.cu)

本实验引入 float4 向量化全局加载 与 Ping-Pong 寄存器双缓冲软流水线,展示如何彻底掩盖全局访存延迟。

实验 4:Tensor Core WMMA FP16 极致算力基准(gemm_tensor_core_wmma.cu)

本实验利用 CUDA 原生 nvcuda::wmma API,展示基于半精度 FP16 Tensor Core 硬件阵列的矩阵乘法,算力直接突破 100+ TFLOPS!

7. Ringi 避坑指南与生产黄金准则

7.1 避坑表格:7 大常见小白错误理解 vs 大厂 AI Infra 正确认知


7.2 生产性能工程黄金 Checklist

  • 1. 【Roofline 瓶颈前置测算】:在动工前,用矩阵维度 M,N,KM, N, K 精确计算算术强度与硬件平衡点,明确目标算力上限。
  • 2. 【三级分块尺寸正交设计】:遵循黄金经验规则:Block Tile 设为 128×128128 \times 128(配合 BK=8BK = 8 或 1616 );Thread Tile 设为 8×88 \times 8;Block 内分配 256 线程。
  • 3. 【强制 128-bit 向量化访存】:在全局显存加载与共享内存写入中,全量使用 float4 / half8(LDG.128),消灭指令发射瓶颈。
  • 4. 【共享内存 Bank 冲突彻底清零】:对 As 矩阵使用转置存储或对每行添加 +4 Padding,确保内层外积循环中 32 个 Bank 零串行化。
  • 5. 【双缓冲寄存器预取流水线】:严格排布主循环,确保全局异步加载指令在当前外积计算刚开始时便发射完毕。
  • 6. 【寄存器用量严格守门】:编译时加入 -Xptxas=-v,检查每个线程的寄存器占用控制在 128 以内,确保 Spill stores 和 Spill loads 严格为 0。
  • 7. 【Tensor Core 场景精度与对齐契约】:大模型场景全面拥抱 FP16/BF16,输入矩阵物理维度必须填充对齐到 16 的倍数(stride % 16 == 0)。

8. Ringi 5 点核心速记口诀、自我检验清单与课后深度思考题

8.1 5 点押韵核心速记口诀


8.2 10 条白板自我检验清单

  1. 为什么朴素 GEMM 的算术强度只有 0.25 FLOPs/Byte?请用输入输出字节数手算推导。
  2. 什么是 A100 GPU 的 Roofline 平衡点?FP32 和 Tensor Core FP16 的平衡点分别是多少?
  3. 在 Block Tiling 中,为什么 AA 矩阵和 BB 矩阵沿 KK 维度切分的步长 BKBK 通常选择 8 或 16,而不是 128?
  4. 什么是 2D Register Tiling?为什么每个线程计算 8×88 \times 8 个输出比计算 1×11 \times 1 性能高出数倍?
  5. 简述外积计算(Outer Product)与内积计算在数据复用比上的数学差异。
  6. 为什么在 Shared Memory 中按列读取 AA 矩阵会产生 32-way Bank Conflict?如何用 Padding 或转置消除?
  7. 画出双缓冲(Ping-Pong Buffer)的时序交替图,说明它是如何实现“计算与访存重叠”的。
  8. 简述 NVIDIA Ampere 架构 cp.async 指令相对于传统加载指令的硬件优势。
  9. 解释为什么 Tensor Core 的最小操作单位是 Warp(32 线程)而不是 Thread。
  10. wmma::fragment 在物理硬件上到底保存在哪里?为什么不能像普通数组一样用动态下标访问?

8.3 3 道高阶开放式课后思考题(含极限 Corner Case)

思考题 1:Wave Quantization 效应与尾块气泡

当矩阵规模不是 Block Tile 的整数倍时(例如 M=4097,BM=128M = 4097, BM = 128 ),边缘的最后一个 Block 只有 1 个有效行,其余 127 行全为空跑。在大型集群调度中,这种现象被称为 Wave Quantization 效应。请分析:在大模型推理动态 Batch 场景下,应如何动态调整 BMBM 和 BNBN(或者采用 Split-K 技术)来消除尾块气泡对算力利用率的断崖式侵蚀?

思考题 2:Split-K GEMM 的系统级取舍

当矩阵的 MM 和 NN 非常小(例如大模型解码生成阶段 Batch=1,GEMV 模式),但 KK 维度极大(如 K=16384K = 16384 )时,常规的 2D 网格切分只能发射很少的 Block,根本填不满 A100 的 108 个 SM。此时业界常采用 Split-K 架构(沿 KK 维度切分到不同 Block 并行算,最后做 Atomic 归约)。请推导 Split-K 带来的并行度收益与其引入的全局原子写同步代价之间的临界平衡点。

思考题 3:CUTLASS 3.x 与 Hopper TMA/WGMMA 的硬件代际跃迁

在 Hopper(H100)架构中,硬件直接提供了 TMA(异步搬运)和 WGMMA(Warp Group 级 128 线程协同张量乘)。这彻底颠覆了 Ampere 时代基于 32 线程的 mma.sync。请调研 CUTLASS 3.x 的 CuTe 布局代数,分析从 Warp 级(32 线程)升级到 Warp Group 级(128 线程)协同后,片上共享内存和寄存器的分配范式发生了怎样根本性的突变?

9. 📚 参考资料与核心源码/经典论文指引

本讲内容与推导过程严格对照并依据本地知识库 AI_BOOK 中的权威一手文献与源码:
  1. 工业级 GEMM 分块体系结构详解:
    • 深入学习 Block-Warp-Thread 三级分块、向量化访存与双缓冲:参见 4.1-CUDA GEMM算子性能优化.md。
  2. LeetCUDA 生产级 SGEMM 源码基准:
    • 参考从朴素实现到 2D Register Tiling 与 Bank Conflict 消除的精炼 C++ 模板:参见 LeetCUDA/kernels/sgemm/README.md 与 LeetCUDA/kernels/interview/sgemm.cuh。
  3. Tensor Core 微架构深度剖析:
    • 学习脉动阵列、指令流水与 Warp 级线程执行机制:参见 AISystem/02Hardware/04NVIDIA/03DeepTC.md。
  4. NVIDIA 官方体系结构白皮书与原著:
    • NVIDIA A100 Tensor Core GPU Architecture, NVIDIA Corporation, 2020.
    • CUTLASS: Fast Linear Algebra in CUDA C++, NVIDIA Developer Blog.

附录:Appendix A — 大厂硬核高频面试题与白板推导(Interview Drill)

题目 1:白板手算推导:Naive GEMM、Shared Memory Block Tiling 与 2D Register Tiling 三者的算术强度对比,证明为什么 8×88 \times 8 寄存器分块通常是最优解。

【面试官考察维度】
  1. 是否具备极强的体系结构直觉与算术强度(Arithmetic Intensity)量化推导能力;
  2. 是否理解从全局显存(HBM)到片上 SRAM 再到寄存器(Registers)的两级复用本质;
  3. 能否从寄存器硬件容量(64K regs/SM)与编译器溢出限制论证为什么 8×88 \times 8 是工程黄金点。
【白板标准解答与推导路径】
  • 第一步:朴素 GEMM(无复用) 计算 1 个 CC 元素需要 KK 次乘加( 2K2K FLOPs),从全局显存读取 AA 的 KK 个数与 BB 的 KK 个数( 2K×42K \times 4 字节)。
Inaive=2K8K=0.25 FLOPs/ByteI_{\text{naive}} = \frac{2K}{8K} = \mathbf{0.25 \text{ FLOPs/Byte}}
  • 第二步:Shared Memory Block Tiling(一级复用) 在 BM×BNBM \times BN 分块内部,每个 BKBK 窗口完成运算量 2⋅BM⋅BN⋅BK2 \cdot BM \cdot BN \cdot BK FLOPs,从全局读取 4⋅BK⋅(BM+BN)4 \cdot BK \cdot (BM + BN) 字节。
Iblock=BM⋅BN2⋅(BM+BN)→BM=BN=12832.0 FLOPs/ByteI_{\text{block}} = \frac{BM \cdot BN}{2 \cdot (BM + BN)} \xrightarrow{BM=BN=128} \mathbf{32.0 \text{ FLOPs/Byte}}
  • 第三步:2D Register Tiling(二级外积复用) 每个线程负责 TM×TNTM \times TN 局部块。在每个 KK 步长中,从 Shared Memory 读取 (TM+TN)×4(TM + TN) \times 4 字节,在寄存器中产生 TM×TNTM \times TN 次乘加( 2⋅TM⋅TN2 \cdot TM \cdot TN FLOPs)。
Ithread=TM⋅TN2⋅(TM+TN)I_{\text{thread}} = \frac{TM \cdot TN}{2 \cdot (TM + TN)}
  • 第四步:论证 8×88 \times 8 为什么是黄金平衡点
    • 若取 TM=TN=4TM = TN = 4: Ithread=16/16=1.0I_{\text{thread}} = 16 / 16 = 1.0 FLOPs/Byte,共享内存带宽仍显局促;
    • 若取 TM=TN=8TM = TN = 8: Ithread=64/32=2.0I_{\text{thread}} = 64 / 32 = 2.0 FLOPs/Byte,共享内存带宽压力骤减 87.5%;此时单个线程占用 64 个累加寄存器 + 16 个缓存寄存器 ≈80\approx 80 个寄存器。A100 单线程上限 255 个,80 个寄存器刚好保证 50%~62.5% 的高 Occupancy 且绝不发生 Register Spilling;
    • 若激进取 TM=TN=16TM = TN = 16:需要 16×16=25616 \times 16 = 256 个累加寄存器,直接打穿硬件上限,触发 Local Memory 溢出雪崩!
    • 结论: 8×88 \times 8 是兼顾寄存器复用最大化与防止寄存器溢出的绝对黄金甜点。

题目 2:在共享内存中存储矩阵分块时,如何通过转置和 Padding 消除按列读取时的 32-way Bank Conflict?

【面试官考察维度】
考查对 GPU 共享内存 32-Bank 物理交叉开关、交错地址映射以及矩阵访存方向性冲突的精细掌握。
【白板推导路径】
  1. 分析冲突成因:
    • 共享内存划分为 32 个 4 字节 Bank,地址 (row×Stride+col)(row \times \text{Stride} + col) 映射到的 Bank 为 (row×Stride+col)(mod32)(row \times \text{Stride} + col) \pmod{32};
    • 矩阵 AA 在 Block Tiling 维度中是 BM×BKBM \times BK(例如 128×8128 \times 8 )。如果按常规方式存储,在做外积时,线程需要沿 KK 维度垂直读取同一列的连续行元素( rowrow 变化,而 colcol 固定);
    • 此时若 Stride=BK=8\text{Stride} = BK = 8,相邻行的 Bank 差值为 88。当 4 个线程跨越 4 行时, 4×8=324 \times 8 = 32,线程 0 和线程 4 会撞入同一个 Bank;当规模更大时,直接退化为严重的 32-way Bank Conflict!
  2. 给出解决方案 A:转置存储(Transpose Storage): 在协作将 AA 从全局显存搬入共享内存时,不按行存,而是按转置格式存入 As[BK][BM]。此时读取时变为沿 BMBM 维度横向读取,连续线程读取连续列,天然无冲突!
  3. 给出解决方案 B:列填充(Padding): 声明数组时人为扩充一列:__shared__ float As[BK][BM + 4]。 此时物理行宽为 BM+4=132BM + 4 = 132。因为 gcd⁡(132,32)=4\gcd(132, 32) = 4,或者让交错模数与 32 互质,打破了 32 的幂次对齐,使得垂直读取的地址在 32 个 Bank 间均匀错开,彻底消灭 Bank 冲突。

题目 3:什么是双缓冲(Double Buffering)?请画出时序图并说明它如何隐藏全局显存到共享内存的访存延迟。

【面试官考察维度】
考查指令级软流水线(Software Pipelining)设计能力以及 GPU 延迟隐藏的体系结构机理。
【白板推导路径】
  1. 对比单缓冲与双缓冲的时序差异:
    • 单缓冲串行等待:
[Load Tile 0]→[Sync]→[Compute Tile 0]→[Load Tile 1]→[Sync]→[Compute Tile 1][\text{Load Tile 0}] \rightarrow [\text{Sync}] \rightarrow [\text{Compute Tile 0}] \rightarrow [\text{Load Tile 1}] \rightarrow [\text{Sync}] \rightarrow [\text{Compute Tile 1}]
  • 串行总耗时:各阶段串行累加,硬件长期处于“走廊跑步”与“厨房炒菜”交替停滞状态:
Tserial=∑(Timeload+Timecompute)T_{\text{serial}} = \sum \left(\text{Time}_{\text{load}} + \text{Time}_{\text{compute}}\right)
  • 双缓冲流水重叠: 在 Shared Memory 中开辟两套缓冲 Buffer[2]:
  • 序幕:在进入主循环前,预取 Tile 0 到 Buffer[0];
  • 循环体:当 ALU 全力使用 Buffer[read] 计算 Tile kk 时,后台通过异步指令将 Tile k+1k+1 预取写入 Buffer[write];
  • 循环步长:仅受限于计算与加载的最大者:
Tstep=max⁡(Timeload,Timecompute)T_{\text{step}} = \max\left(\text{Time}_{\text{load}}, \text{Time}_{\text{compute}}\right)
  1. 延迟隐藏的充要条件: 当计算时间 Timecompute≥Timeload\text{Time}_{\text{compute}} \ge \text{Time}_{\text{load}} 时,全局显存的访问延迟被计算完全掩盖,外界感知到的等效访存延迟为 0!

题目 4:Tensor Core mma.sync.aligned.m16n8k16 原语的 Warp 寄存器切片(Fragment)物理映射白板解析。

【面试官考察维度】
区分初级 CUDA 开发者与资深算子/AI 编译器开发者的终极试金石。
【白板推导路径】
  1. 原语定义: mma.sync.aligned.m16n8k16.row.col 表示一个 Warp(32 线程)协作计算矩阵乘加:
D(16×8)=A(16×16)×B(16×8)+C(16×8)D (16 \times 8) = A (16 \times 16) \times B (16 \times 8) + C (16 \times 8)
  1. 输入矩阵 AA 的碎片分布(Row-Major):
    • 矩阵 AA 大小为 16×16=25616 \times 16 = 256 个 FP16 元素(共 512 字节);
    • Warp 内 32 个线程,每个线程分配 256/32=8256 / 32 = 8 个 FP16 元素;
    • 每个元素 2 字节,8 个元素共 16 字节,刚好装入每个线程的 4 个 32-bit 通用寄存器( R0,R1,R2,R3R_0, R_1, R_2, R_3 );
    • 线程 Lane ID 为 0∼30 \sim 3 负责前 4 行的切片,依次交错排布。
  2. 输入矩阵 BB 的碎片分布(Col-Major):
    • 矩阵 BB 大小为 16×8=12816 \times 8 = 128 个 FP16 元素;
    • 每个线程分配 128/32=4128 / 32 = 4 个 FP16 元素,刚好装入 2 个 32-bit 寄存器( R4,R5R_4, R_5 )。
  3. 输出矩阵 C/DC/D 的累加碎片:
    • 输出矩阵大小为 16×8=12816 \times 8 = 128 个单精度 float 元素;
    • 每个线程分配 4 个 FP32 元素,保存在 4 个专用累加寄存器中。
  4. 结论:32 个线程的寄存器通过片上专属的张量脉动网络互联,指令发射后无缝交织完成计算并原地写回。