为什么你的手机跑 LLM 比别人慢 80 倍:一次端侧推理排查实录
标签:端侧推理 / llama.cpp / Android / 性能优化
一、先说结果
我在 Pixel 4(骁龙 855,2019 年)上跑 Qwen2.5-1.5B-Instruct Q4_K_M, 目标是做一个完全离线的 Android 对话应用。
结果是:
| 指标 | 实测 |
|---|---|
| 生成速度 | 0.5 tok/s |
| 首 token 延迟 | 1.9 秒 |
| 模型加载 | 3-24 秒 |
| 峰值内存 | 1.3-2.0 GB |
| CPU 占用 | 402%(4 线程满载) |
0.5 tok/s 是什么概念? 一个字要等两秒。能用,但谈不上愉快。
但真正让我在意的不是"慢",而是------
理论上不该这么慢。
二、算一笔账:慢 80 倍
先算理论极限。
内存带宽瓶颈(跑 LLM 的主要限制):
bash
模型大小 1.06 GB
骁龙 855 内存带宽约 34 GB/s
每生成 1 个 token 需要读一遍权重
→ 理论极限 = 34 / 1.06 ≈ 32 tok/s
算力瓶颈:
ini
每个 token 计算量 = 1.5B 参数 × 2 FLOPs = 3 GFLOPs
骁龙 855 CPU 峰值 ≈ 182 GFLOPS(4×A76 @2.84GHz,NEON)
→ 理论极限 = 182 / 3 ≈ 60 tok/s
两个理论值都在 30-60 tok/s 区间。实测 0.5 tok/s,差了 60-120 倍。
而且 CPU 已经 402% 满载了------它在拼命算,但算得极其低效。
这就是排查的起点。
三、排查过程
怀疑 1:线程数没生效?(排除)
第一反应是"是不是只用了 1 个核"。
用 top 采样:
bash
adb shell top -b -n 1 -o PID,%CPU,%MEM,ARGS
输出:
perl
PID %CPU %MEM ARGS
17834 402% 24.3% com.example.localai
402% ------ 4 个线程都在满载运行。线程没问题,排除。
怀疑 2:编译时没有启用 ARM 优化?(命中)
这是最有价值的发现。
llama.cpp 的核心计算在 ggml 里。我翻它的 CMake 配置:
cmake
# ggml/src/ggml-cpu/CMakeLists.txt
if (GGML_NATIVE)
# 探测本机 CPU 特性,自动加 -mcpu=native
...
else()
if (GGML_CPU_ARM_ARCH)
list(APPEND ARCH_FLAGS -march=${GGML_CPU_ARM_ARCH}) # ← 关键
elseif(GGML_CPU_ALL_VARIANTS)
...
# 两个都没设置 → 一个 -march 都不加
endif()
问题就在这里:
GGML_NATIVE在交叉编译时是OFF(不能探测目标设备)GGML_CPU_ARM_ARCH默认是空字符串- 结果:编译时完全不传
-march
编译器会用什么?ARMv8-A 的基础指令集 ------ 没有 dotprod、没有 fp16。
后果: 量化矩阵乘法(LLM 推理 99% 的算力所在)退化成慢速实现。
修复:
kotlin
// app/build.gradle.kts
arguments += listOf(
"-DUSE_LLAMA_CPP=ON",
"-DGGML_CPU_ARM_ARCH=armv8.2-a+dotprod+fp16"
)
效果:prefill 从 114 秒降到 45 秒,提速 2.5 倍。
有进步,但......
验证:优化真的生效了吗?
不能只看"编译参数写了"。我用 llvm-objdump 反汇编产物:
bash
llvm-objdump -d libggml-cpu.so | findstr "sdot"
输出:
makefile
18f558: 4e829420 sdot v0.4s, v1.16b, v2.16b
sdot 指令确实存在 (这是 ARM dotprod 的核心指令)。优化生效了,排除。
但速度还是 0.5 tok/s。
怀疑 3:模型页被系统回收?(排除)
下一个怀疑:内存访问。
Android 内存压力大时会回收 mmap 的页,导致每次读权重都回落到 flash。 如果真是这样,速度会是:
bash
flash 读取约 1.5 GB/s
1.06 GB / 1.5 GB/s ≈ 700 ms/token
这个数量级和实测的 2 秒/token 很接近!
于是改用强制驻留内存的加载模式:
cpp
mparams.load_mode = LLAMA_LOAD_MODE_MLOCK; // 读进物理内存并锁定
结果:0.5 tok/s,没有改善。排除。
四、我在过程中犯的三个错误
排查过程中我因为"凭记忆写代码",踩了三个坑。写出来给大家避雷。
错误 1:以为 llama_batch_get_one 会把位置固定为 0
我一开始认为:
"
llama_batch_get_one()把 batch 的 pos 固定为 0,所以每生成一个 token 都从头重算,这是慢的原因。"
于是花时间手写了 llama_batch_init() + 显式设置 pos。
结果:
- 查头文件发现
llama_batch_get_one的pos是nullptr,意思是由 llama.cpp 自动分配位置,不是 0。 - 我手写的版本反而把 prefill 改卡住了(43 tokens 的 prefill 卡住不返回)。
- 回退到
llama_batch_get_one后恢复正常。
教训:性能问题的第一嫌疑是编译选项和内存访问,不是"我猜的某个算法细节"。
错误 2:用了已被移除的 API
cpp
llama_kv_cache_clear(g_ctx); // 新版已移除
新版 llama.cpp 改成了:
cpp
llama_memory_clear(llama_get_memory(g_ctx), true); // 正确
错误 3:猜结构体字段名
cpp
mparams.use_mmap = false; // 错误,字段已不存在
mparams.use_mlock = true; // 错误
新版合并成了一个枚举:
cpp
mparams.load_mode = LLAMA_LOAD_MODE_MLOCK; // 正确
这三条的共同教训:llama.cpp 的 API 演进非常快,任何函数名、字段名都必须先查 include/llama.h,凭记忆写必然踩坑。
五、结论
排除了三个怀疑后,剩下的是:
ini
每个 token 计算量 3 GFLOPs
实测 1 token = 2 秒
→ 实际 CPU 算力利用率 ≈ 0.8%
0.8% 的峰值利用率。 而且不是内存带宽瓶颈(实测 ~500 MB/s,设备有 34 GB/s)。
也就是说:计算效率本身有问题,但已经超出"配置错误"的范畴------ 可能是 Android 内核的 CPU 调度、OpenMP 线程同步开销、或者 llama.cpp 在这个平台上的实现差异。
这不是一两天能解决的,而且------它不该挡住产品的交付。
六、我的处理方式
- 接受现状,把它写清楚:在项目 README 里如实标注 "Pixel 4: 0.5 tok/s"
- 标注已知限制的原因:老设备 + 大模型的客观组合
- 邀请社区补充数据:请骁龙 8 Gen 2/3 用户提交实测数据
- 把性能优化作为 v2 计划,而不是阻塞 v1 发布
七、可迁移的经验
| 经验 | 说明 |
|---|---|
| 端侧推理慢,先查编译选项 | -march / NEON / dotprod 是最常见原因,不是算法 |
| CPU 满载 ≠ 效率正常 | 402% 占用可能只是在跑最慢的实现 |
| 先算理论极限 | 知道差多少倍,才知道该怀疑什么 |
| 用反汇编验证优化 | 编译参数写了 ≠ 指令真的生成了 |
| 查头文件,不要凭记忆 | llama.cpp API 变化极快 |
| 给作品设置"止损点" | 性能优化不该无限期阻塞交付 |
附录:完整配置
| 项 | 值 |
|---|---|
| 设备 | Google Pixel 4(骁龙 855,6GB RAM,Android 13) |
| 模型 | Qwen2.5-1.5B-Instruct-Q4_K_M.gguf(1065 MB) |
| llama.cpp | commit 8a1a9b5(2026-10-09) |
| NDK | 30.0.16248370 |
| CMake | 4.1.2 |
| 编译参数 | -DGGML_CPU_ARM_ARCH=armv8.2-a+dotprod+fp16,-DUSE_LLAMA_CPP=ON |
| 运行参数 | n_threads=4, n_ctx=2048, load_mode=MLOCK |
源码
完整实现(Kotlin + JNI + llama.cpp + Jetpack Compose)已开源:
如果你在更新的设备上跑同一个模型,欢迎把数据发给我------这正是这个项目最需要的。