virt-report虚拟化社区动态

KVM 大页映射的建立、拆分与恢复

本文由 OpenAI Codex (GPT-6-Astra) 辅助编写。

一、大页效率与逐页跟踪的冲突

同一段虚拟机内存可以由一个 2 MiB EPT 叶子项覆盖,也可以由 512 个 4 KiB 叶子项覆盖。大页减少第二级页表遍历层数和表项数量;细粒度映射则允许 KVM 分别控制子页的写权限与脏状态。迁移开始和结束时,这两种需求会推动映射粒度转换。

本文以 x86、Intel EPT、KVM TDP MMU 和普通 HVA 后备 RAM 为例,跟踪大页建立、脏页记录期间的拆分,以及关闭记录后的恢复。QEMU 在宿主用户态开启或关闭内存槽的脏页记录;KVM 在宿主内核中修改 EPT,物理 CPU 使用它完成地址转换。客户机内核继续管理自己的页表。地址转换和首次缺页的完整路径见内存映射与 EPT 缺页处理。

源码版本:Linux 6.18、QEMU 10.2.0。

二、客户机页、后备页与 EPT 叶子

层面管理者与映射与 EPT 大页的关系
客户机页表客户机内核管理 GVA → GPA客户机使用 4 KiB 页,EPT 仍可用 2 MiB 叶子覆盖连续 GPA;客户机使用大页也不能保证 EPT 使用大页。
宿主后备映射宿主内核管理 QEMU 的 HVA → HPA;可能使用 hugetlb 或透明大页提供当前可用的物理连续范围和宿主页表级别,限制 KVM 能建立的映射。
EPT 叶子项KVM 管理 GPA → HPA2 MiB 叶子位于第 2 级;4 KiB 叶子位于第 1 级,分别覆盖不同大小的客户机物理范围。

拆分 EPT 会增加一个下级页表页,并使原叶子变成指向它的非叶子项。宿主的 2 MiB 后备页可以继续存在,客户机页表也无需改动;映射转换本身不复制客户机数据。若宿主随后拆分或迁移后备内存,则由宿主内存管理和 MMU notifier 另行维护一致性。

三、大页映射的建立条件

一个 2 MiB 叶子必须描述完整、按 2 MiB 对齐的 GPA 和 HPA 区间,并以一组属性覆盖整个范围。KVM 还要求该范围落在允许大页的内存槽中,GPA 与 HVA 在对应页级别具有相同偏移。槽首尾未覆盖完整大页、地址偏移不匹配、页跟踪或混合内存属性,都可能限制可用级别。槽边界与相对对齐检查

host_pfn_mapping_level() 遍历宿主页表,识别 PMD / PUD 叶子。kvm_mmu_max_mapping_level() 再结合硬件上限和槽的大页限制,返回可用级别。因此,HVA 连续,甚至恰好连续的若干物理页,都不足以单独保证本路径建立大页;还需符合宿主映射级别与 KVM 的检查。级别选择

宿主可写性、槽属性和执行限制共同约束最终权限。例如 iTLB multihit 缓解可使大页不可执行,并让执行访问使用较小映射。脏页记录开启时,kvm_mmu_hugepage_adjust() 保持缺页目标为 4 KiB,以便逐页记录;后备页仍然可以是 2 MiB。

将这些条件放到一次实际建图中:客户机读取 GPA 0x00403000,目标 EPT 项尚缺;KVM 解析到的后备地址是 HPA 0x80003000。假设宿主以一个完整的 2 MiB 大页承载该范围,槽边界、权限和对齐均允许,且尚未开启脏页记录,那么本次访问虽然只落在一个 4 KiB 子页中,KVM 仍可选择第 2 级叶子,建立 GPA 0x00400000–0x005fffff → HPA 0x80000000–0x801fffff 的整段映射。处理器重试读取时,在这个大页内加上 0x3000 偏移,仍得到同一个 HPA。

源码中,kvm_mmu_hugepage_adjust() 选择目标级别,并把 PFN 向下对齐到该大页的起点;kvm_tdp_mmu_map() 遍历到目标级别后安装叶子,不再继续建立 4 KiB 叶子表。接下来开启迁移脏页记录时,需要改造的就是这一个已经存在的 2 MiB 叶子。级别与 PFN 调整、按目标级别建图

四、将 2 MiB 映射拆分为 512 个 4 KiB 表项

先看拆分的结果:原来一项权限作用于整个 2 MiB 范围,拆分后每个 4 KiB 子页有自己的表项。迁移器可以只跟踪其中被写过的 P3,而不用把同一大页内其他 511 页一起视为被修改。数据仍在原来的宿主物理页,增加的是页表层级与管理粒度。

对象字段或操作
kvm_page_faultmax_level 给出本次缺页上限;req_level 结合后备条件;goal_level 加入限制后决定实际安装级别。
kvm_lpage_infodisallow_lpage 记录某个候选大页范围的限制,供级别选择使用。
kvm_mmu_page 与 SPTEspt 指向页表页;每页 512 个 64 位 SPTE。叶子保存后备 PFN、权限及状态,非叶子保存下级页表地址。

沿用刚建立的 GPA 0x00400000–0x005fffff 到 HPA 0x80000000–0x801fffff 映射,第 i 个子页的 GFN 为 0x400 + i,PFN 为 0x80000 + i。拆分后第 3 项仍将 GPA 0x00403000 映射到 HPA 0x80003000;变化是索引层级和独立权限的数量。

make_small_spte() 从大页项继承状态,加上子页偏移,并在生成 4 KiB 项时清除大页标志。512 个新项写入尚未连接的页表后,KVM 才发布父项的替换。子项构造、页表连接

五、脏页记录期间的映射拆分

QEMU 的内存监听器通过槽标志开启 KVM_MEM_LOG_DIRTY_PAGES。在启用 eager splitting、未使用 initially-all-set 的路径中,KVM 尝试预先将大页拆到 4 KiB,再设置跟踪所需的写保护或脏位状态。提前拆分把页表分配工作移出首次写入路径;分配失败时仍可先保护大页,随后由缺页路径处理。QEMU 标志更新、KVM 启停处理

下面选择软件写保护式记录:没有 PML,eager splitting 成功,未启用 initially-all-set,宿主页允许写入且不启用 NX 大页缓解。开启操作完成后,512 个子项均为 W=0。写 P3 时产生写保护缺页,KVM 记录 P3 并恢复该项的 W;P4 仍可保持 W=0。显示的 dirty 是逐页日志标记,与 EPT 硬件 D 位分开。

一段 2 MiB RAM 的映射生命周期

选择一个 4 KiB 子页,比较拆分前后的地址、W 权限和逐页日志。修改场景会重新开始。

  1. 初始:一个 2 MiB EPT 叶子覆盖 GPA 0x00400000–0x005fffff,W=1。
  2. 开启示例中的写保护跟踪:拆成 512 个 4 KiB 叶子,设置 W=0 并完成 TLB 刷新。
  3. 写 P3:记录第 3 页,恢复其 W=1;P0 等未写页面仍为 W=0。
  4. 关闭记录:后备条件满足时原位恢复 2 MiB 叶子,继承 P0 的 W=0。再次写入可修复写权限。

全过程中 P3 的 HPA 均为 0x80003000;映射拆分和恢复均不复制客户机内存。启用 JavaScript 后可以选择全部 512 个子页并交互操作。

模型选择无 PML、未启用 initially-all-set、eager splitting 成功的写保护路径。宿主与客户机均允许写入,不启用 NX 大页缓解;所有页面已驻留且映射稳定。操作按 ioctl 或缺页处理完成的边界展示。dirty 表示逐页日志,关闭后保留为上一周期记录;未模拟日志收割、硬件 D 位及真实耗时。

拆分步骤本身保留原写权限和脏状态,地址转换也保持等价,因此这一步无需单独立即刷新 TLB。随后的跟踪初始化改变权限或脏位,启用 ioctl 返回前完成相应刷新,确保旧可写转换不会越过交接边界。拆分与刷新顺序

PML 路径会对小页清除脏位,不能套用上述“每页首次写入都退出”的步骤。启用 KVM_DIRTY_LOG_INITIALLY_SET 时,全部页面先被视为脏页,拆分和保护可延后到清除所选范围时;QEMU 会探测并使用这些能力。按范围拆分、QEMU 能力选择。日志交接和重新保护详见Dirty Ring 专题。

六、关闭记录后的恢复

迁移取消后,源虚拟机可能继续运行。关闭槽的脏页记录会调用 kvm_mmu_recover_huge_pages()。Linux 6.18 的 TDP 分支遍历候选非叶子项,重新检查槽和宿主允许的映射级别,找到已有叶子项后构造大页项,原子替换父项并完成所需的 TLB 刷新。后备条件仍不满足的范围保持小页映射。

EPT 大页的拆分与原位恢复
示例采用预先拆分和软件写保护。2 MiB 叶子拆成 512 个 4 KiB 叶子,关闭跟踪后核验条件并原位恢复。宿主后备范围与子页 PFN 始终不变。
示例采用预先拆分和软件写保护。2 MiB 叶子拆成 512 个 4 KiB 叶子,关闭跟踪后核验条件并原位恢复。宿主后备范围与子页 PFN 始终不变。 打开原图
Mermaid 源码
flowchart TB
    accTitle: KVM TDP MMU 大页拆分与原位恢复
    accDescr: 在宿主两兆字节后备映射稳定的条件下,KVM 将一个两兆字节 EPT 叶子拆分为五百一十二个四千字节叶子,完成写保护和 TLB 刷新后逐页记录写入。关闭记录时重新核验大页条件,原位恢复大页并刷新 TLB。宿主物理范围始终不变,映射转换不复制客户机数据。
    H["2 MiB EPT 叶子<br/>GPA 0x00400000 → HPA 0x80000000<br/>W=1"] --> S["开启软件写保护式脏页记录<br/>预先拆分成功"]
    S --> C["原叶子替换为下级页表指针<br/>512 个 4 KiB 叶子继承地址与状态"]
    C --> P["设置 W=0,完成 TLB 刷新<br/>启用记录 ioctl 返回"]
    P --> W["客户机写 P3<br/>记录该页,P3 恢复 W=1"]
    W --> D["关闭脏页记录<br/>重新检查槽与宿主映射级别"]
    D -->|满足大页条件| R["原位替换为 2 MiB 叶子<br/>保留所选子项权限,刷新 TLB"]
    D -->|条件不满足| K["保持 4 KiB 映射"]
    R --> F["若大页仍 W=0<br/>后续写缺页按权限恢复可写"]
    B["宿主后备始终为同一物理范围<br/>PFN 0x80000–0x801ff<br/>拆分与恢复的数据复制量:0"] -.-> H
    B -.-> C
    B -.-> R

恢复函数从已有子项构造新叶子,保留其权限;它没有逐一要求 512 个子项的临时 W 位相同。关闭跟踪后,这些临时差异可以消失。若取到的子项 W=0,新大页暂时仍不可写,后续写缺页可在允许写入的前提下恢复权限。示例保留 P0 的 W=0,便于观察这一阶段。TDP 原位恢复、构造大页项

把示例走到最后,P3 仍映射到原来的 HPA,页内容不变,变化的是 512 项重新由一个较大范围的叶子项覆盖。恢复 EPT 不负责整理或复制后备物理页;宿主透明大页的合并、回收和页迁移属于另一层生命周期。因此需要同时核对后备内存状态、EPT 页级别与后续缺页,才能确认恢复是否真正完成。

七、源码:级别选择、拆分与恢复

处理环节固定版本入口
缺页映射级别选择max_level / req_level / goal_level;kvm_mmu_hugepage_adjust()
512 个子项的构造与发布tdp_mmu_split_huge_page();make_small_spte()
细粒度跟踪的启停kvm_mmu_slot_apply_flags();QEMU 手动保护能力选择
大页与写权限恢复recover_huge_pages_range();快速写权限修复

八、拆分成本与恢复效果的测量

在前面的 2 MiB 示例中,拆分增加一张 4 KiB 页表,原来一个叶子覆盖的范围变成 512 个独立叶子。代价包括页表内存、地址转换缓存压力,以及重新保护和后续写入的处理;收益是可以精确指出哪些 4 KiB 页需要重传。映射项增多并不意味着应用访问就按同样倍数变慢,实际影响取决于工作集、TLB 命中和访问模式。

比较拆分策略,应分别测量开启跟踪时的停顿、迁移期间的客户机延迟与缺页开销、关闭跟踪后的大页恢复情况。预先拆分把一部分工作移到开启阶段,按需拆分把成本留给后续访问;原位恢复减少重新建图的缺页,但不能补齐宿主缺失的连续后备条件。只看迁移完成时间,会漏掉迁移结束后仍使用小页映射的持续影响。

宿主侧可通过支持 KVM 统计接口的工具读取目标 VM 的 pages_4k、pages_2m 和 pages_1g,观察不同大小的叶子映射数量。它们属于 KVM 映射统计,与宿主大页池容量、QEMU 后备映射大小是不同的数据。对前面这一个 2 MiB 范围,理想化的拆分变化是少一个 2 MiB 叶子、多 512 个 4 KiB 叶子;整台 VM 的并发缺页、其他映射和多个 MMU 根都会影响汇总值,不能用全局计数反推某个 GPA 已恢复。统计字段、拆分时的统计更新

九、从按需拆分到原位恢复

2022 年的 TDP eager splitting 提交将拆分提前到启用脏页记录时,减少客户机首次写入时承担的页表处理。它采取尽力而为策略,未完成的拆分仍可通过保护和缺页保证正确性。

Linux 6.13 的 KVM MMU 更新加入 TDP 大页原位恢复,避免先撤销下级映射、等待再次访问才重建大页。本文基线已包含这一路径。预先拆分与原位恢复分别减少进入和退出细粒度跟踪时的 vCPU 扰动,但仍需分配页表、同步 TLB,并受宿主后备条件约束。

十、参考资料