Skip to content
团子云技术 Lite 1.048596
Go back

Mooncake TE 阅读手记-15-RDMA QP/CQ 与操作模式

团团虾导读:一份 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 详解
  2. Send/Recv vs Write/Read:Mooncake 的 RDMA 操作模式实践

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。

两种获取完成通知的方式:

  1. 轮询 (polling):主动调用 ibv_poll_cq() 取 CQE,低延迟但占 CPU
  2. 事件通知:创建 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 是两回事:

关系示意:

你的 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 合并成一个队列会怎样:

没法工作。因为提交时机根本不同:

混在一起的话,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),连续发生会直接崩掉连接。

所以策略是:

  1. 预投一批 Recv WQE — 应用启动时就往 RQ 投 N 个 buffer
  2. 随消费随补充 — poll CQ 拿到一个收到的消息,在处理完后立刻 repost 一个新的 Recv WQE,保持水位
  3. 水位多少合适? 取决于 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 放好了)

典型场景: 控制面消息、应用层请求-响应、需要接收端处理数据内容的情况

2.2 RDMA Write(单边操作)

工作机制 — 只有发起端 CPU 参与:

发起端                              远端
  主动发 Write WQE  ------ 网络 ----->  直接写入远端 MR
  (我知道写什么、写到哪个地址)          (CPU 完全不知道这事发生了)

典型场景: 存储系统(客户端直接把数据写进服务端内存)、分布式 KV、大数据 shuffle

2.3 RDMA Read(单边操作)

工作机制 — 只有发起端 CPU 参与:

发起端                              远端
  主动发 Read WQE  ------- 网络 ----->  RDMA 网卡读远端 MR
  数据直接 DMA 到本地内存  <---------  (CPU 完全不知道这事发生了)

典型场景: 从远端内存直接读数据的场景、分布式共享内存

2.4 对比总结

Send/RecvRDMA WriteRDMA Read
远端 CPU 是否感知感知(poll CQ)不感知不感知
远端需要预配资源Recv WQEMemory RegionMemory 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:

你需要 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 配置验证:

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):

MCIbRelaxedOrderingEnabled 为 true 时,还会额外 OR 上 IBV_ACCESS_RELAXED_ORDERINGrdma_transport.cpp:195-197)。

注意: IBV_ACCESS_REMOTE_ATOMIC 在 MR 访问权限中。它出现在 QP 访问标志 里(rdma_endpoint.cpp:768qp_access_flags),控制的是 QP 的能力范围,和 MR 是两个不同的实体。MR 权限控制的是”这块内存允许远端做什么”,而 QP 访问标志控制的是”这个 QP 能发起什么操作”。

为什么选这个设计:

Mooncake 的场景决定了不需要 Send/Recv 做数据传输:

一句话: 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;
}

通知通道与数据通道的隔离:

为什么走 RDMA 而不是 RPC 发通知:

TENT 的设计刻意把通知也走 RDMA — 降低延迟(不需要经过用户态到内核到网络协议栈的 RPC 调用),而且语义上跟数据传输强关联(“这批 RDMA 操作完了 -> 立刻在同一传输层通知对端”)。

一句话: TENT 的 Send/Recv 通道就是”数据写完后的敲门砖” — 跟理论设计完全一致:Write/Read 做数据面 + Send/Recv 做通知。


总结

概念要点
CQ完成通知队列,硬件写 CQE,软件 poll 获取
QPSQ+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

修订说明


Share this post on:

Previous Post
Mooncake TE 阅读手记-16-路径选择与 Peer NIC Path
Next Post
Mooncake TE 阅读手记-14-RDMA 内存注册与 lkey/rkey