手机是 Redmi K90 Pro Max(骁龙 8 Elite Gen 5,SM8850),日常在 Termux + proot Debian 里跑 llama.cpp 当本地助手。CPU 推理大概 20 tok/s,能接受,但总觉得”这块 SoC 明明有 NPU 和很强的 GPU,为什么用不上”。
于是花了一整晚,把三条路都试了一遍:Hexagon NPU、Adreno OpenCL、Adreno Vulkan。结论先说:三条路都撞墙了,最后回退 CPU。但这几个墙的性质完全不同,值得记下来。
目录
- 起点:这块 SoC 到底有什么
- NPU:编译成功了,被 SELinux 拒之门外
- OpenCL:被 Android 的 linker namespace 挡住
- Vulkan:能跑,但 prefill 打不过 CPU
- 结论:为什么最终回到 CPU
- 附:实测数据与复现命令
1. 起点:这块 SoC 到底有什么
先把硬件摸清楚,避免瞎猜:
| 项目 | 值 |
|---|---|
| SoC | SM8850(骁龙 8 Elite Gen 5),ro.board.platform=canoe |
| NPU | Hexagon,HTP 架构 v81 |
| GPU | Adreno 840,ro.hardware.vulkan=adreno |
| 系统 | Android 17,无 root |
| 内存 | 16 GB LPDDR5X,CPU 与 GPU 共享 |
系统里已经有完整的 DSP 运行时:
/vendor/lib64/libcdsprpc.so
/vendor/lib64/libadsprpc.so
/vendor/lib64/libSNPE.so
/vendor/lib/rfsa/adsp/libSnpeHtpV81Skel.so
/vendor/lib64/hw/vulkan.adreno.so
看起来什么都不缺。而 llama.cpp 上游其实自带 Hexagon 后端(ggml/src/ggml-hexagon/)和 Snapdragon 的构建文档(docs/backend/snapdragon/),官方示例在 HTP 上跑 Llama-3.2-1B 能到 51 tok/s。所以”理论上可行”不是错觉。
2. NPU:编译成功了,被 SELinux 拒之门外
2.1 交叉编译链路
Hexagon 后端的编译需要 Qualcomm 提供的工具链镜像(内含 Android NDK + OpenCL SDK + Hexagon SDK 6.6),因为 DSP 侧的 skel 二进制必须用 x86_64 的 hexagon-clang 交叉编译——手机本体是 ARM,跑不了这套工具链。
在电脑上用官方流程:
git clone https://github.com/ggml-org/llama.cpp
cd llama.cpp
# 一条命令:拉镜像 → 编译 → adb push 到手机
./scripts/snapdragon/build.py --target adb --push
编译产物里能看到:
libggml-hexagon.so
libggml-htp-v73.so
libggml-htp-v75.so
libggml-htp-v79.so
libggml-htp-v81.so ← 对应 SM8850
v81 正好匹配这台机器,交叉编译这步是成功的。
2.2 三个证据,定位到 SELinux
把包放到手机上运行,第一步就失败:
E ggml-hex: failed to load libcdsprpc.so: dlopen failed: library "libcdsprpc.so" not found
补上库路径后,问题变成设备打不开。逐层排查:
证据一:设备节点存在,但打不开
$ open("/dev/fastrpc-cdsp") # Python 实测
PermissionError: [Errno 13] Permission denied
$ stat /dev/fastrpc-cdsp
stat: cannot statx '/dev/fastrpc-cdsp': Permission denied
注意是 EACCES 而不是 ENOENT——节点在,权限不够。
证据二:不是所有设备都被拒
同一进程里对比:
$ head -c 1 /dev/kgsl-3d0 >/dev/null && echo ok
ok # GPU 节点可以访问
$ head -c 1 /dev/fastrpc-cdsp >/dev/null
Permission denied # NPU 通道被拒
/dev/null、/dev/urandom 也都正常。所以不是 /dev 目录的问题,是特定节点的策略限制。
证据三:进程的 SELinux 域
$ cat /proc/self/attr/current
u:r:untrusted_app_27:s0:c144,c257,c512,c768
$ ls -Z /dev/kgsl-3d0
u:object_r:gpu_device:s0 /dev/kgsl-3d0
关键在 untrusted_app_27 这个域名——后缀 _27 表示 targetSdk ≤ 27 的旧应用域,Android 对这类域有更严格的 vendor 设备访问限制。Termux 的 targetSdk 是 28,正好落在这一档。
2.3 为什么换容器没用
一开始我以为把 Termux 换到 proot 容器里会不一样,实测发现:
proot 启动参数里有 --bind=/dev → 容器的 /dev 就是宿主 /dev
容器内 open("/dev/fastrpc-cdsp") → 一样 EACCES
SELinux 域由 app 身份决定,不由容器决定。 Termux 原生 shell、proot Debian、甚至 adb 推上去的二进制,只要还是这个 app 的进程,标签都一样。
那 localdream 这类应用为什么能用 NPU?它们的 targetSdk 是新的,落在 untrusted_app(无 _27 后缀)域,策略允许访问 fastrpc 设备。这是 app 身份带来的差异,Termux 改不了。
2.4 重打包 Termux 可行吗
想过把 Termux 的 targetSdk 改成 34 重新签名打包。评估下来不可行:
- targetSdk ≥ 29 会触发 W^X:Android 10+ 禁止应用从可写目录执行代码,而 Termux 的全部功能就是”在可写目录跑二进制”——这一条直接判死刑
- scoped storage:改完之后
/sdcard/models这类路径也读不了 - 签名冲突:自签名 APK 无法覆盖安装官方 Termux,必须先卸载,
/data/data/com.termux整个家目录会被清空 - 不保证成功:即使改完,
untrusted_app新域在这台 ROM 上是否真的放行 fastrpc,没有证据
所以 NPU 这条路的结论是:非 root 环境下无解,不是配置问题,是安全模型问题。
顺带纠正一个流传很广的说法:「HTP 只支持 Q4_0」。翻源码
ggml/src/ggml-hexagon/ggml-hexagon.cpp的 MUL_MAT 分支,实际支持 Q4_0 / Q4_1 / Q8_0 / IQ4_NL / MXFP4 / Q4_K / Q5_K / Q6_K。官方示例清一色 Q4_0 只是基线模型。真正的硬限制是 HTP 单 session 约 3.5 GB 地址空间,以及 FLASH_ATTN 的 K/V 只接受 F16/Q8_0。
3. OpenCL:被 Android 的 linker namespace 挡住
NPU 走不通,转 GPU。Android 上的 OpenCL 实现来自 GPU 驱动 /vendor/lib64/libOpenCL.so,llama.cpp 也有 ggml-opencl 后端,看起来顺理成章。
3.1 后端根本没注册
先编译(-DGGML_OPENCL=ON),产物里有 libggml-opencl.so。但运行:
$ ./bin/llama-bench -m model.gguf -ngl 99 -dev GPUOpenCL
error: invalid device: GPUOpenCL
$ ./bin/llama-bench -m model.gguf -ngl 99
| ... | backend | CPU | ← -ngl 完全没生效
查符号:
$ readelf -Ws lib/libggml-opencl.so | grep ggml_backend_init
(无输出)
$ nm -D lib/libggml.so | grep -c opencl
0
既没有作为独立后端模块导出 ggml_backend_init,也没有静态链接进主库。这个 .so 是个编出来但接不上的产物。
3.2 就算编对了,还有第二堵墙
手动指定后端路径加载时,终于走到了真正的障碍:
$ dlopen("/vendor/lib64/libOpenCL.so")
WARNING: linker: library "libOpenCL.so" ... is not accessible for the namespace:
[name="(default)", permitted_paths="/system/lib64:...:/data:..."]
permitted_paths 里没有 /vendor/lib64。这是 Android 10+ 的 linker namespace 机制:普通 app 的默认命名空间只允许访问 /system、/data 等路径,vendor 库属于 vendor namespace。
试过两个绕法:
| 做法 | 结果 |
|---|---|
软链 /vendor/lib64/libOpenCL.so 到 lib 目录 |
仍然是 not accessible for the namespace(解析后还是 vendor 路径) |
复制真实文件到 /data 目录 |
dlopen 成功,但报 failed to find ggml_backend_init,且库内部还要加载 /vendor/lib64/libOpenCL_adreno.so,再次被拦 |
所以 OpenCL 在这台机器上对普通 app 基本是不可用的——不是性能问题,是加载不进来。
3.3 那 Vulkan 为什么可以
登录同一个 linker 配置就能看到答案:
permitted_paths="...:/system/lib64/vulkan:/product/lib64/vulkan:..."
Vulkan 的 loader 在 /system/lib64/libvulkan.so,属于系统公共库,在允许列表里;Adreno 驱动由 loader 自己去 /vendor/lib64/hw/ 找,不需要 app 直接接触 vendor 路径。
4. Vulkan:能跑,但 prefill 打不过 CPU
4.1 编译与识别
用 Android NDK r28b + Vulkan SDK(需要 glslc)交叉编译:
cmake -B build-vulkan -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=$NDK/build/cmake/android.toolchain.cmake \
-DANDROID_ABI=arm64-v8a \
-DANDROID_PLATFORM=android-28 \
-DGGML_VULKAN=ON -DGGML_OPENCL=OFF -DGGML_HEXAGON=OFF \
-DVULKAN_SDK=$VULKAN_SDK
设备识别一次成功,不需要任何环境变量:
Available devices:
Vulkan0: Adreno (TM) 840 (19196 MiB, 19196 MiB free)
驱动自报的能力:
uma: 1 | fp16: 1 | bf16: 0 | fp4: 0 | warp size: 64
shared memory: 32768 | int dot: 1 | matrix cores: none
4.2 实测数据
用 llama-bench 跑同机同模型对照:
| 模型 | 后端 | prefill | decode |
|---|---|---|---|
| Spark-1.7B Q8_0 | CPU(8 线程) | 93.2 t/s | 17.7 t/s |
| Spark-1.7B Q8_0 | Vulkan | 9.7 t/s | 22.0 t/s |
| Spark-4B Q4_K_M | CPU(8 线程) | 30.8 t/s | 9.9 t/s |
| Spark-4B Q4_K_M | Vulkan | 11.3 t/s | 16.2 t/s |
规律很清楚:
- decode(逐 token 生成):GPU 快 1.2~1.6 倍,模型越大优势越明显
- prefill(处理 prompt):GPU 慢 3~10 倍
这和”GPU 算力更强”的直觉是反的。prefill 是大 batch 矩阵乘、典型 compute-bound 场景,本该是 GPU 的主场。
4.3 为什么 prefill 反了
三个原因叠加:
matrix cores: none:Adreno 的矩阵加速单元没有被 Vulkan 后端利用。llama.cpp 的 Vulkan shader 是通用实现,对移动 GPU 的张量单元没有专门适配。- shader 效率:移动 GPU 的 warp size 是 64(桌面是 32),llama.cpp 针对 Adreno 的调优一直在补——上游近期还有 PR 专门「cap scalar flash attention at 8 rows for large heads on Qualcomm Adreno GPUs」,说明这块仍在打补丁。
- kernel launch 开销:prefill 的 batch 不大时,调度开销占比高,GPU 的占用率上不去。
而 decode 之所以只快一点,是因为它本质是 memory-bound:每生成一个 token 要把全部权重读一遍。1.7B Q8_0 约 1.7 GB,LPDDR5X 带宽共享的情况下,CPU 和 GPU 面对的是同一条带宽,GPU 的算力优势用不上。
4.4 更麻烦的是不稳定
| 场景 | 结果 |
|---|---|
1.7B,-p 32 -n 8 |
正常 |
1.7B,-p 64 -n 16 |
Segmentation fault |
4B,-p 128 配 -ub 64/128/256 |
全部失败 |
小 batch 能跑,一旦 batch 变大就崩。drivers 或 shader 在大 batch 下不稳,这意味着它连”稳定可用”都算不上。
5. 结论:为什么最终回到 CPU
把三条路的结论放在一起:
| 路线 | 状态 | 卡点 |
|---|---|---|
| Hexagon NPU | 不可用 | SELinux untrusted_app_27 域拒绝 fastrpc 设备 |
| Adreno OpenCL | 不可用 | 后端未注册 + linker namespace 禁止加载 vendor 库 |
| Adreno Vulkan | 可用但无收益 | prefill 慢 3~10 倍,大 batch 崩溃 |
关键判断是:聊天场景看的是 prefill。日常对话的 prompt 往往几百到几千 token(系统提示 + 历史),prefill 慢 10 倍会直接变成”首字等很久”,而 decode 快的那 1.2 倍根本补不回来。
GPU 真正的优势场景是”短 prompt + 长输出”,比如让它写长文。但 llama.cpp 不支持把 prefill 留给 CPU、decode 交给 GPU 的分段方案,所以这个优势实际上没法单独利用。
所以最后的决定很朴素:继续用 CPU,约 21 tok/s,稳定、可预测。
这次折腾唯一留下的可复用经验是:
- NPU 在非 root 的 Android 上基本是禁区,除非应用本身有对应的 SELinux 域
- OpenCL 在 Android 10+ 对普通 app 形同虚设,vendor 库不在命名空间里
- Vulkan 是唯一干净可用的 GPU 路径,但移动端的 llama.cpp Vulkan 后端还没到”能打”的程度
- “算力更强”不等于”更快”——LLM 推理的两阶段里,只有 prefill 吃算力,decode 吃带宽,而移动端两者共享内存
如果哪天 llama.cpp 的 Vulkan 后端能把 Adreno 的矩阵单元用起来,或者高通把 QNN 的运行时像 Vulkan 一样做成系统公共库,这件事才值得重来一次。
6. 附:实测数据与复现命令
复现环境:Termux + proot Debian,跑 Android AArch64 二进制;Vulkan 包用 NDK r28b + -DGGML_VULKAN=ON 编译。
# 设备识别
LD_LIBRARY_PATH=$PWD/lib ./bin/llama-cli --list-devices
# 基准(Vulkan)
LD_LIBRARY_PATH=$PWD/lib ./bin/llama-bench \
-m /sdcard/models/Spark-X2.6-1.7B-Q8_0.gguf -p 32 -n 32 -r 1 -ngl 99
# 基准(CPU,用纯 CPU 包,Vulkan 存在时默认会走 Vulkan)
LD_LIBRARY_PATH=$PWD/lib ./bin/llama-bench \
-m /sdcard/models/Spark-X2.6-1.7B-Q8_0.gguf -p 32 -n 32 -r 1
CPU 单次生成的实测参考:Spark-1.7B Q8_0 在 8 线程下约 17.7 tok/s, 配合 512 token 思考预算,端到端首字延迟约 2~3 秒——这是目前日常使用的配置。
后记:写这篇不是为了证明”手机不能跑大模型”,恰恰相反,20 tok/s 的 CPU 推理已经够日常用。想记下来的是:当”理论上更快”撞上”实际跑不动”时,中间隔着的往往不是性能,而是安全模型、ABI 边界和驱动生态。这三样东西,比调参难解决得多。