如果你在 ARM 大小核平台上调过性能,多半见过一种诡异现象:大核心明明是 2.0GHz 的算力,系统负载看起来却不怎么高;小核心忙得不行,util_avg 却涨得飞快。刚开始我以为是 trace 数据没对齐,后来追到内核源码才发现,这是调度器在做负载统计时少乘了一个东西。这个“东西”,就是标题里的 scale_freq_capacity。
频率不变性(frequency invariance)是 Linux 调度器和负载追踪里非常关键、但容易被忽略的一环。它解决的根本问题是:让不同频率、不同算力的 CPU 在负载计算时使用同一套“标尺”。没有它,load_avg、util_avg 在不同频率下会失真,负载均衡会误判,EAS(能耗感知调度)和 schedutil 调频策略也会跟着跑偏。这篇文章我会从问题源头讲起,把 scale_freq_capacity 的取值来源、PELT 负载追踪里的校正逻辑、以及它在负载均衡中的实际作用拆开讲清楚,最后分享几个我实际排查时踩过的坑。
1. 为什么调度器需要“频率不变性”
1.1 只数任务数量带来的盲区
早期 Linux 调度器判断 CPU 负载,主要看 runqueue 上有多少任务等待运行。这个思路在最朴素的场景下够用:假设所有 CPU 完全同构、频率固定不变,那么“队列长度”确实能反映忙闲程度。可现实不是这样,ARM big.LITTLE 架构下大核和小核算力差距可能在三倍以上,x86 平台的睿频也会让同一颗 CPU 在不同时刻的算力有显著变化。
我用一个生活化例子类比。想象两个银行柜台,一个熟练柜员每分钟能办 3 笔业务,另一个新手每分钟只能办 1 笔。如果只看等待人数,两个柜台看起来负载一样;但把所有客户从熟练柜员那边挪给新手,等待时间立刻翻三倍。调度器的 runqueue 长度就是这个“等待人数”,它完全无法区分柜员能力。早期内核里用 rq->nr_running 和 rq->load.weight 做均衡,在算力差异大的平台上经常出问题,就是这个原因。
1.2 动态调频让问题雪上加霜
如果 CPU 频率固定,至少还能通过“算力差异”来做静态补偿。但现代 CPU 都支持 cpufreq 动态调频,同一个核心可能从 500MHz 跳到 2.0GHz,单位时间内能完成的工作量差四倍。假如一个任务在 1GHz 下跑 10ms,在 2GHz 下只需要 5ms,如果只按墙钟时间做累积,同一段代码贡献的“负载值”会相差一倍。
这就直接影响了调度决策。负载均衡要把任务从忙的 CPU 迁到闲的 CPU,如果负载值本身因为频率变化而失真,调度器看到的“忙”和“闲”就是假象。更隐蔽的是,schedutil governor 调频时需要根据任务 util 估算未来需要的算力,如果 util 和频率没有解耦,调频本身就会陷入循环:频率升高导致负载变“轻”,然后系统认为任务需求低,又把频率降下来。
1.3 频率不变性的核心思路
要解决这个问题,就需要引入一个概念:用“归一化的参考时间”来度量负载,而不是用物理时间。
具体来说,把一个 CPU 在最高频率、满算力下的执行能力定为一个基准(内核里用 SCHED_CAPACITY_SCALE = 1024 表示)。当 CPU 实际运行在某个频率 f 时,经过的时间要乘以 f / f_max 这个比例,折合成“参考时间”。例如最高频率 2GHz,当前 1GHz,那么墙上时间 10ms 折算成参考时间就是 5ms。
这样,不管任务跑在哪个频率,它对负载的贡献都能落到同一个坐标系里。这个“比例”,就是 scale_freq_capacity(内核代码里通常通过 arch_scale_freq_capacity() 获取)。同样的道理,不同微架构的 CPU 之间的算力差异,则需要另一个比例 scale_cpu_capacity(通过 arch_scale_cpu_capacity() 获取)来处理。这两把“尺子”配合,才能让大小核平台上的调度决策有可比性。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. PELT 负载追踪是怎么为负载“计时”的
2.1 从 per-entity load tracking 说起
明白了为什么需要频率不变性,接下来看它到底怎么进到负载计算里。现代调度器的负载追踪基础是 PELT(Per-entity Load Tracking),它为每个调度实体(sched entity)维护一组累积值,包括 load_sum、runnable_sum、util_sum,并从中导出 load_avg、runnable_avg、util_avg 这些常用指标。
PELT 的核心思想是指数滑动平均。它把时间切成 1024us 的周期(可以理解为一个“负载窗口”),越久远的历史负载权重越低。内核用衰减系数 y,满足 y^32 ≈ 0.5,也就是大约 32ms 之前的历史贡献会衰减一半。这个设计让负载能反映最近一段时间的变化趋势,又不会因为瞬时抖动产生剧烈波动。
在实际代码里,每次更新负载时会计算当前时间与上次更新时间之间的差值 delta,然后把 delta 拆成整数周期(periods)和余数(period_contrib),旧负载乘上衰减因子,新负载按比例累加。关键点在于:这个 delta 并不是直接使用的物理时间,而是可能需要经过频率和算力缩放的“参考时间”。
2.2 频率缩放在代码里的位置
以我常看的内核版本为例,调度实体负载更新的核心路径在 kernel/sched/fair.c 的 __update_load_avg() 和相关函数。简化之后大概是这样的逻辑:
c复制u64 delta = now - sa->last_update_time;
/*
* 频率不变性就体现在这里:
* delta 是墙钟时间,乘以 scale_freq 之后,
* 时间单位被换算成了“按最高频率折算的参考时间”。
*/
delta = cap_scale(delta, arch_scale_freq_capacity(cpu_of(rq)));
...
sa->last_update_time = now;
cap_scale 的本质是一次整数乘法加除法:
c复制static inline unsigned long cap_scale(unsigned long value, unsigned long mult)
{
return (value * mult) / SCHED_CAPACITY_SCALE;
}
SCHED_CAPACITY_SCALE 是 1024。如果 CPU 当前频率是最大频率的一半,scale_freq 返回 512,那么真实流逝的 10ms 会被折算成 10 * 512 / 1024 = 5ms。这个 5ms 才是后续累积到 util_sum 里的“参考时间”。
除此之外,scale_cpu 还会在累积 util 时做一次校正,让不同微架构的 CPU 单位时间里贡献的 util 一致。也就是说,PELT 里实际生效的公式大致是:
text复制util_sum += scale_freq * scale_cpu * running_time * weight_factor
scale_freq 负责消除频率差异,scale_cpu 负责消除微架构算力差异。两者相乘,才能把一个任务在不同 CPU、不同频率下的 util 归一化到同一把标尺上。
2.3 scale_freq 与 scale_cpu 的区别
很多初学者会把这两个概念混在一起,其实它们解决的问题不同。
scale_freq_capacity 是动态的。同一颗 CPU,频率可能周期性地变化,所以这个比例是随频率实时更新的,用来修正“同一颗 CPU 在不同时刻的算力差异”。
scale_cpu_capacity 是相对静态的。它描述的是在最高频率下,这颗 CPU 与系统里最强 CPU 的算力比值。比如大核 capacity = 1024,小核 capacity = 512,这意味着同样跑满一个周期,小核能完成的指令数只有大核的一半。这个值通常只在 CPU 拓扑变化或触发热插拔时更新。
在负载均衡里,scale_cpu 影响的是“这颗 CPU 算力有多大”,scale_freq 影响的是“当前这一刻这颗 CPU 实际能发挥出多少算力”。二者缺一不可。
2.4 半衰期与量纲的直观理解
PELT 的衰减机制结合频率不变性之后,会导致一个有意思的直观效果:一个固定任务,在一个算力稳定的系统上,无论你把它迁到大核还是小核,只要负载能稳定下来,它的 util_avg 基本是相同的(前提是它完整跑了一段时间,PELT 收敛后)。
举例来说,一个 CPU 密集型任务,在大核上跑 10ms 然后睡眠 90ms,在最高频下它的 util 稳定在某个值。如果它被迁移到小核,由于小核算力低,同样的工作量可能需要 20ms 才能完成,但因为它消耗的“参考时间”是相同的(小核用更长时间完成了同样的指令量,而 scale_cpu 又把更长时间折算回同等的 util),所以最终的 util 会收敛到接近相同的数值。这正是调度器想要的:task 的 util 反映的是“它对算力的需求”,而不是“它碰巧被分到了什么核”。
3. scale_freq_capacity 到底怎么取值?不同平台差别很大
3.1 架构接口的设计思路
调度器本身不直接去读 cpufreq 的 scaling_cur_freq,而是通过架构相关代码提供的一个接口拿到归一化比例,也就是 arch_scale_freq_capacity(cpu)。这样设计的好处是解耦:调度器只需要面向一个抽象接口,具体怎么算频率比例由各平台自己实现。
在内核配置里有 CONFIG_ARCH_WANTS_DYNAMIC_IRQ 和 CONFIG_SCHED_FREQ_INVARIANT 等开关,其中后者直接控制频率不变性是否开启。x86 和 arm64 都有各自的实现路径。如果平台没有实现,或者 feature 没开,arch_scale_freq_capacity() 会返回一个默认值 1024,也就是“当作始终满频运行”。
3.2 ARM64 的两种来源:AMU 与 cpufreq 反馈
ARM64 平台上,优先使用的频率不变性来源是 AMU(Activity Monitors Unit)。AMU 提供了一组硬件计数器,其中 cpu_cycles 统计内核实际执行的周期数,cntvct 是系统计数器。内核通过周期性地读取这两个计数器,用“周期数的变化量 / 时间的变化量”反推当前实际运行频率,再与最大频率求比例。这个方案的好处是直接反映硬件真实频率,不依赖 cpufreq 驱动是否上报。
不是所有 ARM64 CPU 都带 AMU,而且固件不开启相关 feature 时也无法使用。所以内核还提供了一套 fallback:cpufreq 驱动在频率切换时调用 arch_set_freq_scale(cpu, cur_freq, max_freq),这个函数内部会计算 cur_freq * 1024 / max_freq,并把结果写入该 CPU 对应的 per-cpu 变量 freq_scale。之后调度器读取 arch_scale_freq_capacity() 时,就是从这个变量取值。
实际项目里,我遇到最多的问题是设备树或固件忘了把 AMU 的 enable-method 配好,导致内核虽然编译了 AMU 支持,实际却走不到 AMU 路径,只能靠 cpufreq 反馈。如果 cpufreq 驱动恰好又没有调用 arch_set_freq_scale,那频率不变性就等于失效了,前面所有校正逻辑都白搭。
3.3 x86 和虚拟化场景下的差别
x86 平台上,早期实现主要依赖 APERF/MPERF 模型特定寄存器来估算 CPU 平均运行频率。内核通过这组 MSR 计数器的比值得到实际频率与最大频率的比例,再换算成 scale_freq。这套机制在物理机上工作良好,但到了虚拟化环境就未必了:虚拟机里的 vCPU 看到的 APERF/MPERF 可能只是 host 透传的一段假象,或者压根不支持相关指令。
虚拟化场景是频率不变性最容易被“静默失效”的地方。guest 内核编译了 CONFIG_SCHED_FREQ_INVARIANT,但 hypervisor 没有提供可靠的频率信息源,arch_scale_freq_capacity() 可能永远返回 1024。此时 guest 里看到的所有 CPU util 都基于“满频”假设计算,如果 host 侧实际频率在波动,guest 的负载均衡就会失真。由于没有明显报错,这种问题最难排查。
3.4 更新频率与精度问题
arch_scale_freq_capacity() 返回的值并不是每个 tick 都会重新计算的。AMU 路径下,内核通过 scheduler tick 触发的 amu_scale_freq_tick 定期刷新;cpufreq 路径下,则依赖驱动在频率变化时主动调用 arch_set_freq_scale()。这意味着 scale_freq 的更新是离散的,两次更新之间可能存在滞后。
举个实际例子:CPU 从 1GHz 突跳到 2GHz,但 scale_freq 还是 512,这时候 PELT 更新的一小段 delta 会被折算得过小,util 被低估。这种误差通常只有几个 tick,影响不大,但对某些需要精确追踪瞬间负载的场景(比如实时性敏感的任务)仍然值得注意。反过来说,scale_freq 更新得太频繁也有成本,所以内核在精度和开销之间取了平衡,我们不必苛求它达到硬件级别的实时。
4. 负载校正逻辑在调度流程中的实际应用
4.1 cpu_util 是怎么做校正的
有了 scale_freq 和 scale_cpu 之后,调度器在读取负载时还要做一次归一化,才能让不同 CPU 的负载可以横向比较。最常用的函数是 cpu_util(),它返回某个 CPU 在“标准标尺”下的 util,会做两层处理:取 PELT 的 util_avg(或 util_est 中的较大者),然后用 capacity_orig_of(cpu) 也就是 arch_scale_cpu_capacity(cpu) 做一次 cap。
这里有一个容易踩坑的细节:cpu_util() 返回的是 min(util, capacity)。也就是说,如果 util 已经超过了 CPU 的原始算力,它会被截断到 capacity 值。这是为了防止一个过载 CPU 的 util 无限上涨,进而干扰后续比较。但反过来,如果 scale_freq 失效,util 被低估,cpu_util() 返回的值也会偏低,调度器就会误以为这个 CPU 还很闲。
4.2 负载均衡里的 group_util 与 misfit 检测
在 find_busiest_group() 的流程里,每个调度域组会统计 group_util,也就是组内所有 CPU cpu_util() 的加总。这个值会和组的 capacity 比较,得出“这个组是否超载”的结论。比如两个组,大核组 capacity 总量 1024,当前 util 800;小核组 capacity 总量也是 1024(比如两个 512 的小核),当前 util 900。调度器会判定小核组更忙,尝试把任务迁到大核组。
如果 scale_freq 失效,小核上的 util 因为频率折算不准而被放大,大核上的 util 又被低估,均衡动作就可能往错误方向走。另一个典型场景是 misfit task。当某个任务的 util 超过当前 CPU capacity 的一定比例(内核里用 capacity_margin,通常是 20% 余量)时,调度器会标记它为 misfit,并在负载均衡时优先把它迁移到算力更大的 CPU。这个判断完全依赖 util 的准确性,一旦频率不变性失效,misfit 检测就会出现漏报或误报。
4.3 EAS 和 schedutil 如何依赖 freq invariance
EAS(Energy Aware Scheduling)是移动设备平台非常依赖的调度机制。它在任务唤醒时遍历可能的目标 CPU,用 compute_energy() 估算任务在各个 CPU 上运行时预计消耗的能量,选择能量最低的方案。能量估算需要结合任务的 util、目标 CPU 的 capacity 以及预测的运行频率。如果 util 在不同 CPU 之间不可比,EAS 的“最优选择”就是错的。
schedutil governor 调频也依赖同一套标尺。它的逻辑是根据当前 CPU 的 util(再叠加一些 boost)直接映射出目标频率。有了频率不变性,util 反映的是“任务对算力的真实需求”,schedutil 只要把这个需求翻译成频率即可。这里有个细节:map_util_perf() 函数里针对 EAS 场景做了额外的性能余量调整,这是为了避免 util 刚刚到达 100% 时目标频率还没拉满,导致性能悬崖。
4.4 一个可观测的示例
我平时分析调度问题,第一步会看 util 和 capacity 的比值。假设大核 capacity 是 1024,小核是 512。在频率不变性正常的前提下,如果一个小核任务 util 是 400,另一个大核任务 util 是 400,它们的“忙闲程度”是不同的:小核已经用掉了约 78% 的算力,大核才用了 39%。调度器做均衡时会看 util / capacity 这个归一化后的比例,而不是 raw util。
但如果频率不变性失效,小核 util 可能会因为频率折算错误而变成 600,大核 util 还是 400,调度器就会认为小核比大核忙很多,进而把任务迁到大核。最终结果可能是大核负载越来越高,小核却在摸鱼。用 ftrace 的 sched_load_avg 事件能很直观地看到各个 CPU 的 util 变化轨迹,一旦发现某个 CPU 的 util 不合理地偏高或偏低,怀疑 scale_freq 或 scale_cpu 取值异常是合理的第一步。
5. 排查这类问题的一些实际经验
5.1 怎么判断频率不变性是否生效
排查频率不变性问题,第一步是确认 arch_scale_freq_capacity() 返回的值是否真的跟随频率变化。如果没有专门的 trace 点,我通常用 ftrace 或 bpftrace 挂载这个函数,观察返回值。
内核源码树编译支持 trace 函数时,可以这么做:
bash复制cd /sys/kernel/debug/tracing
echo 'kprobe:arch_scale_freq_capacity cpu=%di' > kprobe_events
echo 1 > events/kprobes/arch_scale_freq_capacity/enable
cat trace_pipe
在 perf 里也可以用类似方式查看。如果返回值恒为 1024,说明频率不变性没有工作,需要继续确认是 feature 未开启还是平台没有实现。另外还可以直接看 cpufreq 的当前频率,和 scale_freq 对应起来验证:
bash复制cat /sys/devices/system/cpu/cpu*/cpufreq/scaling_cur_freq
如果 scaling_cur_freq 在明显变化,但 scale_freq 一动不动,那基本可以确定 arch_scale_freq_capacity 的实现没有生效。
5.2 常见失效原因和排查方向
最常见的问题是内核配置。CONFIG_SCHED_FREQ_INVARIANT 没有打开,或者 ARM64 平台的 CONFIG_ARM64_AMU_EXTN 没有打开,都会导致频率不变性不可用。其次常见的是 cpufreq 驱动没有调用 arch_set_freq_scale()。这个函数是架构代码提供给驱动调用的,不同平台驱动实现的积极性不一样,调研一个板子时值得专门检查一下。
还有一个容易忽略的坑:固件或设备树配置错误导致 AMU 计数器不可用。AMU 正常工作时,内核会通过设备树中 cpu 节点的 enable-method 找到入口,如果固件没有正确初始化相关寄存器,内核即使检测到 AMU 硬件,也无法稳定读取计数。这种情况下代码路径会退回到 cpufreq fallback,而 fallback 又可能没有实现,最终 scale_freq 恒为 1024。
5.3 问题现象速查表
我整理了一个排查频率不变性相关调度问题的速查表,遇到类似现象可以直接对照:
| 现象 | 可能原因 | 检查方向 |
|---|---|---|
| 大核 util 明显偏低,任务却都堆在大核 | scale_freq 恒为 1024,频率不变性未生效 |
检查 CONFIG_SCHED_FREQ_INVARIANT、AMU 配置、arch_set_freq_scale 调用 |
| 小核组持续过载,均衡疯狂往大核迁任务 | scale_cpu 配置错误,小核 capacity 虚高 |
查看 sched_debug 中各 CPU 的 capacity 值 |
| EAS 把任务放到明显不合适的 CPU 上 | 不同 CPU 间 util 不可比,能量估算失真 | 确认 scale_freq 和 scale_cpu 在不同核上是否一致有效 |
| CPU 明明满载,util 却只有 700 多 | 这是正常现象,PELT 收敛行为 + 调频余量导致 | 结合 sched_switch 和 perf stat 对照真实运行时间 |
| 虚拟化环境中跨 vCPU 负载不均 | guest 内没有可靠的频率信息源 | 在 host 侧检查实际频率和 vCPU 线程分布 |
5.4 配合 trace 分析调度波形
针对 util 和调度行为联动的场景,我常用的 trace 事件是 sched_load_avg 和 sched_util_est_cpu。前者在每次 PELT 更新时打印各 CPU 的 load、runnable、util,后者输出 util_est 估计值。通过这两个事件可以画出每个 CPU 的负载曲线,再叠加上 sched_switch 事件观察任务分布,就能比较清楚地看到任务是不是被均衡到了“错误”的核。
下面是一个我用过的 trace-cmd 示例:
bash复制trace-cmd record -e sched:sched_load_avg \
-e sched:sched_switch \
-e sched:sched_wakeup \
-e sched:sched_util_est_cpu sleep 10
trace-cmd report
拿到报告后,筛选某个 CPU 的 util 序列,和它的 scale_freq 做相关性分析。如果 util 的变化和频率变化没有关系,甚至频率升高时 util 反而降低了,那就是典型的量纲不一致问题。这种分析方式不依赖复杂的 profile 工具,只要 trace 数据准,基本能定位到问题。
最后说点个人的体会
调度相关的性能问题,很多时候不是算法设计问题,而是量纲不一致问题。scale_freq_capacity 和 scale_cpu_capacity 就像两把标尺,一把负责频率维度,一把负责算力维度。标尺没校准,后续所有负载均衡、EAS、调频逻辑都会在错误的基础上做“精确计算”,越精确反而越离谱。
我踩过的最深的一个坑,是在一个没有 AMU 的 ARM 平台上调 EAS 功耗,折腾了很多版任务亲和性配置都没效果。最后发现 cpufreq 驱动压根没调 arch_set_freq_scale,频率不变性从头到尾就没生效,EAS 在完全失真的 util 上做优化,怎么可能有正确结果。从那以后,我到一个新平台做调度相关的分析和优化,第一件事就是验证 arch_scale_freq_capacity 的返回值,确认标尺是好的,再谈后面的优化。这个习惯帮我避开了不少“看起来很怪”的调度问题。
