1. 为什么要在Android上折腾QNN SDK
第一次把ONNX模型往手机上塞的时候,我天真地以为跟PC端一样,装个运行时、喂个模型就完事了。结果实测下来,一个不到10MB的检测模型,在骁龙8 Gen 2上跑出了单帧180ms的成绩,功耗还高得离谱,手机背面烫得能煎蛋。后来才搞明白,CPU上跑ONNX Runtime根本没调用到NPU,算力全浪费在通用核心上了。
高通QNN SDK就是来解决这个问题的。它全称Qualcomm Neural Processing SDK,是高通给自家Hexagon DSP、Adreno GPU以及Hexagon Tensor Processor(HTP,也就是我们常说的NPU)提供的一套推理框架。你可以把它理解成高通芯片的"官方驱动层"——只有通过它,模型才能真正落到NPU上执行,而不是在CPU上模拟。
这套流程解决的核心问题有三个:第一,把训练框架产出的ONNX模型转换成QNN能识别的格式;第二,让模型在NPU上高效执行;第三,在转换和量化过程中把精度损失控制在可接受范围内。适合谁看?如果你正在做Android端的AI应用,模型已经能在PC上跑通,但移植到手机后性能拉胯,或者你手上有NPU设备想榨干它的算力,那这篇内容就是给你准备的。
我前后在三个项目里踩过QNN的坑,从SDK版本选择到量化参数配置,每一步都有讲究。下面把完整流程拆开讲,包括那些官方文档里不会写的细节。
2. 环境搭建与工具链选型
2.1 QNN SDK版本与Android NDK的匹配
QNN SDK的版本选择是个容易被忽视的坑。高通每季度会发布新版本,但并不是越新越好。我实测下来,SDK版本和手机芯片型号、Android NDK版本之间存在微妙的兼容性关系。
以目前主流的QNN SDK 2.24和2.28为例,2.24对骁龙888到8 Gen 1的支持更稳定,2.28则针对8 Gen 2和8 Gen 3的HTP v73架构做了优化。如果你手头的测试机是8 Gen 2,建议直接用2.28以上版本,否则可能遇到HTP后端初始化失败的问题。
Android NDK这边,QNN SDK的示例代码默认用NDK r25c编译。我试过r26和r27,编译能过,但链接阶段偶尔报undefined reference to __android_log_print,原因是NDK版本升级后日志库的链接方式变了。稳妥起见,跟着官方推荐的NDK版本走,别自己乱升级。
环境变量配置这块,QNN SDK解压后目录结构是这样的:
QNN_SDK_ROOT/ ├── bin/ ├── include/ ├── lib/ │ ├── aarch64-android/ │ ├── arm64-v8a/ │ └── x86_64-linux-clang/ ├── examples/ └── tools/需要设置的环境变量:
export QNN_SDK_ROOT=/path/to/qnn-sdk export ANDROID_NDK_ROOT=/path/to/android-ndk-r25c export PATH=$QNN_SDK_ROOT/bin:$PATH export LD_LIBRARY_PATH=$QNN_SDK_ROOT/lib/x86_64-linux-clang:$LD_LIBRARY_PATH注意:
LD_LIBRARY_PATH必须包含x86_64-linux-clang目录,因为模型转换工具qnn-onnx-converter是在PC上跑的,依赖这个目录下的动态库。很多人只配了aarch64的路径,结果转换工具一运行就报找不到libQnnHtp.so。
2.2 ONNX模型的前置检查清单
在动手转换之前,先对ONNX模型做一次"体检"。我吃过亏——一个看似正常的模型,转换时报了十几个不支持的算子,排查了一下午才发现是模型里混入了自定义算子。
检查清单如下:
- 算子兼容性:用Netron打开ONNX模型,逐个确认算子是否在QNN支持列表里。QNN对ONNX算子的支持是子集,像
NonMaxSuppression、TopK这些动态shape算子支持得比较晚,2.24之前基本不可用。 - 输入输出shape:QNN对动态shape的支持有限,最好把模型固定成静态shape。如果模型输入是
[1, 3, -1, -1],先用ONNX的shape_inference工具固定成具体尺寸。 - 数据类型:确认模型权重是FP32还是FP16。QNN的HTP后端对FP16支持最好,FP32会走软件模拟,性能差一大截。
- 模型大小:超过2GB的模型在转换时可能内存溢出,需要先做剪枝或分片。
我常用的检查命令:
import onnx model = onnx.load("model.onnx") onnx.checker.check_model(model) for node in model.graph.node: print(node.op_type, node.name)如果发现不支持的算子,有两个选择:一是用ONNX的onnxsim做图优化,把一些复合算子拆解成基础算子;二是修改训练代码重新导出。前者更快,后者更彻底。
2.3 目标设备的NPU能力确认
不是所有骁龙芯片都有NPU。骁龙7系列以下、部分6系列芯片只有DSP没有HTP。确认方法很简单,在手机上跑一下QNN的qnn-device-info工具,或者查高通的芯片规格文档。
我整理了一份常见芯片的NPU支持情况:
| 芯片型号 | HTP版本 | INT8算力(TOPS) | FP16支持 |
|---|---|---|---|
| 骁龙888 | v68 | 26 | 是 |
| 骁龙8 Gen 1 | v69 | 27 | 是 |
| 骁龙8 Gen 2 | v73 | 45 | 是 |
| 骁龙8 Gen 3 | v75 | 73 | 是 |
| 骁龙7 Gen 3 | v73 | 15 | 是 |
| 骁龙695 | 无HTP | - | - |
提示:骁龙695及以下芯片没有HTP,只能走CPU或GPU后端。如果你的目标设备是这类芯片,QNN的加速效果有限,不如直接用NCNN或MNN。
3. ONNX到QNN的模型转换实操
3.1 转换工具链的核心参数解析
QNN SDK提供的转换工具叫qnn-onnx-converter,本质是个Python脚本,封装了ONNX到QNN IR(中间表示)的转换逻辑。核心参数不多,但每个都影响最终结果。
最基础的转换命令:
qnn-onnx-converter \ --input_network model.onnx \ --output_path model.cpp \ --input_dim input "1,3,640,640" \ --out_node output \ --float_bw 16逐个拆解:
--input_dim:指定输入张量的名字和维度。名字必须和ONNX模型里的输入名完全一致,大小写敏感。维度用逗号分隔,不支持-1这种动态维度。--out_node:指定输出节点名。如果模型有多个输出,可以多次指定。不指定的话,QNN会自动推断,但有时候推断结果不对。--float_bw:浮点位宽,可选16或32。选16会走FP16路径,模型体积减半,速度提升明显。选32则保持FP32精度,但HTP上会降速。
还有一个关键参数--preserve_io,作用是保持输入输出的数据类型和布局不变。默认情况下QNN会把输入从NHWC转成NCHW,如果你的预处理代码是按NHWC写的,转换后就会出错。加上这个参数可以避免布局转换。
3.2 量化校准:INT8精度的关键一步
FP16模型跑起来已经比CPU快很多了,但要想榨干NPU算力,还得上INT8量化。INT8量化的核心是校准——用一批代表性数据统计激活值的分布,确定量化参数(scale和zero_point)。
QNN的量化流程分两步:先用qnn-onnx-converter生成FP32模型和校准用的输入列表,再用qnn-quantizer做量化。
校准数据准备是个技术活。我一般从训练集里随机抽200-500张图,覆盖所有类别和场景。数据太少会导致量化参数偏差大,太多则浪费时间。校准数据要预处理成和推理时完全一致的格式,包括归一化、resize、通道顺序。
校准输入列表文件格式:
/path/to/calib/001.jpg /path/to/calib/002.jpg ...量化命令:
qnn-quantizer \ --input_network model_fp32.cpp \ --input_list calib_list.txt \ --output_path model_int8.cpp \ --act_bw 8 \ --weight_bw 8 \ --bias_bw 8--act_bw是激活值位宽,--weight_bw是权重位宽,--bias_bw是偏置位宽。通常都设成8,但有些模型对激活值敏感,可以设成16,权重保持8。
注意:量化后的模型精度损失通常在1%-3%之间。如果超过5%,说明校准数据不具代表性,或者模型本身对量化不友好。这时候可以考虑混合量化——对敏感层保持FP16,其余层INT8。
3.3 模型编译与设备部署
转换出来的.cpp文件是QNN的模型描述文件,还需要编译成.so或.bin才能在设备上加载。编译工具是qnn-model-lib-generator:
qnn-model-lib-generator \ -c model_int8.cpp \ -b model_int8.bin \ -o libmodel_int8.so \ -t arm64-v8a \ --qnn_sdk_root $QNN_SDK_ROOT-t指定目标架构,Android设备用arm64-v8a。编译产物包括.so和.bin两个文件,.so是加载器,.bin是模型权重。
部署到设备时,把这两个文件和QNN的运行时库一起推到手机:
adb push libmodel_int8.so /data/local/tmp/ adb push model_int8.bin /data/local/tmp/ adb push $QNN_SDK_ROOT/lib/aarch64-android/libQnnHtp.so /data/local/tmp/ adb push $QNN_SDK_ROOT/lib/aarch64-android/libQnnHtpV73Stub.so /data/local/tmp/ adb push $QNN_SDK_ROOT/lib/aarch64-android/libQnnSystem.so /data/local/tmp/提示:
libQnnHtpV73Stub.so里的V73对应HTP版本,不同芯片要换对应的Stub库。8 Gen 2用V73,8 Gen 1用V69,888用V68。推错了会报HTP device creation failed。
4. Android端集成与推理代码实现
4.1 JNI层封装与QNN接口调用
QNN的C++ API比较底层,直接暴露给Java层不现实。标准做法是写一层JNI封装,把模型加载、推理、释放封装成几个简单方法。
核心接口调用顺序:
QnnInterface_getProviders:获取QNN接口函数表QnnBackend_create:创建后端实例QnnDevice_create:创建设备实例,指定HTP后端QnnContext_create:创建上下文,加载模型QnnGraph_execute:执行推理- 释放资源
JNI方法签名:
extern "C" JNIEXPORT jlong JNICALL Java_com_example_qnndemo_QnnEngine_init(JNIEnv *env, jobject thiz, jstring model_path, jstring backend_path) { const char *model = env->GetStringUTFChars(model_path, nullptr); const char *backend = env->GetStringUTFChars(backend_path, nullptr); QnnEngine *engine = new QnnEngine(); bool ret = engine->init(model, backend); env->ReleaseStringUTFChars(model_path, model); env->ReleaseStringUTFChars(backend_path, backend); return ret ? reinterpret_cast<jlong>(engine) : 0; }推理方法:
extern "C" JNIEXPORT jfloatArray JNICALL Java_com_example_qnndemo_QnnEngine_infer(JNIEnv *env, jobject thiz, jlong handle, jfloatArray input) { QnnEngine *engine = reinterpret_cast<QnnEngine *>(handle); jfloat *input_data = env->GetFloatArrayElements(input, nullptr); jsize input_len = env->GetArrayLength(input); std::vector<float> output; engine->infer(input_data, input_len, output); jfloatArray result = env->NewFloatArray(output.size()); env->SetFloatArrayRegion(result, 0, output.size(), output.data()); env->ReleaseFloatArrayElements(input, input_data, 0); return result; }注意:QNN的上下文创建比较耗时,实测在8 Gen 2上加载一个10MB的INT8模型需要200-300ms。建议在App启动时初始化一次,后续复用,不要每次推理都重新加载。
4.2 输入预处理与输出后处理的性能陷阱
预处理和后处理是最容易被忽视的性能瓶颈。我见过一个项目,模型推理只花了8ms,但预处理花了40ms,整体帧率被拖垮。
预处理的核心操作:resize、归一化、通道转换。在Android上,用OpenCV的cv::resize比Java层的Bitmap.createScaledBitmap快3-5倍。归一化用NEON指令加速,比逐像素循环快10倍以上。
通道转换(NHWC到NCHW)也有讲究。QNN默认期望NCHW输入,但Android相机输出的是NHWC。转换时不要用嵌套循环,用memcpy按通道拷贝:
// 假设输入是HWC布局,输出是CHW for (int c = 0; c < 3; c++) { for (int h = 0; h < height; h++) { memcpy(dst + c * height * width + h * width, src + h * width * 3 + c, width * sizeof(float)); } }后处理主要是NMS和坐标解码。NMS在CPU上跑,用C++实现比Java快很多。如果模型输出已经包含了NMS,那后处理就只剩坐标解码,开销很小。
4.3 多线程与异步推理的实践
QNN的QnnGraph_execute是同步阻塞的,但可以在多个线程里并发调用不同的图。实测在8 Gen 2上,同时跑两个模型(一个检测一个分类),总耗时比串行少30%左右。
异步推理的实现方式:用一个线程池,把推理任务提交进去,主线程继续处理其他逻辑。注意QNN的上下文不是线程安全的,每个线程需要独立的上下文实例,或者用锁保护。
std::future<std::vector<float>> async_infer(QnnEngine *engine, std::vector<float> input) { return std::async(std::launch::async, [engine, input]() { std::vector<float> output; engine->infer(input.data(), input.size(), output); return output; }); }提示:多线程推理会增加功耗和发热,如果App对续航敏感,建议限制并发数,或者根据温度动态调整。
5. 精度调优与性能分析实战
5.1 精度损失的定位方法
量化后精度下降是常态,关键是要定位到具体是哪一层导致的。QNN提供了逐层精度分析工具qnn-profile-viewer,可以输出每一层的输出和FP32参考值的差异。
分析流程:
- 用FP32模型跑一遍推理,保存每层输出
- 用INT8模型跑一遍推理,保存每层输出
- 用
qnn-profile-viewer对比两层输出,计算余弦相似度和最大绝对误差
qnn-profile-viewer \ --input_log fp32_log.json \ --input_log int8_log.json \ --output_path diff_report.html生成的报告里,余弦相似度低于0.99的层就是"问题层"。常见的问题层集中在:第一个卷积层(输入量化误差大)、最后的全连接层(输出范围大)、以及有残差连接的层(误差累积)。
针对问题层的处理策略:
- 第一个卷积层:保持FP16,不量化
- 全连接层:用per-channel量化,而不是per-tensor
- 残差连接:在加法前插入量化-反量化节点,隔离误差
5.2 混合量化的配置技巧
混合量化是精度和性能的折中方案。QNN支持通过JSON配置文件指定哪些层用FP16、哪些用INT8。
配置文件格式:
{ "quantization_config": { "default": { "activation_bitwidth": 8, "weight_bitwidth": 8 }, "overrides": [ { "layer_name": "conv1", "activation_bitwidth": 16, "weight_bitwidth": 16 }, { "layer_name": "fc_out", "activation_bitwidth": 16, "weight_bitwidth": 8 } ] } }实测下来,把第一个卷积层和最后一个全连接层保持FP16,中间层INT8,精度损失能从3%降到0.8%,而推理速度只比全INT8慢15%左右。
5.3 性能瓶颈的排查思路
推理慢不一定是NPU的问题。我总结了一套排查流程:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 首次推理特别慢 | 上下文初始化 | 测量init耗时,确认是否复用 |
| 每次推理都慢 | 输入输出拷贝 | 用qnn-profile-viewer看各阶段耗时 |
| 推理快但帧率低 | 预处理/后处理瓶颈 | 单独计时预处理和后处理 |
| 功耗高发热大 | 多线程并发 | 降低并发数,观察温度 |
| 精度异常 | 量化参数错误 | 对比FP32和INT8输出 |
我遇到过一个典型案例:模型推理只要5ms,但整体帧率只有15fps。排查发现是每次推理都重新创建了QNN上下文,init耗时60ms。改成全局复用后,帧率直接拉到60fps。
注意:QNN的上下文创建涉及HTP固件加载,第一次调用会触发固件下载,耗时可能超过500ms。务必在App启动阶段完成初始化,不要放在推理循环里。
6. 常见问题与避坑指南
6.1 转换阶段的典型报错
报错1:Unsupported operator: NonMaxSuppression
原因:QNN 2.24之前不支持NMS算子。解决方案:把NMS从模型里剥离,放到后处理用CPU实现。或者升级到2.28以上版本。
报错2:Input dimension mismatch
原因:--input_dim指定的维度跟ONNX模型不一致。解决方案:用Netron确认输入维度,注意NCHW和NHWC的区别。
报错3:Quantization calibration failed
原因:校准数据格式不对,或者数据量太少。解决方案:检查校准图片是否跟推理时预处理一致,增加校准数据到500张以上。
6.2 部署阶段的典型报错
报错1:HTP device creation failed
原因:Stub库版本跟芯片不匹配。解决方案:确认芯片的HTP版本,推对应的Stub库。8 Gen 2是V73,8 Gen 1是V69。
报错2:Model load failed: invalid model
原因:.so和.bin文件不匹配,或者编译架构不对。解决方案:重新编译,确认-t arm64-v8a。
报错3:Inference timeout
原因:模型太大,或者HTP频率被限制。解决方案:检查模型大小,确认没有开省电模式。
6.3 精度调优的独家经验
量化校准数据的选取有个技巧:不要只用正样本,要混入10%-20%的负样本和困难样本。我试过只用正样本校准,结果模型对背景的误检率飙升。混入负样本后,误检率恢复正常。
另一个经验是:量化前先做一轮BN折叠。ONNX模型里的BatchNormalization层在量化时会引入额外误差,用onnxsim做BN折叠后,精度损失能减少0.5%左右。
还有个小技巧:如果模型有多个输出分支,对每个分支单独做量化校准,而不是共用一套校准参数。这样每个分支的量化范围更精确,整体精度更好。
7. 从项目实践看QNN的适用边界
QNN SDK不是万能的。我在三个项目里用下来,总结出它的适用边界:
适合的场景:模型结构规整(以卷积和全连接为主)、输入shape固定、对延迟敏感(如实时检测、人脸识别)、目标设备是骁龙8系或7系中高端芯片。
不适合的场景:模型包含大量动态shape算子(如Transformer类模型)、目标设备是低端芯片(无HTP)、需要频繁切换模型(上下文创建开销大)。
Transformer类模型在QNN上的支持还在完善中。我试过把一个小型ViT模型转QNN,注意力层的MatMul和Softmax在HTP上效率不高,整体速度还不如GPU后端。如果非要在Android上跑Transformer,建议关注QNN的后续版本更新,或者考虑其他推理框架。
最后分享一个实测数据:在骁龙8 Gen 2上,一个YOLOv8n模型(INT8量化后约3MB),QNN HTP后端单帧推理耗时6-8ms,CPU后端约45ms,GPU后端约15ms。NPU的加速比在5-7倍之间,功耗只有CPU的1/3左右。这个数据供你评估QNN是否值得投入。