QEMU在内存迁移阶段首先会标脏所有内存页,然后通知KVM开启脏页统计。本章主要介绍KVM在脏页统计中的作用,首先介绍intel x86脏页统计的硬件基础,然后介绍KVM中脏页统计相关数据结构、脏页开启的IOCTL命令字介绍、脏页统计发起流程,最后实验验证上述分析。
硬件基础
- KVM脏页统计离不开硬件支持,在Intel没有引入PML(Page Modification Logging)特性之前。主要利用SPTE的D状态位跟踪脏页。在引入PML之后,则通过PML跟踪脏页,其主要功能是记录虚机写内存页的行为并将内存页的地址GPA记录下来。我们分别介绍SPTE和PML。
SPTE
硬件要素
- 上图为intel Page table各个字段定义,其中bit 6称为脏页标记位,我们知道intel Page table有两个作用,一个是指向真正的物理页,一个是指向包含下级页表内容的物理页,用于线性地址转化。
- 当一个Page table指向真正物理页的条目(作为SPTE)时,如果CPU对该SPTE指向的物理页有写操作,硬件会将脏页标记位置位,CPU下一次写该页时会再次置位(需要软件清零脏页位),通过硬件置位-软件清零的手段,软件可以跟踪到虚拟机脏页状态。
工作流程
- TODO
PML
硬件要素
- PML是Intel为支持虚拟化场景下脏页跟踪而开发的硬件特性,使用该特性涉及到Intel的几个硬件元素,下面根据以上图示一一介绍:
- Accessed and Dirty flags:这是EPTP字段中位于bit 6的一个标志位,当设置此标志位后,它告诉CPU每当使用EPT查询HPA时,将页结构存放的表项中的Accessed位(bit 8)置1,对于指向物理页的页表项,当往指向的物理页中写数据时,将它的Dirty位(bit 9)置1。
- PML flag:这是VMCS VM-Exection Controls Field区域的一个标志位,用于使能PML特性。只有在Accessed and Dirty flags位开启时,才可以使能PML。
- PML Buffer:一块内存区域,用来存放上一次开启PML特性之后,CPU写过的物理页的地址,大小为4K,可以存放512条GPA。KVM就是通过这个区域来跟踪内存脏页。
- PML Address:用来保存PML Buffer的内存物理地址,它是VMCS VM-Execution control field的一个字段。
- PML index:用来保存PML Buffer的索引,长度16 bit,表示范围0-511,和PML Buffer中的条目对应。PML index用来保存下一次CPU写物理页保存GPA时在PML Buffer中的位置。PML index每次递减。
工作流程
- 下面介绍开启PML特性之后,记录脏页的整个工作流程,如图所示,分以下5个步骤:
- 当CPU想要记录内存脏页,比如迁移开始时,首先设置Accessed and Dirty flags和PML flag标志,使能脏页记录功能。当CPU写VM的物理内存页时,如果发现Accessed and Dirty和PML都使能了,会首先将对应表项的Access和Dirty位置1,然后将GPA记录到PML Buffer中。每写一次物理内存页,PML Buffer就多一条GPA的记录,PML index减1。
- 当PML Buffer被填满时,会产生page-modification log-full event,然后触发VMExit。
- CPU退出到根模式的内核态之后,KVM会判断退出原因,如果是PML Buffer满引起的退出,会将PML Buffer的内容保存下来,然后PML Buffer的索引PML index会被重新置为511。
- PML Buffer的内容,就是CPU最近写过的内存页地址,它就是内存的脏数据,对于内存迁移来说,该数据可以用来评估虚机的内存变化量。如果PML Buffer中内容较少,那么Qemu可以将虚机暂停然后一次性拷贝完。
- 当CPU不想记录内存脏页,迁移已经完成后,就可以关闭PML特性。
- 注意:上面的过程是Intel PML特性的通用使用过程,对于kvm的实现中,并不是每次迁移开始前才开启PML特性,而是在kvm模块初始化的过程中开启,PML Buffer的分配是在vcpu创建过程中。迁移结束也不会关闭PML特性。PML特性是一直存在于KVM模块的生命周期中的。
数据结构
用户态
- RAMList
typedef struct {
......
/* 存放记录脏页的位图,一个block元素可以表示的内存范围是256K个内存页 */
unsigned long *blocks[];
} DirtyMemoryBlocks;
typedef struct RAMList {
......
/* 虚机占用的所有主机上的RAM内存块集合 */
QLIST_HEAD(, RAMBlock) blocks;
/* 用户态从内核获取虚机脏页信息时保存到的结构体 */
DirtyMemoryBlocks *dirty_memory[DIRTY_MEMORY_NUM];
} RAMList;
- RAMBlock
struct RAMBlock {
......
/* 内存区域表示的最大范围,单位是字节 */
ram_addr_t max_length;
/* dirty bitmap used during migration */
/* 记录该内存区域脏页情况的位图,一个内存页对应一个bit位
它是一个长整形指针数组,整个数组元素长度加起来转换
成bit数等于max_length/4K
*/
unsigned long *bmap;
......
}
- 用户态与脏页统计有关的数据结构有以上两个,RAMList和RAMBlock,它们各自的数据结构图示如下,既然都是统计脏页,为什么需要两个数据结构呢?个人觉得,这两个数据结构的使用场景是不一样的,RAMList用在从内核获取脏页的时候,它表示脏页的粒度是kvm中的一个slot;而RAMBlock中的位图用来描述一个RAMBlock的脏页使用情况,它表示的脏页粒度是Qemu中的一个RAMBlock。在内存迁移统计脏页过程中,会依次使用这两个数据结构统计剩余内存的脏页数量。
内核态
- vcpu_vmx
struct vcpu_vmx {
struct page *pml_pg;
......
}
- vmcs_field_to_offset_table
vmcs_field_to_offset_table[] = {
......
FIELD(GUEST_PML_INDEX, guest_pml_index),
FIELD64(PML_ADDRESS, pml_address)
......
}
- kvm_userspace_memory_region
/* for KVM_SET_USER_MEMORY_REGION */
struct kvm_userspace_memory_region {
/* 内存区间所在的插槽 */
__u32 slot;
/* 当flags包含KVM_SET_USER_MEMORY_REGION标识时
表示开启这段内存的脏页记录
*/
__u32 flags;
/* 虚机内存区间物理地址 */
__u64 guest_phys_addr;
/* 虚机内存区间大小 */
__u64 memory_size; /* bytes */
/* 分配给这段内存区间的实际的用户态地址HVA */
__u64 userspace_addr; /* start of the userspace allocated memory */
};
- kvm_dirty_log
/* for KVM_GET_DIRTY_LOG */
struct kvm_dirty_log {
__u32 slot;
__u32 padding1;
union {
void *dirty_bitmap; /* one bit per page */
__u64 padding2;
};
};
- kvm_memory_slot
struct kvm_memory_slot {
gfn_t base_gfn;
unsigned long npages;
unsigned long *dirty_bitmap;
u32 flags;
....
};
API
- 为了让用于态统计虚机的脏页,内核提供了两个接口,分别是
KVM_SET_USER_MEMORY_REGION
、KVM_GET_DIRTY_LOG
,这两个接口
脏页开启
- 内核提供了两个命令供用户态统计虚机的脏页,KVM_SET_USER_MEMORY_REGION、KVM_GET_DIRTY_LOG,KVM_SET_USER_MEMORY_REGION命令字作用在vm的fd,用来通知kvm开启对某段内存区域的脏页跟踪,结构体kvm_userspace_memory_region 是用户态传入的参数,用来描述kvm应该跟踪的内存区域,如下:
Capability: KVM_CAP_USER_MEMORY
Architectures: all
Type: vm ioctl /* 虚机vm ioctl命令字 */
Parameters: struct kvm_userspace_memory_region (in)
Returns: 0 on success, -1 on error
struct kvm_userspace_memory_region {
__u32 slot;
__u32 flags;
__u64 guest_phys_addr;
__u64 memory_size; /* bytes */
__u64 userspace_addr; /* start of the userspace allocated memory */
};
/* for kvm_memory_region::flags */
#define KVM_MEM_LOG_DIRTY_PAGES (1UL << 0)
#define KVM_MEM_READONLY (1UL << 1)
脏页获取
- KVM_GET_DIRTY_LOG命令字作用在vm的fd,用来获取内核跟踪的脏页信息,结构体kvm_dirty_log作为参数用来指定要查询的内存slot,同时保存内核的脏页查询结果,如下:
Capability: basic
Architectures: all
Type: vm ioctl
Parameters: struct kvm_dirty_log (in/out)
Returns: 0 on success, -1 on error
/* for KVM_GET_DIRTY_LOG */
struct kvm_dirty_log {
/* 输入,指定要查询脏页的内存slot */
__u32 slot;
__u32 padding;
union {
/* 输出,保存kvm查询到的脏页 */
void __user *dirty_bitmap; /* one bit per page */
__u64 padding;
};
};
流程
使能记录
- KVM的PML特性在模块初始化的过程中完成,每创建一个vcpu,需要为其分配PML Buffer并初始化PML Index。
使能PML特性:
/* kvm模块初始化入口 */
vmx_init
kvm_init
kvm_arch_hardware_setup
vmx_x86_ops.hardware_setup
hardware_setup
/* 设置VMCS区域内存的内容
在VMLAUNCH和VMRESUME的时候被加载到VMCS区域
*/
setup_vmcs_config
/* 设置SECONDARY_EXEC_ENABLE_PML标识使能PML */
opt2 = SECONDARY_EXEC_VIRTUALIZE_APIC_ACCESSES | XXX | SECONDARY_EXEC_ENABLE_PML
adjust_vmx_controls(min2, opt2, MSR_IA32_VMX_PROCBASED_CTLS2, &_cpu_based_2nd_exec_control)
vmcs_conf->cpu_based_2nd_exec_ctrl = _cpu_based_2nd_exec_control;
设置PML Buffer地址和PML Index初始值:
/* vcpu创建入口 */
vmx_create_vcpu
vmx_vcpu_setup
/* 如果使能了PML特性,分配一个物理页给pml_pg用作PML Buffer */
if (enable_pml) {
vmx->pml_pg = alloc_page(GFP_KERNEL | __GFP_ZERO);
}
/* 将分配好的PML Buffer地址写入VMCS的对应区域,同时初始化PMLIndex为511 */
if (enable_pml) {
vmcs_write64(PML_ADDRESS, page_to_phys(vmx->pml_pg));
vmcs_write16(GUEST_PML_INDEX, PML_ENTITY_NUM - 1);
}
记录脏页
流程图
具体过程
- 根据PML的硬件特性,每当CPU在Guest态根据EPT转换地址后,写数据到物理页,这时如果PML特性开启,在设置EPT页表项的Dirty位之后,还会将GPA地址写入PML Buffer。这里intel暗含的假设是之后Dirty标志位从0变成1,才能写PML Buffer,而Dirty标志位的变成1之后的清零动作是软件做的,因此如果想要记录一个slot的脏页,需要将引用这个slot的所有页表项的Dirty标志位都清零。开启脏页记录需要做以下两件事情:
- 清零Dirty位:KVM的实现中,在创建slot时,如果不想记录某个slot包含的所有物理页的是否为脏,需要默认将这些物理页对应的页表项的Dirty页置位,因为如果Dirty位是0,Guest态CPU写物理页时会将其置1并且填充GPA到PML Buffer,如果PML Buffer满了,就会触发VMExit,增加不必要的开销。反之,要记录脏页,首先需要将指向slot包含的所有物理页的spte的Dirty位清零,这里需要根据gfn找到指向该gfn对应页的spte,反向映射数组rmap就派上了用场。
- 物理页开启写保护:除了清零页表项的Dirty位,记录脏页还需要开启页的写保护(CR0第16位 W-P位),在脏页记录的过程中,所有slot包含的物理页变成只读,当CPU写访问这个页时,发生缺页异常,kvm会重新分配一个新的页给CPU。在脏页记录关闭后,才能将写保护去掉,slot包含的所有页变成可读写。
/* kvm ioctl入口 */
kvm_vm_ioctl
/* slot命令字,内核根据传入的参数
对应地增加,删除和更新slot
这里要跟踪slot的脏页,算在更新里面
*/
case KVM_SET_USER_MEMORY_REGION
kvm_vm_ioctl_set_memory_region
kvm_set_memory_region
__kvm_set_memory_region
/* 根据用户态传入的参数,设置slot,这里是开启脏页的记录*/
kvm_set_memslot
kvm_arch_commit_memory_region
kvm_mmu_slot_apply_flags
/* 根据intel vmx的特性开启脏页记录 */
if (new->flags & KVM_MEM_LOG_DIRTY_PAGES) {
if (kvm_x86_ops.slot_enable_log_dirty) {
/* 对于x86平台,如果intel提供了PML机制可以记录脏页,进入这个流程 */
kvm_x86_ops.slot_enable_log_dirty(kvm, new)
kvm_x86_ops.slot_enable_log_dirty <=> vmx_slot_enable_log_dirty
} else {
/* 在没有PML机制的情况下,使用页只读的方式记录脏页
* 判断两种情况,分别处理:
* 1. 首次迭代时位图都标记为1,不需要标记4k小页,但需要标记
* 2M的大页为只读,这样kvm在处理虚机大页访问异常时会将其拆
* 分成小页。
* 2. 对于其它轮次的迭代,需要将所有4k页的页表项都标记为只读
*/
int level =
kvm_dirty_log_manual_protect_and_init_set(kvm) ?
PG_LEVEL_2M : PG_LEVEL_4K;
kvm_mmu_slot_remove_write_access(kvm, new, level);
}
}
- 分析x86架构下使用PML机制记录脏页的流程,kernel中关于PML场景下的comment如下:
/*
* Dirty logging tracks sptes in 4k granularity, meaning that
* large sptes have to be split. If live migration succeeds,
* the guest in the source machine will be destroyed and large
* sptes will be created in the destination. However, if the
* guest continues to run in the source machine (for example if
* live migration fails), small sptes will remain around and
* cause bad performance.
*
* Scan sptes if dirty logging has been stopped, dropping those
* which can be collapsed into a single large-page spte. Later
* page faults will create the large-page sptes.
*/
- 流程如下:
vmx_slot_enable_log_dirty
kvm_mmu_slot_apply_flags
/* cpu_dirty_log_size存在表明开启了PML,使能脏页日志跟踪分两种情况
* 1: 对于4K普通的页,只需要清零D-bit状态位即可
* 2: 对于2M大页,除了清零D-bit状态位,还需要使能写保护
* 使得虚机访问大页页表时也能触发异常,陷入kvm后以4k页的粒度分配虚机的页
* 从而将大页拆分为小页
*/
if (kvm_x86_ops.cpu_dirty_log_size) {
/* 清零D状态位,方便硬件下一次统计 */
kvm_mmu_slot_leaf_clear_dirty(kvm, new);
/* 这里通过rmap数据,遍历指向slot包含的所有物理页的spte
* 针对每个spte调用__rmap_clear_dirty函数
*/
slot_handle_leaf_4k(kvm, memslot, __rmap_clear_dirty, false)
/* 开启所有2M大页页表项的写保护功能 */
kvm_mmu_slot_remove_write_access(kvm, new, PG_LEVEL_2M);
} else {
kvm_mmu_slot_remove_write_access(kvm, new, PG_LEVEL_4K);
}
/* 清零表项的Dirty位*/
__rmap_clear_dirty
spte_clear_dirty
/* 将kvm模块初始化时设置的Dirty默认值取出,用于设置spte的Dirty位 */
spte &= ~shadow_dirty_mask
mmu_spte_update
......
__set_spte
/* 更新spte表项 */
WRITE_ONCE(*sptep, spte)
/* 设置slot包含的所有页写保护WP */
kvm_mmu_slot_remove_write_access
slot_handle_large_level(kvm, memslot, slot_rmap_write_protect, false);
__rmap_write_protect(kvm, rmap_head, false)
spte_write_protect(sptep, pt_protect)
/* 清零页表项的bit 1,不允许CPU写物理页 */
spte = spte & ~PT_WRITABLE_MASK
mmu_spte_update
......
__set_spte
/* 更新spte表项 */
WRITE_ONCE(*sptep, spte)
- 脏页日志开启后,对于使用位图方式跟踪的4k页,缺页异常的快速处理逻辑中,会首先将脏页在slot的位图中标记,然后将页表项标记为可写,不会进入慢速处理分配页表的阶段,因为这些被标记为写保护的页,本身可能已经分配了物理地址,对于使用PML方式跟踪的4k页同样如此。但是对于PML方式跟踪的大页,这里仍然会开启写保护,其目的是让缺页的慢速路径去处理缺页,使其重新分配一块4K的物理页,从而实现缺页在脏页日志开启情况下
大页拆小页
的逻辑。代码如下:
fast_page_fault
/*
* Currently, to simplify the code, write-protection can
* be removed in the fast path only if the SPTE was
* write-protected for dirty-logging or access tracking.
*/
if (fault->write &&
spte_can_locklessly_be_made_writable(spte)) {
new_spte |= PT_WRITABLE_MASK;
/*
* Do not fix write-permission on the large spte. Since
* we only dirty the first page into the dirty-bitmap in
* fast_pf_fix_direct_spte(), other pages are missed
* if its slot has dirty logging enabled.
*
* Instead, we let the slow page fault path create a
* normal spte to fix the access.
*
* See the comments in kvm_arch_commit_memory_region().
*/
if (sp->role.level > PG_LEVEL_4K)
break;
}
获取脏页
流程图
具体过程
- 获取脏页的流程,实际上也分为了两个步骤,第一步是拷贝dirty bitmap,第二步是清零脏页的标志。
- kvm在早期的实现中,将这两步合在一起,在
KVM_GET_DIRTY_LOG
ioctl中实现了,由于第二步需要拿mmu_lock锁修改页表,并且不知道哪些页需要清零,因此会清零虚机所有页,而vcpu缺页退出guest态时,kvm处理缺页这个操作需要竞争mmu_lock,长时间持有mmu_lock会影响vcpu的内存访问性能,因此第二步的开销很大。 - kvm在之后改进了脏页获取流程,引入了
KVM_CAP_MANUAL_DIRTY_LOG_PROTECT
特性,将脏页获取分成了两个ioctl命令字,KVM_GET_DIRTY_LOG
和KVM_CLEAR_DIRTY_LOG
,对应之前的两个步骤,第一个命令字只负责拷贝dirty bitmap,第二个命令只负责清零脏页,用户态获取到dirty bitmap之后,可以知道哪些页是脏的,因此KVM_CLEAR_DIRTY_LOG
可以是虚机所有页集合的子集,在修改页表时效率会更高。 - 下面分析具体代码:
kvm_vm_ioctl
case KVM_GET_DIRTY_LOG:
kvm_vm_ioctl_get_dirty_log
kvm_get_dirty_log_protect
/* Dirty Ring直接通过用户态和内核态映射的Ring环查找脏页,不再通过 bitmap查找,因此直接返回 */
/* Dirty ring tracking is exclusive to dirty log tracking */
if (kvm->dirty_ring_size)
return -ENXIO;
slots = __kvm_memslots(kvm, as_id);
memslot = id_to_memslot(slots, id);
/* 同步最新脏页 */
kvm_arch_sync_dirty_log
kvm_x86_ops.flush_log_dirty <=> vmx_flush_log_dirty
/* kick每个vcpu让其vmexit,这样在vmexit的路径上可以更新pml buffer */
kvm_flush_pml_buffers
kvm_vcpu_kick
/* dirty bitmap获取到之后,如果使能了KVM_CAP_MANUAL_DIRTY_LOG_PROTECT
* 特性,则跳过清零脏页的步骤,直接拷贝dirty bitmap */
if (kvm->manual_dirty_log_protect) {
dirty_bitmap_buffer = dirty_bitmap;
} else {
/* 获取mmu_lock锁,修改页表,将对应位清零 */
spin_lock(&kvm->mmu_lock);
kvm_arch_mmu_enable_log_dirty_pt_masked(kvm, memslot,
offset, mask);
spin_unlock(&kvm->mmu_lock);
}
kvm_arch_flush_remote_tlbs_memslot
/* 拷贝dirty bitmap到用户态 */
copy_to_user(log->dirty_bitmap, dirty_bitmap_buffer, n)
- 使用PML机制获取dirty bitmap时,只是将所有vcpu kick了一遍,具体的更新操作在vcpu从guest退出的路径上完成,简单分析下:
vmx_handle_exit
if (enable_pml)
vmx_flush_pml_buffer(vcpu);
/* 遍历PML buffer的entry,将其更新到内存页所属的slot的dirty bitmap */
for (; pml_idx < PML_ENTITY_NUM; pml_idx++) {
gpa = pml_buf[pml_idx];
kvm_vcpu_mark_page_dirty(vcpu, gpa >> PAGE_SHIFT);
memslot = kvm_vcpu_gfn_to_memslot(vcpu, gfn);
mark_page_dirty_in_slot(vcpu->kvm, memslot, gfn);
}
- 分析一下
KVM_CAP_MANUAL_DIRTY_LOG_PROTECT
特性下清零脏页的流程:
kvm_vm_ioctl
case KVM_CLEAR_DIRTY_LOG:
kvm_vm_ioctl_clear_dirty_log
kvm_clear_dirty_log_protect
/* 清零脏页前首先同步之前的脏页信息,使dirty bitmap处于最新状态 */
kvm_arch_sync_dirty_log
/* 将最新的脏页信息拷贝到用户态 */
copy_from_user(dirty_bitmap_buffer, log->dirty_bitmap, n)
/* 拿mmu_lock锁,准备清零脏页,也就是使能脏页日志 */
spin_lock(&kvm->mmu_lock)
kvm_arch_mmu_enable_log_dirty_pt_masked
spin_unlock(&kvm->mmu_lock)
实验
- 下面两个commit是基于dirty-bitmap机制脏页同步计算脏页速率的commit:
https://github.com/qemu/qemu/commit/4998a37e4bf2bc47f76775e6e6a0cd50bacfb16a
https://github.com/qemu/qemu/commit/826b8bc80cb191557a4ce7cf0e155b436d2d1afa