DeepGEMM 完全拆解:300 行 FP8 GEMM 内核,如何逼近 Hopper 的算力峰值

9380 字
47 分钟
DeepGEMM 完全拆解:300 行 FP8 GEMM 内核,如何逼近 Hopper 的算力峰值

背景:FP8 算力翻倍,但 per-tensor 量化扛不住离群值#

2025 年 2 月底的 DeepSeek 开源周,官方连续放出了 FlashMLA、DeepEP 和今天的主角 DeepGEMM(2025 年 2 月 26 日,开源周第三天发布,作者 Chenggang Zhao、Liang Zhao、Jiashi Li、Zhean Xu)。仓库自述只有一句话:“clean and efficient FP8 GEMM kernels with fine-grained scaling”——干净、高效的 FP8 矩阵乘内核,带细粒度缩放。这个库支撑了 DeepSeek-V3/R1 训练与推理中的绝大部分线性层计算,在 H800 上达到了 1350+ FP8 TFLOPS(后续更新到 1550 TFLOPS),对比内部精心调优的 CUTLASS 3.6 实现最高快 2.7 倍。而这一切的核心 kernel 只有约 300 行代码

要理解 DeepGEMM 为什么存在,先要理解 FP8 GEMM 的处境。

FP8 的诱惑:一倍算力,一半显存#

FP8 是 8 位浮点格式,分为两种子格式:E4M3(4 位指数 + 3 位尾数,最大可表示值 448)和 E5M2(5 位指数 + 2 位尾数,动态范围更大但精度更低)。在《浮点数与数值稳定性》那篇里我们讲过 IEEE 754 的位布局与舍入机制,FP8 只是把同样的结构压缩到 8 位。这里只说结论:在 Hopper 架构上,FP8 的张量核算力是 BF16 的两倍——H800 SXM5 的 FP8 稠密峰值约 1979 TFLOPS(稀疏约 3959),而 BF16 只有约 989 TFLOPS。对推理来说,把权重和激活换成 FP8,等于免费拿到一倍的 GEMM 算力,还能让权重显存占用减半、访存量减半。

所以”能不能用 FP8”从来不是性能问题,而是精度问题

离群值如何摧毁 per-tensor 量化#

FP8 的精度问题来自它极窄的动态范围。传统做法是 per-tensor 量化:整张矩阵共用一个缩放因子,把矩阵的最大绝对值映射到 FP8 的可表示上限(E4M3 为 448):

s=max(x)448,xq=round(x/s)s = \frac{\max(|x|)}{448}, \qquad x_q = \text{round}(x / s)

这个公式对分布均匀的矩阵没问题,但 LLM 的激活和权重中总存在少量离群值(outlier)——个别元素比绝大多数元素大几个数量级。举个直观的例子:假设一个 128 元素的块里,127 个元素都在 [0.1,0.1][-0.1, 0.1] 区间,只有一个元素是 40。per-tensor 方案下 s=40/4480.089s = 40/448 \approx 0.089,其余 127 个正常元素量化后的量级只有 0.1/0.0891.10.1/0.089 \approx 1.1——也就是在 E4M3 的可表示范围内只用了最底部的两个刻度。E4M3 在接近 0 的区域(非规格化数附近)精度最差,这些正常值几乎全部被压成相同的粗粒度量化值,误差被离群值”传染”了整个块。

DeepSeek-V3 技术报告(arXiv:2412.19437)提出的解法是细粒度量化(fine-grained quantization):不再整张矩阵共用一个缩放因子,而是把矩阵切成小块,每块独立计算自己的缩放因子。具体规则是:

  • 激活(A):按 1×1281 \times 128 的 tile 分组——即每个 token、每 128 个通道一组;
  • 权重(B):按 128×128128 \times 128 的 block 分组——即每 128 个输入通道 × 每 128 个输出通道一组。

每组有自己的缩放因子 sAs_A(激活)和 sWs_W(权重),离群值的影响被限制在它所在的小组内,不会污染整张矩阵。

为什么激活和权重的分组形状不一样?论文附录 B.2 记录了实验:把激活也按 128×128128\times128 的方块分组会导致训练不稳定,而按行切分的 1×1281\times128(每组的形状是”一个 token 的连续 128 通道”)在实验中是稳定的。一个合理的解释是:权重是静态矩阵,按方块分组没有副作用;而激活是动态变化的,方块分组意味着同一 token 的不同 K 区段用不同缩放,等价于对特征维度做了分段增益调整,干扰了训练动力学。

细粒度量化还让一个此前不现实的选择变得可行:全统一采用 E4M3。E4M3 动态范围小(最大 448,最小非规格化数 292^{-9} 量级),FP8 训练时代的主流做法是前向用 E4M3、反向用 E5M2(范围大但精度低)。DeepSeek-V3 发现,缩放因子按小组切分后,指数位实际上由组内元素”共享”——每组只需要覆盖本组元素的范围,E4M3 的窄动态范围不再致命,于是全部张量统一 E4M3,精度更高、实现更简单。缩放因子在线计算:量化时实时求组内 max(x)\max(|x|),不依赖任何历史统计。

这套方案后来被 NVIDIA 官方化——Blackwell 第五代张量核的 MXFP8 微缩放指令集原生支持这种细粒度缩放(只是要求缩放因子是 2 的幂的 UE8M0 格式),而 DeepSeek 在 Hopper 上的实现正是先行者。

细粒度缩放在 GEMM 层面引爆的两个问题#

细粒度量化在算法上很漂亮,但落到 GEMM kernel 上,立刻撞上两道墙——这两道墙定义了 DeepGEMM 的全部设计空间:

  1. 缩放必须逐块乘,张量核做不到。反量化时,每个输出元素 dijd_{ij} 需要乘上它对应激活块和权重块的缩放因子。K 方向每 128 个元素换一组缩放,这个”每 K 块变一次”的缩放无法用张量核的矩阵乘指令表达。DeepSeek-V3 论文原话是:“along the inner dimension of GEMM operations… This functionality is not directly supported in the standard FP8 GEMM”。标准 FP8 GEMM(CUDA 库和 CUTLASS 提供的)只支持 per-tensor 或 per-row/per-column 缩放,per-K-block 缩放需要自己写 kernel。
  2. 张量核的 FP32 累加并不精确。DeepSeek-V3 论文实测发现,H800 上 FP8 GEMM 的张量核累加精度”仅保留约 14 位”(“the accumulation precision of FP8 GEMM on NVIDIA H800 GPUs is limited to retaining around 14 bits”),远低于完整 FP32 累加的精度。K 维度动辄 7168(DeepSeek-V3 的 hidden size)甚至 16384,截断误差会在长累加链里不断累积,最终反量化结果偏离真实值。

顺带一提,乘法本身不是误差来源:两个 E4M3 的乘积最多需要 8 位有效数字(4 位 + 4 位),FP32 的 24 位尾数可以精确容纳,所以 FP8 GEMM 的误差几乎全部来自累加环节。这解释了为什么所有 FP8 方案都把”高精度累加”作为头等大事。

DeepGEMM 的全部设计,就是围绕这两条约束展开的。

核心设计:一个 kernel 函数,三种 GEMM 场景#

DeepGEMM 的接口极简,只有三个入口,对应三种计算场景(约定符号:计算 D=C+A@BTD = C + A @ B^T,即 A 行主、B 列主,简称 NT 布局):

接口场景
gemm_fp8_fp8_bf16_nt稠密模型的普通 GEMM
m_grouped_gemm_fp8_fp8_bf16_nt_contiguousMoE 的连续布局分组 GEMM(prefill 与训练前向)
m_grouped_gemm_fp8_fp8_bf16_nt_maskedMoE 的掩码布局分组 GEMM(decode + CUDA Graph)

为什么只做 NT 布局#

因为线性层在推理中就是 Y=XWY = X W^\top 的形态:激活 XX(M,K)(M, K) 行主存储,权重 WW 在显存里按 (N,K)(N, K) 列主存(等价于 WW^\top(K,N)(K, N) 行主)。把权重视为”转置的 RHS”直接命中 NT 布局,不需要任何额外搬移。DeepSeek-V3 的 attention 投影、MoE 的 gate/up/down 投影全是这个形态。至于转置、FP8 转换这些杂活,DeepGEMM 的态度很明确:不提供,请在前一个 kernel 里融合掉——“our primary focus is on optimizing the GEMM kernels themselves”(我们只专注优化 GEMM kernel 本身)。它甚至要求激活的缩放因子以TMA 对齐且转置(列主)的布局给出:1×1281\times128 的激活缩放形状是 (M,K/128)(M, K/128),每 token 一行;转置成 (K/128,M)(K/128, M) 列主后,一个 M 块的一列恰好是 BLOCK_MBLOCK\_M 个连续 FP32,TMA 引擎可以一次搬进共享内存。

分组 GEMM 的两种布局:MoE 推理的两个阶段#

分组 GEMM 的两种布局对应 MoE 推理的两个阶段,值得展开:

  • 连续布局(contiguous):MoE 里每个专家处理不同数量的 token。prefill 阶段 token 数量已知,把去往同一个专家的 token 在 M 维上拼接成一个连续段,整个段作为一个大 GEMM 计算。与 CUTLASS 的传统分组 GEMM 不同,DeepGEMM 的组只沿 M 轴切分,N 和 K 在所有组间固定——正好对应同层专家形状相同这一事实。每个专家段的 M 需要按 GEMM 的 M 块大小对齐(有专门的 get_mk_alignment_for_contiguous_layout() 查询接口)。
  • 掩码布局(masked):decode 阶段开启 CUDA Graph 后,CPU 不再知道每个专家将收到多少 token(token 数是运行期动态决定的)。这时显存里的 token 排布是”碎片化”的,无法拼成连续段。DeepGEMM 用一个 mask 张量描述每个位置是否有效,kernel 只计算有效部分。DeepEP(DeepSeek 的 MoE 通信库)的低延迟 kernel 输出正是这种布局,二者在 V3/R1 的解码链路里是配对的——DeepEP 做 all-to-all 通信,DeepGEMM 吃它的输出做专家计算。

全 JIT:安装零编译#

另一个贯穿全局的设计是全 JIT(Just-In-Time):安装时零编译,所有 kernel 在运行时由内置的轻量 JIT 模块用 NVCC 现场编译。形状、块大小、流水线级数全部作为模板参数写死在编译期。这一点后面单独开一节讲,它是 DeepGEMM 性能的另一半来源。

细粒度缩放与两级累加:DeepGEMM 的数学内核#

先看 DeepSeek-V3 论文的 Figure 7,它把细粒度缩放和累加提升画在了一张图里:

DeepSeek-V3 论文 Figure 7:(a) 1×128 激活与 128×128 权重的细粒度量化;(b) 每 N_c=128 个元素将部分和提升到 CUDA 核做高精度累加(图片来源:DeepSeek-V3 Technical Report,arXiv:2412.19437)
DeepSeek-V3 论文 Figure 7:(a) 1×128 激活与 128×128 权重的细粒度量化;(b) 每 N_c=128 个元素将部分和提升到 CUDA 核做高精度累加(图片来源:DeepSeek-V3 Technical Report,arXiv:2412.19437)

图 (a) 展示了量化规则:激活 xx 的每一行(一个 token)被切成 128 通道一组,每组按自己的 max(x)\max(|x|) 算出缩放 sAs_A;权重 ww 则切成 128×128128\times128 的方块,每个方块有自己的 sWs_W。组内元素除以各自的缩放后量化到 E4M3。图 (b) 是配套的累加策略:张量核每算完 128 个 K(Nc=128N_c=128,恰好 4 条 WGMMA 指令),就把部分和”提升”(promote)到 CUDA 核,乘上缩放因子后加到高精度累加器里。这两张图合起来就是 DeepGEMM 的核心。

把数学写清楚。设激活 AqA_q、权重 BqB_q 是量化后的 E4M3 矩阵,量化过程为:

aq[i,k]=round ⁣(a[i,k]sA[i,k/128]),bq[j,k]=round ⁣(b[j,k]sW[j/128,k/128])a_q[i,k] = \text{round}\!\left(\frac{a[i,k]}{s_A[i, \lfloor k/128 \rfloor]}\right), \qquad b_q[j,k] = \text{round}\!\left(\frac{b[j,k]}{s_W[\lfloor j/128 \rfloor, \lfloor k/128 \rfloor]}\right)

其中 sARM×(K/128)s_A \in \mathbb{R}^{M \times (K/128)} 是激活缩放矩阵(每 token 每 128 通道一个值),sWR(N/128)×(K/128)s_W \in \mathbb{R}^{(N/128) \times (K/128)} 是权重缩放矩阵。真实结果 D=ABD = AB^\top 由量化矩阵的反量化恢复:

dij=k=0K1aq[i,k]bq[j,k]sA[i,k/128]sW[j/128,k/128]d_{ij} = \sum_{k=0}^{K-1} a_q[i,k]\, b_q[j,k] \, s_A[i, \lfloor k/128 \rfloor] \, s_W[\lfloor j/128 \rfloor, \lfloor k/128 \rfloor]

关键在于:sAsWs_A \cdot s_W 这个乘积随 K 块变化。把 K 按 128 分组重写求和:

dij=t=0K/1281sA[i,t]sW[j/128,t](k块 taq[i,k]bq[j,k])d_{ij} = \sum_{t=0}^{K/128 - 1} s_A[i,t]\, s_W[\lfloor j/128 \rfloor, t] \left( \sum_{k \in \text{块 } t} a_q[i,k]\, b_q[j,k] \right)

张量核只能计算括号里的原始乘积和(FP8 输入、FP32 累加),乘缩放因子必须发生在每个 K 块求和之后。这就解释了为什么不能”最后统一乘一次”:如果先在整个 K 上累加,每个 K 块贡献的缩放不同,事后无法再拆开。所以正确的流程是:张量核按 K 块(128)分别累加出部分和 Pij(t)P_{ij}^{(t)},然后立即在 CUDA 核上执行

final_accum[i][j]+=sA[i,t]sW[j/128,t]Pij(t)\text{final\_accum}[i][j] \mathrel{+}= s_A[i,t] \cdot s_W[\lfloor j/128 \rfloor, t] \cdot P_{ij}^{(t)}

清零张量核累加器,开始下一个 K 块。这个”张量核粗累加 + CUDA 核细累加”的两级方案(two-level accumulation),一次解决了三个问题:

  1. 功能性:per-block 缩放必须逐块乘,张量核做不了,CUDA 核的 FFMA 指令顺手就做了;
  2. 精度:H800 张量核的 FP32 累加只保留约 14 位精度(DeepSeek-V3 论文实测),如果 7168 个 K 全部在张量核里累加,截断误差会持续累积。每 128 个元素提升一次,等于把”长累加链”切成 56 段互不相干的短累加,每段误差独立且不传播,最终累加器始终由 CUDA 核的完整 IEEE FP32 加法维护。DeepSeek-V3 论文特别注明 Nc=128N_c=128 是”显著提升精度而又不带来明显开销的最小间隔”;
  3. 灵活性:缩放因子可以是任意值(不限于 2 的幂),这是 Blackwell 的 UE8M0 微缩放格式做不到的——UE8M0 要求缩放必须是 2 的幂。

顺便说一句,DeepSeek-V3 训练侧也是这个方案:论文里明确写了”以 Nc=128N_c=128 为间隔提升到 CUDA 核做高精度累加”。DeepGEMM 是这套数学在 kernel 层面的实现,训练推理一脉相承——这也是为什么它在开源周里被官方定位为”V3/R1 训练与推理共用的基础设施”。

Kernel 设计(一):持久化 Warp 特化与 300 行主循环#

DeepGEMM 的核心 kernel fp8_gemm_kernel 只有约 300 行,其组织方式可以用仓库自带的官方示意图概括:

DeepGEMM 官方示意图:持久化 warp 特化流水线。TMA 线程组负责把 A、B、缩放因子从全局内存搬进共享内存的多级流水,数学 warpgroup 交替执行 WGMMA 张量核指令与 CUDA 核提升(FFMA),结果经 TMA store 写回全局内存(图片来源:DeepGEMM GitHub 仓库 figures/design.png)
DeepGEMM 官方示意图:持久化 warp 特化流水线。TMA 线程组负责把 A、B、缩放因子从全局内存搬进共享内存的多级流水,数学 warpgroup 交替执行 WGMMA 张量核指令与 CUDA 核提升(FFMA),结果经 TMA store 写回全局内存(图片来源:DeepGEMM GitHub 仓库 figures/design.png)

图中可以看到三级流水:数据搬运(TMA)→ 张量核计算(WGMMA)→ CUDA 核提升(FFMA),三级由不同的线程负责、互相重叠,这正是 Hopper 上”warp 特化(warp specialization)“的标准打法——CUTLASS 3.x 的 Hopper GEMM 也是这个框架,DeepGEMM 沿用了它,但只保留了必需的部分。

线程组织:TMA 线程组与数学 warpgroup#

  • kernel 以 持久化(persistent) 方式启动:gridDim = num_sms,每个 SM 恰好一个 CTA,CTA 内部用一个块调度器循环领取 (M 块, N 块) 任务,而不是每个任务启动一次 kernel——省掉了任务间反复启动、反复建立流水线的开销。
  • 线程分成两类。一类是 TMA 线程组:固定 128 个线程(4 个 warp),只负责发 TMA 拷贝指令和等待屏障。为什么是恰好 4 个 warp?源码注释给出了直接原因:寄存器重新配置指令 setmaxnreg.aligned 要求以整个 warpgroup(4 个 warp)为单位操作。TMA 线程组通过 warpgroup_reg_dealloc<40> 把寄存器让渡到 40 个——它们只跑地址计算和屏障,40 个寄存器绰绰有余,省下的寄存器空间留给数学线程。
  • 另一类是数学 warpgroup:128 个线程一组,通过 warpgroup_reg_alloc<232> 独占 232 个寄存器,专职执行 WGMMA 和提升的 FFMA。当 BLOCK_M = 128 时数学部分有 2 个 warpgroup(256 线程),当 BLOCK_M = 64 时只有 1 个。232 个寄存器是 Hopper 上单线程的寄存器上限(每个 SM 64K 寄存器 / 256 线程),数学线程把几乎全部寄存器都用在了累加器阵列上。
  • 计算块参数:BLOCK_K 固定为 128——源码里有一句静态断言 “Only support per-128-channel FP8 scaling”,因为缩放粒度就是 128,K 块必须与之对齐;BLOCK_M 取 64 或 128;BLOCK_N 在 16 到 128 之间以 8 为步长搜索(选参规则见 JIT 一节)。每个 K 块由 4 条 WGMMA 指令完成(Hopper 的 FP8 WGMMA 是 m64×n×k32m64 \times n \times k32 形状,128/32=4128/32=4)。

共享内存布局:每一字节都有去处#

共享内存的分配完全由编译期常量决定(Hopper 每 SM 227 KB,即 232448 字节):

  • 输出缓冲 smem_dBLOCK_M×BLOCK_N×2BLOCK\_M \times BLOCK\_N \times 2 字节(BF16 结果暂存,128 字节 swizzle 对齐);
  • A 数据每流水线级:BLOCK_M×128BLOCK\_M \times 128 字节(E4M3,一字节一个元素);
  • A 缩放每级只有 BLOCK_M×4BLOCK\_M \times 4 字节——因为 1×1281\times128 缩放每行每 K 块只有一个 FP32,128 行的 A 块每级只需 512 字节,是 A 数据本身的 1/128。这正是细粒度缩放”压缩”带来的红利:缩放矩阵小到可以全程住在共享内存里;
  • B 数据每级:BLOCK_N×128BLOCK\_N \times 128 字节;
  • B 缩放整块常驻:K/128×4\lceil K/128 \rceil \times 4 字节(B 的 128×128128\times128 缩放沿 K 方向每块一个 FP32;BLOCK_N 不能整除 128 时翻倍);
  • 两级屏障数组:2×stages×82 \times stages \times 8 字节(每级一个 full barrier + 一个 empty barrier)。

A 和 B 的数据走 128B swizzle(TMA 的典型共享内存布局,配合银行冲突消除),缩放和屏障用朴素布局。

生产者-消费者同步:TMA 与数学线程的握手#

两级流水线的同步用 CUDA 的 named barrier 实现:每级流水有一个 full_barrier(数据就绪)和一个 empty_barrier(缓冲区空闲)。握手协议是:

  1. TMA 线程组等待 empty_barrier[s](第 s 级缓冲区已空),然后发出 3 条 TMA 拷贝(A、A 缩放、B),每条 TMA 的 arrive_and_expect_tx 告诉屏障”我承诺搬运的字节数”;
  2. 数学 warpgroup 等待 full_barrier[s](TMA 搬完,屏障自动计数归零),执行 WGMMA 和提升;
  3. 数学线程 arriveempty_barrier[s],通知 TMA 线程组”第 s 级用完了,可以覆盖”。

multicast 开启时(cluster 模式),屏障还带分布式语义:full_barrier 的初始计数按簇大小调整,数学线程用 arrive(lane_idx) 按簇内编号抵达,加上 fence_view_async_sharedfence_barrier_init 保证异步代理(TMA)与正常代理(数学线程)之间的可见性。这些细节是 warp 特化 kernel 最容易写错的地方,DeepGEMM 把它们收敛在 fp8_gemm.cuh 一个文件里。

主循环骨架#

数学 warpgroup 的主循环可以浓缩成下面的伪代码(与真实源码一一对应):

// 数学 warpgroup 的主循环(简化)
while (scheduler.get_next_block(m_block_idx, n_block_idx)) {
// 预取本块用到的 B 缩放因子到 smem(与上一个任务的 TMA store 重叠)
load_scales_b(smem_scales_b, n_block_idx);
for (int t = 0; t < K / (kNumStages * BLOCK_K); ++t) { // K 迭代
for (int s = 0; s < kNumStages; ++s) { // 流水线阶段,全展开
full_barriers[s]->wait(...); // 等 TMA 把第 s 级数据搬好
float scale_a_0 = ld_shared(smem_scales_a[s] + r_0); // 读 A 缩放(每行一个)
float scale_a_1 = ld_shared(smem_scales_a[s] + r_1);
float scale_b = ld_shared(smem_scales_b + t * kNumStages + s);
wgmma(smem_a[s], smem_b[s], accum); // 张量核:累加当前 K 块
wgmma_wait<0>();
// CUDA 核提升:乘缩放,加进最终累加器(每 K 块一次)
float scale_0 = scale_a_0 * scale_b;
float scale_1 = scale_a_1 * scale_b;
#pragma unroll
for (int i = 0; i < kNumAccum / 4; ++i) {
final_accum[i*4+0] += scale_0 * accum[i*4+0];
final_accum[i*4+1] += scale_0 * accum[i*4+1];
final_accum[i*4+2] += scale_1 * accum[i*4+2];
final_accum[i*4+3] += scale_1 * accum[i*4+3];
}
empty_barriers[s]->arrive(); // 通知 TMA 线程组:本级已用完
}
}
// 结果写回:FP32 → BF16 转换后经 stmatrix 写入 smem,再由 TMA store 落回全局内存
stmatrix(smem_d, final_accum);
tma_store_d(smem_d, m_block_idx, n_block_idx);
}

几个容易忽略、但决定性能的细节:

  • 提升与 MMA 的重叠。提升跑在 CUDA 核(FFMA 单元)上,WGMMA 跑在张量核上,二者是不同执行单元,天然可以并行。warp 特化的意义就在这里:当数学线程忙于 FFMA 提升时,张量核的下一条 WGMMA 已经排入队列;当张量核在算时,FFMA 流水线也没闲着。三级流水线互相隐藏延迟,是这套 kernel 逼近峰值的结构基础。
  • 非对齐 BLOCK_N 的双缩放问题。当 BLOCK_N 能整除 128 时(128、64、32、16),一个 B 块恰好落在权重的一个 128×128128\times128 缩放方块内,每个 K 块只需要一个 sWs_W(源码里的 kMustUseUniformedScaleB 常量)。但当 BLOCK_N 取 112、96、80 这类非整除值时,一个 B 块会横跨两个权重缩放方块,每个 K 块需要两个 sWs_W,提升时用 predicate 在两组缩放间选择——代码注释特别强调”用 predicate 而不是两个循环,对性能非常重要”(predicate 不引入分支跳转,GPU 的指令发射流水线不受惩罚)。
  • 非整除 K 的处理。K 不一定能整除 stages × 128,最后一轮 K 迭代可能只剩不足 stages 个有效级。launch_k_iterationsDivisibleK/NotDivisibleK 两个编译期分支区分:无效级不搬数据,只做 full_barrier->arrive() 空抵达,保证屏障计数正确。这个分支也是编译期常量,不影响展开质量。
  • stmatrix + TMA store 的两段式写回。累加器在寄存器里,不能直接交给 TMA。先由 stmatrix 指令(warp 级矩阵存储,一次写 32×2 个 BF16)把最终累加器从寄存器写进 smem_d,然后由单个线程发 TMA store 把 smem_d 写回全局内存。TMA store 是异步的,tma_store_wait<0> 保证在 SM 复用 smem_d 前完成。输出走了”寄存器→smem→全局”两跳,但两跳都是专用硬件路径,比逐线程全局写回(还要处理合并访问)快得多。

Kernel 设计(二):TMA、块调度器与”非对齐”的执念#

TMA 的全方位使用#

Hopper 引入的 TMA(Tensor Memory Accelerator)是异步数据搬移引擎:CPU 侧配好 CUtensorMap 描述符后,GPU 上一条指令就能把任意形状的 2D tile 从全局内存搬进共享内存,全程不占寄存器、不占计算线程。DeepGEMM 把 TMA 用到了极致:

  • TMA load:A、B、A 的缩放因子全部走 TMA 进 smem。注意缩放因子也走 TMA——因为缩放矩阵转置后每列连续,天然满足 TMA 的 2D tile 加载条件(A 缩放的 tile 形状是 BLOCK_M×1BLOCK\_M \times 1);
  • TMA store:输出 D 由 TMA 直接写回全局内存(省掉常规的寄存器→全局的写回路径);
  • TMA multicast:仅在 A 上开启。A 按 M 行切块,同一行 M 块会被 N 方向相邻的多个 CTA 共享;开启 cluster(线程块簇)后,一份 A tile 经 NVLink 广播到簇内所有 CTA 的共享内存,每个 CTA 省一次全局内存读取。B 没有这个待遇——B 按 N 切块,不同 CTA 之间不共享。multicast 的启用条件比较苛刻:M1024M \ge 1024、N 能被 BLOCK_N × 2 整除、SM 数能被 2 整除,cluster 尺寸取 2(源码注释明确警告:cluster ≥ 4 会造成性能回退,因为簇间同步和分布式屏障的开销超过了广播的收益);
  • TMA 描述符预取:kernel 启动第一件事就是 prefetch_tma_descriptor,把四个描述符(A、B、scales_a、D)提前载入缓存,避免首次 TMA 指令的冷启动延迟。

统一块调度器与 rasterization#

调度器(scheduler.cuh)是另一个被反复打磨的部件。CTA 内唯一的调度线程按 next_block_idx = (++iter) * gridDim.x + blockIdx.x 领取全局块编号——这是持久化 kernel 的标准做法,保证所有 CTA 以”波浪”方式扫过整个任务。真正有讲究的是块的遍历顺序:不是简单的 M 主序或 N 主序,而是栅格化(rasterization)——每 16 个 N 块组成一个小组,小组内 N 快扫,然后跳到下一个 M 块。这样安排后,同一时刻处于同一 N 带内的 CTA 会共享 A 的 L2 缓存行,显著提高 L2 命中率(CUTLASS 的 threadblock rasterization 文档讲的是同一件事,DeepGEMM 把它内联进了自己的调度器,少一层抽象)。

对掩码布局,调度器额外维护一个组间累计和(cumsum):get_next_block 时先跳过没有有效任务的组,只给有 token 的专家分配 M 块,且每个 M 块只覆盖实际有效的范围——解码时专家间负载严重不均衡(有的专家收 3 个 token,有的收 300 个),这个”动态跳过”是必须的。调度器在 current_iter 上做增量推进,还保证了 CUDA Graph 重放时行为完全确定。

非对齐块大小:把最后 20 个 SM 用起来#

这是 DeepGEMM 少有的”为了极端利用率而做”的设计,也是 README 里用 🐳 标出的独有优化之一。常规 kernel 的 BLOCK_N 必须是 2 的幂(128、64……),但有些形状用 2 的幂会浪费 SM。README 举的例子:M=256,N=7168M=256, N=7168 时,BLOCK_M=128、BLOCK_N=128 的常规分块产生 (256/128)×(7168/128)=112(256/128) \times (7168/128) = 112 个块,而 H800 有 132 个 SM——20 个 SM 完全闲着。把 BLOCK_N 放宽到 112 这种非对齐值后,块数变成 (256/128)×(7168/112)=128(256/128) \times (7168/112) = 128,全部 SM 都在干活。

这个优化和细粒度缩放叠加是很拧巴的:非对齐 BLOCK_N 会让 B 块横跨两个缩放方块(前面讲的双缩放问题),但 DeepGEMM 选择用 predicate 和第二条缩放行把它扛下来,换取 10% 量级的 SM 利用率提升。选参代码里 BLOCK_N 的候选集是 range(16, 129, 8),即 16、24、32、…、128——故意包含非 2 的幂,正是为了这种形状。

控制发射的 SM 数:省 L2、保频率#

调度器还有一处反直觉的设计:num_min_sms——即使任务能占满所有 SM,也故意少用一些。源码的注释写得很直白:“less L2 cache usage and less GPU frequency drop”(减少 L2 占用、避免 GPU 降频)。GPU 的 boost 频率取决于功耗与温度,SM 全开 + 高访存强度会把整卡推向功耗墙导致降频,降频对全 kernel 的伤害远大于闲置几个 SM;少用几个 SM、把任务波浪数控制住,反而能稳定在高频。具体规则是 num_min_sms = ceil(max(波浪所需SM数, num_sms - 8) / multicast) * multicast——留 8 个 SM 做功耗缓冲,同时保证 multicast 的簇对齐。这个细节在 README 里没有,是读源码才能发现的”隐藏调参”。

FFMA SASS 交错:改二进制比特的”歪门邪道”#

这是 DeepGEMM 最出名的野路子,值得单独讲讲。作者对比 NVCC 12.2 与 12.3 编译出的 CUTLASS FP8 kernel,发现 12.3 的性能提升来自 SASS 里一串 FADD 指令中某个比特被翻转——翻的是 yield 位,效果是让当前 warp 让出执行槽,给其他 warp 更多发射机会(作者自嘲说”just a guess”,这只是他们的推测)。顺着这个线索,他们写了一个脚本(interleave_ffma.py)在编译产物上直接改 FFMA 指令的二进制:除了翻 yield 位,还把 reuse 位也翻转——reuse 位表示操作数寄存器可以复用,warp 让出执行槽后复用语义失效,必须一并清掉,否则结果不正确。这套 SASS 修补让细粒度缩放的 FP8 GEMM 在某些形状上再提升 10%+,因为它创造了更多让提升 FFMA 与 WGMMA 重叠的机会。

这条技巧的谱系可以追溯到 2015 年 Scott Gray 的 MaxAs 项目——一个为 Maxwell 架构手写 SASS 汇编器的开源项目,在官方工具链之外直接操作指令二进制。DeepGEMM 只是把同样的思路用到了 FP8 时代:当编译器调度不完美时,直接改产物。随着 NVCC 12.9 开始自动做 FFMA 交错,这个补丁在 2025 年 7 月的重构中被移除——编译器追上了手工作业,这也解释了为什么 DeepGEMM 强烈建议使用 CUDA 12.8+:性能不仅取决于 kernel 代码,还取决于编译器版本。

全 JIT:为什么”运行时编译”反而更快#

安装 DeepGEMM 时不需要编译任何东西,所有 kernel 在第一次调用时由内置 JIT 模块现场编译,缓存到 ~/.deep_gemm/gemm.py 里的模板代码揭示了原理:Python 侧把形状和配置填进 C++ 模板字符串,NVCC 现场编译成 cubin:

constexpr auto N = 7168, K = 16384;
constexpr auto BLOCK_M = 128, BLOCK_N = 128;
constexpr auto kNumStages = 8;
constexpr auto kNumTMAMulticast = 2;
using GemmType = Gemm<N, K, BLOCK_M, BLOCK_N, 128, 1,
kNumStages, kNumTMAMulticast, GemmType::Normal>;

把 N、K、块大小、流水线级数全部变成编译期常量,换来三个好处:

  1. 省寄存器:所有地址计算在编译期折叠成立即数,不需要运行时寄存器保存循环变量和指针——数学线程的 232 个寄存器因此可以几乎全部留给累加器阵列;
  2. 全展开launch_k_iterations 把 K 循环和流水线阶段循环全部 #pragma unroll 展开——full_barriers 这些指针在展开后全部变成常量,NVCC 能消除掉一半的共享内存指针运算。源码注释强调:“unrolling and kNumInnerStages are vital for performance”;
  3. 确定性选参:编译期常量还意味着每个形状可以配一套专属参数。选参逻辑(get_best_configs)是确定性的、不需要 autotune 的启发式,可以概括为四条规则:
1. BLOCK_M:M ≤ 64 时取 64,否则取 128
2. BLOCK_N ∈ {16, 24, …, 128}:
优先最小化波浪数(块总数 / SM 数,向下取整)
波浪数相同时,最大化最后一个波浪的 SM 利用率
仍相同则偏好更小的 BLOCK_N(更细的并行粒度)
3. 流水线级数:从 8 往下试(非整除 BLOCK_N 时从 6 开始),
直到共享内存预算(232448 字节)装得下
4. multicast:M ≥ 1024 且整除性检查通过时开 2 路
最后按 num_min_sms 规则收缩实际使用的 SM 数

为什么”最小化波浪数”是首要目标?波浪是持久化 kernel 的调度单位:任务被切成 N_wave 波,每波填满所有 SM。如果任务有 3.2 个波浪,最后 0.2 波只有少数 SM 在干活,整体利用率被拖到 90% 以下。让块数和 SM 数尽量整除,比任何微优化都值钱。而”最大化最后波浪利用率”的细节是:波浪数相同时,选择让最后波浪的块数尽量多的 BLOCK_N——例如 M=64、N=2112 这种解码形状,BLOCK_N=128 只有 17 个块(132 个 SM 用 17 个),BLOCK_N=16 则有 132 个块恰好填满一整个波浪,启发式会选中后者。JIT 的”每个形状现场编译”让这套启发式可以自由选择任意 BLOCK_N 而不必担心通用性。

这套”编译期极致特化”的思路与 Triton 编译器异曲同工——README 自己也说 “similar to the approach of the Triton compiler”。它最大的受益者是小形状:M=64 的推理解码形状在运行时模板库里常常被通用路径拖累,而 DeepGEMM 的 JIT 特化正好把这类形状补上了——官方数据里提速最狠的 2.7× 恰恰出现在 M=64 上,这不是巧合。

JIT 模块本身也在演进:2025 年 5 月加入 NVRTC 路径(编译速度提升最高 10 倍,PR #94);2025 年 7 月的大重构(PR #112)把 JIT 从纯 Python 脚本改成低 CPU 开销的 C++ 模块,同时因为 NVCC 12.9 已自动做 FFMA 交错,移除了 SASS 后处理。环境变量 DG_JIT_USE_NVRTCDG_JIT_DUMP_SASS 等把编译过程完全暴露给用户——对学习者来说,这是观察”kernel 源码 → PTX → SASS”全链路的现成工具。

性能数据与生态落地#

官方在 H800 SXM5(NVCC 12.8)上测了 DeepSeek-V3/R1 推理会用到的全部形状,对照基线是内部精心调优的 CUTLASS 3.6 实现。稠密 GEMM 的完整结果:

MNK算力 (TFLOPS)带宽 (GB/s)加速比
642112716820616882.7×
6424576153628924551.7×
643276851221921431.8×
6471681638433626681.4×
644096716828723201.4×
647168204829524701.7×
1282112716835215092.4×
12824576153653524481.6×
1283276851235821031.5×
12871681638464526041.4×
1284096716853322212.0×
1287168204851022771.7×
40962112716810585271.1×
40962457615369907861.0×
40963276851259012321.0×
409671681638413583431.2×
40964096716813045001.1×
40967168204810256971.1×

分组 GEMM(连续布局):4 组 × M=8192、N=4096、K=7168 时 1297 TFLOPS(1.2×),8 组 × M=4096 时 1288 TFLOPS;掩码布局:M=1024、N=4096、K=7168 时 1233 TFLOPS(1.2×)。2025 年 4 月 18 日的更新(PR #74/#78/#81/#86)又把 H800 上的峰值推到了 1550 TFLOPS——1979 TFLOPS 硬件峰值的 78%,对带 per-block 缩放的 FP8 GEMM 来说这个利用率已经非常接近上限。

读这张表有几个要点:

  • 加速比与 M 成反比:M=64/128 的小批形状普遍 1.4–2.7×,M=4096 的 prefill 大形状只有 1.0–1.2×。原因前面讲过——小形状被 JIT 全展开、非对齐块、FFMA 交错这些”特化手段”救活,而大形状本身就能打满,CUTLASS 也不差。这恰恰说明 DeepGEMM 是为解码场景(小 M)量身定制的;
  • 看带宽列可以判断瓶颈:M=4096、N=7168、K=16384 时带宽只有 343 GB/s——计算完全打满,HBM 带宽富余,这是典型的 compute-bound;而 M=64 的几行带宽高达 1688–2668 GB/s(接近 H800 的 3.35 TB/s 峰值),说明小 M 形状是带宽敏感型,kernel 优化的重心在于把每个字节的利用率榨干(L2 复用 + TMA 效率 + 非对齐块填满 SM);
  • 带宽与算力的权衡:M=64、N=2112、K=7168 只跑到 206 TFLOPS,但它拿到了最高的 2.7× 加速比——DeepGEMM 的目标从来不是”每个形状都到峰值”,而是”每个形状都比 CUTLASS 快”。

社区也有独立复测。火山引擎开发者社区的《DeepSeek 开源周(三):DeepGEMM 性能实测及 NV 往事》在 H800 上对照官方结果跑了一轮,两张图分别是各形状的实测算力与官方数据对比:

火山引擎社区实测(一):DeepGEMM 各形状实测性能与官方 README 数据对比(图片来源:火山引擎开发者社区文章截图)
火山引擎社区实测(一):DeepGEMM 各形状实测性能与官方 README 数据对比(图片来源:火山引擎开发者社区文章截图)

火山引擎社区实测(二):DeepGEMM 各形状实测性能与官方 README 数据对比(图片来源:火山引擎开发者社区文章截图)
火山引擎社区实测(二):DeepGEMM 各形状实测性能与官方 README 数据对比(图片来源:火山引擎开发者社区文章截图)

作者实测大部分形状与官方结果接近,部分略低——文章归因于测试环境是驱动 535.161.08 + CUDA 12.6,而官方要求 CUDA 12.8 以上才有最佳性能(FFMA 交错等优化依赖新编译器的调度)。这也提醒读者:DeepGEMM 的性能数字对工具链版本很敏感,复现时要先对齐软件环境。

生态落地上,DeepGEMM 已经是 DeepSeek 系模型 FP8 推理的事实标准后端:

  • vLLM:2025 年 6 月 11 日合入 PR #19085(“Support deep_gemm for linear methods”)接入线性层,6 月 24 日 PR #19820 接入新版接口,7 月 11 日 PR #20087 由 NVIDIA 贡献了 SM100(Blackwell)支持;
  • SGLang:2025 年 6 月 13–14 日连续合入 PR #7156(把 DeepSeek 权重重新量化成 DeepGEMM 要求的输入格式)与 #7172(“Support new DeepGEMM”);
  • DeepSeek 自家生态:V3.2 的 DSA 稀疏注意力索引器 kernel 直接放进 DeepGEMM(PR #200,2025 年 9 月 28 日);2026 年 4 月的 Mega MoE 把 MoE 的 dispatch、FP8×FP4 双线性层、SwiGLU 与 combine 融合进单个 mega-kernel,并用 NVLink 通信与张量核计算重叠;FP4 GEMM、HyperConnection(MHC)等新原语也陆续并入。

局限与边界#

把 DeepGEMM 的边界说清楚,比吹它的性能更重要:

  • 硬件挑食:最初只支持 Hopper(sm_90a),2025 年 7 月才加上 SM100(Blackwell);A100、消费级显卡一概不支持(sm_90a 是编译期硬编码的架构特性,A100 的 sm_80 连编译都过不了)。SGLang 在 Blackwell 消费卡(RTX 5090 等)上曾因此被迫关闭 DeepGEMM 路径;
  • 格式挑食:只做 E4M3×E4M3 → BF16 输出(FP8 输入、FP32 累加、BF16 结果);转置、FP8 转换、缩放因子布局整理都留给前一个 kernel;SM90 上只支持 NT 布局(SM100 放开到全布局,缩放因子改为打包的 UE8M0 格式);
  • 形状挑食:README 自己承认 “does not behave very well on some shapes”,某些形状表现平庸,官方欢迎社区提交优化 PR——它不是万能库,而是为 DeepSeek-V3/R1 的推理形状精心调过的专用库;
  • 没有正式论文:DeepGEMM 只有 GitHub 仓库和 README,官方给出的 citation 就是仓库本身。知识藏在代码注释里,这对学习者其实更友好,但也意味着很多设计决策没有文档解释(比如 num_min_sms 的取舍);
  • 工具链敏感:性能依赖 NVCC 版本(12.8 以上),SASS 级优化与编译器版本的耦合很深——这也是所有”极致优化”kernel 的宿命。

小结#

DeepGEMM 值得拆解的,不是某一条独门绝技,而是四层技术同时到位的叠加:

  1. 算法层:承接 DeepSeek-V3 的 1×1281\times128 / 128×128128\times128 细粒度缩放,用”张量核按 K 块累加 + CUDA 核提升”的两级累加同时解决 per-block 缩放与 14 位累加精度两个问题;
  2. 编译期层:全 JIT 把形状与配置做成编译期常量,全展开的 MMA 流水线让编译器做极限优化,还免除了安装时的构建负担;
  3. 硬件层:TMA 的 load/store/multicast/描述符预取、WGMMA 张量核指令、warp 特化的三级流水,把 Hopper 的每一块硬件都调度了起来;
  4. 微架构层:SASS 二进制级别的 FFMA 交错、非对齐块大小、留 SM 保频率——这些是纸面算法之外的真功夫。

300 行代码能打满 Hopper,靠的不是魔法,而是每一层都有人认真打磨过。对于正在学 CUDA 的你,DeepGEMM 的源码是比 CUTLASS 更友好的教材:没有繁重的模板元编程,一个 kernel 文件读到底,就能看到生产级 FP8 GEMM 的全部要素——TMA 描述符怎么配、屏障怎么同步、寄存器怎么让渡、提升怎么跟 WGMMA 重叠。把它和《GPU GEMM 优化完全拆解》系列对照着读,从”共享内存分块”到”warp 特化 + 两级累加”,恰好就是一条从入门到生产的 GPU kernel 成长路径。而把它和 MLA/FlashMLA 对照着读,能看到 DeepSeek 推理栈的另一半:DeepGEMM 管矩阵乘,FlashMLA 管注意力,DeepEP 管 MoE 通信,三件套拼出 V3/R1 的推理底座——理解了其中一件,另外两件的大门也就打开了。

参考资料#

  1. DeepGEMM GitHub 仓库(官方 README、kernel 源码与文档)
  2. DeepSeek-V3 Technical Report(arXiv:2412.19437,细粒度量化与累加提升见 §3.3)
  3. DeepSeek 开源周(三):DeepGEMM 性能实测及 NV 往事(火山引擎开发者社区)
  4. 通俗讲解 DeepSeek 开源:DeepGEMM,究竟是个啥?(第三弹)(架构师之路,腾讯云)
  5. 刚刚!DeepSeek 开源 DeepGEMM,V3/R1 训练推理的关键,核心代码仅 300 行!(北京智源人工智能研究院)
  6. vLLM Pull Request #19085:Support deep_gemm for linear methods
  7. SGLang Pull Request #7156:Re-quantize DeepSeek model weights to support DeepGEMM new input format
  8. SGLang Pull Request #7172:Support new DeepGEMM
  9. DeepGEMM Pull Request #112:SM90/SM100 支持与 JIT 重构
  10. DeepGEMM Pull Request #200:V3.2 DSA 稀疏注意力索引器 scoring kernel
  11. NVIDIA Hopper Tuning Guide(Tensor Memory Accelerator 说明)
  12. NVIDIA PTX ISA 文档(wgmma、stmatrix、UE8M0 等指令定义)
  13. MaxAs:为 Maxwell 架构手写 SASS 汇编器的开源项目(FFMA 交错的历史渊源)

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

DeepGEMM 完全拆解:300 行 FP8 GEMM 内核,如何逼近 Hopper 的算力峰值
https://pinghaoyang.com.cn/aigc/posts/deepgemm/
作者
平昊阳
发布于
2026-08-29
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
平昊阳
乘长风,破巨浪, 展鸿图于未央!
--
总访问量
--
访客数
公告
欢迎来到我的个人博客!欢迎关注交流吖!
更多相关公告,见
社交-留言」。
音乐
封面

音乐

暂未播放

0:000:00
暂无歌词
站点统计
文章
87
分类
18
标签
111
总字数
736,012
运行时长
0
最后活动
0 天前

文章目录