RDMA编程核心对象:MR(内存注册)

从一次诡异的“内存不可访问”说起

去年调一个NVMe over RDMA的存储方案,压测跑到一半,突然报IBV_WC_MW_BIND_ERR,伴随IBV_WC_REM_ACCESS_ERR。查了半天,代码逻辑没问题,内存指针也没飞。最后发现是MR(Memory Region)注册时,内存页对齐没处理好——我传了一个malloc出来的buffer,地址是0x7f8a2b3c0010,不是4K对齐的。HCA(Host Channel Adapter)直接罢工,丢回来一个让人摸不着头脑的错误码。

从那以后,我养成了一个习惯:每次注册MR之前,先看一眼地址是不是页对齐的。这个坑,踩一次就够了。

MR到底是什么?别被“注册”两个字骗了

很多新手以为MR就是“告诉网卡这块内存我能用”,其实远不止这么简单。MR的本质是在HCA和操作系统之间建立一套内存访问的契约

你想想,网卡要直接读写你的用户态内存(DMA),但操作系统有虚拟地址、物理地址、页表、TLB这一整套机制。网卡是个硬件,它不认识什么虚拟地址,它只认物理地址和IOMMU(I/O Memory Management Unit)映射。MR干的事情就是:

  1. 把你指定的虚拟地址区间,锁定在物理内存里(防止被swap出去)
  2. 把这段物理内存的映射关系告诉HCA
  3. 给HCA一个“钥匙”——L_Key(Local Key)或R_Key(Remote Key),后续所有操作都要凭这个钥匙访问

说白了,MR就是给网卡开了一张“内存通行证”,上面写清楚了:哪段物理内存、多大、谁可以读、谁可以写。

注册MR的三种姿势,你选对了吗?

姿势一:ibv_reg_mr —— 最基础,但别乱用

struct ibv_mr *mr = ibv_reg_mr(pd, buf, size, 
                               IBV_ACCESS_LOCAL_WRITE | 
                               IBV_ACCESS_REMOTE_WRITE);
// 这里踩过坑:size必须是页对齐的,否则ibv_reg_mr会返回NULL
// 别这样写:ibv_reg_mr(pd, buf, 100, ...)  // 100不是页大小倍数

这个函数会把整个buf区间注册成一个连续的MR。注意,buf的起始地址和size都必须页对齐。页大小通常是4096字节,但有些架构是64K甚至更大。我习惯用sysconf(_SC_PAGESIZE)动态获取,别写死。

姿势二:ibv_reg_mr_iova2 —— 控制物理地址,高级玩法

struct ibv_mr *mr = ibv_reg_mr_iova2(pd, buf, size, iova, access_flags);
// iova参数指定了HCA看到的“IO虚拟地址”,可以和CPU虚拟地址不同
// 这个接口在DPDK、SPDK里用得比较多,普通应用别碰

这个接口允许你手动指定IOVA(I/O Virtual Address)。什么意思?就是你可以让网卡用一套地址空间,CPU用另一套。比如你做内存池管理,想让网卡看到的地址连续,但CPU看到的物理地址是离散的。这个接口踩坑概率极高,我一般只在做用户态驱动时才用。

姿势三:ibv_alloc_dm + ibv_reg_dm_mr —— 设备内存注册

struct ibv_dm *dm = ibv_alloc_dm(context, &attr);
struct ibv_mr *mr = ibv_reg_dm_mr(pd, dm, 0, size, access_flags);
// 这是注册HCA内部的内存,不是主机内存
// 别搞混了:dm_mr的地址是设备地址,不能直接memcpy

这个场景比较特殊,比如你有一块支持On-Demand Paging的HCA,或者你想用HCA内部的缓存。普通RDMA应用基本用不到,但如果你做的是GPUDirect RDMA或者SmartNIC开发,可能会碰到。

L_Key和R_Key:两把钥匙,开不同的锁

注册完MR,你会得到一个ibv_mr结构体,里面有两个关键字段:

  • lkey:本地操作钥匙。比如ibv_post_send发一个本地读请求,HCA会用lkey验证你有权限访问这段内存。
  • rkey:远程操作钥匙。对面机器要读写你这边的内存,必须带上rkey。

这里有个容易犯迷糊的地方:lkey和rkey是同一个MR的两个属性,不是两把独立的锁。你可以理解为:lkey是“本地身份证”,rkey是“远程通行证”。注册时通过access_flags控制远程权限:

// 只允许本地读写,不允许远程访问
ibv_reg_mr(pd, buf, size, IBV_ACCESS_LOCAL_WRITE | IBV_ACCESS_LOCAL_READ);

// 允许远程读写(对面机器可以RDMA Write/RDMA Read)
ibv_reg_mr(pd, buf, size, 
           IBV_ACCESS_LOCAL_WRITE | 
           IBV_ACCESS_REMOTE_WRITE | 
           IBV_ACCESS_REMOTE_READ);

别这样写:只给了IBV_ACCESS_REMOTE_WRITE,没给IBV_ACCESS_LOCAL_WRITE。这样本地CPU反而不能写这块内存了,调试时会一脸懵。

内存注册的代价:别频繁注册/注销

MR注册不是免费的午餐。每次ibv_reg_mr,内核都要:

  1. 锁定物理页面(调用get_user_pages
  2. 建立IOMMU映射
  3. 把映射关系写入HCA的硬件表

这个过程涉及系统调用、页表遍历、TLB刷新,一次注册大概几微秒到几十微秒。如果你的应用每秒注册/注销几千次MR,性能直接崩盘。

我的经验做法:启动时一次性注册一个大MR(比如几百MB甚至几GB),然后自己管理这块内存的分配。这就是所谓的“Memory Pool + MR”模式。SPDK就是这么干的——注册一个巨大的MR,然后内部做内存分配器。

// 坏做法:每次发送都注册/注销
for (int i = 0; i < 100000; i++) {
    mr = ibv_reg_mr(pd, buf, size, flags);
    // 发送数据...
    ibv_dereg_mr(mr);
}

// 好做法:一次注册,重复使用
mr = ibv_reg_mr(pd, big_pool, pool_size, flags);
for (int i = 0; i < 100000; i++) {
    offset = alloc_from_pool(pool, size);
    // 使用mr + offset访问子区域
    post_send(mr->lkey, pool + offset, size);
}

踩坑实录:那些年我遇到的MR问题

坑1:内存被swap,HCA读到了垃圾数据

MR注册时,内核会锁定页面,但只锁定注册那一刻的物理页面。如果你注册后,这块内存被fork了(子进程写时复制),或者被mremap了,HCA拿到的物理地址就失效了。表现就是:偶尔出现IBV_WC_REM_ACCESS_ERR,重启后又好了。

解决方案:注册前用mlockall(MCL_CURRENT | MCL_FUTURE)锁定整个进程的地址空间,或者用mbind绑定到特定NUMA节点。

坑2:MR大小超过物理内存

你以为注册1GB的MR,系统就给你1GB?如果物理内存不够,ibv_reg_mr会返回NULL,errno是ENOMEM。但更坑的是,有些驱动会注册成功,但实际只锁定了部分页面。压测时突然出现不可恢复的错误,查了半天才发现是内存超卖。

我的做法:注册前用sysinfo/proc/meminfo检查可用内存,留出20%的余量。

坑3:多线程同时注册同一个PD

PD(Protection Domain)是MR的容器。多个线程可以同时注册MR到同一个PD,但ibv_reg_mr不是线程安全的。如果你在多个线程里同时调用,可能会触发内核竞态,导致注册失败或返回错误的lkey。

解决方案:加锁,或者每个线程创建独立的PD。我倾向于后者——PD本身开销很小,多线程用独立PD还能隔离故障。

个人经验:MR管理的“黄金法则”

  1. 注册时机越早越好,注销时机越晚越好。最好在初始化阶段把所有内存都注册好,运行期间只做内存分配和回收,不碰MR注册。

  2. MR的粒度要粗。不要为每个小buffer注册MR,而是注册一个大的内存池,然后用offset来访问子区域。这样lkey可以复用,减少硬件表项占用。

  3. 调试时打印lkey和rkey。我习惯在注册后打印mr->lkeymr->rkey,在发送WR(Work Request)时也打印一下。如果出现错误,对比一下就知道是不是钥匙用错了。

  4. 注意内存对齐。不仅是页对齐,有些HCA还要求MR起始地址是cacheline对齐(64字节)。虽然不强制,但不对齐会导致性能下降——因为HCA可能做partial cacheline的读写,触发总线锁。

  5. 别迷信“零拷贝”。MR注册本身就有开销,加上内存锁定、IOMMU映射,实际延迟可能比memcpy还高。只有在数据量大(比如>4KB)或者需要绕过CPU的场景下,RDMA的优势才明显。

最后说一句:MR是RDMA编程的基石,但也是最容易出问题的地方。每次遇到奇怪的错误,先检查MR的注册参数——地址对齐、大小、权限标志、PD是否匹配。这个习惯,能帮你省下至少一半的调试时间。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐