
我最近在调一个图像处理Demo瓶颈卡在了一个最基础也最容易被忽略的算子上——卷积。调用接口并不复杂真正跑起来才发现整条流水线的时间都被这一个算子吃掉了。这种事在CANN生态里相当常见很多人习惯从计算图、内存分配的层面去找优化空间改到最后才意识到性能的密码埋在底层算子库的细节里。这篇文章想把细节掰开讲清楚核心就是ops-nn这个算子库里的卷积算子实现与优化。如果你是正准备在AI处理器上做算子开发、模型部署优化的人或者只是好奇一个朴素卷积是怎么被一步步压榨到极限的这篇内容应该能给你一些参考。我会先从算子库的定位说起然后深入底层的布局和切分最后用一次完整的调优过程讲清楚不同优化手法的取舍。这些都是我实际动手调过后攒下来的东西不是文档里能直接看到的那种答案。1. 先说清楚ops-nn在CANN生态中到底管什么1.1 从图编译到算子执行中间隔着一大堆细节很多刚接触CANN生态的人会把“算子”和“计算图”混在一起讨论觉得算子的行为就是图编译器定的。实际上一个模型从定义到真正跑在NPU上要经过好几层。顶层是计算图和图引擎图引擎负责算子调度、内存规划、融合决策这些决策最终会落到一个个具体的算子实现上。ops-nn就是这一层最核心的算子库它装着卷积、池化、归一化、激活这类底层算子每个算子内部都封装了大量和硬件强相关的逻辑。图编译器可以决定“把Conv和ReLU融合”但“融合之后的数据往哪放、矩阵单元怎么算、向量单元怎么配合”这些事编译器不知道或者说编译器不该知道得那么细。这正是ops-nn存在的意义它把硬件细节封装成一个个算子实现让上层只需要关心算子的语义。你可以把它理解成装修里的水电工图纸画得再好水管和电线最终还是得靠这个人接得对不对、走得顺不顺。有个容易忽略的点同一个卷积因为输入shape不同、数据布局不同、dtype不同、stride不同底层执行的路径可能完全不一样。ops-nn内部会有各种分支和tiling逻辑去适配这些差异这也是为什么修改输入尺寸后性能会突然变差——很可能不是模型变了而是算子内部走了另一条没优化好的分支。1.2 什么场景才需要去读ops-nn的源码用现成接口是绝大多数时候的选择但有几个场景你不去看底层实现是根本搞不明白的。最常见的是性能异常比如卷积耗时突然暴涨光看调用参数看不出问题得进到算子内部看tiling和搬运策略。第二种场景是自定义算子当你准备写新算子或者扩展某个融合逻辑时ops-nn里的成熟实现就是最好的参考至少能告诉你一个可用的算子内核循环长什么样。第三种是精度问题浮点累加顺序、中间精度的取舍、边界值处理这些只有在算子实现层面才看得见。我自己最长遇到的是第四种场景组合算子效率上不去。很多人觉得几个算子拆开来写、最后通过图引擎融合就够了但融合不是免费的事它要求算子之间得满足数据依赖和布局约束。不读ops-nn的算子约束你很难判断一个融合方案到底合不合法只能瞎试。我建议所有做部署优化的朋友至少要把常用算子的“数据流方向”弄清楚不用背代码但要能知道它从Global Memory读到片上缓存之后哪些计算在矩阵单元、哪些在向量单元、哪些地方会产生等待。1.3 现成算子、组合算子与自定义算子怎么取舍这个问题几乎每次技术讨论都会碰到。拿卷积来说你可以用ops-nn现成的底算子也可以用多个小算子凑还可以自己写一个定制实现。三者的差异不是简单的“性能越高开发成本越高”还得看运行时的稳定性和后续维护成本。方案开发成本运行时开销可控程度适用场景现成算子最低中低常规网络、追求快速落地组合算子中高中需要灵活调整语义、实验阶段自定义算子最高低高瓶颈明确、性能要求极高组合算子听起来灵活但每个小算子都意味着一次片外数据的写回和重新读取这在大模型场景下经常比计算本身还要贵。自定义算子确实能把开销压到最低但也意味着你要处理对齐、缓存、同步、边界等一堆细节往往一个算子就要在调试上耗掉一两个星期。我的建议是先确认瓶颈再决定要不要动手自己写。如果profile数据显示内存搬运占了大头那问题可能根本不在算子内核而在融合策略如果数据显示矩阵单元利用率很低这时候自己实现一个定制算子才值得。2. 卷积算子在NPU上的实现内幕为什么不能照搬CPU思路2.1 卷积在数学上是循环但NPU最讨厌低效循环卷积的定义大家都熟以单输出通道为例[ y[n][co][ho][wo] \sum_{ci}\sum_{kh}\sum_{kw} x[n][ci][ho\cdot sh kh - ph][wo\cdot sw kw - pw] \cdot w[co][ci][kh][kw] ]翻译成循环的话至少有N、CO、CI、KH、KW、HO、WO这七层。CPU上常用的优化手法是重排循环顺序、用SIMD处理内层向量但NPU不一样它的算力大头在矩阵计算单元上你需要把卷积变成矩阵乘让矩阵计算单元高效跑起来。最标准的做法是im2col加GEMM把输入按滑动窗口展开成一个二维矩阵一行对应一个窗口位置列对应 CI×KH×KW 的输入通道和窗口组合权重按同样方式排成另一个矩阵两个矩阵做乘法就能得到输出。这个思路的好处是计算密集度高能喂饱矩阵单元。代价是输入会被重复展开很多倍内存占用会膨胀。NPU上的实现不会傻到把整个输入矩阵完整展开到内存里而是在tile级别做局部展开每次只取一个块在片上内存里完成展开和矩阵乘算完就把临时结果丢掉。这就要求数据的局部复用特别好一个输入元素要被多个输出窗口用到最好在片上缓存里被反复利用而不是每次从全局内存重新读。真正优秀的卷积实现拼命在做的其实是两件事让输入数据在片上被复用尽可能多次同时让权重在片上也尽量驻留。2.2 数据布局的讲究NCHW、NHWC与NC1HWC0CPU上大家习惯了NCHW或者NHWC按哪个方向展开问题不大因为cache命中的差异可以用循环骨架去弥补。但NPU上数据布局直接决定了搬运效率。原因很简单硬件做矩阵运算时输入从全局内存搬进片上缓存必须是按固定字节长度连续搬的如果布局不连续就得做很多次小搬运效率立刻掉下来。CANN生态里除了常见的NCHW和NHWC还有一个面向硬件特性的布局方案常被叫做5HD形式上是NC1HWC0。它的核心思想是把通道维度拆成两部分C0是硬件向量处理的基本长度通常取16或者32C1是剩下的通道数。数据在内存中先把C0个通道连续排在一起再排下一个C1块。这样一来同一个像素位置的一组通道在内存里连续搬运和向量计算都能保持整块操作。拿一个C3的输入来说如果直接按C016对齐有效数据只有三路剩下13路全是填充有效算力利用率不到20%。这就是为什么NPU上的模型往往会做通道重排或者预填充把多个不满16通道的算子合并、凑满对齐宽度。很多第一次在NPU上写卷积的开发者会在这里栽跟头总觉得对齐是浪费却不知道不对齐导致的散转和小搬运比对齐浪费还要命。布局优点缺点适用场景NCHW语义直观、工具链兼容通道维不连续NPU搬运效率差早期原型、跨平台验证NHWCHW维连续适合逐像素计算矩阵乘时权重布局需要额外重排部分后端优化NC1HWC0(5HD)通道按硬件宽度连续搬运密集需要重排转换、非通道维操作复杂NPU算子执行主场景2.3 Tiling把大象装进冰箱的关键分块思路片上内存永远不会大到你一次性把整张特征图都放进去。一个典型的卷积块输入部分、权重部分、输出部分都得同时驻留再加上中间计算需要的临时buffer总量必须控制在片内可用内存以内。于是就有了Tiling问题把整个计算拆成多个块每个块都能在片上装下同时要让块之间的重复计算和搬运尽量少。这里有个很基本但常被忽略的公式块大小估算时至少要满足[ \text{片内占用} \text{输入块} \text{权重块} \text{输出块} \text{临时展开区} \le \text{可用片上内存} ]如果还想做双缓冲每个buffer还得再乘2。所以Tiling不是随便切而是要在“块足够大以复用数据”和“块足够小以塞进片上内存”之间找平衡。切分维度的优先级很重要。通常优先切N和H因为这两个维度切掉后不影响通道方向的连续搬运W维度不轻易切切得太碎会让向量化效率急剧下降。CO维度也要考虑因为权重是按CO切分的切大了权重重复搬运多切小了矩阵乘的K维不够长。实际工程里Tiling策略往往要针对典型shape单独调很多算子库会为常见shape预生成几套tiling参数同时保留运行时动态计算tiling的能力。2.4 一个conv算子的逻辑骨架说了这么多落到代码层面ops-nn里的卷积内核大约是下面这种结构。我做了大量简化但逻辑骨架是一样的。# 简化版展示卷积算子的主要计算阶段 def conv_npu_kernel(params, global_in, global_weight, global_out): tiles plan_tiling(params) # 根据输入shape和片上内存计算分块方案 for tile in tiles: copy_in(global_in, ub_in, tile) # 搬运输入块到片上 copy_in(global_weight, ub_w, tile) # 搬运权重块到片上 gemm_cube(ub_in, ub_w, ub_out, tile) # 矩阵计算单元做矩阵乘 vector_bias(ub_out, bias, tile) # 向量单元加bias vector_act(ub_out, act_type, tile) # 向量单元做激活 copy_out(ub_out, global_out, tile) # 计算结果写回全局内存搬入、矩阵乘、向量操作、搬出这四个阶段构成了一个基本内核循环。真实实现里向量单元可以和矩阵单元交替工作搬入下一块时可以和当前块的计算重叠具体下一章展开讲。这里只要记住卷积的底层其实就是“搬数据、做矩阵乘、搬结果”的往复优化空间也全部集中在这几个阶段的交错和复用上。3. 优化不是靠调参是把“等待”一个个消掉3.1 算子融合减少片外数据往返这是收益最大的一步很多第一次上手优化的开发者会把精力花在调整矩阵乘的block大小、加缓存对齐这些细节上却忘了最有可能拿到质变的操作是算子融合。拿ConvBiasReLU这个组合来说如果不融合卷积的输出要写回全局内存bias算子再从全局内存读一遍ReLU再读一遍。每一次往返都是几倍的带宽浪费。融合之后卷积的输出可以留在片上直接继续做加法和激活最终只要把结果写回一次。搬运量能从三份数据变一份耗时下降往往立竿见影。我见过不少模型推理时间直接砍半就是因为图引擎把这类融合正确打开并落到了算子内核。融合不是想合就能合它要求相邻算子的数据布局一致、输出能留在片上、算子间的同步约束可控。ops-nn里的卷积实现之所以要专门留出bias和激活的入口就是让上层可以传一个融合标记内核在vector单元直接完成这些操作省掉中间算子的启动和搬运开销。3.2 Double Buffer与流水让搬运和计算重叠起来NPU上最浪费时间的不是计算本身而是等待。搬运上一块数据的时候矩阵单元闲着矩阵单元在算的时候搬运单元又闲着这显然不合理。双缓冲的思路很简单准备两块片上buffer一块用来给当前块做计算另一块同时接收下一块的搬运两块轮流切换形成流水。你可以把它想成厨房里的备菜和炒菜。一个厨师如果每次都等菜洗好了才开始炒厨房效率就很低如果他有一个案板专门用来备下一道菜炒菜的同时案板上的菜已经在准备整体吞吐就上来了。双缓冲就是给NPU加上这个“备菜案板”。实现双缓冲后理论上单块的耗时会从“搬运时间计算时间”变成“max(搬运时间, 计算时间)”。当然真实情况还会受同步开销影响buffer分配和地址切换也有成本但方向是对的。这里有个容易踩的坑双缓冲会把片上内存占用翻倍如果你原本Tiling块就很大强行双缓冲会导致块变小搬运次数变多结果性能反而下降。所以双缓冲要跟Tiling一起调让块大小和buffer份数达到平衡。3.3 向量化与标量化尽量别让标量单元干重活NPU里通常有标量单元、向量单元和矩阵单元。矩阵单元管大块矩阵乘向量单元管逐元素计算标量单元适合做循环控制、地址计算这类“轻量级”的事。但很多自定义算子在实现时会把逐元素的索引计算、边界判断、临时变量都放在标量单元里导致标量单元成为瓶颈。类似“每个元素都要根据循环变量计算一个地址”的操作在CPU上可能无所谓在NPU上就可能拖垮整个内核。因为这些计算不能和矩阵单元的流水并行标量单元一旦满了矩阵单元就只能停下来等。优化手段是尽量把这类工作交给向量单元或者通过预计算的步长直接让硬件连续寻址。我调过的算子中有不少就是删掉内层标量判断把地址增量改成固定步长整体性能涨了百分之二三十。越简单的循环结构硬件越容易把它流水化所以能在循环外做的事千万别放到内层循环里一遍遍算。3.4 对齐、尾块和“被吃掉”的有效算力数据对齐是NPU优化里没法绕开的话题。硬件按固定宽度搬运和计算比如一次处理16路或者32路数据。如果输入通道数不对齐就得在尾部填充无效数据这部分计算是纯浪费但比不填充导致的零散搬运要划算得多。尾块问题是另一个坑。当输入尺寸不能整除对齐宽度时最后一个块往往只有很少的数据量却要承担和其他块一样的调度开销。处理尾块有几种常见思路要么把尾块和相邻块合并处理要么调整切分策略让尾块尽量发生在数值计算量小的维度上要么干脆在数据预处理阶段把shape补齐避免运行时的特判分支。有效算力这个概念值得多说一句就算矩阵单元在满负荷工作如果有一半算的是填充数据你的实际效率也只有一半。所以看性能指标不能只看单元利用率最好把“有效计算占比”一起算出来避免被表面数据误导。3.5 什么情况下才值得用Winograd卷积优化的另一个思路是换算法。3x3、stride1的卷积在CPU和GPU上Winograd一直很常用它通过变换把卷积变成对应位置的乘法乘法次数可以降到直接卷积的一半左右。NPU上的矩阵单元同样受益但绕不开两个问题额外的数据变换本身也要消耗计算和搬运变换后的中间结果位宽变大精度控制更难。我在实际中只在两种情况会认真考虑Winograd一是模型基本全是3x3卷积、stride1变换开销可以被大规模摊薄二是对精度要求不极端int8场景下误差曲线是可以接受的。否则还是先把直接卷积的数据复用和流水做好这是更稳妥的第一步。4. 一个从120us优化到52us的实测过程4.1 第一版先保证功能正确其他都不管我用一个模拟项目做了次完整调优初始版本就是最简单的“编译能过、结果正确”的卷积。它处理的是一个固定shape的输入直接循环搬入整块数据到片上不做切分、不做双缓冲、不做融合bias和ReLU也都是单独的算子。第一版跑下来卷积kernel耗时120us。从profile上看矩阵单元利用率只有22%剩下的时间基本都花在等待搬运和算子切换上。内存搬运占比63%这个数字已经说明问题算子根本没有把片上内存的复用做起来数据一直在全局内存和片上之间反复进出。第一个结论很简单功能跑通不等于能上线差得远。做算子优化的人看到这种profile应该立刻意识到两个方向一个是数据复用没做够另一个是计算和搬运没有重叠。4.2 第一轮调整Tiling从“整块卡死”到“分块流水”第一轮改动没有碰融合只调整Tiling。原来的实现试图把整个输出的切块直接放上片导致一个块装不下很多关键数据搬运次数极高。我把切分维度从“按N整块切”改成“按CO方向优先切”同时保证每个块内的输入数据跨越多个输出位置让输入能充分复用。这个改动很直接矩阵单元利用率从22%提高到35%kernel时间降到98us。为什么收益输入不再被反复从全局内存读权重块的重复搬运也少了很多。这时候总耗时还是高但至少能看到计算单元的忙碌程度在往上走。4.3 第二轮合并Bias和ReLU端到端收益最明显第二轮最划算就是把Bias和ReLU直接并进卷积内核。原来的流程是卷积算完写回全局内存Bias算子读一遍ReLU再读一遍三次读写都在片外绕圈。融合之后卷积输出留在片上向量单元顺手加bias、做激活最后一次性写出。这个改动没有调整矩阵乘本身但端到端时间从98us降到了71us。搬运量减少带来的收益非常直接。这也再次印证我前面的观点算子优化不一定非得死磕矩阵乘的算力先把数据流动捋顺收益通常更快、更稳定。融合还有一个隐藏好处中间结果不需要写回全局因此不需要为它申请额外内存、做额外的数据布局转换这在大模型场景里还能顺带降低显存占用。4.4 第三轮双缓冲加向量化从71us到52us第三轮开始有点细节的味道了。我先给搬入和计算加了双缓冲让下一块数据的搬运和当前块的矩阵乘重叠。初期因为buffer翻倍Tiling块不得不缩小性能几乎没动后来我调整了CO方向的切分比例把块大小控制在片上内存的70%才真正让流水跑起来。之后又把内层循环里的标量索引计算改成预计算步长减少标量单元对流水线的干扰。这轮做完kernel时间到了52us矩阵单元利用率超过70%。相比最初120us整体收益2.3倍。但这个2.3倍不是某一次“神优化”带来的而是四次改动叠加的结果每一步的取舍都对最终效果有贡献。阶段改动内容kernel耗时Cube利用率搬运占比初始版本朴素实现无融合120us22%63%第一轮调整Tiling方向、增强数据复用98us35%51%第二轮融合Bias和ReLU71us46%36%第三轮双缓冲、向量化、尾块优化52us71%18%4.5 过程中踩过的坑越界、同步和精度优化过程中有三个坑非常典型。第一个是tiling参数改了以后某个边界块的尺寸和buffer容量不匹配搬运越界跑出来结果时对时错。这类问题最难查因为它不是每次都触发。我后来学乖了每个tiling分支都做一次极端shape测试最大尺寸、最小尺寸、刚好整除对齐的尺寸。第二个坑是双缓冲的同步。双buffer模式里计算单元必须等搬运完成任务再开始但等待信号如果放错了位置就会出现“上一块还没搬完下一块就开始算”的竞争条件。这种问题在单core场景下不明显一开多核就容易偶发只能靠硬件调试工具一遍遍抓。第三个坑是精度。float16累加顺序不同结果会有细微差异int8场景更明显。尤其在多核并行时输出被拆到不同核上算每个核内部的累加顺序不一定一样最终数值和CPU参考结果可能有偏差。这不一定是实现错了可能是算法本身的舍入特征。需要先定好验收误差范围再判断是不是bug。5. 验证与排障别让优化变成纸面性能5.1 数值一致性怎么测才靠谱每次调整完算子第一个问题不是“快了多少”而是“结果还对不对”。最稳妥的做法是准备三组数据随机输入、边界极值输入、全零输入。随机输入用来覆盖常规场景边界极值用来暴露溢出和填充错误全零输入用来检查bias和激活是否被错误跳过。对比指标不要只看绝对误差。卷积输出通常跨度大有的位置数值很小有的位置很大绝对误差没法公平反映整体一致性。我一般会同时看相对误差和余弦相似度相对误差用来过滤逐点异常余弦相似度用来衡量整体形状是否一致。对float16场景建议把误差阈值定在1e-2到1e-3之间对int8场景允许的绝对误差通常卡在1以内。还有一个容易被忽略的点不要只测一个shape。卷积的tiling是shape相关的一个shape对了不代表所有shape都对。准备一张shape回归表至少覆盖典型shape、最小shape、最大shape和带pad的shape每次改完跑一遍。5.2 看Profile时要盯住哪几个指标算子优化的关键指标其实就几个但每个都对应不同的问题。矩阵单元利用率低通常是数据复用不足或者矩阵乘K维太短向量单元利用率高但整体慢可能是搬运和计算没有重叠带宽跑满了大概率是融合不够导致数据反复读写stall等待周期多就要去看是不是同步和流水设计有问题。指标含义常见异常征兆Cube/MAC利用率矩阵单元有效计算占比低说明数据复用差或K维太短Vector利用率向量单元繁忙程度过高而Cube低说明负载不均衡内存带宽利用率片外搬运是否饱和高通常需要算子融合减少往返Stall等待周期单元间互相等待时间高检查双缓冲和同步逻辑profile数据要结合起来看不能只盯一个指标。我曾经看到矩阵单元利用率已经很高但端到端时间还是慢后来才发现是尾块把整体时间拖住了。只看单元利用率这个尾块问题根本看不见。5.3 三个常见的优化误区第一个误区是只看kernel时间不看端到端时间。kernel快了但如果中间数据布局需要反复转换或者同步开销被放大了整体可能反而更慢。优化一定要从端到端时间出发确认瓶颈确实在卷积才去动卷积。第二个误区是拿CPU或GPU上的优化经验硬套到NPU。CPU上常常靠多级cache自动管理NPU的片上内存是显式管理你不去安排数据复用硬件不会替你安排。很多在x86上成立的“循环展开”“cache blocking”经验换到NPU上要重新思考数据从哪来、往哪去。第三个误区是忽略多核扩展性。单核调得再快如果多核切分不均、尾任务拖后腿实际多核加速比可能远低于核心数量。设计Tiling的时候就要考虑任务数能不能均分剩余的小任务要尽量合并到其他块里。5.4 一套我自己常用的排查路径性能问题来了之后我一般按固定顺序走先看全局时间构成确定瓶颈算子再单独跑这个算子看profile里搬运和计算时间占比。如果搬运占比高优先检查数据复用和融合情况如果计算占比高接着看矩阵单元利用率如果利用率低再查Tiling尺寸是否合适、K维是否够长、双缓冲有没有真正重叠。按这个顺序基本能把大部分“卷积怎么调都不快”的问题收敛到具体原因。比无脑调block大小要靠谱得多。另外提一句算子版本对比时一定要固定软件环境和硬件型号否则同样的代码在不同环境下的profile数据差异很大容易得出错误结论。我就在这个上吃过亏同样的优化换了环境后性能倒退排查了半天才发现是环境问题不是算子代码问题。最后分享一点个人体会折腾完这一轮我最大的感受是NPU上的算子性能很少由单一因素决定。调好Tiling发现卡在搬运调好搬运发现卡在融合调好融合又发现尾块在拖后腿。与其指望一次到位不如建立一个“性能-约束”的思考框架把每次改动当成一个可验证的实验。还有一个建议是动手写卷积之前先把输入布局和后续要做的算子一起想清楚。通道对齐、数据布局、融合边界这些都不是孤立问题前一个算子的输出布局会直接影响后一个算子的性能。这个顺序对了后面能省掉大量反复优化和排障的时间。如果你也正在和ops-nn里的卷积算子较劲希望这篇能帮你少走几步弯路。算子优化这件事说穿了就是个不断和硬件对齐的过程耐心一点总能把性能抠出来。