7,720
社区成员
发帖
与我相关
我的任务
分享设备:FV01 开发板(高通 QCS8550,Ubuntu 22.04)|模型:Qwen3-Reranker-0.6B|精度:fp16
本文流程同样适用于犀牛派 X1 开发板。
代码与模型:https://github.com/zhouzengming/qwen3-reranker-qnn
做 RAG 离不开 reranker:向量检索召回几十条候选,再由 reranker 精排,把真正相关的几条交给大模型。
Qwen3-Reranker-0.6B 体积小、效果好、支持中英文,很适合端侧。
这次的目标是:把它部署到 FV01 开发板的 Hexagon NPU 上,支持 4096 token 的长上下文,并对外提供标准的 rerank 服务。
先看最终结果:
| 项目 | 结果 |
|---|---|
| 运行位置 | QCS8550 的 Hexagon v73 NPU(HTP),fp16 |
| 上下文长度 | 4096 token(含 prompt 模板) |
| 延迟 | 满长度时每个 query–文档对 4.56 秒 |
| 精度 | 与 fp32 PyTorch 的 logit 差值误差 ≤ 0.045,排序完全一致 |
| 服务 | FastAPI,兼容 Jina / Cohere 格式的 /v1/rerank,可接入 Dify 等应用 |
这条路走得并不顺。下面按时间顺序,把每个坑和解决办法都记下来。
高通 NPU 上跑模型,最主流的路线是 QNN(Qualcomm AI Engine Direct,现在属于 QAIRT SDK):
PyTorch 模型 ──导出──▶ ONNX ──Qualcomm AI Hub 编译──▶ QNN context binary ──板端 QNN 运行时──▶ NPU
Qwen3-Reranker 的打分原理很简单:它本质上是一个语言模型。把 query 和文档套进固定的 prompt 模板,看模型在最后一个位置输出 "yes" 和 "no" 的概率:
score = P(yes) = softmax([logit_no, logit_yes])[1]
所以导出时不需要完整的 15 万词表输出,只保留 lm_head 的 yes/no 两行,模型直接输出两个 logit 即可。
一开始先求稳:固定输入长度 512,把模型改写成静态形状。NPU 编译器只接受固定形状,所以输入统一左侧补齐到 512,attention mask 在图内生成。
导出 ONNX 后,用 onnxruntime 和官方 PyTorch 实现对比,误差在 1e-7 量级。接着在 AI Hub 上编译成 fp16,在 QCS8550 真机上实测:
按直觉,量化应该更快。我用 AI Hub 的量化功能做了 w8a16(权重 int8、激活 int16),用 400 条中英文检索数据校准,结果:
| fp16 | w8a16 | |
|---|---|---|
| 单次推理 | 201 ms | 210 ms |
| 与 fp32 的排序相关性(Spearman) | 0.9999 | 0.897 |
不但没变快,精度还明显下降,60 对正负样本里有 7 对排反了。在 v73 NPU 上,fp16 对这个模型是更好的选择,量化就此放弃。
fp16 下,P(yes) 接近 1 时精度不够。比如两篇都很相关的文档,fp32 下分数分别是 0.99738 和 0.99721,到了 fp16 都变成了 0.99561,打成平手。
解决办法是用 logit_yes - logit_no(下文称 margin)排序。它和 P(yes) 单调对应,但不会在 1 附近饱和。改用 margin 后,60 对样本的顺序 100% 正确。
512 token 对长文档不够用,于是目标定为 4096。这一步花的时间最多。
直接把长度改成 4096,AI Hub 编译能通过(link 就花了近 2 小时),但一上真机就报:
QNN_COMMON_ERROR_MEM_ALLOC: Memory allocation related error
算一下就明白了:一层注意力的分数矩阵是 16 头 × 4096 × 4096 × 2 字节 = 512 MB,加 mask、softmax 时还会同时存在好几个这么大的张量。
解决:按 query 分块计算注意力。 softmax 是按行做的,每个 query 的计算和其他 query 无关,所以可以把 4096 个 query 切成 8 块,每块 512 个,依次计算再拼起来,结果和原版完全一样。
再利用因果性优化一下:第 i 块 query 只能看到前 (i+1)×512 个 key,后面的直接不算。
for start in range(0, L, 512):
end = start + 512
w = q[:, :, start:end] @ k[:, :, :end].T * scale # 只取因果可见的 key
w = softmax(w + mask[:, :, start:end, :end])
out.append(w @ v[:, :, :end])
效果:最大的注意力张量从 512 MB 降到 64 MB,计算量还少了将近一半。
这个思路和 FlashAttention 类似,但只切 query 一个维度,不需要在线 softmax,改几行 PyTorch 代码就行,NPU 编译器可以直接处理。
分块后,28 层放在一张图里,link 阶段报了个很费解的错误:
graph_prepare.cc: Unable to find op in specified graph context
当时我判断是图太大、编译器处理不了,于是把 28 层切成 4 段,每段 7 层,各自编译成一个 context binary,推理时依次执行,段与段之间传递 hidden state。这也是高通部署大模型的常用做法。
(事后看,这个报错的真正原因是下面的第三道坎。不过分段让每个图更小、编译更快,也便于后面在板上管理内存,所以这个设计保留了下来。)
切段后,第 2、3、4 段顺利通过,唯独第 1 段报同样的错误。第 1 段和其他段的唯一区别是:它包含 embedding 查表(输入是 token id,而不是 hidden state)。
为了确认,我用只有 1 层的小模型做了对照实验:只要图里有 4096 长度的 embedding 查表,link 基本都会失败(前后 7 次尝试失败了 6 次);去掉 embedding 后,4 次全部成功。
解决:把 embedding 查表挪到 CPU 上。 词表存成 fp16 的 .npy 文件(约 310 MB),用 mmap 加载,推理时按 token id 取出对应的行,交给第 1 段。这只是一次数组索引,耗时可以忽略。
至此,4096 token 的方案定型:
文本 ─分词/拼 prompt/补齐─▶ token id ─CPU 查 embedding─▶ hidden
─▶ 第1段(7层) ─▶ 第2段 ─▶ 第3段 ─▶ 第4段+输出头 ─▶ logit_yes − logit_no
(NPU,4 个 QNN context binary)
在 AI Hub 真机上测试:每段约 1.1–1.2 秒,合计约 4.56 秒。再在真机上把 4 段接力跑一遍,8 条样本(171–4096 token)和 fp32 的 margin 平均误差 0.019,正负样本顺序全部一致。
AI Hub 上一切正常,不代表自己的板子上也能直接跑。我用 C++ 基于 QNN C API 写了一个轻量运行时(通过 ctypes 给 Python 调用),在 FV01 上一运行,又连续遇到三个问题。
Unsupported SoC model: 66Dsp startup: Unsupported SoC model (SnapdragonModel): 66
QNN 会从 /sys/devices/soc0/soc_id 读取芯片型号。FV01 的 soc_id 是 603,对应 QNN 里的 QCS8550(编号 66)。
QAIRT SDK 里有好几套 aarch64 Linux 运行库,我一开始为了兼容老版本 Ubuntu,选了 aarch64-oe-linux-gcc9.3,而这套库不支持 QCS8550。
查 SDK 文档里的支持设备表才发现:QCS8550 在 Linux 上只有 aarch64-oe-linux-gcc11.2 这一套库支持(要求 glibc ≥ 2.34,Ubuntu 22.04 正好满足)。换库后问题解决。
顺带一提:AI Hub 上的 "QCS8550 (Proxy)" 实际是按 SM8550(同样是 v73 NPU)编译的,生成的 context binary 在 QCS8550 上可以正常加载。
换好库,又报:
createUnsignedPD unsigned PD or DSPRPC_GET_DSP_INFO not supported by HTP
openSessionForPriority failed ... status=0x00000200
看起来像是"不支持 unsigned PD",但用 root 跑就没问题。用 SDK 自带的 qnn-platform-validator 验证:root 下通过,普通用户下失败。
原因是 CPU 和 NPU 通信靠 FastRPC 设备 /dev/adsprpc-smd,它的权限是 system:system 660,普通用户不在 system 组。
sudo usermod -aG system $USER # 然后重新登录
权限解决后,加载到第 4 段时报:
Failed to find available PD ... context size estimate 3876008960
在 L=4096 下,每段 context 约需 1 GB 的 NPU 内存,其中约 400 MB 是临时缓冲区(spill-fill)。4 段加起来超出了单个进程域(PD)的容量。我先后试了几种方案:
| 方案 | 结果 |
|---|---|
| 把 4 个图 link 进一个 context 文件 | ❌ 临时内存并不共享,预估 3.9 GB,还是放不下 |
Genie 用的 createFromBinaryListAsync + 共享资源 | ❌ QCS8550 的 Linux 后端不支持 |
4 个独立 context 用 REGISTER_MULTI_CONTEXTS 分组加载 | ✅ 加载成功,约 4 秒 |
最后一种方案下,QNN 日志显示第 4 段被自动放进了第二个进程域(pdId 2),执行时没有可测量的额外开销。
$ python3 run_reranker.py selftest
tokens ref_margin npu_margin diff query
85 7.5970 7.5781 0.0189 What is the capital of China?
90 -10.3703 -10.3730 0.0027 What is the capital of China?
104 7.3564 7.3828 0.0264 Explain gravity
86 6.3610 6.3438 0.0173 中国的首都是哪里?
86 -10.7718 -10.7266 0.0453 中国的首都是哪里?
1759 5.8126 5.7969 0.0158 ppt策划案演讲
max |diff| = 0.0453 (tolerance 0.15) -> PASS
$ python3 run_reranker.py bench -n 5
end-to-end (incl. host embedding/copies): median 4563.6 ms
part1 execute: median 1107.1 ms
part2 execute: median 1115.0 ms
part3 execute: median 1122.9 ms
part4 execute: median 1205.7 ms
精度和速度都和 AI Hub 真机的测量结果吻合,CPU 侧的额外开销只有约 13 ms。
模型跑通后,还要让应用方便地调用。OpenAI 官方 API 里其实没有 rerank 接口,业界通用的是 **Jina / Cohere 格式的 /v1/rerank**,vLLM、Xinference 都采用这种格式,Dify、FastGPT 等应用也都支持。
我用 FastAPI 实现了这个服务,有几个设计要点:
/health 仍然 30 ms 左右就能返回;relevance_score,兼顾兼容性和排序正确性。$ curl http://127.0.0.1:8000/v1/rerank -H "Authorization: Bearer sk-xxx" -H "Content-Type: application/json" \
-d '{"query": "中国的首都是哪里?", "documents": ["北京是中华人民共和国的首都。", "今天天气很好。"]}'
{"results": [{"index": 0, "relevance_score": 0.9982, "margin": 6.3438, ...},
{"index": 1, "relevance_score": 0.0000128, "margin": -11.2686, ...}],
"usage": {"total_tokens": 168}, "meta": {"elapsed_s": 8.846, "documents": 2}}
aarch64-oe-linux-gcc11.2 这套 QNN 库。system 组才能访问 NPU。项目已经开源,Release 页面提供了编译好的模型,不需要 AI Hub 账号。
在 PC 上组装部署包:
git clone https://github.com/zhouzengming/qwen3-reranker-qnn.git && cd qwen3-reranker-qnn
# 1. 从高通官网下载 QAIRT SDK 2.50.0,解压到 qairt/ 目录(见 qairt/README.md)
# 2. 从 Releases 页面下载模型包,然后:
sha256sum -c qwen3-reranker-0.6b-qcs8550-L4096-fp16.tar.sha256
python3 export/make_deploy.py --prebuilt qwen3-reranker-0.6b-qcs8550-L4096-fp16.tar \
--qairt-sdk qairt/<SDK 目录> --out dist/deploy
在开发板上运行(把 dist/deploy 拷到板子上):
sudo usermod -aG system $USER # 首次使用:获取 NPU 访问权限,然后重新登录
cd deploy
pip install -r requirements-device.txt
source setup_env.sh
python3 run_reranker.py selftest # 看到 PASS 就说明部署成功
python3 serve_reranker.py --host 0.0.0.0 --port 8000 --api-key sk-你的密钥
如果想自己从头编译(比如换一个上下文长度),执行 bash scripts/run_pipeline.sh 即可走完"导出 → 验证 → AI Hub 编译 → 打包"的全流程。
遇到问题可以先运行 bash tools/diagnose_npu.sh,再对照仓库 docs/NOTES.md 里的排错表排查。
项目地址:https://github.com/zhouzengming/qwen3-reranker-qnn
模型下载:https://github.com/zhouzengming/qwen3-reranker-qnn/releases/tag/v1.0.0