Tensor Core 与新指令集:从 WMMA 到 wgmma、TMA、tcgen05
面试题和附录里提到的“用 WMMA 改写 C4”“读 CuTe 的 MMA atom / TiledMMA”“读 FlashAttention-3/4 和 DeepGEMM 的主循环”,背后是同一条主线:每一代 GPU 都把更多的活从线程手里拿走,交给专用硬件。这一页按 Volta → Ampere → Hopper → Blackwell 的顺序讲清楚每一代新增了什么指令、解决了什么问题、代码里怎么用,并把每一步都链接到 cuda-samples、CUTLASS/CuTe、FA3、FA4 和 DeepGEMM 里对应的源码行。
前置:C4 讲解(三级复用、外积)、T4 讲解(tl.dot、num_stages)、CuTe 专题(Layout、TV layout)。本仓库实测用的 RTX 5090 是 sm_120(消费级 Blackwell),它有 Tensor Core 和 warp 级的 mma 指令,但没有 Hopper 的 wgmma,也没有数据中心 Blackwell(sm_100)的 tcgen05 和 Tensor Memory。所以第 ④⑤ 节的代码要在 H100 / B200 上才能运行,这也是面试题里写“有 H100 / B200 时”的原因。
① 先看全貌:每一代都在把活从线程手里拿走
为什么要专门的矩阵指令:C4 的 v2 里,一个线程每条 FMA 指令做 1 次乘加。Ampere 的 mma.sync.m16n8k16 是一个 warp 一起发的一条指令,一次做 16 × 8 × 16 = 2048 次乘加。指令数少了两个数量级,取指、调度、寄存器读写的开销都随之摊薄,这就是 Tensor Core 快的根本原因。本仓库的实测:RTX 5090 上 FP32 CUDA core 的 cuBLAS 是 65 TFLOP/s(C4),FP16 Tensor Core 的 Triton GEMM 是 218 TFLOP/s(T4)。
但 Tensor Core 算得越快,喂数据就越成问题。每一代新增的指令,几乎都是为了让数据跟上:
| 谁发起矩阵乘 | 操作数从哪来 | 累加器放哪 | 谁搬数据 | 同步方式 | |
|---|---|---|---|---|---|
| Volta WMMA | 一个 warp | 寄存器(fragment) | 寄存器 | 线程 ld / st | __syncthreads |
| Ampere mma.sync | 一个 warp | 寄存器(ldmatrix 从 smem 装进来) |
寄存器 | cp.async(异步) |
cp.async.wait_group |
| Hopper wgmma | 一个 warpgroup(4 个 warp) | shared memory(描述符),A 也可以在寄存器 | 寄存器 | TMA(一个线程发起整块) | mbarrier(按字节数完成) |
| Blackwell tcgen05 | 一个线程 | shared memory 或 Tensor Memory | Tensor Memory | TMA(可跨两个 CTA) | mbarrier |
最早是每个工人一块块搬砖、一块块砌(CUDA core 的 FMA)。Volta 给每个小组发了一台小型砌砖机(WMMA),砌得快多了,但砖还得人搬。Ampere 加了传送带(cp.async),砖可以自己从仓库送到工地旁边,工人不用来回跑。Hopper 换成了塔吊(TMA):一个人按一下按钮,一整托盘砖就吊到指定位置;砌砖机也变大了,四个小组合用一台(wgmma)。Blackwell 的砌砖机自带记录板(Tensor Memory),工人连“砌到哪了”都不用记在自己脑子里(寄存器),只要一个人按启动键就行(单线程发 MMA)。工地上的人越来越少地动手,越来越多地在“调度”。
② 第一步:WMMA,用 Tensor Core 改写 C4
nvcuda::wmma(#include <mma.h>)是 CUDA 9 起的 C++ 接口,最容易上手。它的核心是 fragment:一个 warp 共同持有的一小块矩阵,具体每个线程拿哪几个元素是不公开的(opaque)。
cuda-samples 里最简单的版本 cudasamplessimple_wmma_gemm,核心就是这几行:
wmma::fragment<wmma::matrix_a, 16, 16, 16, half, wmma::row_major> a_frag;
wmma::fragment<wmma::matrix_b, 16, 16, 16, half, wmma::col_major> b_frag;
wmma::fragment<wmma::accumulator, 16, 16, 16, float> acc_frag;
wmma::fill_fragment(acc_frag, 0.0f);
for (int k = 0; k < K; k += 16) {
wmma::load_matrix_sync(a_frag, a + k + aRow * lda, lda);
wmma::load_matrix_sync(b_frag, b + k + bCol * ldb, ldb);
wmma::mma_sync(acc_frag, a_frag, b_frag, acc_frag);
}
wmma::store_matrix_sync(c + cCol + cRow * ldc, acc_frag, ldc, wmma::mem_row_major);
改写 C4 的路线(面试题里的第一步):
- 先照抄上面这个版本:直接从全局内存 load fragment,没有 shared memory。它能工作,但每个 fragment 都从显存读,复用很差。
- 把 C4 v1 的 shared memory tiling 加回来:一个 block 把 A、B 的 tile 读进 shared memory,多个 warp 各自从 shared memory load fragment。cuda-samples 的完整版 cudasamplescompute_gemm 就是这样,cudasamples每个 warp 持有多个 a、b fragment,相当于 C4 v2 的“寄存器 tile”。
- 和 cuBLAS 的 FP16 GEMM 对比。参考上限:本仓库 T4 在 5090 上实测 cuBLAS FP16 约 218 TFLOP/s。
WMMA 的局限:fragment 的寄存器布局不公开,所以你没法自己决定每个线程持有哪些元素,也就没法和 softmax、量化之类的计算精细融合;shared memory 到 fragment 的加载方式也是固定的。这正是下一代写法(直接用 mma.sync PTX,或者用 CuTe)要解决的问题。文档:Programming Guide · WMMA。
③ Ampere:mma.sync、ldmatrix、cp.async,以及 CuTe 的 MMA Atom
三条新指令
| 指令 | 做什么 | CuTe 里的封装 |
|---|---|---|
mma.sync.aligned.m16n8k16 |
一个 warp 做 16×8×16 的矩阵乘,每个线程持有的元素有公开的布局 | cutlassSM80_16x8x16_F32F16F16F32_TN |
ldmatrix(sm_75 起) |
一个 warp 从 shared memory 读 8×8 的小块,直接放成 mma 要的寄存器布局 | cutlassSM75_U32x4_LDSM_N |
cp.async(sm_80 起) |
全局内存 → shared memory 的异步拷贝,不经过寄存器 | cutlassSM80_CP_ASYNC_CACHEALWAYS,配合 cutlasscp_async_fence / cp_async_wait |
MMA Atom:把一条指令的布局写成 layout
mma.sync 的难点在于:32 个线程各自持有 A、B、C 的哪几个元素,是硬件规定好的。CuTe 用TV layout((线程号, 值号) → (行, 列))把这个规定写下来:cutlassMMA_Traits SM80_16x8x16 里的 ALayout、BLayout、CLayout,例如 C 的布局是 cutlassSM80_16x8_Row。这就是 CuTe 专题 第 ⑦ 节说的“硬件也用 Layout 说话”,也是 G1 里你写的“lane 持有哪几个元素”的计算。
TiledMMA:用小印章拼大印章
一条 mma 只算 16×8 的 C。make_tiled_mma 把这个 Atom 在多个 warp 上、在 M / N 方向上重复,拼成一个大的 tile:cutlassmake_tiled_mma(SM80_16x8x16_F16F16F16F16_TN, …)。之后的代码只调用 gemm(mma, A, B, C)(cutlass298 行),每个线程该算哪些、该持有哪些,全部由 layout 推出来。同一份 kernel 换成 cutlassUniversalFMA(普通 FMA)也能跑,只是换了一个 Atom。
一条 mma 指令像一枚 16×8 的小印章,盖一下出一小块图案,而且印章上每个格子对应哪个工人是出厂就定好的(TV layout)。TiledMMA 是把很多枚小印章按规则拼成一块大印版,一次盖出一整个 tile。你只需要说“用这种印章、横着拼几个、竖着拼几个”,每个工人负责哪个格子由 CuTe 自动推出来。换一代硬件,就是换一种印章,拼版的代码不用改。
多级流水线
cutlassK_PIPE_MAX 个 stage:先发出前几个 stage 的 cp.async,主循环里 cutlass等最老的一个 stage 到齐(cp_async_wait<K_PIPE_MAX−2>),算它,同时发出下一个 stage 的拷贝。
示意图,不是实测时间。有了异步拷贝,第 k 块的计算和第 k+1、k+2 块的搬运同时进行,总时间从“搬 + 算”的和变成接近两者中较大的那个。
④ Hopper(sm_90a):TMA、wgmma、mbarrier 与 warp specialization
TMA:一个线程搬一整个 tile
Ampere 的 cp.async 仍然是每个线程算自己那几个元素的地址、各发各的拷贝。Hopper 的 TMA(Tensor Memory Accelerator)是一个专用的拷贝引擎:
- host 端先建一个 tensor map(描述整个张量的形状、stride、tile 大小、swizzle 方式):cutlassmake_tma_atom(SM90_TMA_LOAD, mA, sA, tile_shape)。
- kernel 里一个线程发一条
cp.async.bulk.tensor,整个 tile(比如 128×64)就被搬进 shared memory:cutlassSM90_TMA_LOAD_2D。地址计算、越界处理、swizzle 全部由硬件完成。 - 完成通知走 mbarrier,按字节数计:cutlassarrive_and_expect_tx(barrier, 字节数) 之后发 TMA,消费者 cutlasswait(barrier, phase),等到这么多字节都到了才继续。
- multicast:一个 cluster 里的几个 CTA 要同一块数据时,TMA 可以一次读、同时写进多个 CTA 的 shared memory:cutlassSM90_TMA_LOAD_MULTICAST_2D。cluster 是 Hopper 新加的一层:几个 CTA 保证同时运行在相邻的 SM 上,还能读写彼此的 shared memory(distributed shared memory)。
cp.async 像每个人自己去仓库取几件货。TMA 像下一张整车订单:一个人填好单子(tensor map + 坐标),仓库自动把一整托盘货送到你家门口的快递柜(shared memory)。mbarrier 是快递柜上的计数器:订单上写了“共 16 KB”,计数器数到 16 KB 才亮灯(phase 翻转),等着用货的人(消费者)看灯就知道能不能开柜。multicast 是一张订单同时送到楼里好几户的柜子。
wgmma:四个 warp 合用一台更大的机器,操作数直接从 shared memory 读
- warpgroup = 4 个连续的 warp(128 个线程)一起发一条
wgmma.mma_async,M 固定为 64,N 从 8 到 256,FP16 时 K = 16:cutlassMMA_64x8x16 … m64n8k16(一直到 m64n256k16)。 - 操作数来自 shared memory(通过一个 64 位的描述符,A 也可以放在寄存器里),不再需要 ldmatrix 把数据先搬进寄存器。
- 异步:发出之后线程可以继续做别的事,之后再等它完成:cutlasswgmma.fence / commit_group / wait_group,在 CuTe 里是 cutlasswarpgroup_arrive → gemm → warpgroup_commit_batch → warpgroup_wait。TiledMMA 的构造和 Ampere 一样:cutlassmake_tiled_mma(SM90_64x64x16_F16F16F16_SS)。
warp specialization:分工,而不是每个人什么都干
有了异步的 TMA 和异步的 wgmma,最自然的写法是分工:
- setmaxnreg:生产者几乎不需要寄存器(只是发 TMA),消费者需要大量寄存器存累加器。Hopper 允许 warpgroup 在运行中交换寄存器配额:FA3 里生产者 fawarpgroup_reg_dealloc、消费者 fawarpgroup_reg_alloc;两个消费者 warpgroup、用 TMA 读 K/V 时,生产者降到每线程 24 个寄存器,消费者升到 240 个,其他配置的数字在 faLoadRegisterRequirement / MmaRegisterRequirement。文档:PTX · setmaxnreg。
以前后厨每个厨师都要自己去冷库拿菜、自己切、自己炒(每个线程都搬数据、都算)。现在分成采购员和厨师:采购员(生产者 warpgroup)只负责把菜从冷库送到备菜台(TMA → shared memory),自己两手空空,不需要灶台(寄存器很少);厨师(消费者)守着灶台只炒菜,灶台越大越好(寄存器很多)。备菜台上每个格子有两盏灯:“菜到了”(full)和“盘子空了”(empty),采购员和厨师看灯干活,不用互相喊话。setmaxnreg 就是把采购员用不上的灶台挪给厨师。
读 FlashAttention-3:生产者 + 两个消费者的乒乓
FA3 的 forward kernel 把上面这些全部用上:
- 角色:fawarp_group_idx == 0 是生产者(发 Q、K、V 的 TMA),fa其余是消费者(做 QKᵀ、softmax、PV);消费者的个数是 faNumMmaWarpGroups,1–3 个。
- 主循环:生产者的 faload() 和消费者的 famma()。
- 乒乓调度:softmax 用的是普通 ALU(exp、max、sum),GEMM 用的是 Tensor Core,两者可以同时进行。两个消费者 warpgroup 用 fawarp_scheduler_barrier_sync / arrive 轮流使用 Tensor Core:一个在做 GEMM 时,另一个在做 softmax。另外还有 warpgroup 内部的重叠(faIntraWGOverlap):发出下一块的 QKᵀ 之后,再做上一块的 softmax。
Tensor Core(绿色)几乎一直有活干:总有一个 warpgroup 在做 GEMM。softmax(紫色)的指数运算吞吐比 GEMM 低得多,如果不和 GEMM 重叠,它会成为瓶颈。
读 DeepGEMM(SM90):FP8 GEMM 的精度补偿
DeepGEMM 的 SM90 FP8 GEMM 结构完全一样:deepgemmkNumTMAThreads = 128(一个 warpgroup)+ 若干 math warpgroup,deepgemmTMA 侧 reg_dealloc、deepgemmmath 侧 reg_alloc,deepgemmfull / empty barrier。
它多做了一件事,和 M3 直接相关:每个 K 块做完 deepgemmFP8 的 wgmma 之后,把结果乘上这一块的 scale,deepgemm加到 FP32 的 final_accum 里(在 CUDA core 上做)。这就是 M3 里 acc += tl.dot(a, b) * (sa * sb) 的硬件级版本,同时也补偿了 Hopper FP8 Tensor Core 内部累加精度有限的问题。
⑤ Blackwell(sm_100a):tcgen05、Tensor Memory 与 2-CTA MMA
tcgen05.mma:一个线程发起,累加器放在 Tensor Memory
| Hopper wgmma | Blackwell tcgen05.mma | |
|---|---|---|
| 谁发起 | 一个 warpgroup(128 线程)一起发 | 一个线程发 |
| 累加器在哪 | 128 个线程的寄存器里 | Tensor Memory(TMEM):每个 SM 一块专用的 256 KB(128 行 × 512 列 × 32 位) |
| 读结果 | 直接在寄存器里 | 用 tcgen05.ld 从 TMEM 读到寄存器 |
| 跨 CTA | 不支持 | cta_group::2:两个 CTA(一对 SM)合作做一条更大的 MMA |
| 低精度 | FP8 | FP8 / FP6 / FP4,硬件 block scaling(MXFP8、MXFP4、NVFP4) |
源码:cutlassSM100_MMA_F16BF16_SS → tcgen05.mma.cta_group::1.kind::f16;TMEM 的分配 cutlasstcgen05.alloc;2-CTA 版本 cutlassSM100_MMA_F16BF16_2x1SM_SS → cta_group::2;block-scaled 版本 cutlassSM100_MMA_MXF8F6F4_SS → kind::mxf8f6f4.block_scale。
CuTe 的 Blackwell 教程按难度排好了:cutlass01:tcgen05.mma + tcgen05.ld(cutlass分配 TMEM、cutlassgemm 之后 umma_arrive 通知、cutlassmake_tmem_copy 把结果读回寄存器)→ 02:加 TMA → 03:TMA multicast → cutlass04:2-CTA 的 MMA(cutlassSM100_TMA_2SM_LOAD_MULTICAST)→ 05:TMA epilogue。
Hopper 上,每次大合唱(wgmma)的结果要分到 128 个歌手各自的小本子上记着(寄存器),歌手们因此没空干别的。Blackwell 在舞台边上放了一块公共大黑板(Tensor Memory),合唱结果直接写在黑板上,指挥(一个线程)喊一声开始就行。歌手们的小本子空出来了,可以去做和声以外的事(softmax、反量化)。2-CTA MMA 则是两个合唱团并排站,合起来唱一首更大的曲子,共用一块更大的黑板。
读 FlashAttention-4:用 CuTe DSL 写成,角色分得更细
FA4(faflash_fwd_sm100.py,用 Python 的 CuTe DSL 写)的角色分配一目了然:fasoftmax0、softmax1 各 4 个 warp,correction 4 个 warp,MMA 1 个 warp,epilogue 1 个 warp,load 1 个 warp。
- S 和 O 都在 TMEM 里:fatmem_s_offset、tmem_o_offset,QKᵀ 的结果 S 和输出累加器 O 各占 TMEM 的一段,两个 Q tile(softmax0 和 softmax1)乒乓。
- correction warp:online softmax 里“max 变了就要把 O 乘一个系数”(T5 讲过的 alpha)由专门的 warp 去做,不挡 softmax 和 MMA 的路。
- exp2 软件模拟:Blackwell 的 Tensor Core 太快了,softmax 里的指数运算(硬件的 MUFU 单元)反而成了瓶颈,FA4 用多项式在 FMA 单元上模拟一部分 exp2:faex2_emu 的配置。这正是第 ① 节那条主线的延续:Tensor Core 越快,非矩阵的部分越要想办法挤。
- 寄存器配额同样在运行中调整:fasetmaxregister_decrease。
读 DeepGEMM(SM100):一个 warp 一个角色
deepgemmwarp 0:TMA load,deepgemmwarp 1:MMA issue(只在 leader CTA 上),deepgemm其余 warp group:epilogue。TMEM 分配按是否 2-CTA 选择 deepgemmAllocator1Sm 或 Allocator2Sm,MMA 的形状见 deepgemmUMMA_M / UMMA_N / UMMA_K。和 SM90 版本相比,FP8 的 scale 由 Tensor Core 的 block scaling 在硬件里乘,不再需要 CUDA core 上的 promotion。
⑥ 哪块 GPU 能跑哪些
| GPU | 架构 | 能用的矩阵指令 | 拷贝 | 本页哪几节能动手 |
|---|---|---|---|---|
| A100 | sm_80 | WMMA、mma.sync | cp.async | ②③ |
| RTX 4090 / L40S | sm_89 | 同上 + FP8 mma | cp.async | ②③ |
| H100 / H200 | sm_90a | + wgmma | + TMA、cluster | ②③④(FA3、DeepGEMM SM90) |
| B200 / GB200 | sm_100a | + tcgen05、TMEM、2-CTA | TMA(含 2-SM) | ②③⑤(FA4、DeepGEMM SM100) |
| RTX 5090(本仓库) | sm_120 | WMMA、mma.sync(含 FP8 / FP4) | cp.async 等 | ②③;没有 wgmma 和 tcgen05 |
sm_90a、sm_100a 里的 a 表示“架构专用特性”:编译出来的代码只能在这一代上运行,不向后兼容。这也是 FlashAttention、DeepGEMM 为每一代 GPU 单独写一套主循环的原因。
⑦ 推荐的阅读和动手路线
- 在 5090 上就能做:用 WMMA 改写 C4(第 ②节),然后读 cutlasssgemm_sm80 和 cutlass0t_mma_atom.md,把 MMA Atom 的 TV layout 和 G1 / G2 对照起来。
- 有 H100 时:跑 cutlasswgmma_tma_sm90.cu,再读 FA3 的 fa生产者 / 消费者分支 和 DeepGEMM SM90 的主循环。
- 有 B200 时:按顺序跑 cutlassblackwell/01–05,再读 FA4 和 DeepGEMM SM100。
自测 1:一条 mma.sync m16n8k16 和一条 FFMA 指令,各做多少次乘加?
mma.sync:一个 warp 一条指令,16 × 8 × 16 = 2048 次乘加。FFMA:每个线程 1 次,一个 warp 一条指令 32 次。前者是后者的 64 倍。
自测 2:为什么 Hopper 的 kernel 要把一个 warpgroup 专门用来发 TMA,而不是让所有线程都一起搬一点、算一点?
TMA 只需要一个线程发起,搬运本身由硬件完成,所以“搬数据”几乎不消耗线程的计算和寄存器。让一组线程专门发 TMA、并把寄存器让给计算的 warpgroup(setmaxnreg),计算的 warpgroup 就能用更多寄存器存更大的累加器 tile;两边通过 full / empty barrier 异步配合,搬运和计算完全重叠。所有线程都既搬又算,就要在两件事之间来回切换,寄存器也要按两者中较大的需求分配。
自测 3:Blackwell 把累加器放进 Tensor Memory,最大的好处是什么?
累加器不再占用寄存器,发 MMA 也只需要一个线程,大量寄存器和线程可以去做 softmax、反量化、epilogue 等非矩阵工作。FA4 的 correction warp、exp2 软件模拟,都依赖这些空出来的资源。
源码与资料索引
本仓库:C4 讲解 · T4 讲解 · T5 讲解 · M3 讲解 · CuTe 专题 · G1 讲解
WMMA:cudasamplessimple_wmma_gemm · cudasamplescompute_gemm · Programming Guide · WMMA
CuTe(Ampere):cutlassSM80_16x8x16 · cutlassMMA_Traits · cutlassldmatrix · cutlasscp.async · cutlasssgemm_sm80 · cutlass0t_mma_atom.md
Hopper:cutlassTMA · cutlasswgmma · cutlasswgmma_tma_sm90.cu · faFA3 kernel · faFA3 主循环 · deepgemmDeepGEMM SM90 · flashinferFlashInfer 的 Hopper prefill(同样的生产者 / 消费者)
Blackwell:cutlasstcgen05.mma · cutlassTMEM 分配 · cutlassblackwell/01–05 · faFA4 · deepgemmDeepGEMM SM100
PTX 文档:mma(m16n8k16 的寄存器布局) · ldmatrix · cp.async · cp.async.bulk.tensor(TMA) · mbarrier · wgmma · setmaxnreg · tcgen05.mma · Tensor Memory 分配 · CTA pair · block scaling
CUDA Programming Guide:TMA · Thread Block Clusters