骁龙手机端侧大模型低功耗推理:MNN Hexagon cDSP 后端实录
从一次链接报错开始,到 logcat 里打出完整的
Hexagon DSP Profile——这是一篇关于把 LLM 推理搬上 Hexagon cDSP(v81)的完整踩坑记录,涵盖 DSP 算子库编译、FastRPC 部署模型、Android App 集成,以及 6 个文档里不会告诉你的坑。
为什么做这件事
MNN 最近的提交加入了 Hexagon 后端(source/backend/hexagon),支持把 LLM 推理 offload 到高通 Hexagon cDSP。手里的设备是 SM8850(骁龙 8 Elite Gen 5),Hexagon DSP 架构 v81——比上游主力开发的 v79 还新一代。
目标很朴素:让 Qwen3-0.6B 在这台手机的 cDSP 上跑起来,并且集成到 MnnLlmChat App 里。最终成绩(App 短对话实测):0.6B prefill 222.88 t/s、decode 11.65 t/s;4B prefill 48.27 t/s、decode 1.41 t/s,DSP Profile 里 MATMUL_Q4A16_BLOCK_FP16、FLASH_ATTN 等算子全部落在 cDSP 上。
勘误(2026-08-09 更新):本文初版中的 Hexagon 性能数据(prefill 185 t/s、decode 11.6 t/s)实际是 CPU 回退的结果——当时build.sh漏了-DMNN_HEXAGON=ON,libMNN.so里根本没有 hexagon 后端,logcat 里Can't Find type=10 backend, use 0 instead被忽略了。第二版修正为 0.6B prefill ~49.7 / decode ~1.5 t/s(App 口径),但该数据基于早期构建配置;后续在 pisces312/MNN fork 中持续优化 Hexagon 后端,当前版本 App 实测已达到 0.6B prefill 222.88 / decode 11.65 t/s、 4B prefill 48.27 / decode 1.41 t/s。下文所有性能数字均已替换为最新实测数据。
但过程远比 cmake -DMNN_HEXAGON=ON 复杂。这篇文章按真实发生的顺序记录。
先搞懂架构:两个 so 和一条 FastRPC 通道
MNN Hexagon 后端的部署模型和常规后端完全不同,理解它能省掉后面 80% 的困惑:
复制
App 进程 (AArch64) cDSP (Hexagon) ┌─────────────────────┐ ┌──────────────────────┐ │ libMNN.so │ │ libMNN_htpops_skel.so│ │ └ HexagonRuntime │ FastRPC │ (算子实现, HVX/HMX) │ │ └ dlopen ──────────┼──────────────┤ │ │ libMNN_htpops.so │ (adsprpc) │ │ │ (stub, 跑在 CPU) │ │ │ └─────────────────────┘ └──────────────────────┘
- `libMNN_htpops.so`:AArch64 的 FastRPC stub,跑在 CPU 上,负责把算子请求通过 FastRPC 发到 DSP。它由
HexagonRuntime以dlopen("libMNN_htpops.so")裸名加载。 - `libMNN_htpops_skel.so`:Q6DSP 指令集的 skeleton,跑在 cDSP 上,是真正的算子实现。它不被 Android linker 加载,而是 FastRPC 框架按
ADSP_LIBRARY_PATH环境变量找到并加载到 DSP。
由此产生三条铁律,后文所有坑都源于此:
- DSP so 必须用 Hexagon SDK 单独编译(
htp-ops-lib/build.sh <DSP_ARCH>),且 DSP_ARCH 必须和设备匹配; - skel 的查找路径通过进程环境变量
ADSP_LIBRARY_PATH传递,设置时机和方式都有讲究; - stub 是正常 Android 共享库,走 jniLibs 即可;skel 只是"恰好也打包进 jniLibs 借个目录"。
DSP 架构对照(依据 Qualcomm 官方文档):8 Gen 2 = v73,8 Gen 3 = v75,8 Elite = v79,8 Elite Gen 5 (SM8850) = v81。用错版本编出的 skel 在设备上根本无法加载。
第一战:编译 DSP 算子库(v81)
命令本身很简单:
复制
cd source/backend/hexagon/htp-ops-lib bash build.sh v81
然后立刻撞上两个失败。
坑 1:SDK 头文件的"定义式"写法导致重复符号
复制
ld.lld: error: duplicate symbol: is_CDSP >>> defined at domain_default.h:19 >>> dsp_capabilities_utils.c.o:(is_CDSP) >>> defined at domain_default.h:19 >>> session.c.o:(.text.is_CDSP+0x0)
Hexagon SDK 6.6 的 incs/domain_default.h 直接定义了全局数组 supported_domains[] 和函数 is_CDSP()——不是 extern 声明,不是 static,就是一个会随 include 复制到每个 TU 的定义。而 MNN 的 dsp_capabilities_utils.h 包含了这个头,于是 dsp_capabilities_utils.c 和 session.c 各揣了一份,链接时撞车。
修复思路是经典的"定义归一":头文件里只留声明所需的类型(domain 结构体本来就在 remote.h 里),把 domain_default.h 的包含挪到唯一的 .c 文件里:
复制
// dsp_capabilities_utils.h #include "remote.h" #include <stdbool.h> -#include "domain_default.h" // dsp_capabilities_utils.c #include "dsp_capabilities_utils.h" +#include "domain_default.h" // 全工程只此一个定义点
这类问题在升级 SDK 版本时很常见:旧 SDK 的头文件可能只是声明,新 SDK 改成了定义式,下游代码假设就崩了。
坑 2:脚本写死工具链版本号
skel 明明编译链接成功了,check_so_symbols.sh 却报错退出:
复制
Error: hexagon-nm tool not found at .../HEXAGON_Tools/19.0.04/Tools/bin/hexagon-nm
脚本里硬编码了 19.0.04,SDK 6.6 实际是 19.0.07。改成通配:
复制
NM_BIN=$(ls "${HEXAGON_SDK_ROOT}"/tools/HEXAGON_Tools/*/Tools/bin/hexagon-nm | head -n 1)
之后 bash build.sh v81 一次通过:libMNN_htpops.so(AArch64)+ libMNN_htpops_skel.so(QUALCOMM DSP6),符号校验通过。
第二战:MNN 主库与模型导出
MNN 主库编译本身不需要 Hexagon SDK(host 侧用的是仓库自带的 dsprpc_interface.h),只需要 -DMNN_HEXAGON=ON。真正要注意的是两个环境细节:
- WSL 默认环境没有
ANDROID_NDK变量,必须先export再 cmake; - 交叉编译用 NDK r27d 的
android.toolchain.cmake,ANDROID_ABI=arm64-v8a。
模型侧的限制是硬性的:只支持 4-bit 对称量化(非对称、其他 bit 数都不行),用 MNN 自带的 llmexport.py 一把梭:
复制
python llmexport.py --path /models/Qwen3-0.6B --export mnn \
--quant_bit 4 --quant_block 64 --sym \
--mnnconvert /path/to/MNNConvert \
--dst_path /models/qwen3_0_6b_hexagon
# 导出后把 config.json 的 "backend_type" 改为 "hexagon"
这里有个容易忽略的点:--mnnconvert 最好用同一个仓库源码树新鲜编译的 MNNConvert,而不是 PyPI 的 pymnn——Hexagon 支持是新代码,schema 版本不匹配会出灵异问题。
不想自己走导出流程?HuggingFace 上已有预量化、预配置(
backend_type=hexagon)的现成模型,见下文「模型获取与安装:从 HuggingFace 下载预置模型」。
第三战:设备验证——先命令行,再谈 App
把三个 so、llm_demo、模型目录推到 /data/local/tmp/MNN,设好两个环境变量跑起来:
复制
export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH export ADSP_LIBRARY_PATH="/data/local/tmp/MNN;/vendor/lib/rfsa/adsp;/system/lib/rfsa/adsp" ./llm_demo qwen3_0_6b_hexagon/config.json prompt.txt
ADSP_LIBRARY_PATH 用分号分隔(FastRPC 的约定,不是 Unix 的冒号),并且建议带上系统路径——某些设备缺了 /vendor/lib/rfsa/adsp 会导致系统 skel 找不到。
成功的标志不是"输出了文本"(那可能偷偷跑在 CPU 上),而是 logcat 里出现:
复制
I MNNJNI: Hexagon DSP Profile: I MNNJNI: DSPOpType FLASH_ATTN (18): 4098.797852 ms I MNNJNI: DSPOpType MATMUL_Q4A16_BLOCK_FP16 (34): 98564.882812 ms ... I llm_demo: remote_handle64_close: closed handle ... name libMNN_htpops_skel.so, refs 0
MATMUL_Q4A16_BLOCK_FP16 正是 4bit 对称量化的 matmul kernel——量化格式和 DSP 算子对上了。同时确认没有 qurt/sysfatal 日志和新的 cDSP tombstone(cDSP 崩溃可能让 adb 短暂掉线,排障时先看 /data/vendor/tombstones)。
第四战:App 集成,坑的密集区
MnnLlmChat 里已有 QNN 的集成(QnnModule.kt),照它的模式写了 HexagonModule,但仍有四个坑。
坑 4:环境变量不是你想设,想设就能设
最初的方案草稿里写着:
复制
Runtime.getRuntime().exec(arrayOf("export", "ADSP_LIBRARY_PATH=$dir")) // 无效!
exec 启动的是子进程,env 修改不会回流到 App 进程。正确做法是进程内 setenv:
复制
import android.system.Os
Os.setenv("ADSP_LIBRARY_PATH", "$nativeLibDir;/vendor/lib/rfsa/adsp;/system/lib/rfsa/adsp", true)
路径选择也有讲究:skel 不能放 /data/local/tmp(shell 私有,App 读不到)。最省事的方案是把 skel 也丢进 jniLibs/arm64-v8a/——它不会被 Android linker 加载,但 legacy 打包(useLegacyPackaging = true)会把它解压到 applicationInfo.nativeLibraryDir,这个目录全局可读,adsprpcd 也能访问,刚好做 ADSP_LIBRARY_PATH 的值。stub 放同目录,dlopen 裸名自然能搜到。
坑 5:APK 里的 libMNN.so 是旧的
重编了 libMNN.so,打包、安装、运行——hexagon 后端不存在。查了一圈发现:App 的 libMNN.so 不在 jniLibs,而是 CMake IMPORTED 自 project/android/build_64/lib/libMNN.so(某次 make install 留下的副本)。WSL 里 make 只更新 build_64/libMNN.so,不会碰 lib/ 子目录。手动同步一次即可,但不知道这个陷阱的人会在这里浪费一小时。
坑 6: Timber 还没醒,日志先"丢"了
HexagonModule.setup() 必须放在 Application.onCreate() 最前面——因为 CrashUtil.init() 的静态初始化会触发 System.loadLibrary("mnnllmapp"),连带加载 libMNN.so,而 FastRPC 初始化需要 ADSP_LIBRARY_PATH 已经就位。
但放在最前面意味着 Timber 还没 plant(TimberConfig.initialize() 在 onCreate 更靠后的位置),Timber.i() 全部静默丢弃。代码明明跑对了,logcat 却一片空白,一度怀疑 Hook 没生效。解法很朴素:这个类里直接用 android.util.Log。
坑 7:设置页把 hexagon "洗"回 cpu
最隐蔽的一个。模型 config.json 明明写着 backend_type: "hexagon",App 的模型设置页却显示 CPU——而且只要用户在设置页点一次保存,推理就永久回落 CPU。
两层原因:
- 设置页的 backend 下拉硬编码
["cpu", "opencl"],hexagon不在列表里就显示回退成 cpu; - 保存时把
backend_type: "cpu"写进custom_config.json,而配置合并逻辑里的PROTECTED_KEYS只挡空字符串覆盖,非空的 cpu 会理直气壮地盖掉 config.json 的 hexagon。
修复是让 backend 选项在设备具备 htp-ops 库时追加 hexagon:
复制
val backendOptions = buildList {
add("cpu"); add("opencl")
if (HexagonModule.isReady()) add("hexagon")
}
模型获取与安装:从 HuggingFace 下载预置模型
不想自己编译 App? 直接从 GitHub Releases 下载预编译的 APK,安装即可使用。APK 已内置 Hexagon 后端支持,无需从源码构建。
如果你不想自己走第二战的 llmexport.py 导出流程,我已经在 HuggingFace 上准备好了两个预量化、预配置好的 Hexagon 模型,下载即用:
- Qwen3-0.6B(4-bit):pisces312-hf/qwen3-0.6b-mnn-hexagon-4bit
- Qwen3-4B(4-bit):pisces312-hf/qwen3-4b-mnn-hexagon-4bit
模型已按 MNN Hexagon 后端要求导出(4-bit 对称量化,config.json 的 backend_type 已设为 hexagon),无需本地再转换。装到手机分三步:
第 1 步:在 App 里设置模型存储位置
打开 MnnLlmChat → 设置 → 模型存储位置,指向一个你有写入权限的目录(如手机存储根目录下的 mnn-models)。后续模型就放在这个根目录下。

设置模型存储位置
第 2 步:将模型文件复制到模型根目录
把 HF 下载的整个模型目录(含 config.json、llm.mnn 及权重文件)原样复制到上一步指定的模型根目录下。

将模型文件复制到模型根目录
第 3 步:在模型设置里选择 Hexagon 后端
在 App 的"我的模型"里找到该模型,进入模型设置,把后端从默认(或可能被回退成的 cpu)切到 hexagon。只要设备上 libMNN_htpops 库已就位(见第四战集成),选项里就会出现 hexagon,保存后即可在 cDSP 上推理。

在模型设置里选择 Hexagon 后端
验收:证据链闭环
App 集成的验证同样不看"能不能聊",而看证据链。绝对不能只看"输出了文本"——Hexagon 后端缺失时会静默回退到 CPU,数字会非常好看但跟 cDSP 没关系。
第一步:确认后端真的存在
先看 logcat 有没有这行:
复制
I MNNJNI: [MNN::Hexagon] Successfully loaded htp_rpc_execute_command_group at 0x... I MNNJNI: [MNN::Hexagon] vectorSize=64, vtcmSize=8388608, maxThreads=8 I ...: remote_handle64_open: opened handle ... libMNN_htpops_skel.so ... on domain 3
如果出现的是 Can't Find type=10 backend, use 0 instead,那就是在跑 CPU,别骗自己。初版文章的 "185 t/s prefill / 11.6 t/s decode" 就是这么来的——build.sh 漏了 -DMNN_HEXAGON=ON,libMNN.so 里根本没 hexagon。
第二步:看 DSP Profile(会话释放后)
DSP Profile 打在 ~HexagonRuntime() 里,所以要在会话释放(退出聊天页)后才能看到:
复制
I MNNJNI: Hexagon DSP Profile: I MNNJNI: DSPOpType MATMUL_Q4A16_BLOCK_FP16 (34): 7114.578 ms I MNNJNI: DSPOpType FLASH_ATTN (18): 112.891 ms I MNNJNI: DSPOpType ADD_FUSE_LAYERNORM (16): 13.664 ms I com.alibaba.mnnllm.android.debug: remote_handle64_close: ... name libMNN_htpops_skel.so
MATMUL_Q4A16_BLOCK_FP16 占绝对主导(94%+),这是 Q4 量化 matmul 在 DSP 上跑的铁证。
第三步:App 内真实性能
Qwen3-0.6B:
| 后端 | Prefill | Decode(App) |
|---|---|---|
| Hexagon cDSP | 222.88 t/s | 11.65 t/s |
| CPU | 306 t/s | 70 t/s |

Qwen3-0.6B Hexagon cDSP 实测:prefill 222.88 t/s,decode 11.65 t/s
Qwen3-4B:
| 后端 | Prefill | Decode(App) | Decode(llm_bench tg128) |
|---|---|---|---|
| Hexagon cDSP | 24 tok / 0.50s = 48.27 t/s | 10 tok / 7.10s = 1.41 t/s | 1.35 t/s(llm_bench) |
| CPU | 24 tok / 0.54s = 44.5 t/s | 10 tok / 0.61s = 16.4 t/s | 17.8 t/s |

Qwen3-4B Hexagon cDSP 实测:prefill 48.27 t/s,decode 1.41 t/s
Prefill 两者接近(cDSP 略快),但 decode cDSP 比 CPU 慢约 11.6 倍(App 口径;llm_bench 口径下 1.35 vs 17.8,约 13 倍)。为什么?继续往下看。
性能与结论
App 短对话实测(pisces312/MNN fork,SM8850 v81):
| 模型 | 后端 | Prefill | Decode(App) |
|---|---|---|---|
| 0.6B | Hexagon DSP | 222.88 t/s | 11.65 t/s |
| 0.6B | CPU | 306 t/s | 70 t/s |
| 4B | Hexagon DSP | 48.27 t/s | 1.41 t/s |
| 4B | CPU | 44.5 t/s | 16.4 t/s |
与 QNN HTP 的对比
QNN HTP 是高通官方 SDK 的专用 NPU 路线(HTP = Hexagon Tensor Processor,专用张量加速核),而 MNN Hexagon 后端跑在通用 cDSP(HVX 向量 DSP)上——两者物理上都在 Hexagon 处理器内,但硬件单元完全不同。
| 指标 | Hexagon cDSP(本文) | QNN HTP(专用 NPU) |
|---|---|---|
| Prefill(0.6B) | 222.88 t/s | 12.8~20.7 t/s |
| Decode(0.6B) | 11.65 t/s | 32 t/s |
| KV cache | CPU 侧管理 | HTP 图内管理 |
| 瓶颈 | DMA 带宽墙(~2.8GB/s,固件锁定) | 专用 tensor 单元,不受限 |
Hexagon cDSP 的 prefill 碾压 QNN(通用 DSP 对大 batch GEMM 友好),但 decode 受固件 DMA 带宽墙(~2.8GB/s)限制,仅为 QNN HTP 的 1/3。llm_bench pp512 口径下差距更大:0.6B cDSP 2114 t/s vs QNN 未测,CPU 140 t/s。
QNN HTP 离线模型的完整落地记录见姊妹篇《从 1.5 到 32:MNN QNN 离线模型让骁龙 NPU 真正跑起大模型》。
结论
- cDSP prefill 是真实优势:0.6B pp512 = 2114 t/s(CPU 的 15 倍),长上下文首轮、RAG 等场景红利显著。
- 0.6B decode 已可用:11.65 t/s ≈ 每秒 7~8 个中文字;4B 受 DMA 带宽墙约束仅 1.41 t/s,改善空间有限。
- 需要 decode 高吞吐 → 走 QNN HTP:32 t/s,专用 NPU 的核心价值是能效与不占 CPU。
- 排查过程:exp1~exp4 + getDiag 诊断排除了所有代码侧因素,带宽墙在固件层。详见
mnn-hexagon-npu-decode-investigation.md。
---
*环境:MNN(2026-07~08 master)/ Hexagon SDK 6.6.0.0 / NDK r27d / SM8850(骁龙 8 Elite Gen 5,cDSP v81)/ Android 16*
更多推荐
所有评论(0)