news 2026/10/23 5:11:17

Linux CFS调度器PELT负载跟踪机制解析:从数学原理到内核源码

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Linux CFS调度器PELT负载跟踪机制解析:从数学原理到内核源码

简介:一份围绕Linux内核CFS调度器与PELT(Per Entity Load Tracking)算法的技术PDF,面向内核开发者、系统工程师及对调度器源码感兴趣的学习者。文档从Linux 3.8之前运行队列级负载跟踪的不足切入,说明PELT按1024us周期、衰减因子y计算每个调度实体负载贡献的原理,并结合4096us运行实例展示负载随时间的衰减规律;同时给出内核decay_load()函数及runnable_avg_yN_inv数组的推导思路,有助于理解调度器如何更精确地感知任务负载、减少负载波动。压缩包内仅含1个PDF文件,大小358KB,篇幅凝练但覆盖PELT核心公式与代码实现。目前已有572人学习,适合用来快速建立PELT算法知识框架,或作为阅读Linux 4.18调度器代码时的辅助参考。

1. PELT:一份把 CFS 调度器负载统计“算账逻辑”拆干净的源码笔记

CFS 调度器里最反直觉的一个事实是:一个进程睡眠之后,它对系统的负载贡献并不会立刻清零,而是按一种近似指数衰减的规律缓慢消退;反过来,一个刚被唤醒的进程,系统也不会立刻按它的真实权重去衡量负载,而是给它一个“被高估”的起点,再让它逐步收敛。这套让负载带有记忆、又让记忆按周期打折的机制,就是 PELT(per entity load tracking)。这份源码笔记基于 Linux 4.18.0,把 PELT 从数学推导到内核代码完整拆开:1024us 的周期划分、衰减因子 y 的由来、decay_load() 的定点数实现、accumulate_sum() 的跨周期计算,以及 struct sched_avg 里每个字段的更新时机。适合两类人:一类是读 CFS 调度器源码经常卡在负载更新路径上的内核爱好者,另一类是需要在调度器负载指标上做二次开发的工程师。看完之后,你能在纸上手算一个任务的负载变化,也能动手改衰减参数,观察它对调度行为的影响。

2. 负载跟踪的前世今生:从 rq 级统计到 per-entity 计算

PELT 不是空降的概念,它是为了解决 Linux 3.8 之前 CFS 负载跟踪的两个具体痛点才被合入的。先搞清楚旧方案烂在哪里,再看 PELT 的数学模型,就不会被后面那堆 y^n 吓到。

2.1 rq 级负载跟踪的两个硬伤:来源不明与波动剧烈

在 Linux 3.8 之前,CFS 以每个运行队列(runqueue,简称 rq)为基础跟踪负载。rq 是每个 CPU 上可运行任务的队列,旧方案把同一 rq 上所有任务的运行时间、等待时间全部揉在一起,得到一个总负载值。这种做法表面直观,实际用起来有两个硬伤。

第一个硬伤是负载来源无法定位。rq 上可能同时混着 CPU 密集型的计算任务、IO 密集型的服务进程、还有刚被唤醒的交互任务,总负载是所有这些调度实体贡献之和。调度器拿到手后只知道“这个 rq 很忙”,却说不清楚负载到底是哪个调度实体(scheduling entity,简称 se)制造的。后续做任务迁移、CPU 选择、功耗控制时,这类信息恰恰是最需要的。

第二个硬伤是负载值波动剧烈。即使工作负载相对稳定,rq 级别跟踪的负载值也会大幅摆动。原因在于任务的调度和完成是动态的,一个任务随时可能睡眠、被抢占、被迁移到其他 CPU,rq 里的任务组成一直在变,历史负载又没有一个统一的衰减模型,导致统计值忽高忽低。调度器为了适应这种摆动,很容易做出短视的决策。

PELT 的解法是把负载统计下放到每个调度实体。每个 se 自己维护一份负载历史,rq 的负载由其下所有 se 的负载求和得到。负载来源问题就此消失,波动也因为统一的衰减模型而变得平滑。

2.2 1024us 周期、衰减因子 y 与 L = L0 + L1y + L2y2 递推

PELT 把物理时间(不是虚拟时间)切成 1024us 的序列。1024us 约等于 1ms,作为负载统计的采样窗口足够小,不至于因为采样太频繁而放大噪声,又足够短,能跟上负载的快速变化。

在一个 1024us 周期内,一个调度实体对系统负载的贡献,按该实体处于 runnable 状态的时间比例计算。runnable 状态包含两种情况:正在 CPU 上运行,以及等待 CPU 调度运行。如果在一个周期内 runnable 时间是 x,那么贡献就是 x/1024。

历史负载不能完全丢弃,否则负载信息就只剩当前周期,完全失去“历史趋势”的意义;但也没有理由让远古负载一直全额保留。所以 PELT 引入衰减因子 y,让旧周期的贡献按周期指数衰减。y 来自约束 y^32 = 0.5,推出来的值约为 0.97857206。含义很直观:一个周期过去,旧负载乘一次 y;32 个周期(接近 32ms)过去,旧负载大约只剩下原来的一半。

于是,一个调度实体在所有历史周期上的总负载贡献可以写成:

L = L0 + L1 * y + L2 * y² + L3 * y³ + ... + Ln * yn

注意这个式子从最老的周期排到最新。L0 是最早的周期,被衰减了 n 次;Ln 是当前周期,不需要衰减所以不乘 y。虽然看起来像无限级数,但实现时不需要记录所有历史,因为计算当前负载只需要“上一时刻负载总和乘 y,加上当前周期新增贡献”就可以了。后面讲 accumulate_sum() 时会看到,这个直觉正是代码的核心。

2.3 一个 4096us 连续运行任务的手工演算

为了把公式落到具体数字上,文档举了一个经典例子:一个 task 从第一次加入 rq 后开始一直运行 4096us,然后一直睡眠,问它分别在 1023us、2047us、3071us、4095us、5119us、6143us、7167us 和 8191us 时刻,负载贡献是多少。

前四个时刻任务还在连续运行:

  • 1023us 时,L0 = 1023。第一个周期没走完,没有历史负载,负载就是可运行时间 1023。
  • 2047us 时,L1 = 1023 + 1024y = 2025。第一个周期的 1023 需要乘一次 y,第二个完整周期贡献 1024。
  • 3071us 时,L2 = 1023 + 1024y + 1024y² = 3005。
  • 4095us 时,L3 = 1023 + 1024y + 1024y² + 1024y³ = 3963。

负载还在上升,但增量在放缓,因为每个新周期带来的 1024,会被前面所有周期叠加起来的衰减因子抵消一部分。

从 5119us 开始,任务已经睡眠:

  • 5119us 时,L4 = 1024y + 1024y² + 1024y³ + 1024y⁴ = 3877。注意这里不再有新的常数项。
  • 6143us 时,L5 = L4 * y = 3792。
  • 7167us 时,L6 = L5 * y = 3709。
  • 8191us 时,L7 = L6 * y = 3627。

从这个演算能提炼出两个关键结论。第一,任务睡眠后,负载不再有新增贡献,只剩旧负载按 y 逐周期衰减。第二,计算任意时刻的负载,只需要“上一个周期的负载总和乘以 y,再加上当前周期的时间贡献”,完全不需要回溯更早的历史。这个规律在后面所有源码中都会反复出现,是 PELT 递推实现的总纲。

第一次在纸上验算时很容易被 L 的下标绕晕:每个时刻的 L 是到当前时刻为止的总累计负载,而不是某一个周期的新增负载。建议画一条时间轴,把 1024us 的周期边界标出来,再拿公式逐项核对,就清楚了。

3. 数据结构和定点数技巧:PELT 在代码里长什么样

理解了数学模型,下一步是看内核怎么用数据结构表达它。PELT 的代码核心是两块:一是 struct sched_avg 这个负载账本,二是 decay_load() 这套定点数衰减逻辑。

3.1 struct sched_avg:一个调度实体的负载账本

内核用 struct sched_avg 记录调度实体 se 或就绪队列 cfs_rq 的负载信息。每个调度实体在 CONFIG_SMP 编译配置下都内嵌一个 sched_avg 结构体,代码定义如下:

struct sched_avg { u64 last_update_time; u64 load_sum; u64 runnable_load_sum; u32 util_sum; u32 period_contrib; unsigned long load_avg; unsigned long runnable_load_avg; unsigned long util_avg; };

先看四个核心字段。

last_update_time 记录上一次负载更新的物理时间,用于计算两次更新之间的时间差 delta,也就是负载衰减的时长。load_sum 是基于 runnable 时间的负载贡献总和,runnable 时间包含两种:在 rq 中等待 CPU 调度运行,以及正在 CPU 上运行。util_sum 则只基于 running 时间,也就是调度实体真正在 CPU 上执行的时间。period_contrib 保存上一次更新时不足 1024us 的零头,下一次更新时把它加到 delta 上,才能算准跨了几个完整周期。

结构体下面的三个 avg 字段是真正的“平均负载”,它们由对应的 sum 字段除以一个固定除数得到。load_avg 和 runnable_load_avg 用于负载均衡和调度决策,util_avg 更多被 CPU 频率调节和功耗管理使用,因为它能反映一个调度实体真实占用 CPU 的程度,不受任务优先级权重干扰太多。

调度实体 se 本身由 struct sched_entity 描述:

struct sched_entity { struct load_weight load; unsigned long runnable_weight; #ifdef CONFIG_SMP struct sched_avg avg; #endif };

注意 avg 被包在 CONFIG_SMP 里面,单核配置下整个 PELT 负载追踪是不编译的。load 是实体权重,runnable_weight 是“可运行权重”,这个概念主要面向 group se,因为一个组里不是所有成员都在运行;对普通任务 se 来说,runnable_weight 一直等于 load.weight。

3.2 runnable_avg_yN_inv:用查表法绕开浮点运算

PELT 计算中经常出现 val * yn 这种运算。内核出于性能和一致性的考虑,不希望引入浮点运算,于是把所有衰减因子预计算成定点数,存进查找表数组 runnable_avg_yN_inv。

数组里每一项保存的是 yn 乘以 2^32 后的整数结果:

static const u32 runnable_avg_yN_inv[] = { 0xffffffff, 0xfa83b2da, 0xf5257d14, 0xefe4b99a, 0xeac0c6e6, 0xe5b906e6, 0xe0ccdeeb, 0xdbfbb796, 0xd744fcc9, 0xd2a81d91, 0xce248c14, 0xc9b9bd85, 0xc5672a10, 0xc12c4cc9, 0xbd08a39e, 0xb8fbaf46, 0xb504f333, 0xb123f581, 0xad583ee9, 0xa9a15ab4, 0xa5fed6a9, 0xa2704302, 0x9ef5325f, 0x9b8d39b9, 0x9837f050, 0x94f4efa8, 0x91c3d373, 0x8ea4398a, 0x8b95c1e3, 0x88980e80, 0x85aac367, 0x82cd8698, };

从数值上能直接看出规律:第一项 0xffffffff 对应 y^0 * 2^32 即 2^32 - 1,之后每一项大约是前一项的 0.9786 倍。完整的生成逻辑在内核文档的 Documentation/scheduler/sched-pelt.c 里,calc_runnable_avg_yN_inv() 函数本质上是在算:

runnable_avg_yN_inv[i] = ((1UL << 32) - 1) * pow(0.97857206, i)

只有 0 到 31 这 32 个条目,因为 y^32 = 0.5。当需要 n 大于 31 的衰减值时,先利用 y^32 = 0.5 拆开再通过移位实现。例如 y^33 * 2^32 = 0.5 * y * 2^32 = 0.5 * runnable_avg_yN_inv[1],也就是把数组第 1 项右移一位。这个技巧让查找表只需要覆盖 32 个周期,超过 32 个周期全部转换成移位运算。

3.3 decay_load 逐行解析:周期衰减和边界处理

有了查找表,核心函数 decay_load() 的实现就顺理成章了:

static u64 decay_load(u64 val, u64 n) { unsigned int local_n; if (unlikely(n > LOAD_AVG_PERIOD * 63)) return 0; local_n = n; if (unlikely(local_n >= LOAD_AVG_PERIOD)) { val >>= local_n / LOAD_AVG_PERIOD; local_n %= LOAD_AVG_PERIOD; } val = mul_u64_u32_shr(val, runnable_avg_yN_inv[local_n], 32); return val; }

这个函数的语义是计算 val * yn。参数 val 是历史负载总和,n 表示经过的周期个数。LOAD_AVG_PERIOD 的值为 32。第一重边界检查里,LOAD_AVG_PERIOD * 63 = 2016,意思是当经过的周期数超过 2016 时,负载经过不断减半已经小于一个整数的统计粒度,直接返回 0,不再往下算。

第二重处理针对 local_n >= 32 的情况。因为 y^32 = 0.5,所以每满 32 个周期,负载就折半一次。代码把 val 右移 local_n / 32 位,等价于乘上 (1/2)^(local_n/32),再用 local_n % 32 作为查找表的索引,取 y 的余数部分的精确衰减值。

最后一行 mul_u64_u32_shr(val, runnable_avg_yN_inv[local_n], 32) 是定点数乘法的落地。查找表存的是 yn * 2^32,val 乘以这个值后再右移 32 位,物理意义就是 val * yn。整个过程没有浮点参与,配合上 y^32 = 0.5 的折半特性,衰减运算被压缩成一次移位、一次查表、一次乘法加一次移位。

读这个函数时最需要注意的是参数单位:n 的单位是“周期”,不是微秒也不是纳秒。如果一个任务间隔了两个调度 tick 来更新负载,期间经过 500us,n 是 0 而不是 500,因为 500us 不足一个周期,不需要衰减,只需要把 500 加到当前周期的负载贡献里。

4. 负载更新主路径:从公式到 accumulate_sum 的实现

PELT 的负载更新不是每次 scheduler tick 都从零算一遍,而是基于上一次的负载值递推。这一章把递推公式转换到代码的完整路径走一遍。

4.1 时间三段式分解:d1、d2、d3 与通用公式

先给出最通用的场景。假设上一次更新负载时,负载贡献是 u,距离现在一共经过了 d 时间。要计算现在的负载 u',需要把 d 拆成三段:

  • d1:距离当前时间最远的不完整周期剩余部分,也就是上一个周期里没算完的零头;
  • d2:中间完整的周期时间;
  • d3:当前这个不完整周期里已经走过的时间。

如果 d 一共跨越了 p 个周期,那么 p = 1 + d2 / 1024。通用公式是:

u' = (u + d1) * y^p + 1024 * (y^1 + y^2 + ... + y^(p-1)) + d3

拆开理解:上一时刻的负载 u 需要衰减 p 次,因为从上次更新到现在跨过了 p 个周期边界;d1 属于上一个不完整周期的时间,也要跟着一起衰减 p 次;中间完整周期的时间则根据距离当前时间的远近,分别衰减 1 次、2 次……直到 p-1 次;最后 d3 是当前周期的实时贡献,不需要衰减。

对应到文档里的例子:假设上次负载 u = 1010,之后经过了 d = 2124us。d1 是补齐上一个周期不足 1024us 的部分,d2 是完整周期 1024us,d3 是当前周期内剩余 84us。p = 2,代入公式得到:

u' = (1010 + 1016) * y² + 1024 * y + 84 = 1010y² + 1016y² + 1024y + 84

把 1010y² 解释成“上次的负载跨了两个周期,衰减两次”,1016y² 就是“补齐上一周期的零头也跨了两个周期,衰减两次”,1024y 是“中间那个完整周期只隔了一个周期,衰减一次”,84 是当前时间不衰减。这个拆解和上一章的直觉公式完全一致。

4.2 accumulate_sum:先衰落后补增长

内核把上面的公式分成两个步骤实现。accumulate_sum() 负责衰减旧负载(公式里的 u * y^p 部分),同时把新增负载加到总和里:

static __always_inline u32 accumulate_sum(u64 delta, int cpu, struct sched_avg *sa, unsigned long load, unsigned long runnable, int running) { unsigned long scale_freq, scale_cpu; u32 contrib = (u32)delta; u64 periods; scale_freq = arch_scale_freq_capacity(cpu); scale_cpu = arch_scale_cpu_capacity(NULL, cpu); delta += sa->period_contrib; periods = delta / 1024; if (periods) { sa->load_sum = decay_load(sa->load_sum, periods); sa->runnable_load_sum = decay_load(sa->runnable_load_sum, periods); sa->util_sum = decay_load((u64)(sa->util_sum), periods); delta %= 1024; contrib = __accumulate_pelt_segments(periods, 1024 - sa->period_contrib, delta); } sa->period_contrib = delta; contrib = cap_scale(contrib, scale_freq); if (load) sa->load_sum += load * contrib; if (runnable) sa->runnable_load_sum += runnable * contrib; if (running) sa->util_sum += contrib * scale_cpu; return periods; }

函数第一个参数 delta 是距离上次更新的时间,单位是 us。第一步把 sa->period_contrib 加进 delta,因为它记录的是上次更新时不足 1024us 的零头,补上之后才能得到准确的周期个数。第二步算 periods = delta / 1024,这是跨过的完整周期数量。

如果 periods 非零,说明确实跨过至少一个周期边界,需要先把旧的 load_sum、runnable_load_sum、util_sum 用 decay_load() 衰减掉,对应公式里的 step1。然后 delta 取模 1024,算出当前周期内已经走过的时间,和 1024 - sa->period_contrib(也就是补齐上一周期零头所需的 d1)一起传给 __accumulate_pelt_segments(),用于计算 step2 的新增贡献。

最后是负载叠加的三种路径。load 参数控制 load_sum,runnable 参数控制 runnable_load_sum,running 参数控制 util_sum。对普通调度实体来说,load、runnable、running 是非 0 即 1 的标志位,所以一个 se 的 load_sum 和 runnable_load_sum 在数值上会相等。util_sum 额外乘了 scale_cpu,用于体现不同 CPU 的算力差异。

period_contrib 的更新在代码里容易被忽略。当 periods 为 0 时,delta 不需要取模,period_contrib 直接更新成新的 delta;当 periods 非零时,period_contrib 更新成 delta 取模 1024 后的余数。无论哪种情况,sa->period_contrib 存的始终是“当前更新时刻距离上一个周期边界的距离”,而不是“距离下一个周期边界的距离”,这一点在阅读后续代码时尤其容易绕晕。

4.3 __accumulate_pelt_segments:等比数列求和的魔法

step2 的计算在 __accumulate_pelt_segments() 里,初看是一段再普通不过的代码:

static u32 __accumulate_pelt_segments(u64 periods, u32 d1, u32 d3) { u32 c1, c2, c3 = d3; c1 = decay_load((u64)d1, periods); c2 = LOAD_AVG_MAX - decay_load(LOAD_AVG_MAX, periods) - 1024; return c1 + c2 + c3; }

c1 对应公式里的 d1 * y^p,也就是补齐上一周期零头的部分经过 p 次衰减。c3 就是 d3,当前周期不衰减。麻烦的是 c2,它要表达公式里 1024 * (y^1 + y^2 + ... + y^(p-1)) 这一整串等比数列求和。

如果直接算 p-1 项的和,代价随 periods 增长。内核采用了一个巧妙办法:先定义一个收敛上限 LOAD_AVG_MAX。当 n 趋向无穷时,1024 * (y^1 + y^2 + ... + yn) 收敛于某个上限,这个上限就是 LOAD_AVG_MAX。于是有限项求和可以做减法:

1024 * (y^1 + ... + y^(p-1)) = LOAD_AVG_MAX - decay_load(LOAD_AVG_MAX, periods) - 1024

等式右边的含义是:从最大值里减去 periods 个周期之后剩余的部分,再减掉 y^0 对应的 1024。这样一来,任意 p 的等比数列求和都变成两次查询加一次减法,复杂度恒为 O(1)。

LOAD_AVG_MAX 的数值是 47742。这个值来自 calc_converged_max() 的迭代计算:

void calc_converged_max(void) { int n = -1; long max = 1024; long last = 0, y_inv = ((1UL << 32) - 1) * y; for (; ; n++) { if (n > -1) max = ((max * y_inv) >> 32) + 1024; if (last == max) break; last = max; } printf("#define LOAD_AVG_MAX %ld\n", max); }

这段代码模拟的是 max = max * y + 1024 的迭代过程。从 max = 1024 开始,每次迭代都让旧值衰减一次,再补上一个新周期全速运行的 1024,直到 last 和 max 相等,说明数值已经收敛。最终打印出来的值就是 47742。

理论上等比数列求和取极限会得到 1024 / (1 - y),代入 y = 0.97857206 算出来约等于 47793,和 47742 有几十的误差。原因是代码是整数迭代,每次都经过取整操作,这正是文档特别提醒过的一点:LOAD_AVG_MAX 是代码实际算出来的收敛值,和纯数学解析值之间允许存在偏差,阅读时不要强行追求两者一致。

4.4 update_load_avg 调用链:三个触发点

负载更新入口是 update_load_avg(),它在三个场景被调用:向就绪队列添加进程时,也就是 enqueue_entity 操作;从就绪队列删除进程时,也就是 dequeue_entity 操作;以及每个调度周期到来的 scheduler tick 更新。

static inline void update_load_avg(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags) { u64 now = cfs_rq_clock_task(cfs_rq); struct rq *rq = rq_of(cfs_rq); int cpu = cpu_of(rq); int decayed; if (se->avg.last_update_time && !(flags & SKIP_AGE_LOAD)) __update_load_avg_se(now, cpu, cfs_rq, se); decayed = update_cfs_rq_load_avg(now, cfs_rq); }

注意第一个 if 条件。last_update_time 非零并且没有设置 SKIP_AGE_LOAD 时,才更新 se 自己的负载。任务被迁移到新 CPU 时,se 的 sched_avg 会带着历史负载一起过去,这时通常设置 SKIP_AGE_LOAD,避免在迁移前做一次无意义的衰减。之后 update_cfs_rq_load_avg() 负责把 se 的负载累加到它所在的 cfs_rq 上,完成从 per-entity 到 per-rq 的汇总。

再往下走到 __update_load_avg_se() 的核心:

static int __update_load_avg_se(u64 now, int cpu, struct cfs_rq *cfs_rq, struct sched_entity *se) { if (entity_is_task(se)) se->runnable_weight = se->load.weight; if (___update_load_sum(now, cpu, &se->avg, !!se->on_rq, !!se->on_rq, cfs_rq->curr == se)) { ___update_load_avg(&se->avg, se_weight(se), se_runnable(se)); cfs_se_util_change(&se->avg); return 1; } return 0; }

这里有三点值得展开。

第一,entity_is_task(se) 为真时,runnable_weight 直接等于 load.weight。因为对单个任务来说,要么整个实体可运行,要么整个实体不可运行,不存在“部分可运行”的中间状态。group se 则不同,它有可能只有部分子任务在运行,runnable_weight 会小于 weight。

第二,___update_load_sum() 的三个关键参数分别是 load、runnable、running,这里全部由 se->on_rq 和 cfs_rq->curr 推导出来。on_rq 表示实体是否在就绪队列上,cfs_rq->curr == se 表示当前正在 CPU 上运行。一个任务从 on_rq 变成 0 的瞬间,load 和 runnable 都变成 0,之后即使时间继续流逝,这个 se 也不会有新的负载贡献,只保留历史负载按周期衰减。

第三,___update_load_avg() 最终更新 load_avg 等平均字段时,用到的是 se_weight(se) 和 se_runnable(se)。平均值的计算方式是:

static __always_inline void ___update_load_avg(struct sched_avg *sa, unsigned long load, unsigned long runnable) { u32 divider = LOAD_AVG_MAX - 1024 + sa->period_contrib; if (sa->load_sum) { sa->load_avg = div_u64(sa->load_sum * load, divider); ... } }

divider 不是一个固定常量,而是 LOAD_AVG_MAX - 1024 再加上当前的 period_contrib。因为负载总和受制于当前周期已经积累的时间,如果直接用固定值除,算出来的平均值会有偏差。每次更新都重新算 divider,是这个函数的细节所在。

5. PELT 避坑指南:五个容易翻车的地方

PELT 相关的代码我读了好几遍,也在 Linux 4.18 上自己写过负载观察模块,下面五个坑属于不看源码根本意识不到,看一遍文档又容易忽略的类型。每条按现象、原因、解决三步说清楚。

5.1 时间单位搞混:ns 和 us 的转换

现象:自己照着公式实现 PELT 时,把内核时钟的原始 ns 值直接拿来做周期划分,结果负载衰减速度快得离谱,一个正常运行的进程负载贡献几乎为 0。

原因:内核的时钟读数普遍以 ns 为单位,但 PELT 的周期是 1024us。在 ___update_load_sum() 里有一行 delta >>= 10,就是把 ns 转 us。之后 accumulate_sum() 里再用 delta / 1024 得出周期数。如果直接用 ns 计算,periods 会比预期大 1024 倍,所有负载都被当成跨越了大量周期来处理,自然会被疯狂衰减。

解决:统一把“ns 右移 10 位转 us,us 除以 1024 转周期数”刻在脑子里。看代码时盯住 delta 的单位变化,尤其是 accumulate_sum() 的入参 delta 已经是 us,不要再做一次 ns 转换。

5.2 period_contrib 的更新顺序容易写反

现象:自己实现负载累加时,先后顺序不对,导致跨周期边界的负载反复被算重。负载曲线在周期边界处出现不自然的凹陷或凸起。

原因:period_contrib 存的是“上一次更新时已经走完但不足一个周期的零头”。正确用法是先把 delta 加上 period_contrib,算出完整周期数,取模后再更新 period_contrib。如果先用取模结果覆盖 period_contrib,再拿旧值算周期,就会丢掉补齐全周期的那部分时间。

解决:按 accumulate_sum() 的顺序严格走:delta += period_contrib,算 periods,取模 delta,最后写回 sa->period_contrib。三个步骤的顺序谁都不要动。

5.3 LOAD_AVG_MAX = 47742 的来历和误读

现象:看到 LOAD_AVG_MAX 后直接拿它当分子计算负载百分比,算出负载永远只有几万,无法对应 0 到 1 的负载率,一头雾水。

原因:LOAD_AVG_MAX 不是归一化后的 1,而是在“每个周期满负载贡献 1024us”的前提下,所有历史周期收敛后的总贡献值。它本身是一个带物理意义的累计值,单位是“us 的加权和”,而不是一个 0 到 1 的比例。

解决:把 LOAD_AVG_MAX 当作一个用来做减法的收敛极限,例如 c2 = LOAD_AVG_MAX - decay_load(LOAD_AVG_MAX, periods) - 1024,它参与计算时起到的是“等比数列求和的等价值”作用。真正要转 CPU 占用率,应该看 util_avg 再除以调度周期的尺度,而不是直接用 LOAD_AVG_MAX 去除。

5.4 CONFIG_SMP 下才有 sched_avg

现象:在单核或某些嵌入式配置下编译内核,代码里引用 se->avg 直接编译不过。

原因:struct sched_entity 里的 avg 字段被包在 #ifdef CONFIG_SMP 中,PELT 本质是面向多核负载均衡和迁移设计的,单核配置下整个 per-entity load tracking 被裁剪掉。

解决:需要研究 PELT 时,尽量选用打开 CONFIG_SMP 的内核配置,例如默认的 x86_64 发行版内核。如果必须在非 SMP 环境下看负载,就要换一种思路,直接看 rq 级别的 load 统计,而不是盯着 sched_avg。

5.5 衰减到 0 的分界点:LOAD_AVG_PERIOD * 63

现象:长时间睡眠的进程被唤醒后,load_avg 从 0 慢慢爬升,有人误以为这是进程在“热身”,其实可能是历史负载已经被完全衰减掉了。

原因:decay_load() 的第一个边界条件是 n > LOAD_AVG_PERIOD * 63 时直接返回 0,也就是超过 2016 个周期后,历史负载被判定为完全衰减。2016 个周期对应约 2 秒。一个任务睡眠超过这个时间,之前积累的负载贡献就彻底清零,唤醒后只能从接近零开始重新累积。

解决:理解这个行为后,不要再对“唤醒后负载低”做过度解读。它不是 CPU 频率的问题,也不是调度器把进程降权了,单纯是 PELT 的历史窗口有限。如果希望保留更长的历史,就需要修改 LOAD_AVG_PERIOD 和衰减因子,这就进入了二次开发的范畴。

6. 二次开发:把 PELT 改造成自己的负载指标

读完 PELT 源码之后,多数人会想动手改一改,看它到底能不能按自己的需求调整。这里分享三个最实用的改造方向和验证方法。

6.1 改衰减周期:需要同步改哪些常量

如果想把负载历史窗口从约 2 秒拉长,最简单的做法是让“减半周期”从 32 个周期变成 64 个周期。此时衰减因子 y 由 y^32 = 0.5 变成 y^64 = 0.5,重新计算 y 约等于 0.9892。

但这不是改一个 y 就完事。runnable_avg_yN_inv 查找表需要重新生成,数组条数也要从 32 扩展到 64;decay_load() 里的 LOAD_AVG_PERIOD * 63 边界会随之调整;LOAD_AVG_MAX 需要用 calc_converged_max() 重新计算;___update_load_avg() 的 divider 公式也会受影响。任何一处遗漏,负载曲线都会在某个时间点发生突变。

我的习惯是先把 sched-pelt.c 里的生成脚本下载下来,改完参数重新生成数组,再统一改头文件里的相关宏,保证所有公式引用的是同一组收敛值,而不是靠手算硬编码。

6.2 验证方法:用 trace 观察 load_avg 的爬升和衰减

改造之后的验证不能只靠看性能。最快的方式是用内核的调度 tracepoint,观察进程在睡眠前后的 load_avg 变化。先构造一个周期性的负载任务,让它跑 200ms、睡 800ms,配合 bpftrace 在 sched_load_avg_* 探针上打印 runnable_avg 和 util_avg。

bpftrace -e 'tracepoint:sched:sched_load_avg_task { if (pid == $1) { printf("%llu load=%lu util=%lu\n", elapsed / 1000, args->load_avg, args->util_avg); } }'

bpftrace 脚本里的 $1 换成目标进程 pid。观察输出里 load_avg 从峰值衰减到接近 0 的时长,再对照自己修改后的预期衰减周期,就能快速判断改动是否符合预期。如果差异超过 20%,优先检查 period_contrib 和 LOAD_AVG_MAX 是否同步更新了。

6.3 一个更克制的小改动:把 util_avg 暴露到用户态

如果不想动算法本身,还有一个低风险的改造路径:在调度器调试节点里增加一个接口,把指定任务的 util_avg 直接暴露出来。实现上只需要在 sched_debug 或自定义的 proc 文件里读取 se->avg.util_avg,换算成整数百分比后输出。这个改动不触碰任何调度逻辑,却能让后续实验获得一个直观的 CPU 占用率指标。

我做过一次类似改造后的复盘:最初只想把负载历史调长一点,结果连续三个版本跑下来都出现衰减曲线突变的问题,最后定位到是 LOAD_AVG_MAX 没重新生成,导致 divider 在某些周期数量下误差被放大。从那以后,我每次改 PELT 这类的周期衰减逻辑,都会强制走一遍“先生成表、再核对宏、最后跑 trace 验证”的流程,哪怕只是改动一行常量,也不敢跳过任何一步。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/23 5:11:14

CASS11报‘请不要在虚拟机中运行’的真正原因与5种实测解决方案

简介&#xff1a;本资源是一份针对CASS11软件安装与运行中‘请不要在虚拟机中运行此程序’报错的专项排错指南&#xff0c;面向测绘、地理信息系统&#xff08;GIS&#xff09;及工程制图领域的专业技术人员与高校实践者&#xff0c;解决CADCASS11组合环境下的兼容性限制与启动…

作者头像 李华
网站建设 2026/10/23 5:10:26

SCORM课件演示与测试全攻略:从清单文件到数据提交的避坑指南

简介&#xff1a;这份资源是遵循SCORM标准的交互式课件演示与测试包&#xff0c;面向E-Learning课程开发者、LMS平台测试人员及在线教育从业者&#xff0c;用于验证课件在不同学习管理系统中的兼容性、跟踪与复用能力。压缩包共28个文件&#xff0c;约12.07MB&#xff0c;以swf…

作者头像 李华
网站建设 2026/10/23 5:10:24

Java在线考试系统源码解析:组卷、判分与倒计时实现

简介&#xff1a;这份Java在线考试系统源码包面向Java Web初学者与需要二次开发的开发者&#xff0c;提供一套可直接运行的在线测试与评估系统实现&#xff0c;帮助理解从用户请求处理到数据库交互的完整链路。压缩包共255个文件&#xff0c;约4.54MB&#xff0c;以gif、jpg等图…

作者头像 李华
网站建设 2026/10/23 5:09:47

Activiti6 中文用户指南:从环境搭建到 REST API 的实战避坑手册

简介&#xff1a;这份资源是 Activiti6 工作流引擎的中文用户指南文档&#xff0c;面向需要基于 BPMN 2.0 标准设计、部署与执行业务流程的 Java 开发者、架构师及流程平台运维人员。内容覆盖系统要求、快速入门、ProcessEngine 与数据库配置、Job Executor、邮件服务器与历史记…

作者头像 李华
网站建设 2026/10/23 5:09:03

模拟器开发第一步:为什么调试器比CPU执行循环更重要

简介&#xff1a;NEMU PA1是一个围绕模拟器NEMU与简单调试器&#xff08;sdb&#xff09;的计算机系统实践项目&#xff0c;这份PDF以精炼篇幅梳理了其概述、核心原理与实际应用&#xff0c;适合正在学习计算机系统课程、准备搭建或研究NEMU实验的学生开发者参考。文档覆盖了单…

作者头像 李华
网站建设 2026/10/23 5:08:38

中文大模型评测利器C-EVAL:从选择题设计到避坑实战

简介&#xff1a;C-EVAL是一个面向中文语境的多级多学科评估套件&#xff0c;专为自然语言处理与大型语言模型研究者设计&#xff0c;用于检验模型在中学、高中、大学和专业四个难度层级上的知识与推理能力。资源涵盖52个学科&#xff0c;从人文到科学工程&#xff0c;并包含专…

作者头像 李华