0/3 已展开

LLM 分析

sched/core 与 KVM x86:修复 guest 内 vCPU 抢占期间的 task clock 记账

系列概况

  • 标题[PATCH RFC 0/2] Fix KVM guest scheduling accounting issue related to stealtime
  • 作者:Dongli Zhang dongli.zhang@oracle.com
  • 版本:RFC v1(base-commit 818bebeb63dd6bf5f4e07e145f6cdbace520a34c
  • 规模:2 个补丁,共 3 个文件,+57/-26 行
  • 修改文件
    • arch/x86/kvm/x86.c(+32/-26)
    • kernel/sched/core.c(+24/0)
    • kernel/sched/sched.h(+1/0)
  • 代码统计:2 文件 +25 行 + 1 文件 +1 行 = +57 / -26(合并 diff)
  • Message-ID20260824012716.753022-1-dongli.zhang@oracle.com(封面);...-2-...(patch 1);...-3-...(patch 2)
  • 完整性:完整(封面 + 2 patch + 末尾附复现 Python 脚本)

补丁目的

修复 KVM guest 内跨 vCPU 进行 runqueue clock_task 更新时的 steal time 记账漏洞。

当 vCPU A 通过 update_rq_clock_task() 给 vCPU B 做记账,而 vCPU B 被 host 抢占 stall 时,host 只在 B 重新进入 guest 之前才更新 stealtime。在 stall 期间:

  1. vCPU A 看到的 vcpu_is_preempted(B) == true,stealtime 却还没有增加;
  2. guest 把这段被 host 偷走的时间错误地记成 B 上 task 的 runtime;
  3. 等 host 终于更新 stealtime 时,由于 elapsed delta 已经用过,stealtime 无法全部扣除,任务额外承担一次调度惩罚。

复现:作者用 runtime_stall_detector_v2.py 在 guest 里开了 5 个 pin 到 vCPU 2 的线程,再用 host 上的 chrt -f 90 在 pCPU 10 上 stall 10 秒。修复前有 worker 报告 gap=49.718478 sec(约 10s 真实 stall + 约 40s 额外惩罚),修复后 5 个 worker 都恢复到约 10s。

旧流程的问题

在裸金属上,update_rq_clock_task() 让远端 CPU 帮忙给本 rq 推进 clock_task,逻辑闭环。但在 KVM guest 里,这一闭环被 vcpu_is_preempted() 撕开:

  • B 被 host 抢占,B 的 stealtime 冻结,但 host 上的 B-stalled 时间是真实流逝的;
  • A 用 IRQ + steal delta 推 B 的 clock_task,这段应该被 steal 扣掉的时间被错误地记成 B 上 task 的运行时间;
  • 真正进入 guest 时 stealtime 才更新,但此时 delta 已经消费过,无法回填。

新流程

把 KVM 侧的 stealtime 发布和 guest 侧的 clock_task 推迟配对起来:

  1. KVM host(patch 1/2)record_steal_time()clear preempted=0 之前先把 stealtime 写回用户态缓冲区,保证 vcpu_is_preempted() == false 与最新 stealtime 同步可见。
  2. Guest scheduler(patch 2/2)update_rq_clock_task() 检测 rq_cpu != smp_processor_id() && vcpu_is_preempted(rq_cpu) 时,把 delta 累加到新的 rq->deferred_clock_task 上,立刻 return;当下一次发现 B 不再被抢占时,再把 deferred_clock_task 折回 delta,一起送进 IRQ/steal 账本。

这样 A 推迟记账,B 一旦恢复,delta 一并结清,stealtime 一定能在它覆盖的窗口内被扣掉。

Patch 概览

Patch文件关键改动
1/2arch/x86/kvm/x86.crecord_steal_time 内把 user_access_begin ... version+1 ... smp_wmb ... steal ... user_access_end 整段往写 preempted=0 之前挪,并在末尾追加 smp_wmb(),保证版本号切换对远端观测者可见
2/2kernel/sched/core.c + sched.hrq 新增 u64 deferred_clock_taskupdate_rq_clock_task()CONFIG_PARAVIRT_TIME_ACCOUNTING 下检查 paravirt_steal_rq_enabledvcpu_is_preempted()

关键实现

Patch 1(KVM 侧)核心顺序:

static void record_steal_time(struct kvm_vcpu *vcpu)
{
    ...
    if (!user_access_begin(st, sizeof(*st)))
        return;

    unsafe_get_user(version, &st->version, out);
    if (version & 1)
        version += 1; /* first time write, random junk */

    version += 1;
    unsafe_put_user(version, &st->version, out);

    /* Pairs with the guest side virt_rmb() in kvm_steal_clock(). */
    smp_wmb();

    unsafe_get_user(steal, &st->steal, out);
    steal += current->sched_info.run_delay - vcpu->arch.st.last_steal;
    vcpu->arch.st.last_steal = current->sched_info.run_delay;
    unsafe_put_user(steal, &st->steal, out);

    version += 1;
    unsafe_put_user(version, &st->version, out);

    /*
     * Publish the stealtime before making the vCPU look runnable to
     * the guest.
     */
    smp_wmb();

    ...
    unsafe_put_user(0, &st->preempted, out);    /* now clear */
    vcpu->arch.st.preempted = 0;
out:
    user_access_end();
    mark_page_dirty_in_slot(vcpu->kvm, ghc->memslot, gpa_to_gfn(ghc->gpa));
}

Patch 2(guest scheduler 侧)核心:

static void update_rq_clock_task(struct rq *rq, s64 delta)
{
#ifdef CONFIG_PARAVIRT_TIME_ACCOUNTING
    if (static_key_false(&paravirt_steal_rq_enabled)) {
        int rq_cpu = cpu_of(rq);

        /*
         * A remote CPU can update this rq before the owner vCPU
         * has re-entered the guest and refreshed its stealtime
         * state. Do not charge that elapsed time to the current
         * task until stealtime can be sampled after the vCPU is
         * no longer preempted.
         */
        if (rq_cpu != raw_smp_processor_id() &&
            vcpu_is_preempted(rq_cpu)) {
            rq->deferred_clock_task += delta;   /* remote rq & preempted: defer */
            return;
        }

        if (rq->deferred_clock_task) {          /* once updatable, fold back */
            delta += rq->deferred_clock_task;
            rq->deferred_clock_task = 0;
        }
    }
#endif
    /* existing IRQ + steal accounting logic unchanged */
    ...
}

类比

clock_task 想象成咖啡馆的营业额流水。stealtime 是老板被叫去开会(被 host 抢占)期间没来得及入账的销售额。旧流程:收银员 A 在 B 被叫去开会时直接按当时账面流水记了一笔,老板回来才发现那一小时根本没卖东西。新流程:

  • 老板出门前先把手头的销售小票贴进账本(KVM 侧 patch 1:清 preempted 前先写 stealtime);
  • 收银员 A 看到 B 被叫走,就先把这段时间的入账放进抽屉 deferred_clock_task,等 B 真正回来再一起补登(patch 2)。

这样无论谁先看到 B 恢复上班,账面和实际销售额一定对得上。

ASCII 流程图

Old flow (guest)              KVM host                    vCPU B state
---------------               --------                    ------------
vCPU A: update_rq_clock_task(B)
  delta += IRQ_delta
  delta += steal_delta   <-- steal value still pre-preempt
  rq_B.clock_task += delta   <-- host-stolen time charged as runtime
                              ... after 10s ...
                              record_steal_time(B)
                                write stealtime
                                preempted = 0
                                B is runnable again

New flow (guest + host coordinated)
---------------               --------                    ------------
vCPU A: update_rq_clock_task(B)
  vcpu_is_preempted(B) == 1
  rq_B.deferred_clock_task += delta   <-- stash
  return
                              B about to enter guest:
                              record_steal_time(B)
                                write stealtime
                                smp_wmb()
                                preempted = 0      <-- patch 1: publish first, clear later
                                              B is runnable again
vCPU A: update_rq_clock_task(B)
  vcpu_is_preempted(B) == 0
  delta += rq_B.deferred_clock_task   <-- fold back in
  rq_B.deferred_clock_task = 0
  rq_B.clock_task += delta + steal_delta
                            IRQ + steal deducted together

Highlight:风险与注意点

  1. 必须 host + guest 同时打补丁:patch 1 单独落地无效(guest 仍会读到旧 preempted=1 而推迟),patch 2 单独落地也只能延后记账而看不到新鲜 stealtime。任一侧漏改,问题依旧。
  2. static_key_false(&paravirt_steal_rq_enabled) 守卫:避免在非 paravirt 或未启用 CONFIG_PARAVIRT_TIME_ACCOUNTING 时多走分支;__maybe_unused steal 注释也暗示编译器在关闭配置时忽略 steal 累计路径。
  3. 配对的 memory barrier:patch 1 在两个版本号切换之间各放一次 smp_wmb(),对应 guest kvm_steal_clock() 里的 virt_rmb()。少一道 wmb 都会让远端 vCPU 看到新 preempted 但旧 stealtime 的窗口,重新踩坑。
  4. vcpu_is_preempted() 语义:guest 用的是 arch 提供的 pv 辅助位(例如 x86 的 vcpu_info.preempted),其可见性由 patch 1 的 smp_wmb() 保证;这层合约写进了 patch 2 的注释里。
  5. runtime_stall_detector_v2.py 仅作复现:脚本里的 gap > 30 才退出,复现场景里 worker 5 报告约 9.99s、worker 3 报告约 49.7s,差距 40s 即额外惩罚量级,可作为后续回归用例的判据。

版本变化

当前为 RFC v1,仅一个版本,无 vN->vN+1 可比较。

一句话总结

把 KVM host 发布 stealtime 的时机从 vCPU 进 guest 前提前到清 preempted 前,并在 guest update_rq_clock_task() 端把被抢占期间的远端 clock_task delta 暂存到 rq->deferred_clock_task,等远端 vCPU 不再被抢占时再一并扣减,从而让 guest 调度器在 KVM 下也能正确把 host 偷走的时间从 task runtime 里剥掉。