← 返回文章列表

用手机跑大模型,我折腾了三天GPU加速,结果被CPU上了一课

一个「不换小模型、不刷回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                 │
└──────────────────────────────────────────────┘

vulkaninfo 显示的 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.soVulkan API 实现绑死内核 KGSL 接口版本
libOpenCL.soOpenCL 运行时同样依赖 KGSL
内核 KGSL 驱动GPU 内存管理、调度必须匹配
GPU 固件(.fw微码通用
libllvm-qcom.soLLVM 编译器通用

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/s26.28 t/s↑ 5.2 倍
文本生成9.16 t/s13.23 t/s↑ 44%
首 token 延迟198 ms/token38 ms/token快了 5 倍
每 token 耗时109 ms76 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,或者遇到过类似的问题,欢迎留言交流。