
1. 为什么要在Android上折腾QNN SDK第一次把训练好的ONNX模型往Android手机上塞的时候我天真地以为跟PC端一样装个推理框架跑起来就完事了。结果实测下来CPU推理一张图动辄三四百毫秒稍微复杂点的检测模型直接飙到一秒以上手机背面烫得能煎鸡蛋。后来才意识到高通平台上的Hexagon NPU才是真正该用的算力单元而QNN SDK就是打开这扇门的钥匙。QNN全称Qualcomm Neural Network是高通给自家骁龙平台提供的神经网络推理SDK。它跟通用的NNAPI不一样NNAPI是个抽象层底层具体走CPU、GPU还是NPU由厂商驱动决定你控制不了而QNN SDK是直接面向Hexagon DSP和HTPHexagon Tensor Processor的能拿到更细粒度的控制权也能榨出更极致的性能。代价就是工具链更复杂模型转换、量化、图切分这些环节都得自己盯着。这篇内容适合两类人一类是已经在Android端跑通了ONNX Runtime或者TFLite但发现性能瓶颈卡在CPU上想往NPU迁移的开发者另一类是手里有训练好的ONNX模型准备在骁龙设备上做端侧部署但对QNN这套工具链完全没概念的人。我会把从ONNX模型转换、量化校准、图编译到精度调优的完整链路拆开讲重点放在那些官方文档一笔带过、但实际踩坑率极高的环节上。需要提前说明的是QNN SDK的版本迭代比较快不同版本之间API和工具行为有差异。我下面讲的内容基于QNN 2.x系列的常见实践具体到你的环境时建议先确认SDK版本和对应的文档。另外整个流程涉及的工具比较多我会在每个环节说明为什么这么选、有没有替代方案。2. 环境搭建别急着装SDK先把版本对齐这件事做对2.1 QNN SDK版本与骁龙平台的对应关系很多人拿到SDK压缩包就开始解压配置结果编译出来的模型在设备上加载失败报一堆看不懂的HTP错误。十有八九是SDK版本和目标设备的Hexagon架构版本没对上。QNN SDK的发布是跟骁龙芯片代际绑定的。比如骁龙888对应Hexagon 780骁龙8 Gen 1对应Hexagon 7908 Gen 2是Hexagon 800。每个Hexagon版本支持的HTP算子集和量化策略都有差异。SDK里通常会有多个DSP架构的库文件你需要根据目标设备选择正确的那个。骁龙平台Hexagon版本典型QNN SDK版本注意事项骁龙8656982.10.x较老部分新算子不支持骁龙8887802.14.x支持较好的INT8量化骁龙8 Gen 17902.16.x引入新的图优化pass骁龙8 Gen 28002.20.x支持混合精度更灵活我的建议是先确定你的目标机型查清楚它的Hexagon版本然后去下对应版本的SDK。不要盲目用最新版最新版可能对老设备支持反而变差。另外SDK里的libQnnHtp.so和libQnnHtpV68Stub.so这类库文件名字里的V68、V69就是Hexagon架构代号选错了直接加载失败。2.2 主机端工具链的依赖坑QNN SDK的主机端工具主要在Linux上跑官方推荐Ubuntu 20.04或22.04。Windows下虽然也有部分工具但模型转换和量化校准这块Linux环境稳定得多。装之前先确认几个依赖Python版本建议3.8到3.10之间太高了有些工具脚本会报语法错误protobuf版本要跟SDK里的要求一致这个特别容易出问题因为系统里可能已经装了别的版本还有numpy、onnx这些包版本也要对齐。我踩过最坑的一次是protobuf版本冲突。系统里装的是3.20SDK要求3.19结果模型转换工具跑一半直接段错误没有任何有用报错。后来用virtualenv建了个干净环境按SDK文档里的requirements.txt严格装才解决。提示强烈建议用Python虚拟环境隔离QNN SDK的依赖不要跟系统Python混用。SDK目录下一般有requirements.txt直接pip install -r装。环境变量这块需要把SDK的bin目录加到PATH把lib目录加到LD_LIBRARY_PATH。具体路径根据你的解压位置调整。配完之后跑一下qnn-onnx-converter --help能正常输出帮助信息就说明基本环境OK了。2.3 Android端运行时的集成方式设备端集成有两种路子一种是用QNN SDK提供的预编译库直接放到你的Android工程里通过JNI调用另一种是用QNN的Android AAR包如果SDK版本提供的话。第一种方式更灵活但需要自己写JNI桥接代码。核心是加载libQnnHtp.so、libQnnSystem.so这些库然后通过QNN的C API创建context、加载模型、执行推理。第二种方式省事但封装的粒度比较粗有些底层配置改不了。我一般选第一种因为精度调优阶段经常需要改HTP的配置参数AAR包不一定暴露这些接口。JNI这块的代码量不算大主要就是几个函数初始化backend、创建device、加载模型binary、准备输入输出tensor、执行推理。网上有QNN的sample code可以参考但要注意sample的SDK版本跟你的是否一致。3. ONNX模型转换从通用格式到QNN专属格式的关键一跃3.1 转换前的模型体检拿到一个ONNX模型别急着往qnn-onnx-converter里扔。先做几项体检能省掉后面大量调试时间。第一项用Netron打开模型看看输入输出的shape和dtype。QNN对动态shape的支持有限如果你的模型输入是[batch, 3, -1, -1]这种动态维度转换时大概率会报错。需要先把shape固定下来比如改成[1, 3, 224, 224]。第二项检查算子集。QNN HTP支持的算子列表是有限的有些ONNX算子它不认比如某些自定义算子或者比较新的算子。用onnxruntime跑一遍模型确认能正常推理然后再看QNN转换工具报不报不支持的算子。第三项看模型大小和参数量。HTP的片上内存有限模型太大可能需要做图切分把一部分放到CPU上跑。这个后面会细说。我一般会写个小脚本用onnx的Python API遍历一遍节点把算子类型统计出来跟QNN文档里的支持列表对一遍。不支持的算子要么找替代实现要么改模型结构。3.2 qnn-onnx-converter的参数怎么配qnn-onnx-converter是核心转换工具它的参数直接决定生成的模型能不能在HTP上跑、跑得好不好。最基本的用法是qnn-onnx-converter \ --input_network model.onnx \ --output_path model.cpp \ --input_dim input 1,3,224,224 \ --out_node output \ --float_bias_bits 16这里几个关键参数解释一下。--input_dim指定输入名字和维度多个输入就写多行。--out_node指定输出节点名字不指定的话工具会自己推断但有时候推断出来的输出不是你想要的。--float_bias_bits控制bias的位宽16位是常用值设太高会增加模型体积设太低影响精度。还有一个重要参数是--quantization_overrides用来指定哪些层做量化、用什么校准参数。这个在精度调优阶段会反复用到。转换完成后会生成一个.cpp文件和一个.bin文件。.cpp里是模型结构的C描述.bin是权重数据。设备端加载的是这两个文件编译后的产物。注意转换工具对ONNX的opset版本有要求太新的opset可能不支持。如果报opset相关错误用onnx.version_converter把模型降到支持的版本。3.3 算子不支持时的图切分策略QNN HTP不是万能的有些算子它就是不支持。这时候有两个选择一是改模型用支持的算子替换二是做图切分把不支持的层放到CPU上跑。改模型是首选因为图切分会导致数据在NPU和CPU之间来回拷贝性能损失很大。比如有些模型用了NonMaxSuppressionQNN HTP不支持但你可以把它挪到后处理里用CPU做模型本身只输出原始检测框。如果实在改不了就得切分。QNN SDK提供了图切分的工具和API可以在转换阶段指定切分点。切分的原则是尽量让计算量大的层留在NPU上把零碎的不支持层扔给CPU。切分点选得好不好直接决定最终性能。我做过一个检测模型骨干网络在NPU上跑后处理里的NMS在CPU上跑整体延迟比全CPU快了将近四倍。但如果切分点选得不好比如在卷积中间切一刀数据来回拷贝的开销能把NPU的加速全吃掉。4. 量化INT8精度调优的核心战场4.1 为什么必须做量化HTP对INT8量化的支持是最好的FP16也能跑但性能差一截FP32基本就别想了。量化不只是为了省内存更重要的是HTP的INT8计算单元吞吐量远高于FP16。但量化必然带来精度损失。关键在于怎么把损失控制在可接受范围内。QNN的量化流程分两步先用校准数据集统计各层的激活值分布确定量化参数scale和zero point然后把FP32权重和激活值映射到INT8。校准数据集的选择很讲究。它应该能代表实际推理时遇到的数据分布。如果你用随机噪声做校准量化参数会偏得离谱精度崩掉。一般从训练集或验证集里抽几百张图就够了但一定要覆盖各种场景。4.2 校准数据的准备与预处理校准数据的预处理必须跟模型训练时的预处理完全一致。归一化参数、通道顺序、resize方式任何一项对不上校准出来的量化参数都会有偏差。我一般会把校准数据整理成一个文件夹图片按模型输入的要求预处理好后存成npy或者raw格式。QNN的校准工具支持多种输入格式用npy比较方便因为可以直接控制dtype和shape。校准数据量方面官方建议100到500张。我实测下来200张左右是个比较平衡的点。太少统计不充分太多收益递减还费时间。如果模型有多个输入分支每个分支都要准备对应的校准数据。还有一个细节校准数据的batch size。QNN校准工具一般一次处理一个batchbatch size设成1就行设大了反而可能因为内存问题跑不动。4.3 逐层量化配置与混合精度QNN默认会对所有能量化的层做INT8量化。但有些层对精度特别敏感比如第一层卷积、最后的分类层、或者某些attention结构。这些层如果强行INT8精度可能掉好几个点。这时候就需要混合精度敏感层保持FP16其他层INT8。QNN支持通过--quantization_overrides参数指定每层的量化策略。你可以传一个JSON文件里面列出哪些层用FP16、哪些用INT8。怎么判断哪些层敏感我的做法是先全INT8跑一遍看精度掉多少然后逐层把可疑层改成FP16看精度恢复情况。这个过程比较耗时但能精确定位问题层。另一个偷懒的办法是参考类似模型的量化经验一般第一层和最后一层保持FP16是稳妥的选择。层类型建议精度理由首层卷积FP16输入数据分布差异大INT8容易截断中间卷积INT8计算量大量化收益高分类/回归头FP16输出精度直接影响最终结果逐元素加法INT8对精度不敏感SoftmaxFP16指数运算对量化敏感4.4 量化精度评估的完整链路量化完不能只看模型文件大小必须跑精度评估。评估链路要跟实际部署一致用同样的预处理、同样的后处理、同样的评估指标。我一般会在PC端先用QNN的模拟器跑一遍量化后的模型跟FP32的ONNX模型对比输出差异。QNN SDK提供了qnn-net-run工具可以在x86上模拟HTP的行为。虽然模拟器不能完全代表设备端表现但能快速发现大的精度问题。设备端评估就更直接了把量化模型推到手机上跑用真实数据测精度。这一步不能省因为模拟器和真实HTP之间可能有差异尤其是涉及一些硬件特有的近似计算时。评估指标方面分类模型看top-1/top-5准确率检测模型看mAP分割模型看mIoU。跟FP32基线对比一般要求掉点不超过1%到2%具体看业务容忍度。5. 精度掉点排查从现象到根因的完整链路5.1 先确认掉点发生在哪个环节精度掉点可能发生在多个环节ONNX转QNN时、量化校准时、图切分时、设备端执行时。排查的第一步是定位环节。我的做法是分段验证。先把FP32的ONNX模型转成QNN格式但不量化在设备上跑看精度跟ONNX Runtime比有没有差异。如果没有差异说明转换环节没问题问题出在量化。如果有差异那就是转换环节的算子映射或者图优化出了问题。量化环节再细分用模拟器跑量化模型跟FP32 QNN模型对比。如果模拟器上精度就掉了那是量化参数的问题如果模拟器上没问题但设备上掉了那可能是HTP的硬件近似计算导致的。这个分段排查的思路能快速缩小范围避免盲目调参。5.2 校准数据分布不匹配的典型表现校准数据分布不匹配是精度掉点最常见的原因。典型表现是模型在某些类别的样本上精度正常但在另一些类别上崩得厉害。比如我做过一个车牌识别模型校准数据用的是白天场景结果夜间场景的识别率直接腰斩。因为夜间图像的亮度分布跟白天差异很大校准出来的量化参数对夜间数据不适用。解决办法是让校准数据覆盖所有目标场景。如果某些场景数据少可以做过采样。另外校准数据的预处理一定要跟推理时一致我见过有人校准用RGB、推理用BGR精度不掉才怪。5.3 敏感层定位的二分法如果怀疑是某些层量化敏感可以用二分法快速定位。把模型从中间切成两半前半部分保持FP16后半部分INT8看精度。如果精度恢复明显说明敏感层在前半部分反之在后半部分。然后继续二分直到定位到具体层。这个方法比逐层试快得多尤其适合层数多的模型。定位到敏感层后把它改成FP16再整体评估精度。通常只需要把少数几层改成FP16就能把精度拉回可接受范围而对性能的影响很小。5.4 图切分引入的精度问题图切分本身不会改变计算结果但切分点两侧的数据类型转换可能引入误差。比如NPU侧输出INT8CPU侧期望FP32中间有个反量化过程如果scale选得不好会有精度损失。另外切分后有些融合算子可能被拆开比如ConvBNReLU原本是一个融合算子切分后BN被分到CPU侧融合优化就失效了精度和性能都可能受影响。排查这类问题可以对比切分前后的模型输出。如果切分前精度正常、切分后掉了那就是切分点的问题。调整切分点尽量让融合算子保持完整。6. 性能调优让NPU真正跑满6.1 HTP配置参数的调优空间QNN HTP有一组配置参数可以调影响性能和精度。比如htp_performance_mode可以设成burst、balanced、power_saver等burst模式性能最高但功耗也最高。htp_graph_finalization控制图编译的优化级别级别越高编译越慢但运行时越快。这些参数通过QNN的API在创建context时传入。我一般会在开发阶段用burst模式加最高优化级别把性能上限摸清楚然后再根据实际功耗要求往回调。还有一个重要参数是vtcm_size控制HTP的片上内存分配。VTCM是HTP的紧耦合内存访问速度远快于DDR。如果模型能全部塞进VTCM性能会有质的提升。但VTCM容量有限一般几MB大模型塞不下需要权衡。6.2 模型结构层面的优化除了SDK参数模型结构本身也有很多优化空间。HTP对某些算子有硬件加速比如3x3卷积、depthwise卷积这些算子用好了性能很好。相反一些不规则的算子比如大kernel卷积、空洞卷积HTP支持得不好能换就换。另一个优化点是减少内存搬运。HTP的计算单元和内存之间的带宽是瓶颈如果模型中间特征图太大频繁读写DDR性能就上不去。可以通过减少通道数、降低特征图分辨率来缓解。还有算子融合。QNN在编译阶段会自动做算子融合但有些融合需要模型结构配合。比如ConvBNReLU这种经典组合如果BN的参数能折叠进Conv就能省一次内存读写。训练时用BN推理前把BN折叠掉是个常规操作。6.3 多线程与异步推理QNN支持异步推理可以在一个模型执行的同时准备下一个输入。对于视频流这种连续推理场景异步能显著提高吞吐量。实现上QNN的QnnContext和QnnGraph支持多线程访问但要注意线程安全。一般是一个线程负责准备输入数据另一个线程负责执行推理通过信号量同步。不过异步推理会增加代码复杂度如果单帧延迟已经满足要求没必要上异步。我一般是在吞吐量不够时才考虑这个优化。7. 设备端部署的实战细节7.1 JNI接口的设计与内存管理Android端通过JNI调用QNN的C API接口设计要尽量简洁避免频繁的JNI调用开销。我一般会把整个推理流程封装成一个native函数输入是预处理好的数据输出是推理结果中间的所有QNN调用都在native层完成。内存管理是个容易出问题的地方。QNN的tensor内存需要手动分配和释放如果忘记释放会内存泄漏如果提前释放会导致野指针。我一般用RAII的方式封装QNN的资源确保异常情况下也能正确释放。输入数据的拷贝也要注意。如果输入数据在Java层需要通过JNI传到native层这个拷贝开销不小。优化方法是直接在native层分配输入bufferJava层往里面写数据避免二次拷贝。7.2 模型加载速度的优化QNN模型加载包括两个阶段一是从文件读取模型binary二是HTP编译图。编译阶段比较耗时大模型可能要几秒。如果每次启动都重新编译用户体验很差。优化方法是把编译结果缓存起来。QNN支持把编译后的图序列化到文件下次加载时直接反序列化省去编译时间。这个缓存的key要跟模型版本、SDK版本、HTP配置绑定任何一项变了都要重新编译。我实测过一个中等规模的检测模型首次编译要3秒多缓存后加载只要200毫秒左右提升很明显。7.3 不同骁龙机型的兼容性处理同一份QNN模型binary在不同骁龙机型上的表现可能不一样。新机型的HTP可能支持更多算子、有更大的VTCM老机型则相反。如果模型用到了新机型特有的特性在老机型上可能加载失败。兼容性处理有两种思路一是针对不同机型编译不同的模型binary运行时根据设备型号加载对应的版本二是用一个兼容性最好的配置编译一份binary牺牲一点性能换通用性。我一般选第二种因为维护多份binary的成本太高。但如果性能要求极致第一种也是值得的。关键是做好设备型号到模型版本的映射别加载错了。8. 我踩过的几个印象深刻的坑第一个坑是量化校准数据的预处理。当时模型训练用的是ImageNet的均值和方差做归一化我校准的时候忘了这一步直接拿原始像素值去校准。结果量化参数完全不对精度掉了十几个点。排查了大半天才想起来是预处理的问题。这个教训是校准数据的预处理必须跟训练和推理完全一致一个参数都不能差。第二个坑是HTP的VTCM配置。有个模型在模拟器上跑得好好的到设备上就报内存不足。后来发现是VTCM设得太小模型中间特征图放不下。把VTCM调大后解决。但VTCM不是越大越好设太大可能影响其他模块使用。需要根据模型实际需求调。第三个坑是图切分点的选择。有个模型我切在了两个卷积之间结果性能比全CPU还差。原因是切分点两侧的数据类型不匹配NPU输出INT8CPU输入FP32中间加了个反量化层数据来回拷贝的开销把NPU的加速全抵消了。后来把切分点挪到模型末尾只把后处理切给CPU性能才正常。第四个坑是SDK版本升级。有次手贱把SDK从2.14升到2.16结果原来跑得好好的模型加载失败。查了半天发现是新版本对某个算子的量化策略改了需要重新校准。所以升级SDK前一定要做好回归测试别盲目追新。9. 一些实用的调试技巧QNN SDK提供了几个很有用的调试工具。qnn-profile-viewer可以看模型各层的执行时间和内存占用定位性能瓶颈很直观。qnn-net-run可以在PC上模拟HTP执行快速验证模型正确性。还有qnn-op-package-generator可以查看当前SDK支持的算子列表。日志方面QNN的日志级别可以调。开发阶段把日志开到verbose能看到很多底层信息比如图优化做了哪些变换、量化参数是多少。但verbose日志量很大生产环境要关掉。还有一个技巧是用QNN的--dump_profile参数把profiling数据导出来用Chrome的tracing工具打开能看到时间线上的详细执行情况。这个对分析流水线并行、内存拷贝开销特别有用。最后建议在PC端把整个流程跑通再往设备上迁。PC端的调试工具更丰富出了问题也更容易定位。设备端只做最终验证这样效率最高。