赞
踩
前面说到,block layer提交的io会通过一定的调度算法才会真正写到块设备上,目前linux的io scheduler主要有以下几种:
上节说到,block layer中的scheduler都是为了hdd设计的,由于hdd的随机读写性能差,IO操作在Block Layer中会经过复杂的操作才会被执行,此时io的性能瓶颈在于硬件,而不是内核,内核通过引入各种调度算法来最大化利用hdd的能力,此时内核采用的还是一个全局共享的单队列(Request Queue):
任何io请求都会经过该Request Queue,io的出队、入队、合并、重排等都需要加锁,这个设计在当时并不会成为性能瓶颈,因为:
可以看到,block mq的多个staging queue很大程度上减少了锁竞争,同时由于和cpu core绑定的关系,也避免了remote memory access以及节省了核间中断,因此相比于sq架构很大程度上提升了对存储介质的使用效率。
两级多队列设计:
Software Staging Queue:负责 I/O 的调度和优化,队列的配置可以是per cpu core,也可以是per cpu socket;io的调度优化以queue为单位,不会发生跨queue的调度行为,减少了锁竞争
Hardware Dispatch Queue:负责将从 Software Staging Queues 过来的 I/O 请求发送给底层硬件,一般和硬件队列的个数相等,每个硬件队列对应一个派发队列
可以看到,block mq的多个staging queue很大程度上减少了锁竞争,同时由于和cpu core绑定的关系,也避免了remote memory access以及节省了核间中断,因此相比于sq架构很大程度上提升了对存储介质的使用效率。
实例分析二:多队列场景下fio测试时如何测性能最高?
来看下阿里的essd压测脚本 :https://help.aliyun.com/document_detail/65077.html
cpulist=""
for ((i=1;i<10;i++))
do
list=`cat /sys/block/your_device/mq/*/cpu_list | awk '{if(i<=NF) print $i;}' i="$i" | tr -d ',' | tr '\n' ','`
if [ -z $list ];then
break
fi
cpulist=${cpulist}${list}
done
spincpu=`echo $cpulist | cut -d ',' -f 2-${nu}`
echo $spincpu
fio --ioengine=libaio --runtime=30s --numjobs=${numjobs} --iodepth=${iodepth} --bs=${bs} --rw=${rw} --filename=${filename} --time_based=1 --direct=1 --name=test --group_reporting --cpus_allowed=$spincpu --cpus_allowed_policy=split
}
在一个40 core的机器上测试,以上脚本选择的cpu如下,可以看到通过尽可能选择绑定了不同硬件队列的cpu来减少竞争,提升性能。
# cat /sys/block/{dev}/mq/*/cpu_list
0, 8, 16, 20, 28, 36
1, 9, 17, 21, 29, 37
2, 10, 18, 22, 30, 38
3, 11, 19, 23, 31, 39
4, 12, 24, 32
5, 13, 25, 33
6, 14, 26, 34
7, 15, 27, 35
# echo $spincpu
1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16
简单介绍下cpu和内存的虚拟化:
操作系统是设计在直接运行在裸硬件设备上的,因此它们自动认为它们完全占有计算机硬件。x86 架构提供四个特权级别给操作系统和应用程序来访问硬件,从Ring3到Ring0优先级依次升高,但大多数现代操作系统都只用到了Ring0和Ring3。
几个基本概念:
GVA -> GPA -> HVA -> HPA
GVA - Guest virtual address
GPA - Guest physical address
HVA - Host virtual address
HPA - Host physical address
Guest OS需要使用一个从0开始的、连续的物理地址空间,但是真实的物理地址空间是被Host OS所管理的,因此Guest OS是不能直接在物理内存上加载、运行的,唯一可行的办法是为Guest OS提供一个虚拟的物理内存空间,即GPA。Guest内用户可以看到的内存是Guest OS虚拟化出的GVA。
显然,GVA - GPA 的映射由Guest OS负责维护,而 HVA - HPA 由Host OS负责维护,内存虚拟化的核心是GPA - HVA的映射,GPA-HVA的映射主要有两种方案:
如果是 Guest OS 引起的,则将该异常注入回去,Guest OS 将调用自己的缺页处理函数,申请一个 Page,并将 Page 的 GPA 填充到上级页表项中
如果是 Guest OS 的页表和 SPT 不一致引起的,则同步 SPT,根据 Guest 页表和 mmap 映射找到 GPA 到 HVA 的映射关系,然后在 SPT 中增加/更新GVA-HPA表项
总的来说基于SPT的内存虚拟化方案中,kvm截获了Guest相关的修改操作并更新到SPT,而真正装入物理MMU的是SPT;Guest中GVA和GPA之间的转换实际上变成了GVA与HPA的转换,TLB中缓存的也是GVA和HPA的映射。
SPT方案的优缺点如下:
1. 在 Guest OS 运行时,处于非root模式的CPU加载guest进程的gCR3
2. guest 访问gCR3,由传统页表实现GVA到GPA的转换
3. 再通过查询EPT完成GPA到HPA的转换
EPT方案下,为每个guest只维护一个EPT,只有cpu处于非root模式下才参与内存地址的转换,guest os的page fault在内部处理,不会exit到vmm,但是如果tlb miss,两级页表的查询会引入大量开销。
综合来看,通过硬件EPT技术,大幅减少了页表更新带来的vm exit,同时也大幅减少了内存虚拟化的难度,虽然也有多级页表查询的开销,但总体来看提升明显,是现在内存虚拟化的主流方案。
上节说到,硬件辅助虚拟化既不用修改guest os保持了很好的兼容性,又有接近半虚拟化的性能,是当前虚拟化领域的大势所趋,而在linux环境下,qemu-kvm则是当前最主流的方案。
https://www.qemu.org/
qemu(Quick Emulator)是一个开源的虚拟化软件,是主机上的vmm,通过动态二进制转换来模拟CPU,并提供一系列的硬件模型,使guest os认为自己和硬件直接打交道,其实是同QEMU模拟出来的硬件打交道,QEMU再将这些指令翻译给真正硬件进行操作。
qemu自身就是一个完整的虚拟化方案,不需要其他任何组件,但纯qemu的方案效率太低,因此需要加速方案,cpu、内存的虚拟化通过硬件辅助的方式实现,网络、存储的加速在下文中会提到。
https://www.linux-kvm.org/page/Main_Page
kvm(Kernel Virtual Machine)是Linux on x86上的一个全虚拟化解决方案,主要由两个内核模块组成,kvm.ko提供核心虚拟化功能,kvm-intel.ko或kvm-amd.ko提供硬件虚拟化能力。kvm能够让Linux主机成为一个Hypervisor,kvm只实现cpu和内存的虚拟化,但是需要cpu硬件本身支持虚拟化扩展,也即Intel VT和AMD-V。本质上,KVM是管理虚拟硬件设备的驱动,该驱动使用字符设备/dev/kvm(由KVM本身创建)作为管理接口,主要负责vCPU的创建,虚拟内存的分配,vCPU寄存器的读写以及vCPU的运行。(有关kvm的cpu虚拟化和内存虚拟化会在qemu-kvm中介绍。)
每一个kvm客户机对应一个linux进程,由标准 Linux 调度程序进行调度,每一个vCPU是该进程下的一个子线程,这使得kvm可以使用linux内核的已有功能。
kvm通过硬件辅助虚拟化可以接近物理机的性能,但其本身并不是一个完整的虚拟化方案,只能虚拟化cpu和内存。
通过前面的介绍我们可以看到:
// 第一步,获取到 KVM 句柄 kvmfd = open("/dev/kvm", O_RDWR); // 第二步,创建虚拟机,获取到虚拟机句柄。 vmfd = ioctl(kvmfd, KVM_CREATE_VM, 0); // 第三步,为虚拟机映射内存,还有其他的 PCI,信号处理的初始化。 ioctl(kvmfd, KVM_SET_USER_MEMORY_REGION, &mem); // 第四步,将虚拟机镜像映射到内存,相当于物理机的 boot 过程,把镜像映射到内存。 // 第五步,创建 vCPU,并为 vCPU 分配内存空间。 ioctl(kvmfd, KVM_CREATE_VCPU, vcpuid); vcpu->kvm_run_mmap_size = ioctl(kvm->dev_fd, KVM_GET_VCPU_MMAP_SIZE, 0); // 第五步,创建 vCPU 个数的线程并运行虚拟机。 ioctl(kvm->vcpus->vcpu_fd, KVM_RUN, 0); // 第六步,线程进入循环,并捕获虚拟机退出原因,做相应的处理。 for (;;) { ioctl(KVM_RUN) switch (exit_reason) { case KVM_EXIT_IO: /* ... */ case KVM_EXIT_HLT: /* ... */ } } // 这里的退出并不一定是虚拟机关机, // 虚拟机如果遇到 I/O 操作,访问硬件设备,缺页中断等都会退出执行, // 退出执行可以理解为将 CPU 执行上下文返回到 Qemu。
cpu虚拟化
虚机在 KVM 的支持下,被置于VMX的非根模式下执行二进制指令。在非root模式下,所有敏感的二进制指令都被CPU捕捉到,CPU 在保存现场之后自动切换到根模式,由 KVM 决定如何处理(或直接由kvm处理或交由用户态的qemu处理)。
内存虚拟化
在qemu-kvm架构下,GPA是由qemu进行申请,由kvm进行管理的,具体来看:
qemu根据guest的内存大小通过mmap系统调用在本进程的虚拟地址空间中申请对应大小的连续内存块(只是HVA连续),再通过ioctl的KVM_SET_USER_MEMORY_REGION接口将该内存地址注册到kvm中,由kvm进行维护。其中ioctl传入的参数主要有两个:guest_phys_addr为虚机GPA起始地址,userspace_addr为mmap得到的HVA起始地址。kvm拿到GPA和GVA的起始地址后,就会为当前虚机建立EPT,实现GPA->HPA的映射,同时会为VMM建立HVA->HPA映射。
vm exit发生时,vmm需要能够处理异常,此时vmm获取的是GPA,需要能转换到HPA,由于GPA和HVA的映射关系是qemu维护的,且已经传给了kvm,所以kvm可以通过GPA查询到对应的HVA,再转换到HPA。下图清晰展示了vm和vmm的内存映射关系:
qemu是软件实现的全虚拟化方案,在全虚拟化io的架构下,qemu通过本地的镜像文件向guest模拟出硬盘设备,所有guest io对host来说就和其他应用写本地文件一样。
具体来看下完整的io流程:
1. guest 发起io
2. 对PCI空间的读写是特权指令,会触发VM Exit,被kvm的i/o trap code捕获,kvm将io信息放到sharing page,并通知用户态的qemu
3. qemu从共享页中取出io请求,交由硬件模拟代码去处理:io需要经过host文件系统->page cache->block device这套完整的链路
4. qemu完成此次io后,再将结果放回共享页,并通知kvm
5. kvm中的i/o trap code读取sharing page中的操作结果,并将结果返回到客户机中
6. 触发VM Entry,guest再次获取cpu控制权,根据io返回结果进行处理
当 Guest 通过 DMA 访问大块内存时,QEMU 模拟程序不会把操作结果放到 I/O 共享页中,而是通过内存映射的方式将结果直接写到 Guest 的内存中去,然后通过 KVM 告诉Guest 的 DMA 操作完成。
全虚拟化的io方案简单通用,可以模拟各种硬件设备,但性能很差,具体原因有:
virtio是一套通用的半虚拟化io框架,提供了在hypervisor之上通用模拟设备IO的抽象,它基于hypervisor导出一组通用的io模拟设备,并基于一组通用api使得这些设备可以在虚机内使用。在virtio的设计中,客户机意识到自己运行在虚拟化环境中,通过virtio标准与hypervisor进行配合,进而达到更好的性能。
Guest 使用 VirtIO devices 最典型的方式是通过 PCI/PCIe 协议,PCI/PCIe 是 QEMU 和 Linux 中成熟且支持良好的总线协议。在物理环境中,PCI/PCIe 硬件设备会使用特定的物理内存地址范围,设备的驱动程序可以通过访问该内存范围来读取或写入设备的寄存器,也可以通过特殊的处理器指令来暴露其配置空间(Configuration Space)。基于这个原理,在虚拟化环境中,Hypevisor 可以通过捕获对该内存范围的访问并执行设备仿真。VirtIO 规范还定义了 PCI 配置空间的布局,因此实现起来非常简单。
virtio起初只是Rusty Russell针对自己的虚拟化方案lguest提出的,如今已经成为半虚拟化io的事实标准。virtio的意义有两个:
virtio是前后端的架构,以qemu-kvm+virtio为例,前端是位于guest os中的kernel module,后端是qemu中的驱动代码。前后端之间通过一个ring buffer进行交互,前端将I/O 请求放到buffer中,后端取出后再进行处理,处理完成后再放回buffer中,一次交互过程可以有多个io。具体ring buffer的组织方式也就是virtqueue。
virtio提供io设备的统一抽象,所以在前端中可以实现各种基于virtio的io设备驱动,如网络virtio_net,硬盘virtio_blk和virtio_scsi。
virtio在linux kernel中的实现:
1. guest 发起io
2. io到达guest os,由kernel中的virtio前端驱动进行处理,将io放到virtio-ring中并通知virtio后端
3. qemu作为virtio后端从virtio-ring中取出io请求并进行处理,可以一次性取出多个io并处理
4. qemu完成此次io后,再将结果放回virtio-ring,并通知virtio前端
5. 客户机virtio前端获取io结果并最终返回给应用
关于virtio-ring在qemu-kvm场景下:
内存虚拟化的时候介绍过,guest的GPA内存空间是由qemu通过mmap进行申请的,virtio-ring便是由前端驱动在GPA空间上申请的,所以当qemu去从中取io请求时,可以直接将GPA转换到对应的HVA;在io完成后又可以将io结果直接写到GPA上,整个virtio-ring的交互过程无需拷贝。
前后端的通知机制:
guest通知qemu通过ioeventfd,qemu通知guest通过irqfd,两者都是通过eventfd实现的。
+-------------+ +-------------+ | | | | | | | | | GuestOS | | QEMU | | | | | | | | | +---+---------+ +----+--------+ | ^ | ^ | | | | +---|-----|-------------------------|----|---+ | | | irqfd | | | | | +-------------------------+ | | | | ioeventfd | | | +------------------------------------+ | | KVM | +--------------------------------------------+
问题思考:
https://stackoverflow.com/questions/46418131/in-virtio-why-does-guest-notifier-and-host-notifier-use-ioeventfd-and-irqfd-res
总结:基于virtio的半虚拟化io方案,一方面减少了VM Exit和VM Entry(主要优化,VM Exit对性能的影响巨大),一方面基于vrtio协议,一次可以并行处理多个io,在性能上较之全虚拟化io有明显提升,但要注意其并未缩短io路径,io还是需要经过qemu好host kernel。
最后再回过来看全虚拟化io和半虚拟化io的区别:
virtqueue就是virtio-ring的具体组织形式,virtio的前后端基于virtqueue来实现io传输,每种设备可以有0个或多个virtqueue,每个virtqueue由三部分组成:
+-------------------+--------------------------------+-----------------------+
| Descriptor Table | Available Ring (padding) | Used Ring |
+-------------------+--------------------------------+-----------------------+
其总体结构如下:
struct virtq_desc { /* Address (guest-physical). */ le64 addr; /* Length. */ le32 len; /* This marks a buffer as continuing via the next field. */ #define VIRTQ_DESC_F_NEXT 1 /* This marks a buffer as device write-only (otherwise device read-only). */ #define VIRTQ_DESC_F_WRITE 2 /* This means the buffer contains a list of buffer descriptors. */ #define VIRTQ_DESC_F_INDIRECT 4 /* The flags as indicated above. */ le16 flags; /* Next field if flags & NEXT */ le16 next; };
struct virtq_avail {
#define VIRTQ_AVAIL_F_NO_INTERRUPT 1
le16 flags;
le16 idx;
le16 ring[ /* Queue Size */ ];
le16 used_event; /* Only if VIRTIO_F_EVENT_IDX */
};
struct virtq_used {
#define VIRTQ_USED_F_NO_NOTIFY 1
le16 flags;
le16 idx;
struct virtq_used_elem ring[ /* Queue Size */];
le16 avail_event; /* Only if VIRTIO_F_EVENT_IDX */
};
/* le32 is used here for ids for padding reasons. */
struct virtq_used_elem {
/* Index of start of used descriptor chain. */
le32 id;
/* Total length of the descriptor chain which was used (written to) */
le32 len;
};
Host device通过used ring归还buffer,其只会被device填写,diver读取。used ring的主体也是一个数组,但不同于avail ring只需要记录索引,used ring由于是存放处理后的结果,所以还需要记录写回的数据长度。
基于virtio实现的块设备驱动有两种,virtio-blk和virtio-scsi :
【virtio-blk】
guest: app -> Block Layer -> virtio-blk
host: QEMU -> Block Layer -> Block Device Driver -> Hardware
【virtio-scsi】
guest: app -> Block Layer -> SCSI Layer -> scsi_mod
host: QEMU -> Block Layer -> SCSI Layer -> Block Device Driver -> Hardware
下面介绍一下virtio-blk的协议细节:
一个virtio-blk的请求格式如下,注意只是逻辑上的表示,实际上并不是有一个virtio_blk_req的结构体定义。
struct virtio_blk_req {
// out header
le32 type;
le32 reserved;
le64 sector;
// buffer
u8 data[][512];
// in header
u8 status;
};
一个virtio_blk_req实际上分为3个部分:
struct virtio_blk_outhdr
{
__u32 type; // io的类型
__u32 ioprio; // io优先级
__u64 sector; // io offset,以512 bytes的sector为单位,通常后端收到后需要<<9转到以byte为单位
};
type的常用类型有:
enum { /* These two define direction. */ VIRTIO_BLK_T_IN = 0, // 读 VIRTIO_BLK_T_OUT = 1, // 写 /* This bit says it's a scsi command, not an actual read or write. */ VIRTIO_BLK_T_SCSI_CMD = 2, /* Cache flush command */ VIRTIO_BLK_T_FLUSH = 4, /* Get device ID command */ VIRTIO_BLK_T_GET_ID = 8, /* Discard command */ VIRTIO_BLK_T_DISCARD = 11 };
buffer
请求的中间是一个或多个buffer,这些buffer可能是read-only的也可能是write-only的,它们由descriptor chain中间的desc描述。
virtio_blk_inhdr
请求的最后一个字节是virtio_blk_inhdr,用于表示io结果,它由一个write-only的descriptor描述,由device进行填写。
struct virtio_blk_inhdr {
unsigned char status;
};
下面以两张图来看一次io过程中virtqueue的具体组织形式
host device接收请求
host device完成请求
virtio半虚拟化io方案解决了频繁vm exit的问题,但是仍未缩短io路径,有待进一步优化,其性能上的瓶颈主要有两个:
https://spdk.io/doc/about.html
SPDK是由Intel发起的,用于加速NVMe SSD作为后端存储使用的应用软件加速库。这个软件库的核心是用户态、异步、轮询方式的NVMe驱动。相比内核的NVMe驱动,SPDK可以大幅降低NVMe command的延迟,提高单CPU核的IOps,形成一套高性价比的解决方案。
从目前来讲,SPDK并不是一个通用的适配解决方案。把内核驱动放到用户态,导致需要在用户态实施一套基于用户态软件驱动的完整I/O栈。文件系统毫无疑问是其中一个重要的话题,显而易见内核的文件系统,如ext4、Btrfs等都不能直接使用了。虽然目前SPDK提供了非常简单的文件系统blobfs/blostore,但是并不支持posix接口,为此使用文件系统的应用需要将其直接迁移到SPDK的用户态“文件系统”上,同时需要做一些代码移植的工作,如不使用posix接口,而采用类似AIO的异步读/写方式。
spdk目前有主要以下几种应用场景:
spdk应用框架:
- 最下层为驱动层,管理物理和虚拟设备,还管理本地和远端设备。
- 中间层为通用块层,实现对不同后端设备的支持,提供对上层的统一接口,包括逻辑卷的支持、流量控制的支持等存储服务。这一层也提供了对Blob(Binary Larger Object)及简单用户态文件系统BlobFS的支持。
- 最上层为协议层,包括NVMe协议、SCSI协议等,可以更好地和上层应用相结合。
spdk目前主要的应用场景就是块存储,其通过bdev接口层,统一了块设备的调用方法,使用者只要调用不同的rpc将不同的块设备加到spdk进程中,就可以使用各种bdev,而不用修改代码。
一个很常见的使用spdk的方式是,用户定义自己的bdev,用以访问自己的分布式存储集群。
把virtio backend在qemu外实现即为vhost,spdk target对外暴露指定协议的存储服务。下面以virtio-scsi为例,看一下vhost是如何实现加速的。
spdk vhost-kernel-scsi
qemu virtio-scsi方案的演进,块设备模拟仍然是由qemu来做,只是把virtio backend放到了host kernel中,由kernel去处理virtqueue。
host kernel要处理virtqueue需要知道地址,因此qemu会把virtqueue的内存信息和guest的GPA-HVA的映射告知内核vhost-scsi模块,host kernel直接接收virtqueue中的请求并下发到后端,缩短了io路径,省去了host上用户态到内核态的拷贝。
注:这种方案只有在本地nvme场景下才有优化,针对云盘的bedv做后端和virtio-scsi没有区别。
spdk vhost-user-scsi
vhost-kernel方案相较于virtio-scsi优化了host上的io,但是仍然存在通知的开销,guest需要通知qemu,qemu需要通知host kernel vhost-scsi,于是进一步演进出了vhost-user方案:
整体架构如上图所示,virtio backend仍然在host用户态,但是放到了qemu外部,vhost作为独立进程运行在host用户态,通过hugepage的共享内存和qemu共享vierqueue的地址空间,并通过轮询的方式不断从中取出io请求,再交由bdev进行处理。
这里同样再说一下本地nvme设备和Bytedrive bdev的区别:
两种方案下guest os内的io路径完全相同,从guest放到virtqueue中之后开始有区别:
- qemu-virtio:通过ioeventfd通知qemu处理io,io在qemu内部io thread进行处理
- vhost-user:vhost不断去poll virtqueue,省去了通知的开销
如果是写云盘,之后两者也没有区别,但是vhost-user在线程模型上更具优势;如果是写本地盘,vhost-user的另一个优势:
- qemu-virtio:在qemu中写本地nvme盘,数据会拷贝到内核,再由内核的nvme驱动写盘,写完后由中断通知内核
- vhost-user:通过vhost实现的高速nvme驱动,无需拷贝到内核,直接在用户态写盘,同时busy polling nvme盘的queue pair,io完成也不需要中断通知
https://www.thomas-krenn.com/en/wiki/Linux_Storage_Stack_Diagram
https://cloud.tencent.com/developer/article/1052883
https://kernel.dk/blk-mq.pdf
https://cloud.tencent.com/developer/article/1425141
https://www.cnblogs.com/sammyliu/p/4543597.html
https://searchservervirtualization.techtarget.com/definition/hardware-assisted-virtualization
https://abelsu7.top/2019/09/02/virtio-in-kvm/
https://blog.linuxplumbersconf.org/2010/ocw/system/presentations/651/original/Optimizing_the_QEMU_Storage_Stack.pdf
https://www.static.linuxfound.org/jp_uploads/JLS2009/jls09_hellwig.pdf
http://docs.oasis-open.org/virtio/virtio/v1.0/virtio-v1.0.html
https://www.cs.cmu.edu/~412/lectures/Virtio_2015-10-14.pdf
https://kernelgo.org/virtio-overview.html
https://www.ozlabs.org/~rusty/virtio-spec/virtio-paper.pdf
https://abelsu7.top/2019/07/07/kvm-memory-virtualization/
https://www.cnblogs.com/yi-mu-xi/p/12544695.html
https://mp.weixin.qq.com/s/wuQ8-pwqb9qXfOt4w3Zviw
https://zhuanlan.zhihu.com/p/68154666
https://www.linux-kvm.org/images/a/a7/02x04-MultithreadedDevices.pdf
http://blog.vmsplice.net/2011/03/qemu-internals-overall-architecture-and.html
https://www.cnblogs.com/qxxnxxFight/p/11050159.html
http://bos.itdks.com/506e078a39b84f8cb06300cff8e00bbc.pdf
https://rootw.github.io/2018/05/SPDK-ioanalyze/
https://rootw.github.io/2018/05/SPDK-iostack/
https://vmsplice.net/~stefan/VHPC%202021%20-%20Bring%20your%20own%20virtual%20devices.pdf
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。