1. 从"矩阵乘法为什么快不起来"说起
如果你最近在折腾大模型推理或者训练加速,大概率会频繁听到一个词:DeepGEMM。这不是某个新出的深度学习框架,也不是一个完整的推理引擎,而是一个专门针对矩阵乘法(GEMM,General Matrix Multiply)做极致优化的底层算子库。它的定位非常明确——把深度学习里最耗时的那一类计算,在特定硬件上压榨出尽可能高的吞吐。
矩阵乘法有多重要?简单说,Transformer 里绝大多数的计算量都花在了它身上。注意力机制里的 QK^T、注意力输出与 V 的相乘、前馈网络里的两层全连接,本质上全是矩阵乘法。一个模型跑得快不快,很大程度上取决于这些矩阵乘法有没有被高效执行。很多人第一反应是"调 cuBLAS 不就行了",但实际做过推理优化的人都知道,通用库在特定形状、特定精度、特定硬件组合下,往往留有不少性能余量。DeepGEMM 要解决的,就是把这些余量吃干净。
这篇文章适合谁看?如果你正在做推理加速、算子优化,或者单纯好奇"一个矩阵乘法库到底能玩出什么花样",那接下来的内容会对你有用。我会从它要解决的核心问题讲起,拆解它的关键技术点,补充实操中容易踩的坑,并给出可以复现的验证思路。需要提前说明的是,文中涉及的具体参数和步骤,部分是基于这类算子库的常见工程实践做的合理补充,目的是让你能真正上手,而不是停留在概念层面。
先说结论性的判断:DeepGEMM 这类库的价值,不在于"重新发明矩阵乘法",而在于针对特定场景做极致的特化。它通常聚焦在 FP8 这类低精度格式、聚焦在 MoE(混合专家)这种分组矩阵乘法场景、聚焦在 Hopper 及更新架构的 Tensor Core 特性上。理解了这个定位,后面所有的设计选择就都顺理成章了。
2. DeepGEMM 到底在优化什么:三类核心场景拆解
要理解一个算子库,最忌讳一上来就看代码。先搞清楚它服务的场景,代码才有意义。DeepGEMM 的优化目标可以拆成三块,每一块对应一类真实存在的性能瓶颈。
2.1 普通稠密 GEMM:把 Tensor Core 喂饱
最基础的就是标准稠密矩阵乘法 C = A × B。听起来简单,但要跑满硬件峰值非常难。难点在于:数据从显存搬到寄存器的速度,往往跟不上 Tensor Core 的计算速度。也就是说,计算单元经常在"等饭吃"。
DeepGEMM 在这块的思路是经典的tiling(分块)+ 流水线(pipeline)。把大矩阵切成小块,每块塞进共享内存,再用异步拷贝把下一块提前加载进来,让计算和数据搬运重叠。听起来是教科书操作,但魔鬼在细节里:块切多大、共享内存怎么排布、寄存器怎么分配、warp 怎么分工,每一个参数都会显著影响最终性能。
我实测过的一个经验是:块大小(block size)不是越大越好。块太大,共享内存放不下,或者占用寄存器过多导致 occupancy 下降;块太小,数据复用率不够,搬运开销占比升高。DeepGEMM 针对不同形状会走不同的配置,这也是它比"一套参数打天下"的通用库更有优势的地方。
2.2 分组 GEMM:MoE 场景的刚需
这是 DeepGEMM 最有辨识度的能力之一。MoE 架构里,一个 batch 的 token 会被路由到不同的专家(expert),每个专家处理一部分 token。这就导致每个专家拿到的输入矩阵形状都不一样,而且往往很小、很不规则。
传统的做法是循环调用普通 GEMM,一个专家一次。问题是:小矩阵的 GEMM 效率极低,kernel 启动开销、尾块浪费都很严重。DeepGEMM 的分组 GEMM(grouped GEMM)把多个专家的计算合并到一个 kernel 里,用一组指针描述每个分组的起始位置,让 GPU 在一次启动里处理完所有专家。这个设计直接砍掉了大量 kernel 启动开销,对 MoE 推理的吞吐提升非常明显。
提示:分组 GEMM 的关键在于"分组描述符"的设计。它需要告诉 kernel 每个分组的 M、N、K 以及数据在内存中的偏移。如果描述符组织得不好,反而会引入额外的索引开销。
2.3 低精度 GEMM:FP8 的收益与代价
DeepGEMM 大量围绕FP8做优化。FP8 相比 FP16/BF16,显存占用减半、带宽需求减半、Tensor Core 吞吐翻倍,理论上收益巨大。但代价是精度损失,需要配合缩放(scaling)策略来补偿。
FP8 通常有两种格式:E4M3(4 位指数、3 位尾数)和 E5M2(5 位指数、2 位尾数)。E4M3 精度更高但动态范围小,E5M2 动态范围大但精度低。实际做矩阵乘法时,输入通常用 E4M3,因为权重和激活值经过缩放后大多落在 E4M3 能表示的范围内。
缩放策略又分逐张量缩放和逐块缩放。逐张量缩放简单,一个矩阵一个缩放因子;逐块缩放更精细,每个小块一个因子,精度更好但实现复杂。DeepGEMM 在这块做了不少工程优化,让逐块缩放的额外开销尽量小。
| 精度格式 | 指数位 | 尾数位 | 动态范围 | 典型用途 |
|---|---|---|---|---|
| FP32 | 8 | 23 | 极大 | 基准/累加 |
| BF16 | 8 | 7 | 大 | 训练/推理 |
| FP16 | 5 | 10 | 中 | 训练/推理 |
| FP8 E4M3 | 4 | 3 | 小 | 推理输入 |
| FP8 E5M2 | 5 | 2 | 大 | 梯度/特殊场景 |
这张表建议存下来,调精度的时候随时对照。很多人搞混 E4M3 和 E5M2 的适用场景,结果精度崩了还找不到原因。
3. 拆开看:DeepGEMM 的关键技术点与设计取舍
理解了场景,再来看它用了哪些手段。这一节我会把几个核心技术点讲透,并且解释"为什么这么选"。
3.1 为什么用即时编译(JIT)而不是预编译
DeepGEMM 一个很鲜明的特点是采用JIT(Just-In-Time)编译。也就是说,kernel 不是提前编译好一堆 .so 文件,而是在运行时根据实际形状和配置动态生成并编译。
这个选择乍看反直觉——JIT 有编译开销,第一次调用会慢。但它的收益在于:组合爆炸问题被解决了。矩阵乘法的形状千变万化,M、N、K 各有无数可能,再加上精度、转置、缩放模式等维度,预编译要覆盖所有组合几乎不可能。JIT 让你只在实际用到的形状上编译,用一次编译开销换长期的灵活性。
实操中要注意:JIT 编译有缓存机制。第一次跑某个形状会慢,之后命中缓存就快了。所以做性能测试时,一定要先 warmup 再计时,否则测出来的是编译时间而不是计算时间。这个坑我见过太多人踩。
3.2 共享内存与寄存器的最优分配
GPU 上做 GEMM,数据流大致是:显存 → 共享内存 → 寄存器 → Tensor Core。每一级带宽和容量都不同,分配策略直接决定性能。
DeepGEMM 在这块的常见做法是双缓冲(double buffering):共享内存开两块,一块用于当前计算,一块用于异步加载下一块数据。这样计算和数据搬运能重叠起来。再进一步还有多级流水线,用更多缓冲块换取更高的重叠度,但会占用更多共享内存。
寄存器分配同样关键。Tensor Core 的 MMA 指令需要操作数放在特定寄存器里,如果寄存器不够,就会发生 spill(溢出到本地内存),性能断崖式下跌。DeepGEMM 通过精细的寄存器规划,尽量让热点数据常驻寄存器。
注意:共享内存和寄存器是此消彼长的关系。共享内存开得多,能放更大的块、做更深的流水线,但可能挤压寄存器;反之亦然。调优时这两个参数要一起看,不能孤立调。
3.3 分组 GEMM 的调度逻辑
分组 GEMM 的难点在于负载不均。不同专家分到的 token 数可能差很多,有的专家很忙,有的很闲。如果简单地一个分组一个分组串行处理,就会出现"忙的忙死、闲的闲死"。
DeepGEMM 的调度思路通常是把所有分组的计算任务打散,重新映射到 GPU 的各个 SM(流多处理器)上,让负载尽量均衡。这需要一套任务分配逻辑,把每个分组的块(tile)作为调度单元,动态分给空闲的 SM。
这里有个容易忽略的点:分组数量太少时,分组 GEMM 的优势发挥不出来。如果只有两三个专家,合并 kernel 的收益有限,反而不如直接调普通 GEMM。分组 GEMM 的甜点区是专家数量较多(比如 8 个以上)且每个专家数据量不大的场景。
3.4 缩放因子的处理开销
FP8 逐块缩放虽然精度好,但每个块都要乘一个缩放因子,这个额外计算如果处理不好,会把 FP8 带来的收益吃掉一部分。
DeepGEMM 的优化方向是把缩放融合进主循环,而不是单独跑一个 pass。具体做法是在累加完成后、写回结果前,用向量化指令批量应用缩放因子。这样缩放的开销被隐藏在计算流水线里,几乎不额外增加时间。
实测经验:逐块缩放的块大小要和 GEMM 的 tiling 对齐。如果缩放块和计算块不一致,就需要额外的边界处理,代码复杂度和开销都会上升。对齐之后,缩放基本是"免费"的。
4. 上手实操:从环境准备到跑通第一个 kernel
光讲原理不够,这一节给你一套可操作的流程。再次强调,部分细节是基于这类库的通用工程实践补充的,具体以你拿到的版本为准。
4.1 环境准备中最容易忽略的三件事
第一,架构匹配。DeepGEMM 这类库通常针对特定 GPU 架构(如 Hopper 的 SM90)做了深度优化,用到了该架构特有的指令(比如 TMA、wgmma)。如果你的卡是上一代架构,很多优化路径根本走不到,性能可能还不如通用库。上之前先确认你的硬件架构。
第二,CUDA 版本。低精度和异步拷贝相关的特性对 CUDA 版本有要求。版本太低,某些 intrinsic 用不了,JIT 编译会直接报错。建议用较新的 CUDA 工具链,并且保证驱动版本匹配。
第三,Python 环境与编译依赖。JIT 编译需要完整的编译工具链(nvcc、C++ 编译器)。如果是在容器里跑,很容易缺这缺那。建议先跑一个最小样例,确认编译链路通了,再上真实模型。
# 检查 GPU 架构和 CUDA 版本 nvidia-smi nvcc --version # 确认编译工具链可用 which g++ python -c "import torch; print(torch.version.cuda)"4.2 跑通第一个矩阵乘法的完整步骤
假设你已经装好了库,下面是一个最小验证流程。
import torch import deep_gemm # 假设的导入名,以实际包名为准 # 1. 准备输入,注意 dtype 和布局 M, N, K = 4096, 4096, 4096 a = torch.randn(M, K, device='cuda', dtype=torch.float8_e4m3fn) b = torch.randn(N, K, device='cuda', dtype=torch.float8_e4m3fn) # 2. 准备缩放因子(逐张量示例) scale_a = torch.tensor([1.0], device='cuda') scale_b = torch.tensor([1.0], device='cuda') # 3. warmup,触发 JIT 编译 for _ in range(3): c = deep_gemm.gemm_fp8_fp8_bf16_nt(a, b, scale_a, scale_b) torch.cuda.synchronize() # 4. 正式计时 import time start = time.time() for _ in range(100): c = deep_gemm.gemm_fp8_fp8_bf16_nt(a, b, scale_a, scale_b) torch.cuda.synchronize() print(f"平均耗时: {(time.time()-start)/100*1000:.3f} ms")这段代码里有几个关键点值得展开。第一,warmup 不能省,前面说过 JIT 编译的原因。第二,dtype 必须严格匹配,FP8 的 E4M3 和 E5M2 在 PyTorch 里是不同的 dtype,传错了会报错或者结果不对。第三,布局(layout)要对,很多 GEMM 接口区分 NT、NN、TN、TT(N 表示非转置,T 表示转置),传错布局结果直接错。
4.3 怎么判断性能是否达标
跑通了不代表跑好了。判断性能是否达标,最直接的办法是算TFLOPS,然后和硬件峰值对比。
矩阵乘法的浮点运算量是 2 × M × N × K(乘加各算一次)。用这个数除以实测时间,就得到实际 TFLOPS。
flops = 2 * M * N * K elapsed = (time.time() - start) / 100 tflops = flops / elapsed / 1e12 print(f"实测: {tflops:.1f} TFLOPS")拿这个数和硬件的理论峰值比。如果只能到峰值的 30% 以下,说明配置有问题;能到 60% 以上,基本算合格;能到 80% 以上,说明调得不错。注意 FP8 的峰值通常是 FP16 的两倍,对比时要用对应精度的峰值。
提示:不同形状的 GEMM 能达到的峰值比例差别很大。大而方的矩阵容易跑满,细长的矩阵(比如 M 很小、N 和 K 很大)往往效率低。评估时要覆盖你实际会遇到的形状,别只测一个理想形状就下结论。
5. 踩坑实录:那些文档里不会写的细节
这一节是我最想分享的部分。原理和步骤网上都能查到,但真正让人卡住的往往是这些细节。
5.1 精度对不上:先查缩放因子,再查累加精度
FP8 算出来的结果和 FP32 参考值对不上,是最常见的问题。排查顺序应该是:先确认缩放因子是否正确应用,再确认累加精度。
缩放因子这块,逐张量和逐块是两套逻辑。如果你用逐块缩放,但缩放因子的形状和数据的块划分对不上,结果就会系统性偏移。我遇到过一次,缩放因子按行给,但 kernel 期望按块给,结果误差大得离谱,查了半天才发现是维度理解错了。
累加精度这块,FP8 的乘法结果通常累加在 FP32 里。如果某个实现为了省资源用 FP16 累加,精度会明显下降。确认累加精度是否符合预期,是排查精度问题的第二步。
5.2 性能忽高忽低:多半是形状或缓存的问题
同样的代码,跑不同 batch 性能差异巨大,或者第一次跑特别慢,这些都有原因。
第一次慢,前面说过,是 JIT 编译。但如果每次跑都慢,那可能是形状每次都不同,导致 JIT 缓存一直不命中,每次都在编译。解决办法是尽量固定形状,或者接受首次编译开销。
性能忽高忽低,还可能是显存碎片或频率波动。GPU 长时间高负载会降频,测出来的性能会随时间下降。做严谨测试时,要控制温度、固定频率,或者至少多测几轮取稳定值。
5.3 分组 GEMM 的负载不均:从现象到定位
分组 GEMM 跑出来比预期慢,第一反应应该是负载不均。定位方法是:打印每个分组的实际计算量,看看是不是有的分组特别大。
如果确实不均,可以考虑两个方向。一是调整路由策略,让 token 分配更均匀(这属于上层模型的事,但会影响底层性能)。二是确认调度逻辑是否真的在动态均衡,有些实现只是简单地把分组顺序分给 SM,并没有做真正的负载均衡。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 首次调用极慢 | JIT 编译 | 确认 warmup,检查缓存 |
| 精度系统性偏移 | 缩放因子错误 | 核对缩放维度与块划分 |
| 性能随时间下降 | GPU 降频 | 监控温度与频率 |
| 分组 GEMM 偏慢 | 负载不均 | 打印各分组计算量 |
| 结果完全错误 | 布局传错 | 核对 NT/NN/TN/TT |
这张表建议收藏,出问题时按图索骥,能省不少时间。
5.4 一个反直觉的经验:小矩阵别硬上
很多人觉得"用了优化库就一定快",其实不然。对于很小的矩阵(比如 M、N、K 都小于 256),kernel 启动开销和尾块浪费占比很高,优化库的优势发挥不出来,甚至可能因为额外的调度逻辑而更慢。
我的经验是:小矩阵优先考虑合并或批处理,把多个小矩阵拼成一个大矩阵再算,而不是指望单个小矩阵的 GEMM 跑得多快。这也是分组 GEMM 存在的意义——它本质上就是把一堆小矩阵合并处理。
6. 把 DeepGEMM 放进真实推理链路里
单独跑一个 GEMM kernel 是一回事,把它集成进真实推理链路是另一回事。这一节聊聊集成层面的考量。
6.1 和推理框架的对接方式
DeepGEMM 通常不会单独使用,而是作为推理框架的底层算子被调用。对接方式一般有两种:一是框架直接调用库的 Python/C++ 接口;二是通过自定义算子注册进框架的算子表。
第一种方式简单直接,适合快速验证。第二种方式更规范,能享受框架的调度和内存管理,但需要写注册代码。实际项目中,如果只是替换某个热点算子,第一种就够了;如果要全面替换,第二种更合适。
对接时最需要注意的是内存布局的约定。框架内部的张量布局可能和库期望的不一致,需要做转换。转换本身有开销,如果转换开销比 GEMM 省下的时间还多,那就得不偿失了。所以集成前一定要算清楚这笔账。
6.2 端到端收益怎么评估
不要只看单个 kernel 的加速比,要看端到端。一个 GEMM 加速 2 倍,但如果它只占整体耗时的 20%,端到端提升也就 10% 出头。
评估方法是:先 profile 整个推理流程,找出 GEMM 占比,再估算替换后的端到端收益。如果 GEMM 占比很高(比如 MoE 模型里能到 50% 以上),那收益就很可观;如果占比不高,就要权衡集成成本。
注意:profile 时要用真实的输入分布。用随机输入测出来的占比,和真实业务输入可能差很多,尤其是 MoE 这种对输入敏感的架构。
6.3 稳定性与回退机制
生产环境里,任何优化都要有回退方案。DeepGEMM 这类深度优化的库,在遇到不支持的形状或精度时可能直接报错。集成时必须做好异常捕获和回退——报错了就退回通用库,保证服务不中断。
回退逻辑要提前测。我见过有人写了回退代码但从来没测过,真出问题时回退路径本身也有 bug,结果服务直接挂了。回退路径要和主路径一样认真对待。
7. 我个人在实际操作中的几点体会
折腾这类底层算子库有段时间了,最后分享几点真实体会,不算总结,就是些零散的经验。
第一,先搞清楚硬件再选库。不是所有优化库都适配所有卡,上之前确认架构匹配,能省掉大量无用功。我一开始没注意这点,在一个不支持的架构上折腾了半天,最后发现根本走不到优化路径。
第二,性能测试一定要规范。warmup、固定形状、控制温度、多轮取稳定值,这些步骤一个都不能少。不规范的测试得出的结论往往是错的,会误导后续决策。
第三,精度验证要用真实数据。随机数据测不出某些边界情况,用真实业务数据跑一遍,才能发现潜在的精度问题。
第四,集成成本要算清楚。单 kernel 快不等于端到端快,布局转换、异常处理、回退逻辑这些都是成本。算总账,别只算局部。
第五,保持对版本的敏感。这类库迭代很快,新版本可能修了 bug、加了特性、改了接口。升级前看 changelog,升级后跑回归测试,别盲目跟。
最后再分享一个小技巧:如果你不确定某个形状该用什么配置,可以先用库自带的启发式(heuristic)选一套,然后在这个基础上微调。从默认配置出发,比从零开始试要高效得多。调优是个迭代过程,别指望一次到位。