本文介绍RDMA verbs函数: ibv_fork_init()
的出现原因、作用原理、局限性,并提出RDMA编程中关于fork场景的最佳实践。
应用申请MR内存块
调用ibv_reg_mr,在RNIC中注册内存块
RNIC对内存块的读写采用DMA方式,读写过程应用进程无感知
具体过程不多赘述。
COW是对fork创建子进程过程的一个性能优化手段。
当fork刚刚创建好子进程时,父子进程用的是相同的物理空间(内存区),子进程的代码段、数据段、堆栈都是指向父进程的物理空间,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。
同时,相关内存页被标记为只读,无论父子进程对其进行的首次写入操作,都会触发page fault,进而会重新分配内存页,进行内存拷贝,然后谁触发的写操作,就更新谁的进程页表 (见: Does parent process lose write ability during copy on write?)。
考察一个典型的场景:
某进程P注册的MR对应内存页记为R。P调用fork创建子进程C,C调用exec系列函数加载新的程序。此时存在如下2种可能的时序:
C执行exec系列函数完成之前,P没有对R的写入(例如,P主动block直到C运行结束): 此后,C执行exec完,替换自身页表,P会一直保留原有的内存页R,不会触发COW,不会出问题
在C执行完成exec之前,P对R进行写入操作: 此时,P触发COW,系统复制了一份R的副本R’,并用R’更新了P的页表,如图:
但是,RNIC仍持有原先的R做DMA,而P由于页表改变,错误的从R’ 读写RDMA数据,导致与RNIC不一致,通信数据出现错误。
注意,虽然rdma mr的内存受
mlock
保护,也不会改变这种行为。mlock
只是保证这些内存页不会被换到swap当中,即所谓的pinned内存。
ibv_fork_init
原理为了解决上述问题,RDMA开发人员向内核贡献了MADV_DOFORK
/MADV_DONTFORK
两个madvise的flag (madvise MADV_DONTFORK/MADV_DOFORK),并且新增了ibv_fork_init
函数 (Make fork() work for verbs consumers)。
这些措施仍然没有完美解决问题,见下一节
MADV_DONTFORK
madvise
通过将某内存页标记为MADV_DONTFORK
,强迫fork场景下该页不会被复制
ibv_fork_init
该函数需要在使用RDMA功能之前开启。开启后,基本只会影响ibv_reg_mr
和ibv_dereg_mr
两个函数。
原理:
开启ibv_fork_init
后,调用ibv_reg_mr
时,会对相关内存,调用madvise 打标记MADV_DONTFORK
。
由于madvise
需要对整页进行做标记,但是mr内存不一定是整页,所以verbs库内部对所有注册的MR内存地址段进行了记录:
建立红黑树,记录全部mr,以及mr所在的内存页
每一次调用ibv_reg_mr
时,判断当前mr所在的内存页的起止地址,检查是否红黑树中存在记录
如果不存在,则加入红黑树并调用madvise
打标记MADV_DONTFORK
每一次调用ibv_dereg_mr
,使用当前mr地址查询红黑树
如果该mr所在内存页没有包含其他已注册的mr,则调用madvise
打标记MADV_DOFORK
通过对所有已注册的MR所在内存页打MADV_DONTFORK
标记,创建子进程后,MR所在内存页不会触发COW拷贝,避免了前面所说的COW带来网卡DMA内存地址不一致的问题。
ibv_fork_init
的局限性ibv_fork_init
仍然存在问题,根源是设计上的一个矛盾:ibv_reg_mr
允许任意内存地址段,而madvise
只能对整个内存页进行标记。
通过上面对ibv_fork_init
的行为描述,可以很容易看出,如果mr的地址段不是整内存页,则ibv_reg_mr
会对mr之外的内存做标记,如图:
蓝色部分是注册的MR,红色部分是其他内存,但是红色部分也会被标记为MADV_DONTFORK
如果是fork-exec的用法,子进程不会使用父进程的任何内存,则多标记这一部分内存不会造成影响。
如果是单纯fork,并且子进程运行时会访问到红色部分的内存,则实际上子进程访问的是父进程的内存,就会出现内存访问的问题 (见 A Cursed Bug 、rdma-core: ibv_dontfork_range should not round up to page boundaries)。
最新的rdma-core版本中合入了一个提交 ( verbs: Allow aligned address & size only for fork init #1222) :在开启ibv_fork_init
后,强迫所有mr的起始地址必须是内存页对齐,长度必须是内存页整数倍,其实就是强迫只能按照整页去注册mr,修复了ibv_fork_init
的局限性
最新的内核代码包含了copy-on-fork的功能 (https://github.com/torvalds/linux/commit/70e806e4e645019102d0e09d4933654fb5fb58ce 、 https://lore.kernel.org/linux-rdma/20210418121025.66849-1-galpress@amazon.com/) ,在fork时,DMA内存不再执行COW策略,而是直接复制到子进程,因此没有必要再做MADV_DONTFORK
的标记。
相应的,为了使用该内核功能,verbs提供了一个新的功能 (见Report when ibv_fork_init() is not needed #975) :
引入函数:ibv_is_fork_initialized()
,如果返回的是IBV_FORK_UNNEEDED
,说明当前内核支持上述copy-on-fork特性,不必开启ibv_fork_init()
。
如果确定应用不存在任何fork/popen等创建子进程的行为,则不应该开启ibv_fork_init
,因为根据前面所述,开启后,verbs会建立红黑树管理全部MR,如果注册MR非常频繁,这部分操作会引起性能下降 (见:prov/verbs misleading use of ibv_fork_init() #4974)
如果需要进行创建子进程的操作,可以wait到子进程执行结束,也可以使用posix_spawn
(主进程阻塞直到exec执行完毕)
如果主进程不能等待:
较新版本verbs库,则应该首先调用ibv_is_fork_initialized
,如果返回值是IBV_FORK_UNNEEDED
,则不需要调用ibv_fork_init
;如果返回值是IBV_FORK_DISABLED
,则需要开启ibv_fork_init
旧版本verbs库,需要执行ibv_fork_init
,并且在设计上规避频繁注册MR的行为,避免性能下降