混合内存虚拟设备
对于不需要持久化的场景,NVM的优势在于价格低廉。如果某个workload对内存容量要求很大,但是对于读写带宽(尤其是写带宽)要求比较小(比如一个读多写少的Redis),那么就可以使用NVM替换一部分的内存。这样既能满足性能要求,又降低了成本。 在这种场景中,我们希望有较细的混合粒度。什么意思呢?我们需要让DRAM和NVM交织(interleave)在一起,假设我们希望DRAM:NVM为1:2,那么最好第一个字节是在DRAM,第二、三字节在NVM上,如此循环。我们说此时的粒度是“1字节”。如果第一个N字节在DRAM上,第二、三个N字节在NVM上,如此循环,那么我们说此时的粒度是“N字节”。可以想见,N越小越好,因为如果N很大,那么不同区域内存访问的带宽、延迟将会有明显的差异。 AD mode肯定是不适用的。因此在AD mode下,NVM映射为连续的一段地址空间,因此DRAM和NVM是以很大的粒度混合的。如果通过mmap/mremap等把NVM和DRAM交替地映射到地址空间,那么也很难在粒度和内核资源之间折中。比如说,使用mmap以1MB的粒度交替映射,那么1TB的空间将要占用一百万个VMA(Virtual Memory Area,一种内核记录虚地址空间分段情况的数据结构),内核资源会被耗尽。 此时我们需要NUMA mode,即把NVM当作一个NUMA节点。这个特性加入了Kernel 5.1及以上版本。需要以下开启Kernel编译选项: +++code CONFIG_MEMCG_KMEM=y CONFIG_DEV_DAX_KMEM=m ---code 把NVM从Memory Mode切换到AD mode的命令为: +++code ipmctl create -goal ndctl create-namespace ---code 从AD mode切换到NUMA mode的命令为: +++code modprobe -r dax_pmem_compat modprobe -i dax_pmem daxctl reconfigure-device --mode=system-ram --region=0 all # 把socket 0上的NVM变成NUMA mode daxctl reconfigure-device --mode=system-ram --region=1 all # 把socket 1上的NVM变成NUMA mode ---code 具体的使用可以查看PMDK上#HREFhttps://pmem.io/ndctl/#-HREF1ndctl和daxctl的说明#-HREF2。 在NUMA mode下,我们可以通过操作页表来实现“页粒度”的混合。具体的操作上,有两种方式: #OL #LI 在用户态,通过move_pages()迁移页; #-LI #LI 在内核态,触发page fault绑定页。 #-LI #-OL 方法一最大的优势在于可以用户态实现,但是缺点也很明显,首先如何及时地得知哪些页可以迁移了(未映射的页不能迁移),其次迁移会引入额外性能开销,最后有些页不能迁移。方法二的优势就是方法一的缺点。 我的设计就是,实现一个内核模块hybridmem.ko,导出/dev/hybridmem虚拟设备文件,然后用户进程open()、mmap()就能获得一段交织的内存。 hybridmem.c: +++code #include <linux/fs.h> #include <linux/mm.h> #include <linux/cdev.h> #include <linux/slab.h> #include <linux/module.h> MODULE_LICENSE("Dual BSD/GPL"); #define MODULE_NAME "hybridmem" //============================================================================= struct pattern { size_t len; uint8_t nodes[]; }; static ssize_t write(struct file* file, const char* buf, size_t len, loff_t* offset) { if (len) { size_t struct_size = sizeof(size_t) + len; struct pattern* pattern = kmalloc(struct_size, GFP_KERNEL); if (!pattern) { printk(KERN_ERR "failed to kmalloc(%lu)\n", struct_size); return -ENOMEM; } pattern->len = len; if (copy_from_user(pattern->nodes, buf, len) != 0) { printk(KERN_ERR "failed to copy_from_user(pattern->nodes, %p, " "%lu)\n", buf, len); kfree(pattern); return -EIO; } if (file->private_data) { kfree(file->private_data); } file->private_data = pattern; } else if (file->private_data) { kfree(file->private_data); file->private_data = NULL; } return len; } static vm_fault_t fault(struct vm_fault* vmf) { struct pattern* pattern = vmf->vma->vm_file->private_data; int node; struct page* page; if (pattern) { unsigned long pfn = vmf->address >> PAGE_SHIFT; node = pattern->nodes[pfn % pattern->len]; if (node >= MAX_NUMNODES || !node_online(node)) { return VM_FAULT_SIGBUS; } } else { node = NUMA_NO_NODE; } page = alloc_pages_node(node, GFP_HIGHUSER_MOVABLE, 0); if (!page) { printk(KERN_ERR "failed to allocate a page on node %d\n", node); return VM_FAULT_OOM; } vmf->page = page; return 0; } static struct vm_operations_struct vma_ops = { .fault = fault, }; static int mmap(struct file* file, struct vm_area_struct* vma) { if (!(vma->vm_flags & VM_SHARED)) { printk(KERN_ERR "%s only supports shared mapping\n", MODULE_NAME); return -EINVAL; } vma->vm_ops = &vma_ops; return 0; } static int release(struct inode* inode, struct file* file) { if (file->private_data) { kfree(file->private_data); } return 0; } static struct file_operations fops = { .owner = THIS_MODULE, .write = write, .mmap = mmap, .release = release, }; //============================================================================= static dev_t number; static struct cdev cdev; static struct class* class; static struct device* device; static int init(void) { int ret; ret = alloc_chrdev_region(&number, 0, 1, MODULE_NAME); if (ret < 0) { printk(KERN_ERR "failed to alloc_chrdev_region(&number, 0, 1, " "'%s')\n", MODULE_NAME); return ret; } cdev_init(&cdev, &fops); ret = cdev_add(&cdev, number, 1); if (ret < 0) { printk(KERN_ERR "failed to cdev_add(&cdev, number, 1)\n"); goto clean_1; } ret = -1; class = class_create(THIS_MODULE, MODULE_NAME); if (IS_ERR(class)) { printk(KERN_ERR "failed to class_create(THIS_MODULE, '%s')\n", MODULE_NAME); goto clean_2; } device = device_create(class, NULL, number, NULL, MODULE_NAME); if (IS_ERR(device)) { printk(KERN_ERR "failed to device_create(class, NULL, number, NULL, " "'%s')\n", MODULE_NAME); goto clean_3; } return 0; clean_3: class_destroy(class); clean_2: cdev_del(&cdev); clean_1: unregister_chrdev_region(number, 1); return ret; } static void cleanup(void) { device_destroy(class, number); class_destroy(class); cdev_del(&cdev); unregister_chrdev_region(number, 1); } module_init(init); module_exit(cleanup); ---code Makefile: +++code obj-m := hybridmem.o KERNEL_DIR := /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: make -C $(KERNEL_DIR) M=$(PWD) modules clean: rm -f *.o *.ko *.mod.c .PHONY: clean ---code 代码在Kernel 5.3.9上测试通过。 使用如下: +++code make sudo insmod hybridmem.ko ---code 就能看到多了一个/dev/hybridmem。 测试用例如下: test.c +++code #include <fcntl.h> #include <assert.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/mman.h> int main() { int fd = open("/dev/hybridmem", O_RDWR); assert(fd > 0); ssize_t len = write(fd, "\x00\x02\x02", 3); assert(len == 3); size_t size = 1UL << 30; void* ptr = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); assert(ptr != MAP_FAILED); while (1) { memset(ptr, 0, size); } return 0; } ---code 上述用例中,通过write()告知hybridmem内存需要按照Node = {0, 2, 2}这样的周期来交织,因此三分之一的页来自Node 0,三分之二的页来自Node 2。