端侧推理利器:TVM自定义算子、算子融合与INT8量化实战指南

发布时间:2026/9/28 14:34:20
端侧推理利器:TVM自定义算子、算子融合与INT8量化实战指南 前段时间接了个端侧项目要在手机芯片上跑一个实时视频处理模型。最初我用现成的推理引擎转完模型精度、内存都过关一测速度差了三倍。前后折腾了两周最后把问题定位在计算图优化和量化这两个环节才意识到做端侧推理光会调用推理框架是不够的还得会处理底层算子于是开始完整接触TVM。如果你也在做端侧推理或者正在研究自定义算子、算子融合、模型压缩量化这几个方向这篇文章应该能帮你少走不少弯路。我尽量用实际项目的口吻来写不讲教科书上的概念只讲我踩过的坑和验证过的做法。1. 端侧推理的痛点为什么绕不开TVM这类深度学习编译器1.1 端侧环境的硬约束端侧推理和服务器推理完全是两种玩法。服务器上有大显存、高带宽、随意用CUDA端侧却要同时面对算力弱、内存小、带宽低、发热敏感这几个问题。一个几千万参数的模型放到手机芯片上跑如果直接沿用服务器端的执行方式内存会先扛不住紧接着就是访存瓶颈让速度崩掉。端侧芯片的种类还很杂。CPU有ARM的大小核架构GPU有Mali、AdrenoNPU有各家自研单元指令集和内存模型都不一样。同一个算子在两种芯片上最优的实现方式可能完全不同一个适合拆成多个小kernel并行另一个适合单个大kernel串行执行。这种情况下最怕的就是写一套代码到处跑的框架——算子库虽然能覆盖常见模型但覆盖面永远赶不上新模型结构的速度。模型里一旦出现新结构、新算子就得等框架更新版本项目进度全卡在运维那边。1.2 TVM在技术栈里的定位我第一次理解TVM的时候最大的误区是把它当成又一个推理框架。后来才明白它本质是个编译器输入是模型输出是针对特定硬件生成的代码。这个定位差异非常关键。推理框架是运行时解释模型进来后在框架的算子库里找对应实现再调度到硬件上。TVM做的事情是编译期翻译你把计算图交进去它在编译阶段做图优化、算子融合、内存规划、指令生成最终产出一份针对目标硬件的可执行代码。所以它不依赖某个现成的算子库而是为你的模型现写算子。这种模式下自定义算子和算子融合就不需要等框架支持了。你可以在编译链路里自己定义一个算子的计算逻辑再让TVM为它做调度优化最后生成能跑的代码。这就是为什么端侧调研一圈之后很多人最终会回到TVM路线上来——它把如何适配硬件的主动权交还给了开发者。2. TVM的编译链路从计算图到端侧可执行代码2.1 Relay IR统一的计算图入口TVM的编译入口是Relay IR。它的作用是把各框架导出的模型翻译成同一套中间表示之后所有的图级别优化都在这个IR上进行。实际项目中我从PyTorch和ONNX两个来源转模型比较多。TVM提供了from_pytorch和from_onnx之类的转换接口导入之后可以先用relay.ir_pass.print_ir把计算图打出来看一眼这一步强烈建议养成习惯。因为模型导入过程中有些算子会被融合或拆分直接看导出的IR能发现很多肉眼看不出来的问题比如某些小而碎的算子被生成了独立节点后面推理时就会有额外的kernel启动开销。Relay IR本身是一棵计算图节点之间通过tuple、call、let等结构组织起来。一开始看会有点不习惯但只要理解了每个call节点就是一个算子调用它的参数依赖前序节点读图就够了。后续的算子融合、布局转换、量化本质上都是在改写这棵图。2.2 Tensor Expression与Schedule算子的计算和执行解耦理解TVM过了导入这一步就是它的核心设计计算定义与执行策略分离。计算定义也就是通常说的TETensor Expression用te.compute描述一个算子的数学逻辑比如输出[i][j] 输入[i][j] * 2这样的逐元素计算。它关心的是算什么。执行策略则由Schedule承载用te.schedule或TensorIR的s对象去描述怎么算得更快。包括循环怎么分块split、怎么重排reorder、怎么向量化vectorize、怎么并行parallel等。这两层分开的好处在于同一份计算逻辑换一个硬件只换Schedule就行不需要重写数学逻辑。我刚开始写自定义算子时总想着一口气连计算带调度一起写完结果经常为了调一个循环顺序把计算逻辑也改坏。后来老老实实分开写计算定义保持纯函数式调度单独调Debug起来清晰得多。2.3 代码生成与端侧运行时编译链路的末端是代码生成。TVM根据你指定的后端把优化过的IR翻译成目标代码比如llvm后端生成CPU的机器码opencl后端生成GPU kernelc后端生成可读的C源码。端侧常用的是llvm和c前者直接用LLVM做指令选择后者方便你嵌入到已有的Native工程里。端侧运行时通常会选择一个轻量级的东西。老版本里常见的是Graph Runtime它把编译产物打包成一个二进制模型文件运行时加载后按编译好的执行计划跑。新版里更推荐用VMVirtual Machine对动态shape和复杂控制流支持更好后端集成也需要多做一步。我实际在Android工程里跑的时候用的是C API加GraphRuntime一条链编译时产出一个model.so或model.tar运行时加载进来通过SetInput、Run、GetOutput三个接口完成一次推理。这套链路的好处是非常稳定不依赖额外的Python环境模型格式固定后可以直接用脚本批量编译发布流程也好管理。3. 自定义算子的完整落地路径定义、注册、调度3.1 什么情况下确实得自己写算子我总结下来需要走自定义算子的场景基本有三类。第一类是模型里有TVM尚未覆盖的算子。比如某段时间Transformer里的GELU激活函数在旧版本TVM里支持得不好解析出来的节点要么报错要么生成了效率很低的代码。第二类是默认实现过于通用。TVM对很多算子有一套兜底实现能用但性能平庸。比如某些逐元素算子虽然能跑但没做循环展开和向量化明显比手工调度慢了一倍。第三类是你想把多个算子手动捏成一个。这种情况最典型的是各种LayerNorm变体或者带有残差结构的Attention计算。自动融合虽然能做一部分但复杂算子组合还是需要手工写一个整体kernel才能拿到理想性能。判断标准其实很简单先看编译后的代码里某个算子是不是连续出现了多次内存读写或者多个算子之间有大量数据往返。如果是就值得考虑手工写。3.2 用te.compute写一个自定义算子的示意拿一个简化的LayerNorm举例。它的计算分三块求均值、求方差、归一化。如果用通用实现会被拆成多个kernel如果手工写可以一个kernel搞定。用TE定义计算部分大概长这样import tvm from tvm import te # 假设输入是二维batch x hidden def layer_norm_compute(data, eps1e-5): # 先定义求均值的计算对hidden维度做归约 mean te.compute( (data.shape[0],), lambda b: te.sum(data[b, :], axis0) / data.shape[1], namemean ) # 中心化 平方为求方差做准备 centered te.compute( data.shape, lambda b, h: data[b, h] - mean[b], namecentered ) var te.compute( (data.shape[0],), lambda b: te.sum(centered[b, :] * centered[b, :], axis0) / data.shape[1], namevar ) inv_std te.compute( (data.shape[0],), lambda b: 1.0 / te.sqrt(var[b] eps), nameinv_std ) out te.compute( data.shape, lambda b, h: centered[b, h] * inv_std[b], namelayer_norm_out ) return out注意这只是一个计算定义它生成的代码执行效率并不高。如果直接这样丢给编译器TVM会生成一串串行归约代码速度可能还不如框架自带的实现。接下来要做的是给它加Schedule核心思路是把外层batch维拆到多个线程上并行hidden维尽量让归约在寄存器里完成而不是反复访问内存。基于我的经验先写一个最粗糙的schedule跑通再去调split、vectorize。如果你对调度细节不熟可以先只做一层split别的交给autoTVM去搜。3.3 注册算子的关键步骤与常见坑自定义算子如果只是自己写来测试可以直接走tvm.build编译。但如果你想让算子被Relay图优化自动识别、能在图里和其他算子融合就需要做注册。注册路径分两层。第一层是注册到Relay告诉它这个算子的类型关系、shape推导规则这样图解析就能把对应节点正确映射过来。第二层是注册到TOPITVM的算子实现库把compute和schedule挂到算子上这样后端自动调度时能找到实现。常见的坑有三个。第一个坑是shape推导没写对。Relay层注册时需要写一个shape_func它根据输入shape推导输出shape。我遇到过明明算子是逐元素的映射结果shape函数里加了多余的维度扩展导致后续融合全部错位。第二个坑是dtype不匹配。端侧模型经常是FP16输入自定义算子如果只实现了FP32编译会直接报类型错误或者更隐蔽地做了一次隐式转换性能白丢。第三个坑是layout假设。TVM里算子默认layout通常是NCHW但端侧模型转过来之后可能是NHWC或NCHW4。自定义算子如果没有处理layout差异跑出来的结果就和重构图对不上。排查这类问题我的固定套路是把自定义算子的输入参数固定为简单张量跑一次print(tvm.lower(schedule, args, simple_modeTrue))看生成的低级IR长什么样再和参考实现比如numpy直接计算的LayerNorm对比输出。一般几个来回就能定位。4. 算子融合计算图优化里最值钱的几个模式4.1 融合为什么能把速度提上来算子融合对端侧推理的价值比在服务器端更明显。原因是端侧的内存带宽和kernel启动成本都异常敏感。不融合的情况下每个算子的输出都要写到全局内存或L2缓存里下一个算子再读回来。中间数据越大这种读写开销就越占时间。融合后多个算子的中间结果留在寄存器或片上缓存里直接交给下一个阶段使用省掉一次完整的数据往返。还有一个隐藏开销是kernel启动。端侧GPU或NPU上每一次kernel启动都有固定开销几百个微秒级别的延迟虽然不大但模型里有个几十个算子累积起来就是毫秒级。把多个kernel合成一个启动次数直接降低一个数量级。4.2 TVM融合机制与常见融合模式TVM的图优化里有专门的融合Pass核心逻辑是模式匹配加分组。它会扫描计算图把符合融合规则的算子序列识别出来合并成一个融合组再把组内的算子集中到同一个kernel里实现。这几年我自己验证过、值得优先关注的融合模式如下表所示融合模式涉及算子典型加速场景Conv/BatchNorm折叠Conv BN Scale Shift推理阶段BN可以折叠进卷积权重省掉整体分支激活融合Conv/pool/FC ReLU/PReLU端侧ReLU也能并进前一个算子减少内核调用逐元素融合Add / Mul / Clamp 等逐元素组合残差模块、归一化层后处理归约融合均值/方差/归一化三步LayerNorm、GroupNorm类算子拼接与切分合并Concat Slice 的组合多头注意力里的QKV拆分合并拿卷积加ReLU来说最稳妥的做法是依赖自动融合。TVM的relay.ir_pass.fuse会自己识别这种模式生成融合后的kernel不需要手工介入。我实际跑的时候这类自动融合几乎不用改代码性能提升也明显。但有些融合自动机制做不了或者做不好。比如跨越多层的复杂融合需要把多个没有直接依赖关系的子图合并成一个kernel来换取数据局部性。这种情况下就得手工写融合算子。我的经验是先依赖自动融合拿到一个基线性能再针对profile数据里最耗时的几个算子树做手工融合不要上来就全手工写。4.3 融合之后数值顺序变化的问题融合带来的一个容易被忽略的问题是数值计算的顺序变化。举个简单的例子(a b) * c在不融合时先算加法再算乘法融合成单个kernel后如果编译器为了并行把加法做了重组比如a*c b*c对浮点数来说结果可能有一点点差异。这个差异在FP32下通常可忽略但到了FP16或INT8环境下舍入误差会被放大表现出来就是融合前后精度曲线出现细微差别。为了避免被这种问题坑到我养成了两个习惯。一是每次融合改动后固定用同一组校准数据对比融合前后的输出盯着看最大绝对误差和相对误差不只盯Top-1准确率。二是对精度敏感的层尤其是量化前的归一化层在融合规则里单独排除必要时用PRelu、LeakyReLU这类激活时保留原始结构。5. 模型压缩量化从FP32到INT8的端侧实战5.1 量化方式选型PTQ还是QAT端侧部署一个模型压缩量化几乎是必经之路。量化的收益很直接模型体积缩小到四分之一内存带宽占用同步下降基于INT8的SIMD指令通常还能带来两三倍的推理加速。量化方案上PTQ训练后量化和QAT量化感知训练是两条不同的路。PTQ非常省事拿一个训练好的FP32模型跑一小部分校准样本统计各层激活的数值范围然后直接把权重和激活映射成INT8整数。不需要重新训练GPU也不占用。QAT则要在训练阶段就模拟量化误差让模型在训练中适应数值被截断这件事。效果通常比PTQ好但工程量大得多——要有可训练的量化参数、要改训练流程还要维护整套训练代码。我的建议是端侧项目先试PTQ。如果PTQ精度掉得不多比如Top-1掉点小于1个百分点就直接用PTQ如果掉得比较多再去考虑对敏感层做混合精度。只有PTQ已经救不回来、混合精度也不行的时候才值得为QAT投入资源。5.2 TVM量化流程中的关键参数TVM里做PTQ的流程大致是先加载模型然后在Relay图上跑一遍量化标注quantize pass把图里的浮点算子标记为可量化的算子再通过校准数据统计activation的数值范围最后把图和权重转到INT8表示编译。量化时需要注意几个关键参数。per-channel还是per-tensor。权重张量的每个输出通道可以有自己的缩放因子这通常是per-channel量化精度比per-tensor高很多。在TVM的量化配置里能找到相关开关建议开起来。代价是部分端侧硬件的底层指令对per-channel支持不友好需要看目标芯片的算子库文档。symmetric还是asymmetric。对称量化在数学上实现更简单INT8的范围能完全利用起来非对称量化能覆盖分布不均匀的激活精度更细腻。激活部分我倾向用非对称权重部分用对称实践中精度表现更稳。校准数据集大小。我的经验是至少准备500到1000个样本覆盖模型实际使用中的多种输入类型。校准集太少统计出来的数值范围会偏窄实际部署时遇到边界像素就会截断精度突然掉下来。如果模型是视频相关的校准时要故意混入不同光照条件下的帧。还有一个常量quantizepass的力度问题。TVM默认会把某些算子保留在FP32比如Softmax和LayerNorm。这在精度上非常合理但端侧跑的时候这些浮点算子可能会成为瓶颈。我的做法是先按默认配置跑一遍看profile里哪些浮点算子耗时占比高再逐个判断能不能转INT16或者换一个数值更稳定的低精度方案而不是一刀切全转成INT8。5.3 量化后精度损失的排查套路量化之后精度掉了最忌拍脑袋乱试。我一般按下面这个顺序排查。首先用校准集重新测一次FP32模型的精度排除是不是校准集本身不具代表性。如果FP32在这一批数据上精度就不高那量化后的掉点不是量化造成的得先解决数据问题。其次按层去对比量化前后的输出误差。TVM可以分别提取某一层的输出把FP32图和INT8图在相同输入下的中间结果拉出来对比。找到误差最大的几个层一般问题都集中在那些层上要么数值范围统计不准要么该层对量化太敏感。然后检查归一化层和激活层。观察分布特别宽或特别窄的层手动给它们设置更合适的量化范围或者直接用逐层精调的方式把这几层留在FP32。这种混合精度只针对几个关键层整体收益损失不大却能救回不少精度。最后一步是检查端侧CPU或NPU的INT8实现细节。同一张图在ARM CPU上跑和在NPU上跑量化行为可能不一样。我的经验是NPU的量化计算通常更激进、精度更容易掉所以量化策略有时候要按硬件分开调而不是希望一个量化模型通吃所有端侧芯片。6. 端侧部署的整体流程与排查经验6.1 交叉编译与工程集成端侧部署时开发机和目标机架构通常不一样常规做法是交叉编译。TVM本身提供了target和toolchain参数可以在编译期指定目标平台比如llvm -mtripleaarch64-linux-android这样的目标描述。实际用起来我建议把编译环境固定为一套Docker镜像里面装好目标平台的NDK、OpenCL SDK、对应版本的LLVM。否则三个月后回来说要重新编一次模型环境早就忘了怎么配。这套镜像最好连TVM的版本一起锁定因为TVM API变化很快版本一换之前的编译脚本经常要重写。编译产物我一般直接生成C代码targetc然后塞进Android NDK的C工程里一起编。这样做的好处是集成路径最短不需要在端侧拉起一个完整的TVM运行时只要一个轻量级的tvm_runtime.so就够。6.2 性能排查方法模型跑起来之后性能不达标是常态。我现在的排查顺序是先看图优化情况再看算子级耗时最后看内存和调度。图优化层面先把编译后的IR打出来数一数里面有多少个kernel。如果发现有很多个小的元素级算子独立存在第一步就是想办法融合。这种情况通常是融合规则没有生效或者是模型导入时算子被拆得太细。算子级耗时层面TVM提供了time_evaluator可以精确测某个算子的单次耗时。我更习惯用Android上的perf或CAS工具直接对编译后的Native代码做profiling能看到具体是哪段汇编在拖时间。一旦定位到某个kernel慢基本就是调度问题回到Schedule里去改循环分块和向量化。内存层面有个坑必须提Graph Runtime在默认配置下会预先分配一整块内存池模型的所有中间张量都从这里取。如果模型里存在很大的临时buffer比如大分辨率特征图内存池可能要开到几百MB。这时候不要盲目调最大内存上限先检查是不是有算子输出了没必要保留的大中间张量或者某个临时buffer生命周期太长导致内存池无法复用。6.3 端侧部署常见问题的速查我整理了一张速查表记录端侧项目里最常遇到的问题和对应的处理思路。现象可能原因处理思路编译成功但推理结果全为0或乱码layout或shape推导错误打印IR检查每个节点的输入输出shape和layout第一次推理特别慢后续快线程池初始化、内存池建立成本预热一次推理后再计时多线程配置后反而更慢线程数超过物理核数或缓存竞争按设备核数设置大核优先避免开启超过物理核数的线程量化模型在NPU上精度明显低于CPUNPU量化实现更激进对敏感层做混合精度或保留FP32执行编译好的模型换一台手机性能差异大芯片型号、缓存大小不同按芯片单独编译一次目标模型不做通用包算子融合后输出有细微偏差浮点计算顺序变化用固定校准数据对比融合前后最大误差这些坑我基本都在实际项目里踩过。有一个经验值得一提多线程配置的问题很多时候不是TVM写错了而是手机本身有大核小核之分默认线程调度会把任务丢到小核上。手动把亲和性绑定到大核、再限制线程数不超过大核数量是很多模型提速的关键一步。端侧项目做到这个阶段我发现TVM最大的价值不在于某一个功能多强而在于它把编译器这个思路真正带进了端侧推理的流程。自定义算子让你不再依赖上游框架的算子库算子融合让你能主动掌控计算图的质量量化又让你有了把模型压进内存和带宽限制里的能力。这套链路一旦打通换一个端侧设备或者换一个新模型结构都不至于从零开始。最后分享一个我的操作习惯每次拿到新模型第一件事不是急着调算子或量化而是先用TVM原样编译一版FP32模型在目标设备上测出基线耗时和精度。有了这条基线后面做的自定义算子、融合、量化每一步都能量化对比效果也更容易判断哪些优化真正带来了提升。