一段代码之前是好的,但是经过多年迭代之后,虽然没动过,但是可能就变成了问题,这就是这次要聊的话题:
一个曾经为了改善 Android UI 流畅度而引入的调度器优化,几年后反而让 SoC 功耗最高增加 20% 。
事情的起因是 Linux 调度器里的 cpu_util() boost 机制 ,最近传音控股的工程师 Hongyan Xia 在 LKML 提交了一个 patch:
[PATCH] sched/fair: Revert boost in cpu_util()
这个 patch 主要是把
cpu_util()里基于runnable_avg的 boost 逻辑撤掉,虽然看起来像是一个调度器的小改动,但背后其实牵扯到了 PELT、schedutil、Android 图形管线、JankBench、ADPF、厂商功耗策略,以及 Linux 主线内核和 Android 真实设备之间的验证偏差等问题。
大概情况是,传音的工程师在升级 Linux 内核版本后,发现多款 Android 手机的 SoC 功耗出现明显上升,而且在多个真实工作负载里都出现了类似事情,不局限某个 App 或者芯片,他们测试的场景包括:
- YouTube 1080p 60 播放
- 手机游戏,例如无尽对决、原神
- 多种真实手机工作负载
而出现问题的情况是:
- CPU 频率更容易维持在高档位
- 功耗却明显增加
- 部分场景 SoC 功耗增加约 20%
于是他们开始做 git bisect,最终定位到 Linux CPU 调度器和 schedutil CPU 频率调整路径里的 boost 逻辑,这个 boost 逻辑的关键点是:
过去 schedutil 主要看 util_avg ,后来为了更积极响应 CPU contention,又额外看 runnable_avg,如果 runnable_avg 暗示“排队工作很多”,就提高 CPU 频率。
也就是说,系统看到很多任务处于 runnable 状态,就认为 CPU 可能不够用,于是更积极地拉高频率,但是传音的工程师实测结果却是,这种处理逻辑在现在的 Android 上很不合理,它让 CPU 积极提频,但没有换来对应的性能收益。
这个问题就需要从 Linux 调度器聊起,Linux 里有一个很核心的负载追踪机制,叫 PELT,全称是 Per-Entity Load Tracking,它大致会追踪两个东西:
- util_avg:任务真正拿到 CPU 执行的时间
- runnable_avg:任务处于 runnable 状态的时间,包括正在运行,也包括排队等待 CPU
这两个指标在 CPU 竞争场景下差异很大,举个例子,比如一个 CPU 上同时有 4 个任务争抢:
Task A
Task B
Task C
Task D
如果它们都很想跑,但每个任务只能拿到 25% 的 CPU 时间,那么每个任务的 util_avg 看起来都不高,从单个任务角度看:
Task A 实际只运行 25%
Task B 实际只运行 25%
Task C 实际只运行 25%
Task D 实际只运行 25%
于是
util_avg会显得偏低,但真实情况是 CPU 已经被抢满了,这就是 boost 机制当初想解决的问题。
如果调度器只看「任务实际跑了多久」,就可能低估 CPU 需求,而如果把「任务排队等了多久」也算进去,就能更早发现 CPU contention,所以这个设计的初衷是:
util_avg 反应慢,那就引入 runnable_avg。 如果 runnable_avg 明显高于 util_avg,说明有任务在排队。 任务在排队,可能说明 CPU 频率不够。 那就让 schedutil 更积极提频。
但是在 Android 上就有点不一样,Android 手机上常见的 CPU 调频路径一般可以简化成:
- 任务运行/唤醒/迁移
- 调度器更新 PELT 信号
- schedutil 读取 CPU utilization
- 计算目标频率
- cpufreq 驱动切到对应 OPP
这里主要是 schedutil :每次调度器负载追踪更新时,比如任务唤醒、任务迁移、时间推进,都会调用 schedutil 去更新硬件 DVFS 状态。
也就是说 schedutil 本质上是一个「调度器驱动的 CPU 调频 governor 」,它根据 CPU runqueue 上的 utilization 估算当前需要多少频率,利用率越高目标频率越高,所以 cpu_util() 里多加一个 boost,在 Android 上就不是一个无关紧要的小数值了,它会直接影响 CPU 频率选择。
比如在当前主线代码里 sugov_get_util() 就会调用:
util += cpu_util_cfs_boost(sg_cpu->cpu);
util = effective_cpu_util(...);
util = max(util, boost);
sg_cpu->util = sugov_effective_cpu_perf(...);
这意味着 CFS 的 boost util 会进入 schedutil 的频率决策链路,一旦 cpu_util_cfs_boost() 给出的值偏高,CPU 就更容易被推到高频。
但是这套机制在过去的 Android 其实是有用的,因为当时 Android UI 的体验是经常「慢半拍」,早期 Android 的调度问题里:
- 任务刚醒来时 PELT 还没反应过来
- schedutil 觉得 CPU 不忙
- CPU 频率还没拉起来
- 结果 UI 线程 / RenderThread 错过帧预算
当年很多 Android 性能优化的思路都偏向「宁可早点提频,也不要掉帧」,JankBench 这类工具也正是在这种背景下被用来验证 UI 流畅度,它关注的是 Android Graphics Pipeline,也就是用户滑动、列表渲染、动画等场景下的 jank。
所以在当年看来, boost 逻辑有它的历史合理性,因为 PELT 慢、UI 负载短而急、schedutil 提频慢,所以用户看到卡顿,所以用 runnable_avg 提前补一脚油门。
但是问题在于,时代变了,这也是这次传音发现的结论:
- CPU 频率确实更高了
- 但性能没有明显更好
- 功耗却明显上升
也就是现在 runnable_avg boost 场景在现在的 Android 手机上不成立,因为它的假设是:
runnable_avg 高
↓
CPU contention 高
↓
CPU 频率不够
↓
提高 CPU 频率能改善体验
但是现在测试下来并不是,比如 runnable 多,不一定是 CPU 频率不够,任务排队可能是 CPU 忙,也可能是锁竞争、线程唤醒风暴、binder 调度、GPU 等待、内存带宽压力、thermal 限制,甚至是应用自身线程模型不合理。
这时候调度器看到的是有很多 runnable task ,但系统真正的问题场景可能是:
- GPU 忙
- 内存带宽不够
- 某个锁被占着
- RenderThread 等 SurfaceFlinger
- 游戏主线程在等 GPU fence
- 视频播放受解码和显示链路限制
所以如果瓶颈不在 CPU 频率,提高 CPU 频率其实并不会明显提升性能。
另外现在 Android 已经有更直接的 performance hint ,过去系统需要靠调度器猜,但 Android 后来已经逐步引入了更明确的机制,比如 ADPF(Android Dynamic Performance Framework),它支持游戏和性能敏感 App 更直接地与 Android 的功耗、温控和 CPU 管理系统交互,而不是让调度器只靠 runnable/util 盲猜 。
所以从这个角度看,
runnable_avgboost 是一个比较粗的旧时代启发式规则。
最后现在厂商一般啊自己也有一堆调度和提频策略,毕竟 Android 手机不是裸 Linux,SoC 厂商、系统厂商、游戏模式、Power HAL、thermal governor、GPU governor 都可能参与性能决策。
所以如果厂商已经对前台 App、游戏线程、SurfaceFlinger、RenderThread 做了 hint 或 boost,Linux 调度器再根据 runnable_avg 来一层 boost,就很容易出现多层策略叠加后,导致“过度提频”。
传音这次看到的现象很像这种过度提频,CPU 更常待在高频,SoC 功耗显著增加,但是用户可见性能没有明显变化。
所以这个其实不算是 Bug,而是当年的优化在现在成了负担,现在系统更成熟了,方案也多了,所以 boost 自然就时代的 Bug ,而且 Linux 主线希望机制通用,而 Android 设备希望整机体验和续航最优,这些年 Android 发展太快了,所以 Linux 层面没跟上也算正常。
所以代码不是一开始能跑,就代表了一直能跑,过去优化在现在也可能是负债。