☰
PELT负载跟踪:指数衰减机制与源码实现解析
2026/10/29 2:26:44 网站建设 项目流程

简介:面向Linux内核调度与性能调优方向的学习者,这份PDF围绕CFS调度器中的PELT(per entity load tracking)机制展开,重点回答Linux 3.8之前以运行队列跟踪负载时无法定位负载来源、负载值波动大的问题,并说明按调度实体跟踪负载的思路。内容涵盖1024us周期划分、负载贡献计算、衰减因子与负载累加公式,结合4096us连续运行后睡眠的实例逐步推算各时刻负载值,还讲解decay_load()函数如何用移位与乘法替代浮点运算,以及runnable_avg_yN_inv数组的预计算与y^32=0.5的间接计算技巧,代码分析基于Linux 4.18.0。资源包为1个PDF文件,约358KB,篇幅紧凑便于通读与查阅。已有573人学习,适合作为理解PELT原理与内核实现细节的参考材料。

1. PELT 的真相:调度器记的不是负载,是“记忆的衰减”

一个任务在 CPU 上跑满 4ms 然后睡死过去,它的负载贡献不会立刻归零,而是像一杯热水的温度一样缓慢冷却——1023us 时是 1023,4095us 时是 3963,8191us 时还剩 3627。这个反直觉的现象,正是 Linux CFS 调度器里 PELT(per entity load tracking)机制的核心。很多人第一次看到L = L0 + L1*y + L2*y² + ...这个公式时一头雾水,觉得调度器为什么要搞这么复杂的数学。其实它解决的是一个非常朴素的问题:在 Linux 3.8 之前,CFS 只在 runqueue 级别跟踪负载,你根本分不清这堆负载到底是谁贡献的,而且即使工作负载稳定,rq 级别的负载值也会剧烈抖动。PELT 把跟踪粒度下沉到每个调度实体,用指数衰减给历史负载“记账”,让调度器第一次能看清负载的来源和趋势。搞懂这套衰减机制,你才能理解 CFS 为什么在任务迁移、组调度、能效决策上能做出那些看似“聪明”的判断。

2. 从 rq 到 se:为什么负载跟踪必须下沉到实体级

2.1 rq 级跟踪的两个致命缺陷

Linux 3.8 之前的 CFS 用cfs_rq->load.weight这类字段在运行队列层面统计负载。这个做法在单任务场景下勉强够用,但一旦系统里跑着几十个轻重不一的任务,问题就暴露了。第一个缺陷是负载来源不可追溯:rq 的负载是所有可运行任务权重之和,调度器只知道“这个 CPU 很忙”,却不知道是哪个任务在制造繁忙。当需要做负载均衡时,调度器想把“忙”的 CPU 上的任务挪到“闲”的 CPU 上,但它没有依据判断该挪哪个任务——挪走一个轻量级任务可能毫无帮助,挪走重量级任务又可能矫枉过正。

第二个缺陷更隐蔽:rq 级负载的抖动。任务的 enqueue/dequeue 是高频事件,每次进出队列都会让 rq 的负载值瞬间跳变。一个任务短暂睡眠再唤醒,rq 负载就像心电图一样上下波动。调度器基于这种抖动值做决策,很容易出现“刚判定为忙、下一瞬间又变闲”的误判,血泪经验就是负载均衡经常在错误的时机触发,白白消耗 CPU 周期。

2.2 per-entity 跟踪带来的决策能力

PELT 把struct sched_avg塞进每个sched_entity,让每个任务(以及每个任务组)都有自己的负载历史。这个设计动机很直接:调度器需要的是“这个实体过去一段时间有多重”的平滑估计,而不是“当前这一纳秒队列里有多少权重”。有了 per-entity 的load_avg和util_avg,负载均衡可以精确挑选迁移候选者,组调度可以按组的聚合负载分配带宽,CPU 频率调节(schedutil)也能拿到每个 CPU 的实际利用率而不是猜测值。

这里有个容易踩坑的认知:load_avg和util_avg不是一回事。load_avg基于 runnable 时间(在队列里等待 + 在 CPU 上运行),反映的是“这个实体对 CPU 的需求压力”;util_avg只基于 running 时间(真正在 CPU 上执行),反映的是“这个实体实际消耗了多少算力”。一个任务如果一直在等 CPU 但抢不到,它的load_avg会很高而util_avg可能很低——这个区分是后续所有调度决策的基石。

2.3 1024us 周期与衰减因子的设计取舍

PELT 把物理时间切成 1024us 的周期,每个周期内实体对负载的贡献是runnable_time / 1024。为什么是 1024us?这个值约等于 1ms,和调度 tick 的量级接近,既不会细到让计算开销爆炸,也不会粗到丢失负载变化的细节。衰减因子 y 满足y^32 = 0.5,即经过 32 个周期(约 32ms)后,历史负载的权重衰减一半。这个半衰期的选择是典型的工程折中:太短则负载估计对突发敏感、抖动大;太长则对负载变化反应迟钝、迁移决策滞后。32ms 大致对应一个调度周期的尺度,让 PELT 既能跟上负载变化,又不会被瞬时波动带偏。

常见的实现思路是用定点数代替浮点:y = 0.97857206被预计算成y * 2^32存进runnable_avg_yN_inv[]数组,衰减计算变成移位加乘法。这个转换不是炫技,而是因为内核调度路径对延迟极度敏感,浮点运算在部分架构上会触发协处理器陷阱,定点运算才是安全选择。

3. 衰减公式的源码实现:decay_load 与 accumulate_sum 怎么配合

3.1 decay_load:用查表把 y^n 压成常数时间

decay_load(val, n)要算val * y^n,如果老老实实循环乘 n 次,n 可能上千,调度路径直接翻车。内核的做法是利用y^32 = 0.5把任意 n 拆成“整 32 周期的一半衰减”加“余数查表”:

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; } /* 余数部分查表,mul_u64_u32_shr 做 64x32 乘法后右移 32 位 */ val = mul_u64_u32_shr(val, runnable_avg_yN_inv[local_n], 32); return val; }

LOAD_AVG_PERIOD是 32,runnable_avg_yN_inv[local_n]存的是y^local_n * 2^32的定点值。mul_u64_u32_shr(val, table, 32)等价于(val * table) >> 32,把定点乘法还原成真实衰减。整个函数是 O(1) 的,无论 n 是 1 还是 2000,计算量恒定。n > 2016直接返回 0 是因为y^2016已经小到在 64 位精度下无意义,继续算只是浪费。

3.2 accumulate_sum:把“跨周期”拆成两步

真正难的不是单次衰减,而是“上次更新到现在跨了若干个不完整周期”怎么算。accumulate_sum()把时间差 delta 拆成三部分:d1 是补齐上次不足一个周期的剩余时间,d2 是完整周期,d3 是当前不足一个周期的部分。公式是:

u' = (u + d1) * y^p + 1024 * Σ(y^n, n=1..p-1) + d3

代码里拆成 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; 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) { /* Step 1: 旧负载整体衰减 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); /* Step 2: 计算 d1、d2、d3 三段的贡献 */ 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; }

period_contrib这个字段是关键黑匣子:它记住上次更新时“差多少 us 凑满一个周期”,下次更新时先把它加回 delta,才能正确算出跨了几个完整周期。没有它,每次更新都会把不完整周期丢掉,负载估计会系统性偏低。

3.3 __accumulate_pelt_segments:等比数列求和的巧妙化简

Step 2 里最麻烦的是1024 * Σ(y^n, n=1..p-1)这个等比数列求和。如果按定义循环加,p 可能上千。内核用了一个数学恒等式把它化简:

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); /* d1 * y^p */ /* c2 = 1024 * Σ(y^n, n=1..p-1) * = LOAD_AVG_MAX - decay_load(LOAD_AVG_MAX, periods) - 1024 */ c2 = LOAD_AVG_MAX - decay_load(LOAD_AVG_MAX, periods) - 1024; return c1 + c2 + c3; }

推导逻辑是:设max = 1024 * Σ(y^n, n=0..∞),则max * y^p = 1024 * Σ(y^n, n=p..∞),两者相减再扣掉1024 * y^0,正好剩下1024 * Σ(y^n, n=1..p-1)。LOAD_AVG_MAX是无穷级数的收敛值,代码里算出来是 47742。这个化简把 O(p) 的求和压成 O(1) 的两次decay_load调用,是 PELT 能在调度热路径上跑起来的前提。

提示:LOAD_AVG_MAX的 47742 是代码迭代算出来的,不是解析解。等比数列理论值1024/(1-y)约等于 47742.09,差异来自定点数取整。理解这个差异能帮你在调试负载数值时判断“偏差是正常的定点误差还是逻辑 bug”。

4. sched_avg 的字段语义与更新时机:load_sum 和 util_sum 到底差在哪

4.1 结构体字段的职责划分

struct sched_avg是 PELT 的账本,每个字段都有明确分工:

字段类型含义
last_update_timeu64上次更新时间(ns),用于算 delta
load_sumu64基于 runnable 时间的衰减负载总和
runnable_load_sumu64同上,但用于组调度的可运行权重场景
util_sumu32基于 running 时间的衰减利用率总和
period_contribu32上次更新不足 1024us 的余量
load_avgunsigned longload_sum除以 divider 的平均值
runnable_load_avgunsigned longrunnable_load_sum的平均值
util_avgunsigned longutil_sum的平均值

load_sum和util_sum的差别不在计算方式,而在累加时传入的runnable和running标志。___update_load_sum()的调用点传的是!!se->on_rq(是否在队列里)和cfs_rq->curr == se(是否正在 CPU 上跑)。一个任务在队列里等待时on_rq=1但curr==se为假,所以load_sum涨而util_sum不涨。这个区分让调度器能识别“饥饿任务”——负载高但利用率低,说明它在排队抢不到 CPU。

4.2 平均值的除数为什么是动态的

___update_load_avg()里算平均值用的除数是LOAD_AVG_MAX - 1024 + sa->period_contrib,不是固定的 47742。原因是load_sum的最大值取决于当前处于周期的哪个位置:如果period_contrib是 0,说明刚好在周期边界,最大和是LOAD_AVG_MAX - 1024;如果period_contrib接近 1024,最大和会多出这部分。用动态除数才能保证load_avg在实体满负载时收敛到se->load.weight,而不是被固定除数算出一个偏小的值。这个细节在调试“为什么 load_avg 到不了权重值”时经常是根因。

4.3 更新时机:enqueue、dequeue 和 tick 三处触发

update_load_avg()在三个地方被调用:任务入队(enqueue_entity)、出队(dequeue_entity)、以及 scheduler tick 周期性更新。入队和出队时更新是为了让负载账本在实体状态切换的瞬间保持连续,tick 更新是为了让长期运行的实体负载不会因为长时间不切换而“冻结”。如果只在 enqueue/dequeue 更新,一个跑了几秒不切换的任务,它的load_sum会停留在入队时的值,衰减根本没发生,负载估计完全失真。tick 更新是 PELT 能持续“冷却”的保障。

注意:last_update_time为 0 时__update_load_avg_se()会跳过更新,这是给刚初始化还没入队的实体留的短路。如果你在调试时看到某个 se 的load_avg一直是初始权重值不变化,先检查它是不是从没被 enqueue 过。

5. 避坑与排查:PELT 数值异常时先看这五处

5.1 load_avg 长期停在权重值不下降

现象:一个任务明明已经睡眠很久,/proc/<pid>/sched里的se.load_avg还是等于它的权重值,没有衰减。

原因:任务睡眠后没有再触发update_load_avg()。PELT 的衰减是“惰性”的——它不在后台定时器里跑,而是等下一次有人调用更新函数时才根据时间差一次性补算。如果任务一直不唤醒、不迁移、不参与任何调度事件,账本就不会被结算。

解决:这是设计行为不是 bug。当任务被唤醒重新入队时,enqueue_entity会调用update_load_avg(),用now - last_update_time算出巨大的 delta,一次性把load_sum衰减到接近 0。如果你需要主动观察,可以触发一次迁移或等 tick 更新它所在的 cfs_rq。

5.2 util_avg 超过 CPU 容量 1024

现象:某个 CPU 的util_avg显示大于 1024,按定义利用率不应该超过 100%。

原因:util_sum在累加时乘了scale_cpu(CPU 容量缩放因子),而___update_load_avg()算平均值时的除数没有对应地按容量缩放。在异构 CPU(大小核)场景下,如果arch_scale_cpu_capacity()返回值不一致,就会出现数值溢出感。

解决:检查arch_scale_cpu_capacity()的实现是否和capacity_of()一致。多数情况下这是平台代码的容量表配置问题,不是 PELT 本身算错。用cat /sys/devices/system/cpu/cpu*/cpu_capacity对比各核容量值是否合理。

5.3 组调度的 runnable_weight 和 weight 不相等

现象:group se 的runnable_weight小于se->load.weight,但代码里初始化时明明赋的是相等值。

原因:runnable_weight对 group se 是动态更新的,表示组内“可运行部分”的权重占比。当组内只有部分任务在 runnable 状态时,runnable_weight会被调小。task se 则每次更新都强制runnable_weight = load.weight。

解决:不要假设两者恒等。在计算组负载时用se_runnable(se)而不是se->load.weight,否则会把睡眠任务的权重也算进可运行负载,导致组负载虚高。

5.4 decay_load 返回 0 导致负载突然归零

现象:长时间不更新的实体,下一次更新后load_sum直接变成 0,而不是一个很小的值。

原因:decay_load()里n > LOAD_AVG_PERIOD * 63(即 2016 周期,约 2 秒)直接返回 0。如果两次更新间隔超过 2 秒,历史负载被判定为“完全冷却”。

解决:这是有意的截断,避免定点数下溢和无效计算。如果你的场景需要更长记忆,只能调整LOAD_AVG_PERIOD或衰减因子,但那会改变整个调度器的行为基线,不建议在产物环境动。排查时确认更新间隔是否真的超过 2 秒,而不是代码路径漏调了更新函数。

5.5 period_contrib 累积误差导致负载漂移

现象:长时间运行后,load_avg和理论值有缓慢偏差,越跑越偏。

原因:period_contrib是 u32,每次更新只保留delta % 1024的余量,多次更新的取整误差会累积。虽然单次误差小于 1us,但在高频更新场景下会缓慢漂移。

解决:这是定点计算的固有代价,内核接受这个误差因为调度决策不需要微秒级精度。如果你在做负载相关的性能分析,不要拿 PELT 的load_avg当精确计时器用,它的语义是“平滑估计”不是“精确统计”。需要精确值时用se->sum_exec_runtime这类累计字段。

6. 设计取舍的再思考:为什么 PELT 选择“近似”而不是“精确”

PELT 整套机制里充满了近似:衰减因子用定点数代替浮点、等比数列求和用收敛值化简、超过 2 秒的历史直接丢弃、平均值除数动态调整。这些选择背后是同一条原则——调度器要的是趋势判断,不是精确计量。如果追求精确,每次更新都要遍历所有历史周期做浮点累加,调度热路径的开销会大到不可接受。PELT 用 O(1) 的代价换来了“足够好”的负载估计,这个取舍在 2012 年合入时是合理的,放到今天依然成立。

但近似也带来了理解成本。我见过不少人在调试调度问题时,把load_avg当成“过去 1 秒的平均负载”来读,结果发现数值对不上就怀疑内核有 bug。实际上load_avg的语义是“按指数衰减加权的历史 runnable 时间占比乘以权重”,它没有固定的时间窗口,半衰期是 32ms 但尾巴拖到 2 秒。理解这一点,你才能正确解读/proc/<pid>/sched里那些数字,而不是被它们误导。

验证 PELT 行为最直接的办法是写个用户态程序模拟衰减计算,拿内核的runnable_avg_yN_inv[]数组和decay_load()逻辑对拍。我一般会构造一个“跑 4096us 然后睡”的任务,在几个时间点读se.load_sum,和手算的 1023、2025、3005、3963、3877 对比,偏差在个位数以内就说明理解正确。这个习惯帮我排除了好几次“以为是内核 bug、其实是自己公式套错”的乌龙。

从那以后我每次分析调度负载数据,都强制先确认三件事:更新间隔有没有超过 2 秒、period_contrib当前是多少、读的是load_avg还是util_avg。这三步走完,大部分“玄学”数值都能解释清楚。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询