端侧推理排查(二):线程在大核、CPU 满频,为什么还是慢?

端侧推理排查(二):线程在大核、CPU 满频,为什么还是慢?

系列第一篇:端侧推理排查实录:为什么我的手机跑 LLM 比别人慢 80 倍 本篇聚焦:线程调度 与 CPU 调频 系列标签:端侧推理 / llama.cpp / Android / 性能分析


一、接着上一篇说

第一篇里,我在 Pixel 4(骁龙 855)上跑 Qwen2.5-1.5B,实测 0.5 tok/s, 比这台机器的理论极限慢了 60-120 倍。

那次排查的收获是找到了一个真问题:llama.cpp 交叉编译时默认不传 -march, 导致量化矩阵乘法退化成慢速实现。修好之后,prefill 从 114 秒降到 45 秒。

但速度仍然只有 0.5 tok/s。

也就是说:最大的那个坑填上了,问题还在。


二、一条评论带来的新方向

文章发到 DEV 之后,有位读者留言,指出了两个"看起来像模型慢、其实是环境问题"的陷阱:

调度 :他启动的一个 llama.cpp server,子进程在负载下被调度到了能效核上, 一个原本 3 秒的转写变成了 37 秒 。同样的二进制、同样的参数, 唯一的区别是谁启动的。

存储 :从 U 盘跑模型时,每次启动都要完整读一遍------ 34 MB/s 的盘配 2.6 GB 的模型,一分多钟才出第一个 token。

他还说了一句很扎心的话:

那个看起来像"模型很慢"的数字,几乎从来都不是模型本身的问题。

这两条都指向同一个方向:问题可能不在计算,而在"谁来算、以什么速度算"。

我决定照着验证。


三、先摸清设备:1+3+4 的大小核

在做实验前,必须先知道这台机器的 CPU 结构。

bash 复制代码
# 读每个核心的最高频率
for c in /sys/devices/system/cpu/cpu[0-9]*; do
  echo "$(basename $c) $(cat $c/cpufreq/cpuinfo_max_freq)"
done

结果:

核心 最高频率 类型
cpu0--3 1.79 GHz A55 小核(慢)
cpu4--6 2.42 GHz A76 中核
cpu7 2.84 GHz A76 大核(最快)

这是典型的 1+3+4 配置。如果推理线程被扔到 cpu0-3,性能就是直接砍掉 3-4 倍。


四、实验一:线程到底在哪个核上跑?

方法:读 /proc,不需要任何工具

Linux 的 /proc/<pid>/task/<tid>/stat 里,第 39 个字段是"这个线程当前运行在哪个 CPU"。

bash 复制代码
pid=$(pidof com.example.localai)

for t in /proc/$pid/task/*; do
  tid=$(basename $t)
  comm=$(cat $t/comm)                          # 线程名
  cpu=$(awk '{print $39}' $t/stat)             # 当前所在 CPU
  ticks=$(awk '{print $14+$15}' $t/stat)       # 累计 CPU 时间
  echo "$tid $comm cpu=$cpu ticks=$ticks"
done

关键是连续采样------单次快照没有意义,要看分布。

结果:全在大核,调度没问题

推理进行中连续采样:

yaml 复制代码
主推理线程 9556:  cpu5 → cpu5 → cpu5 → cpu3 → cpu5 → cpu4
19524 openmp_worker  cpu6
19525 openmp_worker  cpu7   ← 大核
19526 openmp_worker  cpu5

4 个推理线程全部稳定在 cpu4-7(中核/大核),没有一个落到小核。

顺带还确认了一件事------OpenMP 确实在工作:

yaml 复制代码
19524 openmp_worker  5607 ticks
19525 openmp_worker  5606 ticks
19526 openmp_worker  5602 ticks
9556  DefaultDispatch 12132 ticks(主线程)

n_threads=4 配置对应 3 个 worker + 1 个主线程,完全吻合。

结论:读者说的"线程被调度到小核",在我这台设备上不成立。这个假设排除。


五、实验二:CPU 是不是在降频?

现象:频率在剧烈抖动

顺手读了一下频率,发现了问题:

bash 复制代码
for c in 4 5 6 7; do
  echo "cpu$c cur=$(cat /sys/devices/system/cpu/cpu$c/cpufreq/scaling_cur_freq) max=$(cat .../cpuinfo_max_freq)"
done

推理期间:

yaml 复制代码
cpu4:  710 ~ 1920 MHz   (最高 2419)   ← 最低只跑到 29%
cpu5: 1612 ~ 2419 MHz   (最高 2419)
cpu6:  710 ~ 2419 MHz   (最高 2419)
cpu7: 1612 ~ 2016 MHz   (最高 2841)   ← 从没超过 71%

CPU 在 710MHz 和 1920MHz 之间来回横跳,而它们最高能跑 2419/2841 MHz。

先排除"过热"

bash 复制代码
adb shell dumpsys battery | grep temperature
# temperature: 339  →  33.9°C

33.9°C,完全不热。所以不是热降频。

那是谁在降频?schedutil ------ Android 的默认调频器。

它的工作方式是"根据 CPU 利用率动态调频"。而 LLM 推理是间歇性突发负载 : 每个 token 的计算量不大,但连续不断。schedutil 的利用率估算滞后, 负载来了它还没升频,刚升上去负载又波动了,结果就是频率反复横跳。

决定性实验:把 CPU 锁死在满频

要验证"降频到底影响多大",只有一个办法:让它不降频。

bash 复制代码
# 需要 root。SELinux Enforcing 下直接写会被拒,要先 chmod
for c in 0 1 2 3 4 5 6 7; do
  g=/sys/devices/system/cpu/cpu$c/cpufreq/scaling_governor
  chmod 666 "$g"
  echo performance > "$g"
done

确认锁频成功:

yaml 复制代码
cpu0-3: 1785 MHz   (小核满频)
cpu4-6: 2419 MHz   (中核满频)
cpu7:   2841 MHz   (大核满频)

结果:只快 20%

指标 schedutil performance 变化
生成速度 0.5 tok/s 0.6 tok/s +20%
首 token 1863 ms 1619 ms −13%

只有 20%。

降频是真的,但它不是主要瓶颈。


六、更新后的排除表

假设 结论
编译缺 ARM 优化(-march) ✅ 真问题,已修复(提速 2.5 倍)
线程被调度到小核 ❌ 排除(全在 cpu4-7)
OpenMP 多线程没生效 ❌ 排除(3 worker 在跑)
mmap 页被系统回收 ❌ 排除(LLAMA_LOAD_MODE_MLOCK 无变化)
CPU 降频 ⚠️ 存在,但只占 20%

七、剩下的谜题

把上述都排除后,情况变成了这样:

markdown 复制代码
CPU 满频(2419/2841 MHz)
线程在大核(cpu4-7)
dotprod 指令确认存在(反汇编验证过)
        ↓
算力利用率仍然只有 ~1%

算一下:

ini 复制代码
每个 token 计算量 = 1.5B 参数 × 2 = 3 GFLOPs
实测 1 token = 1.67 秒
实际算力 = 1.8 GFLOPS
CPU 峰值 ≈ 182 GFLOPS(4×A76 满频)
利用率 = 1%

再看看内存:

bash 复制代码
每生成 1 token 需读全部权重 = 1.06 GB
1.06 GB / 1.67 s = 635 MB/s
设备内存带宽 ≈ 17-34 GB/s
我们只用了 2-4%

CPU 没打满(1%)、内存没打满(3%)------但就是慢。

剩下的解释只有一个:瓶颈在 ggml 的量化计算内核内部。 可能是内存访问模式、cache 命中率,或者 OpenMP 在短任务上的 barrier 开销。

这不是配置层面能解决的。我把它留在这里,作为下一个要啃的问题。


八、这篇的真正价值:方法,不是结论

这次排查的结果是"没找到终极答案",但过程本身有可复用的价值。

1. 一套零依赖的诊断方法

bash 复制代码
# 线程跑在哪个核?
awk '{print $39}' /proc/<pid>/task/<tid>/stat

# 线程累计 CPU 时间(用于判断哪个线程在干活)
awk '{print $14+$15}' /proc/<pid>/task/<tid>/stat

# CPU 实时频率
cat /sys/devices/system/cpu/cpu*/cpufreq/scaling_cur_freq

# 调频器
cat /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor

这些命令在任何 Android 设备上都能用,不需要额外工具。

2. "阴性结果"也是结果

排除 A、B、C 之后,"问题不在 A/B/C"本身就是进展。 它把搜索空间从"什么都可能"缩小到"在 ggml 内核里"。

如果我只报告成功的那部分,别人会以为这条路很顺。 但真实的工程过程是:大部分假设都是错的,价值在于你排除得足够快。

3. 算理论极限,才知道差多少

很多人说"我的模型很慢",但说不出慢多少倍。

先算:

  • 内存带宽能支持多少 tok/s?
  • CPU 算力能支持多少 tok/s?

知道理论上限是 32-60 tok/s,而实测 0.5,你才知道"差 60-120 倍"------ 这个数字本身就告诉了你:问题不在"优化不够",而是"某处完全没跑起来"。


九、环境恢复

实验做完,所有改动都恢复了:

项 恢复值
CPU governor schedutil
SELinux Enforcing
屏幕常亮设置 false
屏幕超时 60000 ms
临时脚本 已删除

测试设备不要留下副作用------尤其是 SELinux,改完必须恢复。


十、下一步

这个系列会继续。 下一个要验证的方向是 ggml 量化内核的效率:

  • Q4_K_M 在 ARM 上走的是哪条代码路径?
  • cache miss 率是多少?
  • OpenMP barrier 在单 token 生成时占多少开销?

如果你对这些问题有经验,欢迎交流------这正是我需要的。


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

系列二:本篇

源码:github.com/Pingredsai/...

相关推荐
黑妹天下第一乖3 小时前
小智改造实战解读-用kokoro-onnx替换云端 TTS 的完整记录
大数据·开发语言·人工智能·python·交互
VimorphTech3 小时前
独立开发 AI 音视频平台:Spring Boot、Next.js 与视频翻译系统设计
人工智能
霍格沃兹测试学院-小舟畅学3 小时前
测试智能体评估与自我进化
人工智能
木头科技3 小时前
【AI 工程化第八篇】Spring AI 多模型路由实战:DeepSeek、通义、Kimi、OpenAI 如何统一接入和自动切换
java·人工智能·spring
信可维3 小时前
AI 说「改完了」怎么确认真改完了:git diff + 测试 + 一张验收表的 3 步复核
人工智能·git
CoderIsArt3 小时前
消除喷头残余振荡方案
人工智能·喷墨打印
镜象科技3 小时前
AI情感陪伴大模型是什么?孤独时代的科技解法与它的专业底线
人工智能
吴佳浩 Alben3 小时前
单卡5090跑125B 大模型:从安装、验证到基准测试的完整操作教程
人工智能
马剑威(威哥爱编程)3 小时前
【AI全栈后端12-11】Spring Boot 把 AI 接口真正上线扛量:限流 / 降级 / 可观测
人工智能·spring boot·后端