页表共享:一种动态内存扩缩设计
背景
PolarDB:PolarDB数据库共享内存三种形式 & 内存扩缩
当前数据库有三种共享内存形式,是同时存在的:
- mmap方式申请的anonymous shared memory;地址通过fork传递;
- sysv方式申请的buffer pool;这部分分为两个功能,一个叫做persisted buffer pool,这部分是为了我们能在异常重启后继续使用内存,地址通过fork传递;一个叫做dsga,就是我们的共享内存的扩缩,地址通过attach detach内存段实现地址共享(维护了一个1TB的虚拟内存地址,通过fork传递);
- 并行查询的动态共享内存,根据参数可以使用mmap/sysv/posix三种方式,地址通过attach detach内存段实现地址共享。每个进程有自己的内存段地址;
站在OS的角度,这里有两类shared memory需要使用页表共享。
在线扩缩共享内存:
现有的扩缩方式,需要在父进程调整后,attach和detach子进程,在子进程数量大的时候这种通信开销较大。如果使用页表共享,仅仅需要父进程调用madvise(MADV_DONTNEED)即可。如此即可将使用的内存释放出来。扩缩的需求是能够对最小到2M粒度的内存(边界都是2M对齐)进行madvise(MADV_DONTNEED)操作。
用了页表共享以后:
- VMA数量变成一个;fork的那块可能存在优化;
- attach和detach;
- 大页变成小页;
内核:匿名共享页 & 页表 & 页表共享
匿名共享页:匿名共享页是通过mmap(MAP_SHARED | MAP_ANONYMOUS)向操作系统申请的一块内存,通过共享同一块page cache实现多个进程间进行共享内存。另外,在OS中,第一次读或写,直接挂接了实际的物理页。
页表:当前页表共享仅支持pte page这一层在多个进程间共享。
页表共享:当前OS支持hugetlb pmd这一级的页表共享。
madvise操作:当前内核madvise(DONTNEED)对匿名共享内存操作,并不能完全释放(因为pagecache还占有);
综上,OS需要提供的页表共享功能包括:
- 对匿名共享内存支持页表共享 —— mmap(MAP_ANONYMOUS + MAP_SHARED);
- 对页表共享的内存支持:madvise(DONTNEED + DODUMP + DONTDUMP);
- 支持PolarDB内存扩缩:madvise(DONTNEED)支持最小16M内存;
已有的技术
hugetlb pmd share
hugetlb是否使用pmd share的判断:
+static int vma_shareable(struct vm_area_struct *vma, unsigned long addr)
+{
+ unsigned long base = addr & PUD_MASK;
+ unsigned long end = base + PUD_SIZE;
+
+ /*
+ * check on proper vm_flags and page table alignment
+ */
+ if (vma->vm_flags & VM_MAYSHARE &&
+ vma->vm_start <= base && end <= vma->vm_end)
+ return 1;
+ return 0;
+}使用:
m = mmap(NULL, s, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_HUGETLB | MAP_SHARED, -1, 0);使用pmd share时,仅仅只能将1G对齐开始使用页表共享,并且是透明使用,例如申请1G或2G hugetlb内存,不一定可以使用上pmd share,如果申请3G hugetlb,中间的1G可以使用上pmd share(中间1G内存边界肯定是1G对齐的)。
页表共享方案&实现
我们当前实现的方案:
相关介绍:
shadow mmu/vma:共享页表的vma会单独创建一个shadow vma和shadow mm(即内核中用来管理进程页表的结构体:mm_struct),shadow mm用来维护共享的页表,所有共享同一段内存的vma可以找到该shadow mm,用于更新当前进程mm。
shadow mm/vma的创建和释放:在管理shadow mm的结构体pgtable_share_struct中,引用计数refcnt用来表示当前shadow mm有多少vma正在使用,在fork过程中增加该计数,在unmap vma过程中会减少该计数,如果引用计数为0,即释放该shadow mm,同时释放shadow vma。
缺页处理:在进程的缺页过程中,直接从shadow mm中取出pmd entry填充进程的mm,但需要切换到origin mm进行memcg记账和刷新tlb。
madvise处理:对于页表共享,当前仅支持MADV_DONTNEED操作,对于PolarDB后续需要开发了DODUMP和DONTDUMP特性,依赖用户态提前将这部分的数据单独mmap到单独的VMA。按之前掌握的信息,DODUMP这部分数据大约十几兆,没有使用页表共享的必要。
对于页表共享,其MADV_DONTNEED语义与MADV_REMOVE更接近。
mprotect/mbind/mremap处理:这些操作对页表共享VMA的影响范围较大,暂时不支持。
内存迁移处理:当前PolarDB场景没有打开NUMA,暂不支持迁移。
内存规整:内存回收或者规整过程中,可能需要zap pte操作,影响到使用该共享VMA的所有进程,考虑到当前用户PolarDB没有打开规整的必要,页表共享VMA在该过程中直接被bypass掉。
内存回收:支持,shadow VMA直接在mapping树上,try_to_unmap()过程会操作。
2M粒度 zap/unmap/shrink
首先,有一个参考,在 shrink 过程中,对于 hugetlb pmd sharing回收过程(try_to_unmap_one())中,会向上一级对齐,本身是 2M pmd页共享,这里会直接扫描 1G 区间。
2M unmap 设计考虑
在 unmap 过程中,会直接取消所有的页表映射,同时也会释放 vma,对于 pgtable sharing,其 pte 页是共享的。对于这种页表共享,在 unmap 其中一个 vma 的时候,我们不应该仅为该 vma 而改写 pte page,这样也会导致其他进程的对应的 vma 共享页表被改写。因此,在当前的设计中,我们直接对当前 vma 执行clear pmd entry,即按 2M 粒度 unmap。
在 unmap vma 过程中也会检查,如果是页表共享的 vma,必须按 2M 粒度,否则返回错误。
2M zap 设计考虑
与 2M unmap 同理,zap过程会遍历 pte entry并取消映射,因此仍按照 2M 粒度操作。
2M shrink
pvmw过程中,没有持有 mmap lock 读写锁,在操作 pte 前,是通过持有 pte_lockptr 保证一致性,按 2M 粒度操作的原因如下:
- 如果强制扫描 pte level,由于没有持有 mmap lock,会与 unmap、zap 过程产生冲突,比如 pvmw 获取pte_lockptr指针时,zap 和 unmap 过程正在 pmd clear,访问了空指针,发生 panic(亲测);
- 参考 hugetlb pmd sharing,可以简化页表共享 pvmw 过程;
MADV_DONTNEED or MADV_REMOVE(打洞)?
MADV_REMOVE导致中间部分成为hole,如果访问这些hole会发生什么?
shmem fallocate仅支持文件打洞。
有名共享支持
PolarDB也需要页表共享支持shmget方式创建的共享内存,如此可以在实例发生异常后快速使用原来磁盘的共享数据拉起新实例并恢复共享数据。
非父子进程页表
一个问题,非父子进程之间(比如A->B->C,A与C之间页表能不能共享,安全性?C的共享页表被修改或者攻击,A也会被破坏),页表是否共享?
(其实页表共享本身就存在语义问题。)
关于使用
一个使用页表共享的用例:
验证是否共享
方法:创建一段共享内存,然后fork出多个子进程,通过crash判断各子进程的PMD entry是否相同。
切换到root用户,安装crash。
执行下面的执行,查看各进程底层数据:
crash /proc/kcore
使用vtop命令查看进程各级页表映射:
crash> vtop -c 41553 7fe0bb200000
VIRTUAL PHYSICAL
7fe0bb200000 (not mapped)
PGD: 44fb2a7f8 => 800000015072a067
PUD: 15072ac10 => 1862ea1067
PMD: 1862ea1ec8 => 412a6ba067
PTE: 412a6ba000 => 0
VMA START END FLAGS FILE
ffff8ea8b48c7740 7fe0bb200000 7fe132df3000 80000fb dev/zero
FILE: dev/zero OFFSET: 0其中41553为进程pid,7fe0bb200000为虚拟地址。
检查PMD中存的值,相等即表示页表共享成功。如下:
crash> vtop -c 82062 7fb3fae00000
VIRTUAL PHYSICAL
7fb3fae00000 1b0f717000
PGD: 4b75527f8 => 8000002e718f8067
PUD: 2e718f8678 => 3c4483067
PMD: 3c4483eb8 => 2f5a940067 (这里)
PTE: 2f5a940000 => 8000001b0f717867
PAGE: 1b0f717000
PTE PHYSICAL FLAGS
8000001b0f717867 1b0f717000 (PRESENT|RW|USER|ACCESSED|DIRTY|NX)
VMA START END FLAGS FILE
ffff8e7f565abf00 7fb3fae00000 7fb472a00000 20080000fb dev/zero
PAGE PHYSICAL MAPPING INDEX CNT FLAGS
ffffea186c3dc5c0 1b0f717000 ffff8e76d6aaecc8 0 2 17ffffc008001c uptodate,dirty,lru,swapbacked
crash>
crash>
crash> vtop -c 82055 7fb3fae00000
VIRTUAL PHYSICAL
7fb3fae00000 1b0f717000
PGD: 2f5a8c07f8 => 8000002e718f0067
PUD: 2e718f0678 => 3f4129d067
PMD: 3f4129deb8 => 2f5a940067 (与前面的进程该值相等)
PTE: 2f5a940000 => 8000001b0f717867
PAGE: 1b0f717000
PTE PHYSICAL FLAGS
8000001b0f717867 1b0f717000 (PRESENT|RW|USER|ACCESSED|DIRTY|NX)
VMA START END FLAGS FILE
ffff8e83fea5d170 7fb3fae00000 7fb472a00000 20080000fb dev/zero
PAGE PHYSICAL MAPPING INDEX CNT FLAGS
ffffea186c3dc5c0 1b0f717000 ffff8e76d6aaecc8 0 2 17ffffc008001c uptodate,dirty,lru,swapbacked
可以对比“PMD: 3f4129deb8 => 2f5a940067”,父子进程该值相等,说明页表共享成功。
性能影响
当前,可以进行的测试主要是madvise、unmap接口在处理不同共享内存时,与正常相比的性能差异。从设计上,页表共享VMA在madvise或munmap的时候,需要刷tlb,包括其他进程这段的地址,因此,如果进程越多,操作地址范围越大,这块的性能影响理论上越大。下面是我们的测试数据。
构建一个2G页表共享区域:
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
95.99 0.078431 39215 2 madvise
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
99.93 3.697094 1848547 2 madvise一个时间是0.07s,页表共享的时间开销是3s(truncate比较重)。
极端
512 M timeofday=0.935s 0.02268s (madvise时子进程没有并行访问这段数据:0.08439s)
256 M timeofday=0.471s
128 M timeofday=0.034s
64 M timeofday=0.014s
32 M timeofday=0.007s
16 M timeofday=0.004s 0.00091s (0.00303s)
8 M timeofday=0.002s
4 M timeofday=0.00091s 0.00023s
2 M timeofday=0.00050s下面是2组数据,分别测试16个子进程和64子进程情况下,父进程调用madvise(DONTNEED)的时间开销。测试数据均取最大值。另外:
- 正常:表示没有使用页表共享;
- 页表共享1、2、3:分别表示父进程调用madvise时,子进程同时对这块内存访问的频率;
2G madvise对不同内存大小操作的开销(创建16个子进程):
madvise操作内存大小 | 正常 | 页表共享1 | 页表共享2 (20ms) | 页表共享3(80ms) |
2 M | 0.00012s | 0.00050s | 0.00051s | 0.00052s |
4 | 0.00021s | 0.00091s | 0.00097s | 0.00095s |
8 | 0.00043s | 0.002s | 0.00183s | xxx |
16 M | 0.00083s | 0.004s | 0.00366s | xxx |
32 | 0.00160s | 0.007s | 0.00717s | 0.00722s |
64 | 0.00309s | 0.014s | 0.01336s | 0.01416s |
128 | 0.00574s | 0.034s | 0.02513s | 0.02212s |
256 | 0.01061s | 0.471s | 0.04841s | 0.04800s |
512 | 0.02268s | 0.935s | 0.92871s | 0.09024s |
注:页表共享在操作内存块大的时候,第一次操作和第二次操作时间开销差距比较大,例如对于256M内存,第一次操作大约0.471s,第二次操作及后续操作仅需要0.05s。
结论:
- 在小于256M之前,页表共享的时间开销基本是正常的5~6倍
- 内存块大于256M后,页表共享的时间开销是正常的4~40倍左右:
- 其他子进程对该块内存没有访问,则开销大约是4倍左右;
- 其他子进程对该段内存高频访问,则开销大约40倍左右(差距这么大的主要原因可能是页表共享有truncate操作);
(一般其他进程在madvise操作这块内存时,其他进程一般会有锁保护使用这段数据,因此 页表共享3 的测试数据一般代表实际的使用场景)
2G madvise对不同内存大小操作的开销(创建64个子进程,页表共享测试分为:极端和缓和):
madvise操作内存大小 | 正常 | 页表共享1 | 页表共享2 (20ms gap) | 页表共享3(80ms) |
2 M | 0.00077s | 0.00217s | ||
4 | 0.00035s | 0.00396s | ||
8 | 0.00282s | 0.00602s | ||
16 | 0.00352s | 0.00859s | ||
32 | 0.00782s | 0.01457s |
结论:
并发数上去后,页表共享3的开销平均在正常madvise的3倍左右。