如果你在推理引擎开发组里待过一段时间,大概率会撞见这种场景:上层图优化做了一堆,算子实现也逐个打磨过,但一串起来性能就是上不去。打开profiler,问题往往藏在那些不起眼的中间张量里——一个卷积算子算完,结果被整块写回内存,下一个算子立刻又把它整块读出来。团队的直觉反应通常是去改融合pass,或者再调一轮kernel的循环展开,但很少有人意识到,真正卡住性能的,其实是算子库的架构本身。
ops-nn这个算子库,处理的恰好就是这一类问题。它不是一个kernel的简单堆叠,而是位于计算图与底层kernel之间的一层基础设施,负责算子描述、分派、调度、tiling、内存规划、融合改写,以及精度策略。这篇文章我会从核心架构和性能优化两个角度拆开来讲,重点说清楚每个设计背后的“为什么”。适合正在做推理引擎、AI加速器软件栈,或者被算子性能问题反复折磨的工程团队。即便你只是上层模块的使用者,理解这一层怎么设计,也能帮你少踩很多性能坑。
1. 边界不清的算子库,注定要走弯路
1.1 一次把调度逻辑写进kernel的翻车经历
我先讲一个自己做过的失败案例。在某次端侧AI芯片的移植中,团队拿到硬件SDK的第一反应是“赶紧把算子实现写出来”。为了快速跑通模型,我们参照文档定义了一批kernel接口,然后让每个kernel自己负责一部分调度逻辑:有的kernel内部自己调内存分配,有的把layout转换硬编码在实现里,还有的为了省事,把融合逻辑直接写在算子里。
单看每个算子,单测跑得都不错,但整模型链路速度勉强只有预期的一半。把中间结果打印出来之后,问题彻底暴露了:一个5x5卷积后面跟着的ReLU和Add,明明可以fuse成一个kernel执行,却白白走了三遍内存;某个elementwise算子的输出是个大tensor,紧接着的下一个算子又做了相反的layout转换。说白了,每个kernel都在“局部最优”,但算子与算子之间的协作被完全忽略了。
这个案例给我的教训很直接:算子库和kernel库的边界一旦模糊,整个软件栈的性能天花板会从一开始就被锁死。
1.2 算子库与kernel库的职责怎么切
现在看很多团队的算子目录,里面会混入大量本不该由kernel负责的逻辑。我建议把职责边界划成下面这样:
| 职责 | 放算子库 | 放kernel库 |
|---|---|---|
| 算子描述与版本管理 | 是 | 否 |
| shape推导、stride计算 | 是 | 否 |
| 内存池与workspace规划 | 是 | 否 |
| layout选择与转换决策 | 是 | 否 |
| tiling策略、调度策略 | 是 | 否 |
| 融合pattern匹配与改写 | 是 | 否 |
| 循环展开、向量化指令选择 | 否 | 是 |
| 寄存器分配、指令调度 | 否 | 是 |
kernel层应该只做一件事:把“给定输入tensor布局和输出tensor布局”的数值计算跑到最快。至于这个算子该用什么layout、要不要和相邻算子融合、中间buffer从哪里来,全部由算子库在上层决策。这个切分看起来很简单,但实际项目里特别容易破功,原因无非是“赶进度”“这个算子特殊”“微调一下kernel更快”。一旦开这个口子,架构腐化只是时间问题。
1.3 为什么算子库这一层直接决定性能天花板
单独的kernel优化只能改善自身那一段计算,而推理模型里真正巨大的浪费往往发生在算子边界上:中间tensor的反复读写、layout转换的重复执行、内存分配的抖动、kernel启动的累积开销。这些跨算子的优化机会,只有算子库这一层能看见,因为它同时掌握图拓扑、数据流和硬件资源。
举个直观的例子,一个卷积后面跟两个elementwise算子,如果不融合,conv要写一整块DRAM,两个elementwise又分别读一遍、写一遍。三笔大流量在带宽不宽裕的设备上是很致命的。这种优化没法靠某个kernel自己完成,必须靠算子库看到整个subgraph之后做决策。所以说,算子库的架构设计直接决定一个推理引擎能走到什么性能水平,真不是夸张。
2. 核心架构的骨架:算子描述、分派与metadata管理
2.1 用一张“分派键”统一所有算子入口
ops-nn在架构层面做的第一个关键决定,是把所有算子入口统一成一个“分派键”驱动的方式。每个算子不是靠一连串if-else去试,也不是靠模板参数堆出无数组合,而是用一个结构描述“我到底要调哪个实现”。
struct OpKey { OpType type; // 算子类型:Conv、Gemm、Elementwise... DataType dtype; // 输入/输出的数据类型:fp32、fp16、int8... TensorLayout layout; // 数据排布:NCHW、NHWC、NC1HC0... uint32_t version; // 实现版本号,用于A/B切换 DeviceId device; // 目标设备类型 }; struct DispatchEntry { OpKey key; std::function<Status(const OpContext&)> impl; };为什么不能只用模板参数加函数重载?因为模板展开发生在编译期,运行时根本不知道shape是静态还是动态;而且算子数量一旦上百,dtype和layout的组合会指数爆炸。用一个运行期分派键,把“哪个实现匹配这个请求”变成一个查表操作,既灵活又能做优先级回退。
分派键里最容易踩坑的是layout。很多算子实现只写了NCHW,没写NHWC,然后内部偷偷做transpose。短期看是省事了,但每个算子都这样做的后果是,整张计算图里transpose满天飞,访存量翻倍。ops-nn的做法是把layout选择提升到算子库层面统一决策,kernel内部默认“layout已经对了”,不再做隐式转换。
2.2 注册表、优先级与回退策略
有了分派键,接下来要设计注册表。ops-nn里会有一个全局注册表,不同算子实现按优先级注册:
- 设备特化的优化kernel
- 通用tiled kernel
- 标量reference实现
这个优先级顺序很重要。一个算子刚移植到新设备时,可能只有reference实现,能跑但不快;后续有人写出tiled kernel,注册表自动优先选用;等设备特化kernel出现,再进一步替换。整个过程对上层透明,不需要改调用方代码。
回退策略必须显式且可观测,不能在某个算子没有特化实现时就静默地跑reference。我们的做法是每次回退都打一条warning日志,同时记录到一个性能计数器里。这样集成测试一跑,哪些算子还依赖reference、哪些已经被优化覆盖,一眼就能看清。
版本号也是注册表里很容易被忽略的值。算子库演进过程中,一个算子往往同时存在新旧实现,老实现可能要保留几个版本供线上灰度。如果分派键里没有version字段,A/B测试就只能在注册表外面再包一层逻辑,复杂度全堆到调用方去了。
2.3 plan/execute分离与metadata复用
另一个核心架构设计是“计划与执行分离”。ops-nn在第一次遇到某个shape组合时,会生成一份执行计划(plan),里面把所有输出shape、stride、布局、workspace大小全部预计算好;后续相同shape的调用直接复用plan,不再重新推导。
这个设计对性能的影响被很多人低估。动态shape场景下,每次调用都重新做一遍shape推导和内存规划,会吃掉不少CPU时间,在端侧设备上尤其明显。ops-nn用一个plan缓存表,以OpKey加具体shape为key,缓存最近用过的计划。命中率高了之后,单次算子调用的调度开销能压到微秒级。
还有一点是metadata的复用。同一个tensor可能被多个算子使用,它的shape、dtype、strides这些信息不应该反复计算。我们会把TensorDesc放在一个统一的内存池里管理,算子之间通过句柄引用,而不是每次new一个描述对象。这不仅省CPU开销,更重要的是保证了数据布局的全局一致性——同一个tensor在任何算子眼里都是同一个layout描述,不会出现“这个算子认为是NCHW,那个算子认为是NHWC”的混乱。
3. 性能第一板斧:tiling、向量化与访存重排
3.1 为什么tiling能同时改善访存和并行度
tiling是算子性能优化里最值得先做的一件事,它解决的问题是:一个很大的计算怎么切成芯片能吃下的小块,让数据在缓存或SRAM里被尽量多次复用。
拿矩阵乘法来说。朴素的i-j-k循环里,每个输出元素要读A的一行和B的一列。假设矩阵规模是1024x1024的fp32,A和B各4MB,朴素实现下,A矩阵会被整体读N次(也就是1024次),B矩阵会被整体读M次,访存量轻松超过几个GB。用tiling之后,把输出切成BMxBN的小块,内层再选一个BK做K维分块,让A的BMxBK块和B的BKxBN块在片上反复复用再去算BMxBN的输出块,全局访存量可以降到原来的几十分之一。
tile参数怎么选,核心是算一笔账。假设片内SRAM容量是1MB,那BM、BN、BK选出来后,三块tile加起来不能超过可用容量的80%到90%:BMxBK和BKxBN分别占一份,输出BMxBN也要占一份,再加上必要的双缓冲。K维的BK太小,A和B被重新加载的次数变多;BK太大,寄存器放不下,会触发spill。一般经验是先按“SRAM塞到八分满”来初选,再通过实测微调。同一次计算里,A tile和B tile的复用次数分别是BN/BK? 准确说,A的每个元素被复用BN次,B的每个元素被复用BM次,所以BM和BN越大,复用率越高,但也不能无限大,因为片上资源有限。tiling系统真正的作用,就是在这个约束下自动找出一组让访存最少的组合。
3.2 向量化不只是“用SIMD”,而是对齐与尾数处理的系统工程
很多人以为向量化就是“在编译器里开O2,让它自动生成SIMD指令”。实际做算子库时你会发现,真正决定向量化能否生效的,是一系列更琐碎的前置条件:地址对齐、连续性、尾数处理。
如果tensor的起始地址不是16字节或32字节对齐,很多向量load指令根本没法定长加载,只能退化成scalar循环或者gather,性能掉好几倍。所以ops-nn的内存池在分配时会强制对齐,通常至少保证32字节对齐。这个约束要从内存池一路传递到kernel签名里,不能等到kernel内部再去处理。
尾数处理是另一个大坑。一个长度为1003的数组,向量化每轮处理8个元素,最后3个元素怎么办?常见做法是masked load,但很多端侧芯片的masked load开销不低;另一个更稳的做法是把尾部单独用scalar循环处理。ops-nn的约定是:主循环负责对齐的主体部分,尾部走一个统一的TailLoop模板,每个算子的kernel都复用这套逻辑,而不是各自写一遍边界处理。
对齐和尾数问题看起来是kernel层的事,但算子库必须提供统一的基础设施,否则每个kernel自己处理,很快就会出现“同一段逻辑被复制粘贴改了五遍,三个版本有bug”的乱象。
3.3 layout转换要放到全局视角
数据排布对访存的影响极大。NHWC布局下,同一像素的多个通道是连续的,对按通道计算的算子很友好;但如果下一个算子要沿通道方向做reduction,NHWC的跨距反而成了负担,NCHW又更合适。所以layout本身没有绝对的好坏,关键是谁来做决策。
ops-nn的架构里,layout选择放在子图级别来决策:当一次layout转换的代价,能被后续连续多个算子的访存收益覆盖时,才值得做。比如一个输入是NHWC的模型,前几个算子都是elementwise,它们的访存模式和NHWC很匹配,就先不改;等遇到一个对NCHW更友好的卷积,再统一转换一次。这样做的效果是,整个模型里的layout转换次数被压到最低,而不是每个算子各转各的。
这里也有一个需要平衡的点:layout转换本身是一笔真实开销,它会读写整块tensor。如果转换之后紧接着只有一两个算子受益,整体可能不划算。实际工程里我会在子图优化pass里给layout转换“定价”:转换代价算作一次全tensor读写,受益算作省下的多次访存,只有净收益为正才做。这个模型不复杂,但非常实用。
4. 性能第二板斧:融合、内存池与双缓冲
4.1 融合为什么常常比单算子优化更值钱
做算子优化的人容易陷入一个误区:总觉得把某个算子的计算密度提高才是“真功夫”。但现实中大量算子的瓶颈根本不在算力,而在访存带宽。elementwise算子的算术强度极低,算几个数就要读一遍内存、写一遍内存,你再怎么优化循环结构,也改变不了“它注定是memory bound”的本质。对这种算子,最有效的优化就是不要让它单独执行一遍,而是把它融合到相邻的算子里面去。
举个具体数字:某卷积输出一个4MB的中间tensor,如果不融合,它要写DRAM,后面的ReLU又要读4MB、写4MB。假设设备DRAM带宽是20GB/s,光是这多余的8MB读写就要花大约0.4毫秒。单看一次不多,但一个模型里这样的小算子可能有几十处,累计起来就是几十毫秒,在端侧推理场景里完全不可接受。融合不仅仅是减少kernel启动次数,更核心的是消除整块中间tensor的DRAM往返。
我在给一个模型做优化时,曾把连续四个elementwise算子全部融合成一个kernel,结果这四个算子原本各自读写一遍内存,最后变成只读一次输入、写一次输出。那个模型的端到端延迟直接下降了7%。单算子microbenchmark可能根本发现不了这个收益,因为它只测了单个kernel,测不出跨算子的访存浪费。
4.2 哪些融合模式值得做,哪些是虚假繁荣
融合也不是越大越好。ops-nn内部维护了一张融合pattern表,常见的高价值模式有:
- 连续多个elementwise算子,比如Add、ReLU、Mul链,全部折叠成一个kernel
- 卷积或GEMM后面接elementwise激活,融合成“带激活的算子”
- BatchNorm这类包含scale和bias变换的算子,如果能做权重折叠,直接并进卷积权重里
- 量化场景下的“quantize -> conv -> dequantize”序列,折叠成量化卷积
哪些融合模式我不推荐?最典型的是把一个很大的reduction算子和一个elementwise算子强行融合。reduction需要跨整个维度做归约,如果和elementwise算子绑定在一起,并行度往往会被拉低,反而让reduction变慢。另一个需要忍住的情况是“贪心融合”:一个kernel里塞进了太多elementwise操作,寄存器里的活变量越来越多,最后发生spill,性能不升反降。融合宽度需要以实测为准,而不是“能并的都并”。
4.3 workspace复用、内存池和stream-local arena
算子执行过程中经常需要临时工作区,比如GEMM的tiling buffer、softmax做online归一化时的临时空间。如果每个kernel执行时都临时alloc/free,内存分配的开销会变成一个隐藏的杀手。ops-nn的做法是:所有workspace大小都在plan阶段算好,执行阶段从统一的内存池里取,用完归还,不碰系统级分配。
内存池的设计也要细。我们用的是按size class分桶的分配器,小块内存有专门的小块桶,大块有独立的大块桶,避免频繁的内存碎片和分配抖动。更重要的是,每个执行流(stream)维护自己的arena,而不是所有算子共享一把全局锁。早期版本用过一个全局内存池,结果多路并发执行时锁竞争十分严重,后来改成per-stream arena,并发性能才正常。
双缓冲是另一个在流水线场景下特别重要的内存策略。如果设备支持异步拷贝(先DMA到片上SRAM,再计算),就需要给tile准备两个buffer:一个buffer在计算时,另一个buffer同时在加载下一块数据。这样访存和计算能够重叠,理想情况下可以把访存延迟完全隐藏。双缓冲的代价是额外的片上内存,需要把tile size相应缩小一点;这块内存的分配也要纳入plan阶段统一考虑,而不是kernel运行到一半临时申请。
5. 性能第三板斧:低精度、量化与数值策略
5.1 低精度不是全局替换,而是逐算子决策
很多团队拿到一个fp16模型,就把所有算子都切成fp16。这样做的结果往往是:某些算子精度确实没问题,但某些reduction类算子误差被放大,最后模型效果崩了。低精度策略应该是逐算子做决策,而不是全局一刀切。
ops-nn的dispatch机制天然支持这个需求:同一个OpKey里,目标dtype可以指定为fp16,但实际执行时系统会根据硬件能力和精度配置,从下面几个候选中选择:
| 目标dtype | 首选实现 | 回退方案 | 代价 |
|---|---|---|---|
| fp16 | fp16特化kernel | fp32 kernel + 输入输出转fp16 | 转换访存开销 |
| bf16 | bf16特化kernel | fp32 kernel + 截断转bf16 | 精度损失需评估 |
| int8 | 量化kernel | fp32 reference + 量化转换 | 精度风险较高 |
选择回退方案时,ops-nn会额外记录一次“精度降级”事件。如果某个模型运行结束后,降级算子数量异常多,说明量化或低精度配置可能有问题,集成测试阶段就能发现,不用等到线上出事故。
5.2 量化参数折叠与反量化最小化
量化推理最常见的性能杀手,是反量化操作反复出现。很多模型文件里,“dequantize -> conv -> quantize”的结构非常常见。如果算子库照本宣科地执行这三个算子,中间会产生一个fp32的大tensor,内存占用翻倍,访存也翻倍。
正确做法是在算子库层面识别这种pattern,把scale和zero point折叠进卷积的权重和bias里,让kernel直接输入int8、输出int8。这样算子内部只需要在乘加之后做一次scale缩放,不再产生中间fp32张量。这个优化在实际量化模型里收益非常大,因为几乎每个卷积都带着一组量化参数。
这里有一个细节值得注意:per-tensor量化最简单,kernel里只需要加载一个scale;per-channel量化则需要为每个输出通道加载不同的scale,这不仅是一次额外的内存读取,还会影响向量化的计算结构。所以per-channel量化虽然在精度上更稳,但性能开销必须被上层充分评估。ops-nn的量化kernel设计时会把scale做成可配置的:per-tensor走快速路径,per-channel走通用路径,而不是让per-channel永久拖累所有量化算子。
5.3 数值回归:如何不信任每个kernel又能快速定位误差
在引入低精度和量化后,数值正确性的检查就不能只靠单算子单测了。很多误差不是某一个算子的实现错误,而是多个低精度操作叠加在一起放大的。ops-nn提供一套“参考结果对照”机制,专门用来定位这类问题。
具体步骤很简单。同一批输入,同时跑一遍fp32参考实现和低精度实现,然后逐层比较输出。比较时我会看两个指标:绝对误差和相对误差。对于softmax、LayerNorm这类reduction算子,绝对误差容易被放大,要改用相对误差来评价;对于卷积和矩阵乘,则主要看绝对误差是否超过阈值。
一旦某个算子超过阈值,系统会直接打印出算子类型、输入shape、误差范数。定位到是单独算子的问题还是累积放大的问题,就方便很多了。这个开关默认关闭,但每个kernel的测试脚本里都会打开它跑一遍,确保kernel在低精度模式下不是“虽然跑得快但完全算错了”。
6. 性能调优的工程闭环:profiling、自动调参与回归
6.1 优化前先回答两个问题:arithmetic intensity和带宽利用率
我见过太多人一上来就调循环展开、改指令顺序,跑完一遍发现没效果,重新从头再来。这种试错式的性能优化缺乏一个统一的判断框架。我的习惯是先做roofline分析,把每个算子归类到“访存受限”还是“计算受限”。
算子算术强度的公式是:AI = 总计算量(FLOPs) / 总访存量(Bytes)。把这个AI和设备自己的ridge point(算力与带宽的比值)对比:AI小于ridge point,说明算子访存受限,优化重点应该放在减少访存、做融合、调tiling;AI远大于ridge point,说明算子计算受限,优化重点才是向量化、指令调度、寄存器分配。
举个例子,elementwise算子的AI通常只有1左右,任何设备上它都是访存受限。所以做elementwise的kernel时,我不会花太多时间在循环展开上,而是优先确认它有没有被融合、有没有绕开DRAM。矩阵乘法的AI随矩阵规模变大可以非常高,优化重心就完全不同,需要仔细设计tile和寄存器复用。
6.2 自动调参:搜索空间收敛才有意义
自动调参是算子库性能优化里炫技价值很高、实际落地价值需要谨慎评估的部分。很多团队一上来就上贝叶斯优化,把tile、unroll、num_threads、layout全部丢给搜索算法,结果搜索空间巨大,跑一个晚上也没收敛。
ops-nn的实际做法是压缩搜索空间。自动调参的第一步,先把layout和融合pattern固定好,只搜几个对性能影响最大的参数:tile大小、循环展开因子、并行线程数。搜索算法我反而推荐轻量级的方案,random search加一点模拟退火就够了。不是因为贝叶斯不好,而是在参数少、评估跑一次又很快的场景下,简单的网格加随机搜索往往能更快找到可用方案。
更关键的落地经验是“离线调参,在线查表”。模型部署时shape固定,算子库完全可以在离线阶段花很长时间搜索最优参数,把结果固化到缓存里,部署时直接查表。运行时自动搜索是不推荐的,因为搜索本身消耗的时间早就超过了那点性能收益。
6.3 性能回归测试的三种粒度
性能优化做进去之后,保护成果比创造成果更重要。一个看似无害的代码改动,可能因为layout隐式转换、融合pattern失效、内存池策略变化,让整体性能悄悄回退。所以ops-nn的性能回归测试分了三个粒度:
| 测试类型 | 触发时机 | 关注指标 |
|---|---|---|
| 单算子microbench | 每次commit | 带宽利用率、算力利用率 |
| 子图benchmark | 每次PR | 中间tensor数量、融合覆盖率 |
| 模型端到端benchmark | 每版本发布 | 延迟、内存峰值、启动开销 |
我特别建议把“带宽利用率”而不是“单算子耗时”作为核心回归指标。因为单算子耗时的波动受频率、环境温度、系统负载影响很大,跑三次可能差出20%;而带宽利用率是一个归一化指标,更稳定也更能反映kernel是否在接近硬件上限。某个kernel如果带宽利用率从90%掉到70%,哪怕耗时数字看着还行,性能也一定出了问题,应该立刻去查。
7. 从手写kernel风暴到编译式算子的渐进路径
7.1 算子多到一定程度,手工优化会进入失控区
任何一个算子库在算子数量少的时候,手写kernel都很愉快。一个算子写一个特化版本,测试直接过,性能也好。但等算子数量从几十涨到几百,情况会快速失控:每个算子要考虑多个后端、多种layout、多种dtype,手写组合很快就变成天文数字。更要命的是,同一个优化思路(比如合理tiling、双缓冲、layout选择)在每个kernel里都要重新实现一遍,很容易出现两个kernel写法完全不同、性能却都很平庸的情况。
这时候很多团队会想到引入代码生成,让机器帮我们生成tiling和loop。但我不建议直接推翻现有架构重建,那样的风险太高,而且老kernel还有存量优化成果,全部重写等于把多年积累扔掉。渐进式引入才是更务实的方案。
7.2 渐进式引入IR与代码生成
ops-nn的做法是保持外部接口不动,在内部新增一条“算子 -> IR -> codegen”的路径。新算子或优化需求,先走这条新路径;老算子继续跑手写kernel。两边共存一段时间,通过统一benchmark gate做对比,确认生成代码不劣于手写,再逐步淘汰老的派生实现。
大致的lowering流程是这样的:
// 旧路径:直接调kernel kernel = get_conv_kernel(param); kernel->run(input, weight, output); // 新路径:先定义算法op,再生成kernel OpDef conv = make_conv(input, weight, param); IRModule mod = lower(conv, target); // 语义保持的IR下降 CodeGenPlan plan = codegen(mod); // tiling、loop生成 plan->launch(input, weight, output); // 产出可执行kernel这个路径最大的价值是,优化经验可以从“每个人的脑子里”变成“IR层面的规则”。比如tiling策略、融合pattern,都可以沉淀成代码生成器里的规则,新算子只要描述清楚算法语义,就能自动享受到这些历史优化。老kernel逐渐减少,最终剩下一批真正无法被生成代码取代的高度特化实现,这样架构就进入了一个更健康的状态。
7.3 演进中最容易忽视的三个工程原则
最后说三个我在演进过程中吃过亏之后总结出来的原则。
第一,新旧实现长期并存是常态,不是过渡态。一定要给每个实现明确的版本号,并且让分派键能够精确指向某个版本,否则A/B对比就无从谈起。
第二,benchmark gate必须和正确性测试绑定在一起。一个kernel即使跑得再快,数值错误也是不可接受的。所有生成代码上线前,都要跑参考对照测试。
第三,老后端的访问入口不能被破坏。渐进改造的过程中,老设备、老模型可能还在线上运行,接口一旦变了,整个上层都要跟着改,改造就进行不下去。ops-nn会把旧路径保留到新路径完全成熟为止。
我自己在踩过几次“重构成功但性能回退”的坑之后,最深的体会是:算子库的性能永远不是某一次优化带来的,而是靠一整套机制持续保护下来的。保住接口稳定、让生成代码与手写代码长期共存、把每一个性能指标固化到回归体系里,这三件事看起来没有写一个快kernel那么有成就感,但真正决定了算子库能走多远。