news 2026/10/23 3:11:35

PELT负载跟踪算法全解析:从原理到Linux内核代码实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PELT负载跟踪算法全解析:从原理到Linux内核代码实现

简介:PDF文档深入剖析了Linux内核CFS调度器中的PELT(Per Entity Load Tracking)算法,面向内核开发与系统性能调优人员,也适合希望理解进程调度底层逻辑的进阶读者。文档基于Linux 4.18.0源码,先点出旧版rq级负载跟踪无法定位负载来源且波动大的缺陷,再通过4096us连续运行与睡眠的递推例子,一步步推导PELT的负载贡献公式、衰减因子y(y^32=0.5)的取值及计算规律。包内仅含1个PDF文件,压缩包大小358KB,内容精炼、无冗余,可顺畅阅读。该资源已有572人学习,口碑良好。读者不仅能获得负载贡献累加方法和decay_load()函数移位乘法实现的详解,还能看到runnable_avg_yN_inv数组的预计算逻辑与内核源码注释,从而理解CFS如何借助PELT平滑跟踪调度实体负载、减少波动,为优化系统吞吐量和功耗提供直接的理论支撑。

1. PELT:让CFS调度器看清每个进程的负载贡献

一个反直觉的事实:在 Linux 3.8 之前,CFS 调度器跟踪负载的单位是运行队列(runqueue),而不是进程。这意味着两个同样占满 50% CPU 的进程,即使一个在 CPU 上运行、另一个在队列里排队等着,调度器看到的负载贡献是一模一样的。PELT(Per Entity Load Tracking)算法改变了这个局面,它把负载跟踪粒度从 runqueue 下钻到每个调度实体,让 CFS 调度器能精确回答"这个负载是谁产生的"。这份资源是一份 PDF 文档,完整拆解了 Linux 4.18.0 中 PELT 的实现,从数学公式推导到 decay_load() 源码、sched_avg 数据结构、负载更新流程,全链路都有代码级讲解。适合研究内核调度器的开发者、性能调优工程师,以及准备把 PELT 移植到自己内核模块里的人。

2. PELT核心原理:从rq级负载到per-entity负载跟踪

2.1 Linux 3.8之前的缺陷:runqueue级别跟踪的盲区

在 PELT 出现之前,Linux 内核中的 CFS 调度器使用基于运行队列的负载跟踪方法。这种方法认为每个 CPU 的运行队列(rq)都有一个总负载,这个值描述了队列内所有任务对 CPU 资源的需求。表面上看这个方法足够简单直接,但实际操作起来有两个致命问题。

第一个问题是谁也看不出负载是哪个进程产生的。假设某个运行队列显示负载为 30,调度器只知道这个 CPU 比较繁忙,但不知道负载来源是几个重型任务还是一堆轻量任务。这个信息在做负载均衡时就非常关键——如果负载来自一个不可迁移的任务,那迁移其他任务也没有意义。

第二个问题更头疼:即使系统整体工作负载非常稳定,rq 级别的负载值也会大幅波动。因为任务总是在不断加入、离开运行队列,每一次 enqueue 和 dequeue 都会改变 rq 的负载统计。加上任务执行时间分布不均匀,可能会出现某段时间内任务集中爆发、另一段时间完全空闲的情况。这种波动传导到负载均衡模块,就会造成不必要的任务迁移和 CPU 频率调整。

为了解决这个问题,PELT 算法在 Linux 3.8 中被合入内核主线。它的核心思路一目了然:跟踪到调度实体(sched_entity)级别,每个进程或者任务组都有自己的负载历史,调度器拿到的信息不再是模糊的"这个 CPU 忙不忙",而是精确的"这个进程过去一段时间对 CPU 的贡献是多少"。

2.2 PELT的基本原理:1024us周期与衰减因子y

PELT 算法的核心是把物理时间切分成若干个 1024us(约 1ms)的周期,然后在每个周期内统计调度实体处于 runnable 状态的时间。这里要注意,runnable 状态不只是"正在 CPU 上执行",还包括在运行队列中等待调度的时间。如果某个实体在一个周期内 runnable 的时间是 x us,那么它对这个周期负载的贡献就是 x/1024。

但仅仅统计当前周期是不够的。一个调度实体的负载贡献是历史性的累积,不能因为某个周期刚结束就把之前所有贡献清零。所以 PELT 引入了一个衰减因子 y,对过去的负载进行逐周期衰减。y 的取值经过精心设计:y^32 = 0.5,也就是经过 32 个周期(约 32ms)后,历史负载衰减为原来的一半,对应解出来 y ≈ 0.97857206。

于是,一个调度实体对系统负荷的总贡献可以写成:

L = L0 + L1 * y + L2 * y^2 + L3 * y^3 + ... + Ln * y^n

公式里每个 Li 表示第 i 个周期中该调度实体对系统负载的贡献,后面的 y^i 表示这个负载经过 i 个周期后衰减剩余的比例。距离当前时刻越远的周期衰减得越狠,越近的周期保留越多。这个几何级数累加的形式保证负载历史不会无限增长,最终会收敛到一个与权重相关的稳定值。

2.3 计算示例:一步一步算出负载贡献

公式摆在纸上总是抽象的,文档给了一个非常能打的具体例子:假设一个 task 从第一次加入 rq 后连续运行 4096us,然后进入睡眠不再运行,那么在若干关键时间点,这个 task 的负载贡献分别是多少?

时间点(us)计算过程负载贡献值
1023只运行了不到一个周期,L0 = 10231023
2047跨过一个周期,1023 衰减一次再加上新周期的 10242025
3071跨过两个周期,前面的负载衰减两次再加 10243005
4095跨过三个周期,累加三个周期的贡献3963
5119当前周期无运行时间,之前负载继续衰减3877
6143睡眠两个周期后,负载从 L4 开始衰减3792
7167睡眠三个周期后,继续衰减3709
8191睡眠四个周期后,衰减到约 L4*y^33627

看这个表格的规律:4095us 之前每个时间点都有新周期的 1024us 加入,而 5119us 之后因为 task 已经睡眠,当前周期的贡献是 0,负载值完全依靠之前历史周期衰减累积。更关键的是,从 5119us 开始每次计算只需要拿上一个时间点的结果乘以衰减系数 y 就行,不需要重新统计所有历史周期。

这个现象给了 PELT 实现很大的简化空间:我们只需要保存上一刻的负载总和和当前周期内的部分贡献,就能推导出任意时刻的负载值,代价只是需要一个高效计算 val * y^n 的手段。

3. 核心代码拆解:decay_load与查找表的设计

3.1 查找表runnable_avg_yN_inv:用整数运算替代浮点

上一节公式里反复出现 val * y^n,这个乘法如果直接用浮点运算做,性能开销完全扛不住。调度器是整个内核的热路径,每个 tick 都会触发负载更新,浮点运算会拖慢上下文切换和调度决策的速度。内核的做法是提前把 y^n 放大 2^32 倍取整,存进一张查找表,计算时用整数乘法和右移完成等价操作。

这个转换的思路其实不复杂。要计算 val * y^n,等价于计算 val * (y^n * 2^32) >> 32。因为 y^n * 2^32 是个定值,可以预计算好存在数组里。运行时只需要查表取出对应的值,做一次 64 位乘法和一次右移,就能得到结果。由于 SOFT_LOOKUP 频繁调用,这种优化能把延迟从几十个周期降到几个周期。

内核源码中的查找表长这样:

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, };

数组一共 32 项,对应 n = 0 到 n = 31 的 y^n * 2^32 值。第一项是 0xffffffff 而不是 0x100000000,这是因为数组类型是 u32,32 位无法容纳完整的 2^32,所以取了低 32 位,等价于 (2^32 - 1) * y^0。后面的值逐项递减,因为 y 小于 1,每乘一次 y 值就缩小一点。

为什么只需要保存 32 项?因为设计时定了 y^32 = 0.5,所以当 n >= 32 时可以利用这个性质拆分。y^33 = y^32 * y^1 = 0.5 * y^1,用代码表示就是可以先右移一位再查表。更一般的表达式是:y^n = (1/2)^(n/32) * y^(n%32)。内核文档中的 sched-pelt.c 文件提供了生成这个数组的参考实现:

void calc_runnable_avg_yN_inv(void) { int i; for (i = 0; i < 32; i++) runnable_avg_yN_inv[i] = ((1UL << 32) - 1) * pow(0.97857206, i); }

注意这个工具是在用户空间运行的,所以可以直接调用数学库的 pow() 函数。内核编译期间不会执行这段代码,而是提前生成常量后硬编码进内核源码。

3.2 decay_load()函数:常数时间衰减计算

有了查找表之后,decay_load() 的实现非常简洁。它的输入是一个负载值 val 和衰减次数 n,返回 val * y^n 的整数近似值:

/* * Approximate: * val * y^n, where y^32 ~= 0.5 (~1 scheduling period) */ 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; }

这段代码有三处关键逻辑。注释 1 处的 LOAD_AVG_PERIOD 值为 32,当 n 大于 32 * 63 = 2016 时,衰减后的值已经趋近于零,直接返回 0 节省后续计算。这个 2016 的来历是 32 个周期衰减为一半,63 次减半之后原本的负载值在 64 位整数范围内已经完全不可见。

注释 2 处理 n >= 32 的情况:因为每 32 个周期负载减半,除以二等价于右移一位。所以先把 val 右移 n / 32 位,相当于把整数个 32 周期对应的衰减一次性处理完,再用余数部分查表。

注释 3 是核心计算。mul_u64_u32_shr(val, table_value, 32) 做了两件事:把 val(64 位)和查找表值(32 位)相乘,然后把乘积右移 32 位。由于查找表值本身就是 y^n * 2^32,右移 32 位正好完成除法,一步到位得到 val * y^n 的近似值。这个函数在不同架构上有不同的实现,x86 上直接用 mul 指令加移位,ARM 上类似,编译器通常能生成非常高效的代码。

3.3 负载累积计算:accumulate_sum与__accumulate_pelt_segments

decay_load() 解决的是单次衰减计算,但实际更新负载时往往跨了多个周期,情况要复杂得多。内核用 accumulate_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; /* 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; }

这个函数把公式拆成了两步:第一步在注释 3 处,把旧的负载值衰减 periods 次,对应公式里的 u * y^p 部分;第二步在注释 4 处,调用 __accumulate_pelt_segments() 计算新产生的负载贡献,包括补齐上一个周期的时间差和当前不足一个周期的部分。

注释 1 先把上次遗留的 period_contrib 加进 delta,这样才能正确计算跨过的周期数。注释 2 整除 1024 得到完整周期数。注释 5 把 delta 对 1024 取余后存回 period_contrib,作为下一次更新时的遗留部分。

这里还有两个值得注意的细节。scale_freq 和 scale_cpu 是频率缩放因子和 CPU 容量缩放因子,在支持频率调整和大小核架构的平台上,它们会修正负载计算,让负载值能反映出 CPU 实际运算能力的影响。如果硬件不支持这些特性,对应接口返回 1,不会破坏计算逻辑。

__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; }

c1 表示上一个周期中尚未计入负载的时间片段 d1,这个片段距离当前时刻已经过去了 periods 个周期,所以要衰减。c3 就是当前周期内已经经过的时间 d3,不需要衰减。c2 是最巧妙的部分——它表示中间 p-1 个完整周期里每个周期贡献 1024 的衰减累加和。

如果不做数学变换,c2 是一个从 n=1 到 n=p-1 的循环累加,写出来就是一堆 decay_load() 的调用。但内核用等比数列求和公式把它化简了:无限项累加的极限值 LOAD_AVG_MAX 减去衰减后的剩余部分再减去当前周期项,一次减法就得到了中间所有完整周期的贡献。

4. 数据结构与初始化流程:sched_avg如何记录负载

4.1 struct sched_avg:负载信息的载体

PELT 的负载数据不是散落在全局变量里,而是集中封装在 struct sched_avg 结构体中。这个结构体既被调度实体(sched_entity)持有,也被 CFS 运行队列(cfs_rq)持有。

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; };

每个成员都有明确的分工。last_update_time 记录上一次负载更新的时间戳,下一次更新时用当前时间减去这个值得到时间差 delta。load_sum 是基于可运行时间的负载贡献总和,可运行时间包括在队列中等待调度的时间和正在 CPU 上执行的时间。util_sum 则只基于正在运行的时间累加,也就是任务真正使用 CPU 的时间。这两个值对应两种不同的调度决策需求:load_sum 用于负载均衡,util_sum 用于 CPU 频率调整。

period_contrib 保存的是不足一个完整周期的时间碎片。因为 1024us 不一定整除两次更新的时间差,总会有零头,这个零头必须记录在案,下次累加时才能凑出完整的周期。最后三个 avg 字段是把各自的 sum 值用等效周期数做除法得到的平均负载,更方便调度器直接比较。

4.2 调度实体与两种初始化策略

调度实体在源码中的定义是 struct sched_entity,它内部嵌入了 sched_avg 结构体:

struct sched_entity { struct load_weight load; unsigned long runnable_weight; #ifdef CONFIG_SMP struct sched_avg avg; #endif };

从 CFS 的角度看,调度实体有两种类型:普通任务的调度实体(task se)和组调度中的组实体(group se)。文档还特别强调,两种实体在初始化时的策略完全不同,初始化函数是 init_entity_runnable_average():

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 初始化时把 load_avg 和 runnable_load_avg 直接设置为进程的权重值,而不是从 0 开始慢慢累积。原因是新任务刚创建时还没有产生任何运行历史,如果从 0 开始计算,调度器会把它当成一个轻量任务,可能低估它对 CPU 的需求。先给一个满值,让负载稳定算法在后续几个周期内逐步收敛到真实水平。

group se 则反其道而行之,初始化为 0。这是因为 task group 刚创建时还没有挂接任何任务,负载确实是 0。等任务开始加入组后,组实体的负载才会逐步累加。

runnable_weight 这个成员是专门针对 group se 的扩展。task se 的 runnable_weight 就是它的 load.weight,二者完全相等。group se 的 runnable_weight 表示组运行队列中可运行部分对应的权重,可能小于 load.weight,因为组里可能有一部分任务在睡眠,但整个组的调度权重依然保留。

4.3 负载更新流程:从update_load_avg到最终平均值

负载更新的调度入口是 update_load_avg(),它在三个典型场景被调用:任务加入运行队列时会触发 enqueue_entity 流程;任务从运行队列删除时会触发 dequeue_entity 流程;每个调度 tick 到达时周期性更新。

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; /* * Track task load average for carrying it to new CPU after migrated, and * track group sched_entity load average for task_h_load calc in migration */ 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_load_avg_se() 更新调度实体本身的负载,第二步 update_cfs_rq_load_avg() 更新整个 CFS 运行队列的负载。运行队列的负载本质上是队列内所有调度实体负载的聚合值,所以第二步通常基于第一步的计算结果做归并。

继续往深处看,__update_load_avg_se() 会调用 ___update_load_sum() 计算负载总和,然后调用 ___update_load_avg() 计算平均值:

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; delta >>= 10; /* 1 */ if (!delta) return 0; sa->last_update_time += delta << 10; /* 2 */ if (!load) runnable = running = 0; if (!accumulate_sum(delta, cpu, sa, load, runnable, running)) /* 3 */ return 0; return 1; }

注意注释 1 处的时间处理:delta 的单位是纳秒,右移 10 位相当于除以 1024,把时间戳差值换算成微秒。如果两次更新之间的时间差不足 1 微秒,右移后直接为 0,函数立即返回,不做任何更新。这是对高频调用场景的防御性优化——调度路径非常频繁,大量时间差可能不到 1us,这些情况没有必要触发负载计算。

注释 2 处更新 last_update_time 时用的是 delta << 10 而不是直接赋值 now,这样可以保证时间戳始终对齐到微秒边界,避免误差累积。

最后的平均值计算在 ___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); sa->util_avg = sa->util_sum / divider; }

divider 的来历一开始不太容易看懂。LOAD_AVG_MAX 是所有周期负载贡献的极限累加值,减去 1024 是去掉当前完整周期,再加上 period_contrib 补回当前周期的部分贡献,得到的才是等效周期数。这个除法的物理意义是:当前负载总和相当于多少个完整 1024us 周期的负载贡献。

5. PELT避坑指南:常见问题与排查方法

5.1 现象:decay_load入参n太大导致负载被清零

在调试自定义调度策略时,有人手动调用 decay_load(val, n),传了一个很大的 n 值,结果负载直接变成 0,追踪日志里完全看不到衰减过程。

原因是 decay_load() 开头有一道防呆判断:n > LOAD_AVG_PERIOD * 63(即 2016)时直接返回 0。这个阈值对应约 2016 * 1024us ≈ 2 秒,是内核认为的历史负载最远影响范围。如果是在内核模块中直接调用这个函数,传入超过阈值的 n,就会得到 0 而不是缓慢衰减的中间值。

解决方法是分段调用。每次传入的 n 不超过 32,分多次调用累积结果,这样既避免触发阈值返回,也能观察到完整衰减曲线。我一般习惯封装一个循环,每次最多衰减 32 个周期。

5.2 现象:___update_load_sum里delta为0导致负载不更新

在快速连续调用 schedule() 的场景中,某些调度实体的 last_update_time 很长时间没有推进,负载值停留在上一次的数据。看起来像是负载跟踪失效了。

原因在 ___update_load_sum() 里的 delta >>= 10。delta 的单位是纳秒,右移 10 位换算成微秒,如果两次更新间隔不足 1024 纳秒,右移后直接为 0,函数返回 0 不做更新。高主频 CPU 上更容易遇到这种情况,因为 tick 间隔和任务切换之间的时间差可能非常短。

这个行为是正常的优化设计,不需要修复。但如果调试代码依赖实时统计,需要先检查 cfs_rq_clock_task() 的返回值,确认两次调用之间确实经过了足够的时间。我自己写 eBPF 探针时就踩过这个坑,直接从 se->avg.last_update_time 和当前时间求差,发现差值总是 0,最后才意识到是纳秒太小、换算成微秒被截断了。

5.3 现象:group se的load_avg初始为0导致组调度误判

配置了 CONFIG_FAIR_GROUP_SCHED 之后,新建一个 task group 并往里放任务,发现组实体的 load_avg 长时间偏低,影响了负载均衡决策。

原因是 init_entity_runnable_average() 对 task se 和 group se 采用了完全不同的初始化策略。task se 初始化为进程权重,让新进程立刻被调度器"看见";group se 初始化为 0,因为创建组时里面没有任务。但如果你把已经挂载了大量任务的组迁移到新 CPU 上,这个组的 load_avg 要从 0 开始重新爬升,需要几十毫秒才能反映真实负载。

如果实际生产场景中组迁移很频繁,可以在迁移后手动调用 update_load_avg() 把组的现有负载刷到目标 CPU。但这属于比较 hack 的做法,不建议轻易上生产环境,因为 PELT 的设计初衷就是让负载平滑收敛,避免瞬时尖峰导致调度抖动。

5.4 现象:LOAD_AVG_MAX的理论值和代码计算值不一致

在验证 __accumulate_pelt_segments() 的数学推导时,用等比数列求和公式手算 LOAD_AVG_MAX = 1024 / (1 - y) ≈ 47742,和内核源码中定义的 47742 在低位上不一致。

原因很简单:代码里的 LOAD_AVG_MAX 不是通过浮点公式直接算出来的,而是用 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); }

每次迭代都是整数乘法和右移,必然有截断误差,最后收敛到的值自然和浮点公式的理论值有细微出入。这个差距不会引发功能问题,因为负载计算全程使用同一套整数运算,误差是自洽的。

解决方法是:移植 PELT 到自己的实现时,直接照抄内核源码中的常量,不要自己用浮点公式算。内核文档里提供 sched-pelt.c 就是专门用来生成这些常量的工具。

6. 二次开发技巧:改PELT参数并验证效果

6.1 调整衰减周期长度的影响评估

PELT 的 1024us 周期长度并不是魔法数字,它影响了负载跟踪的时间灵敏度和平滑度。周期越短,负载对突发任务的反应越灵敏,但也越容易抖动;周期越长,负载越平滑,但对短任务的响应就越迟钝。

如果要做实验,可以尝试修改 __accumulate_pelt_segments() 里硬编码的 1024。比如把周期改为 2048us,同时更新 decay_load() 中的 LOAD_AVG_PERIOD 为 64,并且重新生成 runnable_avg_yN_inv 查找表。

但要注意,修改周期长度是个牵一发动全身的操作,不仅影响负载计算本身,还影响负载均衡的迁移决策、schedutil 的调频策略、以及 task group 的权重分配逻辑。如果只是随便改个数字就编译内核,很可能出现调度异常。

我一般不建议直接修改周期常量。更安全的实验方式是通过 tracepoint 观察现有行为。内核提供了 sched_load_avg_task 和 sched_load_avg_cfs_rq 等 tracepoint,可以直接查看负载变化过程和收敛曲线,不需要改任何代码:

echo 1 > /sys/kernel/debug/tracing/events/sched/sched_load_avg_task/enable cat /sys/kernel/debug/tracing/trace_pipe

如果确实要改参数,建议在修改前先通过 tracepoint 记录一份基线数据,修改后再记录一份,对比负载收敛速度、超调量和稳定值的变化。

6.2 验证负载计算正确性:从单CPU到SMP

我的习惯是先用单 CPU 环境验证计算正确性,再开 SMP 验证并行行为。单 CPU 环境下没有负载迁移的干扰,可以直接对比 tracepoint 采集到的 load_avg 和通过公式手算的期望值。

如果发现误差超过预期,优先检查 period_contrib 是否正确更新。这个值是最容易出问题的地方——如果某次更新忘记了保存余数,下一次 accumulate_sum() 计算周期数时就会偏差,导致衰减次数和实际经过的周期数不一致。

SMP 环境下的验证重点是负载迁移行为。将一个高负载任务从 CPU0 迁移到 CPU1,观察 CPU1 上对应调度实体的 load_avg 是否能快速爬升到接近迁移前的水平。如果爬升太慢,说明衰减计算有误或者 task se 的初始化负载没有正确传递。

6.3 一个具体的测试用例

我在验证 PELT 参数改动时经常用这个测试法:创建一个纯计算型任务循环跑 200ms,然后用 trace 记录前 100ms 内的 util_avg 变化曲线。正常情况下应该看到从进程权重初始值出发,在几十个周期内逐步稳定到接近 1024 的值,因为 util_avg 的满值是 1024,对应一个 CPU 核心被完全占满。

如果曲线出现持续振荡或者明显超调,说明衰减系数和周期的匹配有问题。这时候我会回去检查 runnable_avg_yN_inv 数组是否用了正确的 y 值,以及 decay_load 的右移操作是否被编译器优化出了意外行为。曾经在一次移植过程中忽略了大端序的问题,查找表字节序不对导致计算结果完全错误,后来用 trace 对比才暴露出来。

从那以后我每次改 PELT 相关的代码都会强制走一遍完整验证流程:先用单 CPU 环境做正确性测试,再用 tracepoint 观察负载曲线,最后才考虑性能影响。这套流程虽然繁琐,但确实帮我避开了不少隐蔽的坑,希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/23 3:11:15

Python手游自动化脚本:ADB截屏、OpenCV模板匹配与状态机避坑

简介&#xff1a;这份基于 Python 的王者荣耀自动化脚本学习包&#xff0c;面向希望理解游戏辅助实现思路、练习图像识别与键鼠模拟的开发者&#xff0c;也可作为 Python 小型项目的拆解案例&#xff0c;帮助读者从配置、源码到运行依赖完整观察一个自动化脚本的组成。压缩包共…

作者头像 李华
网站建设 2026/10/23 3:08:16

CloudCompare零基础入门:点云处理从汉化到配准滤波全流程实战

简介&#xff1a;CloudCompare使用教程面向三维点云处理初学者及地质、建筑、工程领域的实践者&#xff0c;系统梳理了从基础操作到高级分析的核心流程。教程涵盖文件导入导出、显示设置、几何体创建、点云编辑与法线计算、八叉树优化&#xff0c;以及去噪、配准、降采样、分割…

作者头像 李华
网站建设 2026/10/23 3:08:13

从Transformer到LLaMA2:大语言模型原理与实战训练指南

简介&#xff1a;《Happy-LLM&#xff1a;从零开始的大语言模型原理与实践教程》由Datawhale发起&#xff0c;是一份系统性的大语言模型学习资料。它面向有一定自然语言处理或深度学习基础、希望深入理解大语言模型内部原理并动手实践的开发者&#xff0c;旨在弥补从理论认知到…

作者头像 李华
网站建设 2026/10/23 3:07:39

数学建模赛题:大学生学习影响评价中的问卷数据清洗与降维选型

简介&#xff1a;数学建模电工杯赛题《人工智能对大学生学习影响的评价》的参赛论文完整收录&#xff0c;文件为DOCX格式&#xff0c;共1个文档&#xff0c;压缩包大小871KB。该论文聚焦AI技术对大学生学习的实际影响&#xff0c;从问卷数据预处理入手&#xff0c;包括缺失值筛…

作者头像 李华
网站建设 2026/10/23 3:07:39

自动驾驶仿真平台建设:从数据管理到流程自动化与CAE追溯体系

简介&#xff1a;面向自动驾驶、主动安全与人工智能研发人员&#xff0c;系统讲解西门子仿真平台与体系建设思路&#xff1a;从虚拟场景构建&#xff0c;到协同设计仿真平台&#xff0c;再到物料清单转换、流程自动化、多学科协同和任务闭环管理&#xff0c;覆盖整车开发验证周…

作者头像 李华