一个「不换小模型、不刷回MIUI」的偏执狂的自救记录
一、起因
我在手机上用 Termux 部署了 llama.cpp,跑 Qwen3.5-2B 模型,纯 CPU 模式 8~9 t/s。
能用,是能用。但每次看着 Adreno 650 这个曾经的旗舰 GPU 在后台闲着,心里就痒痒。
明明有个 GPU 在这,为什么不让它干活?于是开始了为期三天的 GPU 加速摸索之路。
二、第一个知识点:Vulkan vs Turnip vs Adreno 原厂驱动
在正式开始之前,必须先搞清楚这三个概念。它是三个完全不同的层次:
┌──────────────────────────────────────────────┐
│ 应用层 — llama.cpp │
│ ↓ 调用 Vulkan API │
├──────────────────────────────────────────────┤
│ Vulkan 标准接口 ← 类似 USB-C 标准 │
├──────────────────────────────────────────────┤
│ 驱动实现层 │
│ ┌─────────────────┐ ┌──────────────────┐ │
│ │ Turnip (开源) │ │ Adreno 原厂驱动 │ │
│ │ 社区逆向工程 │ │ Qualcomm 官方 │ │
│ │ 功能有缺失 │ │ 功能完整 │ │
│ └─────────────────┘ └──────────────────┘ │
├──────────────────────────────────────────────┤
│ 硬件层 — Adreno 650 GPU │
└──────────────────────────────────────────────┘

Vulkan 是一个 GPU 编程接口标准,由 Khronos 组织定义。它只规定「怎么做」,不负责「具体实现」。类似的概念还有 OpenGL(老标准)、DirectX 12(Windows 专属)、Metal(苹果专属)。
Turnip 是 Linux 开源社区逆向工程出来的 Adreno 驱动。名字本身就是个彩蛋:A-d-r-e-n-o → T-u-r-n-i-p(把字母重排就成了「萝卜」)。因为 Qualcomm 不给开源社区提供官方文档,开发者只能通过黑盒测试来写这个驱动,有些高级功能自然就没法实现。
Adreno 原厂驱动 是 Qualcomm 自己的闭源驱动,随 MIUI 等官方 ROM 一起发布,功能完整。但问题是,它和特定的内核版本强绑定。
用一个类比来理解:
| 概念 | 类比 |
|---|---|
| Vulkan | 普通话标准(规定了怎么说) |
| Turnip | 带口音的方言版(能交流,但有些词不会) |
| 原厂驱动 | 标准普通话版(完整功能) |
三、第一回合:升级 Turnip 驱动
当前 Termux 里 Mesa 版本是 26.0.6。我搜到了一个专门为 Android 编译 Mesa 的项目——lfdevs/mesa-for-android-container,里面打包了最新的 26.2.0-devel 版 Turnip 驱动。
但实际操作发现:这个项目编译的驱动依赖 glibc,而 Termux 用的是 Android 的 bionic libc,不能直接替换。于是绕了个弯——在 Termux 里通过 proot-distro 装了一个 Ubuntu 24.04 容器,在容器内安装新版 Mesa,再用容器内的 vulkaninfo 验证。
结果对比:
旧版 Turnip 26.0.6
storageBuffer8BitAccess = false ← ❌ 不支持
新版 Turnip 26.1.99 (基于 26.2.0-devel)
storageBuffer8BitAccess = false ← ❌ 还是不支持
- 换了也没用。
+ Adreno 650(6XX 系列)在 Turnip 上就是不支持这个功能。
+ 不是版本问题,是该 GPU 在 Turnip 驱动上的功能覆盖盲区。
四、第二个知识点:什么是 StorageBuffer8BitAccess?
llama.cpp 的 GPU 推理需要把量化后的 4-bit/8-bit 权重传到显存。为了省带宽,它希望 GPU 的 compute shader 能直接从 storage buffer 里按 8-bit 粒度读取数据。
如果驱动不支持,shader 就只能读 16-bit 或 32-bit 的值再自己拆包:
8-bit 直读(理想情况): 16-bit 读取拆包(无奈之举):
┌────┬────┬────┬────┐ ┌──────┬──────┬──────┬──────┐
│ w1 │ w2 │ w3 │ w4 │ │ w1w2 │ w3w4 │ w5w6 │ w7w8 │
└────┴────┴────┴────┘ └──────┴──────┴──────┴──────┘
一次读 4 个权重 一次读 2 个,带宽浪费一半
带宽利用率 100% 带宽利用率 50%
这就意味着显存带宽需求翻倍,而 GPU 推理的核心瓶颈恰恰就是显存带宽。带宽翻倍后,CPU 和 GPU 之间的传输开销抵掉了 GPU 并行计算的优势——GPU 比 CPU 还慢。
用一个日常例子来理解:
你有一箱 4 瓶装的酸奶。CPU 一次拿一瓶喝,8 秒喝完 4 瓶。GPU 本来可以整箱一起扛走,但卡车没有「4 瓶装车道」,只能把每两瓶捆成一组再搬——结果搬一箱要 10 秒,比 CPU 还慢。
五、第二回合:换 MIUI 原厂驱动
既然 Turnip 不支持,那就从 MIUI ROM dump 里把 Qualcomm 原厂的 vulkan.adreno.so 提取出来,打成 Magisk 模块刷进去。
我找到了 pedrozzz0/adreno-magisk-builder,它能从 Android Dumps 网站自动下载指定机型的原厂驱动。
POCO F2 Pro 的代号是 phoenixin,在 dumps 上找到了 MIUI 12.5.7 的完整 vendor 镜像。脚本成功提取了全套闭源驱动:
提取到的文件:
├── vulkan.adreno.so 64位 1.8MB + 32位 1.4MB ← Vulkan 驱动
├── libOpenCL.so ← OpenCL 运行时
├── libEGL_adreno.so + libGLESv2_adreno.so ← OpenGL ES 驱动
├── libllvm-qcom.so ← Adreno LLVM 编译器
├── libgsl.so ← GPU 系统库
├── a630_sqe.fw + a630_gmu.bin ← GPU 固件
└── 其他 30+ 个配套库
打成 50MB 的 Magisk 模块 ZIP,刷入,重启……
boot loop。
直接卡在开机画面了
在 Recovery 中用 ADB 删掉模块后恢复正常:
# 进入 Recovery → Advanced → Enable ADB → 电脑执行:
adb shell rm -rf /data/adb/modules/adreno_stock_driver/
adb reboot
为什么 MIUI 的驱动不能直接给 LineageOS 用?
Adreno 原厂 Vulkan 驱动不是一个孤立的 .so 文件,它依赖一整套内核接口:
| 组件 | 作用 | 兼容性 |
|---|---|---|
vulkan.adreno.so | Vulkan API 实现 | 绑死内核 KGSL 接口版本 |
libOpenCL.so | OpenCL 运行时 | 同样依赖 KGSL |
| 内核 KGSL 驱动 | GPU 内存管理、调度 | 必须匹配 |
GPU 固件(.fw) | 微码 | 通用 |
libllvm-qcom.so | LLVM 编译器 | 通用 |
MIUI 的 vulkan.adreno.so 是在特定内核版本上编译的,它调用的 KGSL ioctl 接口号和数据结构 layout 和 LineageOS 的 4.19 内核不匹配,加载时就 segment fault。
这也是为什么 LineageOS 用 Turnip 开源驱动——它跟着内核走,总是适配当前版本,代价就是功能少一些。
六、第三回合:CPU 编译优化——意外的惊喜
GPU 的路全走不通了,只能回到 CPU。但这次不留遗憾——从源码编译,开满 CPU 优化。
Snapdragon 855(Kryo 485)支持 ARMv8.2-A + Dot Product 指令集。这是专门为量化神经网络设计的硬件加速指令:
SDOT / UDOT 指令:一条指令完成 4 组 8-bit 整数的乘加运算
普通 NEON: 4 次乘法 + 4 次加法 = 8 条指令
Dotprod: 1 条指令搞定
🚀 理论加速 8 倍
编译命令:
git clone https://github.com/ggerganov/llama.cpp.git
cd llama.cpp
cmake -B build \
-DCMAKE_C_COMPILER=clang \
-DCMAKE_CXX_COMPILER=clang++ \
-DCMAKE_C_FLAGS="-march=armv8.2-a+dotprod+fp16+rcpc -O3" \
-DCMAKE_CXX_FLAGS="-march=armv8.2-a+dotprod+fp16+rcpc -O3"
cmake --build build -j4
编译耗时约 10 分钟,生成 llama-server 二进制 135MB。
(新版 llama-server 正常启动,日志显示 model loaded 成功)
速度对比
| 指标 | 旧版(系统包) | 新版(dotprod 优化) | 提升幅度 |
|---|---|---|---|
| Prompt 处理 | 5.05 t/s | 26.28 t/s | ↑ 5.2 倍 |
| 文本生成 | 9.16 t/s | 13.23 t/s | ↑ 44% |
| 首 token 延迟 | 198 ms/token | 38 ms/token | 快了 5 倍 |
| 每 token 耗时 | 109 ms | 76 ms | 快了 30ms |
说实话,看到这个结果挺意外的。纯 CPU 优化居然能提升接近 50%,尤其是首 token 延迟大幅改善——问问题后模型几乎秒回,不再有那种「卡一下才开始输出」的感觉。
七、第四个知识点:为什么 t=4 最优?
我还做了一个简单的线程数扫描,找到最优的并行度:
线程数扫描结果:
─────────────────────────────────────
t=2 ████████████████░░░░░░ 11.05 t/s
t=4 ████████████████████░░ 13.36 t/s ← 🏆 最优
t=6 ████████████████░░░░░░ 10.81 t/s
t=8 ███████████████░░░░░░░ 9.78 t/s
─────────────────────────────────────
Kryo 485 的 CPU 架构是 1 + 3 + 4:
┌──────────────────────────────────────────────┐
│ Kryo 485 核心布局 │
│ │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │A76 │ │A76 │ │A76 │ │A76 │ ← 4 个大核 │
│ │2.84G│ │2.42G│ │2.42G│ │2.42G│ │
│ └─────┘ └─────┘ └─────┘ └─────┘ │
│ │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │A55 │ │A55 │ │A55 │ │A55 │ ← 4 个小核 │
│ │1.8G │ │1.8G │ │1.8G │ │1.8G │ │
│ └─────┘ └─────┘ └─────┘ └─────┘ │
└──────────────────────────────────────────────┘
用 6 或 8 线程时,任务被分配到小核上。小核性能只有大核的 60%,加上线程同步开销,反而拖慢速度。
推理的最佳线程数 = 大核数,不是总核心数。
另一个有趣的发现:t=2 只比 t=4 慢了 17%,说明模型在这 4 个大核上的并行度其实有限。有接近一半的时间花在了内存访问上——这是 LLM 推理的典型特征:memory-bound,而不是 compute-bound。
八、最终总结
三天,四条路,结果如下:
┌─ 尝试的所有路径 ─────────────────────────────────┐
│ │
│ ① Turnip 驱动升级 │
│ → Adreno 650 功能缺失,换版本也没用 ❌ │
│ │
│ ② MIUI 原厂驱动 Magisk 模块 │
│ → 内核不兼容,boot loop ❌ │
│ │
│ ③ 换小模型(主动放弃) │
│ → 偏执狂不想换 🤷 │
│ │
│ ④ CPU 自编译 dotprod 优化 │
│ → 13 t/s,+48% ✅ │
│ │
└────────────────────────────────────────────────┘
回过头来看,有几个认知刷新:
1. 手机 GPU 加速没你想的那么简单
不是装个驱动就能跑的。开源驱动(Turnip)功能不全,闭源驱动(原厂)绑死特定内核。尤其刷了 LineageOS 的用户,处在一个尴尬的中间地带。
2. CPU 的潜力被你低估了
ARM 的 dotprod 指令是为 ML 推理量身定做的硬件加速器。在手机上,调编译器参数比折腾 GPU 驱动划算得多。
3. GPU 的瓶颈在带宽
手机 GPU 的显存带宽远不如桌面级。即使计算单元能跑满,带宽也会成为瓶颈。对于 1~2B 级别的模型,CPU 推理不一定比 GPU 差太多。
最终配置
| 项目 | 值 |
|---|---|
| 手机 | POCO F2 Pro (LineageOS 23) |
| 模型 | Qwen3.5-2B-Q4_K_M.gguf (1.2 GB) |
| 引擎 | llama.cpp 自编译 (ARMv8.2-a dotprod) |
| 线程 | -t 4 |
| 加速 | CPU NEON + LLAMAFILE + SDOT |
| 生成速度 | 13.36 t/s |
从 9 t/s 到 13 t/s —— 不换模型、不换系统、不加硬件,改几行编译参数就做到了。
不要高估手机 GPU 的能力,不要低估 CPU 优化的潜力。
如果你也在手机上折腾本地 LLM,或者遇到过类似的问题,欢迎留言交流。