首页/新闻资讯/正文详情

Linux CFS PELT负载跟踪源码解析与调试避坑

发布时间:2026/11/3 19:35:25 来源:云帆数科 栏目:资讯中心
Linux CFS PELT负载跟踪源码解析与调试避坑
简介这份PDF文档面向Linux内核调度与CFS机制的学习者聚焦PELTper-entity load tracking算法的引入背景与核心原理适合已具备进程调度基础、希望深入理解负载跟踪实现的中高级读者。内容从Linux 3.8之前按运行队列跟踪负载的两个痛点切入说明无法定位负载来源与rq级负载波动大的问题进而展开按调度实体跟踪负载的动机并结合1024us周期、衰减因子y、负载贡献累加公式和4096us运行后睡眠的计算示例解释PELT如何平滑负载并降低波动。文档还涉及decay_load()的移位乘法优化、runnable_avg_yN_inv数组及Linux 4.18.0代码分析便于读者把公式、示例与内核实现对应起来。压缩包共1个PDF文件约358KB轻量便于查阅已有573人学习下载可作为CFS调度器与PELT源码学习的专题参考。1. 一个任务把整条运行队列“带偏”的现场去年排查一台线上机器的调度抖动两个线程绑在同一个物理核上一个每 20ms 醒一次、每次只跑 200us 的采集线程另一个是持续计算的压缩线程。/proc/sched_debug里那条 cfs_rq 的负载一直在 55% 上下飘看上去像“中等负载、尚有富余”可实际吞吐比预期低了近三成。把视角切到 per-entity 才看清采集线程虽然占 CPU 时间不多但它每次醒来都以满权重进队rq 级指标把它的瞬时冲击和压缩线程的真实占用搅在了一起谁该降频、谁该迁核根本说不清。Linux CFS 在 3.8 之后换了一套记账方式回答这个问题也就是 PELTper entity load tracking把物理时间切成 1024us 的周期按调度实体处于 runnable 状态的时间比例记贡献再乘衰减因子把历史压下去。这份文档基于 Linux 4.18.0 源码从公式一路拆到decay_load()、accumulate_sum()的实现适合要读调度源码、调 cgroup 权重、或做 CPU 能效实验的人。2. 记账模型1024us 周期、衰减因子与 sched_avg 结构2.1 从 rq 级到 se 级问题到底出在哪3.8 之前 CFS 只在 runqueue 这一层维护负载。这个做法有两个绕不过去的毛病文档里点得很直白一是无法确定当前负载的来源一个 rq 上挂着三个 task负载 80%你不知道是哪个 task 造成的做负载均衡时只能整条 rq 搬二是即便工作负载本身很稳定rq 级的负载值波动也很大因为 task 的入队、出队、睡眠、唤醒都在毫秒级发生采样点不同结果就不同。把粒度下沉到 sched_entity 之后每个 task以及每个 task group都有自己的一本账rq 的负载就是挂在上面的 se 负载之和。这样一来“谁重谁轻”有了局部答案负载均衡可以按 se 迁移而不是按 rq 盲搬同时因为每个 se 的账本用的是同一个衰减模型短时抖动被平滑掉了指标才有可比性。模型本身不复杂。时间被切成 1024us 的序列一个 entity 在第 i 个周期里处于 runnable 的时间是 xx 最大 1024那么它对这个周期的贡献就是 x。历史周期的贡献不丢弃但要打折第 i 个周期之前第 n 个周期的贡献乘 y 的 n 次方。总负载写成L L0 L1*y L2*y^2 L3*y^3 ... Ln*y^n其中 y 0.97857206并且刻意选成满足y^32 0.5。这个等式是整个实现的地基32 个周期约 32ms之后历史贡献减半既保证衰减足够快以跟上负载变化又让“除以 2 的幂”可以用移位实现省掉浮点和除法。这里有个容易被忽略的点runnable 状态包含两部分时间一是在 rq 里排队等 CPU二是正在 CPU 上跑。也就是说load_sum记的是“想要 CPU 的时间”不是“拿到 CPU 的时间”。真正只统计 running 时间的是另一个累加器util_sum这两者在后面会分道扬镳一个服务于负载均衡一个服务于频率和算力选型。2.2 一步步推一个任务跑 4096us 后睡着的账怎么算文档给了个非常具体的例子一个 task 从加入 rq 起连续跑 4096us然后一直睡眠。问在 1023us、2047us、3071us … 8191us 这些时刻负载贡献分别是多少。这个例子值得手推一遍因为它是理解accumulate_sum()的唯一钥匙。先把递推关系写出来。如果当前周期跑满那么当前时刻的负载 上一时刻负载乘 y 再加当前周期贡献L(i) L(i-1) * y 1024按这个式子推时刻 (us)状态负载贡献递推来源1023运行中首个周期不满1023直接等于运行时间2047运行中跨一个周期20251023 1024*y3071运行中30051023 (L11)*y4095运行中最后满周期39631023 (L21)*y5119已睡眠38770 (L31)*y6143睡眠3792L4*y7167睡眠3709L5*y8191睡眠3627L6*y注意 4095us 之后 task 睡了从 5119us 起当期贡献变成 0递推退化成纯衰减L(i) L(i-1) * y。这就是 PELT 平滑性的来源负载不会因为 task 睡下去就立刻掉到 0而是以 32ms 减半的速度往下走睡眠时间足够长才趋近于 0。反过来长睡眠的任务被唤醒时账本上残留的旧负载会让它一开始显得“有点重”这也是设计上接受的代价。但现实里更新负载的时刻不会正好落在周期边界上。文档接着推了更一般的情况上一时刻负载是 u经过 d 时间这 d 被切成三段——d1 是补齐上一个不完整周期的剩余部分d2 是若干个完整周期d3 是当前不完整周期的已过部分。设经历的周期数为 p 1 d2/1024则u (u d1) * y^p 1024 * (y^1 y^2 ... y^(p-1)) d3这个式子的每一段都有物理含义(u d1) 是“过去的总账加上补齐那一段”整体要衰减 p 次中间 d2 里每个完整周期贡献 1024各自按距离当前的周期数衰减d3 是当前周期已经过去的部分不衰减。拿上面的例子验算u1010、d2124us切出 d11016、d21024、d384、p2代入得 1010y² 1016y² 1024y 84 3024和逐周期手推一致。提示d1 这一段最反直觉。它不是“新过去的时间”而是上一个周期里没算完的零头所以它要和 u 一起衰减同样的次数而不是按当前周期算。写用户态复算程序时这里最容易错。2.3 sched_avg 与初始化task se 和 group se 为什么不一样账本落在struct sched_avg里每个sched_entity和每条cfs_rq都嵌了一个/* include/linux/sched.h, Linux 4.18 */ struct sched_avg { u64 last_update_time; /* 上次结算的时刻用来算时间差 */ u64 load_sum; /* runnable 时间的加权贡献总和 */ u64 runnable_load_sum; /* 同上但带 runnable_weight 系数 */ u32 util_sum; /* running 时间的贡献总和 */ u32 period_contrib; /* 上次没凑满 1024us 的零头 */ unsigned long load_avg; /* load_sum 归一化后的平均值 */ unsigned long runnable_load_avg; /* runnable_load_sum 归一化后的平均值 */ unsigned long util_avg; /* util_sum 归一化后的平均值 */ };三组_sum对应三种口径load_sum记录 runnable 时间含排队runnable_load_sum在 runnable 时间上再乘runnable_weightutil_sum只记 running 时间并且用 CPU capacity 做了缩放。对应的三个_avg是_sum除以一个归一化分母得到的这个分母后面会讲到它不是固定值。sched_entity里除了avg还有两个权重字段容易混struct sched_entity { struct load_weight load; /* 调度实体权重决定 vruntime 推进速度 */ unsigned long runnable_weight; /* 可运行部分的权重group se 才真正用得上 */ #ifdef CONFIG_SMP struct sched_avg avg; #endif };对 task se 来说runnable_weight永远等于load.weight对 group se 来说它小于等于load.weight表示这个 cgroup 里“当前可运行那部分”占的权重。这个区分在 cgroup 组调度做负载向上传播时非常关键runnable_load_sum而不是load_sum才是拿来做task_h_load和迁移判断的量。初始化函数里有一段很讲究的分支void init_entity_runnable_average(struct sched_entity *se) { struct sched_avg *sa se-avg; memset(sa, 0, sizeof(*sa)); /* * task 初始化为满负载避免刚创建的重任务被低估 * group 初始化为 0因为此时组里还什么都没挂。 */ if (entity_is_task(se)) sa-runnable_load_avg sa-load_avg scale_load_down(se-load.weight); se-runnable_weight se-load.weight; }task se 初始load_avg直接等于自身权重意味着一个刚 fork 出来的 task 在被观测到的头几十毫秒里账面上是“满负载”的随着 tick 更新再衰减到真实水平。这是有意的保守策略宁可高估一个新任务也不要在它已经压满 CPU 时因为账本为 0 而被调度器忽略。group se 反过来初始化为 0因为 cgroup 刚建出来时确实没有任务占用。3. 算得快才叫内核代码decay_load 与 accumulate_sum 的实现3.1 decay_load查表 移位把浮点赶出热路径公式里到处是val * y^n如果真用浮点算调度器 tick 路径上这么干是要挨骂的。内核的做法是把 y 的 0 到 31 次方乘以 2^32 预先算好塞进一张 32 项的常量表运行时用移位和乘法还原。/* kernel/sched/pelt.c, Linux 4.18 */ static const u32 runnable_avg_yN_inv[] { 0xffffffff, 0xfa83b2da, 0xf5257d14, 0xefe4b99a, 0xeac0c6e6, 0xe5b906e6, 0xe0ccdeeb, 0xdbfbb796, 0xd744fcc9, 0xd2a81d91, /* ... 共 32 项最后一项 0x82cd8698 约等于 0.5 * 2^32 ... */ };表是靠Documentation/scheduler/sched-pelt.c里的calc_runnable_avg_yN_inv()生成的思路就是runnable_avg_yN_inv[i] ((1UL 32) - 1) * pow(0.97857206, i)。32 项就够了因为y^32 0.5超过 32 次方的部分可以用“移位除以 2”来顶不需要再查表。static u64 decay_load(u64 val, u64 n) { unsigned int local_n; if (unlikely(n LOAD_AVG_PERIOD * 63)) /* 超过 2016 个周期直接归零 */ return 0; local_n n; /* * y^n (1/2)^(n/32) * y^(n%32) * 前半段用右移后半段查表 */ if (unlikely(local_n LOAD_AVG_PERIOD)) { val local_n / LOAD_AVG_PERIOD; /* 每 32 个周期砍一半 */ local_n % LOAD_AVG_PERIOD; } val mul_u64_u32_shr(val, runnable_avg_yN_inv[local_n], 32); return val; }参数就两个val是要衰减的量可能是load_sum也可能是1000这种常数n是经过了多少个周期。第一道判断LOAD_AVG_PERIOD * 63 2016是个工程上的截断y^2016约等于 2^-63乘任何u64都已经是 0再算下去纯属浪费。第二段是核心val n/32把大周期整除掉余数部分查表最后mul_u64_u32_shr()做一次 64 位乘 32 位再右移 32 位并处理溢出。注意这个函数正确性完全依赖y^32 0.5。自己改衰减系数时如果没保持这个关系val n/32那一步就成了纯错误账本会算飞。3.2 accumulate_sum 的三段式先衰减、再补周期、最后累加accumulate_sum()是把前面那个 u 公式落地的地方函数不长但信息密度高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 时的贡献即不足一个周期 */ 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; /* 一个周期 1024us */ /* 2 */ 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); delta % 1024; contrib __accumulate_pelt_segments(periods, /* 4 */ 1024 - sa-period_contrib, delta); } sa-period_contrib delta; /* 5 */ contrib cap_scale(contrib, scale_freq); /* 6 */ 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; }逐点说。第 1 步把上次留下的零头period_contrib补进delta这样除以 1024 得到的periods才是真正跨过的完整周期数如果忘了这一步每次更新都会把不足一个周期的部分丢掉误差会累积。第 2 步算出周期数。第 3 步对应公式里的u * y^p三个_sum各自衰减一次衰减的周期数一样。第 4 步交给__accumulate_pelt_segments()处理 d1 和中间那些完整周期传进去的1024 - sa-period_contrib正好是 d1——补满上一个周期还差多少。第 5 步把当前周期的零头存回去留给下次用。第 6 步的cap_scale(contrib, scale_freq)是频率不变性修正CPU 降频时同样跑 1024us实际干的活少了用当前频率相对最大频率的比例缩放贡献保证util在不同频点下可比。arch_scale_freq_capacity()在多数 x86 上返回 1024没有实现在支持cpufreq的 ARM 平台上才会真正起作用。还有三个 if。load、runnable、running三个开关决定这次更新往哪些累加器里加数。对 se 来说load和runnable通常都是“在 rq 上”这个条件running是“当前正在跑”。一个在排队但没跑的 taskload_sum和runnable_load_sum涨util_sum不涨一旦它睡下去三个都不涨只等衰减。这就是“想要 CPU”和“用掉 CPU”在数据上的分野。3.3 __accumulate_pelt_segments 与 LOAD_AVG_MAX 的等比数列技巧__accumulate_pelt_segments()处理公式的后两项其中 c2 那一段是整个 PELT 里最漂亮的一手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); /* * c2 1024 * (y^1 y^2 ... y^(p-1)) * max - max*y^p - 1024 * 其中 max 1024 * (y^0 y^1 ... ) LOAD_AVG_MAX */ c2 LOAD_AVG_MAX - decay_load(LOAD_AVG_MAX, periods) - 1024; return c1 c2 c3; }按定义c2 是一个 p-1 项的等比数列求和逐项算要循环 p 次p 可能到几十上百放在 tick 路径上不能接受。内核借用了无穷级数的和令max 1024 * Σ(n0..∞) y^n那么从 np 到无穷的和等于max * y^p因为Σ(n0..∞) y^(np) y^p * Σ(n0..∞) y^n。于是有限和可以写成两个无穷和相减再扣掉 n0 那项 1024c2 max - max*y^p - 1024一次decay_load()就搞定复杂度 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直到值不再变化也就是收敛了。之所以用定点数迭代而不是直接套等比数列求和公式1024 / (1 - y)是因为后者算出来是 47788 左右和定点实现的结果差了几十。内核要的是和运行时代码完全一致的常数否则divider会有系统性偏差所以用同样的定点运算跑一遍取收敛值47742 是这么来的。这类“用计算代替推导”的做法在内核里不少见特点是可复现、和运行时逻辑严格同构。4. 什么时候算update_load_avg 的调用链与三个累加器4.1 三个触发点enqueue、dequeue、tick账本不会自己动得有事件驱动。update_load_avg()的调用点主要有三类入队enqueue_entity()、出队dequeue_entity()、以及调度 ticktask_tick_fair()一路下来。另外 load balance 迁移 task 时也会触发因为要把账本带到新 CPU 去。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; /* * 把 task 的负载平均值带到新 CPU 上 * 同时更新 group se 的账本供迁移时算 task_h_load。 */ if (se-avg.last_update_time !(flags SKIP_AGE_LOAD)) __update_load_avg_se(now, cpu, cfs_rq, se); /* 1 */ decayed update_cfs_rq_load_avg(now, cfs_rq); /* 2 */ /* ... 组调度下还会调用 update_tg_load_avg() 向上传播 ... */ }第 1 步更新 se 自己的账本注意前面那个se-avg.last_update_time判断如果从来没更新过比如刚 init 的实体就不做结算否则now - 0会得到一个巨大的 delta算出来的东西没意义。SKIP_AGE_LOAD是给某些特殊路径用的比如新建任务首次入队时故意不结算历史让初始化给的“满负载”先保留一会儿。第 2 步更新 se 所挂的那条 cfs_rq 的账本。因为 rq 的负载语义上是“挂在上面的所有 se 之和”直接求和太贵内核用的是增量维护每次 se 的账本变化把变化量加到 rq 上。update_cfs_rq_load_avg()会先对 rq 自己的avg做一次衰减再把se的增量累加进去返回这次是否发生了衰减。4.2 __update_load_avg_seload、runnable、running 三个开关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; /* 1 */ if (___update_load_sum(now, cpu, se-avg, !!se-on_rq, /* 2 */ !!se-on_rq, cfs_rq-curr se)) { ___update_load_avg(se-avg, se_weight(se), se_runnable(se)); /* 3 */ cfs_se_util_change(se-avg); return 1; } return 0; }第 1 步对 task se 同步runnable_weight因为 task 的权重可能被nice调过每次结算刷新一遍最省事。对 group se 这行不生效它的runnable_weight由子层聚合出来。第 2 步的三个布尔值是重点前两个!!se-on_rq对应load和runnable——只要 se 在 rq 上无论排队还是正在跑就计入可运行负载第三个cfs_rq-curr se对应running——只有当这个 se 恰好是当前正在执行的才计入util_sum。这就是同一个时刻一个排队 task 的load_avg在涨而util_avg不涨的原因。第 3 步做归一化参数se_weight(se)和se_runnable(se)分别是实体权重和可运行权重注意这里是权重乘在_sum上而不是后面再乘。为什么要乘因为load_sum记录的是时间贡献不同权重的 task 跑同样长的时间对系统负载的意义不一样权重高的该被认为更“重”。___update_load_sum()负责时间归一static __always_inline int ___update_load_sum(u64 now, int cpu, struct sched_avg *sa, unsigned long load, unsigned long runnable, int running) { u64 delta; delta now - sa-last_update_time; if ((s64)delta 0) { /* 时钟回退直接对齐后退出 */ sa-last_update_time now; return 0; } /* * 用 1024ns 作为计量单位近似 1us 且移位即可完成换算 */ delta 10; /* 1 */ if (!delta) return 0; sa-last_update_time delta 10; /* 2 */ if (!load) runnable running 0; /* 3 */ if (!accumulate_sum(delta, cpu, sa, load, runnable, running)) return 0; return 1; }第 1 步的delta 10常被误解成“ns 转 us”严格说不是。1024ns 才约等于 1us内核是为了省一次除法才用右移 10 位近似误差约 2.3%。这个近似在所有地方一致所以不影响相对比较但如果你写用户态工具去精确复算load_sum必须用同样的 1024 而不是 1000否则对不上。第 2 步很妙delta 10之后加回last_update_time被截掉的那不到 1024ns 的零头没有被丢弃只是留到下次再算。这是避免长期累积漂移的关键否则高频更新下每次丢几百纳秒几小时后账本会明显偏小。第 3 步if (!load)是个一致性保护不在 rq 上的实体runnable 和 running 都强制清零。理论上调用方不该传这种组合但防御一下比事后查玄学便宜。4.3 ___update_load_avgdivider 里为什么要减 1024最后一步把_sum变成_avg文档在___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; /* Step 2: 归一化得到平均值 */ 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是归一化分母。如果实体一直满载运行load_sum会收敛到多少按定义一个周期贡献 1024历史按 y 衰减无穷和就是LOAD_AVG_MAX。但这个和的第一项1024是当前周期的没有衰减而实际计算里period_contrib是当前周期已经过去的部分最大 1023。所以满载实体的稳定值不是LOAD_AVG_MAX而是LOAD_AVG_MAX - 1024 period_contrib。用这个值做分母才保证一个权重为 w、长期跑满的 task 的load_avg恰好稳定在 w语义上“满负载 自身权重”后续比较才有意义。WRITE_ONCE只包了util_avg因为util_avg会被频率选点、CPU 算力估算等路径无锁读取而load_avg只在调度器内部持 rq lock 时用。这个细节在改代码时要注意别顺手把load_avg也改成无锁读会踩数据竞争。5. 避坑与排查PELT 调试里最容易翻车的几处5.1 五条踩坑记录一、新进程看起来“一上来就吃满 CPU”现象/proc/pid/sched里se.avg.load_avg在刚启动的头几十毫秒就是满权重之后才慢慢降到实际水平做压测时误以为程序一启动就满负荷。原因init_entity_runnable_average()对 task se 把load_avg和runnable_load_avg初始化成se-load.weight这是有意的保守初始化防止新建的重任务在账本为 0 时被调度器忽略。解决别拿启动瞬间的load_avg做判断等它收敛再采样或者直接看util_avg它从 0 开始涨反而更接近真实占用节奏。要观察稳态通常等 100ms 以上再看。二、改了衰减系数负载再也不收敛现象有人想让负载跟得更快把y从 0.97857206 改成 0.9编译进去后load_avg忽大忽小甚至在迁移时溢出。原因decay_load()里val n/32那一步的正确性完全建立在y^32 0.5上严格等价于1/2^(n/32) * y^(n%32)。y0.9时y^32 ≈ 0.034而不是 0.5移位那一步就成了错误缩放。同时runnable_avg_yN_inv[]表还是旧值两张皮。解决要么不动y要么连表一起重新生成——用Documentation/scheduler/sched-pelt.c里的calc_runnable_avg_yN_inv()按新的y打印整张表覆盖内核源码同时用calc_converged_max()重新生成LOAD_AVG_MAX。三处必须同步缺一个都会算飞。三、util_avg超过 1024或者大小核上同负载数值不同现象明明说util_avg满值 1024实际读出来 1200 多同一个负载跑在大核和小核上util_avg差了快一倍。原因util_avg的归一化分母divider是固定的但累加时sa-util_sum contrib * scale_cpuscale_cpu来自arch_scale_cpu_capacity()不同核的 capacity 不同。在 big.LITTLE 或带 EAS 的平台上大核 capacity 可能就是 1024小核只有 512 甚至更低跨核比较必须先过一遍 capacity 换算。解决比较之前用capacity_of(cpu)做归一别直接比util_avg绝对值。判断“满不满”用util_avg * 1024 / capacity_of(cpu)。另外检查arch_scale_freq_capacity()是否在你的平台上被实现了实现了的话降频也会影响结果。四、睡眠任务的负载“掉不下去”看着像泄漏现象一个任务睡了 200ms 才被唤醒期间它的load_avg几乎没变唤醒瞬间还保持着一个不低的值。原因账本是被事件驱动的睡着的 task 不参与 enqueue/dequeue/ticklast_update_time就停在最后一次更新的时刻。等它下次醒来入队___update_load_sum()用now - last_update_time一次性补算这几百毫秒的衰减。所以你在/proc 里读到的值是“最后更新时刻”的值不是“当前时刻”的值。解决这是预期行为不是泄漏。要读真实值得在它醒来之后再读写监控工具的话可以用last_update_time和当前时钟自己补算一次衰减再展示。另外注意n 2016个周期后decay_load()直接返回 0睡够两秒左右负载就彻底清零了。五、cgroup 下父组负载和子组之和对不上现象开了组调度父 cgroup 的load_avg不等于几个子 cgroup 之和差得很明显。原因group se 用的是runnable_weight而不是load.weight它小于等于总权重只表示“可运行那部分”。而且向上聚合是靠update_tg_load_avg()增量传播的不是每次读取时求和传播有延迟。另外这个机制依赖CONFIG_FAIR_GROUP_SCHED没开的话组 se 的行为完全不同。解决先确认内核配置开了组调度看cfs_rq-tg_load_avg_contrib的传播是否正常对比时用runnable_load_avg而不是load_avg前者才是组调度里真正参与task_h_load计算的量。5.2 观察点与排查顺序排查 PELT 相关问题我一般按这个顺序看从外到内第一步看/proc/pid/sched里面有se.load.weight、se.runnable_weight、se.avg.load_avg、se.avg.util_avg、se.avg.last_update_time。先确认权重对不对再看平均值最后看更新时间戳是不是“卡住”了。第二步看/proc/sched_debug或/sys/kernel/debug/sched/debug里面每条 cfs_rq 会打印load_avg、util_avg、runnable_load_avg。如果 se 正常但 rq 不正常问题在update_cfs_rq_load_avg()的增量维护如果 rq 也不正常往上找调用路径。第三步看内核配置CONFIG_SMP、CONFIG_FAIR_GROUP_SCHED、CONFIG_SCHED_DEBUG有没有开。sched_avg整个结构体是包在#ifdef CONFIG_SMP里的UP 内核上根本没这东西代码里直接引用会编译不过。第四步才是回头看公式。多数“算错了”的情况其实不是公式问题而是采样时刻不对、或者拿不同口径的量在做比较。提示last_update_time的单位是 ns而且是用cfs_rq_clock_task()取的它已经扣掉了 irq 和 steal time。自己写工具对比时用sched_clock()会差一截看起来像时钟错乱实际是基准不同。6. 二次开发在 4.18 内核上做 PELT 实验6.1 写个用户态复算器先对齐公式再动内核直接改内核做实验编译一次十几分钟出问题还不好定位。我的习惯是先写一个用户态小程序把同样的输入喂进去看能不能和内核对上。对不上的地方九成是你对公式的理解有偏差这时候改内核纯属浪费。/* pelt_sim.c: 复算 PELT 的 load_sum用来对照内核 sched_debug */ #include stdio.h #include stdint.h #include math.h #define LOAD_AVG_PERIOD 32 #define LOAD_AVG_MAX 47742 static uint32_t yN_inv[LOAD_AVG_PERIOD]; static void build_table(void) { for (int i 0; i LOAD_AVG_PERIOD; i) yN_inv[i] (uint32_t)(((1ULL 32) - 1) * pow(0.97857206, i)); } static uint64_t mul_shr32(uint64_t val, uint32_t mul) { return (uint64_t)(((unsigned __int128)val * mul) 32); } static uint64_t decay_load(uint64_t val, uint64_t n) { unsigned int local_n; if (n LOAD_AVG_PERIOD * 63) return 0; local_n (unsigned int)n; if (local_n LOAD_AVG_PERIOD) { val local_n / LOAD_AVG_PERIOD; local_n % LOAD_AVG_PERIOD; } return mul_shr32(val, yN_inv[local_n]); } static uint32_t segments(uint64_t periods, uint32_t d1, uint32_t d3) { uint32_t c1 (uint32_t)decay_load(d1, periods); uint32_t c2 LOAD_AVG_MAX - (uint32_t)decay_load(LOAD_AVG_MAX, periods) - 1024; return c1 c2 d3; } /* 模拟一个实体每 1ms 调一次running 传 1 表示这次跑了整整一个周期 */ static void tick(uint64_t *sum, uint32_t *period_contrib, int running) { uint64_t delta 1024; /* 1024ns 单位下的一个周期 */ uint32_t contrib; delta *period_contrib; uint64_t periods delta / 1024; if (periods) { *sum decay_load(*sum, periods); delta % 1024; contrib segments(periods, 1024 - *period_contrib, (uint32_t)delta); } else { contrib (uint32_t)delta; } *period_contrib (uint32_t)delta; if (running) *sum contrib; } int main(void) { uint64_t sum 0; uint32_t pc 0; build_table(); for (int i 0; i 100; i) /* 满载跑 100ms */ tick(sum, pc, 1); for (int i 0; i 100; i) /* 再睡 100ms */ tick(sum, pc, 0); uint32_t divider LOAD_AVG_MAX - 1024 pc; printf(load_sum%llu period_contrib%u load_avg%llu\n, (unsigned long long)sum, pc, (unsigned long long)(sum / divider)); return 0; }这段代码的逻辑和内核一一对应build_table()还原calc_runnable_avg_yN_inv()decay_load()照抄segments()对应__accumulate_pelt_segments()tick()是accumulate_sum()的简化版省掉了权重缩放和 scale_freq。参数上只有两点要注意时间单位统一用 1024ns不要混 ns 和 usrunning开关决定往不往_sum里加和前面讲的三个累加器是一个道理。跑出来的load_avg在满载 100ms 后应该稳定在 1024 附近睡 100ms 后掉到一半左右因为每 32ms 减半100ms 约三个半减半周期剩 1/2^3.1 ≈ 11%具体值取决于更新粒度。如果和你预期差得远先查公式理解再查内核。对齐之后再做内核侧实验就心里有底了。改一个参数、重新编译、用/proc/sched_debug采样若干次把数值丢进脚本和用户态结果对比几轮下来就能确认改动是否有效。6.2 改参数与验证的边界PELT 上能动的点其实不多而且每个都有连带影响改动点位置连带影响验证方式衰减系数 ypelt.c 表 LOAD_AVG_MAX必须保持 y^320.5否则移位近似失效用户态复算对齐后再上机LOAD_AVG_PERIOD多个宏影响 2016 截断值和表长度改完检查所有引用点arch_scale_freq_capacity架构代码直接影响 util_sum 的可比性固定频点下测稳定值初始负载策略init_entity_runnable_average影响新任务冷启动判断测 fork 风暴下的调度公平性我的教训是一开始觉得改y最直接想让负载跟得快一点结果忘了重新生成表和LOAD_AVG_MAX编译进去之后load_avg在各种边界上乱跳排查了整晚才发现是移位那一步错了。从那以后我每次动 PELT 参数都强制先跑一遍用户态复算器把表、常数、公式三者对齐再动内核。这一步多花十分钟能省掉后面几小时的玄学排查。希望帮到你。本文还有配套的精品资源点击获取

相关推荐

Ubuntu 18.04 上 Vina 1.1.2+MGLTools 1.5.6+Slurm 批量对接
Ubuntu 18.04 上 Vina 1.1.2+MGLTools 1.5.6+Slurm 批量对接

简介:这份 PDF 面向生物信息、计算化学方向的学习者与科研人员,围绕 Linux 环境下 AutoDock Vina 分子对接与 Slurm 集群调度的配合展开,适合希望把单机对接流程迁移到批量化、集群化任务中的中级读者。压缩包内仅有 1 个 PDF 文件&#xff0… · 2026/11/3 19:34:25

Ubuntu下批量分子对接:Open Babel、Vina与Slurm并行实践
Ubuntu下批量分子对接:Open Babel、Vina与Slurm并行实践

简介:围绕 Autodock Vina 批量分子对接的 PDF 文档,面向生物计算、药物设计方向的学习者,以及需要在集群上开展虚拟筛选的研究人员。内容覆盖 Ubuntu 18.04 下从零搭建对接流程:安装 Open Babel、Autodock Vina 与 MGLTools&#… · 2026/11/3 19:34:25

Linux常用命令速查与运维排查手册
Linux常用命令速查与运维排查手册

简介:《10分钟让你掌握Linux常用命令》是一份面向Linux初学者、运维入门者和日常开发人员的命令速查型PDF,重点解决目录与文件操作不熟、日志排查和权限管理易出错等问题。内容按使用场景归类,覆盖目录切换与创建、文件增删改查、内容查看与v… · 2026/11/3 19:34:25

条码标签打印软件流水号跳号设置与漏号排查指南
条码标签打印软件流水号跳号设置与漏号排查指南

条码标签打印软件里最容易被做成“事故”的一个功能,就是流水号。我这些年帮不少仓库、产线、门店处理过标签打号的问题,其中问得最集中的一类就是——为什么我的流水号打出来是跳号的。有意思的是,“跳号”这个词在条码标签打印软件里其实是… · 2026/11/3 22:41:24

Elasticsearch中文分词优化与IK分词器实战
Elasticsearch中文分词优化与IK分词器实战

1. 为什么需要自定义 Elasticsearch 分词器在中文搜索场景中,默认的分词器往往无法满足业务需求。以电商平台为例,当用户搜索"苹果手机"时,系统需要准确识别这是一个完整商品名称,而不是将"苹果"和"手机… · 2026/11/3 22:41:24

Vue.js动态样式绑定实战与优化技巧
Vue.js动态样式绑定实战与优化技巧

1. 动态样式绑定的核心价值在Vue.js开发中,样式管理一直是前端工程师需要面对的重要课题。传统的静态class绑定方式虽然简单直接,但在处理复杂交互逻辑时往往显得力不从心。v-bind指令(通常简写为:)为class绑定带来了革命性的灵活… · 2026/11/3 22:41:24

零基础Linux入门:从计算机基础到man、nano与SELinux
零基础Linux入门:从计算机基础到man、nano与SELinux

简介:《鸟哥的Linux私房菜-基础篇》第四版以PDF电子书形式呈现,面向具备基本计算机操作能力、希望系统入门Linux的初学者及需要巩固基础的IT技术人员,重点解决计算机组成、操作系统原理与Linux常用工具之间的知识断层。资源包仅含1个PDF文件&… · 2026/11/3 22:41:24

研究生毕业文章的致谢部分要单独成文,科迅捷AI(官方网站:www.kexunjie.com)说明了段落安排
研究生毕业文章的致谢部分要单独成文,科迅捷AI(官方网站:www.kexunjie.com)说明了段落安排

研究生毕业文章写到致谢这一页,多数人随手写三段:感谢导师、感谢同学、感谢父母,翻完交差。可这一页恰恰是评审最后读到的文字,也是最容易被看出火候的地方。致谢不是客套话大拼盘,它要单独成文,段落之间有… · 2026/11/3 22:41:24

从零手搓AI工程:数据契约与特征一致性实战避坑指南
从零手搓AI工程:数据契约与特征一致性实战避坑指南

1. 从零手搓AI工程:为什么我不建议你直接调包第一次看到ai-engineering-from-scratch这个项目名的时候,我正坐在工位上啃一个调了三天都没收敛的推荐模型。当时第一反应是:又来了一个教人从零实现Transformer的教程仓库。但点进去翻了翻结构&… · 2026/11/3 22:40:22

双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 13:10:28

STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 6:04:24

DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 5:52:46

STM32F411RE与MAX14431实现SPI隔离:工业现场抗共模干扰实战
STM32F411RE与MAX14431实现SPI隔离:工业现场抗共模干扰实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59

ESP32+大模型AI硬件开发:从Demo到产品的8个工程问题
ESP32+大模型AI硬件开发:从Demo到产品的8个工程问题

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59

基于MAX14431BAEE+与PIC18F85J10的SPI隔离方案设计与实践
基于MAX14431BAEE+与PIC18F85J10的SPI隔离方案设计与实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59

双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 13:10:28

STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 6:04:24

DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 5:52:46

了解更多?预约专属演示

我们的顾问将为您一对一讲解产品与方案

企业微信二维码