Linux PELT负载跟踪源码拆解:1024us衰减与sched_avg 简介《CFS调度器4-PELT(per entity load tracking)》是一份面向Linux内核调度学习者与性能调优开发者的PDF技术笔记聚焦CFS中PELT机制的引入缘由、跟踪模型与工程实现。内容从Linux 3.8前按运行队列跟踪负载导致来源不明、波动较大的问题切入解释per-scheduling entity跟踪如何细化负载归因并展开1024us周期、衰减因子y、负载累加公式、decay_load()移位乘法优化及runnable_avg_yN_inv预计算等关键点配有连续运行与睡眠场景的数值示例代码分析基于Linux 4.18.0。资源包为1个PDF文件约358KB便于单篇查阅与归档。已有573人学习适合希望理解CFS负载跟踪原理、补齐内核调度知识链路的读者。1. PELT 为什么值得单独拆从 rq 级负载到 per-entity 的转折linux CFS 的负载跟踪在 3.8 之后换了一套账本以前看 runqueue现在看每个调度实体。你如果只用 /proc/loadavg 判断忙闲很容易被一个已经睡了的线程留下的衰减尾巴骗到——这不是 bug是 PELT 把历史负载按周期衰减后仍然算在账上。这份资料围绕 Linux 4.18.0 把 PELT 的 1024us 周期、y^320.5、sched_avg 字段和 accumulate_sum 拆到函数级适合想改调度策略、调负载均衡、排查 CPU 利用率虚高的内核与性能工程师。先跟一遍 1023us 到 8191us 的算例再对着源码把 update_load_avg 串起来比从调度器入口硬啃省力。2. 1024us 与 y^320.5把衰减账算平再读源码2.1 为什么把时间切成 1024us 的周期PELT 要解决的是 rq 级负载跟踪的两个老毛病一是看不出负载到底来自哪个调度实体二是工作负载稳定时 rq 级读数仍然跳得厉害。把时间切成 1024us 的周期每个周期里一个实体处于 runnable 状态多久就对负载贡献多少这样负载来源被压到 se 这一级读数也不再是整条 runqueue 混在一起的一锅粥。runnable 时间包含两段在 rq 里等着上 CPU 的时间以及正在 CPU 上跑的时间这两段都算“可运行”所以 load_sum 和 util_sum 的统计口径并不一样后面读字段时别混。一个周期内 runnable 了 x 微秒贡献就是 x/1024多个周期的贡献按 y 的幂次衰减后累加公式写成 L L0 L1y L2y^2 L3y^3 ... Lny^n。y 不是随手拍的取 y^32 0.5也就是经过 32 个周期、大约 32ms 后历史负载衰减一半这样既保留历史又不让旧账压死新任务。2.2 手工算例4096us 运行后睡眠的八个时刻正文给了一个很适合拿纸笔跟的算例一个 task 从加入 rq 后一直运行 4096us然后一直睡眠问 1023us、2047us、3071us、4095us、5119us、6143us、7167us、8191us 这些时刻的负载贡献。1023us 时就是 L01023到 2047us 时上一个周期的 1023 要乘 y 衰减一次再加上本周期跑满的 1024写成 L1 1023 1024y 2025到 3071us 时 L2 1023 1024y 1024y^2 3005到 4095us 时 L3 1023 1024y 1024y^2 1024y^3 3963。4096us 之后 task 睡眠5119us 时新增贡献为 0只剩历史项衰减L4 0 1024y 1024y^2 1024y^3 1024y^4 38776143us 时 L5 0 0 1024y^2 1024y^3 1024y^4 1024y^5 37927167us 时 L6 L5y 37098191us 时 L7 L6y 3627。这组数最直观的地方是睡眠之后负载不是立刻归零而是按周期往下掉所以看瞬时读数时别把衰减尾巴当成“任务还在跑”。2.3 decay_load 与定点数y^n 怎么变成移位和乘法内核不会为了算负载去跑浮点decay_load() 把 valy^n 拆成移位和 32 位乘法。代码里先做边界判断n 大于 LOAD_AVG_PERIOD63 时直接返回 0LOAD_AVG_PERIOD 是 32也就是超过 2016 个周期后认为衰减到 0然后利用 y^320.5把 n 拆成 n/32 和 n%32前者用右移表示 1/2 的幂后者查 runnable_avg_yN_inv 表最后 mul_u64_u32_shr(val, runnable_avg_yN_inv[local_n], 32) 一次乘加完成。runnable_avg_yN_inv 存的是 y^n*2^32n 从 0 到 31数组首项是 0xffffffff第二项 0xfa83b2da第三项 0xf5257d14和正文给出的表一致。这个表的生成逻辑在 sched-pelt.c 的 calc_runnable_avg_yN_inv() 里本质是 ((1UL 32) - 1) * pow(0.97857206, i)i 取 0 到 31。读这段代码时重点看两个参数LOAD_AVG_PERIOD32 是衰减半衰期的周期数runnable_avg_yN_inv 是定点化后的 y 幂表二者合起来保证常数时间算衰减。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; if (unlikely(local_n LOAD_AVG_PERIOD)) { val local_n / LOAD_AVG_PERIOD; /* y^32 0.5用右移折半 */ local_n % LOAD_AVG_PERIOD; } val mul_u64_u32_shr(val, runnable_avg_yN_inv[local_n], 32); return val; }这段代码的逻辑是先把大周期数拆成“折半次数”和“查表余数”再做一次 64 位乘 32 位右移 32 位的定点乘法。参数 val 是待衰减的负载和n 是经过的周期数边界 2016 不是随便写的是 32*63超过这个范围后 y^n 小到可以当 0。实际读源码时别只看函数名local_n / LOAD_AVG_PERIOD 和 local_n % LOAD_AVG_PERIOD 这两步决定了衰减精度改任何一个常量都会让算例对不上。2.4 用 python 复现一遍衰减账把正文的八个时刻用 python 跑一遍比盯着公式硬看快。下面的代码按“每跨一个周期旧负载乘 y新周期加上本周期贡献”的递推写runs 数组前四个周期跑满 1024us后四个周期睡眠新增 0输出时刻按 1023 k*1024 取周期末尾。y 0.97857206 # y^32 0.5 反推出来的单周期衰减因子 runs [1024, 1024, 1024, 1024, 0, 0, 0, 0] # 前 4 个周期运行后 4 个周期睡眠 L 0.0 for k, c in enumerate(runs): L L * y c # 旧负载衰减一次再加本周期贡献 print(f{1023 k * 1024}us: {L:.0f})这段代码的逻辑是把连续时间离散成 1024us 周期参数 y 控制历史衰减速度runs 控制每个周期是否跑满。运行结果会落在 1023、2025、3005、3963、3877、3792、3709、3627 附近和正文算例的尾数差异来自 y 的截断和取整。你可以把 runs 改成 [512, 1024, 0, ...] 观察半周期贡献也可以把 y 改成 0.9 看衰减变慢后负载尾巴有多长但别把这个 y 当成可调参数内核里的 y 由 y^320.5 定死用户态调不了。提示算例里的时间点取的是周期末尾实际内核用 delta 累加 period_contrib 再整除 1024边界上会差几微秒别拿算例尾数去卡内核打印值。3. sched_avg 与调度实体字段、初始化与 task/group 的分岔3.1 sched_avg 里每个字段对应哪本账Linux 用 struct sched_avg 记录 se 或者 cfs_rq 的负载信息字段不多但每个字段的统计口径不一样。last_update_time 是上一次负载更新时间用来算时间间隔load_sum 是基于 runnable 时间的负载贡献总和runnable 包含等 CPU 和正在跑runnable_load_sum 也是 runnable 口径但在组调度里和 load_sum 的用法有差别util_sum 是基于 running 时间的贡献总和running 只算真正在 CPU 上执行的时间period_contrib 记的是上次更新不足 1024us 周期的那一段load_avg、runnable_load_avg、util_avg 是对应的平均值。读 /proc/sched_debug 时经常看到 load_avg 和 util_avg 并排如果一个任务等 CPU 很久但跑得少load_avg 会上去util_avg 不会跟着涨这就是两本账的差别。字段统计口径读的时候注意last_update_time上次更新时间迁移、tick 漏更时先查它load_sumrunnable 时间贡献总和包含等待和运行runnable_load_sumrunnable 口径的负载和组调度里和 load_sum 有区别util_sumrunning 时间贡献总和只算真正上 CPUperiod_contrib不足一个周期的余量算周期数时要先加上它load_avg / runnable_load_avg / util_avg平均值看趋势不看单点3.2 task se 与 group se 初始化差异init_entity_runnable_average() 把 sched_avg 清零后对 task se 和 group se 走了两条路。task se 的 runnable_load_avg 和 load_avg 初始化为 scale_load_down(se-load.weight)注释写得很直白任务先按满负载看待等它有机会稳定到真实负载水平group se 则初始化为 0因为组里还没挂任何任务。runnable_weight 对 task se 就是 se-load.weight对 group se 是实体权重的一部分表示组 runqueue 的可运行部分后续会不断更新。这个差异是很多误判的来源你看到某个 group se 的 load_avg 是 0不一定代表组里没活可能只是还没更新你看到 task se 初始 load_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; }; void init_entity_runnable_average(struct sched_entity *se) { struct sched_avg *sa se-avg; memset(sa, 0, sizeof(*sa)); if (entity_is_task(se)) sa-runnable_load_avg sa-load_avg scale_load_down(se-load.weight); /* task 先按满负载初始化 */ se-runnable_weight se-load.weight; /* group se 后续会更新这个值 */ }这段代码的逻辑是先清空账本再按实体类型给初值。参数 se-load.weight 是调度实体的权重scale_load_down() 在开启权重缩放时把值压回低位task se 的初值等于权重group se 的 load_avg 保持 0。读初始化代码时别忽略注释注释里“heavy tasks until they get a chance to stabilize”就是告诉你初值不是真实负载刚创建的任务读数偏高是正常的。3.3 在源码里定位结构体和初始化函数照着资料读源码第一步不是从 fair.c 第一行开始翻而是用 grep 把结构体和初始化点钉出来。Linux 4.18.0 里 struct sched_avg 在 include/linux/sched.h 附近struct sched_entity 也在同一带update_load_avg 和 accumulate_sum 在 kernel/sched/fair.c。下面几条命令可以在源码根目录直接跑先把位置和调用点找齐再打开编辑器跟。# 找结构体定义和初始化函数 grep -rn struct sched_avg include/linux/sched.h kernel/sched/fair.c grep -rn init_entity_runnable_average kernel/sched/fair.c # 找 update_load_avg 的调用点enqueue/dequeue/tick 都会出现 grep -rn update_load_avg( kernel/sched/fair.c # 找衰减表和常量 grep -rn runnable_avg_yN_inv\|LOAD_AVG_PERIOD\|LOAD_AVG_MAX kernel/sched/fair.c这几条命令的逻辑是先定位再精读参数 -rn 表示递归并显示行号路径按 4.18 的源码树写。grep 结果里如果 update_load_avg 出现在 enqueue_entity、dequeue_entity、task_tick_fair 附近就说明负载更新跟着入队、出队和 tick 走如果只盯着 accumulate_sum 看很容易漏掉“什么时候触发更新”这个前提。定位到之后先读结构体字段再读初始化最后读更新函数顺序别反。3.4 runnable_weight 与 load.weight 的关系task se 的 runnable_weight 和 load.weight 相等group se 的 runnable_weight 总是小于等于 weight表示组里可运行部分占组权重的比例。这个值在 __update_load_avg_se() 里对 task se 会重新赋成 se-load.weight对 group se 则由上层更新。读负载均衡代码时task_h_load() 一类计算会用到 runnable_weight如果你把 group se 的 load_avg 直接当任务负载去算迁移就会把“组里可能没活”的组算得很重。实际排查时看到 group se 的 runnable_weight 和 weight 差很多先别怀疑衰减算错去看组里任务是不是都睡了。4. accumulate_sum 拆解一次更新里 step1 和 step2 各干什么4.1 update_load_avg 的三个调用点update_load_avg() 不是定时器专属enqueue_entity 把进程加进就绪队列时会调dequeue_entity 把进程摘出去时会调scheduler tick 周期性更新时也会调。函数先取 cfs_rq_clock_task(cfs_rq) 作为 now然后在 se-avg.last_update_time 存在且没有 SKIP_AGE_LOAD 标志时调 __update_load_avg_se() 更新 se再调 update_cfs_rq_load_avg() 更新 cfs_rq。这个顺序很重要先更新实体再更新队列rq 的负载是下面所有 se 负载的总和。读调用点时要注意 flagsSKIP_AGE_LOAD 会跳过 se 的 aging迁移、attach 一类路径上会出现别看到 last_update_time 没变就以为 tick 丢了。4.2 accumulate_sum 的 step1 和 step2accumulate_sum() 把公式拆成两步。第一步是 Step 1如果跨过了周期边界就用 decay_load() 把 sa-load_sum、sa-runnable_load_sum、sa-util_sum 按 periods 衰减第二步是 Step 2把 d1、d2、d3 三段拼起来算新增贡献其中 d1 是离当前最远的不完整周期剩余部分d2 是完整周期d3 是当前周期剩余部分。代码里先把 delta 加上 sa-period_contrib再 periods delta / 1024这样上一次不足一个周期的余量不会被丢掉然后 delta % 1024 得到当前周期余量contrib __accumulate_pelt_segments(periods, 1024 - sa-period_contrib, delta)。最后 sa-period_contrib delta把本次余量留给下一次。参数 load、runnable、running 在调用点非 0 即 1分别决定要不要把 contrib 累加到 load_sum、runnable_load_sum、util_sum。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; /* 1024us 一个周期 */ 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); /* Step 2 */ } 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 是两次更新之间的时间差单位已经由调用方从 ns 转成 usperiod_contrib 是上次剩下的不足 1024us 的部分periods 是本次跨过的完整周期数。scale_freq 和 scale_cpu 是频率和 CPU 容量缩放contrib 算完还要按这两个因子缩放所以同样的运行时间在不同 CPU 上对 util_sum 的贡献可能不同。读这段时重点看 delta sa-period_contrib 和 sa-period_contrib delta 这一进一出少了任何一步周期边界上的负载就会越算越偏。4.3 __accumulate_pelt_segments 里 c2 的等比数列技巧__accumulate_pelt_segments() 算的是 d1、d2、d3 三段贡献。c1 decay_load(d1, periods) 是补齐上一周期剩余部分再衰减 periods 次c3 d3 是当前周期剩余部分不需要衰减c2 是完整周期部分的和公式写成 1024 * sum(y^n)n 从 1 到 p-1。正文给了一个很巧的转换把 c2 看成 LOAD_AVG_MAX 减去尾部再减去 1024代码写成 c2 LOAD_AVG_MAX - decay_load(LOAD_AVG_MAX, periods) - 1024。LOAD_AVG_MAX 是 1024*(1 y y^2 ... y^n) 的极限等比数列求和后 n 趋向无穷值是 47742。这个 47742 不是手算出来的整数是 calc_converged_max() 用定点数迭代到 last max 得到的所以拿数学公式算出来的值可能和它差一点别拿计算器结果去对内核常量。static u32 __accumulate_pelt_segments(u64 periods, u32 d1, u32 d3) { u32 c1, c2, c3 d3; /* y^0 1 */ c1 decay_load((u64)d1, periods); c2 LOAD_AVG_MAX - decay_load(LOAD_AVG_MAX, periods) - 1024; return c1 c2 c3; }这段代码的逻辑是把无限衰减和拆成“总和减尾部”。参数 periods 是跨过的完整周期数d1 是上一周期剩余部分d3 是当前周期剩余部分。c2 里减去的 1024 是因为求和从 n1 开始不含 n0 的 1024decay_load(LOAD_AVG_MAX, periods) 是尾部衰减。读这段时如果对 c2 没感觉就把 periods1 代进去c2 47742 - 47742y - 1024差不多就是 1024y 的量级和手工算例对得上。4.4 ___update_load_sum 与 ___update_load_avg 的平均值换算___update_load_sum() 先算 delta now - sa-last_update_time然后 delta 10 把 ns 转成 us如果 delta 为 0 直接返回说明两次更新间隔不到 1us没必要衰减否则 sa-last_update_time delta 10把时间基往前推再调 accumulate_sum()。accumulate_sum() 返回 periods如果 periods 为 0说明没跨周期___update_load_sum() 返回 0平均值也不更新。___update_load_avg() 里 divider LOAD_AVG_MAX - 1024 sa-period_contrib然后 load_avg div_u64(load_sum, divider)runnable_load_avg 和 util_avg 同理。divider 加上 period_contrib 是为了把当前不足一个周期的余量算进分母这样平均值不会在周期边界上跳变。读平均值换算时别只盯 load_sumdivider 的变化同样影响读数。5. 避坑与排查PELT 读数对不上时先查这四件事5.1 时间基与单位delta 右移后为 0 的假死现象是某个 se 的 load_avg 长时间不动enqueue/dequeue 都发生了但 sched_debug 里读数像被冻住。原因常见于时间基单位___update_load_sum() 里 delta now - last_update_time 单位是 nsdelta 10 之后才是 us如果两次更新间隔不到 1024nsdelta 右移后为 0函数直接返回last_update_time 也不推进。另一种情况是调用方传进来的 now 不是 cfs_rq_clock_task()而是 wall clock 或者别的时钟时间差算出来偏小甚至为负。解决方法是先确认 now 的来源4.18 的 update_load_avg() 用的是 cfs_rq_clock_task(cfs_rq)排查时在___update_load_sum() 入口打印 now 和 last_update_time看差值是否按 ns 走如果差值长期小于 1024先查调用路径是不是在 tick 之外被高频触发别急着改衰减表。注意PELT 的最小计量单位是 us不是 ns拿 ns 差值直接套 1024 会把周期数算大 1000 倍。5.2 迁移与 last_update_time跨 CPU 的账怎么带现象是任务迁移到另一个 CPU 后load_avg 突然偏高或偏低负载均衡把它选来选去。原因是 se-avg 跟着任务走last_update_time 是全局时间基迁移时如果没把旧 CPU 上的账结清新 CPU 第一次更新会用当前 now 减去旧的 last_update_time补出一大段 delta如果迁移路径上带了 SKIP_AGE_LOAD又会跳过 se 的 aging队列负载更新了但 se 没更新。解决方法是看迁移代码里 update_load_avg() 的 flags确认是否跳过 aging排查时对比任务迁移前后 /proc/ /sched 里的 se.avg 字段和 /proc/sched_debug 里目标 cfs_rq 的 load_avg如果 se 的 load_sum 没变但 cfs_rq 变了先查队列级更新如果 se 的 last_update_time 停在旧 CPU 时间再查迁移路径有没有漏掉结账。别用“迁移后读数必须连续”去要求 PELT它是按周期补账的跨 CPU 后有一次跳变不稀奇。5.3 组调度与 runnable_weightgroup se 初始 0 的误判现象是开了 CONFIG_FAIR_GROUP_SCHED 后某个 group se 的 load_avg 一直是 0但组里明明有任务在跑或者 group se 的 runnable_weight 远小于 weight负载均衡却把它算得很重。原因是 group se 的 load_avg 初始化为 0runnable_weight 表示组 runqueue 的可运行部分组里任务都睡时它本来就该小而 task_h_load() 一类计算会把组权重和 runnable_weight 混着用读错字段就会误判。解决方法是先确认你看的是 task se 还是 group setask se 的 runnable_weight 等于 load.weightgroup se 的 runnable_weight 小于等于 weight再看组里任务是否真的 runnable。排查时用 /proc/sched_debug 找对应 cfs_rq 的 load_avg 和 util_avg再和组 se 的读数对别拿 group se 的 0 去证明调度器没更新。5.4 tick 漏更与 NO_HZ补账边界现象是长时间 idle 后突然忙起来第一次读到的 load_avg 跳得很高或者反过来忙了一阵读数却上不去。原因是 NO_HZ_IDLE 下 CPU 空闲时 tick 可能停掉负载更新不是每 1024us 都发生等下一次更新时 delta 很大accumulate_sum() 会一次性补很多周期衰减和新增贡献一起算读数自然跳。另一个边界是 delta 超过 LOAD_AVG_PERIOD*63 个周期后 decay_load() 直接返回 0旧账被清掉看起来像“负载突然归零”。解决方法是别拿单次采样判断趋势至少看几个 tick 的 sched_debug 输出排查长时间 idle 后的跳变先确认 CPU 是否进了 NO_HZ再看 last_update_time 和 now 的差值是不是几千个周期。调负载均衡参数时也要考虑这个补账边界别把一次补账跳变当成参数调坏了。# 看 cfs_rq 和 se 的负载读数连续采几次比单次可靠 grep -A 12 cfs_rq /proc/sched_debug | head -60 cat /proc/pid/sched | grep -E se\.(load|util|runnable) # 看调度 tick 和迁移相关 tracepoint 是否有更新 ls /sys/kernel/debug/tracing/events/sched/ | grep -E sched_stat|sched_migrate这几条命令的逻辑是先看读数再看事件grep -A 12 是把 cfs_rq 后面的字段带出来/proc/ /sched 里 se.avg 字段在部分 4.18 配置下可能不全没有就去 sched_debug 找对应队列。参数上别把 head -60 当成固定窗口队列多的时候要按 CPU 号过滤tracepoint 先确认 debugfs 挂载再开事件别在线上环境长时间开全量 sched 事件。6. 验证与调参用 sched_debug 和算例把 PELT 钉死6.1 用 sched_debug 看 cfs_rq 的 load_avg/util_avg验证 PELT 最直接的办法是连续采 /proc/sched_debug看 cfs_rq 的 load_avg 和 util_avg 是不是按预期收敛。跑一个纯 CPU 任务util_avg 应该往上走load_avg 跟着走再让它睡一会儿两个值都应该往下掉但不会瞬间归零。采的时候按 CPU 分组别把不同 CPU 的队列混在一起看。下面这条命令把 cfs_rq 段和关键字段一起抓出来连续跑几次对比。for i in $(seq 1 5); do echo ---- sample $i ---- awk /cfs_rq/ {print; c12} c0 {print; c--} /proc/sched_debug | \ grep -E cfs_rq|load_avg|util_avg|runnable sleep 1 done这段脚本的逻辑是每次抓 cfs_rq 附近若干行过滤出负载字段sleep 1 拉开采样间隔。参数 c12 控制每个 cfs_rq 带出的行数队列字段多的时候可以加大grep 里的 runnable 用来顺带看可运行任务数。读结果时先看趋势再看绝对值load_avg 和 util_avg 的差值能告诉你任务是在等 CPU 还是在跑。6.2 用 tracepoint 和算例交叉验证tracepoint 适合看“什么时候更新”算例适合看“更新成多少”。开 sched:sched_stat_runtime 和 sched:sched_migrate_task跑一个短任务确认 enqueue/dequeue/tick 三个调用点都出现再把第 2 章的 python 算例改成和任务运行时间一致对比 sched_debug 里的读数。注意 tracepoint 有开销别在压测高峰期开全量。验证时优先看 update_load_avg 的调用次数和 last_update_time 的推进读数对不上再回头查 accumulate_sum 的 period_contrib。6.3 调参边界哪些能改哪些别动PELT 的 1024us、LOAD_AVG_PERIOD32、LOAD_AVG_MAX47742 是编译期常量用户态没有 sysctl 能调改它们等于改算法负载均衡的收敛速度会跟着变。能调的是 CFS 的调度参数比如 sched_latency_ns、sched_min_granularity_ns、sched_wakeup_granularity_ns、sched_migration_cost_ns这些影响任务多久被切、唤醒后能不能抢、迁移代价怎么算间接影响你看到的负载读数。调之前先记基线一次只改一个参数改完至少看三组 sched_debug 采样。参数作用调 PELT 读数时的注意点sched_latency_ns调度周期目标改小会让任务切得更频繁util_avg 采样更碎sched_min_granularity_ns单任务最小运行时间太小会增加切换读数抖动变大sched_wakeup_granularity_ns唤醒抢占粒度影响 runnable 时间load_avg 跟着变sched_migration_cost_ns迁移代价估计影响负载均衡选核间接影响 se 迁移血泪经验是别把 PELT 常量当性能开关看到 util_avg 上不去就改 LOAD_AVG_PERIOD最后负载均衡和功耗一起翻车。从那以后我每次改调度参数前都先用 python 把 1024us 算例跑一遍再上板子看 sched_debug确认读数变化是参数引起的还是衰减补账引起的。希望帮到你。本文还有配套的精品资源点击获取