大模型FCN算子(GEMM)CUDA实现
无论是 Transformer 的 QKV 投影、MLP 的 up/gate/down projection,还是卷积经 im2col 之后的计算,本质上都在反复做同一件事:
图 1 把一个输出 tile 的计算拆成三个尺度:CTA 负责 BM × BN 的输出,warp 消费 CTA tile 的子块,MMA 指令只计算其中的 fragment。沿 K 维遍历时,A/B tile 被重复装载与复用,C tile 一直留在寄存器累加。
GEMM 不是“写三层循环”这么简单。真正难的是:同一套模型会同时遇到 prefill 的大矩阵、decode 的 skinny GEMM、MoE 的不规则 grouped GEMM,以及 FP4/FP8/INT8 到 FP64 的不同数值约束。为某个方形 FP16 shape 跑满,不等于实现了一个可放进框架的 FCN 算子。
本文沿用一个很实用的讲解顺序:先算清楚性能上界,再从朴素核函数逐层推出分块、访存、流水和 Tensor Core;最后把它们组织成按形状和精度 dispatch 的算子族。这里不虚构某块 GPU 的绝对跑分,所有“最快”的结论都必须在目标 GPU、目标 CUDA 和真实 shape 上重新测量。
1. 性能目标到底是什么
单次 GEMM 的工作量为:
运行时间为 时,吞吐为 。但只报 TFLOP/s 不足以判断 kernel 好坏,因为它还受矩阵是否足够大、是否对齐、是否发生额外读写、launch 开销和数值精度影响。
一个有用的最小模型是 Roofline:
其中 是算术强度。GEMM 通过 tile 复用 A/B 的元素,通常最终受计算单元约束;而小矩阵、skinny GEMM、拆得过细的 epilogue 则可能重新落回带宽或 launch 受限。
因此,“极致优化”不是只看一项指标,而是同时做到:
- A/B 的全局访存合并且对齐;
- shared memory 的布局没有可避免的 bank conflict;
- Tensor Core 或 CUDA Core 持续有数据可算;
- 寄存器、shared memory、CTA 数之间保持可接受的 occupancy;
- 输出的 bias、activation、residual、量化尽量在同一次写回中完成;
- 对不适合主路径的形状及时换 kernel,而不是硬撑。
2. 从朴素实现出发:为什么它离峰值很远
标量 baseline 的逻辑如下:
template <typename T>__global__ void gemm_naive(int M, int N, int K, const T* A, const T* B, T* D) { int m = blockIdx.y * blockDim.y + threadIdx.y; int n = blockIdx.x * blockDim.x + threadIdx.x; if (m >= M || n >= N) return; float acc = 0.f; for (int k = 0; k < K; ++k) acc += float(A[m * K + k]) * float(B[k * N + n]); D[m * N + n] = T(acc);}它正确,却几乎没有复用:相邻 thread 会重复读取同一行 A 或同一列 B。即使 L1/L2 能帮上一部分忙,数据搬运仍由缓存偶然性决定,无法稳定接近峰值。优化的第一原则就是把“会被多个输出元素使用的数据”显式搬到片上。
3. 第一级:CTA tile 与层次化分块
一个 thread block 计算 的 BM × BN tile,并沿 K 每次推进 BK。数据层级是:Global A/B → shared-memory A/B tile → warp tile → registers / MMA fragments → D tile。
在每个 K tile 中,CTA 协作将 A/B 连续搬入 shared memory;之后每个 warp 读取其中一小块,多个输出元素复用同一份数据。关键参数不是“越大越好”:
BM, BN越大,A/B 的复用越好,但累加器和 shared memory 也更多;BK太小,循环与同步开销明显;太大则挤占 shared memory、降低常驻 CTA;- warp tile 决定寄存器 fragment 大小与并行度;
- instruction tile 必须匹配实际架构和 dtype。
一个正确的调优流程是先枚举少量合理 tile,而不是从无穷多数字中盲猜。以常见矩阵乘数据通路为例:BM/BN 取 64、128、256 一类,BK 对 FP16/BF16 常取 32/64,warp tile 与 MMA 指令 tile 对齐;实际值必须让编译器资源报告和 profile 说话。
4. 第二级:访存布局比算术更早决定成败
4.1 全局内存:连续、对齐、向量化
线程应协作读取连续地址,使一个 warp 的访问能尽量合并为少量内存事务。对齐满足时,FP32 可使用 float4,FP16/BF16 可用成对或更宽的 pack;否则必须有独立的尾部路径,不能用未对齐的强制 reinterpret cast 赌运气。
向量化的价值不只是少几条指令,还在于明确表达连续访问。OneFlow 在优化 Softmax 时也把 half 打包成 half2,借助更高的指令吞吐处理成对元素;GEMM 的 A/B 装载同样应把 dtype、对齐和 layout 放入模板/dispatch 条件,而不是散落在 kernel 分支里。
4.2 shared memory:布局必须为消费端服务
shared memory 不是“放进去就快”。若同一 warp 的线程访问同一 bank 的不同地址,会发生 bank conflict,访问被串行化。常见处理方式包括 padding、swizzle,以及按 ldmatrix/MMA 的消费顺序重排 layout。
尤其需要避免一种误区:全局内存写入连续,并不保证 shared memory 读取也连续。生产实现会分别设计 global-to-shared 和 shared-to-register 的 layout,并在 Nsight Compute 中检查 shared load/store 的冲突指标。OneFlow 对 shared buffer 调整索引顺序以避开 bank conflict 的案例,是这条原则的一个小而典型的例子。OneFlow Softmax CUDA kernel 优化
4.3 寄存器:GEMM 的真正工作台
每个 warp 对应的输出 fragment 长时间留在寄存器,只在 K 循环结束后写回全局内存。任何把中间 A×B partial 频繁写到 global memory、再由下一 kernel 读取的设计,都会直接破坏 GEMM 的算术强度。
不过,寄存器也不是越多越好。过多 accumulator 会导致 spill 或降低 occupancy;对于 decode 的小 M,可能需要 persistent CTA 或更小 tile 来获得足够并发。
5. 第三级:让搬运和计算真正重叠
同步式 tile loop 是“搬 A/B → 等待 → 计算 → 再搬”的串行流程。现代 GPU 的关键是多阶段流水:当 stage s 的 fragment 正在 MMA 时,stage s+1 的 A/B tile 已经在路上。
// stage 0: async copy A0/B0 -> shared// stage 1: async copy A1/B1 -> shared, compute A0 * B0// stage 2: async copy A2/B2 -> shared, compute A1 * B1// ...在支持的架构上,可用异步 copy 建立双缓冲或多缓冲;再新的数据通路可以由专门的搬运机制和 warp-specialized producer/consumer 组织。核心不在于记住某个 intrinsic 名称,而在于三个不变量:
图 2 中的 C_acc 是同一输出 tile 的寄存器累加器;它只在所有 K tile 完成后才交给 epilogue。双缓冲的目的不是增加计算量,而是让 load(k+1) 与 MMA(k) 重叠。
- consumer 在读取前确认对应 stage 已完成;
- producer 不覆盖仍被消费的 stage;
- stage 数增加带来的 latency hiding,必须大于它对 shared memory 和 occupancy 的代价。
CUTLASS 把这些策略封装为可组合的 mainloop、collective 和 epilogue,是阅读工业级 GEMM 流水线的好入口;其近代实现还覆盖了 grouped GEMM、FP8、block-scaled 类型与架构专用调度。NVIDIA CUTLASS
6. Tensor Core 不是一个 dtype 开关
Tensor Core 路径的本质是 warp/warp-group 使用特定矩阵乘累加指令,操作 fragment 而非单个标量。要让它工作,至少要同时满足:dtype、矩阵布局、指令 tile、K 对齐和目标计算能力的约束。
典型策略是:主路径处理满足 tile 对齐的大块,边界使用 predicated load/store 或单独的 residue kernel;不要让每一次 MMA 都包在复杂的逐元素边界判断里。对于常见混合精度,输入可以是 FP16/BF16/TF32/FP8,而 accumulator 通常是 FP32;CUDA Programming Guide 明确说明 BF16 WMMA fragment 以 float 为累加类型,TF32 需要从 FP32 显式转换得到相应语义。CUDA C++ Programming Guide
7. 精度矩阵:从 INT4/FP4 到 FP64
“支持所有精度”不应理解为一个模板把所有类型硬塞进一个指令序列,而是每种精度都有明确的输入、累加、缩放、输出与 fallback 规则。
| 输入 A/B | 默认累加 | 典型用途 | 实现重点 |
|---|---|---|---|
| INT4 / INT8 | INT32 或 FP32 | 量化推理 | per-channel/per-group scale、零点、饱和及反量化 epilogue |
| FP4 | FP32 或框架指定精度 | 超低比特推理 | block scale 是数据布局的一部分,不是事后乘法 |
| FP8 E4M3/E5M2 | FP32 | 训练/推理加速 | scale、amax 管理、fast-accum 的误差边界 |
| FP16 / BF16 | FP32 | 训练与通用推理 | MMA + FP32 accumulator;BF16 保留更大指数范围 |
| TF32 语义 | FP32 | FP32 范围、可接受 reduced mantissa | 仅在允许的数值合同下打开 |
| FP32 | FP32 | 精度基线、敏感算子 | CUDA Core 或适配的 tensor 路径,严测误差 |
| FP64 | FP64 | 科学计算、高精度参考 | 走目标硬件支持的双精度路径,吞吐/布局另行调优 |
这里有四个容易被忽略的细节:
- 存储与累加分离。 FP16 输入、FP32 累加不是“可选优化”,而应是通用训练的默认数值合同。
- 缩放粒度属于 ABI。 INT4/FP4/FP8 的 scale 按 tensor、channel、group 还是 block 存储,会同时决定精度、访存和 kernel layout;必须在 tensor descriptor 中明确。
- K 方向分割会影响数值。 split-K 改变累加顺序;高精度或可复现模式应采用固定归约树,必要时使用更高精度 partial。
- 低比特不等于总更快。 若 shape 太小、scale 读取占主导,或需要单独 dequant kernel,端到端速度可能反而下降;理想做法是把 dequant/scale 融合在加载和 epilogue 中。
当前 CUTLASS 的功能与变更记录展示了 FP8、NVFP4、MX 格式、block scaling 和 sparse/block-scaled GEMM 等路线;它们说明低比特 GEMM 是一套数据布局与调度问题,而不只是把 float 换成更短的类型。CUTLASS changelog
8. GEMM 不是一种形状:分段 dispatch 才是通用解
和 OneFlow 的分段 Softmax 一样,GEMM 也不存在一个在所有形状上最优的 kernel。一个实用 dispatcher 至少把以下信息作为 key:
{arch, dtypeA, dtypeB, dtypeD, accumulator, layoutA, layoutB, layoutD, M, N, K, alignmentA/B/D, alpha/beta, epilogue, batch/group count, deterministic, workspace limit}对应的主路径可这样划分:
| 问题特征 | 优先策略 | 原因 |
|---|---|---|
| 大 M/N/K,整齐对齐 | 大 CTA tile + 多阶段 Tensor Core pipeline | 复用高、可计算饱和 |
M 很小、decode skinny GEMM | persistent CTA / 更小 tile / grouped GEMM | 避免 CTA 不足与 launch 主导 |
K 极大,M×N tile 不足 | split-K 或 Stream-K | 增加并行度,随后规约 |
| 多个小而异形的 GEMM(MoE) | grouped GEMM | 合并 launch,动态平衡工作 |
| M/N/K 边界不对齐 | 主 kernel + residue,或 predicate tile | 不让边界条件污染内层主循环 |
| FP32 高精度或 FP64 | 独立高精度 kernel | 不与低精度 Tensor Core 假设混用 |
split-K 并不是免费午餐:它增加 workspace 或原子/归约开销,只有原始网格并行度不足时才值得使用。Stream-K 的意义也正是在 tile 间重新分配 K 工作,缓解固定 tile 调度的不平衡;它应作为 benchmark 候选,而非默认开关。
9. 一个可维护的 kernel 骨架
生产代码通常不把所有细节塞进 __global__ 函数,而是把策略变为类型和常量。下面是结构示意:
template <class ElementA, class ElementB, class ElementD, class Accumulator, class Tile, class Layout, class Mainloop, class Epilogue>struct GemmKernel { __device__ void operator()(GemmArgs args) { // 1. 根据 CTA 坐标取得 D 的 Tile。 // 2. Mainloop: 预取 A/B,管理 pipeline stage,执行 MMA。 // 3. Epilogue: alpha/beta、bias、residual、activation、量化/转换。 // 4. 只写回最终 D。 }};
GemmPlan make_plan(const GemmArgs& args) { // 静态规则先过滤不合法的 dtype/layout/alignment; // 查 shape+arch 的 autotune 缓存;缺失时仅 benchmark 少量候选。}这种分层使主循环只关心“如何高复用地计算”,epilogue 只关心“如何生成最终输出”,dispatcher 则对形状做选择。它也让你能把框架的 bias、GELU/SwiGLU、残差、输出量化等融合进去,而不污染 MMA 内层循环。
10. 融合 epilogue:节省的往往比 MMA 更真实
以 FCN 为例,常见路径是:
D = A @ WY = activation(D + bias)若拆成两个 kernel,D 至少经历一次完整的 global write 与一次 global read。把 alpha/beta + bias + activation + cast/quantize 放在 GEMM epilogue 中,可以让 accumulator fragment 在寄存器中完成最后变换后只写一次 Y。
但融合有边界:复杂 activation、大量额外输入或分支会提高寄存器占用,可能伤害主 GEMM。判断标准永远是端到端 benchmark,而不是“kernel 数量越少越好”。
11. 如何验证“快”与“对”
基准至少应包含 LLM 常见 shape:prefill 的大 M、decode 的 M=1..16、MLP 的大 N/K、QKV 的非方阵、MoE 的 grouped GEMM,以及难看的不对齐尾部。每项都要记录:
environment: GPU/SM、CUDA/驱动、时钟状态problem: 矩阵 layout、M/N/K、dtype、accumulatortiming: warmup、重复次数、CUDA event 时间、总时间、TFLOP/s、workspacecorrectness: 最大绝对/相对误差、随机种子、确定性模式正确性基准要独立于被测实现:低精度输入可用 FP32 或 FP64 reference 比较,比较容忍度随 dtype 和 K 长度设置;对 FP8/FP4/INT4,额外测 scale 边界、NaN/Inf、饱和、非整齐 K 和 beta 非零。性能分析使用 Nsight Compute 观察 Tensor Core 活跃度、DRAM 吞吐、shared bank conflict、寄存器 spill、occupancy 和 warp stall 原因。
不要只对一个漂亮的 8192×8192×8192 报数。真正的大模型推理,往往被 decode、短 batch、量化 scale、融合边界和 grouped workload 决定。
12. 常见的“优化”陷阱
- 用一个 tile 打天下。 大矩阵最优 tile 在 skinny GEMM 上可能只会浪费线程。
- 无条件加深 pipeline。 stage 多会占用更多 shared memory,可能使常驻 CTA 下降。
- 只测 kernel,不测 epilogue/量化/转换。 用户感知的是端到端时间。
- 默认 split-K。 没有并行度问题时,额外规约就是纯负担。
- 将 TF32/FP8 当作无损 FP32。 数值合同必须由模型和测试决定。
- 把未对齐指针强转成 vector 类型。 这不是优化,是未定义行为风险。
- 忽略 launch 开销。 对 decode 或小 GEMM,CUDA Graph、grouped/persistent 调度常比更大的 MMA tile 重要。
总结
高性能 GEMM 的路径可以概括为:用 CTA/warp/instruction 三层 tile 建立复用,用对齐向量化和无冲突 shared layout 喂饱计算单元,用异步多阶段流水隐藏搬运延迟,用 Tensor Core 与混合精度匹配硬件能力,最后用 shape-aware dispatch 覆盖大矩阵、skinny、split-K 与 grouped workload。
对于大模型 FCN,真正可上线的版本还必须把 FP4/INT4、FP8、FP16/BF16、TF32、FP32、FP64 的数值合同和 fallback 写清楚,并把 bias/activation/quantize 融进合适的 epilogue。所谓“极致优化”,从来不是某个花哨 intrinsic,而是一套在真实输入、真实数值约束和真实硬件上持续测得最快的系统工程。
参考资料
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!













