GEMM算子调优实战:从21 GFLOPS到592 GFLOPS的完整路径 前一阵我在给自研推理框架做性能优化线上模型在CPU上的延迟一直压不到目标值。用VTune跑完一遍profile之后问题出奇地集中一个矩阵乘算子GEMM的耗时占比超过60%实现代码是老式三重循环没有分块没有向量化计算效率大概只有理论峰值的零头。这种场景在算子库里太常见了我这次把GEMM从21 GFLOPS调到592 GFLOPS整个过程大概花了一个星期。这篇博文就把这次算子库调优的完整思路、关键细节和踩过的坑完整记录下来给同样在折腾算子性能的同学一个参考。1. 定位瓶颈调优先调指标别上来就改代码1.1 用Profile数据做“破案”热点一目了然接到任务的第一件事不是翻代码而是先跑profile。我习惯先用perf top按CPU占用率排序或者用VTune的Hotspots分析把线程栈和热点函数一起拉出来很快就能看到底是哪个算子吃掉了CPU时间。这次的结果非常清晰GEMM算子占端到端推理耗时的62%。这个名字看起来平平无奇但它是自研算子库里最基础的计算内核卷积、全连接、attention里的QK^T和V矩阵乘底层都会调到它。也就是说一个算子的效率拉着整个模型的延迟下水。我当时的判断很简单即使把剩余38%的耗时全部优化为零整体性能最多也只提升38%。但如果把GEMM的耗时砍掉一半整体延迟就能降低30%以上。所以火力集中在GEMM上是投入产出比最高的选择。这也是我想说的第一个经验调优前的profile阶段本质上是在做“投资决策”资源永远优先砸向占比最大的热点而不是看起来代码写得最丑的地方。1.2 用Roofline模型算天花板才知道差距有多大锁定了GEMM之后还需要搞清楚一个关键问题当前实现离硬件的极限到底差多远这里我用了Roofline模型的思想不引太多理论核心就是算两个数计算峰值和内存带宽。以我们这台测试机为例单路 Xeon Gold 624820核主频2.5GHz支持AVX512。每个核心每周期可以执行两次512位FMA指令一条FMA涉及16个float的乘法和加法也就是每周期64次浮点操作。理论峰值粗略估算就是20核 × 2.5GHz × 64 FLOPs/cycle ≈ 3200 GFLOPSAVX512重负载下会有降频实际全核能跑到2200~2600 GFLOPS算正常。内存带宽方面用STREAM实测大约在120GB/s左右。衡量一个矩阵乘是计算密集还是访存密集需要算算术强度。以MNK2048的矩阵为例计算量 2 × 2048³ ≈ 17.2 GFLOP如果采用朴素实现并把中间结果反复读写访存量动辄几十GB甚至上百GB但如果做好分块和寄存器复用访存量可以压缩到接近2×2048²×4字节×2次左右也就是64MB级别算术强度 计算量 / 访存量一旦算术强度足够高瓶颈就从访存转向计算我们的基线版本在720ms内完成这个矩阵乘算下来只有23.9 GFLOPS连理论峰值的1%都不到。这说明瓶颈根本不是硬件能力不足而是代码压根没把CPU的计算喂饱。搞清楚了天花板和当前的位置后面每一步优化就都有了参照系。2. 算子库调优的几个方向从数据布局到算子融合2.1 数据排布让访存连续起来cache利用率翻倍很多性能差的GEMM实现问题不在乘法的算术运算而在访存模式。矩阵在内存里是线性排列的一般按行主序存。三重循环里最内层是sum A[i][k] * B[k][j]访问A是连续的访问B却是一列一列地跳着读每读一个元素就要跨一行cache命中率自然上不去。一个简单的思路是先把B转置或pack成连续的内存块。转置之后内层循环访问A和packed_B都是顺序读取CPU硬件预取器也能更好地工作一个cache line读进来16个float全部用上而不是读一两个就扔掉。这一步对性能影响极大尤其在现代CPU上主存延迟达到几十纳秒而L1 cache命中只要几个周期两者差距是两个数量级。2.2 分块Tiling把大矩阵拆小让计算在cache里完成矩阵规模一大任何缓存都装不下完整的结果。分块的基本思路是把C矩阵拆成多个小的子块让每个子块的计算尽量在L1/L2 cache中完成减少对主存的重复访问。我这次第一轮调优就是引入了分块。外层循环对M和N方向分别按64×64切块最内层K方向也切块这样A的一个子块、B的一个子块和C的一个子块可以同时放进L2 cache。64×64这个值不是随手定的而是根据L2 cache容量和数据类型算出来的64×64的A子块为float64×64×4 16KB64×64的B子块16KB64×64的C子块16KB三者加起来约48KB在1MB L2 cache面前有充足余量还可以留出空间给代码和数据。分块之后内层循环的每次迭代都从缓存里取数不再频繁触发cache miss。2.3 向量化与并行把CPU的SIMD能力用满分块解决了cache命中问题接下来要解决的是“单次指令只算一个数”的浪费。现代x86 CPU都有SIMD指令AVX2一次操作8个floatAVX512一次操作16个float。如果代码是普通三重循环编译器即使开了-O2也不一定能正确自动向量化因为存在可能的别名问题或循环依赖。显式使用intrinsics是更可控的做法。在GEMM内层循环里用一个广播指令把A[i][k]复制到向量的每个lane然后与B的连续16个元素做FMA乘加融合指令一条指令就完成16次乘法和16次加法。同样的循环体内层展开后指令数减少一百多倍计算吞吐直接提升一个档次。并行层面则用OpenMP将M方向的行块分配给多个线程并行计算。这里要注意的是线程数不是越多越好在超线程机器上如果线程数设为逻辑核数反而可能因为资源竞争导致性能下降。我最终选择绑定物理核效果明显更好。2.4 算子融合省掉跨内存的中间结果往返算子库里的算子往往是单一功能的GEMM就是一个矩阵乘bias加偏置是另一个算子ReLU激活又是一个算子。如果每个算子都独立执行那么GEMM结束后整块结果要写回内存下一个算子再读出来做完运算再写回去中间结果的内存往返非常浪费。实际业务模型里GEMM后面通常紧跟bias和ReLU完全可以在GEMM的寄存器尾处理阶段直接加上偏置和激活只写一次最终结果。这个思路叫做算子融合也是我这次调优后期收益最大的一个环节。融合之后端到端推理延迟下降很明显比单独优化GEMM内核带来的收益还要直接。3. 实操过程一次从21 GFLOPS到592 GFLOPS的调优实录3.1 基线实现先跑出可信的数字基线代码就是最朴素的ijk三重循环用OpenMP做了最简单的并行但没做任何内存优化。核心逻辑大概长这样// 基线代码三重循环 OpenMP并行未做分块和向量化 #pragma omp parallel for num_threads(20) schedule(static) for (int i 0; i M; i) { for (int j 0; j N; j) { float sum 0.0f; for (int k 0; k K; k) { sum A[i * K k] * B[k * N j]; } C[i * N j] sum; } }编译命令也很常规g -O3 -marchnative -fopenmp gemm_baseline.cpp -o gemm_baseline用MNK2048的单精度矩阵测试重复10次取中位数基线耗时是720ms折算约23.9 GFLOPS。这是所有后续优化的起点。3.2 第一轮优化内存重排 分块第一轮改动做了两件事一是把B矩阵预先转置成行主序的packed_B二是按64×64分块。分块循环的伪代码大致如下// 第一轮优化分块 B转置 for (int i 0; i M; i MB) { for (int j 0; j N; j NB) { for (int k 0; k K; k KB) { // 计算C[i:iMB][j:jNB] 子块 for (int bi 0; bi MB; bi) { for (int bj 0; bj NB; bj) { float sum C[(i bi) * N j bj]; for (int bk 0; bk KB; bk) { sum A[(i bi) * K k bk] * packed_B[(k bk) * N j bj]; } C[(i bi) * N j bj] sum; } } } } }实测下来这一轮就把耗时降到了220msGFLOPS提升到78左右。性能大约翻了三倍多但依然远没到理想状态。cache miss率肉眼可见地下降perf stat里L1 cache miss从40%多降到19%说明方向对了。3.3 第二轮优化AVX512向量化单指令算16个数分块把数据喂到了cache里但CPU的核心计算单元还在“空转”因为每一条指令只处理了一个float。第二轮优化我给内层循环换上了AVX512 intrinsics用16路float同时算。核心片段如下// 第二轮优化AVX512 FMA 向量化内层循环 #include immintrin.h for (int i 0; i M; i) { for (int j 0; j N; j 16) { __m512 c _mm512_loadu_ps(C[i * N j]); for (int k 0; k K; k) { __m512 a _mm512_set1_ps(A[i * K k]); // 广播单个A元素 __m512 b _mm512_loadu_ps(packed_B[k * N j]); // 连续16个b元素 c _mm512_fmadd_ps(a, b, c); // 乘加融合一步到位 } _mm512_storeu_ps(C[i * N j], c); } }这段代码看起来不复杂但每一轮内层循环都在做16次乘法和16次加法吞吐量提升接近一个数量级。实测耗时从220ms降到78msGFLOPS来到了220左右。这个阶段已经可以看到CPU的算力开始被真正利用起来了。这里有一个容易忽略的细节_mm512_set1_ps是广播指令它会生成一条比较重的shuffle指令。更好的做法是在内层循环外先把A的16个元素加载进寄存器再配合shuffle来做广播。不过对于编译器和高成本的分块实现我当前这个版本已经足够更精细的微架构优化留到后续。3.4 第三轮优化多线程亲和性 NUMA绑定到了220 GFLOPS我一度以为快到瓶颈了但用perf stat一看CPU利用率只有40%不到很多核在等待内存数据。这就不是算法问题了而是线程调度和内存访问位置有问题。多线程的GEMM每个线程应该尽量处理自己本地内存附近的数据否则会发生跨socket或跨NUMA节点的远程访问延迟翻倍。我做了两个调整。第一OpenMP的调度方式从默认动态调度改为schedule(static)让每个线程在M方向连续分配一块数据减少线程切换开销。第二显式设置CPU亲和性和线程数export OMP_NUM_THREADS20 export OMP_PROC_BINDtrue export GOMP_CPU_AFFINITY0-19这样每个OpenMP线程都固定在自己的物理核上运行线程之间不再争抢L2缓存也没有了跨核迁移的开销。这一轮改动后耗时从78ms降到29msGFLOPS到了592。我没有继续往单核极致优化因为这个性能已经能覆盖业务需求而且从工程角度来看再往下减几十毫秒要花的时间成本太高。3.5 算子融合落地端到端延迟再砍一刀GEMM内核优化完了我又看了一眼整个推理链路。原始流水线是GEMM - bias加法 - ReLU激活三个算子分别执行中间两张中间矩阵都要写回内存再读出来。融合的思路非常简单在GEMM的C寄存器写回内存之前把bias和ReLU的计算直接加进去。修改后的尾处理代码大概长这样// 算子融合在GEMM尾处理中直接完成bias和ReLU __m512 c _mm512_loadu_ps(C[i * N j]); // ... GEMM累加 ... if (has_bias) { c _mm512_add_ps(c, bias_vec); } if (has_relu) { c _mm512_max_ps(c, _mm512_setzero_ps()); } _mm512_storeu_ps(C[i * N j], c);这个改动本身没有增加任何计算但省掉了两次全矩阵的访存往返。从端到端看单个GEMM激活链路的延迟从580ms降到了340ms左右收益非常直接。算子库里的融合优化不一定每次都看起来高大上很多时候就是帮内存搬运工省点活。4. 常见问题与排查技巧实录4.1 线程数越加越慢多半是超线程和伪共享的锅在调优过程中我踩过一个问题把线程数从20调到40性能不升反降。后来检查发现机器开启了超线程逻辑核40个但物理核只有20个。两个逻辑核共享同一个物理核的执行单元在纯计算负载下不仅没有加速反而引入了调度开销和缓存争抢。排查方法很简单用lscpu看一下Thread(s) per core如果是2那就把OMP_NUM_THREADS设成物理核数。另外还遇到过一种情况多线程各自写C矩阵相邻行时两个线程写到了同一个cache line的不同部分导致cache line反复在多核之间同步这叫伪共享false sharing。解决办法是让每个线程在M方向上处理连续的行块而不是把行轮流分配给所有线程。4.2 数据重排开销比收益还大需要提前pack我第一版转置B矩阵是在GEMM调用内部做的也就是说每次调用都要先花时间转置一遍。对于大矩阵转置本身的代价不小尤其是单次调用场景这部分开销可能直接把分块和向量化带来的收益吃回去。正确的做法是区分调用频率。如果同一个B矩阵会被多次使用那就把转置放在算子构造阶段提前pack好后续调用直接复用。如果每次调用B都不同可以把pack放到初始化阶段或者和上游算子融合让上游直接输出packed布局。数据重排不是不能做而是要算清楚摊销成本。4.3 混精度BF16/FP16的精度坑不能只盯着速度调优到后期我同事提出用BF16替代FP32来提速。BF16的指数范围和FP32一致但尾数只有7位精度损失相当明显。在我这个业务场景里模型权重分布范围比较大直接换成BF16后最终输出误差上涨了一个量级logits错了一截。后来我们只在特定中间层尝试了混精度并加了loss scaling才在误差可控范围内获得额外加速。这里我想说的是精度换速度的决策一定要有量化指标兜底不要只盯着GFLOPS。我在团队里通常的做法是规定一个相对误差阈值比如max_abs_err 1e-3每次精度优化前先跑一遍回归数据再决定是否合入。5. 调优沉淀下来的方法论5.1 一次算子库调优的完整流程回头看我这次调优的过程其实可以抽象成一个可以复用的流程这也是我给自己团队定的标准动作第一步建立可信的基线固定矩阵大小、数据类型、线程数、编译参数跑多次取中位数第二步用profile工具定位热点算子和瓶颈类型计算密集还是访存密集第三步对照Roofline模型估算理论峰值确定优化空间和优先级第四步逐步实施分块、向量化、并行、融合等优化每改一步就回归一次性能第五步用性能计数器IPC、cache miss、带宽利用率解释为什么有效或无效而不是“看起来快了”这套流程其实和JVM调优、SQL调优没什么两样JVM调优是先看GC日志、堆内存和线程快照再有针对性地改堆大小、GC收集器参数SQL调优是先看执行计划和慢查询日志再决定加索引还是改SQL写法。本质上都是“采样-定位-修改-验证”的循环只不过算子库的采样工具换成了VTune、perf和Roofline。5.2 给后来者的一些小建议最后分享几条我被问得最多的经验编译参数不要用默认值。-O2和-O3 -marchnative差很多尤其是-marchnative可以让编译器针对当前CPU生成AVX512指令。但如果代码要分发到不同机器则需要谨慎可以退而求其次用-mavx2。性能日志一定要保留。我在这次调优过程中每一轮实验都把耗时、GFLOPS、cache miss、线程数记录下来形成一张表。后面复盘时每次“我以为优化了”但实际没变化的情况都是靠日志找回了原因。做一次只改一个变量。千万不要同时改分块大小、向量化方式和线程数否则性能变化了你根本说不清楚是哪一个起的作用。我习惯用一个shell脚本把每一轮的编译命令、环境变量和运行结果一起存下来相当于给性能调优也做了版本管理。这次调优还有一个让我印象深刻的点真正的高性能算子库不是靠一两个“大招”堆出来的而是把访存、计算、并行、融合每个环节的小细节抠到极致。每个环节单独拿出来都有点琐碎但合在一起就是数量级的差距。我们最终把GEMM从21 GFLOPS推到592 GFLOPS当然还有继续往上的空间但对业务来说已经足够调优也要懂得及时收手把时间留给更值得的地方。