3、RDMA编程模型:Verbs API、Queue Pair、Completion Queue、Work Request的基本工作流程

RDMA(Remote Direct Memory Access)之所以能实现微秒级延迟与数十Gbps带宽的数据传输,其核心在于将网络I/O的控制平面与数据平面彻底分离。应用程序通过一套名为Verbs的标准化API与RDMA网卡(HCA,Host Channel Adapter)交互,而数据搬运则由硬件直接完成。理解这一编程模型,关键在于掌握三个核心对象:负责描述“做什么”的Work Request(WR)、负责承载通信上下文的Queue Pair(QP)、以及负责异步通知“做完了”的Completion Queue(CQ)。本章将深入拆解这三者的协作机制,并完整梳理从提交请求到完成收割的端到端工作流程。

应用进程 (发送/接收缓冲区) Verbs API ibv_post_send / ibv_post_recv Queue Pair (QP) Send Queue (SQ) Receive Queue (RQ) QP状态: RESET→INIT→RTR→RTS QP Number (QPN) 标识 关联CQ: send_cq / recv_cq Completion Queue (CQ) CQ Entry (CQE) wr_id / status / opcode ibv_poll_cq 轮询 ibv_req_notify_cq 事件 RDMA网卡 (HCA) DMA引擎 数据搬运 协议卸载 ibv_post_send 完成信号 硬件处理 核心工作流程 ① 创建QP/CQ ibv_create_qp/cq ② 注册MR ibv_reg_mr ③ 提交WR ibv_post_send/recv ④ 硬件DMA 网卡直接读写内存 ⑤ 收割CQE ibv_poll_cq

上图展示了RDMA编程模型的核心组件与数据流路径。应用通过Verbs API将工作请求(WR)投递到QP的发送队列(SQ)或接收队列(RQ),RDMA网卡硬件从队列中取走请求并执行DMA操作,完成后在CQ中生成完成队列条目(CQE),应用通过轮询或事件通知收割结果。整个过程CPU仅在提交与收割阶段介入,数据路径完全由硬件接管。

3.1 Verbs API:控制平面的统一接口

核心原理:Verbs API是RDMA编程的事实标准接口,最早源自OpenFabrics Alliance(OFA)定义的软件栈,目前由Linux内核的InfiniBand子系统(drivers/infiniband)和用户态库libibverbs共同实现。它屏蔽了不同厂商HCA的硬件差异,向上提供统一的操作原语。Verbs API分为两类:管理类(创建/销毁QP、CQ、MR、AH等资源)和数据路径类(post_send、post_recv、poll_cq等热路径操作)。管理类调用通常涉及内核态系统调用,而数据路径类经过优化后可直接通过用户态驱动(如mlx5的uverbs)写入硬件门铃(Doorbell),避免上下文切换。

关键Verbs函数清单:

函数原型功能调用时机
ibv_create_qp()创建队列对,指定SQ/RQ深度、QP类型(RC/UC/UD)连接建立前
ibv_modify_qp()状态机迁移:RESET→INIT→RTR→RTS连接握手阶段
ibv_create_cq()创建完成队列,指定CQ深度连接建立前
ibv_reg_mr()注册内存区域,获取lkey/rkey缓冲区分配后
ibv_post_send()向SQ投递发送WR(RDMA读/写/SEND)数据发送前
ibv_post_recv()向RQ投递接收WR,预备接收缓冲区数据接收前
ibv_poll_cq()从CQ轮询收割完成条目(非阻塞)等待完成时
ibv_req_notify_cq()请求CQ事件通知(阻塞或信号驱动)等待完成时
经验提醒:在性能敏感的数据路径中,应避免在每次I/O时调用管理类Verbs(如ibv_modify_qp)。正确做法是在连接初始化阶段一次性完成QP状态迁移,数据路径只使用ibv_post_send、ibv_post_recv和ibv_poll_cq这三个热函数。

3.2 Queue Pair:通信的端点抽象

核心原理:QP是RDMA通信的基本端点,一个QP由一对队列组成:发送队列(Send Queue, SQ)和接收队列(Receive Queue, RQ)。SQ承载三种操作类型:SEND(双边操作,需要对端RQ配合)、RDMA READ(单边读操作,直接读取对端内存)、RDMA WRITE(单边写操作,直接写入对端内存)。RQ则只承载RECV操作,用于接收SEND消息。QP的类型决定了通信语义:RC(可靠连接)提供有序、可靠的双向字节流,UC(不可靠连接)提供无序但可能丢包的传输,UD(不可靠数据报)支持多对一通信但报文长度受限。

QP状态机与迁移流程:

  1. RESET(复位态):QP刚创建时的初始状态,SQ和RQ均为空,不参与任何通信。
  2. INIT(初始化态):通过ibv_modify_qp将QP迁移至INIT,此时可向RQ投递接收WR,但SQ仍不可用。需要指定QP的PKEY、QKEY、访问权限(本地写、远程读/写等)。
  3. RTR(Ready to Receive):迁移至RTR后,QP可以接收对端发来的SEND或RDMA WRITE请求。必须配置对端的QPN、LID/GID等路径信息。
  4. RTS(Ready to Send):最终状态,SQ被激活,可以发起SEND、RDMA READ/WRITE操作。同时可设置超时重传参数(timeout、retry_count)。
风险提示:QP状态迁移必须严格按顺序执行,且RTR→RTS的迁移需要双方配合完成。如果对端尚未进入RTR状态就发送数据,会导致连接错误(IBV_WC_WR_FLUSH_ERR)。在RC模式下,错误QP会被自动迁移到ERROR状态,必须重新初始化才能恢复。

3.3 Completion Queue:异步完成的收割通道

核心原理:CQ是RDMA异步模型的关键组件。每个WR被硬件处理完毕后,HCA会在关联的CQ中生成一个CQE(Completion Queue Entry)。CQE包含的关键信息有:wr_id(用户自定义的请求标识)、status(成功或错误码)、opcode(完成的操作类型)、byte_len(实际传输字节数)。应用通过ibv_poll_cq轮询CQ获取CQE,或者通过ibv_req_notify_cq注册事件通知。一个CQ可以被多个QP共享,但一个QP的SQ和RQ必须关联到不同的CQ(或同一CQ的不同队列)。

CQ操作流程:

  • 创建:ibv_create_cq(verbs, cqe_num, context, channel, comp_vector),其中cqe_num决定CQ深度,channel用于事件通知。
  • 轮询收割:ibv_poll_cq(cq, num_entries, wc_array),返回实际收割的CQE数量。轮询是忙等操作,适合低延迟场景。
  • 事件通知:ibv_req_notify_cq(cq, solicited_only),注册后当新CQE到达时通过ibv_get_cq_event获取通知,适合高吞吐场景。
  • 错误处理:检查wc.status,若不为IBV_WC_SUCCESS,需根据错误码(如IBV_WC_REMOTE_INV_REQ、IBV_WC_LOC_LEN_ERR)定位问题。
性能调优要点:CQ深度应设置为QP深度之和的1.5~2倍,避免CQ溢出导致丢完成事件。轮询时建议批量收割(如一次poll 64个CQE),减少函数调用开销。在NUMA架构下,CQ和QP应分配在同一内存节点,避免跨节点访问延迟。

3.4 Work Request:描述数据搬运的指令

核心原理:WR是应用提交给QP的最小工作单元。每个WR通过struct ibv_send_wr(发送)或struct ibv_recv_wr(接收)描述。发送WR的核心字段包括:wr_id(完成时回传)、opcode(IBV_WR_SEND/RDMA_WRITE/RDMA_READ)、sg_list(散列表,描述本地内存地址与长度)、num_sge(散表项数量)。对于RDMA操作,还需指定wr.rdma.remote_addr(对端虚拟地址)和wr.rdma.rkey(对端内存区域的远程键)。

WR提交与完成流程:

// 发送侧示例:提交RDMA WRITE
struct ibv_send_wr wr, *bad_wr = NULL;
struct ibv_sge sge;
struct ibv_send_wr *wr_list[1];

memset(&wr, 0, sizeof(wr));
sge.addr = (uint64_t)local_buf;      // 本地缓冲区地址
sge.length = data_len;               // 数据长度
sge.lkey = mr->lkey;                 // 本地内存区域键

wr.wr_id = 1001;                     // 自定义ID
wr.opcode = IBV_WR_RDMA_WRITE;
wr.sg_list = &sge;
wr.num_sge = 1;
wr.send_flags = IBV_SEND_SIGNALED;   // 需要完成通知
wr.wr.rdma.remote_addr = remote_addr; // 对端地址
wr.wr.rdma.rkey = remote_rkey;       // 对端rkey

wr_list[0] = ≀
ibv_post_send(qp, wr_list, &bad_wr);  // 提交到SQ

// 完成收割
struct ibv_wc wc;
while (ibv_poll_cq(cq, 1, &wc) == 0); // 忙等
if (wc.status == IBV_WC_SUCCESS) {
    // 处理完成,wc.wr_id == 1001
}

关键设计决策:

  • 信号标志(Signaled vs Unsignaled):设置IBV_SEND_SIGNALED的WR完成后会生成CQE,未设置的WR不生成CQE。批量发送时,通常只在最后一个WR上设置信号标志,减少CQ压力。
  • 散列表(SGL):支持将不连续的内存区域组合成一次传输,避免内存拷贝。但SGE数量过多会增加硬件解析开销,建议控制在4~8个以内。
  • 内联数据(Inline):对于小于max_inline_data(通常256字节)的SEND,可将数据直接嵌入WR,避免DMA读取,显著降低小消息延迟。

3.5 完整工作流程与实战案例

端到端流程拆解:以RC模式下的一次RDMA WRITE为例,完整流程如下:

阶段操作涉及APICPU参与
1. 资源创建创建QP、CQ、MR,注册内存ibv_create_qp/cq, ibv_reg_mr是(管理路径)
2. 连接建立交换QPN、LID/GID、rkey等信息ibv_modify_qp (RTR/RTS)是(握手)
3. 预备接收对端预先投递RECV WR(若使用SEND)ibv_post_recv是(提交)
4. 提交发送构造WR并调用ibv_post_sendibv_post_send是(提交)
5. 硬件执行HCA读取WR,DMA搬运数据到对端硬件自动否(零拷贝)
6. 完成通知HCA写CQE到CQ硬件自动否
7. 收割完成轮询或事件获取CQEibv_poll_cq / ibv_get_cq_event是(收割)

实战案例:基于RDMA WRITE的分布式键值存储

某内存数据库系统需要将热点数据从计算节点同步到存储节点,数据块大小为4KB,要求端到端延迟低于10微秒。初始方案使用TCP Socket,实测延迟约35微秒,CPU占用率高达40%(主要消耗在数据拷贝和协议栈处理)。改用RDMA WRITE后:

  1. 初始化阶段:双方各注册4KB对齐的内存池(ibv_reg_mr),存储节点将接收缓冲区地址和rkey通过带外通道(TCP)发送给计算节点。
  2. 数据路径:计算节点直接构造RDMA WRITE WR,指定对端地址和rkey,调用ibv_post_send提交。存储节点无需任何CPU参与,数据由HCA直接写入其内存。
  3. 完成收割:计算节点轮询CQ确认写入完成,存储节点通过另一个QP的SEND消息通知应用数据已就绪。
  4. 结果对比:端到端延迟降至7.2微秒(降低79%),CPU占用率降至5%以下。但代价是需要额外的内存注册开销(约2微秒/次),且存储节点无法感知写入完成(需额外通知机制)。
常见误区与复盘:① 误以为RDMA WRITE不需要对端参与,实际上对端必须提前注册MR并提供rkey,且对端内存不能被回收或换页;② 忽略max_sge和max_inline_data限制,导致WR提交失败;③ 在CQ收割时未检查wc.status,导致错误被静默吞掉;④ 将QP深度设置过大(如1M),导致内存占用过高且硬件队列溢出。正确的做法是:根据实际并发度设置QP深度(通常128~1024),CQ深度为QP深度的2倍,并启用IBV_SEND_SIGNALED按批次确认完成。

总结:RDMA编程模型的核心在于将“数据搬运”这一最耗时的操作卸载到硬件,而CPU只负责“描述意图”和“确认结果”。理解QP的状态机、CQ的异步通知机制、WR的灵活构造,是编写高性能RDMA应用的基础。在实际工程中,建议先用ibv_devinfo确认硬件能力(如max_qp_wr、max_sge),再结合具体业务场景选择合适的操作类型(SEND vs RDMA WRITE/READ),最后通过批量提交、内联数据、事件通知等技巧进一步优化性能。