3. RDMA通信模型

RDMA(Remote Direct Memory Access)的核心价值在于绕过内核、绕过CPU参与数据搬运,让网卡直接与应用程序内存交换数据。但“绕过内核”并不意味着通信语义可以被简化——恰恰相反,RDMA提供了比传统TCP/IP更丰富、更底层的操作原语。理解这些原语的语义边界、适用场景和性能特征,是编写正确、高效RDMA程序的前提。本章将系统拆解RDMA的三大类基本操作:发送/接收(Send/Recv)、读/写(Read/Write)以及原子操作(Atomic),并深入讨论它们与硬件队列、内存注册、完成事件之间的协作关系。

发起端 (Request) 应用缓冲区 App Buffer 内存注册 (MR) lkey / rkey 队列对 (QP) SQ (发送队列) RQ (接收队列) 完成队列 (CQ) 响应端 (Response) 应用缓冲区 App Buffer 内存注册 (MR) lkey / rkey 队列对 (QP) SQ (发送队列) RQ (接收队列) 完成队列 (CQ) RDMA 通信语义 Send/Recv · Read/Write · Atomic Send Recv Read (直接读取远端内存) Write (直接写入远端内存) 关键点 • Send/Recv 需要双方配对,消耗 RQ 资源 • Read/Write 单边操作,不消耗对端 RQ

上图展示了RDMA通信的核心架构:发起端与响应端各自维护独立的QP(队列对)和CQ(完成队列),通过InfiniBand或RoCE网络交换数据。Send/Recv操作需要两端配对,而Read/Write操作则允许单边访问远端内存。下面我们逐一深入分析每种语义。

3.1 发送/接收(Send/Recv):双向协作的可靠消息传递

核心原理

Send/Recv是RDMA中最接近传统消息传递模型的语义,但它与TCP的send/recv有本质区别。在RDMA中,接收端必须提前向RQ(接收队列)投递接收请求(RR,Receive Request),每个RR都绑定了一个预先注册好的内存缓冲区。当发送端的Send请求到达时,硬件自动将数据写入接收端最早投递的RR所指向的缓冲区。如果接收端没有预投递RR,发送端的Send请求会被丢弃(对于不可靠连接)或触发错误(对于可靠连接)。

这种“接收端预投递”机制带来了两个关键特性:

  • 零拷贝:数据从发送端应用缓冲区直接DMA到接收端应用缓冲区,中间不经过任何软件拷贝。
  • 流量控制:接收端通过控制RR的投递数量,天然实现了背压式流控。

操作流程

  1. 初始化阶段:两端分别创建QP、CQ、MR(内存注册),并交换QP的QPN(队列对编号)、LID(局部ID)等连接信息。
  2. 预投递RR:接收端调用ibv_post_recv(),将一个或多个WR(工作请求)投递到RQ,每个WR包含sge(散聚列表)指向已注册的缓冲区。
  3. 发送数据:发送端调用ibv_post_send(),指定操作类型为IBV_WR_SEND,并携带sge描述待发送数据。
  4. 完成通知:两端分别通过ibv_poll_cq()或ibv_get_cq_event()获取WC(完成队列元素),确认操作成功或失败。

关键代码片段(发送端):

struct ibv_send_wr wr, *bad_wr;
struct ibv_sge sge;
memset(&wr, 0, sizeof(wr));
sge.addr = (uint64_t)send_buf;
sge.length = msg_len;
sge.lkey = mr->lkey;
wr.wr_id = 1;
wr.sg_list = &sge;
wr.num_sge = 1;
wr.opcode = IBV_WR_SEND;
wr.send_flags = IBV_SEND_SIGNALED;
ibv_post_send(qp, &wr, &bad_wr);

案例拆解:RPC请求-响应模式

假设我们要实现一个基于RDMA的键值存储服务。客户端发送一个GET请求(包含key),服务端返回value。最直接的做法是:

  1. 服务端启动时预投递N个RR(例如N=64),每个RR指向一个固定大小的请求缓冲区。
  2. 客户端发送一个包含key的Send请求。
  3. 服务端从CQ中获取WC,解析key,查询本地数据库,然后将value通过Send发回客户端。
  4. 客户端预投递RR以接收响应。

这种模式简单可靠,但存在一个性能隐患:服务端必须持续补充RR。如果服务端处理速度跟不上请求到达速率,RQ中的RR耗尽,后续请求将被丢弃。因此,生产级实现通常采用“批量补充”策略:每次从CQ取出一批WC后,立即补充相同数量的RR。

常见误区:初学者容易混淆RDMA Send/Recv与Socket编程。在Socket中,接收方不需要预先准备缓冲区,数据到达时内核会分配内存。但在RDMA中,接收缓冲区必须由应用层提前注册并投递,否则数据无处可放。这是RDMA高性能的代价——所有内存管理责任都交给了应用。

3.2 读/写(Read/Write):单边操作与远端内存直接访问

核心原理

RDMA Read和Write是真正体现“远程直接内存访问”精髓的操作。与Send/Recv不同,Read/Write不需要接收端CPU参与,甚至不需要接收端预投递任何请求。发起端直接指定远端内存地址(通过rkey和虚拟地址),硬件网卡负责完成数据搬运。

特性 Send/Recv Read/Write
对端参与 必须双方配对 单边操作,对端CPU无感知
接收端RQ消耗 消耗RR 不消耗RR
内存访问权限 仅需本地lkey 需要远端rkey(远程访问权限)
典型场景 RPC、控制消息 大数据块传输、共享内存池
错误处理 对端可感知错误 对端完全无感知,错误仅本地可见

操作流程与关键参数

执行一次RDMA Write需要以下步骤:

  1. 建立连接并交换内存信息:远端必须将其内存区域的rkey和虚拟地址(va)告知发起端。这通常通过带外通道(如TCP)或Send/Recv完成。
  2. 构造WR:在ibv_send_wr中设置opcode = IBV_WR_RDMA_WRITE或IBV_WR_RDMA_READ,并通过wr.rdma.remote_addr和wr.rdma.rkey指定远端地址。
  3. 提交并轮询完成:调用ibv_post_send(),然后通过CQ确认操作完成。

关键代码片段(RDMA Write):

struct ibv_send_wr wr;
memset(&wr, 0, sizeof(wr));
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
ibv_post_send(qp, &wr, &bad_wr);

实战场景:分布式共享内存池

假设我们构建一个多节点机器学习训练系统,每个节点需要频繁读取其他节点的参数向量。使用RDMA Read,每个节点可以直接从远端内存中拉取最新参数,无需远端CPU介入。具体设计:

  • 每个节点将参数缓冲区注册为IBV_ACCESS_REMOTE_READ权限,并将rkey+va广播给所有对端。
  • 训练过程中,节点A需要参数时,直接发起RDMA Read到节点B的地址,数据在微秒级返回。
  • 节点B的CPU完全空闲,可以专注于计算。

这种模式将通信开销从“CPU中断+内存拷贝”降低为“纯DMA搬运”,延迟可降低一个数量级。但代价是内存一致性由应用层保证——如果节点B正在更新参数,节点A可能读到中间状态。解决方案包括:使用双缓冲(double buffering)或原子操作配合。

风险提示:RDMA Read/Write是“火枪手”操作——一旦远端地址或rkey错误,可能直接导致远端进程崩溃(访问非法地址)。务必在连接建立阶段严格校验内存信息,并在生产环境中使用独立的地址交换协议。

3.3 原子操作(Atomic):并发控制的硬件级原语

核心原理

RDMA提供了两类原子操作:FetchAndAdd(取后加)和CompareAndSwap(比较交换)。这些操作由网卡硬件在远端内存上直接执行,保证原子性,无需远端CPU参与。它们主要用于分布式锁、计数器、无锁数据结构等场景。

  • FetchAndAdd:原子地将远端内存值加上一个增量,并返回旧值。
  • CompareAndSwap:比较远端内存值与预期值,若相等则替换为新值,并返回旧值。

操作流程

  1. 远端内存必须注册为IBV_ACCESS_REMOTE_ATOMIC权限。
  2. 发起端构造WR,设置opcode = IBV_WR_ATOMIC_CMP_AND_SWP或IBV_WR_ATOMIC_FETCH_AND_ADD。
  3. 通过wr.atomic.remote_addr和wr.atomic.rkey指定目标地址,compare_add字段存放比较值或增量,swap字段存放新值。
  4. 操作完成后,旧值通过sge指向的本地缓冲区返回。

案例拆解:分布式锁实现

考虑一个多节点竞争同一资源的场景。使用CompareAndSwap实现自旋锁:

  1. 锁变量初始化为0(未锁定)。
  2. 节点尝试CAS(0, 1):如果返回0,说明获取锁成功;如果返回1,说明锁被占用。
  3. 获取失败后,节点可以轮询(反复CAS)或使用FetchAndAdd实现排队锁。

这种实现完全绕过了远端CPU,锁获取延迟仅取决于网络往返时间(通常1-3微秒)。但需要注意:原子操作只能作用于单个64位对齐的内存地址,无法实现复杂的锁语义(如读写锁)。

经验总结:原子操作虽然强大,但并非万能。在RDMA中,原子操作的性能远高于Send/Recv实现的锁,但仍低于本地原子操作。设计分布式算法时,应尽量减少原子操作次数,优先使用批量Read/Write。

3.4 三种语义的对比与选型

维度 Send/Recv Read/Write Atomic
通信模式 双向配对 单边主动 单边主动
对端CPU负载 需要处理完成事件 零负载 零负载
延迟(典型) 2-5 μs 1-3 μs 1-2 μs
带宽利用率 高(但受RR数量限制) 最高(无接收端瓶颈) 低(单次操作仅8字节)
编程复杂度 中(需管理RR池) 高(需管理远端地址) 高(需处理并发竞争)
典型应用 RPC、控制面 数据面、共享内存 分布式锁、计数器

选型建议

  • 消息长度 < 1KB且需要确认:优先Send/Recv,语义清晰,易于调试。
  • 消息长度 > 4KB且对端可暴露内存:使用RDMA Write,减少接收端CPU开销。
  • 需要读取远端最新数据:使用RDMA Read,避免发送请求-响应往返。
  • 需要并发控制:使用Atomic,但注意仅限64位标量。

3.5 常见误区与总结复盘

误区一:认为RDMA Write不需要接收端任何准备。实际上,接收端必须提前将内存注册为IBV_ACCESS_REMOTE_WRITE权限,并且该内存区域的生命周期必须覆盖整个通信过程。如果接收端提前释放了内存,发起端的Write操作将导致不可预测的错误。

误区二:忽略完成事件的处理。即使RDMA操作由硬件完成,应用层仍需通过CQ获取完成通知,以确认操作成功并释放缓冲区。如果忘记轮询CQ,可能导致内存泄漏或缓冲区重用错误。

误区三:在不可靠连接(UD)上使用Read/Write。UD(Unreliable Datagram)只支持Send/Recv,不支持Read/Write和Atomic。如果需要在RoCE上使用单边操作,必须使用RC(Reliable Connection)或XRC(Extended Reliable Connection)。