一、前言
在上一篇文章中,提到**任务放置(task placement),**当阻塞的任务被唤醒的时候,确定该任务应该放置在那个CPU上执行。通过对任务放置场景(task placement)的均衡分布进行分析,加深对内核调度器实现任务均衡分布的理解。
二、任务放置
2.1 什么是任务放置(task placement)
Linux 为每个CPU都配置一个cpu runqueue,用以维护当前CPU需要运行的所有线程,调度器会按一定的规则从runqueue中获取某个线程来执行。如果一个线程正挂在某个CPU的runqueue上,此时它处于就绪状态,尚未得到cpu资源,调度器会适时地通过负载均衡(load balance)来调整任务的分布;当它从runqueue中取出并开始执行时,便处于运行状态,若该状态下的任务负载不是当前CPU所能承受的,那么调度器会将其标记为misfit task,周期性地触发主动迁移(active upmigration),将misfit task布置到更高算力的CPU。
上面提到的场景,都是线程已经被分配到某个具体的CPU并且具备有效的负载。如果一个任务线程还未被放置到任何一个CPU上,即处于阻塞状态,又或者它是刚创建、刚开始执行的,此时调度器又是何如做均衡分布的呢?这便是今天我们要花点篇幅来介绍的任务放置场景。
内核中,task placement场景发生在以下三种情况:
(1)进程通过fork创建子进程;
(2)进程通过sched_exec开始执行;
(3)阻塞的进程被唤醒。
2.2 调度域(sched domain)及其标志位(sd flag)
如果你正在使用智能手机阅读本文,那你或许知道,目前的手机设备往往具备架构不同的8个CPU core。我们仍然以4小核+4大核的处理器结构为例进行说明。4个小核(cpu0-3)组成一个little cluster,另外4个大核(cpu4-7)组成big cluster,每个cluster的CPU架构相同,它们之间使用同一个调频策略,并且频率调节保持一致。大核相对小核而言,具备更高的算力,但也会带来更多的能量损耗。
对于多处理器均衡(multiprocessor balancing)而言,sched domain是极为重要的概念。内核中以结构体 struct sched_domain对其进行定义,将CPU core从下往上按层级划分,对系统所有CPU core进行管理,本系列文章第一篇已进行过较为详细的描述。little cluster和big cluster各自组成底层的MC domain,包含各自cluster的4个CPU core,顶层的DIE domian则覆盖系统中所有的CPU core。
内核调度器依赖sched domain进行均衡,为了方便地对各种均衡状态进行识别,内核定义了一组sched domain flag,用来标识当前sched domain具备的均衡属性。表中,我们可以看到task placement场景常见的三种情况对应的flag。
|--------------------|---------|---------------|
| 属性 | 标识位 | 含义 |
| SD_LOAD_BALANCE | 0x0001 | 允许负载均衡 |
| SD_BALANCE_NEWIDLE | 0x0002 | 进入idle时进行均衡 |
| SD_BALANCE_EXEC | 0x0004 | exec时进行均衡 |
| SD_BALANCE_FORK | 0x0008 | fork时进行均衡 |
| SD_BALANCE_WAKE | 0x0010 | 任务唤醒时进行均衡 |
| SD_WAKE_AFFINE | 0x0020 | 任务唤醒时放置到临近CPU |
| ... |
在构建CPU拓扑结构时,会为各个sched domain配置初始的标识位(build_sched_domains->build_sched_domain->sd_init),如果是异构系统,会设置SD_BALANCE_WAKE:
//kernel/kernel/sched/topology.c
static struct sched_domain *
sd_init(struct sched_domain_topology_level *tl,
const struct cpumask *cpu_map,
struct sched_domain *child, int dflags, int cpu)
{
struct sd_data *sdd = &tl->data;
struct sched_domain *sd = *per_cpu_ptr(sdd->sd, cpu);
int sd_id, sd_weight, sd_flags = 0;
#ifdef CONFIG_NUMA
/*
* Ugly hack to pass state to sd_numa_mask()...
*/
sched_domains_curr_level = tl->numa_level;
#endif
sd_weight = cpumask_weight(tl->mask(cpu));
if (tl->sd_flags)
sd_flags = (*tl->sd_flags)();
if (WARN_ONCE(sd_flags & ~TOPOLOGY_SD_FLAGS,
"wrong sd_flags in topology description\n"))
sd_flags &= TOPOLOGY_SD_FLAGS;
/* Apply detected topology flags */
sd_flags |= dflags;
*sd = (struct sched_domain){
.min_interval = sd_weight,
.max_interval = 2*sd_weight,
.busy_factor = 16,
.imbalance_pct = 117,
.cache_nice_tries = 0,
.flags = 1*SD_BALANCE_NEWIDLE
| 1*SD_BALANCE_EXEC
| 1*SD_BALANCE_FORK
| 0*SD_BALANCE_WAKE
| 1*SD_WAKE_AFFINE
| 0*SD_SHARE_CPUCAPACITY
| 0*SD_SHARE_PKG_RESOURCES
| 0*SD_SERIALIZE
| 1*SD_PREFER_SIBLING
| 0*SD_NUMA
| sd_flags
,
.last_balance = jiffies,
.balance_interval = sd_weight,
.max_newidle_lb_cost = 0,
.next_decay_max_lb_cost = jiffies,
.child = child,
#ifdef CONFIG_SCHED_DEBUG
.name = tl->name,
#endif
};
cpumask_and(sched_domain_span(sd), cpu_map, tl->mask(cpu));
sd_id = cpumask_first(sched_domain_span(sd));
/*
* Convert topological properties into behaviour.
*/
....
return sd;
}
2.3 task placement均衡代码框架
linux内核的调度框架是高度抽象、模块化的,所有的线程都拥有各自所属的调度类(sched class),比如大家所熟知的实时线程属于rt_sched_class,CFS线程属于fair_sched_class,不同的调度类采用不同的调度策略。上面提到的task placement的三种场景,最终的函数入口都是core.c中定义的select_task_rq() 方法,之后会跳转至调度类自己的具体实现。本文以CFS调度类为分析对象,因为该调度类的线程在整个系统中占据较大的比重。有兴趣的朋友可以了解下其它调度类的**select_task_rq()**实现。
int wake_up_process(struct task_struct *p)
{
return try_to_wake_up(p, TASK_NORMAL, 0);
}
EXPORT_SYMBOL(wake_up_process);
wake_up_process 函数调用的是try_to_wake_up。
2.4 select_task_rq_fair ()方法
CFS调度类的线程进行task placement时,会通过core.c的select_task_rq () 方法跳转至select_task_rq _fair(),该方法声明如下:
//kernel/kernel/sched/fair.c
static int
select_task_rq_fair(struct task_struct *p, int prev_cpu, int sd_flag, int wake_flags)
{
struct sched_domain *tmp, *sd = NULL;
int cpu = smp_processor_id();
int new_cpu = prev_cpu;
int want_affine = 0;
int sync = (wake_flags & WF_SYNC) && !(current->flags & PF_EXITING);
if (sd_flag & SD_BALANCE_WAKE) {
record_wakee(p);
if (sched_energy_enabled()) {
new_cpu = find_energy_efficient_cpu(p, prev_cpu);
if (new_cpu >= 0)
return new_cpu;
new_cpu = prev_cpu;
}
want_affine = !wake_wide(p) && cpumask_test_cpu(cpu, p->cpus_ptr);
}
rcu_read_lock();
for_each_domain(cpu, tmp) {
/*
* If both 'cpu' and 'prev_cpu' are part of this domain,
* cpu is a valid SD_WAKE_AFFINE target.
*/
if (want_affine && (tmp->flags & SD_WAKE_AFFINE) &&
cpumask_test_cpu(prev_cpu, sched_domain_span(tmp))) {
if (cpu != prev_cpu)
new_cpu = wake_affine(tmp, p, cpu, prev_cpu, sync);
sd = NULL; /* Prefer wake_affine over balance flags */
break;
}
if (tmp->flags & sd_flag)
sd = tmp;
else if (!want_affine)
break;
}
if (unlikely(sd)) {
/* Slow path */
new_cpu = find_idlest_cpu(sd, p, cpu, prev_cpu, sd_flag);
} else if (sd_flag & SD_BALANCE_WAKE) { /* XXX always ? */
/* Fast path */
new_cpu = select_idle_sibling(p, prev_cpu, new_cpu);
if (want_affine)
current->recent_used_cpu = cpu;
}
rcu_read_unlock();
return new_cpu;
}
wake_flags参数:特地为SD_BALANCE_WAKE提供的唤醒标识位,一共有三种类型:
// kernel/kernel/sched/sched.h
/*
* wake flags
*/
#define WF_SYNC 0x01 /* Waker goes to sleep after wakeup */
#define WF_FORK 0x02 /* Child wakeup after fork */
#define WF_MIGRATED 0x04 /* Internal use, task got migrated */
#define WF_ON_CPU 0x08 /* Wakee is on_cpu */
#define WF_LOCK_SLEEPER 0x10 /* Wakeup spinlock "sleeper" */
select_task_rq_fair() 内仅对WF_SYNC进行处理,若传入该标识位,说明唤醒线程waker在被唤醒线程wakee唤醒后,将进入阻塞状态,调度器会倾向于将wakee放置到waker所在的CPU。这种场景使用相当频繁,比如用户空间两个进程进行非异步binder通信,Server端唤醒一个binder线程处理事务时,调用的接口如下:
select_task_rq_fair() 中涉及到三个重要的选核函数:find_energy_efficient_cpu () ,find_idlest_cpu () ,select_idle_sibling (),它们分别代表任务放置过程中的三条路径。task placement的各个场景,根据不同条件,最终都会进入如下三条路径的某一条路径,得到任务放置CPU并结束此次的task placement过程。现在让我们来理一理这三条路径的常见进入条件以及基本的CPU选择考量:
(1)EAS选核路径find_energy_efficient_cpu ()。当sd_flag为SD_BALANCE_WAKE,并且系统配置key值sched_energy_present(即考虑性能和功耗的均衡),调度器就会进入EAS选核路径进行CPU的查找。这里涉及到内核中Energy Aware Scheduling(EAS)机制。总之,EAS路径在保证任务能正常运行的前提下,为任务选取使系统整体能耗最小的CPU。通常情况下,EAS总是能如愿找到符合要求的CPU,但如果当前平台不是异构系统,或者系统中存在超载(Over-utilization)的CPU,EAS就直接返回-1,不能在这次调度中大展拳脚。
**当EAS不能在这次调度中发挥作用时,分支的走向取决于该任务是否为wake affine类型的任务,**这里让我们先来简单了解下该类型的任务。
用户场景有时会出现一个主任务(waker)唤醒多个子任务(wakee)的情况,如果我们将其作为wake affine类型处理,将wakee打包在临近的CPU上(如唤醒CPU、上次执行的CPU、共享cache的CPU),即可以提高cache命中率,改善性能,又能避免唤醒其它可能正处于idle状态的CPU,节省功耗。看起来这样的处理似乎非常完美,可惜的是,往往有些wakee对调度延迟非常敏感,如果将它们打包在一块,CPU上的任务就变得"拥挤",调度延迟就会急剧上升,这样的场景下,所谓的cache命中率、功耗,一切的诱惑都变得索然无味。
对于wake affine类型的判断,内核主要通过wake_wide () 和wake_cap() 的实现,从wakee的数量以及临近CPU算力是否满足任务需求这两个维度进行考量。注意:考虑到在select_idle_sibling中做了相同工作,wake_cap()在6.6中取消了,参考patch:sched/fair: Kill wake_cap()
(2)慢速路径find_idlest_cpu () 。有两种常见的情况会进入慢速路径:a、传入参数sd_flag为SD_BALANCE_WAKE,且EAS没有使能或者返回-1时,如果该任务不是wake affine类型,就会进入慢速路径;b、传入参数sd_flag为SD_BALANCE_FORK、SD_BALANCE_EXEC时,由于此时的任务负载是不可信任的,无法预测其对系统能耗的影响,也会进入慢速路径。慢速路径使用find_idlest_cpu **()**方法找到系统中最空闲的CPU,作为放置任务的CPU并返回。基本的搜索流程是:
首先确定放置的target domain(从waker的base domain向上,找到最底层配置相应sd_flag的domain),然后从target domain 中找到负载最小的调度组 ,进而 在调度组中找到负载最小的CPU 。
这种选核方式对于刚创建的任务来说,算是一种相对稳妥的做法,开发者也指出,或许可以将新创建的任务放置到特殊类型的CPU上,或者通过它的父进程来推断它的负载走向,但这些启发式的方法也有可能在一些使用场景下造成其他问题。
(3)快速路径select_idle_sibling () 。传入参数sd_flag为SD_BALANCE_WAKE,但EAS又无法发挥作用时,若该任务为wake affine类型任务,调度器就会进入快速路径来选取放置的CPU,该路径在CPU的选择上,主要考虑共享cache且idle的CPU。在满足条件的情况下,优先选择任务上一次运行的CPU(prev cpu),hot cache的CPU是wake affine类型任务所青睐的。其次是唤醒任务的CPU(wake cpu),即waker所在的CPU。当该次唤醒为sync唤醒时(传入参数wake_flags为WF_SYNC),对wake cpu的idle状态判定将会放宽,比如waker为wake cpu唯一的任务,由于sync唤醒下的waker很快就进入阻塞状态,也可当做idle处理。
如果prev cpu或者wake cpu无法满足条件,那么调度器会尝试从它们的LLC domain中去搜索idle的CPU。
task_struct 数据结构中有一个 recent_used_cpu 成员记录了进程最近经常使用的CPU。
三、wake affine 特性
3.1 引入 WAKE_AFFINE 的背景
当进程被唤醒的时候(try_to_wake_up),需要用 select_task_rq_fair为该 task 选择一个合适的CPU(runqueue), 接着会通过 check_preempt_wakeup 去看被唤醒的进程是否要抢占所在 CPU 的当前进程.
这个选核的过程我们一般称之为 BALANCE_WAKE. 为了能清楚的描述这个场景,我们定义
- 执行唤醒的那个进程是 waker
- 而被唤醒的进程是 wakee
Wakeup有两种,一种是sync wakeup,另外一种是non-sync wakeup。
- 所谓 sync wakeup 就是 waker 在唤醒 wakee 的时候就已经知道自己很快就进入 sleep 状态,而在调用 try_to_wake_up 的时候最好不要进行抢占,因为 waker 很快就主动发起调度了。此外,一般而言,waker和wakee会有一定的亲和性(例如它们通过share memory进行通信),在SMP场景下,waker和wakee调度在一个CPU上执行的时候往往可以获取较佳的性能。而如果在try_to_wake_up的时候就进行调度,这时候wakee往往会调度到系统中其他空闲的CPU上去。这时候,通过sync wakeup,我们往往可以避免不必要的CPU bouncing。
- 对于non-sync wakeup而言,waker和wakee没有上面描述的同步关系,waker在唤醒wakee之后,它们之间是独立运作,因此在唤醒的时候就可以尝试去触发一次调度。
当然,也不是说sync wakeup就一定不调度,假设waker在CPU A上唤醒wakee,而根据wakee进程的cpus_allowed成员发现它根本不能在CPU A上调度执行,那么管他sync不sync,这时候都需要去尝试调度(调用reschedule_idle函数),反正waker和wakee命中注定是天各一方(在不同的CPU上执行)。
select_task_rq_fair 的原型如下:
int select_task_rq_fair(struct task_struct *p, int prev_cpu, int sd_flag, int wake_flags)
在 try_to_wake_up 场景其中 p 是待唤醒进程, prev_cpu 是进程上次运行的 CPU, 一般 sd_flag 是 BALANCE_WAKE, 因此其实wakeup 的过程也可以理解为一次主动 BALANCE 的过程, 成为 WAKEUP BALANCE, 只不过只是为一个进程选择唤醒到的 CPU. wake_flags 用于表示是 sync wakeup 还是 non-sync wakeup.
我们首先看看UP上的情况。这时候waker和wakee在同一个CPU上运行(当然系统中也只有一个CPU,哈哈),这时候谁能抢占CPU资源完全取决于waker和wakee的动态优先级(调度类优先级, 或者 CFS 的 vruntime 等, 依照进程的调度类而定),如果wakee的动态优先级大于waker,那么就标记waker的need_resched标志,并在调度点到来的时候调用schedule函数进行调度。
SMP情况下,由于系统的CPU资源比较多,waker和wakee没有必要争个你死我活,wakee其实也可以选择去其他CPU执行,但是这时候要做决策:
- 因为跑到 prev_cpu 上, 那么之前如果 cache 还是 hot 的是很有意义的
- 同时按照之前的假设 waker 和 wakee 之间有资源共享, 那么唤醒到 waker CPU 上也有好处
- 如果 prev_cpu, waker cpu 都很忙, 那放上来可以并不一定好, 唤醒延迟之类的都是一个考量.
那么这些都是一个综合权衡的过程, 我们要考虑的东西比较多
- wake_cpu,prev_cpu 到底该不该选择?
- 选择的话选择哪个?
- 它们都不合适的时候又要怎么去选择一个更合适的?
内核需要一个简单有效的机制去做这个事情, 因此 WAKE_AFFINE 出现在内核中.
3.2 WAKE_AFFINE 机制简介
select_task_rq_fair 选核其实是一个优选的过程, 通常会有限选择一个 cache-miss 等开销最小的一个
-
根据 wake_affine 选择调度域并确定 new_cpu
-
根据调度域及其调度域参数选择兄弟 idle cpu 根据调度域及其调度域参数选择兄弟 idle cpu
-
根据调度域选择最深idle的cpu根据调度域选择最深idle的cpu find_idest_cpu
在进程唤醒的过程中为进程选核时, wake_affine 倾向于将被唤醒进程尽可能安排在 waking CPU 上, 这样考虑的原因是: 有唤醒关系的进程是相互关联的, 尽可能地运行在具有 cache 共享的调度域中, 这样可以获得一些 chache-hit 带来的性能提升. 这时 wake_affine 的初衷, 但是这也是一把双刃剑.
**出现的问题:**将 wakee 都唤醒在 waker CPU 上, 必然造成 waker 和 wakee 的资源竞争. 特别是对于 1:N 的任务模型, wake_affine 会导致 waker 进程饥饿.
62470419e993f8d9d93db0effd3af4296ecb79a5 sched: Implement smarter wake-affine logic
因此后来 (COMMIT 62470419e993 "sched: Implement smarter wake-affine logic"), 实现了一种智能 wake-affine 的优化机制. 用于 wake_flips 的巧妙方式, 识别出 1:N 等复杂唤醒模型, 只有在认为 wake_affine 能提升性能时(want_affine)才进行 wake_affine.
3.3 wake_affine 机制分析
根据 want_affine 变量选择调度域并确定 new_cpu:
选择的标准:
-
将要唤醒的进程p的调度域参数设置了SD_BALANCE_WAKE
-
当前cpu的唤醒次数没有超标(wakee_flips 变量的值小于 当前调度组的CPU的capacity)
-
当前task p消耗的capacity * 1138小于min_cap * 1024
-
当前cpu在task p的cpu亲和数里面的一个
static int
select_task_rq_fair(struct task_struct *p, int prev_cpu, int sd_flag, int wake_flags)
{
struct sched_domain *tmp, *sd = NULL;
int cpu = smp_processor_id();
int new_cpu = prev_cpu;
int want_affine = 0;
int sync = (wake_flags & WF_SYNC) && !(current->flags & PF_EXITING);if (sd_flag & SD_BALANCE_WAKE) { record_wakee(p); if (sched_energy_enabled()) { new_cpu = find_energy_efficient_cpu(p, prev_cpu); if (new_cpu >= 0) return new_cpu; new_cpu = prev_cpu; } want_affine = !wake_wide(p) && cpumask_test_cpu(cpu, p->cpus_ptr); } rcu_read_lock(); for_each_domain(cpu, tmp) { /* * If both 'cpu' and 'prev_cpu' are part of this domain, * cpu is a valid SD_WAKE_AFFINE target. */ if (want_affine && (tmp->flags & SD_WAKE_AFFINE) && cpumask_test_cpu(prev_cpu, sched_domain_span(tmp))) { if (cpu != prev_cpu) new_cpu = wake_affine(tmp, p, cpu, prev_cpu, sync); sd = NULL; /* Prefer wake_affine over balance flags */ break; } if (tmp->flags & sd_flag) sd = tmp; else if (!want_affine) break; } if (unlikely(sd)) { /* Slow path */ new_cpu = find_idlest_cpu(sd, p, cpu, prev_cpu, sd_flag); } else if (sd_flag & SD_BALANCE_WAKE) { /* XXX always ? */ /* Fast path */ new_cpu = select_idle_sibling(p, prev_cpu, new_cpu); if (want_affine) current->recent_used_cpu = cpu; } rcu_read_unlock(); return new_cpu;
}
-
wake_wide 和 wake_cap 为调度器提供决策, 当前进程是否符合 wake_affine 的决策模型. 如果他们返回 1, 则说明如果采用 wake_affine 进行决策, 大概率是无效的或者会降低性能, 则调度器就不会 want_affine 了.
want_affine = !wake_wide(p) && cpumask_test_cpu(cpu, p->cpus_ptr);
wake_wide 检查当前cpu的唤醒关系符合 wake_affine 模型.
task p 可以在当前 CPU 上运行.
注意:wake_cap 已经从调度器源码中删除
- wake_affine 则为目标进程选择最合适运行的 wake CPU.
内核引入 wake_affine 的初衷就是识别什么时候要将 wakee 唤醒到 waking CPU, 什么时候不需要. 这个判断由 want_affine 通过 wake_wide() 来完成.
3.4 record_wakee 与 wakee_flips
通过在 struct task_struct 中增加两个成员: 上次唤醒的进程 last_wakee, 和累积唤醒翻转计数器wakee_flips. 每当 waker 尝试唤醒 wakee 的时候, 就通过 record_wakee 来更新统计计数.
在 select_task_rq_fair 开始的时候, 如果发现是 SD_BALANCE_WAKE, 则先会 record_wakee 统计 current 的 wakee_flips.
static int
select_task_rq_fair(struct task_struct *p, int prev_cpu, int sd_flag, int wake_flags)
{
if (sd_flag & SD_BALANCE_WAKE) {
record_wakee(p);
wakee_flips 表示了当前进程作为 waker 时翻转(切换)其唤醒目标的次数, 所以高 wakee_flips 值意味着任务不止一个唤醒, 数字越大, 说明当前进程又不止一个 wakee, 而且唤醒频率越比较高.
比如一个进程 P 连续一段时间的唤醒序列为: A, A, A, A, 那么由于没有翻转, 那么他的 wakee_flips 就始终为 1.
static void record_wakee(struct task_struct *p)
{
/*
* Only decay a single time; tasks that have less then 1 wakeup per
* jiffy will not have built up many flips.
*/
if (time_after(jiffies, current->wakee_flip_decay_ts + HZ)) {
current->wakee_flips >>= 1;
current->wakee_flip_decay_ts = jiffies;
}
if (current->last_wakee != p) {
current->last_wakee = p;
current->wakee_flips++;
}
}
wakee_flips 有一定的衰减期, 如果过了 1S (即 1 个 HZ 的时间), 那么 wakee_flips 就衰减为原来的 1/2, 这就类似于 PELT 的指数衰减, Ns 前的 wakee_flips 的占比大概是当前这一个窗口的 1 / 2^N;
全局变量jiffies用来记录自系统启动以来产生的节拍的总数(经过了多少tick). 启动时, 内核将该变量初始化为0, 此后, 每次时钟中断处理程序都会增加该变量的值.一秒内时钟中断的次数等于Hz, 所以jiffies一秒内增加的值也就是Hz.系统运行时间以秒为单位, 等于jiffies/Hz.
将以秒为单位的时间转化为jiffies:
seconds * Hz
将jiffies转化为以秒为单位的时间:
jiffies / Hz
jiffies记录了系统启动以来, .
一个tick代表多长时间, 在内核的CONFIG_HZ中定义.比如CONFIG_HZ=250, 则一个jiffies对应4ms时间.所以内核基于jiffies的定时器精度也是4ms
3.5 wake_wide
当前 current 正在为 wakeup p, 并为 p 选择一个合适的 CPU. 那么 wake_wide 就用来检查 current 和 p 之间是否适合 wake_affine 所关心的 waker/wakee 模型.
wake_wide 返回 0, 表示 wake_affine 是有效的. 否则返回 1, 表示这两个进程不适合用 wake_affine.
那么什么时候, wake_wide 返回 1 ?
static int wake_wide(struct task_struct *p)
{
unsigned int master = current->wakee_flips;
unsigned int slave = p->wakee_flips;
int factor = __this_cpu_read(sd_llc_size);
if (master < slave)
swap(master, slave);
if (slave < factor || master < slave * factor)
return 0;
return 1;
}
wake_affine 在决策的时候, 要参考 wakee_flips
- 将 wakee_flips 值大的 wakee 唤醒到临近的 CPU, 可能有利于系统其他进程的唤醒, 同样这也意味着, waker 将面临残酷的竞争.
- 此外, 如果 waker 也有一个很高的 wakee_flips, 那意味着多个任务依赖它去唤醒, 然后 1 中造成的 waker 的更高延迟会对这些唤醒造成负面影响, 因此一个高 wakee_flips 的 waker 再去将另外一个高 wakee_flips 的 wakee 唤醒到本地的 CPU 上, 是非常不明智的决策. 因此, 当 waker-> wakee_flips / wakee-> wakee_flips 变得越来越高时, 进行 wake_affine 操作的成本会很高.
理解了这层含义, 那我们 wake_wide 的算法就明晰了. 如下情况我们认为决策是有效的 wake_affine
factor = this_cpu_read(sd_llc_size); 这个因子表示了在当前 NODE 上能够共享 cache 的 CPU 数目(或者说当前sched_domain 中 CPU 的数目), 一个 sched_domain 中, 共享 chache 的 CPU 越多(比如 X86 上一个物理 CPU 上包含多个逻辑 CPU), factor 就越大. 那么在 wake_affine 中的影响就是 wake_wide 返回 0 的概率更大, 那么 wake_affine 的结果有效的概率就更大. 因为有跟多的临近 CPU 可以选择, 这些 CPU 之间 cache 共享有优势.
3.6 wake_affine 函数
如果 want_affine 发现对当前 wakee 进行 wake_affine 是有意义的, 那么就会为当前进程选择一个能尽快运行的 CPU. 它总是倾向于选择 waking CPU(this_cpu) 以及 prev_cpu.
其中
-
wake_affine_idle 则看 prev_cpu 以及 this_cpu 是不是处于 cache 亲和的以及是不是idle 状态, 这样的 CPU往往是最佳的.
-
wake_affine_weight 则进一步考虑进程的负载信息以及调度的延迟信息.
//kernel/kernel/sched/fair.c
static int wake_affine(struct sched_domain *sd, struct task_struct *p,
int this_cpu, int prev_cpu, int sync)
{
int target = nr_cpumask_bits;if (sched_feat(WA_IDLE)) target = wake_affine_idle(this_cpu, prev_cpu, sync); if (sched_feat(WA_WEIGHT) && target == nr_cpumask_bits) target = wake_affine_weight(sd, p, this_cpu, prev_cpu, sync); schedstat_inc(p->se.statistics.nr_wakeups_affine_attempts); if (target == nr_cpumask_bits) return prev_cpu; schedstat_inc(sd->ttwu_move_affine); schedstat_inc(p->se.statistics.nr_wakeups_affine); return target;
}
wake_affine_idle:
static int
wake_affine_idle(int this_cpu, int prev_cpu, int sync)
{
/*
* If this_cpu is idle, it implies the wakeup is from interrupt
* context. Only allow the move if cache is shared. Otherwise an
* interrupt intensive workload could force all tasks onto one
* node depending on the IO topology or IRQ affinity settings.
*
* If the prev_cpu is idle and cache affine then avoid a migration.
* There is no guarantee that the cache hot data from an interrupt
* is more important than cache hot data on the prev_cpu and from
* a cpufreq perspective, it's better to have higher utilisation
* on one CPU.
*/
if (available_idle_cpu(this_cpu) && cpus_share_cache(this_cpu, prev_cpu))
return available_idle_cpu(prev_cpu) ? prev_cpu : this_cpu;
if (sync && cpu_rq(this_cpu)->nr_running == 1)
return this_cpu;
return nr_cpumask_bits;
}
如果 this_cpu 空闲, 则意味着唤醒来自中断上下文. 仅在 this_cpu 和 prev_cpu 有共享缓存时允许移动. 否则, 中断密集型工作负载可能会将所有任务强制到一个节点, 具体取决于IO拓扑或IRQ亲缘关系设置. 同时如果 prev_cpu 也是空闲的, 优先 prev_cpu.
另外没有证据保证来自中断的缓存热数据比 prev_cpu 上的缓存热数据更重要, 并且从cpufreq的角度来看, 最好在一个CPU上获得更高的利用率.
wake_affine_weight:
wake_affine_weight
会重新计算 wakeup CPU
和 prev CPU
的负载情况, 如果 wakeup CPU
的负载加上唤醒进程的负载比 prev CPU
的负载小, 那么 wakeup CPU
是可以唤醒进程.
static int
wake_affine_weight(struct sched_domain *sd, struct task_struct *p,
int this_cpu, int prev_cpu, int sync)
{
s64 this_eff_load, prev_eff_load;
unsigned long task_load;
this_eff_load = cpu_load(cpu_rq(this_cpu));
if (sync) {
unsigned long current_load = task_h_load(current);
if (current_load > this_eff_load)
return this_cpu;
this_eff_load -= current_load;
}
task_load = task_h_load(p);
this_eff_load += task_load;
if (sched_feat(WA_BIAS))
this_eff_load *= 100;
this_eff_load *= capacity_of(prev_cpu);
prev_eff_load = cpu_load(cpu_rq(prev_cpu));
prev_eff_load -= task_load;
if (sched_feat(WA_BIAS))
prev_eff_load *= 100 + (sd->imbalance_pct - 100) / 2;
prev_eff_load *= capacity_of(this_cpu);
/*
* If sync, adjust the weight of prev_eff_load such that if
* prev_eff == this_eff that select_idle_sibling() will consider
* stacking the wakee on top of the waker if no other CPU is
* idle.
*/
if (sync)
prev_eff_load += 1;
return this_eff_load < prev_eff_load ? this_cpu : nr_cpumask_bits;
}
wake_affine_weight 中负载比较的部分经历了很多次的修改.
eeb603986391 sched/fair: Defer calculation of 'prev_eff_load' in wake_affine_weight() until needed
082f764a2f3f sched/fair: Do not migrate on wake_affine_weight() if weights are equal
1c1b8a7b03ef sched/fair: Replace source_load() & target_load() with weighted_cpuload(), 这个是 sched: remove cpu_loads 中的一个补丁, 该补丁集删除了 cpu_load idx 干掉了 LB_BIAS 特性, 它指出 LB_BIAS 的设计本身是有问题的, 在负载均衡迁移时平滑两个 cpu_load 的过程中, 用 source_load/target_load 的方式在源 CPU 和目的 CPU 上用一个随机偏差的方式是错误的, 这个平衡偏差应该取决于cpu组之间的任务转移成本,而不是随机历史记录或即时负载。因为历史负载可能与实际负载相差很大,从而导致不正确的偏差.
11f10e5420f6c sched/fair: Use load instead of runnable load in wakeup path https://lore.kernel.org/patchwork/patch/1141693, 该补丁是 rework load balancce 的一个补丁, 之前唤醒路径用下的是 cpu_runnable_load, 现在修正为 cpu_load. cpu_load 对应的是 rq 的 load_avg, 代表就绪队列平均负载,其包含睡眠进程的负载贡献, cpu_runnable_load 则是 runnable_load_avg只包含就绪队列上所有可运行进程的负载贡献, wakeup 的时候如果使用 cpu_runnable_load 则可能造成选核的时候选择到一个有很多 runnable 线程的 overloaded 的 CPU, 而不是一个有很多 blocked 线程, 但是还有很大空闲的 CPU. 因此使用 cpu_load 在 wakeup 的时候可能更好.
当前内核版本 5.6.13 中 wake_affine_weight 的实现参见, 跟我们前面将的思路没有太大变化, 但是没有了 LB_BIAS, 同时比较负载使用的是 cpu_load().
四、wake_affine 演进
Michael Wang 实现了 Smart wake affine, 引入 wakee_flips 来识别 wake-affine 的场景. 然后 Peter 做了一个简单的优化, factor 使用了 sd->sd_llc_size 而不是直接获取所在NODE 的 CPU 数目. nr_cpus_node(cpu_to_node(smp_processor_id()));
接着 Vincent Guittot 和 Rik van Riel 做了不少功耗优化的工作. 这时候 wake-affne 中开始考虑 CPU capacity 的信息.
然后 Rik van Riel 在 NUMA 层次支持了 wake_affine
紧接着是 Peter Zijlstra 的一堆 FIX, 为了解决支持了 NUMA 之后一系列性能问题.
目前最新 5.2 的内核中,Dietmar Eggemann 删除了 LB_BIAS 特性, 因此 wake-affine 的代码做了部分精简.(仅仅是代码重构, 没有逻辑变更)
4.1 wake_affine 对 select_task_rq_fair 的影响.
在唤醒CFS 进程的时候通过 select_task_rq_fair 来为进程选择一个最适合的 CPU.
try_to_wake_up cpu = select_task_rq(p, p->wake_cpu, SD_BALANCE_WAKE, wake_flags);
那么在 wake_affine 机制的参与下, 选核流程是一个什么样的逻辑呢?
- 首先 sd_flag 必须配置 SD_BALANCE_WAKE 才会去做 wake_affine, 如果是 energy aware, EAS 会先通过 find_energy_efficient_cpu 选核, 不过这个是 EAS 的范畴, 不是我们今天的重点.
- 先 record_wakee 更新 wake affine 统计信息, 接着通过 wake_wide 看这次对进程的唤醒是不是 want_affine 的.
- 接着从 waker CPU 开始向上遍历调度域,
如果是 want_affine, 则先通过 wake_affine 在当前调度域 tmp 中是从 prev_cpu 和 waker CPU 以及上次的 waker CPU( recent_used_cpu) 中优选一个合适的 new CPU, 待会选核的时候, 就会从走快速路径 select_idle_sibling 中从 prev_cpu 和 new cpu 中优选一个 CPU. 同时设置 recent_used_cpu 为当前 waker CPU
否则, 如果是 want_affine, 但是 tmp 中没找到满足要求的 CPU, 则最终循环结束条件为 !(tmp->flag & SD_LOAD_BALANCE),
只要 wakeup 的时候, 会通过 wake_affine, 然后通过 select_idle_sibling 来选核.
其他情况下, 都是找到满足 sd_flags 的最高层次 sd, 然后通过 find_idlest_cpu 在这个调度域 sd 中去选择一个最空闲的 CPU.