一篇讲清训练好的模型是怎么变成车机芯片上每秒上千次推理的二进制的入门长文 —— 整体流程 · 架构 · 原理 · 概念 · 实现细节在服务器上训练好的模型车机芯片根本看不懂。它需要经过一次翻译重新排版——这就是 QNN 编译链。本文用三代真实模型一个 38MB 的 BERT、一个 3GB 的 GLM、一个 0.8B 的 Qwen把这条路的每一步、每个坑都摊开讲。平台SA8397 车机Hexagon NPU v81 工具Qualcomm QNN SDKQualcomm QNN SDK官方下载地址Qualcomm® Software Center〇、先建立直觉芯片里的三个工人车机 SoCSA8397里能干数学活的硬件有三块性格完全不同硬件类比擅长短板CPUKryo老教授什么题都会做通用、灵活、精度可控fp32 逐位可复现慢。GLM 一句话推理 12.2 秒GPUAdreno一百个中学生人多手快并行吞吐高GLM 只需 4 秒算得糙fp32 被静默降精度输出看着像、实际错NPUHexagon HTP专业计算器只会固定套路极快极省电GLM 380ms、BERT 0.85ms挑食只认特定格式的菜谱我们踩过的坑全在这部署的本质把训练框架PyTorch里的模型翻译成 NPU 能执行的私有格式并保证翻译后的数值仍然对。这个翻译流水线就是QNNQualcomm Neural Network工具链。一、整体流程一次部署的完整旅程【训练侧 · 服务器】 PyTorch 模型 (fp32, 3GB) │ ① torch.onnx.export —— 把模型写成通用交换格式 ONNX ▼ ONNX 文件 ──────────────── ② (可选) 拆分图2GB 必须切成几段 │ │ ③ qnn-onnx-converter ── 翻译ONNX 算子 → QNN 算子 │ ├─ fp32 直接翻译CPU/GPU 路线 │ ├─ --float_bitwidth 16NPU fp16 路线 │ └─ int8 量化需要标定数据统计激活范围 ▼ QNN 模型.cpp 图定义 .bin 权重包 │ ├── 路线 A小模型④ qnn-context-binary-generator │ → 单文件 .ctx架构无关板端 JIT 编译 │ └── 路线 B大模型④ NDK 交叉编译 tar 解权重 → llvm-objcopy(每个权重→.o) → clang → 链接 → 自包含 libxxx_a64.so │ 【车机侧 · adb push】 │ ⑤ qnn-net-run --model/--backend → 在线构图 / 加载 ctx │ ⑥ 喂输入token id 的 int32 二进制→ 拿输出logits ▼ 推理结果 ── ⑦ 和服务器上的金标准逐位对拍 → 验证通过 ✓后面所有章节就是把这条流水线上的每个节点掰开讲。二、架构一块 SoC 里的翻译-装载-执行三层楼2.1 软件栈分层从上到下┌─────────────────────────────────────────────────────┐ │ 应用层 qnn-net-run / 你的 App / 自研引擎 bin │ ← 决定喂什么、拿什么 ├─────────────────────────────────────────────────────┤ │ QNN API QnnModel / QnnContext / QnnTensor │ ← C 接口构图、加载、执行 ├─────────────────────────────────────────────────────┤ │ QNN 后端库 libQnnHtp.so │ libQnnCpu.so │ libQnnGpu.so│ ← 同一接口三块硬件各自实现 ├─────────────────────────────────────────────────────┤ │ DSP 侧 libQnnHtpV81Skel.so (skel) HTP 内核 │ ← 真正跑在 NPU 上的机器码 ├─────────────────────────────────────────────────────┤ │ 内核/驱动 fastrpc-cdsp / fastrpc-nsp1000 / dma_heap │ ← CPU↔DSP 通信与内存通道 └─────────────────────────────────────────────────────┘ CPU 世界 ────────── fastRPC ──────────► DSP 世界2.2 三个必须知道的名字HTPHexagon Tensor Processor高通对 NPU 的内部叫法。SA8397 的 HTP 是v81 代——所以你会看到 libQnnHtpV81Skel.so 这样的文件。fastRPCCPU 和 DSP 之间的远程调用机制。CPU 把输入数据放进共享内存dma_heap喊 DSP 来算算完取回。所有板端玄学问题一半出在内存权限上。skelSkeletonDSP 侧的骨架程序。CPU 侧的 libQnnHtp.so 只是前台真正的计算发生在 DSP 上的 skel 里。它通过 ADSP_LIBRARY_PATH 环境变量定位——路径里含字符它会直接罢工真事App 的 nativeLibraryDir 恰好含 。类比QNN API 像快递下单界面统一标准三个后端是三家物流公司同一张运单各家自有车队。你的模型打包装进运单.ctx 或 .so至于运输途中怎么分拣算子怎么映射到硬件指令每家有自己的规矩——这就是为什么同一个模型换后端可能换出一堆新坑。三、原理量化——把精装图改成施工图为什么需要量化训练时的模型是 fp32每个数 4 字节像精装修图纸标注到毫米。NPU 最爱的运算格式是 int8/int16每个数 1-2 字节像施工图只标米和厘米。量化 找到一个映射让小数字也能表达大模型的权重和激活值float_value ≈ (int_value − offset) × scalescale/offset 从哪来——标定calibration拿几百条真实输入跑一遍 fp32 模型统计每层激活值的实际范围min~max据此算出每层的 scale/offset。所以 int8 量化必须带标定数据fp16 一般不用。代价4 字节变 1 字节 体积缩 4 倍BERT151.7MB fp32 → 36.5MB int8 ctx、速度快数倍风险范围挤压导致的类坍缩——我们第一代 BERTbd4有 8/13 个分类头的 ACC 掉到 8%根因就是大维度分类头的 per-channel scale 错位修复后bd5恢复到 87%~98%。fp16 呢大模型常走 fp16半精度浮点不需要标定数值范围比 int8 宽得多NPU 原生支持。GLM 1.5B 全链 fp16 数值稳定与 fp32 基线 cos≥0.99985。但 fp16 只有 11 位有效数字动态范围是生命线——后面 Qwen 的故事就是被它坑的。四、概念词典report 里反复出现的名词概念一句话解释ONNX模型的通用交换格式.onnx 文件。训练框架各说各话ONNX 是大家都认的普通话。opsetONNX 的方言版本号。我们固定用 opset 17。op算子模型里的一个基本运算矩阵乘、卷积、LayerNorm……。模型 有向图节点是 op边是数据。converterqnn-onnx-converter把 ONNX 的 op 翻译成 QNN op。翻译器不是万能的——遇到它不认识的 op 会拒绝或翻译错见第六节。context binary.ctx编译好的模型上下文含量化图板端加载时 JIT 成 DSP 机器码。架构无关36.5MB 起。在线构图 .so另一种产物把图定义编译成 C 代码 权重打包进一个 .so板端加载时现场搭出计算图。大模型的救命稻草。qnn-net-runSDK 自带的命令行推理器给它模型和后端库就能在板端跑起来。调试期的第一工具。graph / backendgraph计算图实例backend某硬件的实现库libQnnHtp/Cpu/Gpu.so。同一份模型可以换后端跑三块硬件。cos余弦相似度衡量两个输出向量方向对不对的指标1.0完全一致。我们所有对拍的通用标尺。argmax / marginargmax输出向量里最大的那个位置分类答案margin第一名比第二名领先多少越大越稳。五、实现细节逐步骤拆解含真实命令1.导出 ONNX —— 把模型写成通用格式transformers 一行导出但有暗坑训练代码里的动态逻辑if/else、动态 mask会让 tracing 走错分支。我们的做法是monkeypatch 所有动态行为attention mask 手写成固定 additive 矩阵、triu/tril 换成 arange 比较导出后立刻用onnxruntime 跑一遍和 PyTorch 原模型对拍cos≥0.999999 才放行。这一步不过关后面全是无用功。2.转换 —— 翻译成 QNN 方言# fp16 路线NPU 主用 qnn-onnx-converter --input_network cutA.onnx --float_bitwidth 16 --output_path cutA_qnn # int8 路线小模型需标定 qnn-onnx-converter --input_network bert.onnx --quantization_overrides ...转换器对 3-D 输入会做spatial-first 重排[1,512,2048] 悄悄变成 [1,2048,512]不报错——这是静默错位的头号来源必须用转置桥或转换开关关闭。另外整图超过protobuf 2GB 上限会直接失败GLM 3.04GB 的 fp32 模型就是在这里被迫学会了拆。3.编译 —— 把权重变成 .soconverter 产出两个文件.cpp图定义代码几十 MB和.bin权重 tar 包几百个 .raw。NDK 构建三部曲tar -xf cutA.bin -C obj/binary # 解权重 llvm-objcopy -I binary -O elf64-littleaarch64 -B aarch64 \ obj/binary/w001.raw obj/binary/w001.o # 每个权重 → ELF 对象相对路径! clang -c -O2 -fPIC model.cpp -o model.o clang -shared -o libcutA_a64.so objs.rsp -lm -ldl # 链接成自包含 .so最阴的坑objcopy 用绝对路径跑生成的符号名是_binary_E__Project_...而 cpp 里硬编码引用_binary_obj_binary_...——链接时成功、板端 dlopen 时才报cannot locate symbol。相对路径是唯一正解符号名由输入路径决定。这个坑在 CPU/GPU/NPU 三条线各踩了一遍才彻底固化进脚本。4.板端加载与执行export ADSP_LIBRARY_PATH$DIR/skel_v81;$DIR/libs;/vendor/dsp/cdsp export LD_LIBRARY_PATH$DIR/libs qnn-net-run --model libcutA_a64.so --backend libQnnHtp.so \ --use_native_input_files --input_list inA.txt --output_dir out/ # 路线 A 则换成: qnn-net-run --retrieve_context xxx.ctx --backend libQnnHtp.so ...两个板端怪癖① 不加--use_native_input_files这台板子的输入恒为全零结果看起来能跑其实全错② 输出 dump 一律 fp32 字节序——即使模型是 fp16/int8int8 输出是 uint8 量化值要按 metadata 里的 scale/offset 反量化。5.验证 —— 怎么知道结果是对的三层递进详见第七节方法论① 逐层中间量对拍cos NaN 计数定位坏算子→② 任务级判据argmax margin→③ 全量测试集1919 句共现矩阵 匈牙利映射算 ACC。六、踩坑实录三个真实病例比成功更有价值坑 1静默错位所有数字都对就是结果不对GLM 的 A 段输出喂 B 段推理成功、数值全错、无任何告警。排查数天才定位converter 对 3-D 输入做了 spatial-first 重排[1,512,2048] 的文件被 B 段按 [1,2048,512] 解读——数据一字节没错语义全错。解法板端转置桥工具20ms 开销。教训跨段接口必须显式校验布局不能信能跑。坑 2GPU 的认真造假GPU 后端 fp16 全 NaNfp32 跑通但分类结果错argmax10正确是 8。单层探针证明 LN、RoPE 全对唯独scores→softmax 这一步 cos 掉到 0.91同一个 .so 放 CPU 上 cos1.0。我们做了三向对照实验默认配置、强制 FP32、HYBRID——强制 FP32 的输出与默认md5 逐位一致即精度开关被后端静默忽略。结论Adreno GPU 后端无任何官方途径获得真 fp32 计算定性不可交付证据链已固化可提 Qualcomm 工单。教训跑通 ≠ 可用。没有精度对拍GPU 的错误会一路绿灯。坑 3Qwen 线性注意力数学形式决定生死Qwen3.5 的 GDN 线性注意力在 HTP 上 fp16 有 14.7% token 全 NaN、fp32 竟也有 61.7%。根因它官方的 chunk 并行 实现里 exp 的参数是 cumsum 的差值动态范围±588——fp16 最大才 65504溢出是数学必然。解法不是修 SDK而是换数学等价形式把 64-token 并行 chunk 改写成逐 token 递推recurrent每步 exp 参数缩到 ≤1.2。结果单层 cos0.999998、双层 fp16 cos0.9996790 NaN。教训芯片挑的不是算法是算法的数值形态。同一个数学换个写法死活两重天。坑 4大图的内存墙recurrent 版 8 层整图在板端构图时 OOM卡在 Finalizing Graphs。原理512 步递推里每一步的中间 state 下一步都要用内存复用失效活跃张量随层数线性膨胀。解法分层拆分实测 2 层能过宿主程序串接 state——构图一次、推理毫秒级。这同时绕过了 protobuf 2GB 的老问题。大模型部署的终极形态不是一个大图而是一串小图 一个聪明的调度器。七、精度验证怎么证明跑对了金标准链路PyTorch fp32原始模型──► onnxruntime fp32 基线 ──► gold 文件族每一层的中间输出 │ 板端输出 ◄── qnn-net-run ──► 每一层逐级对拍cos NaN 计数 逐 token 分布门禁前移每版 ONNX 先过 ORT 对拍cos≥0.999999才允许上板——板端问题先排除导出就错了。多输出探针把可疑模块内部的十几路中间量做成同图多输出板上跑一次 十几次对拍坏算子定位从数天缩到一次。任务级判据分类模型看 argmax marginGLM 4 句 margin 6.9~10.7远超抖动区语言模型看 logits 对拍 下游生成质量。后端对照法同一 .so 换后端跑——CPU 对 GPU 错 后端实现问题GPU 定性就靠这一招。NaN 分布分析不数有多少 NaN要看哪些 token、哪些通道、从第几层开始——Qwen 的 14.7% NaN 就是靠 token 级分布发现是全局性数值域问题而非个别溢出。八、性能账本0.85ms/句是怎么来的模型规模路线冷启动稳态推理内存BERT-4head int836.5MB ctx离线 CBG~200msJIT0.85ms/句1176 句/sRSS 14.6MB dmabuf 85MBGLM 1.5B fp16拆分 .so ×2在线构图53s 72s一次性380ms/句dmabuf 为主CMA 0Qwen3.5-0.8B分层 .so规划在线构图×N构图一次目标秒级/句待测三个决定性因素① 量化档位int8 比 fp16 快且省 4 倍内存但精度需验证② 常驻进程构图是一次性成本服务化后单句延迟才是真实体验③ batch拆分段后单句串行GLM 的 380ms 是 AB 两段串行之和。九、路线选择指南决策树你的模型多大 ├─ ONNX 2GB如 BERT 类 │ ├─ 要极致延迟/内存 → 离线 CBG int8 量化路线 A✓ 最简单 │ └─ int8 精度不达标 → fp16 CBG 或走路线 B └─ ONNX ≥ 2GB大语言模型 ├─ protobuf 直接报错 → 必须拆分图 ├─ 拆分后 → 在线构图 .so路线 B │ ├─ 含递推/大范围 exp 类算子 → 先做数值域分析必要时换等价形式 │ └─ 段间接口 → 警惕 spatial-first 重排用转置桥 ├─ NPU(CPU) 精度验证 → 单层探针先行别直接全链对拍 └─ GPU → 目前 SA8397 上定性不可用别浪费时间十、结语回顾整条路部署这件事可以浓缩成三句话转换是翻译不是压缩——翻译器有方言、有盲区每个算子都值得对拍精度是信任链——从 PyTorch 到板端每一步都要有金标准和门禁NaN 会撒谎cos 不会工程闭环在板端——权限、路径、内存、进程生命周期这些不是算法的问题恰恰占了排期的一半。三代模型走下来方法论已经收敛成一套可复现的流水线和门禁体系新模型上车先问三个问题——算子覆盖了吗数值范围安全吗金标准建好了吗三个都有答案路就通了。