尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

基于TVM的端侧推理优化实战:自定义算子、算子融合与量化部署

发布时间:2026/9/28 14:34:20

资讯中心
01
ARTICLE

基于TVM的端侧推理优化实战:自定义算子、算子融合与量化部署

基于TVM的端侧推理优化实战:自定义算子、算子融合与量化部署
1. 端侧推理的痛与TVM的解法先说个场景。模型在GPU服务器上跑得飞快精确度也漂亮但一放到手机、开发板、工控机这类端侧设备上推个图片要几百毫秒内存还动不动就爆。你试过转成ONNX、TensorRT、CoreML折腾一圈发现总有算子不支持要么就是精度掉了要么就是某些层在端侧根本没法加速。这种情况做AI落地的人几乎都碰到过。端侧推理的核心问题其实不只是“模型太大跑不动”而是“你的硬件根本听不懂模型在说什么”。GPU上有cuDNN帮你把算子调度得明明白白但端侧通常是ARM CPU、Mali GPU、NPU、DSP这类异构设备每个设备的指令集、内存层级、并行方式都不一样。你指望框架自动帮你优化好基本不现实。TVM这个项目解决的就是这一层的问题。它本质上一个编译器把模型从TensorFlow、PyTorch、ONNX这些前端格式翻译成能在各种端侧硬件上高效执行的代码。不止翻译它还会在翻译过程中做一系列优化包括算子融合、内存规划、自动向量化甚至自动生成算子实现而不是只依赖预置的算子库。我最初接触TVM是因为一个项目里需要在RK3399板子上跑一个检测模型ONNX Runtime的CPU后端慢了那个硬件又没有好用的NPU工具链。后来用TVM的AutoTVM试了一轮虽然调优过程比较耗时但跑出来的速度确实比默认的ONNX Runtime快了将近一倍。从那时开始我陆续把几个端侧项目切到TVM上也踩了不少坑。这篇就围绕我自己实践中比较重要的几个方向展开自定义算子、算子融合、模型压缩量化以及整套流程在端侧部署时的注意点。适合看的读者是那种已经有模型训练基础想把模型真正部署到端侧硬件上并且对性能有要求的工程师。如果你只是跑个demo不太关心推几毫秒那TVM的学习成本可能不太划算。但如果你在做产品想让模型在有限算力的设备上跑得更快、更稳TVM绝对值得花时间研究。2. 自定义算子让硬件听懂你的计算2.1 为什么一定会有自定义算子的需求很多人一开始觉得训练模型用到的算子不就那几十个吗Conv、Pool、MatMul、Softmax、激活函数官方算子库都覆盖了有必要自己写吗实际部署过就会发现远没那么简单。一方面新论文里经常出现一些新结构比如Deformable Conv、CoordConv、各种自定义Attention变体训练框架里可能有实现但推理引擎里未必有。另一方面就算算子名字一样端侧对某些算子的实现也不一定高效。比如MobileNet里的Depthwise Conv在不少推理引擎里只是普通Conv的特例跑得很慢又比如某些量化模型里的Requantize操怌官方算子可能根本没有针对你那个硬件做优化。还有一类更麻烦的情况模型里某个算子只有训练实现比如在PyTorch里用autograd Function写了一个自定义前向过程。导出ONNX时如果没写好symbolic函数出来的图可能是一个子图里面全是小算子拼接在端侧执行效率惨不忍睹。这种时候就直接写一个TVM的自定义算子把整个子图收拢成一个算子性能立刻不一样。2.2 TVM算子的两层写法TE与TIRTVM自定义算子主要有两种写法层面。一种是相对高层的TETensor Expression适合快速验证算法逻辑另一种是底层一点的TIRTVM IR适合精细控制内存和计算细节也是最终编译优化的目标形态。TE写起来像在写数学公式。比如要实现一个简单的向量相加TE的写法大致是这样import tvm from tvm import te n 1024 A te.placeholder((n,), nameA) B te.placeholder((n,), nameB) C te.compute((n,), lambda i: A[i] B[i], nameC) s te.create_schedule(C.op) print(tvm.lower(s, [A, B, C], simple_modeTrue))这里te.compute描述的是“C的每一个元素怎么算出来”至于怎么并行、怎么向量化、怎么切分循环是schedule阶段才考虑的事。这种“算法描述”和“调度策略”分离的设计是TVM和其他推理引擎很不一样的地方。你可以在不改算法逻辑的前提下尝试不同的schedule比如循环展开、向量加载、多线程分块找到最适合目标硬件的版本。但TE在处理复杂逻辑时有点力不从心。比如算子内部有数据依赖、有原子操作或者需要直接控制寄存器分配这些用纯函数式的TE很难表达清楚。这时候用TIR会更直接。TIR和写底层C代码的感觉很像支持buffer管理、循环、条件分支甚至内联汇编。一个TIR算子的基本结构可以这样看tvm.script.ir_module class MyAdd: T.prim_func def main(A: T.Buffer((1024,), float32), B: T.Buffer((1024,), float32), C: T.Buffer((1024,), float32)): for i in T.serial(1024): with T.block(compute): T.reads(A[i], B[i]) T.writes(C[i]) C[i] A[i] B[i]这个T.Block里的reads/writes声明很重要它告诉TVM这个block访问了哪些数据后续做算子融合、内存规划、并行化分析的时候都要依赖这些信息。如果乱写或者漏写轻则优化失效重则产生错误结果而且是那种很难查的错误。实际项目中我通常先用TE快速写一个版本跑通精度再对着profile结果决定要不要改用TIR做精细优化。因为TE已经能生成比较高效的代码大多数情况下没必要直接上TIR。只有那种对局部内存访问模式要求极其苛刻的算子才值得直接用TIR手写。2.3 把自定义算子接入Relay图写好了算子本身只是第一步。真正要让它参与模型推理还需要把算子接进Relay IR。Relay是TVM的高层计算图表示模型在TVM里是以Relay图的形式存在的。要让引擎认你的算子得告诉Relay两件事这个算子长什么样以及怎么把它翻译成可执行代码。举个例子假设你做了一个自定义的LayerNorm变体名称叫MyLayerNorm输入是一个tensor和两个scale/bias参数。先用Relay的op.register方式注册一个算子然后在compute和schedule注册里分别挂上TE/TIR实现和调度策略。这样Relay在做图优化的时候会把你的算子当成一等公民参与融合、布局转换等操作。如果是从ONNX导入模型还需要在导入前端里加一个转换函数把ONNX的对应节点映射到注册的Relay算子。有一个常见写法是用_register或者手动在转换pass里加case。我自己习惯用tvm.relay.frontend.onnx的注册机制这样后续模型导入时能自动触发。这个环节最容易出问题的点在于算子属性序列化。Relay算子的属性必须是可序列化的JSON友好类型如果你在属性里塞了一个对象或者lambda编译部署时在runtime反序列化就会挂掉。我踩过一次这种坑本地构建时好好的一到目标板上跑就报InternalError查了两天才发现是属性类型的问题。2.4 算子实现完成后怎么验证自定义算子不要直接扔进大模型里测先单独验证精度和性能。精度验证最简单的方法是构造几组随机输入把TE/TIR实现的输出和PyTorch里用浮点算出来的结果做对比。注意要用多组不同shape的输入特别是边界shape比如channel为1、height为1、海量元素的情况。误差容忍度按照算子类型定如果是纯矩阵运算一般至少要求1e-4级别的一致性如果算子内部有exp/log这类非线性放宽到1e-3也不一定够最好还是逐元素检查出错的位置分布。性能验证方面建议直接用TVM的build生成目标代码然后在开发板上跑benchmark不要只在x86上测。因为很多优化策略在不同架构上表现差异巨大。比如NEON向量化在ARM上效果显著但x86上的AVX策略搬到ARM上就不能直接用。TVM支持通过tvm.target指定目标设备cross-compile后放到板子上跑才是真实性能数据。3. 算子融合不折腾内存就是最快的加速3.1 融合到底解决的什么问题推理引擎里一个很常见的现象大量的算子是element-wise类型的比如ReLU、Add、Sigmoid、乘scale这些。这些算子的计算量本身非常小真正的开销全在内存读写上。每一个算子执行完都要把中间结果写回DRAM下一个算子再从DRAM读出来。DRAM的带宽和延迟比片上缓存慢一个数量级来回倒腾几次性能自然就崩了。算子融合的思路就是把相邻的多个计算合并成一个kernel中间结果直接留在寄存器或缓存里不落到DRAM。最经典的例子是ConvBNReLU。把BN的scale和shift折算进Conv的weight和bias再和ReLU放一个kernel里一个循环里就把三件事干完了。我在手机上实测过单是这个融合就能省掉20%到30%的延迟内存占用也有明显下降。TVM里的算子融合不是靠人工手动改图而是通过对Relay图做pass来完成的。默认的FuseOpspass会把图中可以融合的算子分组生成一个个Function每个Function对应一个融合后的算子。具体哪些算子能融到一起取决于TVM对算子访问模式的判断。按我的经验TVM融合策略里最常用的是injective类算子也就是输出和输入基本按位置一一对应的算子这类融合比较安全。3.2 实操中的融合配置在Relay里用build之前你可以显式调用tvm.relay.transform.FuseOps来控制融合行为。默认情况下TVM会尝试一种融合级别但你也可以通过参数控制融合的激进程度。最常用的两个fusion级别配置在transform里对应的是FuseOps中的fuse_ops_level字段。level越高融合越激进可能把更多算子包进一个function里但也会让代码生成变得复杂有时反而降低性能因为register压力增大、指令调度更难。具体到我做过的项目level 2的融合效果比较适中它覆盖了最常见的injective算子组合包括elementwise、broadcast、reduction组合等不会过度激进。如果你的模型里有一些奇怪的结构比如类似split-two branches-concat这种形态level 3可能更合适。这类图融合后能减少大量中间buffer。但注意融合不是越多越好。我碰到过一个模型把连续好几个elementwise算子都融合进去了结果kernel变得极其复杂ARM上寄存器溢出严重性能反而比不融合还差。所以最终还是要以实际benchmark数据为准不要盲目调高fusion level也不要完全信任默认值。建议在开发板上跑一遍不同level的对比选择最优值。3.3 手动融合与异构融合算子除了依赖TVM的自动pass有些场景需要手动干预融合。最典型的是跨设备融合。端侧设备上常常是CPU和GPU或者NPU同时存在有些算子只能在CPU上执行。比如一个模型里既有卷积又有深度可分离卷积某些硬件上它们不能同时放在GPU上。TVM支持通过异构执行heterogeneous来分配算子到不同设备但融合策略默认不支持跨设备融合。这种时候我一般做法是先把模型里需要跨设备的部分切分成两个子图分别build成独立的执行模块然后在自己的runtime里串联起来。这比硬塞进TVM的自动融合流程里容易控制得多而且排错也方便。接口上可以用tvm.contrib.graph_executor加载多个module手动设置输入输出tensor来串联。另一个常见需求是融合两个计算密集算子比如MatMul激活或者ConvClip。TVM对这类的支持主要依赖你的调度策略融合后kernel的schedule可以做得比较精细。比如把matmul的分块逻辑和elementwise的向量化逻辑合并到一个kernel里合理设计循环顺序。这块比较吃经验我建议先用TVM的调度原语反复试验观察汇编输出和性能找到你的目标硬件上最优的循环结构。3.4 融合效果怎么评估我自己评估融合效果时通常不只看端到端的推理时间还会单独统计两部分数据算子kernel总数变化以及内存分配总量变化。在TVM里如果没有开 profiling你很难直接看中间buffer分配。不过可以用tvm.runtime.profiler或者打开debug模式打印Relay优化前后的图结构对比intermediate tensor的个数。简单放个对比参考以常见的MobileNetV2为例ARM Cortex-A55上未融合前Relay图中的算子数量大约在150个左右实际执行时很多小算子需要反复读写中间buffer。默认FuseOps后算子数量降到60多个端到端耗时减少约25%。手动微调融合级别并优化融合kernel后算子数量进一步降到40多个耗时比默认融合再减少10%左右。当然不同模型、不同硬件数字会差很多但趋势是明确的融合是端侧推理优化里ROI最高的手段之一和量化并列第一优先级。4. 模型压缩与量化精度和速度的平衡术4.1 为什么要量化以及量化在TVM里怎么落地端侧部署中模型参数动不动几十MB、上百MB光加载模型就是一大笔开销。哪怕不出于算力考虑光是模型体积和内存占用就让人头疼。量化的核心思路是降低数据位宽最常见的是从FP32降到INT8。INT8模型的体积只有FP32的四分之一在支持INT8加速的硬件上推理速度也能有2到4倍提升。TVM的量化路线和PyTorch的量化不完全一样。PyTorch的量化主要在框架内部做而TVM是在图优化阶段用pass方式做常见做法是relay.quantize。这个pass会把Relay图中的float32算子转换成带qnn前缀的量化算子比如qnn.conv2d、qnn.add、qnn.quantize等。这些算子会在计算过程中模拟量化误差服务于后续的代码生成。量化流程在TVM里大致分三步第一步校准。用一批有代表性的输入数据统计每一层激活值的范围min/maxTVM里通过relay.quantize.calibrate配合dataset完成。第二步转换。根据校准得到的范围把浮点图转换为量化图。TVM会选择合适的零点zero point和缩放因子scale。第三步编译。量化图经过图优化后编译成目标代码。如果目标硬件支持INT8指令代码生成时会尽量使用INT8计算如果不支持会插入反量化再计算但这种情况性能收益有限。4.2 PTQ和QAT两种模式下哪个更靠谱量化分两种主要思路训练后量化PTQPost-Training Quantization和量化感知训练QATQuantization-Aware Training。两者的取舍非常现实。PTQ不用重新训练模型做起来快几行代码就能跑通。但它的精度损失不可控尤其对于分布不均匀的激活值比如某些层输出集中在很小的区间同时偶尔有离群大值min/max范围会被离群值拉得很宽量化精度就崩了。经典的解决方案是使用百分位而不是min/max确定范围比如把范围设为0.1%到99.9%分位效果会好很多。TVM里的校准支持配置这个方法。QAT则是在训练时插入伪量化节点让模型在训练阶段就适应量化误差。精度通常比PTQ高尤其对ANNs、MobileNet这类本来就比较紧凑的模型QAT几乎是必须的。但QAT需要改训练代码还得有标注数据和训练资源在工程进度紧的时候不太现实。我的经验是如果模型性能余量足够、数据分布稳定优先PTQ配上合适的校准方法搭一个快速threshold。如果PTQ精度崩了或者模型结构特别敏感再上QAT。不要一开始就QAT因为迭代成本高后期调部署问题也很麻烦。4.3 量化算子支持情况与常见坑TVM的量化算子覆盖面这些年越来越全但依然有限。它支持常见的Conv2D、Dense全连接、Add、Mul、Softmax、Relu等基本覆盖了主流分类模型和检测模型。但如果模型里有比较特殊的算子比如TopK、GatherND或者自定义动态shape的逻辑量化pass可能会卡住或者产生错误的图。这时候我通常的做法是把不能量化的算子切出来单独走浮点计算通过混合精度方式执行。TVM里做混合精度需要手动干预图没有很自动化的接口。一个取巧的办法是在量化前把不需要量化的算子明确标注为float或者规划图结构时让特殊算子单独成块。这样量化的主体部分保持INT8特殊部分留在FP32整体精度和性能都能兼顾。还有一个高频坑量化后的模型在x86上验证精度和性能都正常但到ARM板上跑到qnn算子时报错。排查下来通常是指令集问题因为ARM和x86对于INT8乘加的底层实现差别不小。特别是涉及vdot这类指令时老一点的ARM核不支持TVM代码生成后运行时会报illegal instruction。建议在build阶段用tvm.target明确定义CPU架构和特性如-mcpucortex-a55 -mattrdotprod提前规避这类问题。4.4 量化后的精度回归怎么做量化后的模型不能只看准确率一个指标需要做完整的精度回归。我的基本清单是在开发板的实际推理环境里跑完整的测试集不是在服务器上模拟因为不同硬件上INT8行为有细微差异。对比中间层输出。TVM支持把量化图的某个中间tensor打印出来和PyTorch的对应层输出对齐分析误差来源。方法上可以往图里插入debug节点来导出中间结果虽然要多一次编译但定位问题非常高效。专门挑选一些困难样本做人工评测特别是低纹理、低亮度或者有噪声的图像量化对这些样本的扰动最容易被放大。如果出现精度下降先判断是权重量化导致还是激活值量化导致。一个快速定位方法是把权重保持INT8激活值改为FP32跑一次看精度再把激活值保持INT8权重改为FP32跑一次。哪个恢复精度问题就在哪一侧精准定位后针对该层调整校准策略或者单独采取混合精度处理。5. 端侧部署适配与Runtime集成5.1 编译流程里最关键的三步把TVM模型部署到端侧完整流程可以概括为三步构建、导出、加载。构建阶段是核心。你选择目标硬件、指定优化passes、生成目标代码。端侧项目里我常用tvm.relay.build配合target参数来生成runtime.Module。值得关注的是TVM支持export_library把编译产物导出为动态库格式比如.so文件。这样在目标板上只需要装一个轻量TVM runtime不需要完整编译TVM源码这能省掉板子上很多麻烦。导出阶段有两样东西需要打包一个是编译后的模型库.so另一个是图结构描述。TVM里常用的是tvm.relay.build后保存lib和graph_json或者直接用runtime.Module的export_library加上参数表文件。params通过tvm.relay.save_param_dict导出注意格式和版本都要匹配否则加载时容易出现版本不一致的玄学报错。加载阶段是在目标设备上运行的。用C或者Python加载动态库配合tvm.contrib.graph_executor创建执行器。我最常用的是C API因为板子上Python环境往往不全C直接集成进自己的应用框架里也比较顺。加载后设置输入tensor调用run()取输出。只要构建阶段target设置正确这个流程在大多数硬件上都能跑通。5.2 端侧设备上的内存与线程规划端侧设备的内存是稀缺资源。TVM编译后的模型占用的内存包括模型参数、中间激活值、输出缓冲区。默认情况下TVM会做内存规划通过storage rewrite优化复用中间buffer。但我发现在内存特别小的设备上还要手动控制线程数。TVM runtime默认会根据设备核数开启多线程但如果你的应用同时还有其他任务在跑所有核都被TVM占满反而会导致整体体验下降。建议在加载模型时通过tvm.runtime配置线程数留出一些核给系统和其他模块。在线程数调优上我通常先跑一轮不同线程数从1到最大核数的benchmark找拐点。常见ARM板的经验是线程数超过4以后内存带宽会成为瓶颈再增加线程数性能提升不明显。另外要注意CPU调频策略。板子上默认的ondemand或performancegovernor会影响推理延迟的一致性。如果做产品交付建议在运行时临时切换到performance模式跑推理完事后再切回或者干脆写一个脚本控制。否则你测出的推理延迟在发热后会大幅恶化。5.3 Runtime集成时容易忽略的问题Runtime集成最容易忽略的是输入数据的布局。TVM默认的布局可能是NCHW但端侧硬件通常更喜欢NHWC或者NC4HW4这类通道向量化的布局。构建阶段你可以通过relay.transform.ConvertLayout来改变布局。不过我建议这个动作放在性能调优阶段再弄不要在最初集成时同时引入布局变换和量化那样出了问题根本没法定位。还有一个是模型更新迭代的问题。你不可能每次模型版本更新都让板子重新完整编译最理想是模型嵌入式的场景每次只更新权重。TVM支持分离权重编译权重以参数形式单独保存运行时加载更新就行。我建议项目从一开始就用这种分离方式不要图省事把权重打包到so里后面模型版本迭代时能省大量重复编译时间。6. 常见问题速查与避坑经验结合我实际项目里踩过的问题整理一个速查表。这些坑都不是从文档里看出来的而是跑环境时真实遇到的现象常见原因排查方向构建成功板子上加载so报错编译器版本和runtime版本不一致检查TVM版本编译和部署端保持同一版本尤其子模块版本模型输出全为0或随机数值输入数据的tensor dtype与实际不符检查dtype是否设置了uint8或float32但实际传入类型不匹配量化后精度大幅下降校准数据分布和真实场景差异过大重新校准尽量使用真实推理场景的数据集而非训练集ARM上跑INT8 kernel报非法指令目标CPU不支持dotprod指令在target里显式指定dotprod或改用不依赖该指令的优化等级融合后性能反而变差融合kernel过复杂寄存器溢出降低融合level或者手动拆分融合分组加载模型时内存暴涨中间buffer过多复用不足检查storage rewrite是否生效考虑手动调整图结构或内存规划策略同一模型在不同板上性能差异巨大未针对具体硬件CPU特性优化用tvm.target分别指定CPU型号和扩展指令不可用通用target一揽子解决还有一个经验调试时不要直接在板上跑完才看结果。TVM支持在构建时开启tvm.rpc先在电脑上远程调试板子实时拿到日志速度会快很多。尤其对未知bug的定位直接板子上反复烧固件实在太浪费时间。算子层的调试也一样建议小步验证。每次写完一个自定义算子先单算子验证再逐步加大模型里的占比不要一上来就全模型替换出问题难定位。7. 关于TVM的一点个人体会做端侧推理这一年多我最大的感受是TVM确实是个复杂系统学习曲线比较陡但它的价值在于让你真正掌握模型在硬件上执行的过程。用ONNX Runtime或者TensorRT很多时候你像个黑盒用户只能调几个参数不知道里面发生了什么用TVM你被迫理解调度、内存、向量化、融合这些底层逻辑之后再回到其他推理引擎上游刃有余。如果你刚开始接触TVM我的建议是先别急着上自定义算子从一个标准模型比如ResNet、MobileNet跑通部署流程开始再试着开不同的pass组合对比性能差异。等你能清楚解释每次优化带来的变化后再挑战自定义算子和细粒度调度就不会觉得无从下手。算子融合和量化这两个方向无论在哪个框架里都值得深挖它们对端侧推理的收益几乎立竿见影。TVM只是提供了更灵活和透明的实现路径。后面有机会我可以再单独聊聊AutoTVM和Ansor在端侧算子自动调优上的用法那个话题也很有意思。如果你也在折腾端侧部署、自定义算子或者量化遇到什么奇怪的问题欢迎随时在评论里聊聊我尽量给出有针对性的排查建议。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。