团团虾导读:一份 RDMA 操作模式的速查手册。先从 QP 和 CQ 的基础关系讲清楚 SQ、RQ、CQ 三者如何协作,然后逐一对比 Send/Recv(双边操作)和 Write/Read(单边操作)的差异——包括 CPU 参与程度、内存注册要求、通知机制。最后验证了 Mooncake 在两种路径下实际使用的操作模式。
阅读版本: Mooncake v0.3.10.post2-104-geaf724ab (commit eaf724ab, 2026-05-20)
目录
1. RDMA Queue Pair 与 Completion Queue 详解
1.1 CQ (Completion Queue) 到底是什么
CQ 是 Completion Queue(完成队列),它是 RDMA 异步通信模型中的完成通知容器。
工作机制:
RDMA 的通信是异步的。Work Request (WR) 被提交到 Send Queue 或 Receive Queue 后,硬件异步处理。处理完成后,硬件会产生一个 Completion Queue Entry (CQE) 写入 CQ,通知软件”这个 WR 已经完成了”。
软件通过 ibv_poll_cq() 从 CQ 中取出 CQE,确认哪些 WR 已完成。一个 CQ 可以同时服务多个 QP 的 Send Queue 和 Receive Queue。
两种获取完成通知的方式:
- 轮询 (polling):主动调用
ibv_poll_cq()取 CQE,低延迟但占 CPU - 事件通知:创建 Completion Channel,通过
ibv_get_cq_event()阻塞等待,适合低负载场景
一句话: CQ 是 RDMA 网卡向软件报告”某操作已完成”的队列,而”轮询”只是消费这个队列的一种方式。
1.2 QP (Queue Pair):SQ + RQ 都在本地
QP (Queue Pair) 包含两个工作队列,都在本地:
| 队列 | 方向 | 用途 |
|---|---|---|
| Send Queue (SQ) | 本地到远端 | 你往里面投 Send / RDMA Write / RDMA Read 请求 |
| Receive Queue (RQ) | 远端到本地 | 你往里面预投递 Receive Buffer,等待接收对端数据 |
“Pair” 指的是 SQ + RQ 这一对,不是你和对端的两端视角。你本地的 QP 和对端的 QP 配对,形成一条通信链路。
CQ 跟 QP 是两回事:
- CQ 只能消费(poll),硬件往里面写 CQE,软件从中取出完成通知
- 你往 SQ/RQ 提交任务,从 CQ 拿结果 — 提交和消费走的是不同的队列
关系示意:
你的 QP 远端 QP
+-----------------+ +-----------------+
| SQ - 提交请求 | ---> | RQ (收到) |
| RQ - 收到数据 | <--- | SQ (发送) |
+-----------------+ +-----------------+
| 完成通知
+-------+
| CQ | <-- 只 poll,不提交
+-------+
1.3 为什么 SQ 和 RQ 要分开
本质原因是 RDMA 通信是单端驱动的,两端角色不对称。
SQ 和 RQ 的使用方式完全不同:
| Send Queue (SQ) | Receive Queue (RQ) | |
|---|---|---|
| 谁写 WQE | 你自己 | 你自己 |
| 什么时机投 | 你想发数据时,主动投 | 提前预投,不知道对端什么时候发 |
| WQE 内容 | 包含要发送的数据地址、长度、类型 | 预先准备好空的接收缓冲区,等数据进来 |
核心矛盾在于:
如果你主动发 Send,对端必须提前在 RQ 里预投好了 Recv WQE,否则数据到了没地方放,报文会被丢弃(RNR 重传)。但对端并不知道你什么时候发、发多大数据。
所以 RQ 的策略是:预先投递一堆 Recv Buffer,不管发不发、什么时候发,先把接收位准备好。
如果 SQ 和 RQ 合并成一个队列会怎样:
没法工作。因为提交时机根本不同:
- Send WQE 是事件驱动的(“我现在要发这个数据”)
- Recv WQE 是预投的(“我不知道你什么时候发,先把 buffer 备好”)
混在一起的话,poll CQ 时你分不清是”我发的消息确认送达了”还是”收到了一个消息”。
一句话: SQ 和 RQ 分开,不是因为方向不同,而是因为生产者和时机不同 — SQ 由你要发数据的意图驱动,RQ 由”提前备战”的需求驱动。
1.4 RQ 必须一直保持水位吗
对,在使用 Send/Recv 操作时,这是 RDMA 可靠性保证的关键。
RQ 空了会怎样:
对端发过来的 Send 报文到了,你本地 RQ 里没有匹配的 Recv WQE -> RNR (Receiver Not Ready) -> 报文丢弃,对端重传。
RNR 重传很重:一次 NAK 触发 1 秒级别的退避(RNR timer),连续发生会直接崩掉连接。
所以策略是:
- 预投一批 Recv WQE — 应用启动时就往 RQ 投 N 个 buffer
- 随消费随补充 — poll CQ 拿到一个收到的消息,在处理完后立刻 repost 一个新的 Recv WQE,保持水位
- 水位多少合适? 取决于 QP 参数中
min_rnr_timer设置了对端等多长时间。一般撑 2~3 个 RTT 的突发量就够了,通常几十到几百个
重要例外:RDMA Write / Read 不需要 Recv Buffer!
只有 Send / Recv 操作需要预投 Recv WQE。RDMA Write 和 RDMA Read 直接操作远端注册的 Memory Region,不消费 RQ,对端 CPU 完全不感知。
2. Send/Recv vs Write/Read:Mooncake 的 RDMA 操作模式实践
2.1 Send/Recv(双边操作)
工作机制 — 两端 CPU 都参与:
发送端 接收端
主动发 Send WQE ------ 网络 -----> 匹配之前投递的 Recv WQE
(我知道发什么、发多少) (我已经提前把空 buffer 放好了)
- 发送端知道要发什么数据、发多长
- 接收端必须预投 Recv WQE,但不知道数据内容、不知道什么时候到
- 收到数据后,接收端 poll CQ 才知道”收到了什么、谁发的、多长”
- 对端 CPU 一定感知(要 poll CQ + 处理消息内容 + repost buffer)
典型场景: 控制面消息、应用层请求-响应、需要接收端处理数据内容的情况
2.2 RDMA Write(单边操作)
工作机制 — 只有发起端 CPU 参与:
发起端 远端
主动发 Write WQE ------ 网络 -----> 直接写入远端 MR
(我知道写什么、写到哪个地址) (CPU 完全不知道这事发生了)
- 发起端需要知道远端的目标内存地址和 r_key(在建连时通过 Send/Recv 交换)
- 远端 CPU 不感知 — 数据直接被 RDMA 网卡 DMA 到远端内存
- 不去消费远端的 RQ,不需要远端预投 Recv WQE
典型场景: 存储系统(客户端直接把数据写进服务端内存)、分布式 KV、大数据 shuffle
2.3 RDMA Read(单边操作)
工作机制 — 只有发起端 CPU 参与:
发起端 远端
主动发 Read WQE ------- 网络 -----> RDMA 网卡读远端 MR
数据直接 DMA 到本地内存 <--------- (CPU 完全不知道这事发生了)
- 发起端知道远端内存地址,直接拉数据到本地
- 远端 CPU 不感知
- 额外一步:Read 比 Write 多一次往返(请求 -> 远端读 -> 返回)
典型场景: 从远端内存直接读数据的场景、分布式共享内存
2.4 对比总结
| Send/Recv | RDMA Write | RDMA Read | |
|---|---|---|---|
| 远端 CPU 是否感知 | 感知(poll CQ) | 不感知 | 不感知 |
| 远端需要预配资源 | Recv WQE | Memory Region | Memory Region |
| 远端需要知道数据来了吗 | 必须 | 不用 | 不用 |
| 发起端需知道远端地址 | 不需要 | 需要 | 需要 |
| 消息边界 | 保留 | 不保留(流式) | 不保留 |
| 延迟 | 低 | 极低 | 中等(额外往返) |
实际使用中通常是组合:
建连阶段:Send/Recv 交换内存注册信息(MR addr + r_key)
数据面: RDMA Write/Read 做大规模数据传输
通知机制:Send(或 Write with Immediate)告诉对端"数据放好了/读完了"
一句话: Send/Recv 是”我和你商量着传”,两端都需要参与;Write/Read 是”我自己动手”,远端网卡默默干活,CPU 不感知。
2.5 只用 Write/Read 还需要 QP 和 CQ 吗
需要 QP,但 RQ 可以是空的;CQ 依然必不可少。
QP 必须存在:
QP 是 RDMA 通信的基本单位,没有 QP 就没有连接。Write/Read 请求是提交到 SQ 上的 — 没错,Write 和 Read 的 WQE 也投到 SQ 里,只是这些操作类型不需要远端 RQ 来匹配。RQ 可以永远是空的,但你得有一个 QP,QP 里得有一个 SQ。
所以 QP 不是简单的 Send/Recv 工具,而是所有 RDMA 操作的执行通道。
CQ 也必须存在:
Write/Read WQE 提交到 SQ 后,操作完成时硬件同样会产生 CQE:
- Write 完成 -> CQE 写入 CQ,告诉你数据已经写进远端内存了
- Read 完成 -> CQE 写入 CQ,告诉你数据已经拉回本地 buffer 了
你需要 poll CQ 来确认操作完成、回收本地 buffer、知道数据已可用。
Read 还有一层:在 CQE 出来之前,你本地 Read buffer 里的数据是无效的。
对比:
| 只用 Send/Recv | 只用 Write/Read | |
|---|---|---|
| QP 需要? | 需要 | 需要 |
| SQ 需要? | 需要 | 需要(Write/Read WQE 提交到这里) |
| RQ 需要预投? | 必须,否则 RNR | 不需要,RQ 为空也没事 |
| CQ 需要? | 需要,确认收发完成 | 需要,确认 Write/Read 完成 |
一句话: SQ 是”发任务”的入口,CQ 是”收结果”的出口,跟用什么操作类型无关。RQ 才是 Send/Recv 特有的。
2.6 Mooncake 实际使用的操作模式与代码验证
Mooncake 的核心场景是 LLM 推理的 KV-Cache 传输 — 需要在节点间高效搬运大块内存数据。它的设计完美契合了上文的理论分析。
数据传输(热路径):只用 RDMA Write 和 RDMA Read
数据面的目标就是单边操作 — 远端 CPU 完全不参与:
// 经典 TE: mooncake-transfer-engine/src/transport/rdma_transport/rdma_endpoint.cpp:623-625
wr.opcode = slice->opcode == Transport::TransferRequest::READ
? IBV_WR_RDMA_READ
: IBV_WR_RDMA_WRITE;
// TENT: mooncake-transfer-engine/tent/src/transport/rdma/endpoint.cpp:593-601
static ibv_wr_opcode getOpCode(RdmaSlice* slice) {
switch (slice->task->request.opcode) {
case Request::READ:
return IBV_WR_RDMA_READ;
case Request::WRITE:
return IBV_WR_RDMA_WRITE;
default:
return IBV_WR_RDMA_READ;
}
}
QP 配置验证:
- 经典 TE 中的数据 QP 创建 (
rdma_endpoint.cpp:73-81):sq_sig_all = false(不每个 WR 都发信号),qp_type = IBV_QPT_RC(可靠连接),没有 post recv buffer - TENT 中的数据 QP 创建 (
tent/endpoint.cpp:81-99):同样的IBV_QPT_RC,sq_sig_all = false,RQ 为空
CQ 轮询验证:
// 经典 TE: worker_pool.cpp:269-350, performPollCq()
// 工作线程在数据 CQ 上调用 ibv_poll_cq,处理 IBV_WC_SUCCESS 完成和错误重试
int nr_poll = context_.poll(kPollCount, wc, cq_index);
for (int i = 0; i < nr_poll; ++i) {
Transport::Slice *slice = (Transport::Slice *)wc[i].wr_id;
if (wc[i].status != IBV_WC_SUCCESS) {
// 错误处理:重试或标记失败
} else {
slice->markSuccess(); // 确认 Write/Read 操作完成
}
}
内存注册权限: 所有注册的内存区域(MR)授予以下权限 (rdma_transport.cpp:190-192):
IBV_ACCESS_LOCAL_WRITEIBV_ACCESS_REMOTE_WRITEIBV_ACCESS_REMOTE_READ
当 MCIbRelaxedOrderingEnabled 为 true 时,还会额外 OR 上 IBV_ACCESS_RELAXED_ORDERING(rdma_transport.cpp:195-197)。
注意:
IBV_ACCESS_REMOTE_ATOMIC不在 MR 访问权限中。它出现在 QP 访问标志 里(rdma_endpoint.cpp:768,qp_access_flags),控制的是 QP 的能力范围,和 MR 是两个不同的实体。MR 权限控制的是”这块内存允许远端做什么”,而 QP 访问标志控制的是”这个 QP 能发起什么操作”。
为什么选这个设计:
Mooncake 的场景决定了不需要 Send/Recv 做数据传输:
- 源端知道数据在哪儿(内存地址),目标端也知道该写到哪儿(预先通过 metadata RPC 交换过 MR + rkey)
- 不需要跟对端 CPU 商量,直接 DMA 过去就行,延迟最低
- Send/Recv 的双边开销(预投 buffer、CPU poll、处理消息)完全不必要
一句话: Mooncake 的数据面是纯单边 Write/Read,RQ 空转,SQ 投 Write/Read WQE,CQ 收完成通知。
2.7 TENT 版本:Send/Recv 通知通道
TENT(Mooncake 的下一代传输引擎)额外维护了一个独立的通知 QP,专门用于带外控制消息。这个通道才用传统的 Send/Recv。
为什么要通知通道:
数据面传输是不通知对端 CPU 的(远端不参与,不知道你写没写完)。但实际应用需要知道”这批数据传完了,你可以开始消费了”。
TENT 的做法是:应用在提交传输时附带一个 Notification 结构体。当那批任务全部完成后,TENT 自动通过 Send/Recv 通道把这个 Notification 推到对端。
节点 A(发起 Write) 节点 B(被写端)
1. RDMA Write 数据 -> 远端内存 (CPU 不感知)
2. 数据写完,CQ 确认
3. NOTIFY QP SEND(Notification) -------> 4. Recv 收到通知
5. "哦,数据到了,可以读了"
通知 QP 的配置(代码验证):
// tent/src/transport/rdma/endpoint.cpp:101-119
// 数据 QP 使用 notify_cq(独立 CQ,与数据 CQ 分开)
auto notify_cq = context_->notifyCq()->cq();
notify_attr.send_cq = notify_cq;
notify_attr.recv_cq = notify_cq;
notify_attr.sq_sig_all = true; // 通知要对每个 WR 发信号
notify_attr.qp_type = IBV_QPT_RC;
notify_attr.cap.max_send_wr = kNotifyMaxPendingSends; // 256
notify_attr.cap.max_recv_wr = kNotifyMaxPendingSends; // 256
通知 QP 的 RQ 需要预投 Recv Buffer(tent/endpoint.cpp:851-877):
// 连接建立后预投 256 个 Recv Buffer
void RdmaEndPoint::repostAllNotifyRecvs() {
for (size_t i = 0; i < kNotifyMaxPendingSends; ++i) {
postNotifyRecv(i);
}
}
// 每个 Recv Buffer 64KB
void RdmaEndPoint::postNotifyRecv(size_t idx) {
sge.addr = reinterpret_cast<uint64_t>(notify_recv_buffers_[idx].data());
sge.length = notify_recv_buffers_[idx].size(); // 64KB
sge.lkey = notify_recv_mrs_[idx]->lkey;
ibv_post_recv(notify_qp_, &wr, &bad_wr);
}
通知的发送(tent/endpoint.cpp:965-1031):
// 序列化格式: [name_len(4)][name][msg_len(4)][msg]
wr.opcode = IBV_WR_SEND; // 用 Send 操作
wr.send_flags = IBV_SEND_SIGNALED; // 确保生成 CQE
// 流量控制:最多 256 个 in-flight 发送
notify_send_cv_.wait(lock, [this] {
return notify_pending_count_ < kNotifyMaxPendingSends;
});
通知的接收与重新投递(tent/endpoint.cpp:1033-1078):
// CQ 轮询线程收到 IBV_WC_RECV 后
bool RdmaEndPoint::handleNotifyRecv(size_t buffer_idx, size_t byte_len) {
// 反序列化
uint32_t name_len = *reinterpret_cast<uint32_t*>(data);
std::string name(data + 4, name_len);
uint32_t msg_len = *reinterpret_cast<uint32_t*>(data + 4 + name_len);
std::string msg(data + 4 + name_len + 4, msg_len);
// 加入传输层队列供应用消费
context_->transport_.addNotificationToQueue(name, msg);
// 立刻 repost 这个 Recv Buffer,保持水位
postNotifyRecv(buffer_idx);
return true;
}
通知通道与数据通道的隔离:
- 通知 QP 有自己独立的 CQ(
notify_cq_),不和数据 QP 共享 - 通知消息由一个专用的轮询线程处理,与数据面的工作线程分开
- 轮询间隔固定为 10us(
notifyWorkerThread()在循环中调用usleep(notify_poll_interval_us_),无动态调整逻辑;kNotifyMinPollUs=100和kNotifyMaxPollUs=10000常量已定义但未被使用)
为什么走 RDMA 而不是 RPC 发通知:
TENT 的设计刻意把通知也走 RDMA — 降低延迟(不需要经过用户态到内核到网络协议栈的 RPC 调用),而且语义上跟数据传输强关联(“这批 RDMA 操作完了 -> 立刻在同一传输层通知对端”)。
一句话: TENT 的 Send/Recv 通道就是”数据写完后的敲门砖” — 跟理论设计完全一致:Write/Read 做数据面 + Send/Recv 做通知。
总结
| 概念 | 要点 |
|---|---|
| CQ | 完成通知队列,硬件写 CQE,软件 poll 获取 |
| QP | SQ+RQ 都本地,是 RDMA 所有操作的执行通道 |
| SQ/RQ 分离 | 时机不同:SQ 主动发,RQ 预投备战 |
| RQ 水位 | Send/Recv 必须保持,空了 RNR 丢包;Write/Read 不需要 |
| Send/Recv | 双边,两端 CPU 感知,RQ 必须预投 buffer |
| Write/Read | 单边,远端 CPU 不感知,RQ 可为空 |
| Mooncake 数据面 | 纯 Write/Read,RQ 空,SQ 提交,CQ 确认 |
| Mooncake 通知 | TENT 额外维护独立通知 QP,走 Send/Recv |
修订说明
- [2026-05-26] 修正 2.6 节 MR 访问权限错误:删除了错误归属给 MR 注册的
IBV_ACCESS_REMOTE_ATOMIC权限。该标志实际出现在 QP 访问标志(rdma_endpoint.cpp:768)中,与 MR 是不同的实体。同时补充了IBV_ACCESS_RELAXED_ORDERING的条件性添加(rdma_transport.cpp:195-197)。 - [2026-05-26] 修正 2.7 节轮询间隔描述:将”自适应(启动 10us,范围 100us-10ms)“改为”固定 10us”。
notifyWorkerThread()始终调用usleep(notify_poll_interval_us_),无任何动态调整逻辑。kNotifyMinPollUs和kNotifyMaxPollUs常量虽已定义但从未被使用。