大模型推理离不开的MatMul:CANN算子实现与Tiling调优全解析
在CANN的ops-nn算子集合里MatMul是我这几年看得最多的基础算子之一。大语言模型的Embedding、QKV投影、Attention内部的两次矩阵乘、FFN的两个全连接层乃至最后输出头的logits本质上最后都收敛到同一件事矩阵乘。所以不管是做LLM推理性能优化还是翻算子库源码第一个需要死磕的算子几乎都是它。这篇文章想把MatMul彻底展开它在大语言模型里到底占多大比重在CANN的软件栈里是怎么被调用和组织起来的ops-nn里的实现为什么一定要做分块和搬运流水以及实际调Tiling时最常踩的坑。适合正在做LLM推理优化、想入门CANN算子开发或者准备算子相关岗位的人。我会尽量落到数值和代码层面不聊虚的。1. 大语言模型视角下的MatMul为什么它是绝对核心1.1 算一笔账不同阶段的MatMul开销先看Transformer里到底哪些地方在跑MatMul。最常见的位置有这几个位置A矩阵B矩阵典型阶段QKV投影[M, H][H, 3H]prefill / decodeAttention内QK^T[M, D][D, S]prefill 为主Attention内PV[M, S][S, D]prefill 为主FFN gate/up[M, H][H, 4H]prefill / decodeFFN down[M, 4H][4H, H]prefill / decode输出logits层[M, H][H, V]decode 阶段常见这里的M在不同阶段含义不同prefill阶段M接近输入序列长度比如2048或者4096decode阶段M变成了当前batch大小往往只有4、8、16。同样是矩阵乘这两个阶段的性能画像天差地别。举个具体例子。假设一个模型hidden size是4096FFN的intermediate size是11008。单算一层FFN的gate投影即[M, 4096] [4096, 11008]。再假设目标硬件的FP16算力是400 TFLOPSHBM带宽是1.6 TB/s。HBM到算力的roofline转折点是400e12 / 1.6e12 250 FLOP/Byte意思是当计算密度低于250 FLOP/Byte时性能受带宽限制高于这个值性能受算力限制。prefill阶段取M2048那么FLOPs 2 * 2048 * 4096 * 11008 ≈ 184.7 GFLOP访问内存A约16.8MBB约90.2MBC约45.1MB合计约152MB计算密度 184.7e9 / 152e6 ≈ 1215 FLOP/Byte这个密度远高于250所以prefill阶段是典型的算力受限Cube越满性能越高。decode阶段取M8同样这一层FLOPs 2 * 8 * 4096 * 11008 ≈ 721 MFLOP访问内存A约65KBB还是90MBC约176KB计算密度 721e6 / 90.3e6 ≈ 8 FLOP/Byte密度只有8远低于250。哪怕矩阵乘法本身算得再快性能也被B矩阵的读取死死压住。这就是为什么在大模型推理里decode阶段的性能瓶颈经常不是算力而是带宽。同一个MatMul算子在不同阶段要用完全不同的优化思路去对待。1.2 模型越大MatMul越需要抠细节参数量变大时权重矩阵本身也在变大。decode阶段每生成一个token原则上需要把所有相关权重从HBM里读一遍。这个读权重的开销随模型规模线性增长而计算量只在有足够并发时才能打满。这也是为什么很多LLM推理引擎在做算子优化时会特别关注“权重复用”和“访存次数”。另一个现实因素是量化。为了把大模型塞进单卡或者降低带宽压力FP16换BF16、INT8、INT4已经非常普遍。但量化通常最先影响的就是MatMul输入是否需要反量化、累加是否保持FP32、缩放因子是per-tensor还是per-channel这些都会改变算子的内部结构。只看最基础的FP16版本不看量化分支在实际部署里很容易吃亏。2. 从调用链到算子库MatMul在CANN里的位置与实现骨架2.1 一条MatMul的实际执行链路很多看算子源码的人会直接跳到某个kernel函数却忽略了上层调用关系。实际上从框架到NPU一条MatMul要走这么几步用户代码PyTorch/TensorFlow先把计算图交给图编译器或算子适配层框架通过统一的AscendCL接口进入CANN运行层运行层根据算子名找到对应的实现比如aclnnMatMul如果是单算子模式还会在这个阶段完成shape推导和Tiling计算最后把kernel二进制调度到AI Core上执行。ops-nn这个算子集合所处的层次就是“已经实现好的、供上层框架直接调用的神经网络算子”。MatMul在其中属于最基础的公共算子很多上层复合算子或者融合算子内部本质上是复用了MatMul的分块和搬运思路。这里有个容易被忽略的点单算子模式下输入shape是运行时才确定的。这就是为什么MatMul的Tiling逻辑必须在host侧动态计算而不是编译期写死一套参数。即使是同一个算子传入不同的M、N、K内部执行的tile切分都可能是不同的。理解这一点再看源码里的tiling代码就不会觉得绕。2.2 算子实现三件套Tiling、Copy、Compute一个典型的AI Core算子无论用什么语言和框架编写核心骨架基本都由三件事组成Tiling在host侧确定如何把大矩阵切成小块、每个核计算哪几块。Copy把需要的A/B子块从全局内存搬到本地缓存。Compute在Cube单元或者Vector单元上完成矩阵乘计算再把结果从本地写回全局内存。可以打个比方一个算子内核就像一家中央厨房。Tiling是切配师傅负责把大块食材拆成标准化小份Copy是传菜员负责在备料区和灶台之间来回搬运Compute是掌勺大厨真正出菜。如果传菜跟不上大厨再快也白搭。多核情况下Tiling还要负责把输出矩阵的某个区域划分给多个AI Core。比如M2048时可以按M方向切成16块每个核负责其中一块或几块。最终每个核的执行单元一致只是数据地址和tile坐标不同。这也是为什么AI Core编程里“单核逻辑写好多核主要靠坐标参数区分”的原因。2.3 ops-nn中MatMul相关代码怎么读CANN的算子实现在不同版本里目录组织会有差异一般会分host侧和device侧。host侧主要处理Tiling参数推导、shape合法性检查、内存地址对齐device侧是真正跑在AI Core上的kernel其中可能包含matmul、tiling、kernel这些关键词相关文件。我个人的阅读习惯是先找一个固定shape的简单示例跑通之后再去看全功能版本。因为正式算子为了支持动态shape、FP16/BF16/INT8、transpose开关、量化分支代码里会有大量if else。如果从全功能版本开始读很容易被分支淹没反而不容易建立整体认知。如果当前版本的CANN工具链里没有现成的MatMul样例也可以从Ascend C的单算子demo切入把固定shape写死先保证一条主路径完全跑通。等理解了Tiling和搬运结构再逐步打开各种开关。3. 核心实现拆解GEMM分块、搬运流水线与Cube映射3.1 GEMM为什么不写三重循环很多人第一次接触矩阵乘写出来是这种三重循环for (int i 0; i M; i) for (int j 0; j N; j) for (int k 0; k K; k) C[i][j] A[i][k] * B[k][j];这段代码在通用CPU上都能跑但放到AI Core上效率会惨不忍睹。原因在于缓存局部性太差内层循环读取B的一列时B是按行存储的取一列等于不断跨行访问缓存命中率很低同时每个C元素的计算只复用了很少的A/B数据。GEMM的标准优化思路是分块也就是把输出矩阵切成MC×NC的小块再在K维度上按KC分块。每次只在片内计算一个子块A子块和B子块被反复从缓存中读取。这样做的核心目的只有一个提高数据复用率减少全局内存访问。AI Core的存储结构大致可以抽象为全局内存GML2缓存L1缓存以及靠近Cube单元的L0A/L0B/L0C缓冲区。L0A和L0B分别存放A和B的分块输入L0C作为累加器保存结果。不同硬件的具体大小有差异但套路一致计算单元只和最靠近它的那层小缓冲区打交道大矩阵必须先切到能放进去的程度。3.2 Tiling切分数值示例用一个数值例子演示切分过程。假设要算M2048, N11008, K4096的GEMMAI Core数量为20个Cube的最小计算块以16为基准不同硬件可能不同这里只做量级演示。可以取主分块为MC128, NC128, KC256。于是M方向tile数 2048 / 128 16N方向tile数 11008 / 128 86输出tile总数 16 * 86 1376每个核平均处理约1376 / 20 ≈ 69个输出块每个输出块内K方向循环次数 4096 / 256 16再看单个128×128×256子块内Cube调用次数。如果Cube一次算一个16×16×16的小矩阵乘那么一次子块需要M方向16块N方向16块K方向16块共 8 * 8 * 16 1024次小Cube操作内存占用量也很直观。以FP16为例A子块大小是128*256*2 64KBB子块是128*256*2 64KBC累加器按FP32算需要128*128*4 64KB。这个量级说明分块不是拍脑袋而是必须和片上存储容量匹配。MC或NC设得太大L0放不下设得太小Cube被频繁切换流水线损耗变大。Tiling参数之间是互相牵制的。实际工程里往往不能只看单一数值而是要同时考虑多核负载均衡、缓存占用、搬运粒度。这也是为什么调整Tiling最花时间的部分。注意不同CANN版本、不同NPU型号的Cube最小块大小和L0容量都不一样。上面这组数字是用来演示计算过程不是通用于所有硬件。真正做算子适配时要查对应硬件手册。3.3 搬运与计算如何重叠如果实现方式是“先Copy等Copy完再Compute等Compute完再CopyOut”每个阶段都有大量空闲等待。最简单的优化就是双缓冲。双缓冲的流程是当前块的A/B在L0中参与计算时下一块的A/B已经开始从GM往L1搬运。等当前块算完下一块的数据也已经就位Cube可以无缝切换。这样搬运时间和计算时间在时间线上重叠硬件利用率才能真正拉上去。在多缓冲实现中缓存块数量一般设置成2或3。数量太少搬运来不及隐藏数量太多占用的片内存储过多反而挤压Tiling空间。这中间需要靠同步机制保证不能把还没计算完的缓冲块覆盖掉也不能在数据没到位时就让Cube启动。实际调参时我通常会在性能profile数据里看两个数字Cube利用率和搬运等待占比。如果搬运等待很高优先级是加深缓冲、调整同步位置如果Cube利用率本身很低再看是不是Tiling切小了Cube启动次数太多导致有效计算占比下降。3.4 FP16/BF16/INT8与累加精度MatMul在大模型里的精度策略不是只有“半精度输入、半精度输出”这么简单。通常Cube单元内部会用FP32做累加来避免K维度累加误差被放大。K一旦到4096甚至更大如果中间累加只用FP16精度损失会非常明显这也是很多“算出来但diff很大”的根因。BF16相对FP16来说指数位更多、范围更广但尾数位少单个数值精度低一些。好在很多大模型推理场景对权重范围的宽容度更高所以BF16矩阵乘在LLM里很常见。FP16则需要注意数值溢出和loss scaling的问题。INT8量化引入的改动更大。MatMul输入不再是单纯的半精度张量还伴随scale和zero point。有些实现会把反量化放在MatMul内部完成也就是输入INT8、输出FP16有些则把反量化放在后处理阶段。这让算子的接口和内部Tiling都要增加相应处理分支。一个常见的误区是看到算子输入是INT8就以为输出也必须是INT8。但实际上主流的LLM量化推理中矩阵乘累加是用INT32或者FP32完成的最后再通过scale转回FP16。检查精度问题时先确认累加和输出类型往往比反复怀疑硬件更快。4. LLM场景调优清单decode、KV Cache与算子融合4.1 decode阶段同一个MatMul另一种活法decode阶段M很小比如batch为4或8时A矩阵只有[4, 4096]。这种形状的浮点计算量非常低但B矩阵仍然大于是整个算子的瓶颈彻底变成了B矩阵的读取。应对思路之一是让多个小请求向一个“大GEMM”靠拢把多个请求的Q拼成更大的A矩阵尽量复用同一份B权重。这也是很多推理框架做连续批处理和动态打包的原因之一。连续的请求不断进来、走掉M不断变化那么算子的Tiling就不能静态固定必须在运行时根据当前batch重新算。另一个思路是“忽略M方向的细粒度平衡”直接用很小的MC把输出切成细条。虽然这样Cube利用率不高但至少能够发挥带宽优势。decode场景反正算力没用满牺牲一点计算并行度换取更短的搬运路径实测下来经常更快。4.2 KV Cache与GQA/MQA对MatMul的shape影响Attention里有两个关键矩阵乘一个算QK^T一个算PV。常规多头注意力中每个头分别做矩阵乘等价于在M、N、K三维之外多了一个“head”维度。编程时可以把head合并到batch维度处理也可以单独循环。GQA/MQA出现后情况变得复杂。多个查询头共享同一组K/V如果直接按传统GEMM展开要么重复读取同一份K/V数据要么支持“批量里带广播”的分组GEMM语义。这会让Tiling逻辑从简单的二维切分变成“先分组、再按组内GEMM切分”的两级结构。KV Cache的数据排布也常给MatMul提供“非连续输入”。分页式KV Cache会把K/V拆成多个block每个block有自己的物理地址和stride不再是标准GEMM要求的连续内存。于是要么做一次layout重排再进MatMul要么在算子内部支持带stride的输入访问。后者看起来只多了一个参数实际对搬运和地址计算的改造不小。4.3 算完立刻融合把后处理并进MatMul大模型里常见的计算链是MatMul - Bias - ActivationGELU/SiLU- 残差 - Norm。如果每个算子都单独执行每经过一个算子中间结果就要写回全局内存再读出来。这个开销在decode这种带宽受限场景里尤其致命。合理的做法是让Cube算完的结果留在L0C里交给Vector单元做后处理处理后只把最终结果写回GM。融合算子看起来复杂实际收益非常直接省掉了多次全量读写。但融合也不是越多越好。如果Vector单元本身已经很忙把过多后处理塞进来会导致Vector成为新瓶颈。通常建议先对每个后处理的GM读写量做估算再决定融合边界。比如一个简单的MatMul Add几乎无脑融合如果把LayerNorm也硬塞进来就要考虑有没有额外的归约开销。4.4 性能观测指标一览调优时我习惯同时盯几个数字指标看什么通常能说明Cube利用率计算单元是否吃满偏低时先怀疑切分或同步搬运带宽访存是否到顶接近峰值时说明带宽瓶颈GM read/write bytes是否有多余读写偏离理论值说明存在重复搬运实际TFLOPS端到端有效算力对算力受限场景直接衡量收益端到端时延最终用户体验所有调参最终落在时延上计算实际FLOPS的公式是2*M*N*K / time。把这个值和理论算力对比就能得出算力利用率。如果是decode场景我通常先看搬运带宽是否接近硬件峰值如果已经接近就没必要在tile大小上继续折腾重点应该转向减少访存量如果是prefill场景优先看Cube利用率。5. 踩坑实录MatMul算子常见问题与排查方法5.1 常见错误速查表先把容易遇到的问题整理成一个表方便快速对照现象可能原因排查点算子执行返回参数错误shape为0、dtype不支持检查输入张量的M/N/K和dtype结果与CPU参考不一致输入存在NaN/Inf、累加精度不对打印输入A/B逐项检查精度误差偏大transpose语义理解反了确认transA/transB是否正确性能远低于预期Tiling过大或过小观察Cube利用率和搬运等待内存访问报错地址未对齐、数据非连续检查张量stride和起始地址量化结果整体偏移scale/dequant路径不一致检查per-tensor/per-channel配置在LLM场景里transpose方向特别容易错。比如PyTorch里常用x weight.T而底层MatMul接口可能默认B不做转置。如果直接把weight交给算子而忘了手动转置算出来的结果就会是另一个形状的矩阵表现通常是“维度对不上”或者“结果和参考完全不匹配”。5.2 一个精度问题的完整排查流程遇到精度问题我的固定流程是先把shape缩小到64左右能快速复现然后同时用FP32和FP64算一份参考结果再针对输入A/B检查数值范围。接着要确认算子的累加路径。用半精度输入但FP32累加时K为1024量级的误差通常可控如果误差大到离谱优先怀疑量化路径。之前我遇到过一种情况量化权重在进入算子之前已经被反量化了一遍算子内部又根据“INT8输入”的分支做了第二次反量化结果等于乘了两个scale。这类问题通过检查输入数值分布就能发现输入本来该是整数结果却是一堆小数。最后可以对比L0C里前几个累加结果的中间值和CPU逐步累加的中间值定位是否在某个K段开始出现显著偏差。不要一上来就怀疑硬件这个流程走完绝大多数问题能定位到软件层。5.3 性能不达标时的三步定位第一步判断理论形态。用2*M*N*K / 访存量粗算计算密度对照硬件roofline转折点先分清是算力受限还是带宽受限。这一步能避免后续做无用功比如带宽受限场景却拼命增大MC。第二步看profile数据中的Cube利用率和带宽占用。如果带宽已经接近峰值说明访存已经是天花板接下来要减少访存量而不是调计算如果Cube利用率很低且带宽远没跑满说明搬运和计算的同步有问题重点检查缓冲深度和等待逻辑。第三步做一个对照实验把K维度缩小或者把搬运完全跳过只测计算观察时间变化。这样可以分离“计算耗时”和“搬运耗时”。按我的经验70%的情况最终会落在“数据搬运没有和计算重叠”或者“Tiling切得不够规整导致额外寻址开销”这两类问题上。5.4 换dtype后性能莫名其妙变化FP16和BF16都是半精度但换过去之后性能不一定相同。原因是不同数据格式可能走不同的Cube路径也可能影响内存对齐和L0缓冲占用。如果发现BF16比FP16慢不少先查是否走了回退分支。这类情况在日志里往往不显眼需要通过查看实际调用的kernel路径来确认。还有一个容易被忽略的点dtype变化后tile内字节数变了原来合适的MC*KC可能现在放不进L0。比如FP16能放下的块换BF16之后虽然也是2字节但某些硬件的BF16计算路径对L0缓冲有不同分配限制。此时重新算一遍tile容量往往能解释性能差异。6. 动手实现一个最小MatMul算子的Tiling与验证6.1 设定最小case与Tiling为了让流程完整我不建议从大shape开始。先固定一个MNK64的小矩阵FP16输入、FP32累加、FP16输出。跑通之后再逐步放开shape和dtype。假设当前环境有4个AI Core参与计算。最简单的方式是只按M方向切分每个核算MC16, NC64的一块也就是输出矩阵的16×64区域。K方向上KC64一次搬入整块。这种切分的好处是思路清晰没有复杂的K分块和多次循环。缺点是M方向只有4块时多核并行度就固定在4没法充分利用更多核。它适合作为学习用的最小实现不适合直接作为高性能版本。6.2 Ascend C高层封装写法示意下面是核心结构示意用Ascend C风格写一个大致的框架#include kernel_operator.h using namespace AscendC; constexpr uint32_t MC 16; constexpr uint32_t NC 64; constexpr uint32_t KC 64; class KernelMatMul { public: __aicore__ inline KernelMatMul() {} __aicore__ inline void Init(GM_ADDR a, GM_ADDR b, GM_ADDR c) { aGM.SetGlobalBuffer((__gm__ half*)a); bGM.SetGlobalBuffer((__gm__ half*)b); cGM.SetGlobalBuffer((__gm__ half*)c); // 分配本地buffer这里简化处理 aLocal.SetGlobalBuffer(aGM.GetAddress(), MC * KC); bLocal.SetGlobalBuffer(bGM.GetAddress(), KC * NC); cLocal.SetGlobalBuffer(cGM.GetAddress(), MC * NC); } __aicore__ inline void Process() { CopyIn(); Compute(); CopyOut(); } private: __aicore__ inline void CopyIn() { // 把当前核负责的A子块、B子块从GM搬到LocalTensor } __aicore__ inline void Compute() { // 按Cube小矩阵乘的方式完成累加 } __aicore__ inline void CopyOut() { // 把L0C结果写回GM } GlobalTensorhalf aGM, bGM, cGM; LocalTensorhalf aLocal, bLocal; LocalTensorfloat cLocal; };这段代码重点不是“直接能编译”而是呈现一个算子内核的骨架Init完成地址绑定和buffer分配Process把搬运、计算、写回三个动作串起来。真正工程化的MatMul会用更高层的封装或者把Tiling参数从host侧传进来但理解主线后看那些实现就不会晕。如果只是想在项目里快速使用库内MatMul完全不需要自己写kernel直接调用aclnnMatMul这类接口即可。自己动手写的最小算子主要用于理解内部发生了什么以及后续定制特殊layout时能改得动代码。6.3 内核入口与调用侧内核入口一般长这样extern C __global__ __aicore__ void matmul_kernel(GM_ADDR a, GM_ADDR b, GM_ADDR c) { KernelMatMul op; op.Init(a, b, c); op.Process(); }实际工程里算子需要经过算子工程的编译、注册才能被CANN识别和调度。这里我们不做完整的算子工程配置展开只看核心函数。使用Native接口或者通过框架调用时底层最终都会走到类似的入口函数。要注意的是GM_ADDR只是全局内存指针的包装。AI Core不能直接把这个地址当普通CPU指针解引用必须先绑定到GlobalTensor再通过LocalTensor搬入片内缓冲区。这是很多刚上手的人容易犯的错误在kernel里直接解引用GM地址结果行为异常。6.4 正确性与耗时验证验证正确性时最省事的方法是用NumPy生成参考结果。输入用随机数覆盖不同数值范围避免全零或全一掩盖问题import numpy as np M, N, K 64, 64, 64 a np.random.uniform(-1, 1, (M, K)).astype(np.float16) b np.random.uniform(-1, 1, (K, N)).astype(np.float16) ref (a.astype(np.float32) b.astype(np.float32)).astype(np.float16) # out 是从NPU读回的结果 err np.abs(ref.astype(np.float32) - out.astype(np.float32)) print(max abs err:, err.max())个人经验是FP16输入、FP32累加、输出FP16时同样随机输入范围的误差通常可以控制在比较小的量级。如果误差达到1e0甚至更高基本不是浮点误差的锅而是逻辑、数据格式或量化路径的问题。测性能时计算实际TFLOPS用2*M*N*K / time并和理论峰值对比。刚开始写出一个利用率不高的小算子很正常之后可以按前面的调优思路逐步增加缓冲深度、调整分块、引入多级流水对比每次改动前后的三个核心数字Cube利用率、搬运带宽、实际TFLOPS。最后分享一个自己的习惯每改一次Tiling参数我都会把这三个数字记在同一个表格里。MatMul调试的大多数问题最后都会落到这三个数不匹配上。先判断算力受限还是带宽受限再决定调Tiling还是减少访存量比盲目翻代码、反复试参数有效得多。