尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

QNN SDK实战:骁龙平台ONNX模型转换、INT8量化与Android部署全攻略

QNN SDK实战:骁龙平台ONNX模型转换、INT8量化与Android部署全攻略 在骁龙平台上跑ONNX模型QNN SDK绝对是个绕不开的狠角色。我搞Android端AI部署这几年从最早用CPU硬扛到后来折腾GPU再到转向QNN调用高通HTPHexagon Tensor Processor异构加速可以说把这条链路上的坑基本都踩平了。这篇东西就把我从ONNX模型转到QNN再到Android App里精度调优的完整过程拆开了讲包括那些官方文档里压根不会写明的坑。如果你正打算在骁龙设备上部署本地模型或者已经因为精度掉点、转换报错而头大这篇应该能帮你省下好几个通宵。先说明一点我实操的环境是Ubuntu 20.04主机 QNN SDK 2.20现在叫AimET的也整合进来了 Android Studio 2023.1.1Giraffe开发板是SM8450骁龙8 Gen1。这套流程在SM8550、SM7250这些平台上同样能用就是HTP架构版本不一样量化策略上会有细微差别后面我会提。1. 内容整体设计与思路拆解1.1 QNN SDK到底解决什么问题在Android上做AI推理第一反应肯定是TFLite、ONNX Runtime。但如果你跑的是高德纳公司魔改出来的大模型或者对时延敏感到了极致CPU和GPU的算力瓶颈很快就能卡死你。TFLite的GPU delegate在Adreno上虽然也能调用OpenCL但算子支持和内存带宽利用率和专门为Hexagon优化的QNN方案比差距相当明显。QNN SDK本质上就是高通官方给出来的、能够直接调度Hexagon DSP/HTP的软件栈它处理的不只是加速而是把模型切成适合硬件执行的形态让卷积和矩阵运算在HTP的低功耗高吞吐架构上跑出接近硬件的极限。很多人一提到QNN就自动联想到高通专属心里总有点抗拒。其实换个角度来看只要你的目标设备是骁龙平台QNN就是性能和能效比的最优解而且它的模型来源非常开放——既能从TensorFlow、TFLite转也能直接吃ONNX。我实际用下来ONNX转到QNN的路径最顺因为ONNX本身是个中间表示算子结构相对规整给QNN的优化器发挥空间也大。1.2 为什么选择ONNX作为中间表示你可能要问了为什么不直接用TFLite转换原因很简单ONNX的算子生态覆盖面远超TFLite尤其是我要跑的一些检测和OCR模型TFLite转换时经常会碰到不支持的算子要么手动拆算子要么写自定义delegate非常难受。而QNN对ONNX的支持相对成熟qnn-onnx-converter能自动识别大多数常见的ONNX算子并映射到QNN后端。另外一个小细节Google Play上架要求64位so而QNN的libQnnHtp.so从2.x版本开始已经完整支持arm64-v8a打包之后体积增长不大部署起来没有历史包袱。再加上HTP能直接访问非连续内存、支持多线程调度这对视频流和连续帧检测这类高负载场景来说属于降维打击。1.3 整体流程设计整个链路我画在脑子里大概是这样的实际跑起来也是按这个顺序来准备ONNX模型确保模型计算图是静态shape或者提供Dynamic Axis的shape信息在x86主机上用qnn-onnx-converter做第一次转换产出QNN C代码或二进制模型做INT8 PTQ量化需要准备一个带校准数据的目录并配置data_reader脚本在Android工程里集成QNN runtime通过JNI调用HTP加载量化后的模型跑测试集对比精度定位量化敏感算子迭代优化这套流程里每一步都藏了不少坑尤其是量化那步稍不留意精度就从95%掉到60%下面细讲。2. 模型转换与工具链配置详解2.1 环境准备与依赖安装先说环境QNN SDK安装本身不复杂下载解压后就是一套工具目录关键是要把环境变量配好。我在.bashrc里这样设置export QNN_SDK_ROOT/opt/qnn/2.20 export PYTHONPATH$QNN_SDK_ROOT/lib/python:$PYTHONPATH export LD_LIBRARY_PATH$QNN_SDK_ROOT/lib/x86_64-linux-clang:$LD_LIBRARY_PATH export PATH$QNN_SDK_ROOT/bin/x86_64-linux-clang:$PATH注意这里有一个容易忽略的点QNN的工具链依赖Python 3.8~3.10如果你系统默认Python版本太高比如3.11或3.12直接跑qnn-onnx-converter会崩报错还特别隐晦通常都是TypeError或者ImportError。我实际测试下来Python 3.8.10最稳推荐用一个独立的venv来跑QNN工具链别跟系统Python混在一起。python3.8 -m venv qnn_env source qnn_env/bin/activate pip install onnx onnxruntime numpy opencv-python另一个依赖是Android NDK。QNN SDK官方文档推荐用NDK r23或r25我试过r26也能编译但会有警告。如果你是Android Studio里集成的NDK注意在build.gradle里显式指定ndkVersion否则SDK自动下载的版本可能过高导致链接stdc时出问题。android { ndkVersion 25.2.9519653 externalNativeBuild { cmake { cppFlags -stdc17 } } }2.2 ONNX模型前置处理这一步大多数人会跳过但强烈建议不要犯懒。QNN虽然能处理Dynamic Shape但动态shape在HTP上效率和稳定性都不如静态shape。如果你模型里有Resize、ROIAlign这类输入尺寸不确定的算子最好先把模型输入固定成要部署的分辨率。我常用的做法是这样用onnxsim做一次计算图简化把Constant节点摺叠掉用Python脚本把Dynamic Axis改成静态shapeimport onnx from onnxsim import simplify model onnx.load(model.onnx) # 你需要先修改 model.graph.input[0].type.tensor_type.shape.dim # 把dim_param 换成 dim_value model_sim, check simplify(model) onnx.save(model_sim, model_sim.onnx)这里有个小坑onnxsim在某些带ControlFlow的模型上会失败比如含有If节点或者带动态Conditional的模型。遇到这种情况我的替代方案是直接用onnxruntime对输入跑一次推理然后把中间层的静态shape记录成Const节点但这个方法工作量比较大一般只用在特别顽固的模型上。2.3 执行第一次转换准备好干净模型后就可以跑转换了。我最常用的命令是qnn-onnx-converter \ --input_model model_sim.onnx \ --input_dim input 1,3,640,640 \ --output_dir qnn_output \ --model_version 1.0 \ --debug这里--input_dim的格式是name 维度维度之间用逗号分隔。为什么要手动指定input_dim因为如果你的ONNX模型里明确写了batch维度为None或者某个维度是负数QNN转换器会自动做一个Dynamic Shape处理。虽然也能转成功但后续在Android运行时你需要额外设置输入shape麻烦且容易出错。转换完成后输出目录里会有一个model.cpp和一堆.bin文件。如果是2.20以上版本还会多一个model.serialized二进制这个文件其实就是预编译好的HTP固件指令序列Android端运行时可以直接加载。通常部署时用serialized格式最省事不需要在手机端再编译C代码APK体积也小很多。第一次转换你可能会遇到算子不支持的报错最常见的几个Not supported op: NonMaxSuppressionNot supported op: GridSampleUnsupported ONNX op: InstanceNormalization遇到这种问题思路不是硬刚而是把这些后处理算子从模型里拆出去放到App层用CPU或GPU做。QNN擅长的是卷积、全连接、池化等重计算算子NMS、Anchor解码这类轻逻辑留在CPU上反而更方便调参和调试。3. INT8量化与精度调优实战3.1 为什么要做INT8量化早期我做QNN部署直接跑FP16模型精度和FP32几乎无损但HTP性能优势没完全释放。后来切换到INT8模型速度提升了近一倍功耗还下降了30%。骁龙HTP的INT8计算单元明显是重兵布防算力上比FP16高了两三倍。所以如果你的业务场景允许一定精度损失INT8量化是性价比最高的选择。但INT8量化有其敏感之处。QNN默认的PTQ方案需要准备校准数据校准数据的好坏直接决定量化后模型精度。我见过太多人随便找几十张图丢进去结果模型直接崩。3.2 校准数据的准备策略校准数据的核心原则是覆盖真实部署场景。如果你要部署到工业检测相机里就别拿网上找的美女图当校准集如果模型要识别夜间车牌校准集里必须包含低光照、雨雾、反光等典型情况。具体数量上我的经验是300~500张比较合适太少统计不出来激活值的分布太多转换时间长得离谱且收益递减。QNN官方文档写200张左右就行但我实验下来对于多任务模型或者输出头比较多的模型300张是保底。还有个细节校准图片在做预处理时必须和模型训练时保持一致。比如训练时是用ImageNet的std和mean做归一化那校准图片也必须走同样的预处理。QNN的data_reader脚本返回的应该是模型真实输入张量而不是原始图片。这块错了的话量化后精度会非常拉胯。3.3 编写data_reader脚本QNN的量化使用的是通过Python脚本生成的校准数据不是直接给一个图片文件夹。SDK里给了个示例# data_reader.py import numpy as np from PIL import Image import os class DataReader: def __init__(self, data_path, input_list): self.data_path data_path self.input_list input_list self.idx 0 def get_next(self): if self.idx len(self.input_list): self.idx 0 return None img_name self.input_list[self.idx] img Image.open(os.path.join(self.data_path, img_name)).resize((640, 640)) img np.array(img).astype(np.float32) / 255.0 img img[np.newaxis, :, :, :] # NHWC img np.transpose(img, (0, 3, 1, 2)) # NCHW self.idx 1 return {input: img}注意这里返回的字典key一定要和模型输入名一致如果模型有多个输入就返回多个键值对。QNN量化器会反复调用get_next直到返回None所以文件列表读完后要重置索引否则会报StopIteration错。实际踩坑过程中我发现QNN量化工具对输入张量的layout非常敏感。ONNX多数是NCHW而HTP内部更偏NHWC这个转换在量化阶段最好显式做掉不要让运行时去转换。你在data_reader里直接返回NCHW的数组转换器会自己处理。3.4 执行量化校准数据准备好之后在转换命令里加上--quantize相关参数qnn-onnx-converter \ --input_model model_sim.onnx \ --input_dim input 1,3,640,640 \ --output_dir qnn_quantized \ --quantize \ --quantization_type tf_enhanced \ --calibration_file calibration.txt \ --data_reader_script data_reader.py \ --tensor_quantize falsecalibration.txt格式很简单每行一个图片文件名img_001.jpg img_002.jpg img_003.jpg--quantization_type有几个选项tf、tf_enhanced、symmetric。我测试下来对大多数视觉模型tf_enhanced综合效果最好它在激活值上使用非对称量化权重用对称量化能够照顾到ReLU后非负分布的数值范围symmetric在权重上更友好适合权重分布正负偏大、近似正态分布的模型。3.5 量化后的精度验证量化模型生成后在主机上先跑一下精度测试。QNN SDK带了一个tool叫qnn-model-tool不过实际用起来不如直接通过QNN Python接口加载模型、跑onnxruntime对比方便。我的做法是用onnxruntime跑FP32 ONNX模型得到输出记为fp32_out用QNN Python binding加载量化后的serialized模型跑同样的输入得到qnn_out计算两者的余弦相似度和最大绝对误差import qnn.python.QnnPython as qnn import numpy as np # 加载模型和输入 qnn_manager qnn.QnnContext() qnn_manager.load_network(model_quantized.serialized) input_data load_and_preprocess(test.jpg) outputs qnn_manager.execute({input: input_data})如果余弦相似度低于0.99就要警惕。如果是低于0.95基本可以断定某个敏感算子被量化压坏了得去逐层排查。3.6 精度调优的实用套路当量化后精度不达标我一般按下面的优先级去排查增加校准数据多样性最廉价有效的手段有时候从200张加到500张精度直接就从88%跳到94%。检查输入预处理是否一致个坑非常隐蔽。我遇到过一次训练时归一化是用img (img - 127.5) / 127.5校准脚本里却用了img / 255.0结果一切努力都白费因为分布差异直接导致量化参数偏移。逐层定位敏感算子QNN提供了量化后的debug dump可以输出每层的激活值min/max范围。你可以看看各层量化范围有没有异常值。比如某个层输出min-0.001max0.002但weight范围极大就有可能导致精度崩塌。qnn-python-converter \ --input_model model.onnx \ --quant_debug_level 1 \ --output_dir debug_out对特定层回退到FP16QNN支持在INT8模型里把某些敏感层标记为FP16。这个操作需要逐层尝试步骤是先用debug信息找到异常层再用--override_quantize_op参数指定该算子回退到FP16。实测中检测模型里最后几个卷积加回归头经常就是导致精度掉点的主因。使用Linear Quantization调整如果权重分布极不均衡可以让量化器在per-tensor和per-channel之间切换。QNN默认per-tensor但遇到深度可分离卷积这类权重通道间差距大的结构per-channel量化能显著提升精度。4. Android端集成与运行时调优4.1 在Android Studio中集成QNN Runtime主机上把模型转好后接下来就要把它塞进Android App里跑起来。QNN的Android运行时主要由两组so组成libQnnSystem.so: 负责加载HTP驱动、管理系统资源libQnnHtp.so和libQnnHtpV*.so: 实现具体的HTP后端逻辑通常还需要依赖高通的HTP驱动libcdsprpc.so或libFastRPC*.so这个在设备的/vendor/lib64里已经有但是App进程不一定能访问到vendor分区所以最好把QNN SDK里提供的libcdsprpc.so也一起打进APK。在CMake里这样配置add_library(qnn SHARED IMPORTED) set_target_properties(qnn PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/libs/arm64-v8a/libQnnHtp.so) add_library(qnn_system SHARED IMPORTED) set_target_properties(qnn_system PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/libs/arm64-v8a/libQnnSystem.so) target_link_libraries(native-lib qnn qnn_system)运行时先初始化system context再加载HTP backend#include QnnInterface.h Qnn_ErrorHandle_t initQnn() { QnnInterface_t* interface nullptr; QnnSdkVersion_t sdkVersion; if (QnnInterface_getProviders(nullptr, interface, sdkVersion) ! QNN_SUCCESS) { return QNN_FAILURE; } // 设置backend QnnBackend_Config_t* backendConfig nullptr; if (interface-backendCreate(logHandle, backendConfig, backendHandle) ! QNN_SUCCESS) { return QNN_FAILURE; } // 加载模型 QnnModel_Config_t* modelConfig nullptr; int32_t modelError interface-modelCreate(backendHandle, modelBuffer, modelBufferSize, modelConfig, modelHandle); return modelError; }这里有个关键的初始化顺序必须先创建backend再创建model最后才profile和execute。如果顺序反了会直接报NULL handle错误原因在官方文档里比较容易忽略因为示例代码都是拆开的。4.2 HTP图准备与内存策略QNN在HTP上执行前要先通过graphPrepare把模型图编译成HTP固件可执行的指令序列。这里有一个参数很关键QNN_GRAPH_PREPARE_PROFILE。QnnGraph_Config_t graphConfig; graphConfig.option QNN_GRAPH_CONFIG_OPTION_PROFILE; graphConfig.profile QNN_GRAPH_PROFILE_FAST; // 高性能模式 interface-graphPrepare(graphHandle, graphConfig);FAST模式优先追求低时延BURST模式适合连续多帧场景DEFAULT模式则平衡时延和功耗。在线检测业务里我通常选FAST如果是后台运行的低优先级任务比如相册聚类分析可以用DEFAULT省电。内存层面建议开启QNN_TENSOR_MEM_ION或QNN_TENSOR_MEM_SYSTEM_MEM。如果App里同时有相机流和图像处理用ION buffer可以直接复用相机HAL层的buffer避免两次拷贝。但这个需要你对Android Camera2的ImageReader有比较深的理解否则不建议搞直接用System Mem也能用只是多几次memcpy而已。4.3 JNI封装与输入输出转换一个常见的坑是Java层和C层的Bitmap格式转换。QNN模型通常需要RGBA或RGB连续内存但Android Bitmap默认是ARGB_8888又带行对齐。如果直接传ByteBuffer很容易出现图像偏移或色彩错乱。我习惯在JNI里做一个像素重排extern C JNIEXPORT jfloatArray JNICALL Java_com_example_qnn_MainActivity_runInference(JNIEnv* env, jobject thiz, jobject bitmap) { AndroidBitmapInfo info; void* pixels; AndroidBitmap_getInfo(env, bitmap, info); AndroidBitmap_lockPixels(env, bitmap, pixels); // 把ARGB转成float RGB且按模型预处理要求归一化 std::vectorfloat input_data(1 * 3 * 640 * 640); for (int i 0; i 640 * 640; i) { uint8_t r ((uint8_t*)pixels)[i * 4 2]; uint8_t g ((uint8_t*)pixels)[i * 4 1]; uint8_t b ((uint8_t*)pixels)[i * 4 0]; input_data[0 * 640 * 640 i] (r / 255.0f - 0.485f) / 0.229f; input_data[1 * 640 * 640 i] (g / 255.0f - 0.456f) / 0.224f; input_data[2 * 640 * 640 i] (b / 255.0f - 0.406f) / 0.225f; } AndroidBitmap_unlockPixels(env, bitmap); // 执行推理... }一些细节上的坑转成NCHW时内存访问顺序是C维度优先所以上面代码里每个i对应三个通道分别赋值。如果用嵌套循环先给R通道赋值再给G通道效率反而低因为会造成cache miss。如果模型输入是NHWC可以在data_reader里就固定NHWC然后JNI里不用transpose直接排成HWC顺序能省点耗时。4.4 性能基准测试方法在Android端跑性能测试别直接用System.currentTimeMillis()包一圈就完事。要用Trace.beginSection配合systrace或者至少用SystemClock.elapsedRealtimeNanos()。我推荐用简单的三步long t1 SystemClock.elapsedRealtimeNanos(); int status nativeRunInference(buffer); long t2 SystemClock.elapsedRealtimeNanos(); Log.d(QNN, inference (t2 - t1) / 1000 us);跑分的时候要跑满100次丢弃前10次预热后面取p50和p90。不要只看均值因为HTP的调度偶尔会被其他任务打断p90更能反映真实场景下的体验。在SM8450上我跑的YOLOv5s输入640x640INT8大概是单帧8~12msYOLOv7-tiny大约6~9ms。对比同样在GPU上跑ONNX Runtime提升了接近两倍多。5. 常见问题与排查技巧实录5.1 转换阶段报错速查我把自己踩过的和帮朋友看的转换期报错整理成了一个速查表报错信息主要原因解决方案Not supported op: NonMaxSuppression后处理算子不在HTP支持列表从ONNX里拆掉在Android端CPU做NMSUnsupported ONNX op: GridSample上采样算子不支持换结构用ConvTranspose或用Resize替代Shape mismatchedONNX里存在dynamic shape固定输入尺寸用onnxsim折叠shape计算libInit: init failed工具链环境变量没配好或Python版本不对用Python3.8 venv检查LD_LIBRARY_PATHVtcm allocation failed模型中个别层显存分配过大减小输入尺寸或者将敏感层回退到FP165.2 Android运行时常见崩溃运行时的坑比转换期更隐蔽。先列一个经典错误E QnnHtp: Failed to load HTP driver. Error code: 331这个错误码331在QNN里对应的是QNN_DRIVER_NOT_AVAILABLE。排查思路确认手机是高通平台且Android系统用户空间里有libcdsprpc.so。很多定制ROM权限管控严格会拦掉App访问fastrpc设备节点这种情况下需要在AndroidManifest里加上android.permission.ACCESS_DRM或android.permission.SYSTEM_OVERLAY_WINDOW视厂商而定。检查APK里是否打包了与设备HTP版本不匹配的libQnnHtpV*.so。HTP驱动是分代际的比如8Gen1对应libQnnHtpV73.so8Gen2对应libQnnHtpV75.so。如果混用驱动加载也会失败。还有一个很折磨人的问题模型加载成功后第一次execute偶发ANR或App重启。这个问题通常跟动态shape有关也可能是输入buffer没有做cache alignment。QNN底层要求输入张量内存地址按cache line对齐实际部署时建议用posix_memalign(ptr, 256, size)来分配buffer。void* aligned_ptr nullptr; posix_memalign(aligned_ptr, 256, input_size); memcpy(aligned_ptr, input_data.data(), input_size);5.3 精度突然下降的排查步骤如果你在Android上跑量化模型发现精度和主机测试对不上不要先怪量化。大概率是下面几个问题之一输入预处理不一致主机上用BGRAndroid JNI里却用了RGB格式。这种错误非常隐蔽因为卷积对颜色通道的敏感性极高稍微调换通道顺序精度就崩了。我的排查方法是打印模型第一层输出的绝对均值如果量化模型每层输出范围和主机差距巨大基本就是预处理的问题。输入张量的内存分布不一致QNN量化模型对输入layout敏感如果训练和转换时用的是NCHWAndroid运行时就千万别改成NHWC。后处理阈值需要重新调量化后模型输出分布会整体发生偏移置信度阈值也需要跟着调。这个很多人会忽略明明模型输出分布正常但映射回业务置信度时阈值设得太死把正样本全过滤掉了。5.4 HTP调度优先级调整在连续推理场景中比如视频流检测默认的调度策略可能会导致帧率波动。QNN的profile config里有一个QNN_GRAPH_PRIORITY_HIGH_PRIORITY选项可以把它加进去。但这里要小心一个反直觉的事把图优先级跳成High之后在过热场景下系统可能会直接杀掉你的App进程。因为高优先级任务会被系统视为高功耗行为会触发温控惩罚。所以别一昧追求高优先级考虑加个帧率调节机制比如检测帧数降到20fps时换低优先级执行。5.5 APK瘦身与multidex问题QNN相关so文件加起来大约30~40MB如果再加上串行化的模型文件APK体积很容易膨胀到100MB以上。针对这个有两个建议只保留arm64-v8a的so放弃armeabi-v7a。现在新机绝大多数是64位QNN对32位支持也不够完善强行兼容只会增加包体并引入稳定性风险。模型文件放到Android的/data/local/tmp下或者应用私有目录不要打进APK的assets。如果必须放assets系统会自动解压一遍到加密目录既费空间又增加首启耗时。我一般会在App首次启动时把模型从assets拷贝到filesDir/model/之后每次直接读。6. 部署前必须落实的几个优化6.1 用静态输入shape替代动态shape可能有人会心存侥幸觉得QNN转换器既然能处理dynamic shape那就直接上。但实际在HTP上动态shape会引起每次execute时的重编译导致显著性能抖动。我在测试中动态shape的YOLOv5推理时延从8ms到35ms波动完全不可控。用静态shape之后时延稳定在9ms左右标准差很小。6.2 输出后处理尽量留到Java/Kotlin层很多模型把NMS、Score过滤都放在网络里面转换到QNN后虽然也能跑但会占用HTP上宝贵的计算资源而且这些逻辑在CPU上用几行代码就能完成。我通常把模型输出裁剪到原始检测头之后的raw tensor比如每个anchor的box和score然后在Kotlin里用sort、阈值过滤、NMS。这样不仅转换容易后续调试也灵活许多。6.3 热启动与多线程安全QNN的context在创建模型后可以反复execute。对于多线程并发推理要注意每个线程最好使用独立的QnnContext或者至少保证同一个context的execute操作不用org并发。我实际测试中QNN HTP后端在arm64上并不是完全并发安全的两个线程同时execute同一context会出现偶发崩溃。合理的做法是维护一个context pool每个线程拿一个独立context执行。6.4 模型加密与安全保护部署到用户设备上的模型难免担心被抽取扒走。QNN本身不提供模型加密但你可以用AES-GCM对serialized模型加密然后在App内解密到内存后再传给QNN modelCreate。这个方案比单纯混淆C代码有效得多因为即使逆向出so拿不到解密密钥也无济于事。7. 最后的个人经验总结QNN SDK的学习曲线确实比TFLite陡峭不少路径也更复杂。但我个人觉得如果你要在骁龙平台上做高性能、低功耗的AI部署这笔投入早晚要花。我现在做新项目的时候已形成习惯动作评估模型是否适合量化校准数据有没有覆盖真实场景后处理留不留到端上Android端内存对齐做了没。真要说最值得复用的经验就是别在转换报错时硬解算子。转换器说这个算子不支持先想到拆、换成等价子结构或者挪到CPU后处理里很多时候反而能得到更高效、更稳定的整体架构。量化精度低了也别慌先查预处理再换校准数据分布最后再动层量化回退按这个顺序排查能省不少时间。如果你正踩在某个具体坑上建议把报错信息、QNN版本、芯片平台贴出来社区里应该能很快有人接毕竟这整套工具链的玩法现在已经越来越成熟了。
返回列表