The KVM halt polling system(KVM暂停轮询系统)
Linux内核:v6.2
KVM 暂停轮询系统是 KVM 内的一项功能,其在某些情况下可以通过在vCPU 放弃运行并让出后,在主机中进行一段时间的轮询来降低虚拟机的延迟。简而言之,当vCPU 放弃运行(即执行 cede 操作)或在 PowerPC 中,当一个虚拟核心(vcore)的所有vCPU 都放弃运行时,主机内核会在将 CPU 让给调度程序之前,通过轮询等待唤醒条件。
轮询在某些情况下提供了延迟优势,尤其是在虚拟机可以非常快速地再次运行的情况下。这至少可以通过减少通过调度程序的开销来节省一些时间,通常在几微秒的数量级上,尽管性能优势取决于工作负载。在轮询间隔内,如果没有唤醒源到达,或者运行队列上有其他可运行的任务,则会调用调度程序。因此,在具有非常短唤醒周期的工作负载中,halt轮询特别有用,因为最小化了halt轮询的时间,同时可以避免调用调度程序的时间花费。
暂停轮询间隔:
在调用调度程序之前轮询的最长时间,称为暂停轮询间隔,会根据轮询的有效性而增加或减少,以尽量限制无意义的轮询。这个值存储在vCPU 结构体(struct vcpu)中的一个地方kvm_vcpu->halt_poll_ns
,这是一个per cpu变量。
在轮询过程中,如果在halt轮询间隔内收到唤醒源,则该间隔保持不变。在轮询间隔内没有收到唤醒源(因此调用了调度程序)的情况下,有两种选择:要么轮询间隔和总阻塞时间[0]小于全局最大轮询间隔(请参阅下面的模块参数),要么总阻塞时间大于全局最大轮询间隔。
如果轮询间隔和总阻塞时间均小于全局最大轮询间隔,那么轮询间隔可以增加,希望下次在较长的轮询间隔内,当主机正在轮询时,将收到唤醒源并获得延迟优势。轮询间隔在函数 grow_halt_poll_ns()
中增加,并通过模块参数 halt_poll_ns_grow
和 halt_poll_ns_grow_start
进行乘法操作。
如果总阻塞时间大于全局最大轮询间隔,那么主机将永远无法轮询足够长的时间(受全局最大值的限制),以便在轮询间隔内唤醒。因此,为了避免无谓的轮询,轮询间隔将在函数 shrink_halt_poll_ns()
中缩短,并且会除以模块参数 halt_poll_ns_shrink
,或者如果 halt_poll_ns_shrink == 0
,则设置为 0。
值得注意的是,这个调整过程试图追求某个稳定状态的轮询间隔,但只有在唤醒以大致恒定的速率发生时才能真正做得很好,否则将不断调整轮询间隔。
总阻塞时间
模块参数:
kvm 模块有三个可调节的模块参数,用于调整全局最大轮询间隔以及轮询间隔增长和缩小的速率。这些变量在 include/linux/kvm_host.h
中定义,并作为模块参数在 virt/kvm/kvm_main.c
中使用。
这些参数可以通过debugfs进行调整: /sys/module/kvm/parameters/
请注意:这些模块参数是系统范围的数值,无法在每个虚拟机的基础上进行调整
对这些参数的任何更改都将在新创建的和现有的vCPU 下次暂停时生效,但有一个值得注意的例外,即使用 KVM_CAP_HALT_POLL 的虚拟机(请参阅下一节)。
KVM_CAP_HALT_POLL
KVM_CAP_HALT_POLL
是一个虚拟机功能,允许用户空间在每个虚拟机的基础上覆盖 halt_poll_ns
。使用 KVM_CAP_HALT_POLL
的虚拟机完全忽略 halt_poll_ns
(但仍然遵循 halt_poll_ns_grow
、halt_poll_ns_grow_start
和 halt_poll_ns_shrink
)。
- 架构:所有
- 目标:虚拟机
- 参数:
args[0]
是以纳秒为单位的最大轮询时间 - 返回:成功时返回 0;错误时返回 -1
KVM_CAP_HALT_POLL
覆盖了 kvm.halt_poll_ns
模块参数,用于设置目标虚拟机中所有vCPU 的最大暂停轮询时间。可以随时调用此功能,而且可以多次调用,以动态更改最大暂停轮询时间。
注意事项
- 在设置
halt_poll_ns
模块参数时需要小心,因为设置一个较大的值有可能使 CPU 利用率在几乎完全空闲的机器上达到 100%。这是因为即使在虚拟机的唤醒期间很少进行工作且唤醒时间相隔较长,如果这个时间段小于全局最大轮询间隔(halt_poll_ns
),那么主机将始终在整个halt时间内进行轮询,从而使 CPU 利用率达到 100%。 - halt轮询本质上是在功耗和延迟之间进行权衡,模块参数应该用于调整这方面的亲和力。空闲的 CPU 时间基本上被转化为主机内核时间,目的是在进入虚拟机时降低延迟。
- 当主机上没有其他可运行的任务时,主机才会进行halt轮询,否则轮询将立即停止,并调用调度程序以允许其他任务运行。因此,这不允许虚拟机拒绝服务 CPU。
代码分析
相关代码如下:
//linux6.2/arch/x86/kvm/x86.c
static inline int vcpu_block(struct kvm_vcpu *vcpu)
{
bool hv_timer;
/*
* 如果 vCPU 不可运行,先切换到软件定时器,然后再执行 HLT 等待。
* 这样做是因为在执行 HLT 等待之前,需要切换到软件定时器,因为
* 客户机的定时器可能是 vCPU 的中断事件,而虚拟化程序计时器只在
* CPU 处于客户机模式时运行。在阻塞之前切换,以便 KVM 在阻塞之前
* 就能识别到定时器的过期事件。
*/
if (!kvm_arch_vcpu_runnable(vcpu)) {
hv_timer = kvm_lapic_hv_timer_in_use(vcpu);
if (hv_timer)
kvm_lapic_switch_to_sw_timer(vcpu);
kvm_vcpu_srcu_read_unlock(vcpu);
//vCPU 的当前状态选择是执行 HALT 处理还是将其置于阻塞状态。
if (vcpu->arch.mp_state == KVM_MP_STATE_HALTED)
kvm_vcpu_halt(vcpu);
else
kvm_vcpu_block(vcpu);
kvm_vcpu_srcu_read_lock(vcpu);
if (hv_timer)
kvm_lapic_switch_to_hv_timer(vcpu);
/*
* 如果 vCPU 仍然不可运行,表示有信号或其他主机事件挂起;
* 在不改变 vCPU 活动状态的情况下,处理它。
*/
if (!kvm_arch_vcpu_runnable(vcpu))
return 1;
}
/*
* 在退出 halted 状态之前评估嵌套事件。这样可以正确记录 VMCS12
* 的活动状态字段中的 halt 状态(AMD 没有类似的字段,而 VM-Exit
* 总是导致从 HLT 中的虚假唤醒)。
*/
if (is_guest_mode(vcpu)) {
if (kvm_check_nested_events(vcpu) < 0)
return 0;
}
....
}
vcpu阻塞函数为kvm_vcpu_block():
/*
* 阻塞 vCPU,直到 vCPU 可运行、事件到达或有信号挂起。
* 这主要用于使 vCPU 暂停,但也可以直接用于其他 vCPU 的不可运行状态,
* 例如 x86 的 Wait-For-SIPI。
*/
bool kvm_vcpu_block(struct kvm_vcpu *vcpu)
{
struct rcuwait *wait = kvm_arch_vcpu_get_wait(vcpu);
bool waited = false;
// 将 vCPU 的 blocking 状态设置为 1,表示正在阻塞中
vcpu->stat.generic.blocking = 1;
preempt_disable();
// 调用架构相关的 vCPU 阻塞函数
kvm_arch_vcpu_blocking(vcpu);
prepare_to_rcuwait(wait);
preempt_enable();
for (;;) {
// 设置当前进程状态为 TASK_INTERRUPTIBLE,即可中断等待状态
set_current_state(TASK_INTERRUPTIBLE);
// 检查是否需要阻塞 vCPU
if (kvm_vcpu_check_block(vcpu) < 0)
break;
waited = true;
// 进行调度,切换到其他可运行的任务
schedule();
}
preempt_disable();
finish_rcuwait(wait);
// 调用架构相关的 vCPU 解除阻塞函数
kvm_arch_vcpu_unblocking(vcpu);
preempt_enable();
// 将 vCPU 的 blocking 状态设置为 0,表示不再阻塞
vcpu->stat.generic.blocking = 0;
return waited;
}
调整vcpu的halt poll时间:
/*
* 模拟 vCPU 的halt条件,例如 x86 上的 HLT,arm 上的 WFI 等等...,
* 如果启用了暂停轮询, 在阻塞之前忙等待一段时间,
* 以避免在 vCPU 暂停后立即出现唤醒事件时进行阻塞+解除阻塞,产生额外开销。
*/
void kvm_vcpu_halt(struct kvm_vcpu *vcpu)
{
unsigned int max_halt_poll_ns = kvm_vcpu_max_halt_poll_ns(vcpu);
bool halt_poll_allowed = !kvm_arch_no_poll(vcpu);
ktime_t start, cur, poll_end;
bool waited = false;
bool do_halt_poll;
u64 halt_ns;
// 如果 halt_poll_ns 超过最大允许的 halt poll 时间,则将其设置为最大值
if (vcpu->halt_poll_ns > max_halt_poll_ns)
vcpu->halt_poll_ns = max_halt_poll_ns;
// 检查是否允许使用 halt poll
do_halt_poll = halt_poll_allowed && vcpu->halt_poll_ns;
start = cur = poll_end = ktime_get();
if (do_halt_poll) {
ktime_t stop = ktime_add_ns(start, vcpu->halt_poll_ns);
// 在规定的 poll 时间内持续进行轮询
do {
if (kvm_vcpu_check_block(vcpu) < 0)
goto out;
cpu_relax();
poll_end = cur = ktime_get();
} while (kvm_vcpu_can_poll(cur, stop));
}
// 调用 kvm_vcpu_block 函数,使vCPU 进入休眠状态
waited = kvm_vcpu_block(vcpu);
cur = ktime_get();
// 如果休眠了,记录休眠等待的时间
if (waited) {
vcpu->stat.generic.halt_wait_ns +=
ktime_to_ns(cur) - ktime_to_ns(poll_end);
KVM_STATS_LOG_HIST_UPDATE(vcpu->stat.generic.halt_wait_hist,
ktime_to_ns(cur) - ktime_to_ns(poll_end));
}
out:
// 计算 vCPU 休眠总时间,包括轮询时间
halt_ns = ktime_to_ns(cur) - ktime_to_ns(start);
/*
* 注意,即使在休眠轮询循环本身结束之前唤醒事件到达,
* halt-polling 仍然被认为是成功的,只要 vCPU 没有实际上被调度出去。
*/
if (do_halt_poll)
update_halt_poll_stats(vcpu, start, poll_end, !waited);
if (halt_poll_allowed) {
/* 重新计算最大休眠轮询时间,以防它发生变化。*/
max_halt_poll_ns = kvm_vcpu_max_halt_poll_ns(vcpu);
// 如果不允许唤醒 vCPU,缩小 暂停轮询 时间
if (!vcpu_valid_wakeup(vcpu)) {
shrink_halt_poll_ns(vcpu);
} else if (max_halt_poll_ns) {
// 如果休眠时间较长,缩小轮询时间
if (halt_ns <= vcpu->halt_poll_ns)
;
// 如果有长时间的休眠,缩小轮询时间
else if (vcpu->halt_poll_ns &&
halt_ns > max_halt_poll_ns)
shrink_halt_poll_ns(vcpu);
// 如果休眠时间较短且轮询时间太小,增大轮询时间
else if (vcpu->halt_poll_ns < max_halt_poll_ns &&
halt_ns < max_halt_poll_ns)
grow_halt_poll_ns(vcpu);
} else {
vcpu->halt_poll_ns = 0;
}
}
// 记录 vCPU 唤醒事件
trace_kvm_vcpu_wakeup(halt_ns, waited, vcpu_valid_wakeup(vcpu));
}
其中和调整轮询时间相关的函数为grow_halt_poll_ns()和shrink_halt_poll_ns()
static void grow_halt_poll_ns(struct kvm_vcpu *vcpu)
{
unsigned int old, val, grow, grow_start;
// 保存当前 halt_poll_ns 的值,以备后续比较
old = val = vcpu->halt_poll_ns;
// 从 halt_poll_ns_grow_start 和 halt_poll_ns_grow 读取值
grow_start = READ_ONCE(halt_poll_ns_grow_start);
grow = READ_ONCE(halt_poll_ns_grow);
// 如果 grow 为 0,则直接跳转到 out 标签
if (!grow)
goto out;
// 将 halt_poll_ns 值按 grow 参数进行增长
val *= grow;
// 如果计算后的 val 小于 grow_start,则将其设为 grow_start
if (val < grow_start)
val = grow_start;
// 将新的 halt_poll_ns 值保存到 vcpu 结构体中
vcpu->halt_poll_ns = val;
out:
// 记录 vCPU 的 halt_poll_ns 增长事件
trace_kvm_halt_poll_ns_grow(vcpu->vcpu_id, val, old);
}
....
static void shrink_halt_poll_ns(struct kvm_vcpu *vcpu)
{
unsigned int old, val, shrink, grow_start;
// 保存当前 halt_poll_ns 的值,以备后续比较
old = val = vcpu->halt_poll_ns;
// 从 halt_poll_ns_shrink 和 halt_poll_ns_grow_start 读取值
shrink = READ_ONCE(halt_poll_ns_shrink);
grow_start = READ_ONCE(halt_poll_ns_grow_start);
// 如果 shrink 为 0,则将 halt_poll_ns 设为 0
if (shrink == 0)
val = 0;
else
val /= shrink;
// 如果计算后的 val 小于 grow_start,则将其设为 0
if (val < grow_start)
val = 0;
// 将新的 halt_poll_ns 值保存到 vcpu 结构体中
vcpu->halt_poll_ns = val;
// 记录 vCPU 的 halt_poll_ns 缩小事件
trace_kvm_halt_poll_ns_shrink(vcpu->vcpu_id, val, old);
}
使用eBPF程序实时记录vCPU暂停轮询时间的变化
上述代码中,shrink_halt_poll_ns
函数中定义了一个tracepoint:trace_kvm_halt_poll_ns_shrink(vcpu->vcpu_id, val, old)
grow_halt_poll_ns
中定义了一个tracepoint:trace_kvm_halt_poll_ns_grow(vcpu->vcpu_id, val, old)
这两个tracepoint通过一个bool变量被定义为同一个tracepoint:trace_kvm_halt_poll_ns(true, vcpu_id, new, old)
对于这个tracepoint的定义源码分析如下:
//linux-6.2/include/trace/events/kvm.h
/*
* 定义 KVM 的跟踪事件,用于记录暂停轮询的时间间隔。
* 该事件用于追踪虚拟 CPU 在暂停轮询过程中的时间间隔变化。
*/
TRACE_EVENT(kvm_halt_poll_ns,
TP_PROTO(bool grow, unsigned int vcpu_id, unsigned int new,
unsigned int old),
TP_ARGS(grow, vcpu_id, new, old),
TP_STRUCT__entry(
__field(bool, grow)
__field(unsigned int, vcpu_id)
__field(unsigned int, new)
__field(unsigned int, old)
),
TP_fast_assign(
__entry->grow = grow;
__entry->vcpu_id = vcpu_id;
__entry->new = new;
__entry->old = old;
),
TP_printk("vcpu %u: halt_poll_ns %u (%s %u)",
__entry->vcpu_id,
__entry->new,
__entry->grow ? "grow" : "shrink",
__entry->old)
);
/*
* 为了方便使用,定义两个宏:
* - trace_kvm_halt_poll_ns_grow: 用于跟踪轮询时间间隔的增长情况。
* - trace_kvm_halt_poll_ns_shrink: 用于跟踪轮询时间间隔的缩短情况。
*/
#define trace_kvm_halt_poll_ns_grow(vcpu_id, new, old) \
trace_kvm_halt_poll_ns(true, vcpu_id, new, old)
#define trace_kvm_halt_poll_ns_shrink(vcpu_id, new, old) \
trace_kvm_halt_poll_ns(false, vcpu_id, new, old)
挂载点 | 提取指标 |
---|---|
tracepoint:kvm:kvm_halt_poll_ns | vcpu轮询时间增长变化前后的值,和变化类型 |
运行结果如下:
输出各列解释:
- TIME(ns):poll halt时间变化时的时间戳
- COMM:进程名
- PID/TID:进程id/vcpu线程id
- TYPE:halt poll时间变化类型
- VCPU_ID:vcpuid
- OLD,NEW(ns):halt poll时间
vCPU暂停轮询参数对系统性能的影响
这里我们可以修改halt-polling中的参数:
root@nans:/sys/module/kvm/parameters# echo 10000000 > halt_poll_ns_grow_start ; echo 200000000 > halt_poll_ns ; echo 2 > halt_poll_ns_grow ; echo 2 > halt_poll_ns_shrink
halt_poll_ns_grow_start:默认halt-polling时间
halt_poll_ns:全局最大halt-polling间隔
halt_poll_ns_grow:halt-polling时间增长因子
halt_poll_ns_shrink :halt-polling时间缩小因子
然后通过eBPF程序来记录halt-polling的变化记录:
可以看到轮询的时间、增长、缩小因子和最大时间间隔成功修改。
将两个vcpu线程的cpu亲和性设置为cpu1,如下
root@nans:/sys/module/kvm/parameters# taskset -p -c 1 141585
pid 141585's current affinity list: 0-7
pid 141585's new affinity list: 1
root@nans:/sys/module/kvm/parameters# taskset -p -c 1 141586
pid 141586's current affinity list: 0-7
pid 141586's new affinity list: 1
这个是默认轮询参数时的cpu1的利用率和虚拟机进程的cpu利用率
这个是修改轮询参数后的cpu1的利用率和虚拟机进程的cpu利用率,和虚拟机内部的cpu利用率
这里可以看到虚拟机内部空载,但是由于将轮询的时间修改的非常大,导致vcpu线程一直占用cpu1,而导致在主机侧的cpu利用率非常高。
结论
在计算资源充足的情况下,为使虚拟机获得接近物理机的性能,可以使用halt-polling特性。没有使用halt-polling特性时,当vCPU空闲退出后,主机会把CPU资源分配给其他进程使用。当主机开启halt-polling特性时,虚拟机vCPU处于空闲时会polling一段时间,polling的时间由具体配置决定。若该vCPU在polling期间被唤醒,可以不从主机侧调度而继续运行,减少了调度流程的开销,从而在一定程度上提高了虚拟机系统的性能。
说明: halt-polling的机制保证虚拟机的vCPU线程的及时响应,但在虚拟机空载的时候,主机侧也会polling,导致主机看到vCPU所在CPU占用率比较高,而实际虚拟机内部CPU占用率并不高。
使用eBPF程序实时记录vCPU唤醒时间
上述代码中,kvm_vcpu_halt
函数中定义了一个tracepoint:trace_kvm_vcpu_wakeup(halt_ns, waited, vcpu_valid_wakeup(vcpu))
各个参数解释如下:
halt_ns
: 表示虚拟 CPU 处于 “halt” 状态的总时间,包括 halt-polling 的时间。waited
: 是一个布尔值,表示在 halt 状态期间是否进行了等待。vcpu_valid_wakeup(vcpu)
: 是一个函数调用,用于检查虚拟 CPU 是否具有有效的唤醒事件。返回值是一个布尔值,表示vCPU 是否有有效的唤醒事件。
对于这个tracepoint的定义源码分析如下:
//linux-6.2/include/trace/events/kvm.h
/*
* 跟踪记录 KVM 虚拟 CPU 唤醒事件的跟踪点定义。
*/
TRACE_EVENT(kvm_vcpu_wakeup,
TP_PROTO(__u64 ns, bool waited, bool valid),
TP_ARGS(ns, waited, valid),
TP_STRUCT__entry(
__field(__u64, ns) // 在等待或轮询状态中总共花费的时间
__field(bool, waited) // 指示 CPU 在唤醒期间是否处于等待状态
__field(bool, valid) // 指示唤醒事件是否有效
),
TP_fast_assign(
__entry->ns = ns;
__entry->waited = waited;
__entry->valid = valid;
),
TP_printk("%s time %lld ns,polling %s",
__entry->waited ? "wait" : "polling", // 根据 'waited' 标志打印等待或轮询
__entry->ns, // 打印在等待或轮询中总共花费的时间
__entry->valid ? "wait" : "poll" // 打印唤醒事件是否有效
);
挂载点 | 提取指标 |
---|---|
tracepoint:kvm:kvm_vcpu_wakeup | vCPU 唤醒的相关信息 |
fentry/kvm_vcpu_halt | tid对应的vcpu_id |
运行结果:
输出各列解释:
- TIME(ns):vCPU唤醒时的时间戳
- DURATIONS_TIME(ns):表示vCPU 处于halt状态的总时间,包括 halt-polling 的时间。
- COMM:进程名
- PID/TID:进程id/vcpu线程id
- VCPU_ID:vcpuid
- WAIT/POLL:wait表示在 halt 状态期间是否进行了等待,poll表示轮询完成后直接halt
- VAILD:表示vcpu是否被有效唤醒
代码链接:https://github.com/linuxkerneltravel/lmp/tree/develop/eBPF_Supermarket/kvm_watcher