假设手里有一个自研小模型 PrismML,参数量在亚十亿量级,输入是文本 token 或一小段音频特征,目标是让它在一副骁龙平台的智能眼镜上离线常驻推理。这件事的难点不在模型本身,而在"从 PyTorch 到眼镜上的二进制资产"这条链路上:导出格式对不对、量化掉不掉点、云端跑出来的延迟和真机差多少、眼镜上电池能撑多久。下面按顺序走一遍。
做完能得到什么
走完这套流程,你会拿到四样东西:
1. 一个经过量化、编译成 QNN 资产、可以直接推到设备上的模型文件;
2. 云端 profile 报告:逐层耗时、峰值内存、估算推理延迟;
3. 端侧实测数据:预热后的稳态延迟分位数、连续跑十分钟后的热衰减曲线、功耗区间;
4. 一套可重复执行的脚本,模型改了以后能一键重跑。
前置条件清单
- 模型侧:PrismML 已经能从 PyTorch 或 ONNX 导出,输入输出 shape 固定,没有依赖运行时的动态控制流。
- 账号侧:Qualcomm AI Hub 账号与 API AI 词典:Token">Token,具体申请入口以官网当前页面为准。
- 本地环境:Linux 机器、Python 环境、能装
qai-hub包;另外准备一份adb可用的设备连接。 - 设备侧:一副(或一台同代平台的参考设备)出现在 AI Hub 设备列表里的骁龙设备。眼镜形态的算力、内存、散热都弱于手机参考机,两边的数据都要测,别只测一台。
- 校准数据:几百到一两千条有代表性的真实输入。数量不是关键,覆盖度才是。
- 端侧运行时:设备上要有对应平台的 QNN / QAIRT 运行时,以及
qnn-net-run、qnn-profile-viewer这类命令行工具,具体获取方式以官方 SDK 文档为准。
第 1 步:把模型导出成可编译的形式
先做一次"干净导出"。固定 batch 为 1,序列长度做成若干固定档位(比如短、中、长三档各编译一份),比留一个动态维度更省事,端侧表现也更稳定。
```python
import torch
import torch.nn as nn
class Wrapper(nn.Module):
"""把预处理之外的纯计算部分包起来,方便导出"""
def __init__(self, backbone):
super().__init__()
self.backbone = backbone
def forward(self, input_ids, attention_mask):
return self.backbone(input_ids, attention_mask)
model = Wrapper(prismml_backbone).eval()
dummy_ids = torch.randint(0, 32000, (1, 64), dtype=torch.long)
dummy_mask = torch.ones(1, 64, dtype=torch.long)
torch.onnx.export(
model,
(dummy_ids, dummy_mask),
"prismml_seq64.onnx",
input_names=["input_ids", "attention_mask"],
output_names=["logits"],
opset_version=17, # 以转换工具当前支持的版本为准
do_constant_folding=True,
)
```
导出后先验证数值一致性:同一份输入,PyTorch 和 ONNX Runtime 的输出做余弦相似度或最大绝对误差对比。这一步不做,后面精度掉了根本分不清是量化的问题还是导出的问题。
导出完顺手做一次算子体检,看看有没有会掉到 CPU 上的算子(常见的是自定义 attention、动态 shape 的 reshape、非常规激活函数)。掉 CPU 的算子会带来跨后端拷贝,延迟抖动会很明显。
第 2 步:配置 AI Hub 并确认设备名
```bash
pip install qai-hub
export QAI_HUB_API_TOKEN="你的 token"
qai-hub configure --api_token "$QAI_HUB_API_TOKEN"
```
列出可选设备,记下你要用的那台的准确名称,后面所有 job 都用这个字符串:
```python
import qai_hub as hub
for d in hub.get_devices():
print(d.name, getattr(d, "os", ""), getattr(d, "attributes", ""))
```
命令行侧也有列设备的子命令,具体名称以 AI Hub 文档为准。
第 3 步:先编译浮点版本,建立基线
不要一上来就量化。先编译一个浮点版本,拿到"未量化"的延迟和内存基线,后面才知道量化省了多少、掉了多少。
```python
import qai_hub as hub
device = hub.Device("设备列表里的准确名称")
compile_job = hub.submit_compile_job(
model="prismml_seq64.onnx",
device=device,
input_specs=dict(
input_ids=((1, 64), "int32"),
attention_mask=((1, 64), "int32"),
),
options="--target_runtime qnn_context_binary",
name="prismml-fp-seq64",
)
print(compile_job.url)
compile_job.wait()
```
options 里的目标运行时字符串、以及是否要额外生成 context binary 的分离形式,都以 AI Hub 当前文档为准——这部分接口改动相对频繁,照抄旧博客容易踩坑。
第 4 步:训练后量化
端侧小模型默认走 PTQ(训练后量化)。常见组合:
- w8a8:权重和激活都 8 位,压缩比和加速都明显,对激活分布敏感;
- w8a16:权重量化、激活保持 16 位,对精度更友好,适合注意力类结构;
- w4a16:权重 4 位,内存占用进一步下降,适合眼镜这种内存紧张的场景,但精度风险更高。
先把权重做逐通道量化(per-channel),激活做逐张量(per-tensor),跑一遍看精度。如果掉点集中在某几层,可以做混合精度:把敏感层(通常是第一层、最后一层、以及归一化前后)留在高精度,其余压到低位宽。
```python
校准数据:用真实场景里的输入,不要用随机张量
calib_data = [
dict(input_ids=ids, attention_mask=mask)
for ids, mask in real_batches # 几百到一两千条
]
quantize_job = hub.submit_quantize_job(
model="prismml_seq64.onnx",
calibration_data=calib_data,
weights_dtype="int8",
activations_dtype="int8",
name="prismml-ptq-w8a8",
)
quantize_job.wait()
print(quantize_job.url)
```
参数名和可选取值请对着 AI Hub 文档核对,不同时期命名会有出入。如果平台自带量化不满足需求,可以换成 AIMET 在本地做,再把量化后的模型传上去编译。
量化完必须做一次端到端精度回归:拿 50 到 200 条带标注的样本,对比浮点版和量化版的输出。分类任务看准确率,生成任务看人工抽检。只要掉点超过你设定的容忍线,就回退到混合精度。
第 5 步:用云端 profile 摸底
```python
profile_job = hub.submit_profile_job(
model=quantized_compiled_model,
device=device,
name="prismml-profile-w8a8",
)
profile_job.wait()
print(profile_job.url)
```
报告里重点看三个数:逐层耗时占比、峰值内存、估算推理延迟。有两个地方要留心:
第一,云端 profile 的设备往往是散热条件较好的参考机,眼镜上的稳态表现会更差。第二,报告给的是稳态估算,首帧还要加上模型加载和内存分配的时间。眼镜上如果每次唤醒都要重新加载,首帧延迟可能比稳态高一个量级,这时候要考虑常驻内存还是分包加载。
第 6 步:把编译产物推到设备上跑
```python
download_path = "artifacts/"
job = quantized_compile_job
job.download_artifacts(download_path)
```
推到设备,准备一份输入清单文件(每行一个输入文件路径):
```bash
adb push artifacts/ /data/local/tmp/prismml/
adb push inputs.txt /data/local/tmp/prismml/
adb shell "cd /data/local/tmp/prismml && \
./qnn-net-run \
--backend libQnnHtp.so \
--retrieve_context prismml_quantized.bin \
--input_list inputs.txt \
--profiling_level detailed \
--output_dir ./out"
```
具体的可执行文件名、后端库名、以及 context 的传入方式是 --retrieve_context 还是别的参数,以你所装的 QAIRT SDK 版本自带文档为准。跑完后把 profiling 日志拉回来:
```bash
adb pull /data/local/tmp/prismml/out ./device_out
qnn-profile-viewer --input_log ./device_out/qnn-profiling-data.log
```
第 7 步:测延迟、内存和功耗
延迟:先预热 10 到 20 次丢掉,再连续跑 200 次以上,记录 P50 和 P95。只看平均值会掩盖抖动。然后把不同序列长度档位各跑一遍,画一张"长度—延迟"曲线。
内存:adb shell dumpsys meminfo <包名> 看进程 RSS,同时记录 QNN 报告的峰值内存。眼镜上内存是硬约束,宁可留足余量。
热衰减:连续跑 5 到 10 分钟,每 30 秒采样一次延迟,看曲线什么时候开始抬升、抬升多少。眼镜的散热面积小,这一步的结果往往比峰值性能更有参考价值。
功耗:能用功率轨(配合 Snapdragon Profiler 之类的工具)就用功率轨,精度高、时间分辨率好。拿不到功率轨时,用电池电流做粗测:
```bash
固定条件:屏幕亮度固定、关闭 Wi-Fi 与不必要的传感器
adb shell dumpsys batterystats --reset
跑一轮固定次数(例如 1000 次)的推理
adb shell "cd /data/local/tmp/prismml && ./run_batch.sh 1000"
adb shell dumpsys batterystats --charged > batt.txt
```
也可以高频采样瞬时电流:
```bash
for i in $(seq 1 600); do
adb shell cat /sys/class/power_supply/battery/current_now
sleep 0.1
done > current_samples.txt
```
电流乘以电池电压得到功率,再按推理次数折算成每次推理的能量消耗(毫焦耳/次)。这个指标比纯功率更实用,因为它把"跑得多快"和"耗多少电"合在了一起。
把结果整理成一张表:
| 配置 | P50 延迟 | P95 延迟 | 峰值内存 | 单次能量 | 相对浮点精度 |
|---|---|---|---|---|---|
| 浮点基线 | 100% | ||||
| w8a8 | |||||
| w8a16 | |||||
| w4a16 |
第 8 步:针对眼镜形态做取舍
眼镜和手机的约束不一样:电池小、散热差、佩戴时不能被感知到发热,而且很多场景是"按需唤醒"而不是持续推理。几个实用做法:
- 分档调度:常驻的唤醒检测用极小模型或传统信号处理,识别到意图后再加载大一点的 PrismML。
- 限制占空比:与其追求单次延迟压到多低,不如把每秒调用次数降下来,整体功耗收益更明显。
- 温度降档:读设备温度节点,超过阈值自动切到短序列档位或降频运行。
- 端云协同:本地只做置信度判断和简单意图,复杂的走云端,本地保留兜底能力。
常见坑与排错
编译失败,报不支持的算子。 先看是不是动态 shape 或控制流。把动态维度改成固定档位,把 Python 层的 if/for 改写成张量操作,通常能解决。
量化后精度掉得厉害。 九成出在校准数据上。用随机张量或只用单一场景的数据做校准,量化参数会偏。换成覆盖真实场景、长度分布接近线上的样本再试。
云端延迟很漂亮,眼镜上完全不是这个数。 检查三件事:热衰减、内存带宽竞争、以及是否每次推理都在重新加载模型。前两个靠限制占空比和降低位宽缓解,第三个靠常驻内存解决。
延迟抖动很大。 大概率是某些层跑在 CPU 上。用逐层 profile 定位,再看能不能换成等效的受支持算子。
功耗测出来忽高忽低。 先固定条件:屏幕、连接、传感器、环境温度。如果还是不稳,把测量时长拉长到几分钟取平均,别用几十秒的样本下结论。
首帧特别慢。 把模型加载、内存分配、首次算子初始化分开计时。眼镜端可以做成开机预热一次,后续走常驻路径。
前后处理成了瓶颈。 分词、重采样、归一化这些如果放在 CPU 上逐帧做,很容易吃掉可观的时间。能塞进模型图里的就塞进去,塞不进去的就用固定 buffer 复用,避免频繁分配。
下一步建议
1. 建立回归门禁:把精度、P95 延迟、单次能量三个指标做成脚本,模型或量化配置一改就自动重跑,超过阈值直接拦下来。
2. 试更低位宽:在 w8a16 稳定之后,针对权重探索 4 位甚至混合位宽,重点观察内存占用和精度两条曲线的交叉点。
3. 接入应用层:把 QNN 资产通过对应平台的推理框架集成进眼镜端应用,处理好多实例、生命周期和后台调度。
4. 做真实场景实测:实验室里的循环跑分和"用户戴一整天、间歇唤醒几百次"是两回事,后者才是决定能不能上线的数据。
5. 设计降级路径:为高温、低电量、内存紧张各准备一套降级配置,让设备在边界条件下仍然可用,而不是直接崩掉或卡死。
