这是 AI 实操第 6 天。前 5 天模型还只在通用引擎上跑,今天开始走上车路线:用高通 SNPE 把 ONNX 转成骁龙芯片专用的 DLC 格式,并在 x86 CPU 上验证推理结果一致。 本文首发地址 https://h89.cn/archives/672.html 项目地址 https://gitee.com/chenjim/cockpit-ai-from-zero

引言:为什么需要 SNPE?

之前我已经学会用 PyTorch 训练 MNIST 手写数字识别模型,并把它导出成 ONNX 通用格式。ONNX 就像是模型的「通用语言」,各种推理框架都能读。但真正要把 AI 跑在手机上——尤其是高通骁龙平台的手机或车机——就需要用高通自己的推理框架 SNPE(Snapdragon Neural Processing Engine,骁龙神经处理引擎)。

这个 SDK 最新版改名叫 QAIRT(Qualcomm AI Runtime),不过大家还是习惯叫 SNPE。它的核心工作就是把 ONNX 模型转成高通专用的 DLC 文件,然后让骁龙芯片的 CPU/GPU/DSP/HTP(NPU)跑推理。

下面是我从零搞这玩意儿的全过程。

SNPE SDK 架构图

SDK 怎么下载

SNPE SDK 需要从 Qualcomm Developer 官网下载,地址是:

https://developer.qualcomm.com/software/qualcomm-neural-processing-sdk

需要注册一个高通开发者账号(免费),下载最新版本即可(不区分 Linux/Windows,一个包包含所有平台)。我下载的是 QAIRT v2.48.0,解压后目录结构长这样:

neural-processing-sdk/qairt/2.48.0/
├── bin/                     # 可执行工具(按平台分)
│   ├── x86_64-linux-clang/  # ← 开发机(Linux x86)用
│   └── aarch64-oe-linux-*/  # → 目标设备(骁龙车载/手机)用
├── lib/                     # 共享库 + Python 绑定
│   ├── x86_64-linux-clang/  # x86 运行时库(CPU 后端可在开发机验证)
│   ├── aarch64-oe-linux-*/  # 目标设备运行时库
│   └── python/              # Python API(qairt / qti.aisw / snpe)
├── include/                 # C/C++ 头文件
├── docs/                    # HTML 文档
└── examples/                # 示例代码(Python / C++)

注意区分「开发机用的工具」和「目标设备用的运行时」。比如 snpe-onnx-to-dlc 这个转换工具只存在于 x86_64-linux-clang 目录,用于在开发机上把 ONNX 转成 DLC。而 snpe-net-run 推理执行器在两个平台都有——开发机用 CPU 后端跑验证,真机则用 DSP/HTP。

关键工具有哪些

工具 功能 示例
snpe-onnx-to-dlc ONNX 模型→DLC 转换 snpe-onnx-to-dlc --input_network model.onnx -d input 1,1,28,28 --output_path model.dlc
snpe-dlc-info 查看 DLC 结构(层/张量/算子/后端支持) snpe-dlc-info --input_dlc model.dlc
snpe-net-run 命令行推理(x86 或真机) snpe-net-run --container model.dlc --input_list list.txt
snpe-dlc-quantize DLC 量化(FP32→INT8) 下一篇文章详述
qairt-converter 新版统一转换器(功能等价,新项目推荐用这个) 用法类似

SDK 里 bin/x86_64-linux-clang/ 下面有 50+ 个工具,除了上面几个核心的,还有精度调试器、架构检查器、自定义算子生成器等等。但我们目前只需要上面这 5 个就够了。

理解 DLC 格式

DLC(Deep Learning Container,深度学习容器),高通自家的模型格式。说它是文件不准确——更像一个容器,塞了模型结构、权重参数、后端兼容性标注等等。

我跑完转换后生成的 MNIST DLC 文件只有 439KB,用 snpe-dlc-info 查看它的内部结构:

| Id | Name          | Type              | Inputs          | Out Dims     | Runtimes | Parameters        |
|----|---------------|-------------------|-----------------|--------------|----------|-------------------|
| 0  | /conv1/Conv   | Conv2d            | input [1,28,28,1]| 1x26x26x16   | A D G C  | param: 160        |
| 1  | /Relu         | ElementWiseNeuron |                 | 1x26x26x16   | A D G C  |                   |
| 2  | /MaxPool      | Pool              |                 | 1x13x13x16   | A D G C  |                   |
| 3  | /conv2/Conv   | Conv2d            |                 | 1x11x11x32   | A D G C  | param: 4k         |
| 4  | /Relu_1       | ElementWiseNeuron |                 | 1x11x11x32   | A D G C  |                   |
| 5  | /MaxPool_1    | Pool              |                 | 1x5x5x32     | A D G C  |                   |
| 6  | Transpose     | Transpose         |                 | 1x32x5x5     | A D G C  | NHWC→NCHW         |
| 7  | /fc1/Gemm     | FullyConnected    |                 | 1x128        | A D G C  | param: 102k       |
| 8  | /Relu_2       | ElementWiseNeuron |                 | 1x128        | A D G C  |                   |
| 9  | /fc2/Gemm     | FullyConnected    |                 | 1x10         | A D G C  | param: 1k         |

几个点:

  • NHWC 数据布局:NHWC(batch、height、width、channel,通道排在最后)是 DLC 用的布局([1,28,28,1]),PyTorch 用 NCHW([1,1,28,28],通道在第二位),不一样。转换工具自己插了 Transpose 层来处理这个差异。
  • 算子映射:ONNX 的 Conv→Conv2d,Gemm→FullyConnected,Relu→ElementWiseNeuron。SDK 自动处理。
  • 多后端标注:Runtimes 列写着 A D G C,就是 AIP/DSP/GPU/CPU。同一个 DLC 文件,运行时选哪个后端都行。
  • 总参数量:108,618 个参数,总 MACs(乘加运算次数)6M。

SNPE 运行时后端

SNPE 有四种推理后端,简单说一下区别:

后端 说明 适合场景
CPU Kryo CPU 通用核心 哪里都能跑,开发机验证首选
GPU Adreno 图形处理器 图像相关、并行计算多的任务
DSP Hexagon 数字信号处理器 旧机型在用,新项目别碰了
HTP Hexagon 张量处理器 现在的标配 NPU,省电效果最好

AIP(AI Processor)是一种抽象标记,实际上对应 DSP 或 HTP,取决于芯片型号。DLC 模型里标注了 A D G C,代表它理论上支持全部后端,但如果某层算子不被某个后端支持,实际运行时会报错。

实战:从 ONNX 到 DLC

环境搭建

先把 SDK 下载解压好,然后配环境。SNPE SDK v2.48.0 的 Python 绑定只预编译了 Python 3.10 和 3.12 两个版本(没有看到 3.11 的 .so),我用 uv 创建了独立的 Python 3.10 虚拟环境:

uv venv --python 3.10 .venv_snpe
source .venv_snpe/bin/activate
pip install "onnx==1.16.1" onnxruntime numpy pyyaml aenum pandas onnxsim

注意这里锁定 onnx==1.16.1——因为新版 onnx(1.22+)去掉了 onnx.version.version 属性,SNPE SDK 转换器依赖这个,所以必须用旧版。

然后设置 SNPE 环境变量:

export SNPE_ROOT=/path/to/neural-processing-sdk/qairt/2.48.0.260626
export PATH=$SNPE_ROOT/bin/x86_64-linux-clang:$PATH
export LD_LIBRARY_PATH=$SNPE_ROOT/lib/x86_64-linux-clang:$LD_LIBRARY_PATH
export PYTHONPATH=$SNPE_ROOT/lib/python:$PYTHONPATH

这一个步骤我写成一键脚本了:snpe_setup.shsource snpe_setup.sh 即可搞定所有配置。

执行转换

snpe-onnx-to-dlc \
  --input_network mnist_cnn.onnx \
  -d input 1,1,28,28 \
  --output_path mnist_cnn.dlc
  • --input_network:ONNX 文件路径
  • -d input 1,1,28,28:固定输入尺寸。因为 ONNX 模型的 batch 维度是动态的(=0),DLC 需要确定值
  • --output_path:输出 DLC 文件路径

理解转换过程

ONNX→DLC 的转换不是简单的「另存为」,SDK 内部做了这四步处理:

SNPE 转换四步流程

  1. ONNX 解析:读取 ONNX 计算图,提取所有节点、张量、权重参数
  2. 算子映射:把 ONNX 算子映射为 QNN 算子(Conv→Conv2d, Gemm→FullyConnected, Relu→ElementWiseNeuron)。如果遇到不支持的自定义算子,可以用 SDK 的 UDO(User Defined Operator)机制自己写适配
  3. 布局转换:PyTorch 用 NCHW(batch, channel, height, width),SNPE 用 NHWC。如果是 [1,1,28,28] NCHW 的输入,转换器会自动插入 Transpose 变成 [1,28,28,1] NHWC
  4. DLC 序列化:写入 .dlc 文件。一份 DLC 里同时包含 FP32 的原始权重和量化参数占位符(为后续 INT8 量化预留空间)

这一步还有一个参数值得注意——--preserve_io_datatype。如果不想让 SDK 自动改变输入输出精度,可以用这个标志保持原始类型。对于 MNIST 这种小模型,默认行为就够用了。

查看 DLC 结构

snpe-dlc-info --input_dlc mnist_cnn.dlc

这个命令输出模型的全部信息:每层的输入输出尺寸、算子类型、支持哪些后端。排查转换问题时,先看这个。

x86 CPU 推理验证

没有骁龙手机也能先验证——SNPE SDK 的 x86 版本自带 CPU 后端。我用 snpe-net-run 在开发机上跑了 200 张 MNIST 测试图片做抽样验证:

snpe-net-run 流程:
  1. 加载 MNIST 图片 (28x28 灰度) → 预处理 → NHWC [1,28,28,1] float32
  2. 写成 raw 二进制文件(float32 小端序字节流)
  3. 创建 input_list.txt(每行一个 raw 文件路径)
  4. snpe-net-run --container mnist_cnn.dlc --input_list input_list.txt --output_dir ./output
  5. 读取 ./output/Result_0/ 下的输出 raw 文件,解析为 float32 数组

这里有个细节:snpe-net-run 的输入必须是 raw 二进制文件,不是图片。你要自己先把 MNIST 的 uint8 像素值(0-255)normalize 成 float32(0.0-1.0),再按 NHWC 的排列写成连续的字节流。输出也是 raw,过来之后按 [1,10] 的格式解析就行。

结果:PyTorch / ONNX Runtime 在 10,000 张全量测试集上准确率为 99.12%(与 day01 一致)。在抽样的 200 张上,ONNX Runtime 准确率为 97.62%,SNPE x86 CPU 与 ONNX Runtime 同 200 张预测结果逐张完全一致——说明 DLC 转换没有引入精度误差。不同抽样集合的数字会有波动,重点看 FP32 与 ONNX 在同一份样本上是否一致。

踩坑记录

这一节是我实际折腾过程中遇到的所有坑,每个都花了十几分钟到几小时不等。如果也有人遇到类似问题,希望这篇能帮你省时间。

坑一:Python 版本不兼容(30 分钟)

SDK v2.48.0 的 .so 文件(libPyIrGraph310.solibPyIrGraph312.so 等)只编译了 Python 3.10 和 3.12 两个版本。如果你的系统 Python 是 3.11(很多 Linux 发行版的默认版本),导入 SDK 时会报:

ImportError: Unsupported Python version: 3.11

解决:用 uv venv --python 3.10(或 --python 3.12)创建独立的虚拟环境,不要用系统的 Python 3.11。

坑二:onnx 版本限制(10 分钟)

SNPE 转换器内部调用 onnx.version.version,新版 onnx(1.22+)已经移除了这个属性:

AttributeError: module 'onnx' has no attribute 'version'

解决:固定安装 onnx==1.16.1——这是 SDK 文档里声明的兼容版本。

坑三:系统库依赖缺失(30 分钟)

SNPE SDK 的 .so 文件链接了 LLVM libc++(不是 GCC 的 libstdc++),Debian/Ubuntu 系统默认没有:

ImportError: libc++.so.1: cannot open shared object file: No such file or directory
ImportError: libpython3.10.so.1.0: cannot open shared object file: No such file or directory

解决

  1. 从 Debian 仓库下载 libc++1 和 libc++abi1 的 .deb 包(以 Debian 12 / LLVM 16 为例):
    curl -LO "http://deb.debian.org/debian/pool/main/l/llvm-toolchain-16/libc++1-16_16.0.6-15~deb12u1_amd64.deb"
    curl -LO "http://deb.debian.org/debian/pool/main/l/llvm-toolchain-16/libc++abi1-16_16.0.6-15~deb12u1_amd64.deb"
    dpkg-deb -x libc++1-16_*.deb libc_ext/
    dpkg-deb -x libc++abi1-16_*.deb libc_ext/
    cp libc_ext/usr/lib/llvm-16/lib/libc++.so.1.0 libc_ext/usr/lib/llvm-16/lib/libc++abi.so.1.0 $SNPE_ROOT/lib/x86_64-linux-clang/
    ln -s libc++.so.1.0 libc++.so.1
    ln -s libc++abi.so.1.0 libc++abi.so.1
    
    然后把解压出的 libc++.so.1.0libc++abi.so.1.0 放到 SDK 的 lib/x86_64-linux-clang/ 目录,并创建 .so.1 软链接。
  2. 把 uv 的 Python 3.10 lib 路径加入 LD_LIBRARY_PATH

坑四:Padding 计算无符号整数溢出(2 小时)

这是最隐蔽的一个 bug。转换时 MaxPool 层的 padding 计算报错:

AssertionError: Explicit pad values (0, 0) do not result in expected pad_value (4294967295) to calculate Op output dim

4294967295 是什么?它是 0xFFFFFFFF——也就是 -1 在无符号 32 位整数下的表示。

原因分析:我的 MNIST CNN 里第二个 MaxPool 的输入是 11x11(经过两次 conv 后),kernel=2、stride=2、padding=0,输出公式为 floor((11-2)/2)+1 = 5。SDK 的 C++ 扩展用无符号整数计算 total_pad_amount = (5-1)*2 + 2 - 11 = -1,结果溢出成 4294967295。

修复:在 SDK 源码 qnn_translations.py 中把计算显式转为 Python int:

# 文件:neural-processing-sdk/qairt/2.48.0.260626/lib/python/qti/aisw/converters/qnn_backend/qnn_translations.py
# 位置:第 2545-2553 行,get_pool_pad_size 方法

     def get_pool_pad_size(self, input_size, output_size, stride_size, filter_size, padding_size_strategy,
                           pad_before, pad_after):
         if padding_size_strategy not in [ir_graph.PADDING_SIZE_IMPLICIT_SAME_BEGIN,
                                          ir_graph.PADDING_SIZE_IMPLICIT_SAME_END]:
-            pad_value = (output_size - 1) * stride_size + filter_size - input_size
+            # 全部转为 Python int 再计算,防止 C 扩展的无符号整数溢出
+            # (如 11x11 输入 MaxPool kernel=2 stride=2 时,(5-1)*2+2-11=-1 会溢出为 4294967295)
+            pad_value = (int(output_size) - 1) * int(stride_size) + int(filter_size) - int(input_size)
             return self.get_pad_size_c(padding_size_strategy, pad_value, pad_before, pad_after,
                                        stride_size)
         return pad_before, pad_after

根本原因:C++ 扩展(ir_graph 模块)用的 unsigned int,Python 直接拿来做算术,结果为负就溢出。加上 int() 强制把运算拉回 Python 的有符号整数空间,问题解决。

注意:这个问题仅在 padding=0、且输入尺寸「不整除」kernel/stride 时才触发。我的 MNIST 模型正好踩中了,如果你的模型 conv/pool 尺寸都对齐,可能遇不到。

验证结果汇总

验证项 结果
ONNX→DLC 转换 ✅ 成功,生成 mnist_cnn.dlc(439KB)
DLC 结构查看 ✅ 10 层算子,支持 A/D/G/C 四个后端
x86 CPU 推理 ✅ 成功,snpe-net-run 输出与 ONNX Runtime 完全一致
准确率(200 张抽样) ✅ ONNX Runtime 97.62%,FP32 DLC 96.00%;DLC 与 ONNX 同 200 张逐张一致
Total Parameters 108,618
Total MACs 6M
估计算法内存 612.3 KB

总结

这趟下来搞清楚了几件事:

  1. SNPE SDK 环境怎么搭,工具链怎么用
  2. DLC 不只是个文件格式——结构、参数、后端标注都塞在里面
  3. ONNX→DLC 走的是解析→映射→布局转换→序列化四步,不是简单另存为
  4. x86 CPU 后端让开发机上就能跑推理验证,不用等真机
  5. 遇到 unsigned int 溢出这种坑怎么查——从报错翻源码,定位到修复

下一步是第 7 天:给 DLC 做 INT8 量化,推骁龙手机跑延迟和精度对比。


系列文章

本系列「车载端侧 AI 工程化从零上手」共 10 篇,建议按序阅读:

  1. 零基础用 PyTorch 识别手写数字:MNIST 实战入门
  2. 把 PyTorch 模型变成 ONNX:导出、验证和可视化一次学会
  3. MLflow 实验追踪:从入门到上手
  4. 用 MNN 在 CPU 上跑 AI 推理:阿里端侧推理框架上手记
  5. 小白用 TensorRT 给模型加速:ONNX 转 Engine 踩坑实录
  6. 零基础上手:高通 SNPE 模型转换实战(从 ONNX 到 DLC)
  7. 零基础搞懂 SNPE 模型量化:INT8 精度损失 = 0% 的秘密
  8. Python 工程化重构:从 print 脚本到 pytest 项目
  9. 车载语音指令识别:从 44% 到 96% 的调优之路
  10. 车载语音指令识别:SNPE 转换与真机部署实测记录

本文链接:零基础上手:高通 SNPE 模型转换实战(从 ONNX 到 DLC) - https://h89.cn/archives/672.html

版权声明:原创文章 遵循 CC 4.0 BY-SA 版权协议,转载请附上原文链接和本声明。

标签: Python, x86, ONNX, CPU, PyTorch, MNIST, CNN, ONNX Runtime, SNPE, DLC, Qualcomm, 骁龙

🎓 呈言英语 智能英语学习平台
📚单词学习 🎧听说练习 📖阅读理解 ✏️拼写练习 🌟 AI智能推荐 · 科学记忆曲线
🚀 立即开始免费学习

添加新评论