乐于分享
好东西不私藏

PELT 负载跟踪源码精读——CFS 如何用几何级数计算进程的“重量”

PELT 负载跟踪源码精读——CFS 如何用几何级数计算进程的“重量”

拆解 PELT 几何衰减 y³²=0.5、三负载指标语义、定点数 decay_load 查表、util_est EWMA 预测,基于 v6.6 源码。

PELT 负载跟踪——CFS 如何用几何级数计算进程的"重量"

系列:调度器演进 · 从 CFS 到 EEVDF前置知识:CFS 调度器数据结构、vruntime 计算、pick_next_task 选择逻辑参考内核版本:v6.6 LTS

版本说明:v6.6 起,CFS 的选进程逻辑已被 EEVDF(Earliest Eligible Virtual Deadline First)取代。本篇所述的 PELT 机制在 CFS 与 EEVDF 下原样通用(主题不受影响);但涉及"调度周期 6ms""唤醒抢占的 vruntime 比较"等 CFS 专属表述,描述的是 v6.5 及以前的行为,v6.6 的差异已在相应章节标注。


一、CFS 知道进程"多重"吗

前 3 讲我们讲完了调度器的核心循环——CFS 选进程(第 1 讲)、进程被唤醒(第 2 讲)、调度被触发(第 3 讲)。这三个环节构成了一个"谁应该运行"的完整故事线。

但如果你仔细想,会发现一个缺口:CFS 怎么决定把一个进程放到哪个 CPU 上?怎么决定唤醒时是否该抢占当前进程?怎么在多核之间做负载均衡?

这些问题的答案都指向同一个系统——PELT(Per-Entity Load Tracking,每实体负载跟踪)

"负载"这个词容易让人误解。PELT 跟踪的不是进程有多忙(那不是 top 命令在干嘛吗?),而是进程在过去一段时间内对 CPU 的"需求程度"——一个持续 100% 占用 CPU 的进程和一个每 100ms 才跑 1ms 的进程,它们的"负载"应该被区分开。

但关键问题是:怎么定义"过去一段时间"? 你可能会想到滑动窗口取平均,但 PELT 的设计者 Paul Turner 与 Ben Segall(Google,2012 年 LKML "sched: track load and utilization" 补丁系列,v3.8 合入)选择了一条更优美的数学路径——几何级数衰减


二、y^32 = 0.5:PELT 的数学灵魂

2.1 为什么不能简单取平均

如果你用一个 1 秒的滑动窗口来取 CPU 使用率的平均,1 秒前突然停掉的进程和 1 秒前突然启动的进程在这个窗口里的负载是一样的——但它们的调度需求完全不同。刚刚启动的进程需要立即获得 CPU(它可能是一个交互式任务),而刚刚停掉的进程可以等一等。

问题本质:近期的行为比远期行为更重要。 你需要一个"遗忘机制"——越久远的 CPU 使用记录,对当前负载的影响越小。

2.2 几何级数衰减:用半个生命周期定义"遗忘速度"

PELT 选择了一个指数衰减模型:每经过一个固定的时间周期,历史负载的权重减半。

具体来说,PELT 定义了一个衰减因子 y

y^32 = 0.5

这意味着:每经过 32 个"周期",历史负载的影响恰好减半。 这个"周期"在 Linux 中定义为 1ms(一个 PELT 时间单位为 1024ns ≈ 1us,32 个 1ms = 32ms)。

💡 彩蛋知识点 #1

为什么是 32 而不是 16 或 64?PELT 补丁选择了 32ms 的半衰期,源码注释给出的理由是"y 的取值基于一个合理的调度周期的宽度"(We choose y based on the width of a reasonably scheduling period):

  • 太小(如 16ms):历史衰减太快,PELT 只能反映最近 100ms 内的行为——无法捕捉间歇性负载的宏观模式
  • 太大(如 64ms):历史衰减太慢,一个 1 秒前就跑完的进程仍然被认为"值得留在当前 CPU 上"——负载均衡反应迟钝
  • 32ms:v6.6 中调度时片由 sysctl_sched_base_slice(默认 0.75ms,kernel/sched/fair.c:78)控制,32ms 半衰期约覆盖 40+ 个时片——设计初衷是让 PELT 覆盖若干个完整的调度周期,既捕捉最近的负载模式,又不过度反应瞬态。(v6.5 及以前 CFS 以 sysctl_sched_latency≈6ms 定义调度周期,32ms ≈ 5 倍周期,论证思路相同)

32ms 半衰期自 2012 年合入后沿用至今未被更改。

2.3 衰减公式的推导

给定衰减因子 y(满足 y^32 = 0.5),PELT 的核心公式是:

L(t) = L(t-1) * y + (1 - y) * l(t)

其中 L(t) 是当前负载估计值,l(t) 是当前时刻的"瞬时负载"(进程是否在运行:1 或 0)。

展开这个递推公式:

L(t) = (1-y) * [l(t) + y*l(t-1) + y^2*l(t-2) + y^3*l(t-3) + ...]

这是一个无限级数——历史上的每个 l(t-k) 都贡献了一份权重,但权重随时间以 y^k 衰减。k=32 时,y^32 = 0.5,贡献减半;k=64 时,y^64 = 0.25,只剩四分之一。

2.4 定点数实现:整数计算的精度艺术

浮点数在内核中是禁区(上下文切换不能碰 FPU 状态)。PELT 使用了一种定点数技巧:

// kernel/sched/sched-pelt.h (v6.6, "Generated by Documentation/scheduler/sched-pelt")// y ≈ 0.97857206(满足 y^32 = 0.5)// 实际存储:将 y 放大 2^32 倍为整数// SCHED_FIXEDPOINT_SHIFT 定义于 include/linux/sched.h:401#define LOAD_AVG_PERIOD          32#define LOAD_AVG_MAX             47742// 预计算表:只有 32 项(y^0 ~ y^31)staticconst u32 runnable_avg_yN_inv[] = {0xffffffff,  // y^0  ≈ 1.0000  × 2^320xfa83b2da,  // y^1  ≈ 0.9786  × 2^320xf5257d14,  // y^2  ≈ 0.9578  × 2^32// ... 共 32 项,到 y^31};// v6.6 中没有 runnable_avg_yN_sum 表(旧内核 v4.x~v5.x 曾有,// v6.6 已删除,见下文)

y^n(n ≥ 32)怎么办?decay_load()kernel/sched/pelt.c:48-51)用移位处理:每满 32 个周期右移一位(数值减半),余数部分再查 yN_inv 表:

// kernel/sched/pelt.c (v6.6)static u64 decay_load(u64 val, u64 n){// n 上限为 LOAD_AVG_PERIOD * 63 —— 超过约 63×32ms 的历史// 直接衰减到 0(不再有意义)if (unlikely(n > LOAD_AVG_PERIOD * 63))return0;// 完整的 32 周期块:右移 = 乘 y^32 = 减半if (unlikely(n & LOAD_AVG_PERIOD))        val >>= 1;return mul_u64_u32_shr(val, runnable_avg_yN_inv[n & (LOAD_AVG_PERIOD-1)], 32);}

那"过去 n 个周期的累加衰减权重"怎么算? 旧内核(约 v4.x–v5.x)确有一张 runnable_avg_yN_sum[] 查找表,v6.6 已删除,改为O(1) 解析式——由 __accumulate_pelt_segments()kernel/sched/pelt.c:57-100)分段计算:

c2 = LOAD_AVG_MAX - decay_load(LOAD_AVG_MAX, periods) - 1024

原理:一个满载实体累计 periods 个周期后的级数部分和,等于"无限级数和(47742)减去从现在看已衰减掉的尾巴,再扣除本周期尚未计入的 1024"。查表换了解析式,但复杂度仍是 O(1)——这是 v6.6 相对旧文献的一个重要差异(不少老文章还在讲两张表)。

核心抓住「太旧归零、满 32ms 右移减半、零头查表相乘」就够了


三、struct sched_avg:三个负载指标,三种语义

PELT 为每个调度实体(sched_entity)维护一个 sched_avg 结构体,跟踪三个独立的负载指标:

// include/linux/sched.h (v6.6, 简化展示核心字段)structsched_avg {/*     * ① 三个负载指标(sum 形式 = 加权后的原始值之和)     */    u64     last_update_time;       // 上次更新的时钟时间(ns)    u64     load_sum;               // 运行负载的加权累计(考虑权重的 CPU 需求)    u64     runnable_sum;           // 就绪负载的加权累计(在就绪队列上的时间)    u32     util_sum;               // 利用率负载的加权累计(CPU 使用率,与权重无关)/*     * ② 三个对应的"平均值"(avg 形式 = sum / divider)     * divider = LOAD_AVG_MAX - 1024 + period_contrib(pelt.h:40-45,     * 随当前周期内位置动态变化,用于消除 [1002..1024] 区间的振荡)     * 这些是调度器实际用来做决策的值     */    u32     period_contrib;         // 当前周期内剩余的部分贡献unsignedlong   load_avg;       // (se_weight × load_sum) / divider → 考虑权重的"平均负载"unsignedlong   runnable_avg;   // runnable_sum / divider → "平均就绪时间"unsignedlong   util_avg;       // util_sum / divider → "平均 CPU 利用率"/*     * ③ util_est:利用率的估算值(用于唤醒选核的快速决策)     */structutil_estutil_est;// 见第七节};

三个指标的区别——这是整篇文章最重要的概念区分

指标
跟踪什么
与 nice/权重有关?
典型用途
runnable_avg
进程在"就绪队列"上的时间占比
无关
(纯粹的 on_rq 时间)
判断"这个 CPU 上等待的进程多不多"
util_avg
进程"正在运行"的时间占比
无关
(纯粹的 running 时间)
判断"这个 CPU 还有没有余力接更多进程"
load_avg
进程在就绪队列上、按权重缩放的时间
有关
(runnable_avg × weight)
判断"这个 CPU 上累积了多少'加权'需求"

用一个例子来理解三者的差异:

进程 A: nice 0 (weight=1024), 过去 1000ms 期间在就绪队列上 800ms (80%)进程 B: nice 10 (weight=110),  过去 1000ms 期间在就绪队列上 800ms (80%)A 和 B 的 runnable_avg 相同(都是 80%)A 和 B 的 util_avg 相同(取决于实际运行时间,相同则相同)但 A 的 load_avg ≈ 800,B 的 load_avg ≈ 86(差了近 10 倍!)

为什么 load_avg 需要区分权重? 回到 CFS 的核心原则:高优先级的 nice -20 进程获得更多的 CPU 份额。如果负载均衡只看 util_avg(不考虑权重),高优先级进程在过载 CPU 上会被错误地认为"和低优先级进程一样重"——这会导致负载均衡失效。

load_avg 用权重缩放后,nice -20 的进程即使 runnable_avg 只有 50%,它的 load_avg 也能让负载均衡器认为"这个 CPU 已经很重了"。

⏱️ 一个经常被问到的困惑

Q: PELT 既然叫"负载跟踪",为什么需要用 util_avg?用 load_avg 不是够了?

A: 不够。load_avg 在组调度场景下有一个致命问题——一个 cgroup 的 load_avg 是所有子进程的 load_avg 之和,但当 cgroup 被 throttle(带宽限制)时,这些"负载"不代表真实的 CPU 需求。util_avg 直接反映 CPU 的实际使用率,不受权重和 cgroup 带宽的扭曲。所以在选核(select_task_rq)中,util 信号(util_avg 及其与 util_est 的合成值)承担容量感知决策——"这个任务装不装得下那个 CPU""哪个 CPU 还有空闲算力"(注意:判定"CPU 是否空闲"用的是队列状态而非 PELT,见第五节)。


四、__update_load_avg_se:PELT 的核心更新函数

4.1 调用时机

PELT 的更新是周期性的——每次时钟中断(tick)或调度事件发生时,update_load_avg 被调用(kernel/sched/fair.c:4520,时间戳 now = cfs_rq_clock_pelt(cfs_rq) 在这里取得并传入)。在 sched_entity 级别,入口函数是 __update_load_avg_se

// kernel/sched/pelt.c:306-318 (v6.6, 逐行注释版)int __update_load_avg_se(u64 now, struct cfs_rq *cfs_rq, struct sched_entity *se){/*     * ① 第一步:累计三个 sum     *     * 注意第三个参数 load 是 !!se->on_rq —— 一个 0/1 布尔值!     * 不是权重。权重根本不进入 sum 的累计。     *   runnable = se_runnable(se)(任务级也是 !!on_rq)     *   running  = cfs_rq->curr == se(实体正在 CPU 上运行)     */if (___update_load_sum(now, &se->avg, !!se->on_rq,                           se_runnable(se), cfs_rq->curr == se)) {/*         * ② 第二步:sum 变化后,由 sum 计算 avg         *         * 权重 se_weight(se) 在这里、也只有在这里作为乘数引入:         *   load_avg = se_weight(se) * load_sum / divider         * 而 runnable_avg / util_avg 完全不乘权重         */        ___update_load_avg(&se->avg, se_weight(se));        cfs_se_util_change(&se->avg);   // 通知 util_est 需要检查更新return1;    }return0;}

⏱️ "1ms 周期"的去耦

PELT 的数学假设是每个周期 1ms,但内核的时钟中断(tick)频率可能是 1ms (HZ=1000)、4ms (HZ=250),甚至 NO_HZ(无固定频率)。PELT 通过 cfs_rq_clock_pelt(基于 rq_clock_peltpelt.h:64-70,提供经容量/频率缩放的时钟 rq->clock_pelt - rq->lost_idle_time)采集时间戳,确保衰减计算独立于 tick 频率。单位换算分两步在 PELT 内部完成:___update_load_sum 中 delta >>= 10 把 ns 转成 us(1024ns ≈ 1us),accumulate_sum 中 (delta + period_contrib) / 1024 把 us 转成周期数(1024us ≈ 1ms)。所以传给衰减计算的既不是 tick 数也不是 ns,而是精确的"过去了多少个 1ms 周期"。

4.2 ___update_load_avg:衰减 + 累加

v6.6 的 PELT 更新分三层函数(都在 kernel/sched/pelt.c)。逐层看:

第一层 ___update_load_sum(pelt.c:179-230):拆时间、调 accumulate_sum、推进时间戳

// kernel/sched/pelt.c (v6.6, 简化但忠实于真实流程)int ___update_load_sum(u64 now, struct sched_avg *sa,unsignedlong load, unsignedlong runnable, int running){    u64 delta;// ① 关键规则:不在队列上 → runnable/running 一并清零//    (睡眠实体三个 sum 都只衰减、不累加)if (!load)        runnable = running = 0;    delta = now - sa->last_update_time;if ((s64)delta < 0)return0;                          // 时钟回退(迁移)→ 放弃本次更新    delta >>= 10;                          // ② ns → us(1024ns ≈ 1us)if (!delta)return0;// ③ d1/d2/d3 三段式累计(跨周期边界才衰减旧 sum)    sa->last_update_time += delta << 10;   // 时间戳按 us 推进if (accumulate_sum(delta, sa, load, runnable, running)) {// ④ sum 有效变化了 → 返回 1,通知上层重算 avgreturn1;    }return0;}

第二层 accumulate_sum(pelt.c:101-149):三段累计 + 衰减

时间差被切成三段:d1(本周期剩余部分)、d2(完整的 n 个周期)、d3(下个周期的头部)。只有跨过周期边界,旧 sum 才被衰减一次;跨过的整周期用解析式一次性补上加权贡献:

// kernel/sched/pelt.c (v6.6, 简化)static __always_inline u32accumulate_sum(u64 delta, struct sched_avg *sa,unsignedlong load, unsignedlong runnable, int running){    u32 contrib = (u32)delta; /* p(d') = p(d) / 1024 剩余的 us 数 */    u32 periods = delta / 1024;              // 完整的 1ms 周期数if (periods) {// d1: 本周期剩余部分(1024 - period_contrib)// d2: n 个整周期 → 解析式 c2 = LOAD_AVG_MAX//                    - decay_load(LOAD_AVG_MAX, periods) - 1024// d3: 下个周期头部(1024 - delta % 1024)        contrib = __accumulate_pelt_segments(periods,1024 - sa->period_contrib,1024 - (delta % 1024));// 旧 sum 统一衰减 y^periods        sa->load_sum = decay_load(sa->load_sum, periods);        sa->runnable_sum = decay_load(sa->runnable_sum, periods);        sa->util_sum = decay_load(sa->util_sum, periods);    }// 剩余的不足一周期的部分,留给下一轮累计    sa->period_contrib = delta % 1024;// 当前部分周期的贡献(注意:sum 里不含权重!load/runnable 都是// 任务级的 0/1,组实体的 runnable 为其 runnable_weight)if (load)        sa->load_sum += load * contrib;if (runnable)        sa->runnable_sum += runnable * contrib;if (running)        sa->util_sum += contrib << SCHED_CAPACITY_SHIFT;  // util 永不带权return periods;}

第三层 ___update_load_avg(pelt.c:256-267):sum → avg,权重在此引入

// kernel/sched/pelt.c (v6.6)staticinlinevoid ___update_load_avg(struct sched_avg *sa,unsignedlong load){    u32 divider = get_pelt_divider(sa);   // LOAD_AVG_MAX - 1024 + period_contrib    sa->load_avg = div_u64(load * sa->load_sum, divider);    sa->runnable_avg = div_u64(sa->runnable_sum, divider);    sa->util_avg = div_u64(sa->util_sum, divider);// 注意 load_avg:权重作为乘数出现在这里(除法之前),// 而 load_sum 本身累计时只有 0/1 —— 与第三节语义表完全对应}

三个容易搞错的点(对照第三节语义表):

  1. sum 不含权重load_sum 累计的是 !!on_rq × contrib,权重 se_weight(se) 只在 ___update_load_avg 里乘上去
  2. util 与权重无关util_sum += contrib << SCHED_CAPACITY_SHIFT(10 位定点放大),只看 running
  3. 除数是动态 dividerLOAD_AVG_MAX - 1024 + period_contrib,不是固定的 LOAD_AVG_MAX——这个设计消除了满载时 avg 在周期内位置的锯齿振荡(见下方彩蛋)

💡 彩蛋知识点 #2

LOAD_AVG_MAX = 47742 不是一个魔法数字——它是无穷级数 1/(1-y) = 1/(1-0.978572) ≈ 46.67 的放大版本(乘以 1024 = 47742)。这个值代表"一个持续 100% 运行且永不衰减的进程的 util_sum"。但因为实际的 util_sum 每周期最多加 1024,无限周期的累积也是 1024 / (1-y) ≈ 1024 × 46.67 ≈ 47742 × 1024 / 47742 = 1024。所以 util_avg 的值域是 [0, 1024],且持续满载的任务实际收敛到接近 1024。


五、PELT 在选核(select_task_rq)中的应用

这是 PELT 最直接的应用场景。当一个进程被唤醒时,select_task_rq 需要决定把它放到哪个 CPU 上。

5.1 空闲 CPU 优先——但"空闲"不是 PELT 说了算

先澄清一个常见误解:available_idle_cpu()不基于 PELT。它是纯队列状态判定(kernel/sched/core.c:7349-7358):

// kernel/sched/core.c (v6.6, 简化)intavailable_idle_cpu(int cpu){// idle_cpu(): rq->curr == rq->idle && !rq->nr_running && !rq->ttwu_pending//             —— idle 进程正在跑、没有就绪任务、没有待处理唤醒// vcpu_is_preempted(): 虚拟化场景下排除被宿主机抢占的 vCPUreturn idle_cpu(cpu) && !vcpu_is_preempted(cpu);}

select_idle_sibling()kernel/sched/fair.c:7321-7425)的真实检查顺序是:target → prev(需 cpus_share_cache)→ per-cpu kthread 特例 → recent_used_cpu → 非对称容量分支 → SMT/LLC 空闲扫描

// kernel/sched/fair.c (v6.6, select_idle_sibling 的骨架简化)staticintselect_idle_sibling(struct task_struct *p, int prev, int target){// ① target 本身空闲?直接用(唤醒路径最常见的快速命中)if (available_idle_cpu(target) || sched_idle_cpu(target))return target;// ② prev CPU 空闲且与 target 共享缓存?用 prev——利用缓存热度if (prev != target && cpus_share_cache(prev, target) &&        (available_idle_cpu(prev) || sched_idle_cpu(prev)))return prev;// ③ per-cpu 内核线程堆叠、recent_used_cpu 复用……// ④ 非对称容量系统(big.LITTLE/DynamIQ)→ select_idle_capacity// ⑤ select_idle_cpu(): for_each_cpu_wrap 扫描 LLC 内找空闲核//    (含 SMT 兄弟线程判断)    ...}

5.2 PELT 在选核中的真实角色:容量适配(asym_fits_cpu)

PELT 的 util 信号不在"空不空闲"的判定里,而在容量检查里。在非对称容量系统(sched_asym_cpucap_active(),如 ARM big.LITTLE / DynamIQ——Jetson Orin 的 12 个 A78AE 是对称多核,不走此路径)上,select_idle_sibling 会:

  1. 先 sync_entity_load_avg(&p->se)——把任务睡眠期间欠下的 PELT 衰减补齐;
  2. 取 task_util = task_util_est(p)(util_avg 与 util_est 的合成值,见第七节),并叠加 uclamp 的 min/max 限幅;
  3. 用 asym_fits_cpu(task_util, util_min, util_max, cpu) 判断"这个任务的预估需求装不装得下目标 CPU 的容量"——装不下的(如重负载任务 vs 小核)直接排除,走 select_idle_capacity() 路径。

在无空闲 CPU 的兜底路径(find_idlest_group + find_idlest_cpu)中,比较"多闲"的核心指标才是 cfs_rq 的聚合 util_avg/load_avg。注意:cfs_rq 级的 util_avg 不是它上面所有任务 util_avg 的直接求和——它由 __update_load_avg_cfs_rq()(pelt.c:320-333)以队列自身是否占用 CPU 为信号独立跟踪,任务 PELT 只在实体入队/迁移(attach_entity_load_avg)时同步进队列;rq 级的 util 还叠加 rt/dl/irq/thermal 四路独立平均(update_rt_rq_load_avg() 等)。


六、PELT 参与唤醒抢占吗?——v6.6 的答案是:不参与

这是一个澄清误解的章节。不少资料(包括旧版 CFS 时代的文章)会讲"PELT/util 在唤醒抢占中提供附加判断"——v6.6 中不存在这类逻辑

v6.5 及以前的 CFS:唤醒抢占由 vruntime 差值 + wakeup granularity 判定(wakeup_preempt_entity()),与 util 无关。

v6.6 合入 EEVDF 后,check_preempt_wakeup()kernel/sched/fair.c:8055-8106)的抢占判定收敛为一行:

// kernel/sched/fair.c (v6.6, check_preempt_wakeup 的核心判定)staticvoidcheck_preempt_wakeup(struct rq *rq, struct task_struct *p, int wake_flags){structsched_entity *se = &p->se, *curr = &rq->curr->se;structcfs_rq *cfs_rq = cfs_rq_of(se);int cse_is_idle = 0;// ... put_prev_entity / 同核/组调度特例处理 .../*     * 判定:若被唤醒实体恰好是"EEVDF 视角下此刻最该运行的实体"     * (即它将赢得 pick_eevdf 的挑选),则请求重调度。     *     * EEVDF = Earliest Eligible Virtual Deadline First:     * 每个实体有虚拟截止期(由其 time slice 与 lag 推出),     * 调度器总是选"已符合资格(eligible)且截止期最早"的实体。     */if (pick_eevdf(cfs_rq) == pse)goto preempt;}

对 PELT 而言要记住的结论是:唤醒抢占判定完全不看 util/load;"这个任务该不该抢占"由 EEVDF 的资格与截止期决定,"这个任务该放哪个 CPU"才用得到 PELT 的容量检查(第五节的 asym_fits_cpu)。两个问题、两套信号,互不越界。(WF_SYNC 在 fair.c 中只出现在 select_task_rq_fair() 的唤醒选址,与抢占无关。)


七、util_est:PELT 的预测增强

PELT 有一个根本缺陷:衰减意味着过去的信息正在丢失。 当一个进程刚刚醒来时,它的 util_avg 可能已经衰减到了 0——PELT 认为它"很轻"。但事实上,这个进程在上次运行时可能是个 100% 的 CPU 消耗者。

util_est(Utilization Estimation)解决的就是这个问题——它在 util_avg 的基础上加上了一个"应该会恢复到"的预测值:

// include/linux/sched.h (v6.6)structutil_est {unsignedint    enqueued;       // 最近一次入队时的 util_avg 快照unsignedint    ewma;           // 指数加权移动平均(EWMA)的预测值};// 真实更新逻辑(util_est_update, kernel/sched/fair.c:4692-4776)://// 更新时机:仅在 task_sleep 时(完成一次完整激活)——迁移出队不更新//   1. ue.enqueued = task_util(p)   ← 直接取当前 util_avg,不比较、不取大//   2. ±1% 容差内跳过(within_margin,避免无意义的抖动更新)//   3. 若 task_util(p) > capacity_orig_of(cpu) 则跳过//      (任务跑满都装不下这个 CPU 时,util_avg 已失真,不采信)//   4. UTIL_EST_FASTUP(v6.6 默认开启)://        ewma < enqueued 时 → ewma = enqueued,立即抬升//        —— EWMA 只用来平滑"下降",上升直通、不延迟!//      下降时才做经典 EWMA:ewma = (ewma×4 + (enqueued−ewma)) / 4//        —— 即 25% 新样本权重(1/2^UTIL_EST_WEIGHT_SHIFT,SHIFT=2)//// 调度器看到的"有效 util"(task_util_est, fair.c:4617-4626)://   task_util_est(p) = max(task_util(p),//                          max(ue.ewma, ue.enqueued))//   —— 三层 max:util_avg、EWMA 预测值、入队快照,谁大用谁

效果:一个进程在运行期间 util_avg = 800(80% CPU),然后睡眠了 15ms。睡眠期间 PELT 衰减让 util_avg 降到了 600(800 × y^15 ≈ 800 × 0.748 ≈ 599)。但 util_est.ewma 保留着历史:按 25% 新样本权重更新后 ewma ≈ 0.75×780 + 0.25×600 ≈ 735(假设此前 ewma 为 780)。调度器在选核时会用 max(600, max(735, enqueued))——这个进程仍然被认为"很重",不会被塞到已经繁忙的 CPU 上。

💡 彩蛋知识点 #3

util_est 的 EWMA(指数加权移动平均)选择 25% 的新样本权重并非随意——它恰好让 util_est 的半衰期约为 3 个出队周期:

new_ewma = old_ewma * 75% + new_sample * 25%

折半需要多少次更新?0.75^n = 0.5 → n = ln(0.5)/ln(0.75) ≈ 2.4

这意味着 util_est 在约 2-3 次出队后就会适应新的负载模式——足够快以响应负载变化,又足够慢以抵抗瞬态噪声。与 PELT 本身的 32ms 半衰期形成了互补:PELT 负责短期精度,util_est 负责中长期记忆。


八、PELT 在 SMP 负载均衡(load_balance)中的应用

SMP 负载均衡是 PELT 最复杂的应用场景。它的核心问题:当多个 CPU 的负载不均衡时,应该把哪些进程从最忙的 CPU 迁移到最闲的 CPU?

8.1 判断"不均衡":基于 PELT 聚合的 imbalance 计算

先纠正一个旧资料遗留的错误认知:早期内核(约 v4.x 以前)确实在 struct rq 里维护过一个五窗口历史负载数组 cpu_load[CPU_LOAD_IDX_MAX],但它在 v4.4/v4.5 的 PELT 清理系列中就被删除了。v6.6 的 struct rq 中没有这个数组;v6.6 里的 cpu_load 是一个函数(kernel/sched/fair.c:6693-6696),直接返回 cfs_rq 的 PELT load_avg:

// kernel/sched/fair.c (v6.6)staticunsignedlongcpu_load(struct rq *rq){return cfs_rq_load_avg(&rq->cfs);}

v6.6 判断不均衡不再依赖任何历史负载窗口:find_busiest_group()(fair.c:10629)基于调度组聚合的 PELT load_avg/util_avg(含对 rt/dl/irq 负载的扣除)调用 calculate_imbalance() 算出 env->imbalance,后续的迁移量都以这个值为目标。nohz.next_balance(kernel/sched/sched.h:1019)真实存在,但它的语义是"下次 nohz 空闲均衡的触发时刻"(NO_HZ CPU 不接收 tick,需要有人代为触发均衡),不是"判断不均衡的信号"。

8.2 迁移决策:load_avg 的加权比较

v6.6 的函数名仍是 load_balance(v6.7 起才改名为 sched_balance_rq)。真实签名与核心流程(kernel/sched/fair.c:11051):

// kernel/sched/fair.c (v6.6, load_balance 的骨架简化)staticintload_balance(int this_cpu, struct rq *this_rq,struct sched_domain *sd,enum cpu_idle_type idle,int *continue_balancing){structlb_envenv = {        .sd         = sd,        .dst_cpu    = this_cpu,        .dst_rq     = this_rq,        .src_cpu    = -1,          // 由 find_busiest_queue 填充        .idle       = idle,        .imbalance  = 0,           // 由 calculate_imbalance 填充// ...    };// ① 在调度域内找到"最忙"的调度组,并算出不均衡量    env.src_grp = find_busiest_group(&env);   // 内部 calculate_imbalance()if (!env.src_grp) return0;// ② 在最忙的组内找到负载最高的 CPU    busiest = find_busiest_queue(&env, env.src_grp);// ③ 批量迁移:detach_tasks 从最忙 CPU 的红黑树上逐个取下//    任务(用 task_h_load —— 即 PELT 的分层 load_avg —— 判断//    "谁最重、搬谁最划算"),直到填平 env.imbalance    detach_tasks(&env);          // fair.c:8830,内部 detach_task(p, &env)// ④ 挂到本 CPU 队列    attach_tasks(&env);          // fair.c:8996return env.nr_moved;}

注意与"逐个拿、逐个放"的直观想象不同:v6.6 的原语是批量的detach_tasks()/attach_tasks()(单任务原语 detach_task(p, env) 只作为内部循环体存在)——先在最忙 CPU 上一口气摘下足以填平 imbalance 的任务,再统一挂到目标队列,减少锁的往返。

关键:负载均衡的迁移判断基于 load_avg(带权重),确保 nice 值差异被正确反映。如果只看 util_avg,nice -20 和 nice 19 的进程会被同等对待——nice 19 的进程可能被无故迁移到忙碌的 CPU,而 nice -20 的进程可能留在过于拥挤的 CPU 上。

💡 彩蛋知识点 #4

大核(big core)和小核(little core)在 ARM 的 big.LITTLE 和 Intel 的混合架构(P-core/E-core)中,相同 util_avg 不代表相同的能力。E-core 的 100% 利用率可能只有 P-core 的 50% 的性能。这就是 capacity 与 fits_capacity() 判定的引入原因——PELT 的 util_avg 必须被 arch_scale_cpu_capacity 缩放,才能正确反映进程在不同类型核心上的"真实负载"。v6.6 中旧的 capacity_margin 变量已被移除,取而代之的是硬编码宏 #define fits_capacity(cap, max) ((cap) * 1280 < (max) * 1024)(fair.c:116)——即 1.25 倍(25%)容量余量:任务 util 不超过 CPU 容量的 80% 才算"装得下"。这种容量缩放已集成到选核和负载均衡的所有关键路径上。


九、性能考量:PELT 的计算开销

操作
大约开销(估算,来源待验证)
频率
说明
__update_load_avg_se
(单实体)
数十 cycles 量级
每 tick 或调度事件
主要是查表 + 移位
__update_load_avg_cfs_rq
(队列级)
数十 cycles 量级
同上
聚合所有实体的 PELT
decay_load
(衰减计算)
10 cycles 量级
每次更新
查表 O(1)
PELT 总计 / 进程 / tick
百 cycles 以内
HZ=1000 时每秒 1000 次
对于现代 CPU 可忽略

注:表中具体 cycle 数为量级估算,非源码注释或公开基准数据,来源待验证;方向性结论(查表+移位、开销可忽略)与实现一致。

PELT 的设计保证了低开销:用一张预计算表 runnable_avg_yN_inv 加上一个 O(1) 解析式(LOAD_AVG_MAX - decay_load(LOAD_AVG_MAX, periods) - 1024),将原本需要浮点指数运算的衰减计算变成了查表 + 移位。这在内核中是最核心的性能优化模式——空间换时间,离散预计算换连续近似。


十、总结:PELT 的三个设计哲学

  1. 数学先于启发式:PELT 用 y^32 = 0.5 的几何级数衰减替代了早期 Linux 调度器中的一堆 if 启发式。一个数学上精确的半衰期定义比一百行 "如果进程睡眠了 50ms 则减去 3 个优先级" 的代码更可靠。
  2. 信息分离:三个独立的负载指标(load_avg / runnable_avg / util_avg)将"CPU 的真实使用"与"权重的公平分配"解耦。这让选核可以用 util_avg(纯 CPU 容量),而负载均衡可以用 load_avg(权重感知),避免了"一个指标两套矛盾语义"的混乱。
  3. 短期精度 + 长期记忆:PELT 的 32ms 半衰期提供短期精度,util_est 的 EWMA 提供长期记忆。两个机制互补,让调度器既对近期的负载变化敏感,又不会因为短暂的睡眠就遗忘一个进程的"历史重量"。

系列预告

这四篇文章已经把 CFS 调度器的核心"静态"机制讲完了:进程如何被挑选、如何被唤醒、调度如何被执行、负载如何被跟踪。但调度器还有一个关键的"动态"维度:当多个 CPU 的负载不均衡时,内核怎么重新分配进程?

这就是 SMP 负载均衡(SMP load balancing)——调度器中最复杂的子系统,涉及调度域(sched_domain)、调度组(sched_group)的层级拓扑、active balancing、nohz balancing 等多个子机制。

下一讲,我们将从 rebalance_domains 的入口开始,追踪一次完整的跨 CPU 进程迁移,理解内核如何在数百个 CPU 之间维持负载均衡而不产生调度风暴。下一篇:SMP 负载均衡——内核如何在上百个 CPU 之间重新分配进程


互动思考

看完这篇文章,测试一下你对 PELT 三个负载指标的理解:

如果一个 nice -20 的进程和一个 nice 19 的进程都在同一个 CPU 上 100% 运行,它们的 util_avg 会相同吗?load_avg 呢?这两个指标中哪一个更适合用来判断"这个 CPU 还有没有空余算力"?

提示:回顾第三节的三个指标对比表,以及第五节中 select_task_rq 的选核逻辑。

上篇文章的思考题答案:switch_to 之后,代码在 B 的上下文中执行。B 的内核栈上包含:B 之前被换出时 __schedule 的栈帧(局部变量 + 返回地址 + callee-saved 寄存器)。变量 prev 是 __schedule 在 C 中的局部变量,它存储在 B 之前的栈帧中——当 B 被换回时,这个局部变量在 B 的栈帧中是完好的。 但关键:此时 prev 的值仍然是 A 的 task_struct 指针(它在 switch_to 之前就被赋值了,值保存在 B 的栈上而非被覆盖的寄存器中)。



关于作者:专注 Linux 内核子系统源码级解读,参考内核版本 v6.6 LTS。不搬文档,只讲源码背后的设计权衡。


本文基于 Linux v6.6 LTS 源码分析。PELT 核心实现位于 kernel/sched/pelt.c__update_load_avg_se 306-318、accumulate_sum 101-149、___update_load_sum 179-230、___update_load_avg 256-267、__accumulate_pelt_segments 57-100);表与常量位于生成的 kernel/sched/sched-pelt.h;调用入口 update_load_avg 位于 kernel/sched/fair.c:4520util_est_update 位于 fair.c:4692check_preempt_wakeup(EEVDF 判定)位于 fair.c:8055load_balance 位于 fair.c:11051