有一次线上推理服务的延迟告警,我打开 profilling 数据看了一眼,将近一半时间耗在几个看起来不起眼的算子上面。最初我以为是模型结构的问题,顺着计算图一路排查下去,才发现真正的问题在更底层——算子库。对做 AI 推理工程化的人来说,算子库是一个很容易被忽略、却决定最终性能上限的模块。这篇文章就以一套国产 AI 推理引擎的 ops-nn 算子库为线索,讲讲算子库在整个推理链路里的位置、算子执行背后的机理,以及我在算子级性能优化里反复用到的方法和踩过的坑。无论你是做模型部署、推理引擎二次开发,还是想搞清楚为什么同一个模型在不同平台上性能差这么多,这篇都值得继续往下看。
1. 瓶颈往往不在框架,而在算子库这一层
1.1 算子库到底在推理引擎里扮演什么角色
推理引擎的经典分层大致是:前端负责解析模型格式,中端做计算图优化,后端则是算子库和运行时。前端和中端解决的问题,是让计算图更精简、让数据流动更合理,但模型最终能不能跑得快,还得看每个算子落到硬件上时怎么执行。
算子(Operator)就是卷积、矩阵乘、归一化、激活函数这些神经网络里的基本计算单元。一个推理模型,不管结构多复杂,归根结底就是几十上百个算子的串联和并联。算子库做的事情,是把这些计算单元的“高效实现”整理成一套可复用的接口,同时屏蔽底层的硬件差异。
我经常打一个比方:上层框架决定了“做什么”,算子库决定了“做得有多快”。同样一个卷积算子,朴素实现和经过精细调度的实现,吞吐差距能做到数倍。这也是为什么同一个模型换一套推理引擎跑,性能可能有天壤之别——很多时候不是框架的图优化不行,而是它背后的算子库没有把硬件吃透。
1.2 算子库的性能表现为什么能差出好几倍
很多人以为“算子快”就是代码写得巧妙,实际上这是一个系统工程。看 AI 处理器的结构就明白了:芯片上有大量的并行计算单元(比如矩阵计算单元和向量计算单元),数据先通过 DMA 从外部存储搬到片上缓存,再进计算单元。计算单元本身的吞吐往往不是瓶颈,真正让性能掉下来的,是数据搬运的等待、流水线空转和调度开销。
算子库要解决的,就是在有限的缓存和带宽下,把数据喂到计算单元,并且让计算单元尽量不闲着。这涉及 kernel 的选择、tiling 切分、流水线安排、数据布局转换,甚至内存分配的时机。任何一环没做好,硬件算力都会被浪费掉。
还有一层容易被忽视的是通用性。算子库要应对各种输入 shape、各种数据类型、各种硬件型号,所以内部往往维护着多个 kernel 变体。不同条件下选不同的实现,这个选择逻辑一旦不完善,就可能在特定 shape 下走到性能极差的路径上。后面我会专门用一个真实排查案例来展开这一点。
1.3 从“算子快”理解 AI 处理器的性能逻辑
在传统 CPU 上做优化,我们习惯性关注循环展开、指令集、缓存命中率。到了 AI 处理器上,逻辑是类似的,但维度有一些变化。一个算子跑得是否快,通常看三件事:
- 数据搬运是否连续且可预测;
- 计算单元和搬运单元是否在并行工作;
- kernel 的任务粒度是否足够大,大到能掩盖启动和调度开销。
用一个生活化的例子:流水线上工人(计算单元)处理一个零件只要两分钟,但从仓库(外部存储)把零件拉到工位(片上缓存)可能要五分钟。如果每道工序都让零件回一次仓库,整体效率就惨不忍睹。算子库的工作,就是让零件尽量留在工位上,把多道工序一口气完成,同时提前把下一批零件准备好。
理解了这个逻辑,后面聊算子融合、tiling、double buffer 这些优化手段时就顺了:它们本质上都在做同一件事——减少数据搬运的等待,让计算单元跑得更满。
2. 算子执行全链路:从一次API调用到内核真正跑起来
2.1 算子调用在引擎内部经历了什么
很多人以为调用一个算子就是“传几个参数然后等结果”,实际上算子库内部有一个完整的执行链路。一次普通算子调用的生命周期大致是这样的:
- 上层框架根据算子类型和输入张量信息发起调用;
- 算子库检查输入参数的合法性,做 shape 推导;
- 根据算子类型、shape、dtype、数据布局和硬件型号,查表选择具体的 kernel 实现;
- 申请必要的内存和工作空间;
- 把 kernel 参数打包成任务,提交到硬件执行队列;
- 异步或者同步地等待计算完成,返回结果。
这条链路里,每一步都可能消耗时间。对大型算子来说,真正计算的时间占了绝对大头;但对那种“小算子”,比如加一个标量、复制一块数据,启动开销和同步等待反而可能支配整体耗时。这也是为什么推理引擎做图优化时,会把一堆小算子合并成一个大算子——不是为了省那几次计算,而是为了省掉反复启动和内存读写的开销。
我见过不少人在算子层做优化,一上来就盯着计算指令抠细节,结果 profiling 显示大部分时间花在内存分配和同步等待上。所以排查问题先看全链路,再决定优化方向。
2.2 TensorDesc与数据布局:算子库的“接口契约”
张量描述符(TensorDesc)是算子库对外沟通的核心结构,它描述一个张量的形状、数据类型、内存布局等信息。可以把它理解成快递单:计算的数据是货物,TensorDesc 就是写清楚“货从哪里来、什么规格、怎么摆放”的单子。
数据布局是这里最容易踩坑的地方。以图像数据为例,常见的有 NCHW 和 NHWC 两种,AI 处理器上还经常出现分块格式,比如把通道维拆成主通道和子通道的 NC1HWC0 这类布局。不同的布局直接影响连续访存的长度:某些硬件上 NHWC 能让一行数据在内存里连续排布,向量化访存效率更高;但有些算子内部计算逻辑又偏好 NCHW。
所以一个成熟的算子库不会只支持一种布局,而是针对不同算子提供多种布局实现,再由引擎层根据数据流特点做布局规划。布局转换本身也有成本,如果引擎频繁地在两种布局之间切换,性能也会被打折。做算子适配时,先搞清楚数据的实际布局,再谈优化,不然很容易白忙一场。
2.3 静态shape和动态shape,性能天差地别
输入 shape 是否固定,对算子性能的影响经常被低估。静态 shape 意味着编译器在模型加载阶段就能确定所有张量的大小,于是可以提前选定 tiling 参数、固定 buffer 大小,甚至把权重重新排列成硬件友好的格式。这些工作在运行前就做完了,执行时只需要按既定计划跑。
动态 shape 就麻烦多了。每次输入尺寸都可能变化,算子库只能在运行时临时判断,要么走一个通用的、适应所有 shape 的保守 kernel,要么反复触发 JIT 编译。保守 kernel 往往不是为当前 shape 最优化的,性能自然打折。
实测下来,一个模型从动态输入改成静态输入,单次推理延迟下降 15% 到 25% 是很常见的。所以在做在线推理服务时,我会优先建议业务方固定输入的尺寸,或者至少固定一个合理的动态范围。对必须支持动态 shape 的场景,则要在算子库里面针对常见 shape 预生成特化 kernel,把分布最集中的几条路径做成“快车道”。
3. 动态shape下的性能陷阱:一次耗时劣化的完整排查
3.1 现象:同一个算子在动态shape下耗时暴涨
有一次我把一个视觉检测模型部署到某引擎上,单帧推理延迟比预期高了不少。最初我怀疑是模型本身太重,但对比之后发现,同样的模型在另一个推理框架上能明显快出一截。于是我用 profiling 工具把整张计算图的算子耗时拉了出来,发现一个很反常的现象:
| 输入 shape | Conv2D 耗时 | 某个逐元素算子耗时 | 总延迟 |
|---|---|---|---|
| [1,3,224,224] | 2.1 ms | 1.2 ms | 8.4 ms |
| [1,3,223,223] | 2.2 ms | 3.8 ms | 11.0 ms |
| [1,3,222,222] | 2.1 ms | 1.1 ms | 8.3 ms |
其他算子耗时基本平稳,唯独一个逐元素算子(Elementwise)在 shape 为 [1,3,223,223] 时耗时暴涨到原来的三倍多。你可能会觉得,一个逐元素算子能有多慢?它确实不该慢,但数据就是这么显示的。
3.2 排查链路:从profiling数据一路追到kernel选择分支
第一步,我先确认这不是偶发抖动。连续跑了十多次,耗时都稳定在这个水平,排除了系统噪声。然后我把输入从 224 改成 223 再改回 224,现象可以反复复现,说明和 shape 强相关。
第二步,查看这个算子的实参。它是模型里的一个逐元素操作,对输入张量的每个元素做一次简单变换。224 和 223 只差一个像素,为什么性能差距如此明显?我怀疑是算子库内部 kernel 的选择逻辑出了问题。
第三步,打开算子库的调试日志,确认实际选择的是哪个 kernel 变体。结果发现:当最后一个维度的尺寸刚好是硬件向量位宽的整数倍时,走了优化过的向量化 kernel;一旦不满足对齐条件,就走了一个通用的标量版本。223 这个宽度恰好处在不满足条件的那一侧。
第四步,为了验证这个判断,我在预处理阶段把输入先 padding 到 224,再送进模型,耗时立刻恢复到正常水平。至此,根因基本锁定。
3.3 根因:shape没有对齐向量宽度,算子退化成标量路径
AI 处理器上的向量计算单元,一次可以并行处理多个数据。比如某个硬件的向量单元一次能处理 8 个 float32,那么理想情况下一个循环就能处理 8 个元素。但如果最后一个维度的元素个数不是 8 的倍数,最后一组数据就凑不齐,需要做掩码操作或者走剩余循环。
很多算子库为了简化实现,遇到不对齐的情况干脆放弃整个向量路径,退化成一个一个处理的标量循环。这种实现虽然正确,性能却可能差出两三倍。动态 shape 场景下,输入尺寸千变万化,很容易踩到这种不对齐的分界点上。
这个坑的隐蔽之处在于:它不是“某个算子一直慢”,而是“某个算子在特定 shape 下突然慢”。如果只看平均耗时或者只看典型 shape 的数据,很难发现。
3.4 修复与预防:padding、kernel特化与shape扫描表
修复这个问题有几个层次。短期最快的办法是在预处理阶段 padding:把输入尺寸补齐到向量对齐的整数倍,比如把 223 补到 224。很多视觉模型的边缘 padding 对最终精度影响非常小,甚至可以直接在数据读取时用常量填充。
中期更稳的做法,是给算子库的 kernel 选择逻辑增加一条规则:当最后一个维度不对齐时,不要整体退化成标量版本,而是先按 8 的倍数做一段向量操作,剩余的几个元素用标量代码收尾。这样既保证正确性,又能保住大部分性能。
长期来看,最好在模型编译阶段就加入 shape 约束传播:让引擎知道整个模型可能出现的输入尺寸范围,提前为那些高频 shape 生成特化 kernel,避免运行时反复落入通用路径。我在团队内部还维护了一张“shape 扫描表”,把模型推理可能出现的所有输入尺寸批量跑一遍,记录各算子耗时。这张表就是排查这类问题的第一手线索,比等到线上告警再追要省太多时间。
4. 算子融合实战:为什么访存密集型算子收益最大
4.1 内存墙才是真正要对付的瓶颈
AI 硬件算力这些年增长非常快,但内存带宽的增长速度远远跟不上,这就是所谓的“内存墙”。对一个算子来说,如果它的计算量很小,而输入输出数据量很大,那么它的耗时主要取决于访存而不是计算。这种算子就是访存密集型算子,逐元素操作、数据格式转换、复制都属于这一类。
算子融合的出发点很简单:把多个算子合并成一个 kernel,让中间结果不要写回外部存储,而是直接留在片上缓存甚至寄存器里给下一个计算步骤用。这可以省掉大量无谓的 DRAM 读写。
我见过一个很典型的例子:Conv、BatchNorm、ReLU 三个算子分开执行时,每个算子都要把数据从外部存储读一遍、写一遍。假设数据量是 N,那么三个算子的访存量大约相当于读 N 加写 N 乘以三遍。融合成一个算子之后,只需要读一次 N、写一次 N,访存总量直接降低了差不多一半。
4.2 三种最常见的融合模式与收益拆解
先说 Conv+BN 融合。BatchNorm 在推理阶段是可以被化简的:它对每个通道做一次线性变换,本质上就是 y = (x - mean) / sqrt(var + eps) * gamma + beta。把卷积的输出公式代进去,可以把这个线性变换直接折进卷积的权重和偏置里。于是三个算子变成两个,省掉一次中间结果的完整读写。要理解方便的话,可以这样看:
新权重 = 旧权重 * (gamma / sqrt(var + eps)) 新偏置 = (旧偏置 - mean) * (gamma / sqrt(var + eps)) + beta
这个换算只需要在模型加载时做一次,运行时的成本几乎是零。所以 Conv+BN 融合是性价比极高的一项优化。
再说激活融合。ReLU、sigmoid 这类激活函数本身计算量不大,但每个算子都要遍历一遍数据。把激活挂在卷积后面,让激活在卷积输出刚刚产生、数据还在寄存器里时立刻处理完,就不需要再单独读一遍、写一遍。这个融合几乎不影响任何其他逻辑,属于白捡的收益。
然后是残差结构融合。ResNet 这类模型里经常出现卷积旁路加上一个残差输入再做加法,单独实现要先把卷积结果写回内存,再读取残差分支,做加法后再写回。融合做法是让加法直接发生在卷积的累加器上,换句话说,卷积计算完成前,把残差数据作为初始值加进累加结果里。这样整个残差结构只产生一次读写。
4.3 融合不是越多越好:什么时候该融,什么时候不该融
有些团队一听到融合有收益,就把所有能合的东西都往一起合,结果反而变慢。为什么?因为融合会增加单个 kernel 的寄存器压力,可能让同一个计算单元上同时运行的线程或数据块变少,导致硬件利用率下降。
我自己的判断标准是看算子的访存密集程度。两个访存密集型算子放在一起,融合几乎是稳赚的。两个计算密集型的算子,比如 Conv 后面再接一个大的 MatMul,融合的意义就不大,因为它们中间需要保留的数据量太大,很难在片上缓存里兜住。关键还得看硬件指令是否直接支持这种组合——如果硬件在矩阵计算单元里本身就支持输出前做一次激活,那融合就很顺;如果要用通用寄存器硬拆,就得小心斟酌。
一个靠谱的验证方法就是做 A/B benchmark:分别实现融合 kernel 和分离 kernel,在典型的 shape 集合上对比耗时。不要只看一个 shape,因为融合在某个 tile 尺寸下受益,在另一个 tile 尺寸下可能适得其反。用小成本试出一条曲线,比拍脑袋决定靠谱得多。
5. 底层调优三板斧:tiling、流水线与数据搬运
5.1 tiling:把大计算块切成硬件吃得下的小份
片上缓存永远比外部存储小,但算子面临的数据量通常很大。一次把整张 feature map 塞进缓存是不现实的,所以需要把计算任务切成一个个小块,每一块在缓存里完成计算后再处理下一块,这就是 tiling。
tiling 的核心矛盾是:块太大,缓存放不下,出现频繁换出换入;块太小,调度次数增多,启动和循环的开销又上来了。理想状态是:单个 tile 的数据量刚好能被片上缓存装下,并且这个 tile 的计算时间足够长,能掩盖数据的搬运时间。
以矩阵乘为例,常见做法是沿着 M、N、K 三个维度各取一块,比如 64x64 的一小块,让它刚好命中缓存容量。具体取多少,没有通用答案,要看硬件缓存大小、DMA 搬运粒度和计算单元的并行宽度。工程上最有效的办法是做一个参数扫描:把 tile 大小从 16 到 128 都试一遍,画出耗时曲线,选择拐点附近的值。
5.2 double buffer:让搬运和计算并行起来
单缓冲的问题是经典的串行等待:先搬运数据,再计算,再搬运下一块,再计算。计算单元有一半时间在等数据。double buffer 的思路是在计算第 i 块数据时,趁这个时间把第 i+1 块数据预先搬到另一块缓冲区里。这样搬运和计算重叠起来,数据搬运的大部分耗时就被隐藏在计算背后了。
这个方案在工程实现上并不复杂:准备两块 buffer,一块给当前计算用,一块给预取用,计算完成后两块交换角色。但要注意同步点,必须确认预取的数据已经就位,才能开始下一轮计算。如果流水深度不够、预取距离过短,效果会大打折扣。
很多硬件 DMA 引擎支持不等 CPU 参与就完成搬运,配合硬件同步原语能把流水线做得更细。可以把流水拆成四段甚至八段:预取第 i+2 块、计算第 i 块、写回第 i-1 块。理论上,只要预取速度跟得上计算速度,搬运时间对整个执行过程的影响就能被压到很小。实测里,同样的算子加上 double buffer 之后,计算单元利用率从百分之四十提升到接近百分之八十是非常常见的。
5.3 调优的正确姿势:先看搬运,再看算力利用率
很多人调 kernel 喜欢直接对着指令级代码抠细节,但我的经验是,先用量化数据确认瓶颈在哪里。看 profiling 的时候重点盯两个指标:数据搬运总量和计算单元利用率。如果利用率长期低于 50%,大概率问题出在数据流和调度上,而不是计算指令本身。
有一种很典型的场景是算子已经做好 tiling,但每个 tile 的颗粒太小,调度开销占比高;或者没有做好预取,计算单元频繁等待数据到达。这时候最有效的动作就是调大 tile 或者加深流水线。
另有一种场景是搬运的数据确实减不下来,比如数据布局不合理导致每次读取都只能搬一小段,连续访存变成了零散访存。这种问题一般不是改 kernel 能解决的,需要回到布局规划上,在引擎层就把数据的摆放方式理顺。总之,路径应该是:先看访存有没有浪费,再看计算有没有等待,最后才是指令层面的微调。顺序反了,往往会做很多无用功。
6. 性能优化不只看数字:基准测试与回归验证
6.1 一个可复现的算子级基准测试怎么搭
做算子性能优化,最怕的是“优化完感觉快了,但是说不清快了多少”。所以一套可复现的基准测试是必须的。我现在的做法其实很简单:固定输入 shape、固定数据类型、固定输入数据分布,每次测试前先 warmup 若干轮,再采样多次,取中位数或者 P90。中位数反映典型表现,P90 可以帮助发现偶发性能劣化。
环境信息也要记录清楚:硬件型号、算子库版本、权重版本、图优化开关、并行线程数。否则一次测试结果很难跟另一次对比。我自己一般会把这些信息自动写进测试输出文件名里,省得事后对着数据百思不得其解。
还有一个细节值得注意:如果后续要对算子代码做改动,尽量在同一个环境里做 A/B 对比,而不是拿今天的数据跟上周的数据对比。硬件温度、频率策略都可能影响绝对值,影响相对对比的准确性。
6.2 用算子性能矩阵替代单点优化
单点优化很容易带来虚假的正向结果。最典型的情况是:在一个 shape 上优化得很漂亮,换一个 shape 就性能倒退。所以我不太信单个 benchmark 的结论,而是习惯做一张“算子性能矩阵”。
做法是:把一组关键 shape 排列成表格,横轴是不同的 shape,竖轴是优化前后的耗时,在优化版本里跑一遍,把每个 cell 的耗时都填上。如果一个优化方案能让大部分 cell 变好、个别 cell 变差,需要重新评估;如果大部分变差,哪怕某个热点 shape 变快,也要谨慎。
我遇到过不止一次这样的情况:给某个算子加了特化分支,热点 shape 快了 20%,结果非对齐的 shape 慢了 15%。如果只看热点没发现问题,上线后整体延迟反而变差了。矩阵化对比能把这层皮揭下来。
在团队里我还习惯把基准测试脚本挂到持续集成流程里,每次算子库有改动,自动跑一遍关键模型和 shape 集合。性能回归和功能回归一样重要,一个不经意的改动可能就让某个隐蔽 shape 掉进慢路径。
6.3 如何让优化成果不被下一次改动悄悄推翻
算子库是一个长期演进的项目,版本升级、硬件适配、新算子加入都可能影响旧的优化。我见过很多辛苦调出来的高性能路径,在下一个版本里因为一个通用分支的优先级调整被覆盖掉,性能一夜回到解放前。
比较好的做法是给关键 kernel 加上“性能断言”:在基准测试里设定一个阈值,一旦某个关键 shape 的耗时超过阈值就报错。这个做法一开始会被嫌弃“太严格”,但坚持跑几个月之后,会帮团队拦住很多隐性问题。
另外每个 kernel 的变更记录里最好写明性能测试结果和适用 shape 范围,这样后人改代码时能快速知道哪些路径是有意为之,哪些是历史遗留。我个人的习惯是,在代码注释里直接写上“这个分支不要随便删,[1,3,223,223] 这种 shape 依赖它”,算是给未来的自己留个线索。
最后再分享一个我自己的体会:算子库这一层,看起来只是一个“调包”模块,真正钻进去之后才发现深度远超预期。它既要照顾硬件特性,又要应对千奇百怪的模型结构,还要守住性能下限。无论是做算子融合、tiling 还是 kernel 特化,最根本的一条永远是先用数据说话,再动手改实现。如果你们也在折腾推理引擎,建议先把关键算子的耗时矩阵做出来——很多时候,性能问题一眼就能从这张表里看出来。