端侧推理排查实录:为什么我的手机跑 LLM 比别人慢 80 倍

0 阅读6分钟

为什么你的手机跑 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 的主要限制):

模型大小 1.06 GB
骁龙 855 内存带宽约 34 GB/s
每生成 1 个 token 需要读一遍权重
→ 理论极限 = 34 / 1.06 ≈ 32 tok/s

算力瓶颈:

每个 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 采样:

adb shell top -b -n 1 -o PID,%CPU,%MEM,ARGS

输出:

PID    %CPU   %MEM   ARGS
17834  402%   24.3%  com.example.localai

402% —— 4 个线程都在满载运行。线程没问题,排除。


怀疑 2:编译时没有启用 ARM 优化?(命中)

这是最有价值的发现。

llama.cpp 的核心计算在 ggml 里。我翻它的 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% 的算力所在)退化成慢速实现。

修复:

// 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 反汇编产物:

llvm-objdump -d libggml-cpu.so | findstr "sdot"

输出:

18f558: 4e829420    sdot   v0.4s, v1.16b, v2.16b

sdot 指令确实存在(这是 ARM dotprod 的核心指令)。优化生效了,排除。

但速度还是 0.5 tok/s。


怀疑 3:模型页被系统回收?(排除)

下一个怀疑:内存访问。

Android 内存压力大时会回收 mmap 的页,导致每次读权重都回落到 flash。 如果真是这样,速度会是:

flash 读取约 1.5 GB/s
1.06 GB / 1.5 GB/s ≈ 700 ms/token

这个数量级和实测的 2 秒/token 很接近!

于是改用强制驻留内存的加载模式:

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。

结果:

  1. 查头文件发现 llama_batch_get_one 的 pos 是 nullptr,意思是由 llama.cpp 自动分配位置,不是 0。
  2. 我手写的版本反而把 prefill 改卡住了(43 tokens 的 prefill 卡住不返回)。
  3. 回退到 llama_batch_get_one 后恢复正常。

教训:性能问题的第一嫌疑是编译选项和内存访问,不是"我猜的某个算法细节"。

错误 2:用了已被移除的 API

llama_kv_cache_clear(g_ctx);   // 新版已移除

新版 llama.cpp 改成了:

llama_memory_clear(llama_get_memory(g_ctx), true);   // 正确

错误 3:猜结构体字段名

mparams.use_mmap  = false;   // 错误,字段已不存在
mparams.use_mlock = true;    // 错误

新版合并成了一个枚举:

mparams.load_mode = LLAMA_LOAD_MODE_MLOCK;   // 正确

这三条的共同教训:llama.cpp 的 API 演进非常快,任何函数名、字段名都必须先查 include/llama.h,凭记忆写必然踩坑。


五、结论

排除了三个怀疑后,剩下的是:

每个 token 计算量 3 GFLOPs
实测 1 token = 2 秒
→ 实际 CPU 算力利用率 ≈ 0.8%

0.8% 的峰值利用率。 而且不是内存带宽瓶颈(实测 ~500 MB/s,设备有 34 GB/s)。

也就是说:计算效率本身有问题,但已经超出"配置错误"的范畴—— 可能是 Android 内核的 CPU 调度、OpenMP 线程同步开销、或者 llama.cpp 在这个平台上的实现差异。

这不是一两天能解决的,而且——它不该挡住产品的交付。


六、我的处理方式

  1. 接受现状,把它写清楚:在项目 README 里如实标注 "Pixel 4: 0.5 tok/s"
  2. 标注已知限制的原因:老设备 + 大模型的客观组合
  3. 邀请社区补充数据:请骁龙 8 Gen 2/3 用户提交实测数据
  4. 把性能优化作为 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.cppcommit 8a1a9b5(2026-10-09)
NDK30.0.16248370
CMake4.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)已开源:

github.com/Pingredsai/…


如果你在更新的设备上跑同一个模型,欢迎把数据发给我——这正是这个项目最需要的。