两年前如果有人跟我说,同一个AI算法跑在手机芯片上,能比跑在一台专用AI加速硬件上更快,我一定觉得他疯了。那会儿我手里正好有个分割模型项目,模型比较大,一直在一台专用硬件上跑,理由是“硬件是专门为AI设计的,软件怎么优化也不可能超过它”。后来我们做了两件事之后,结果彻底反转了:一件叫模型压缩,一件叫编译器优化。压缩砍掉冗余计算,编译器减少无效数据搬运,把移动端本来被低估的资源潜力真正挖了出来。最终在移动端跑出来的延迟反而比专用硬件还低,功耗也小一个量级。这篇内容就是把当时从怀疑到验证再到落地的全过程拆开讲清楚,适合正在做端侧部署、或者在专用硬件和移动端之间做选型的人参考。
1. 为什么“专用硬件”反而是最容易被打败的对手
1.1 专用硬件赢在路线,输在自由度
专用AI加速硬件通常走一条很明确的技术路线:算子经过定制、内存按固定模式管理、计算单元按流水线排布,所有决定在设计阶段就定死了。这套路线的优点是确定性高、单位功耗算力强,比如在固定输入尺寸、固定算子组合下,它确实能把吞吐拉到很高。缺点也很明显,一旦算法结构发生变化,硬件本身不能跟着变,只能靠软件去适配,而适配的手段非常有限。
当时我手里那台硬件就是这种情况。模型是分割模型,输入分辨率固定,卷积和反卷积混合。硬件内部把卷积相关的计算流水线优化得很好,可一旦遇到动态形状、或者分支结构复杂一些的算子,利用率就断崖式下降。表面看它很强大,实际情况是它只对某一种计算模式强大。这与编译器优化恰恰相反:编译器的目标不是为一种算子做专用流水线,而是针对一张完整计算图做全局策略,哪里值得融合、哪里要改布局、哪个算子可以用更智能的调度方式执行,编译器可以通过变换来适配。
所以我的第一个判断是:不要把“专用”看成绝对优势。专用意味着被过去的设计思路锁死,面对新的算法结构时,它反应不过来。模型的演进速度永远快过硬件带宽的演进,而编译器是唯一能自动跟随模型变化的环节。
1.2 移动端真正的底牌多数人没用满
移动端SoC的结构和专用硬件不太一样,它拥有CPU、GPU、DSP、NPU等多种计算单元,内存带宽和缓存策略虽然受功耗限制,但编译器可调度的资源组合非常丰富。问题是,很多端侧项目的“编译器优化”只是开了默认模式:默认融合只覆盖常见组合,默认内存布局固定不变,默认算子调度保守执行。结果就是模型压缩做得再多,性能也被编译器的默认策略拖住了。
我之前观察过几组真实部署的模型,同样一个INT8模型,用默认方式编译和用深度调优方式编译,卷积类算子的耗时差距经常超过30%。差距不是来自模型内容,而是来自算子融合数量、中间张量分配次数、内存访问顺序、以及缓存命中率。移动端芯片为了省电,内存在物理上是分层架构,越往里越快、越小;数据一旦没搭上“局部性”这趟车,计算单元再快也没有用。
专用硬件的高效依赖预先设计好的存储结构,而移动端的高效依赖编译器把访存路径理顺。多数项目没拿到的,是编译器这一层的红利。
2. 压缩三板斧:量化、结构化裁剪、蒸馏的配合顺序
2.1 量化不是把FP32换成INT8那么简单
提到移动端优化,第一步基本都会做量化。INT8之所以成为主流,不完全是因为“精度够用”,更直接的原因是移动端SoC里的DSP和NPU计算单元原生位宽就是8位,用INT8访问内存时数据量直接减半。这个省下来的内存带宽,对整体性能的影响往往比计算单元本身还大。
单纯把模型从FP32转成INT8,在工具链上点一下就能完成,但实际效果要看两部分工作:校准集和误差分析。校准集不是随便拿几十张图片就行,它要覆盖真实场景里的输入分布。我当时从业务侧拉了500多张代表性图像,包括逆光、低对比度、小目标区域占比较高的样本,然后带着量化器做逐层激活值范围统计。
有一点容易忽略:量化误差不是均匀分布的。某些层激活值范围极小,量化步长过粗,会让关键特征直接被吃掉。我们当时的做法是逐层看激活值的最大值和分布直方图,找出那些“数值范围明显大于模型总体水平”的层,优先处理。处理手段通常两种:提升该层的量化scale,或者在工具链支持的情况下给这一层单独保留更高精度。但移动端工具链有时候不支持任意混合精度,更稳的办法是在网络结构上做调整,把容易出现数值溢出的层换成数值更稳定的激活函数或加截断层。这个思路听上去有点绕,实际测试效果却很好。
如果离线量化效果达不到要求,就走量化感知训练(QAT)。它的基本原理是在前向计算时把量化误差模拟进去,让模型的参数在训练阶段就学会适应量化带来的扰动。误差不再是在推理阶段被突然加进来,而是在梯度下降过程中被模型“消化”掉,收敛后的模型在INT8下的精度明显更稳。
2.2 结构化裁剪:这一层我们直接删掉
第二部分压缩是剪枝。业界一直有两种流派:非结构化剪枝和结构化剪枝。非结构化剪枝可以做到高稀疏率,比如把40%的小权重置零,但在移动端并不讨巧。原因是稀疏后的计算需要索引支持,NPU和DSP很难高效处理不规则稀疏块,索引读取和跳转开销反而拖慢计算;专用硬件更不用提,它对稀疏的支持也是定死的。
移动端真正好用的是结构化剪枝:按照通道、滤波器或组来裁剪。只需要保留原来张量的部分通道,张量形状依然规则,编译器可以继续用向量化指令处理,无需处理稀疏索引。尤其对卷积层,按通道剪掉对输出特征图影响较小的那一部分,精度损失通常可控。
更有价值的是“层淘汰”。一些模型为了增加表达力,会分两个支路并行计算再合并。如果其中一个支路的功能与另一个重叠度很高,进入部署阶段后可以直接把无用小支路删掉,让大支路独自承担。这种结构级的删减,比剪权重高效得多。当时我们就是通过可解释性分析工具把一些低贡献支路直接清掉,模型体积下降不多,但推理耗时下降了将近20%,因为省掉的是整段计算逻辑和对应内存访问。
2.3 蒸馏在这里的真正作用是留精度余量
做了量化和裁剪之后,误差是累计的:量化压掉一部分、裁剪丢一部分、后续编译器激进优化又可能改变执行精度。这时候往往会发现精度掉得比单独跑量化时要多。解决办法是蒸馏,而且建议放在量化之前。
蒸馏的通俗理解是:大模型不仅输出最终分类结果,还把自己的中间逻辑“话语权”交给小模型。小模型从大模型的软标签中学习类别间的细微差异,这样它对量化扰动更有韧性。当时我们拿压缩后的模型做蒸馏,精度比直接压缩高出约1.2%,这个余地正好覆盖掉编译阶段合并算子时产生的微小计算差异。
顺序上建议:先蒸馏,再剪枝,最后量化。蒸馏让模型表达更稳健,剪枝把不重要的通道删掉,量化再压缩数值表示,每一步的变化都被前一步的“余量”吸收掉,最终精度损失才能控制在可接受范围。这个顺序反过来做,后一步的操作很容易把前一步留下的误差放大。
3. 编译器优化才是拉开差距的主战场
3.1 算子融合:一次数据搬运的消失胜过一次计算加速
模型压缩能砍掉很多计算量,但它不解决访存方式的低效。一个计算图中,数据从一个算子流向另一个算子时,如果每次都要把中间结果写回内存,再下一次读出来,那内存带宽就是瓶颈。编译器优化里最直接的手段就是算子融合。
融合的常规做法是Conv和BN合成一个卷积,再加ReLU合成一个算子。这只是最基础的一层。再往上走,是三路分支共享同一个输入张量,原本每条分支都要读一遍输入,融合后只读一次,输出也合并收集在一起;再往上,是先把后处理算子(比如argmax、softmax)吸收进计算图,而不是让它们在CPU上单独执行一遍。
我从实际数据看,传感器类模型里,把激活函数、归一化、输出后处理这些算子并进去之后,整个图从30个算子缩到了十几个。算子数量减少本身不是目标,关键是中间张量的分配次数和写回次数大幅下降。内存分配是端侧推理的隐形开销,每次分配都有锁、有对齐、有清零,次数多了积累起来非常可观。
3.2 布局转换:一个反直觉的性能杀手
说个反直觉的事:很多模型在服务端训练时用的是NCHW布局,直接拿到移动端,不做布局优化,性能会非常难看。NCHW意味着在特征图维度上数据是连续的,而移动端卷积在计算时总是沿着通道维度做累加,通道间的数据在内存中隔得很远,缓存命中率极低。换成NHWC之后,单个像素的通道数据连续排布,数据加载自然快。
但不是所有算子都喜欢NHWC,有些全连接、某些归一化算子更喜欢NCHW,这个取舍很容易让人犯难。合理的做法是对整张计算图做划分:每个子图内部布局一致,子图之间插入Layout转换算子。这样可以保证绝大多数核心算子拿到最合适的布局,而转换算子的开销通过融合消化掉。
当时有一层3x3卷积,单纯把布局从NCHW改成NHWC,实测延迟下降了约15%,只做了这一处布局调整。把整个推理图分区块做布局规划后,整体又掉了约8%。编译器要做的不是全局强制一种布局,而是局部最优、全局换乘。
3.3 算子调度:大核跑重活、小核做杂活
移动端是多核异构结构,调度也有讲究。大计算量的算子放GPU或NPU上是通常做法,但遇到小算子、维度不规整的逻辑时,上NPU反而会浪费调度开销。编译器需要判断算子图的可并行程度,做一个任务划分:大卷积给计算密集型处理器,不规则逻辑放CPU,低功耗场景下还可以把一部分任务交给DSP。
很多框架的默认调度策略是“当前芯片默认设备跑所有算子”,这种策略只保证正确,不保证最优。有一次我们手动把部分维度的拼接操作从小核调度改成大核并行,看似只动了调度策略,整体延迟就少了5%。调度的本质是让处理器的空闲资源和等待时间变少,所以观测每个算子的耗时占比、处理器利用率,然后逐个试调度组合,比盲目相信默认策略靠谱得多。
3.4 别只信Profile工具,要以设备端实测为准
做编译器优化最容易被误导的就是性能分析工具给出的结果。Profile工具消耗本身的性能开销、电源管理策略的干扰、模拟器与真机的差异,都会让数据显示的计算热点和真实情况不一致。
我的习惯是:先用Profiler找到瓶颈算子和内存复制热点,但最终判断必须上真机实测。在代码里埋计时点,把每个算子的耗时打印出来,对比Profiler结果,进行筛选。任何优化改动,落地前都跑一个完整的设备端时间统计,不只看单次,看多次采集中位数和尾延迟。很多编译器优化在实际执行时会因为缓存状态、温度、频率的不同展现完全不同的效果,这一步不能省。
4. 性能基线、功耗与稳定性:验证方法和统计陷阱
4.1 性能指标不能只拿一个延迟
很多人对比性能只看平均延迟或者P50,这在移动端是非常危险的。手机芯片的频率受温度、核心状态影响极大,同样一个模型连续跑十次,每一次的耗时可能波动超过20%。只看平均值会掩盖掉最差的体验,而且平均值的提升有可能是“刚好频率跑得高”,而不是优化真的生效。
我通常做数据统计时任务包括:冷启动一次、预热三十次、正式测试两百次。然后取P50、P90、P95。优化的目标是让P50下降,同时也让P90-P50的差距收窄。如果P50下降但P95明显变长,说明某个环节发生了偶发性抖动,这个优化在真实业务里未必有利。
下面是一组虚构但不失参考价值的对比数据,展示查看多个统计量时的典型状态:
| 方案 | P50 (ms) | P90 (ms) | P95 (ms) | 内存峰值 (MB) |
|---|---|---|---|---|
| 未优化FP32 | 82.1 | 91.4 | 96.8 | 690 |
| 量化为INT8 | 61.3 | 67.2 | 70.5 | 430 |
| 量化+算子融合 | 43.8 | 47.1 | 49.9 | 356 |
| 融合+布局调整 | 36.2 | 38.5 | 40.1 | 322 |
只拿P50看,优化确实很明显;但如果在P95上有异常尾巴,就要回头去找是不是某个线程调度、某个内存分配器在作怪。
4.2 功耗比瞬时延迟更能决定体验
移动端场景里,性能不只被延迟衡量,功耗同样重要。模型跑得快但功耗高,手机会发热降频,反而导致后续性能更差。专用硬件虽然峰值计算强,但静态功耗大,长时间运行后功耗和温度都会拉高。移动端平时待机功耗极低,如果任务能在低功耗模式下完成,反而更“快”——因为不需要额外冷却时间。
我们当时把移动端和专用硬件放在同样的任务里各跑一小时,连续监测电流和芯片温度。专用硬件一开始吞吐很高,大概十分钟后频率下降,吞吐明显收敛;移动端虽然单次延迟稍高一点,但因为功耗低、温度上升慢,整个时段的稳定吞吐反而更好。这个结果告诉我们,判断“谁更强”,一定要放回真实运行时长和温度条件里,而不是只比单次推理。
4.3 公平对比的隐藏前提:散热、供电、甚至壳温
对比平台一旦不同,环境条件就尤其重要。用一个带散热器的专用硬件,对比一台裸奔的手机,结果没有公平性。我的做法是给两个平台定义同一组约束:连续运行时长、初始温度、输入分辨率、精度定义、数据批次大小。手机端还得分两种模式测:插电状态和电池状态。
插电时手机一般会把CPU/GPU频率拉高,结果很漂亮;但用户实际使用大多是电池状态,频率被功耗策略压低,延迟会明显变大。所以对外汇报移动端性能时,我要求必须附带电池状态的数据,否则就是自欺欺人。另外,手机亮度也要固定,屏幕模块耗电会影响整机功耗,从而影响频率策略。
5. 我踩过的四个坑:从定点溢出到冷启动抖动
5.1 定点溢出带来的“彩虹图纹”
第一次把分割模型完整量化后,我拿到一批输出图像,结果让我愣住:原本应该平滑的预测图出现了一些规则的彩色条纹,像彩虹图纹一样。排查半天才发现,某一层激活值在统计时没有覆盖真实高亮区域,最大值超过127,量化后被硬性截断,数值饱和导致输出产生条纹。
修复方法不算复杂:单独收集该层激活值分布,把scale调大一些,再做一次量化校准。但这个过程提醒我,量化的误差分析不能只看平均精度,还要输出几组典型样本到可视化工具里看过,确认语义边界没有被破坏。数值统计本身不能代替视觉检查。
5.2 融合后BatchNorm的语义漂移
还有一个教训和BatchNorm有关。模型里带有BN层,默认情况下BN在推理时用的是训练期间统计出来的均值和方差,理论上数值是固定的。编译器做算子融合时会把BN的参数折进卷积核的weight和bias,这样省掉一次算子调用。如果两个分支的BN统计量差异很大,融合后可能会出现分布漂移,影响精度。
当时我们处理的方案是量化感知训练时先冻结BN,让模型的参数在固定的归一化状态下收敛,再去做融合和量化。冻结BN看起来是训练细节,实际效果却直接决定了端侧部署精度,很多人忽略这一点,只怪工具链不准。
5.3 冷启动抖动:预热和不预热的差别
移动端推理有个很常见的现象:第一次调用非常慢,后几次变快。原因是运行时首次创建计算上下文、分配内存池、加载算子库,这些都是额外开销。如果直接把第一次调用的耗时计入性能数据,那移动端永远比不过专用硬件,因为这个对比不公平。
我在移动端做了预热处理:正式测试前空跑几十次,把初始化和算子加载的抖动去掉。但业务侧不能全靠预热解决,真实场景里App启动就是会需要模型首次推理。所以最后的方案是把一部分初始化逻辑提前到App启动阶段异步执行,让用户在真正用到模型时已经准备就绪,把冷启动耗时从关键路径上移走。
5.4 低功耗模式下的“假胜利”
有一次,优化后的移动端模型在插电测试时比专用硬件快了大约10%,我们都很兴奋。第二天在没插电的状态下跑同一套测试,结果完全变了,延迟反而慢了将近15%。原因很简单:电池供电时系统默认限制了大核频率,编译器调度的算子在低频状态下优势被消减,而专用硬件有固定供电,不受这个影响。
从那以后,我把移动端性能测试固定为“插电+电池”双轮制。插电轮用来验证峰值性能极限,电池轮用来验证真实用户场景下的底线。真正决定是否采用移动端方案的是底线性能,而不是峰值性能。同理,功耗数据也必须分两种状态测量,因为插电时的电流曲线和电池供电时完全不同。
6. 关于“超越专用硬件”的最后一句话
这个项目的最终结果是移动端跑完压缩和编译优化后,在电池状态下P50延迟仍然比专用硬件低大约18%,精度损失控制在0.5%以内,整机功耗只有专用硬件的十分之一左右。结论不是“移动端芯片比专用硬件强”,而是把“模型压缩+编译器优化”这件事做完整后,移动端很多隐藏余地会被释放出来。
我的做法其实很朴素:先量化,再剪枝,再蒸馏,最后集中火力做编译器的融合、布局、调度;每一步改动都拿设备端实测数据说话,不轻信Profile工具的“热点”。对正在做端侧部署的同学,我给一个具体的建议:拿到新模型后先别急着追求极限,先把默认工具链跑通,看看哪些算子耗时占比最高、哪些中间张量分配最频繁,再决定压缩和优化的优先级。
如果一个项目里的模型会经常迭代,把编译优化做成自动化流水线,比再换一块更强的专用硬件更划算。硬件选型很难回头,编译器优化则随时可以再做一轮。