☰
端侧推理利器:TVM自定义算子、算子融合与INT8量化实战指南
2026/9/28 14:34:18 网站建设 项目流程

前段时间接了个端侧项目,要在手机芯片上跑一个实时视频处理模型。最初我用现成的推理引擎转完模型,精度、内存都过关,一测速度差了三倍。前后折腾了两周,最后把问题定位在计算图优化和量化这两个环节,才意识到做端侧推理,光会调用推理框架是不够的,还得会处理底层算子,于是开始完整接触TVM。

如果你也在做端侧推理,或者正在研究自定义算子、算子融合、模型压缩量化这几个方向,这篇文章应该能帮你少走不少弯路。我尽量用实际项目的口吻来写,不讲教科书上的概念,只讲我踩过的坑和验证过的做法。

1. 端侧推理的痛点:为什么绕不开TVM这类深度学习编译器

1.1 端侧环境的硬约束

端侧推理和服务器推理完全是两种玩法。服务器上有大显存、高带宽、随意用CUDA,端侧却要同时面对算力弱、内存小、带宽低、发热敏感这几个问题。一个几千万参数的模型放到手机芯片上跑,如果直接沿用服务器端的执行方式,内存会先扛不住,紧接着就是访存瓶颈让速度崩掉。

端侧芯片的种类还很杂。CPU有ARM的大小核架构,GPU有Mali、Adreno,NPU有各家自研单元,指令集和内存模型都不一样。同一个算子在两种芯片上最优的实现方式可能完全不同:一个适合拆成多个小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过了导入这一步,就是它的核心设计:计算定义与执行策略分离。

计算定义,也就是通常说的TE(Tensor 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 kernel,c后端生成可读的C源码。端侧常用的是llvm和c,前者直接用LLVM做指令选择,后者方便你嵌入到已有的Native工程里。

端侧运行时通常会选择一个轻量级的东西。老版本里常见的是Graph Runtime,它把编译产物打包成一个二进制模型文件,运行时加载后按编译好的执行计划跑。新版里更推荐用VM(Virtual 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, eps=1e-5): # 先定义求均值的计算:对hidden维度做归约 mean = te.compute( (data.shape[0],), lambda b: te.sum(data[b, :], axis=0) / data.shape[1], name="mean" ) # 中心化 + 平方,为求方差做准备 centered = te.compute( data.shape, lambda b, h: data[b, h] - mean[b], name="centered" ) var = te.compute( (data.shape[0],), lambda b: te.sum(centered[b, :] * centered[b, :], axis=0) / data.shape[1], name="var" ) inv_std = te.compute( (data.shape[0],), lambda b: 1.0 / te.sqrt(var[b] + eps), name="inv_std" ) out = te.compute( data.shape, lambda b, h: centered[b, h] * inv_std[b], name="layer_norm_out" ) return out

注意,这只是一个计算定义,它生成的代码执行效率并不高。如果直接这样丢给编译器,TVM会生成一串串行归约代码,速度可能还不如框架自带的实现。

接下来要做的是给它加Schedule,核心思路是:把外层batch维拆到多个线程上并行,hidden维尽量让归约在寄存器里完成而不是反复访问内存。基于我的经验,先写一个最粗糙的schedule跑通,再去调split、vectorize。如果你对调度细节不熟,可以先只做一层split,别的交给autoTVM去搜。

3.3 注册算子的关键步骤与常见坑

自定义算子如果只是自己写来测试,可以直接走tvm.build编译。但如果你想让算子被Relay图优化自动识别、能在图里和其他算子融合,就需要做注册。

注册路径分两层。第一层是注册到Relay,告诉它这个算子的类型关系、shape推导规则,这样图解析就能把对应节点正确映射过来。第二层是注册到TOPI(TVM的算子实现库),把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_mode=True)),看生成的低级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 -mtriple=aarch64-linux-android这样的目标描述。

实际用起来,我建议把编译环境固定为一套Docker镜像,里面装好目标平台的NDK、OpenCL SDK、对应版本的LLVM。否则三个月后回来说要重新编一次模型,环境早就忘了怎么配。这套镜像最好连TVM的版本一起锁定,因为TVM API变化很快,版本一换,之前的编译脚本经常要重写。

编译产物我一般直接生成C代码(target="c"),然后塞进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模型,在目标设备上测出基线耗时和精度。有了这条基线,后面做的自定义算子、融合、量化每一步都能量化对比效果,也更容易判断哪些优化真正带来了提升。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询