资讯动态

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

发布时间:2026/10/23 5:11:31 来源:尧图企业网站定制
简介一份围绕Linux内核CFS调度器与PELTPer 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 调度器里最反直觉的一个事实是一个进程睡眠之后它对系统的负载贡献并不会立刻清零而是按一种近似指数衰减的规律缓慢消退反过来一个刚被唤醒的进程系统也不会立刻按它的真实权重去衡量负载而是给它一个“被高估”的起点再让它逐步收敛。这套让负载带有记忆、又让记忆按周期打折的机制就是 PELTper 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 级别跟踪的负载值也会大幅摆动。原因在于任务的调度和完成是动态的一个任务随时可能睡眠、被抢占、被迁移到其他 CPUrq 里的任务组成一直在变历史负载又没有一个统一的衰减模型导致统计值忽高忽低。调度器为了适应这种摆动很容易做出短视的决策。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。含义很直观一个周期过去旧负载乘一次 y32 个周期接近 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^32val 乘以这个值后再右移 32 位物理意义就是 val * yn。整个过程没有浮点参与配合上 y^32 0.5 的折半特性衰减运算被压缩成一次移位、一次查表、一次乘法加一次移位。读这个函数时最需要注意的是参数单位n 的单位是“周期”不是微秒也不是纳秒。如果一个任务间隔了两个调度 tick 来更新负载期间经过 500usn 是 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 是完整周期 1024usd3 是当前周期内剩余 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_sumrunnable 参数控制 runnable_load_sumrunning 参数控制 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 位转 usus 除以 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 扩展到 64decay_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 验证”的流程哪怕只是改动一行常量也不敢跳过任何一步。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑