ibv_fork_init 原理,局限性与最佳实践

1,148次阅读
没有评论
ibv_fork_init 原理, 局限性与最佳实践

本文介绍 RDMA verbs 函数: ibv_fork_init() 的出现原因、作用原理、局限性,并提出 RDMA 编程中关于 fork 场景的最佳实践。

RDMA 内存读写原理

  1. 应用申请 MR 内存块
  2. 调用ibv_reg_mr,在 RNIC 中注册内存块
  3. RNIC 对内存块的读写采用 DMA 方式,读写过程应用进程无感知

具体过程不多赘述。

fork 带来的问题

Copy-On-Write (COW)

COW 是对 fork 创建子进程过程的一个性能优化手段。

当 fork 刚刚创建好子进程时,父子进程用的是相同的物理空间(内存区),子进程的代码段、数据段、堆栈都是指向父进程的物理空间,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。

同时,相关内存页被标记为只读,无论父子进程对其进行的首次写入操作,都会触发 page fault,进而会重新分配内存页,进行内存拷贝,然后谁触发的写操作,就更新谁的进程页表 (见: Does parent process lose write ability during copy on write?)。

问题

考察一个典型的场景:

某进程 P 注册的 MR 对应内存页记为 R。P 调用 fork 创建子进程 C,C 调用 exec 系列函数加载新的程序。此时存在如下 2 种可能的时序:

  1. C 执行 exec 系列函数完成之前,P 没有对 R 的写入(例如,P 主动 block 直到 C 运行结束):此后,C 执行 exec 完,替换自身页表,P 会一直保留原有的内存页 R,不会触发 COW,不会出问题
  2. 在 C 执行完成 exec 之前,P 对 R 进行写入操作:此时,P 触发 COW,系统复制了一份 R 的副本 R’,并用 R’更新了 P 的页表,如图:
ibv_fork_init 原理, 局限性与最佳实践

但是,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 内存地址段进行了记录:

  1. 建立红黑树,记录全部 mr,以及 mr 所在的内存页
  2. 每一次调用 ibv_reg_mr 时,判断当前 mr 所在的内存页的起止地址,检查是否红黑树中存在记录
  3. 如果不存在,则加入红黑树并调用 madvise 打标记MADV_DONTFORK
  4. 每一次调用ibv_dereg_mr,使用当前 mr 地址查询红黑树
  5. 如果该 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 之外的内存做标记,如图:

ibv_fork_init 原理, 局限性与最佳实践

蓝色部分是注册的 MR,红色部分是其他内存,但是红色部分也会被标记为MADV_DONTFORK

如果是 fork-exec 的用法,子进程不会使用父进程的任何内存,则多标记这一部分内存不会造成影响。

如果是单纯 fork,并且子进程运行时会访问到红色部分的内存,则实际上子进程访问的是父进程的内存,就会出现内存访问的问题 (见 A Cursed Bug、rdma-core: ibv_dontfork_range should not round up to page boundaries)。

相关解决方案

MR 整页限制

最新的 rdma-core 版本中合入了一个提交 (verbs: Allow aligned address & size only for fork init #1222):在开启 ibv_fork_init 后,强迫所有 mr 的起始地址必须是内存页对齐,长度必须是内存页整数倍,其实就是强迫只能按照整页去注册 mr,修复了 ibv_fork_init 的局限性

copy-on-fork

最新的内核代码包含了 copy-on-fork 的功能 (https://github.com/torvalds/linux/commit/70e806e4e645019102d0e09d4933654fb5fb58ce、https://lore.kernel.org/linux-rdma/[email protected]/),在 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 的行为,避免性能下降
正文完
 2
评论(没有评论)
验证码