☰
LLM端侧部署-把大模型塞进SA8397车机 NPU
2026/10/10 2:55:01 网站建设 项目流程

一篇讲清"训练好的模型是怎么变成车机芯片上每秒上千次推理的二进制"的入门长文 —— 整体流程 · 架构 · 原理 · 概念 · 实现细节

在服务器上训练好的模型,车机芯片根本"看不懂"。它需要经过一次"翻译+重新排版"——这就是 QNN 编译链。本文用三代真实模型(一个 38MB 的 BERT、一个 3GB 的 GLM、一个 0.8B 的 Qwen)把这条路的每一步、每个坑都摊开讲。

平台:SA8397 车机(Hexagon NPU v81) 工具:Qualcomm QNN SDK

Qualcomm QNN SDK官方下载地址:Qualcomm® Software Center

〇、先建立直觉:芯片里的三个"工人"

车机 SoC(SA8397)里能干数学活的硬件有三块,性格完全不同:

硬件类比擅长短板
CPU(Kryo)老教授:什么题都会做通用、灵活、精度可控(fp32 逐位可复现)慢。GLM 一句话推理 12.2 秒
GPU(Adreno)一百个中学生:人多手快并行吞吐高(GLM 只需 4 秒)算得糙:fp32 被静默降精度,输出"看着像、实际错"
NPU(Hexagon HTP)专业计算器:只会固定套路极快极省电:GLM 380ms、BERT 0.85ms挑食:只认特定格式的"菜谱"(我们踩过的坑全在这)

部署的本质:把训练框架(PyTorch)里的模型,翻译成 NPU 能执行的私有格式,并保证翻译后的数值仍然对。这个翻译流水线,就是QNN(Qualcomm Neural Network)工具链。

一、整体流程:一次部署的完整旅程

【训练侧 · 服务器】 PyTorch 模型 (fp32, 3GB) │ ① torch.onnx.export —— "把模型写成通用交换格式 ONNX" ▼ ONNX 文件 ──────────────── ② (可选) 拆分图:>2GB 必须切成几段 │ │ ③ qnn-onnx-converter ── "翻译":ONNX 算子 → QNN 算子 │ ├─ fp32 直接翻译(CPU/GPU 路线) │ ├─ --float_bitwidth 16(NPU 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 三个必须知道的名字

  • HTP(Hexagon Tensor Processor):高通对 NPU 的内部叫法。SA8397 的 HTP 是v81 代——所以你会看到 libQnnHtpV81Skel.so 这样的文件。
  • fastRPC:CPU 和 DSP 之间的远程调用机制。CPU 把输入数据放进共享内存(dma_heap),"喊" DSP 来算,算完取回。所有板端玄学问题一半出在"内存权限"上。
  • skel(Skeleton):DSP 侧的"骨架程序"。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) × scale
  • scale/offset 从哪来?——标定(calibration):拿几百条真实输入跑一遍 fp32 模型,统计每层激活值的实际范围(min~max),据此算出每层的 scale/offset。所以 int8 量化必须带标定数据,fp16 一般不用。
  • 代价:4 字节变 1 字节 = 体积缩 4 倍(BERT:151.7MB fp32 → 36.5MB int8 ctx)、速度快数倍;风险:范围挤压导致的"类坍缩"——我们第一代 BERT(bd4)有 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.编译 —— "把权重变成 .so"

converter 产出两个文件:.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/int8(int8 输出是 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 开销)。
教训:跨段接口必须显式校验布局,不能信"能跑"。

坑 2|GPU 的"认真造假"

GPU 后端 fp16 全 NaN;fp32 跑通但分类结果错(argmax=10,正确是 8)。单层探针证明 LN、RoPE 全对,唯独scores→softmax 这一步 cos 掉到 0.91;同一个 .so 放 CPU 上 cos=1.0。我们做了三向对照实验:默认配置、强制 FP32、HYBRID——强制 FP32 的输出与默认md5 逐位一致,即精度开关被后端静默忽略。结论:Adreno GPU 后端无任何官方途径获得真 fp32 计算,定性不可交付,证据链已固化(可提 Qualcomm 工单)。
教训:跑通 ≠ 可用。没有精度对拍,GPU 的错误会一路绿灯。

坑 3|Qwen 线性注意力:数学形式决定生死

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。结果:单层 cos=0.999998、双层 fp16 cos=0.999679,0 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 + margin(GLM 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~200ms(JIT)0.85ms/句(1176 句/s)RSS 14.6MB + dmabuf 85MB
GLM 1.5B fp16拆分 .so ×2在线构图53s + 72s(一次性)380ms/句dmabuf 为主,CMA 0
Qwen3.5-0.8B分层 .so(规划)在线构图×N构图一次目标:秒级/句待测

三个决定性因素:① 量化档位(int8 比 fp16 快且省 4 倍内存,但精度需验证);② 常驻进程(构图是一次性成本,服务化后单句延迟才是真实体验);③ batch(拆分段后单句串行,GLM 的 380ms 是 A+B 两段串行之和)。

九、路线选择指南(决策树)

你的模型多大? ├─ ONNX < 2GB(如 BERT 类) │ ├─ 要极致延迟/内存 → 离线 CBG + int8 量化(路线 A)✓ 最简单 │ └─ int8 精度不达标 → fp16 CBG 或走路线 B └─ ONNX ≥ 2GB(大语言模型) ├─ protobuf 直接报错 → 必须拆分图 ├─ 拆分后 → 在线构图 .so(路线 B) │ ├─ 含递推/大范围 exp 类算子 → 先做数值域分析,必要时换等价形式 │ └─ 段间接口 → 警惕 spatial-first 重排,用转置桥 ├─ NPU(CPU) 精度验证 → 单层探针先行,别直接全链对拍 └─ GPU → 目前 SA8397 上定性不可用,别浪费时间

十、结语

回顾整条路,"部署"这件事可以浓缩成三句话:

  1. 转换是翻译,不是压缩——翻译器有方言、有盲区,每个算子都值得对拍;
  2. 精度是信任链——从 PyTorch 到板端每一步都要有金标准和门禁,NaN 会撒谎,cos 不会;
  3. 工程闭环在板端——权限、路径、内存、进程生命周期,这些"不是算法的问题"恰恰占了排期的一半。

三代模型走下来,方法论已经收敛成一套可复现的流水线和门禁体系:新模型上车,先问三个问题——算子覆盖了吗?数值范围安全吗?金标准建好了吗?三个都有答案,路就通了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询