从一次链接报错开始,到 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_FP16FLASH_ATTN 等算子全部落在 cDSP 上。

勘误(2026-08-09 更新):本文初版中的 Hexagon 性能数据(prefill 185 t/s、decode 11.6 t/s)实际是  CPU 回退的结果——当时  build.sh 漏了  -DMNN_HEXAGON=ONlibMNN.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/s4B 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。

由此产生三条铁律,后文所有坑都源于此:

  1. DSP so 必须用 Hexagon SDK 单独编译(htp-ops-lib/build.sh <DSP_ARCH>),且 DSP_ARCH 必须和设备匹配
  2. skel 的查找路径通过进程环境变量 ADSP_LIBRARY_PATH 传递,设置时机和方式都有讲究;
  3. 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.cmakeANDROID_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 还没 plantTimberConfig.initialize() 在 onCreate 更靠后的位置),Timber.i() 全部静默丢弃。代码明明跑对了,logcat 却一片空白,一度怀疑 Hook 没生效。解法很朴素:这个类里直接用 android.util.Log

坑 7:设置页把 hexagon "洗"回 cpu

最隐蔽的一个。模型 config.json 明明写着 backend_type: "hexagon",App 的模型设置页却显示 CPU——而且只要用户在设置页点一次保存,推理就永久回落 CPU。

两层原因:

  1. 设置页的 backend 下拉硬编码 ["cpu", "opencl"]hexagon 不在列表里就显示回退成 cpu;
  2. 保存时把 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 模型,下载即用:

模型已按 MNN Hexagon 后端要求导出(4-bit 对称量化,config.json 的 backend_type 已设为 hexagon),无需本地再转换。装到手机分三步:

第 1 步:在 App 里设置模型存储位置

打开 MnnLlmChat → 设置 → 模型存储位置,指向一个你有写入权限的目录(如手机存储根目录下的 mnn-models)。后续模型就放在这个根目录下。

设置模型存储位置

第 2 步:将模型文件复制到模型根目录

把 HF 下载的整个模型目录(含 config.jsonllm.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:

后端PrefillDecode(App)
Hexagon cDSP222.88 t/s11.65 t/s
CPU306 t/s70 t/s

Qwen3-0.6B Hexagon cDSP 实测:prefill 222.88 t/s,decode 11.65 t/s

Qwen3-4B:

后端PrefillDecode(App)Decode(llm_bench tg128)
Hexagon cDSP24 tok / 0.50s = 48.27 t/s10 tok / 7.10s = 1.41 t/s1.35 t/s(llm_bench)
CPU24 tok / 0.54s = 44.5 t/s10 tok / 0.61s = 16.4 t/s17.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):

模型后端PrefillDecode(App)
0.6BHexagon DSP222.88 t/s11.65 t/s
0.6BCPU306 t/s70 t/s
4BHexagon DSP48.27 t/s1.41 t/s
4BCPU44.5 t/s16.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/s12.8~20.7 t/s
Decode(0.6B)11.65 t/s32 t/s
KV cacheCPU 侧管理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*

更多推荐