简介:一份聚焦移动端AI落地的技术手册,系统讲解如何在算力、内存与功耗受限的移动环境中,借助PyTorch Mobile完成图像分类模型的压缩与部署优化。核心覆盖剪枝、量化、知识蒸馏三大主流模型压缩手段,逐一拆解其在PyTorch中的实现原理与部署方法,并结合综合应用策略讨论精度与资源的平衡;同时围绕MobileNet、ShuffleNet、EfficientNet等轻量级网络,梳理数据集处理、模型选型、训练调优与评估指标,帮助读者建立从训练到端侧推理的完整链路。实践部分给出Android与iOS双平台从环境搭建、库引入、模型加载到推理运行的全流程指引,并配有花卉与宠物图像分类两个完整案例,从数据准备、模型压缩到效果评估逐步复盘。资源为1个PDF文档,全文49页,目录结构清晰,支持章节跳转与大纲定位,压缩包大小约2.03MB,已有56人学习,适合准备把深度学习模型迁移到移动端或从事模型小型化工作的开发者作为系统性参考。
1. PyTorchMobile 部署图像分类:为什么先谈压缩,再谈跨平台
把一台训练服务器上跑得飞快的 ResNet50 搬到手机里,你会发现帧率掉到 1 以下,内存直接顶到几百 MB。这是每个做移动端图像分类落地的人都会撞上的墙。PyTorchMobile 提供的是移动端推理运行时和跨平台转换链路,但真正让它能跑起来的,是压缩这一步。模型压缩技术负责把计算量和参数量降下来,PyTorchMobile 负责把压缩后的模型转换成手机能加载的格式,并在 Android 和 iOS 上稳定推理。这两件事缺一不可,也是这篇实践笔记的主线。
这篇内容适合正在做智能巡检、工业分拣、离线图像分类 App 的工程师。你已经有一个训练好的分类模型,想在移动端 CPU 上跑到可用的延迟,同时不想从零学一套新的推理框架。下面我会按「压缩选型 → 模型转换 → 量化调优 → 端侧部署 → 踩坑排查」的顺序,把这套链路完整地走一遍。
2. 压缩三板斧怎么选:量化、剪枝、蒸馏在移动端的分工
2.1 量化为什么是移动端首选:PTQ 与 QAT 的适用边界
移动端本质上是拿精度换速度,量化是路径最短的那一条。把 FP32 的权重和激活从 32 位降到 8 位整数,模型体积直接缩到四分之一,推理速度在支持 SIMD 指令的 CPU 上能快 2 到 4 倍,而且不需要改模型结构。分类任务对量化容忍度相对高,因为最后的 softmax 输出只关注 top-5 的排序关系,少量精度损失不一定会改变最终类别判断。
常见的做法是 PTQ(训练后量化)和 QAT(量化感知训练)。PTQ 是拿少量校准数据跑一遍推理,统计每层激活的数值范围,然后确定量化 scale 和 zero point。它的优点是快,不需要训练,缺点是敏感层精度损失可能比较大——尤其是 BatchNorm 折叠之后数值分布偏移的情况。QAT 是在训练过程中插入伪量化算子,让网络自己适应量化误差,精度通常更高,但要重新训练模型,成本高一个量级。
我一般这样选:项目周期紧、模型是 MobileNetV3 或 EfficientNet-Lite 这类本身不太敏感的,直接 PTQ;模型是 ResNet 系列、或者对精度损失零容忍的,走 QAT。还有一个折中方案是「敏感层排查 + 混合精度量化」,后面第 4 章会专门讲。
2.2 结构化剪枝怎么配合 CPU 推理
剪枝在移动端是个矛盾体。非结构化剪枝把模型里接近零的权重置零,参数量看着小了,但推理时 CPU 没法跳过这些零值,实际加速几乎为零,除非底层做了稀疏矩阵优化——而 PyTorchMobile 的 CPU 算子没有做。所以移动端只能做结构化剪枝,也就是把整个卷积核或整个通道删掉。
结构化的做法是对每个卷积核计算 L1 范数或 BN 层的 gamma 值,按重要性排序后删除不重要的通道。PyTorch 官方的 torch.nn.utils.prune 支持非结构化剪枝,但结构化剪枝更常见的做法是写一个前置脚本:对每个卷积层计算权重绝对值之和,然后把这个通道权重置零并把对应的 BN 层输出裁剪掉。裁剪之后要重新训练或者微调,让后面的层适应通道数变化。
实际落地时,我会把剪枝率控制在 30% 以内。超过这个阈值,MobileNetV3 的精度会掉得比量化还猛。对分类任务,剪枝更适合用来腾出内存空间,而不是追求极致加速。速度和压缩的主力是量化,剪枝是给量化之前先瘦身用的。
2.3 知识蒸馏在小模型上的收益
知识蒸馏解决的是小模型训练不到位的问题。移动端常用的分类网络在 ImageNet 上和大模型差距主要是模型容量,蒸馏让小模型直接学大模型的软标签,收敛速度和上限都会有提升。具体做法是在训练 loss 里加一项 KL 散度,让 MobileNetV3 的 softmax 分布去逼近 ResNet50 的 softmax 分布,温度 T 通常设 4 到 8。
PyTorch 里实现蒸馏损失并不复杂,训练循环里同时前向大模型和小模型,计算小模型 logits 和大模型 logits 之间的 KL 散度,加上小模型的交叉熵 loss:
import torch import torch.nn as nn import torch.nn.functional as F def distiller_loss(student_logits, teacher_logits, labels, T=4.0, alpha=0.7): # 温度缩放:除以 T 之后再做 softmax,拉平分布、暴露类间关系 student_soft = F.log_softmax(student_logits / T, dim=1) teacher_soft = F.softmax(teacher_logits / T, dim=1) kd_loss = F.kl_div(student_soft, teacher_soft, reduction="batchmean") * (T * T) ce_loss = F.cross_entropy(student_logits, labels) return alpha * kd_loss + (1.0 - alpha) * ce_loss这个函数的关键参数是温度和 alpha 比例。温度 T 控制分布平滑程度,T 太大会让所有类别概率趋同,信息被稀释;T 太小则蒸馏退化成普通交叉熵。alpha 控制蒸馏损失和真实标签损失的比例,我习惯从 0.7 起步,如果小模型在训练集上的 top-1 精度爬不上去,再把 alpha 降到 0.5 给真实标签更多权重。注意这里的教师模型和学生模型要各自做 train() / eval() 切换,否则 BN 层统计量会互相干扰。
2.4 三板斧的执行顺序建议
这三项技术不是互斥的,但顺序有讲究。我的顺序是:先蒸馏训练出一个小模型,再做 20% 到 30% 的结构化剪枝并微调,最后量化。理由很简单:蒸馏让小模型先恢复一部分容量,剪枝在容量恢复之后再删通道,此时精度损失更可控,量化留到最后统一压缩。如果先量化再剪枝,量化引入的噪声会被剪枝放大,后面很难排查到底是哪一步造成了掉点。
| 压缩手段 | 模型体积收益 | 推理加速 | 改动成本 | 精度损失 |
|---|---|---|---|---|
| PTQ 量化 | 约 4 倍 | 2~4 倍 | 低 | 中 |
| QAT 量化 | 约 4 倍 | 2~4 倍 | 高(需重训) | 低 |
| 结构化剪枝 | 1.3~1.5 倍 | 1.2~1.5 倍 | 中 | 中 |
| 知识蒸馏 | 无(模型变小) | 无 | 高(需重训) | 负(精度提升) |
在决定做哪几项之前,先看一个关键指标:当前模型到底是被什么卡住了。用 PyTorchMobile 的 profile 工具先跑一遍端侧推理,如果耗时大头在最后的全连接层和 softmax,剪枝收益很小,量化就足够;如果耗时大头在深层卷积,就先剪枝再量化。
3. 从 PyTorch 模型到 PyTorchMobile 格式:转换链路与首个 Android 推理
3.1 trace 和 script:为什么转换控制流时容易翻车
torch.jit 是 PyTorch 的模型序列化方案,它提供两种方式:torch.jit.trace 和 torch.jit.script。trace 的思路是给模型一个假的输入,实际跑一遍前向过程,记录所有张量运算,生成一个计算图。这个方式的优点是快、对大多数 CNN 都有效;缺点是「数据相关」的逻辑会被固定死,比如 if 条件判断只要没走到某个分支,那个分支就不会被记录下来。
script 则是编译器思路,直接解析 Python 代码生成等效的 TorchScript 图。它能处理 Python 语法里的循环和条件分支,但对代码有要求:不能使用 list append 以外的动态结构、不能依赖需要逐行读取 Python 对象的第三方库。图像分类模型一般结构固定,trace 够用;但如果你在 forward 里有if input_size > 224之类的分支,就必须用 script 或者把分支改写成 torch.where 后再 trace。
我自己经历的翻车现场是这样的:模型在前处理里写了一个动态 padding 逻辑,根据输入宽高决定 pad 多少,用 trace 转换后,部署到端上发现输出维度变了。原因是 trace 记录的是这一次输入的固定路径,换个尺寸就露馅。后来我把动态逻辑挪到端侧 Java 代码里做,模型内部只接收统一尺寸的输入,问题就解决了。这就是 trace 和 script 的边界:模型内部的逻辑必须是静态的。
3.2 optimize_for_mobile:体积和速度双赢的收敛参数
转换完的原始 TorchScript 模型还带着很多调试信息和冗余算子,直接部署到手机上有两个问题:包体大、启动慢。optimize_for_mobile 是专门针对移动端做算子融合和常量折叠的工具,它能做几件有用的事:把 Conv2d + BatchNorm 合并成一个带偏置的卷积、把 ReLU 融合进 Conv 里、删除一些移动端用不到的调试字段。
import torch import torch.utils.mobile_optimizer as mobile_optimizer # loaded_model 是 torch.jit.trace / torch.jit.script 产出的 ScriptModule # example_input 必须和训练时的预处理完全一致,否则 trace 出来的图是错的 scripted_model = torch.jit.load("resnet18_mobile.pt") optimized_model = mobile_optimizer.optimize_for_mobile( scripted_model, optimization_level=1, # 0=不优化, 1=算子融合和常量折叠 backend="xnnpack" # 移动端 CPU 推理后端,按当前 PyTorch 版本选择 ) optimized_model._save_for_lite_interpreter("resnet18_mobile.ptl")这里有两个参数值得关注。optimization_level 是优化力度,level 1 会做 BN 折叠、ReLU 融合、常量预计算,但不会做有精度风险的变换;如果你之后要做量化,建议先量化再 optimize,因为量化后的算子融合收益更大而且不会破坏量化参数。backend 参数在较新的 PyTorch 版本里是 xnnpack,旧项目里是 qnnpack,这俩对应不同的 CPU 指令集实现,选择依据是你的 PyTorch 版本和最低支持的 Android 机型。不确定的时候先跑默认值,再在真机上用 profile 对比。
Saved .ptl 文件就是用 Lite Interpreter 加载的格式,相比完整 TorchScript 去掉了很多只在 PC 端用的依赖,Android 集成时能少打几个基础依赖包。
3.3 在 Android 端跑通第一个分类推理
Android 端集成 PyTorchMobile 的常见做法是依赖 pytorch_android_lite 的 Maven artifact,具体版本号去官方发布列表里取一个稳定版。核心调用逻辑写在后面这个例子里,我直接给一个最小可用版本。
import org.pytorch.LiteModuleLoader; import org.pytorch.Module; import org.pytorch.Tensor; import org.pytorch.torchvision.TensorImageUtils; Module module = LiteModuleLoader.load("resnet18_mobile.ptl"); // 从 assets 加载 // 读取图片,统一缩放到训练的输入尺寸 224x224 Bitmap bitmap = BitmapFactory.decodeStream(getAssets().open("test.jpg")); Bitmap resized = Bitmap.createScaledBitmap(bitmap, 224, 224, true); // 预处理:RGB 均值归一化,数值必须与训练保持一致 // 如果训练用的是 ImageNet 统计量,这里就是 0.485, 0.456, 0.406 和 0.226, 0.224, 0.225 Tensor inputTensor = TensorImageUtils.bitmapToFloat32Tensor( resized, new float[]{0.485f, 0.456f, 0.406f}, new float[]{0.226f, 0.224f, 0.225f} ); Tensor outputTensor = module.forward(IValue.from(inputTensor)).toTensor(); float[] scores = outputTensor.getDataAsFloatArray(); // 简单 top-1 判定,密集计算量很小 int argmax = 0; for (int i = 1; i < scores.length; i++) { if (scores[i] > scores[argmax]) argmax = i; }这套代码有两个经常出问题的点。第一是 bitmapToFloat32Tensor 里的 mean 和 std,很多人训练脚本里用的是transforms.Normalize((0.485, 0.456, 0.406), (0.226, 0.224, 0.225)),但端侧放了另一组数值,结果 top-1 总是差一个类似位置。第二是线程数设置,PyTorchMobile 默认会占用多个 CPU 核,在 UI 线程直接推理会让界面掉帧,正确做法是把 forward 放到后台线程,或者用 torch.autograd 无关的独立线程池。如果你处理的是视频流里的帧,最好复用同一个 Module 实例,反复调用 forward 即可,不要每次都重新加载。
提示:asset 里的 .ptl 文件不要放在子目录太深的位置,Android asset 会做压缩和索引,深层路径在低端机上解析慢,加载耗时会明显变长。
4. 量化参数调优:qconfig、observer 与敏感层排除法
4.1 先量后调:PTQ 的最小复现流程
量化不是跑一条命令就完事。你需要先搭一个可复现的 PTQ 流程,把精度对比做进每次实验里。下面这段代码展示了 PyTorch 官方的量化 API 在分类模型上的完整用法,我加上了自己对校准数据量和模型评估的注释。
import torch from torch.quantization import ( get_default_qconfig, prepare, convert, QConfig, HistogramObserver, MinMaxObserver, ) model = resnet18 # 预训练权重,要用 eval 模式 # 配置量化后端,x86 上用 x86 后端,ARM 移动端用 qnnpack/xnnpack model.qconfig = get_default_qconfig("xnnpack") # 通过 fuse_modules 把 Conv+BN+ReLU 融合在一起,融合后才能量化 torch.quantization.fuse_modules(model, [["conv1", "bn1", "relu"]], inplace=True) # prepare 阶段在模型里插入 Observer,不真的量化 model_prepared = prepare(model) # 用一小部分训练数据跑 forward,让 Observer 收集激活的 min/max 分布 # 推荐用 1000~2000 张图,类别分布要均匀;只有一两百张会低估激活范围 model_prepared.eval() with torch.no_grad(): for images, _ in calibration_loader: model_prepared(images) # convert 阶段真正把 FP32 权重量化成 int8 model_quantized = convert(model_prepared) torch.jit.script(model_quantized).save("resnet18_quantized.pt")关键参数都在 qconfig 和 Observer 上。get_default_qconfig("xnnpack") 会为权重和激活选定默认的 8 位量化配置,权重用 per-tensor 的 MinMaxObserver,激活用移动平均的 HistogramObserver。默认配置适合大多数场景,但如果你发现某一层激活数值整体接近零分布、量化后噪声非常大,就需要改成 per-channel 或者调整 observer 的 averaging_constant。这个参数控制激活范围估计的平滑程度,默认值 0.01 偏保守,在实际数据稀疏时容易低估最大值导致精度下降,可以往 0.001 方向调。
校准数据集的覆盖度比数量更重要。比如你的分类模型包含夜间森林图像分类场景,校准集里就必须保证有足够多的夜间帧,否则量化点在白天数据上,晚上推理时激活数值直接越过量化范围,精度瞬间掉到噪声水平。
4.2 敏感层排查:哪一层在拖累精度
当你把整网量化后精度掉了 3 个点以上,最有效的排查方法是逐层回退。原理很简单:先把所有层量化,然后一层一层地把它改回 FP32,每改一层就评估一次精度,精度回升最大的那一层就是敏感层。找到之后,这一层在最终部署时保持 FP32 精度,其他层维持量化。
# 逐层排查示例:回退指定层到 FP32 from torch.quantization import disable_observer, enable_fake_quant from torch.quantization.quantize_fx import prepare_fx, convert_fx # 这里用 FX 图模式量化做示范,适合需要细粒度控制的结构 qconfig_fp32 = None for name, module in model.named_modules(): if name == "layer4.1.conv1": # 假设这个层最敏感 module.qconfig = qconfig_fp32 # None 表示不量化 model_prepared = prepare_fx(model, {"": qconfig_dict}) # 校准和 convert 流程与前面一致FX 模式量化的优势在于你可以在 qconfig_dict 里用通配符指定哪一层量化、哪一层跳过,比逐层修改 module.qconfig 更清晰。敏感层的典型特征有几类:第一类是短连接层和残差加和层,它们的激活直接与输出相加,量化误差会累积;第二类是卷积层后面跟着数值范围特别宽的激活函数,比如 swish 在 MobileNetV3 里就会出现宽分布;第三类是最后的全连接层,它对分类边界的敏感度最高。
如果你动手试了,会发现一个常见的位置:ResNet 的 layer4 后半段比 layer1 的敏感度高很多。原因是深层特征图更稀疏,多数激活值接近零,量化后那些小的正值全部被压成同一个整数值,信息就没了。这正好对应前面说的「移动端 CPU 天梯」——不同档位的 CPU 对量化算子的支持不一样,低端 ARM 核上量化误差引起的推理结果变异更大,所以上层敏感层保留 FP32 在低端机上收益尤其明显。
4.3 per-tensor 与 per-channel:MobileNetV3 的选择题
per-tensor 是对整层的一个 channel 维度的全部权重用一个 scale,per-channel 是对每个输出通道单独用一个 scale。per-channel 对精度的提升通常是压倒性的,因为深度可分离卷积的每个通道权重数值差异很大,统一用同一个 scale 会让小数值通道被粗暴截断。
from torch.quantization.quantize_fx import prepare_fx custom_qconfig = QConfig( activation=HistogramObserver.with_args( dtype=torch.quint8, qmin=0, qmax=255 ), weight=MinMaxObserver.with_args( dtype=torch.qint8, qmin=-128, qmax=127, per_channel_dtype=torch.qint8 ) ) model_prepared = prepare_fx(model, qconfig_dict={"": custom_qconfig})这个配置把权重改成 per-channel,激活保持 per-tensor。你需要知道的是:per-channel 权重量化在 xnnpack 和 qnnpack 后端都能跑,但有些较老的移动端 CPU 库版本对 per-channel 的支持不完整,表现为某些层输出异常但没有报错。如果你最终部署的 Android 设备遇到诸如「某一类别的置信度全部接近零」的问题,先检查后端的 per-channel 支持情况,再回退到 per-tensor 看精度对比。
还有一个常被忽略的参数是 qmin/qmax 的对称性。激活用非对称量化(0 到 255)是安全的,因为激活大多非负;权重用对称的 -128 到 127 效果更好,因为权重分布是近似零中心的高斯分布,非对称会浪费一个量化格子。
5. 部署避坑记录:转换失败、精度跳水和推理速度的玄学
5.1 换了一张图片,输出就完全错乱
现象:同一个 .ptl 模型,跑代码里固定的测试图片结果正确,换成从相册读取的图片后,top-5 全部乱套,甚至 score 出现 NaN。
原因:trace 阶段垃圾输入导致固定分支错误只是其一,更常见的是端侧缩放的图片和训练预处理不一致。TensorImageUtils.bitmapToFloat32Tensor 默认把 Bitmap 转成 RGB 张量时保持原始宽高比做 CenterCrop,而你的训练代码可能用的是 Resize 后直接 CenterCrop,两者之间长宽比例不同。另一个原因是 EXIF 方向信息,手机拍的照片带旋转属性,BitmapFactory.decodeStream 不处理 EXIF,导致图片旋转后再缩放,内容已经不是原来的方向。
解决:不要依赖 TensorImageUtils 的默认行为。自己用 Bitmap.createScaledBitmap 先缩放到目标尺寸,再在 Java 层面处理旋转:读 ExifInterface 的 ORIENTATION,按需要旋转 Bitmap。然后手动把 RGB 三个 channel 按顺序取出来,显式传给 FloatBuffer。这样每一步都在你控制之内,出问题可排查。
5.2 量化后精度从 92% 掉到 60%
现象:PTQ 之后在端上评估,top-1 掉了 30 多个点,但同一份模型在 PC 上用同样的评估脚本只掉了 2 个点。
原因:这是我在实际项目里踩过最狠的坑之一。PC 端评估走的是 x86 的推理后端,量化走的是 qnnpack/xnnpack 的算子。两边虽然都是 int8 计算,但算子实现的精细节不一样:有的后端会把激活先转回浮点再算,有的则严格在整型域算。更隐蔽的原因是端侧的输入预处理不统一:PC 评估时输入图像的 scale 是 0~1,Android 端忘了除以 255,导致整幅图的数值偏大,量化点和实际分布错位。还有一种情况是 BatchNorm 没有被完全折叠进前面的卷积里,量化算子把 BN 的 shift 和 scale 暴露出来了,端侧因为算子不支持直接忽略了它们,输出当然错。
解决:先把 PC 端评估和端侧评估对齐到完全相同的输入角度和后端设置。用同一个张量喂给 PC 的 PyTorch 和端侧的 .ptl,逐一对比输出,超过 0.01 的差异就是算子行为不一致。然后检查 BatchNorm 是否全部折叠干净——把原始 TorchScript 和优化后的 .ptl 都加载,打印每一层的参数 shape 和类型,如果量化后还有 BN 层,说明 optimize_for_mobile 没处理到位。最后检查端侧预处理代码里的除以 255,这个失误在真实项目里重复出现的频率远超想象。
5.3 推理速度达不到目标,发现 CPU 只跑了一个核
现象:端侧 benchmark 显示 ResNet18 推理需要 80ms,但同款芯片在其他平台上只要 30ms。
原因:PyTorchMobile 在默认情况下会用满所有可用的 CPU 核,但前提是编译时开启了 OpenMP 或者 pthread 的调度。如果你集成的是精简版 AAR,它可能默认单线程。另一个常见因素是 Android 的 CPU affinity:线程跑在小核上,大核被系统调度策略限制,对功耗敏感的设备尤其明显。你的 benchmark 代码如果在 UI 线程或者其他 low priority 线程里跑,CPU 调度器不会给它分配大核。
解决:跑推理前显式设置线程数,并让推理运行在独立的高优先级线程上。线程数设置在 Java 端可以用 PyTorch 的接口,但需要注意,这个接口只对当前线程生效,所以要在每次推理前调用,确保线程配置和目标执行线程一致。更重要的度量方式是抓 Profile:跑推理的同时用 adb shell top 或者 perf 观察进程的 CPU 占用情况。如果占用只有 100%(单核),说明线程数配置没有生效;如果 CPU 占用拉满但延迟仍高,再去优化算子和模型结构。
5.4 动态分支导致 trace 产物不可用
现象:模型在训练脚本里用随机输入多次推理验证结果是对的,转成 TorchScript 之后,传入不同尺寸的输入就报维度错误。
原因:trace 的根本机制决定了它只能记住「这次输入」的执行路径。如果你在 forward 里有if x.shape[-1] > 100这类分支,trace 时输入恰好是 128,那大于 100 的分支被记录,小于 100 的分支根本不在图里。推理时输入变成 64,程序直接走到不存在的那条路径上。
解决:分两条路走。结构简单的模型,用 torch.jit.script 重写 forward,把所有 Python 分支改写成 TorchScript 支持的张量运算形式;结构复杂、改造代价高的模型,在 trace 之前固定输入尺寸——把端侧预处理统一到一个固定尺寸。我们要接受一个现实:移动端推理的输入往往是固定 shape,动态 shape 带来的灵活性很多时候只是训练脚本里的一种习惯,生产环境并非常需。
5.5 选择合适的部署文件格式,避免黑匣子
现象:加载 .pt 格式的 TorchScript 文件成功了,但第一次 forward 特别慢,后续推理才恢复正常速度。
原因:.pt 是全功能 TorchScript 格式,包含完整的 Python 字节码和调试信息,Lite Interpreter 加载时需要解析大量额外字段,还会在首轮推理时做算子的懒加载和内存分配,导致首次调用开销极高。用 .ptl 格式配合 optimize_for_mobile 能去掉这些冗余内容,首轮推理耗时显著下降。
解决:统一用 LiteModuleLoader 加载 .ptl 文件,并在页面初始化时预先加载和预热模型。预热的方式是拿一张 dummy 图先跑一次 forward,让算子调度和内存池都稳定下来,之后再处理真实请求,可以看到首帧延迟下降不少。这个技巧在低端机和首次启动场景里尤其明显。
6. 端到端验证方法:用 Profile 定位瓶颈,而不是靠猜
确认模型部署后,先不要急着去调算法,先跑一个端到端基准。我更推荐直接在 Android 工程里嵌入一个 profile 工具类,用 System.nanoTime() 测量每个片段耗时,把数据打印到 logcat 里:
long startLoad = System.nanoTime(); Module module = LiteModuleLoader.load(modelPath); long loadMs = (System.nanoTime() - startLoad) / 1_000_000; Bitmap resizedBitmap = createScaledBitmap(photo, 224, 224); long preprocessMs = measure(() -> createInputTensor(resizedBitmap)); // 预热一次,避免后续推理触发懒加载或内存池扩容 module.forward(IValue.from(inputTensor)); long startInfer = System.nanoTime(); Tensor output = module.forward(IValue.from(inputTensor)).toTensor(); long inferMs = (System.nanoTime() - startInfer) / 1_000_000;拿到数据先分类讨论。如果 loadMs 占了总耗时的 30% 以上,说明 .ptl 文件还是偏大、磁盘 IO 慢或文件放在 asset 深层;如果 preprocessMs 占比高,说明 Bitmap 缩放和通道转换没有利用缓存,可以考虑复用 Bitmap 对象;如果 inferMs 占绝对大头,就把模型按层拆开 profile,或者回头检查线程配置。
下一步值得做的是和移动端 CPU 天梯做对比。不同档位的 CPU 对这两种类型的耗时影响不同:低端机瓶颈在算子计算本身,量化收益最大;中高端机瓶颈反而常出现在内存带宽和数据搬运,此时算子融合的收益比单纯减参数量更明显。你可以用线上特定机型的采集数据来回推,决定下一步是继续加深量化、做算子替换,还是直接换更小的模型结构。
说到更小的模型结构,如果压完精度不达标,我现在的习惯是把 EfficientNet-Lite 或 MobileNetV3 替换成 MobileFormer 这类新出的移动端分类结构,而不是扭头去找更多压缩技巧。压缩省出来的空间是有限的,模型结构本身的计算量天花板决定最终体验。希望帮到你。
本文还有配套的精品资源,点击获取