简介:围绕 Linux CFS 调度器与 PELT 负载跟踪机制展开的技术文档,面向具备一定内核基础、希望深入理解调度器负载计算的开发者与内核爱好者。内容从 Linux 3.8 之前按运行队列跟踪负载所造成的负载来源不明、数值波动大等问题切入,讲解 PELT 如何以 1024us 为周期、按调度实体累计可运行时间,并用衰减因子平滑历史负载的影响;结合 y^32≈0.5、衰减序列取值与逐周期负载计算的示例,剖析 decay_load() 与 runnable_avg_yN_inv 数组怎样以移位和乘法替代浮点运算,代码分析基于 Linux 4.18.0。压缩包共 1 个 PDF 文件,约 358KB,便于离线查阅与随时对照。已有 573 人学习,适合需要厘清公式推导与内核实现细节的读者研读。
1. 从 rq 到 se:负载跟踪的粒度为什么决定了一切
Linux CFS 在 3.8 之前是拿 runqueue 当负载单位的,这个直觉很干净:一个 CPU 的 rq 上挂了多少权重、跑了多久,就代表这个 CPU 有多忙。但只要把观测窗口缩到毫秒级,就会撞见两个尴尬现象——两个负载结构完全不同的 CPU,rq 的 load 值可能差不多;而同一个 CPU 在负载几乎没变的情况下,load 值却来回跳。前者是「没法归因」,后者是「测量噪声」。PELT 的 per-entity load tracking 就是来修这两件事的:把跟踪粒度从 rq 下沉到每一个调度实体 se,再用指数衰减把瞬时抖动抹平。搞懂它,等于拿到了 CFS 后续所有负载均衡决策的数据底账。
2. 1024us 与 y^32=0.5:几何衰减的时间标尺是怎么定的
2.1 为什么标尺是物理时间,而不是虚拟时间
CFS 里已经有一个衡量「谁该多跑」的量:vruntime。它按权重倒数缩放,专门用来做公平性比较。但负载跟踪要回答的是另一个问题——这个实体给系统施加了多大压力。这两个问题不能共用一把尺子。vruntime 被权重扭曲过,一个高权重任务即使只跑了一小会儿,vruntime 也可能涨得很慢;用它反推负载,等于把调度策略混进了度量里。
PELT 明确选物理时间做标尺:时间被切成 1024us 的序列,一个实体在第 i 个周期里处于 runnable 状态的时间是 x(单位 us),那么它对这个周期的负载贡献就是 x/1024。注意 runnable 的定义——既包含正在 CPU 上跑的时间,也包含在 rq 上排队等 CPU 的时间。这点常被误解成「只算 running」。排队也在制造压力:它占着 rq 的位置,影响其他任务的等待时间,所以必须计入 load。后来才拆出第二本账 util,只记真正 running 的时间,服务于频率和容量决策。两本账的分工在后面第 4 章会看得更清楚。
2.2 衰减因子 y:为什么恰好要求 y^32 = 0.5
单周期的 x/1024 没有意义,负载必须能累积历史。PELT 用几何级数累积:
L = L0 + L1·y + L2·y² + L3·y³ + ... + Ln·yⁿ其中 y = 0.97857206。这个数字不是随手拍的,它满足 y^32 ≈ 0.5。也就是说,32 个周期(32 × 1024us ≈ 32.7ms)之后,任意一份历史贡献正好衰减一半。这个约束是整个算法能被高效实现的关键,理由有三层。
第一层是可计算性。有了 y^32 = 0.5,任意 y^n 都能拆成 2^(-n/32) × y^(n mod 32),前者是纯移位,后者查一张 32 项的表就够了,衰减计算从 O(n) 变成 O(1)。
第二层是时间尺度。32ms 大致是调度器感知「近期」和「久远」的分界。衰减再快,负载就变成瞬时值,一个任务稍微让出 CPU 就被判成轻负载,稍微抢占一下就成重负载,抖动全回来了;衰减再慢,历史包袱太重,一个刚从计算密集退下来的任务会长时间被误判为重量级,迁移决策跟着出错。这个量级的选择,本质是在「响应速度」和「稳定性」之间取平衡点。
第三层是有界性。级数收敛,意味着单个实体的负载贡献存在理论上限,这个上限后面会具体算出是 47742,正好被用来做归一化除法。
2.3 从 4096us 那个例子看「只需要上一刻」
假设一个 task 从加入 rq 开始连续跑 4096us 然后一直睡,在 1023us、2047us、3071us……这些时刻的负载贡献分别是多少?逐周期累加得到 1023、2025、3005、3963、3877、3792、3709、3627。观察前四个点:每个点都比上一个点乘以 y 再加 1024 稍多一点,而到 5119us 之后不再有新的 runnable 时间,数值就开始单纯按 y 衰减。
归纳出的递推式是:
L_new = L_old · y + 当前周期的贡献这个式子是 PELT 能在内核里落地的根本原因:不需要保存历史数组,不需要环形缓冲,一个 u64 就装下了全部历史。每一份历史都被编码进了当前这个数里,衰减只是它随时间自然发生的结果。这也是我理解 PELT 时最关键的一步——它不是「记录再衰减」,而是「衰减即记录」。
2.4 d1/d2/d3:任意时间间隔的通用拆分
真实调度里两次更新之间的间隔当然不是 1024 的整数倍。PELT 把间隔 d 拆成三段:
d1 d2 d3 ^ ^ ^ | | | |<->|<----------------->|<--->| |---x---|------| ... |------|-----x (now) p-1 u' = (u + d1)·y^p + 1024·\Sum y^n + d3·y^0 n=1d1 是上一次更新时那个不完整周期剩下的零头,d2 是中间的完整周期,d3 是当前这个不完整周期已经走过的部分。p 是跨越的周期总数,p = 1 + d2/1024。d1 和旧负载 u 一起被衰减 p 次,d3 不衰减。这里 d1 的处理是整段逻辑里最容易看漏的地方:它属于过去,却和 u 走同一个衰减幂次,因为两者距离当前时刻的周期数是一样的。
把 2124us 那个例子代进去:u = 1010,d1 = 1016,d2 = 1024,d3 = 84,p = 2,得到 1010y² + 1016y² + 1024y + 84 ≈ 3024。数值对不对是次要的,重要的是这个拆分把「历史衰减」和「新增贡献」变成了两个可以分别用闭式公式求解的部分,直接决定了下一章代码的形状。
3. decay_load 的定点数实现:一次乘法如何吃掉 32 个周期的衰减
3.1 把 y^n 提前算成 2^32 倍的定点整数
调度器热路径上不能用浮点。内核的做法是:把 y^n × 2^32 的值预先算好,存进一张 u32 数组,运行时用「64 位乘 32 位再右移 32 位」来还原。2^32 这个缩放因子的选择很讲究——u32 刚好装下 32 位精度,乘完之后高 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, };这张表只覆盖 n 从 0 到 31。生成方式在 sched-pelt.c 里,核心就是runnable_avg_yN_inv[i] = ((1UL << 32) - 1) * pow(0.97857206, i),i 取 0 到 31。注意这里乘的是 2^32 - 1 而不是 2^32,这是典型的定点数技巧:用全 1 掩码做上界,保证结果不会溢出到 u32 之外,代价是极小的精度损失,对调度器这种本来就带统计性质的量完全可以接受。
第 0 项是 0xffffffff,也就是 1.0 的定点表示。第 31 项约等于 0.5,正好对应 y^32 = 0.5,边界校验一眼就能看出来。
3.2 decay_load 的三段结构
static u64 decay_load(u64 val, u64 n) { unsigned int local_n; if (unlikely(n > LOAD_AVG_PERIOD * 63)) /* 1 */ return 0; /* after bounds checking we can collapse to 32-bit */ local_n = n; /* * As y^PERIOD = 1/2, we can combine * y^n = 1/2^(n/PERIOD) * y^(n%PERIOD) * With a look-up table which covers y^n (n<PERIOD) * * To achieve constant time decay_load. */ if (unlikely(local_n >= LOAD_AVG_PERIOD)) { /* 2 */ val >>= local_n / LOAD_AVG_PERIOD; local_n %= LOAD_AVG_PERIOD; } val = mul_u64_u32_shr(val, runnable_avg_yN_inv[local_n], 32); /* 3 */ return val; }三段各自的职责:
第一段是边界。LOAD_AVG_PERIOD 是 32,32 × 63 = 2016。为什么是 63 而不是 64?因为第二段要做val >>= n/32,而 val 是 u64,右移超过 63 位在 C 语义里是未定义行为。2016 个周期约等于 2.06 秒,此时 2^(-63) 已经远小于任何有意义的负载量级,直接归零既安全又合理。这类位宽保护在内核里到处都是,是我看内核代码时养成的第一个习惯——看到移位就先找边界检查。
第二段把 n 拆成商和余数。商对应的是纯右移,因为每 32 个周期正好衰减一半;余数落回 0 到 31,交给查表。
第三段是真正的乘法。mul_u64_u32_shr在 64 位平台上通常就是一次 mulx 加一次取高位,把 val(最大约 47742,远小于 2^32)和表中的定点值相乘,右移 32 位去掉缩放。整个过程没有分支预测失败、没有浮点上下文切换、没有循环。
3.3 常数时间的意义
如果把 decay_load 写成朴素循环,n 次衰减就要 n 次乘法。在负载均衡的路径上,一次遍历可能要对几十上百个实体调用它,n 动辄上千,开销不可接受。而 PELT 的选择是:把 y 的特殊性(y^32 = 0.5)编码进数据结构(那张 32 项的表),于是衰减从「循环」变成「移位 + 查表 + 乘法」。
这套思路有个副作用值得留意:因为表只有 32 项,所有 n ≥ 32 的衰减都依赖 y^32 严格等于 0.5 这个近似。实际 y^32 算出来是 0.49999 左右,每 32 个周期累积一点误差,但在 2016 个周期内误差远小于负载量级本身,可以忽略。用一处设计上的精确性,换取整条路径的常数时间,这是 PELT 里最典型的取舍。
4. sched_avg 与一次完整的负载更新:从 period_contrib 到 load_avg
4.1 sched_avg 的九个字段
每个 sched_entity 和每个 cfs_rq 都内嵌一个 sched_avg,用来记录负载历史。字段语义如下:
| 字段 | 类型 | 含义 |
|---|---|---|
| last_update_time | u64 | 上次更新的时刻,单位 ns,用于算时间间隔 |
| load_sum | u64 | 基于 runnable 时间的未加权贡献总和 |
| runnable_load_sum | u64 | 供组调度使用的可运行贡献总和 |
| util_sum | u32 | 基于 running 时间的贡献总和,按 CPU 容量缩放 |
| period_contrib | u32 | 上次更新时不足一个周期的零头 |
| load_avg | unsigned long | load_sum 归一化后的平均值,上限是实体权重 |
| runnable_load_avg | unsigned long | runnable_load_sum 归一化后的平均值 |
| util_avg | unsigned long | util_sum 归一化后的平均值 |
理解这几个字段的关键是分清「sum」和「avg」。sum 是几何级数的原始累加值,量纲是「时间 × 权重」,会随时间衰减;avg 是 sum 除以一个随周期零头微调的分母,量纲被归一化成「相当于多少个满负荷的实体」。
4.2 初始化:task se 满负载,group se 零负载
void init_entity_runnable_average(struct sched_entity *se) { struct sched_avg *sa = &se->avg; memset(sa, 0, sizeof(*sa)); /* * Tasks are intialized with full load to be seen as heavy tasks until * they get a chance to stabilize to their real load level. * Group entities are intialized with zero load to reflect the fact that * nothing has been attached to the task group yet. */ if (entity_is_task(se)) sa->runnable_load_avg = sa->load_avg = scale_load_down(se->load.weight); se->runnable_weight = se->load.weight; /* when this task enqueue'ed, it will contribute to its cfs_rq's load_avg */ }task se 初始化为满权重,这是一个刻意的偏保守设计。新任务还没跑过,它的真实负载是未知的,如果初始化为 0,它会被当成轻负载,可能立刻被迁移到最闲的核上,甚至抢不到足够的 CPU 时间。把它当成「重任务」起步,等它真正跑起来再由衰减机制自然降到真实水平,最多多占用一点资源,不会让新任务饿死。这个「宁可高估、不可低估」的取向,在调度器里反复出现。
group se 初始化为 0 就很好理解:组里还没挂任何任务,负载当然是零。注意 runnable_weight 在这里先赋成 se->load.weight,后续会随组内可运行任务的变化不断重算。task se 的 runnable_weight 恒等于 weight,group se 的 runnable_weight 则总是小于等于 weight——这个差值就是「组里有多大比例的任务真的在 rq 上」。读 group 负载时如果不区分这两个量,很容易把「组很大但基本都在睡」误判成「组很忙」,这是我在第 5 章要重点说的一个坑。
4.3 accumulate_sum:Step 1 衰减旧账,Step 2 累计新账
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; /* p == 0 -> delta < 1024 */ u64 periods; scale_freq = arch_scale_freq_capacity(cpu); scale_cpu = arch_scale_cpu_capacity(NULL, cpu); delta += sa->period_contrib; /* 1 */ periods = delta / 1024; /* A period is 1024us (~1ms) */ /* 2 */ /* * Step 1: decay old *_sum if we crossed period boundaries. */ if (periods) { sa->load_sum = decay_load(sa->load_sum, periods); /* 3 */ sa->runnable_load_sum = decay_load(sa->runnable_load_sum, periods); sa->util_sum = decay_load((u64)(sa->util_sum), periods); /* * Step 2 */ delta %= 1024; contrib = __accumulate_pelt_segments(periods, /* 4 */ 1024 - sa->period_contrib, delta); } sa->period_contrib = delta; /* 5 */ 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; }对应第 2 章的公式:Step 1 处理 u·y^p,Step 2 处理 d1·y^p + 1024·Σy^n + d3 那一坨。几个细节值得逐条拆:
period_contrib是整个函数的心脏。它记着「上次更新时,当前周期已经走了多少 us」。这次更新时先把它加回 delta,才能凑出完整的周期边界。少了它,时间就会被反复舍入,短间隔的高频更新会让周期永远凑不满,负载永远涨不上去。更新逻辑在最后一行:sa->period_contrib = delta,其中 delta 已经被%= 1024削减成零头。
delta是 us 量级(调用者在 ___update_load_sum 里右移了 10 位),除以 1024 得到跨越的周期数。如果 periods 为 0,说明这次更新没跨周期,Step 1 直接跳过,只做 Step 2 的零头累加。
cap_scale(contrib, scale_freq)按 CPU 当前频率对贡献做缩放。这是为了处理降频场景:同样跑 1ms,2GHz 和 3GHz 干的活不一样,负载不应该一样。这个缩放用的是 arch 层抽象的接口,不同架构的实现不同,但语义一致——把时间贡献折算成与频率无关的「标准工作量」。
三个if决定了这本账记在哪。调用者传进来的 load 和 runnable 是 0 或 1(!!se->on_rq),所以 load_sum 里加的是未加权的 contrib,权重在归一化时才乘回去。running 决定 util_sum 是否累加,注意它额外乘了 scale_cpu,这样 util_avg 才能和 CPU 容量直接比较。低容量的核上,util_avg 涨得更慢,符合直觉。
4.4 __accumulate_pelt_segments:等比数列的闭式解
static u32 __accumulate_pelt_segments(u64 periods, u32 d1, u32 d3) { u32 c1, c2, c3 = d3; /* y^0 == 1 */ /* * c1 = d1 y^p */ c1 = decay_load((u64)d1, periods); /* * p-1 * c2 = 1024 \Sum y^n * n=1 * * inf inf * = 1024 ( \Sum y^n - \Sum y^n - y^0 ) * n=0 n=p */ c2 = LOAD_AVG_MAX - decay_load(LOAD_AVG_MAX, periods) - 1024; return c1 + c2 + c3; }c2 那一行是整篇代码里最巧的一处。原本从 n=1 累加到 p-1,是个长度为 p 的求和,跟 p 成正比。但把视角拉到无穷级数上,利用「无穷和 = 前 p 项 + 第 p 项之后的所有项」以及「第 p 项之后的和 = 无穷和 × y^p」,就得到一个常数时间的表达式:
c2 = MAX - MAX·y^p - 1024MAX 就是 1024 × (1 + y + y² + ...),即无穷级数和。减 1024 是为了从 n=0 起算的和里剔掉 n=0 那一项,因为 c2 从 n=1 开始。这一手把「一个随 p 增长的循环」压成了「两次 decay_load 加一次减法」,无论中间隔了 1 个周期还是 1000 个周期,开销都一样。
我应该在这里提醒一句:这个简化依赖级数收敛。y < 1 是前提,所以它是建立在「衰减」这个设定上的,不是通用的求和公式。
4.5 LOAD_AVG_MAX 与那一次除法
MAX 的值是 47742。它由用户态工具 calc_converged_max 算出来,核心是一个不动点迭代:max = (max × y_inv) >> 32) + 1024,直到 max 不再变化。等比数列公式给的是理论值 1024/(1-y) ≈ 47742.9,代码算出来是 47742,差的那点就是定点运算的舍入误差。这个数在代码里当宏用,直接参与减法,所以精度必须和 decay_load 的实现严格一致,不能自己拿数学库算一个更「准」的值塞进去。
平均负载的更新在 ___update_load_avg:
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; sa->load_avg = div_u64(load * sa->load_sum, divider); sa->runnable_load_avg = div_u64(runnable * sa->runnable_load_sum, divider); WRITE_ONCE(sa->util_avg, sa->util_sum / divider); }分母 divider = MAX - 1024 + period_contrib,可以理解成「当前这个不完整周期进度下的有效满分」。一个实体持续满负荷运行,load_sum 会趋近 MAX - 1024 + period_contrib(因为最后那个零头还没凑满一周期),除下来负载趋近 load 参数本身,也就是实体的权重。这就是为什么 init 里把 load_avg 初始化成 weight——它是这个量的稳态上限。
注意 load_avg 和 runnable_load_avg 的分子里乘了传入的 load(对 se 来说就是 se_weight(se)),而 util_avg 不乘。原因是 load_sum 在累加时用的是 0/1 的 on_rq,没加权;要得到「这个实体的负载有多大」这个概念,必须在归一化时把权重乘回来。而 util 表达的是「占用了多少 CPU 时间比例」,与权重无关,所以直接除。这两类量的语义差异,是后面用它们做决策时最容易混的地方。
5. 排查清单:PELT 读数不对时先看这五处
5.1 读出来的 load_avg 长时间不动
现象:跟踪某个 se 的 avg.load_avg,发现它几个 tick 都不变,甚至永远等于初始化值。
原因:avg 是在 update_load_avg 里算的,直接读结构体字段读到的是上一次更新留下的旧值。如果这个 se 长时间没被 enqueue/dequeue,也没有 tick 落在它身上,就没人给它做更新。更隐蔽的一种情况是迁移路径上带了 SKIP_AGE_LOAD 标志,__update_load_avg_se被跳过,字段自然停在那里。
解决办法:读之前先触发一次更新,或者确认读的时刻确实在 update 之后。调试时我一般会在目标 se 上打一个 trace point,把 last_update_time 一起打出来,如果它离当前时间差了好几个 tick,说明这个数就是陈的,别拿它下结论。
5.2 频率更新很勤,负载反而涨不上去
现象:在极短的间隔里反复调用更新函数,delta 一直小于 1us,负载长期为零。
原因:___update_load_sum 里第一步就是delta >>= 10,把 ns 折成 us。如果两次更新间隔不到 1024ns,折完就是 0,函数直接返回,连 last_update_time 都不推进。这是设计上的保护,不是 bug。
解决办法:不要在高频路径上指望 PELT 给你精细的增量。PELT 的分辨率就是 1us 和 1024us 的周期,低于这个粒度的时间就该被丢掉。同时要注意sa->last_update_time += delta << 10这一行——它不是直接赋 now,而是只推进了整数倍的部分,剩下的余数留在原地,等下次一起算,这样长时间累积下来不会丢精度。如果看到 last_update_time 和 now 的差值一直不收敛,先怀疑是不是调用点太密。
5.3 util_avg 和 CPU 容量比较时数字对不上
现象:拿 util_avg 去和 capacity_orig 比,发现利用率算出来总是偏低或偏高,尤其在大小核机器上。
原因:util_sum 累加时乘了 scale_cpu,util_avg 的量纲是「相对于一个标准核的容量」。如果直接和某个具体核的 capacity 比,而不做归一化,就会系统性地偏。
解决办法:比较前先确认两边的量纲。频率缩放那边用的是 scale_freq,它来自 arch 层,如果平台没实现,返回的是一个固定值,这时候负载就退化成纯时间统计,别指望它反映频率变化。我踩过这个坑:在没有实现 arch_scale_freq_capacity 的平台上做功耗优化,util_avg 完全不响应降频,调了半天以为是算法问题,其实是接口没实现。
5.4 组调度的负载看起来「虚高」
现象:一个 cgroup 里大部分任务都在睡,但读出来的 load_avg 明显大于它的实际繁忙程度。
原因:group se 的 load_avg 是按权重归一化的量,它的上限是组的权重,而不是组内任务的实际运行时间。更关键的是 runnable_weight 和 weight 的区别——前者才是「可运行部分」的权重。只看 load_avg 而不看 runnable_load_avg,会丢掉「组里有多少比例在跑」这个信息。
解决办法:组层面至少同时看三个量。load_avg 反映组的份额,runnable_load_avg 反映组内真正在抢 CPU 的部分,util_avg 反映实际占用。三者之间的关系才是判断依据。这是我做多组混部压测时最花时间的地方,光看一个数几乎一定会误判。
5.5 decay_load 的边界让人误以为是精度问题
现象:某个长期睡眠的任务被唤醒后,负载贡献直接是 0,看起来像衰减「过头」了。
原因:n > LOAD_AVG_PERIOD × 63 时函数直接返回 0,也就是超过 2016 个周期(约 2 秒)的历史被彻底抛弃。这是位宽保护下的有意取舍,不是浮点误差。
解决办法:接受它,别去调这个参数。2 秒之前的历史对当前调度决策本来就没多少参考价值,而且真要算下去,u64 也装不住。如果业务确实需要更长的记忆窗口,那说明该换一套机制,而不是在这个常数上打补丁。
6. 设计取舍:固定衰减常数换来了什么,又押上了什么
PELT 最容易被低估的地方是它把一大堆决策压缩成了两个常数:1024us 的周期和 y = 0.97857206。周期定了采样粒度,y 定了记忆长度。这两者一旦固定,所有实体、所有 CPU、所有负载场景就都用同一套时间常数。
好处很直接。负载比较变成同量纲的除法,实体之间可以直接比大小;代码路径上没有循环、没有浮点、没有动态分配;多核之间迁移任务时,se 的 avg 可以原样带过去,不需要重新预热。这些性质让 PELT 成为了一个非常「好算」的基础设施,后来上层的能效调度、频率选择、任务放置全都建在它上面。
代价同样明确:这套机制对变化的响应天然滞后。一个任务从重负载骤降为轻负载,load_avg 要经过几十个毫秒才降下来;反过来从轻变重,也得等满负荷的贡献慢慢累积。对于平稳的服务器负载这没问题,对于突发型的交互负载,就可能出现「该升频时没升、该迁走时没迁」的窗口。这也是为什么后来在 PELT 之上又叠了各种加速和补偿机制,而不是直接改 y。
我做性能调优时给自己立过一条规矩:任何一次关于负载的判断,都要先确认三件事——读的是不是最新更新的值、用的是 load 还是 util、量纲和 CPU 容量是否对齐。这三件事任何一件出岔子,结论就会偏,而且偏得很隐蔽,因为你看到的永远是一个「看起来合理」的数字。从那以后我每次看调度相关的指标,都强制先把数据的更新时刻、量纲和语义过一遍,再动手改参数。希望这五条排查经验和这套核对习惯,能帮你在翻 PELT 的源码和文档时少走几段弯路。
本文还有配套的精品资源,点击获取