团团虾导读:RDMA 编程中最容易搞混的概念就是 lkey 和 rkey——它们都来自同一次 ibv_reg_mr() 调用,但一个留在本地,一个通过 etcd 传给对端。这篇从 registerLocalMemory 的源码链路出发,把内存注册→密钥分发→RDMA WR 构造→Worker Pool 消费这一整条路径完整走了一遍。最后讨论了 rkey 能不能独立映射内存这个常见误区。
阅读版本: Mooncake v0.3.10.post2-104-geaf724ab (commit eaf724ab, 2026-05-20)
RDMA 内存注册与 lkey/rkey 三元组
在阅读 Mooncake Transfer Engine 的 registerLocalMemory() 代码时,我们会看到这样一条链路:TransferEngineImpl 遍历所有已安装的 Transport,把用户传入的同一块内存注册到每一个 Transport 上。对于 RDMA Transport 而言,“注册”就是调用 ibv_reg_mr() 创建 Memory Region(MR),并从中拿到 lkey(Local Key) 和 rkey(Remote Key)。
本文从 Mooncake 源码出发,先理清这三个概念,然后完整追踪从内存注册到 RDMA 数据传输的全链路,最后讨论 “rkey 能不能独立映射内存” 这个问题。
一、lkey / rkey / MR 概念速览
| 概念 | 英文全称 | 属于谁 | 用途 |
|---|---|---|---|
| MR | Memory Region | 哪一方注册就属于哪一方 | 一块被 HCA 注册过的内存区域,只有 MR 内的地址才能被 RDMA 直接访问 |
| lkey | Local Key | 注册方自己的 | 本地 HCA 在工作请求(WR)中读写本地 MR 时出示的凭证 |
| rkey | Remote Key | 注册方自己,但交给远端 | 远端 HCA 在 RDMA READ/WRITE 时访问本地 MR 需要出示的凭证 |
| PD | Protection Domain | 本地 | QP 和 MR 必须属于同一个 PD 才能互访,提供安全隔离 |
核心事实:每次 ibv_reg_mr() 同时产生一个 lkey 和一个 rkey,它们出自同一个 struct ibv_mr:
// struct ibv_mr 的关键字段
mr->addr // 映射后的虚拟地址(iova-based MR 下不一定等于注册地址)
mr->lkey // 本地密钥
mr->rkey // 远程密钥
lkey 留给自己用,rkey 通过 metadata server 交换给对方用。
二、完整路径:从注册到传输
2.1 入口:TransferEngineImpl 遍历所有 Transport
// src/transfer_engine_impl.cpp:511-534
int TransferEngineImpl::registerLocalMemory(void* addr, size_t length,
const std::string& location,
bool remote_accessible,
bool update_metadata) {
// 1. 禁止重叠注册
if (checkOverlap(addr, length)) {
LOG(ERROR)
<< "Transfer Engine does not support overlapped memory region";
return ERR_ADDRESS_OVERLAPPED;
}
// 2. 零长度检查
if (length == 0) {
LOG(ERROR)
<< "Transfer Engine does not support zero length memory region";
return ERR_INVALID_ARGUMENT;
}
// 3. 遍历所有已安装的 Transport,逐个注册
for (auto transport : multi_transports_->listTransports()) {
int ret = transport->registerLocalMemory(
addr, length, location, remote_accessible, update_metadata);
if (ret < 0) return ret;
}
// 4. 记录到本地 map,防止重复注册
std::unique_lock<std::shared_mutex> lock(mutex_);
insertMemoryRegionLocked({addr, length, location, remote_accessible});
return 0;
}
listTransports() 就一行:遍历 transport_map_ 返回所有已安装 Transport 的裸指针。
// src/multi_transport.cpp:506-511
std::vector<Transport*> MultiTransport::listTransports() {
std::vector<Transport*> transport_list;
for (auto& entry : transport_map_)
transport_list.push_back(entry.second.get());
return transport_list;
}
所以如果同时安装了 rdma、tcp、cxl 三个 Transport,同一块内存会被注册三次——每个 Transport 各一次。
2.2 RdmaTransport:在每个 Context 上注册 MR 并收集密钥
// src/transport/rdma_transport/rdma_transport.cpp:183-304
int RdmaTransport::registerLocalMemoryInternal(void *addr, size_t length,
const std::string &name,
bool remote_accessible,
bool update_metadata,
bool force_sequential) {
BufferDesc buffer_desc;
// 设置访问权限
const int kBaseAccessRights = IBV_ACCESS_LOCAL_WRITE |
IBV_ACCESS_REMOTE_WRITE |
IBV_ACCESS_REMOTE_READ;
int access_rights = kBaseAccessRights;
if (MCIbRelaxedOrderingEnabled) {
access_rights |= IBV_ACCESS_RELAXED_ORDERING; // IBVERBS_1.8+
}
// 预触碰需同时满足三个条件:至少一个 Context、至少 4 个 CPU 核心、
// 且内存大小 >= 4GB。预触碰目的是加速后续 MR 注册。
bool do_pre_touch = context_list_.size() > 0 &&
std::thread::hardware_concurrency() >= 4 &&
length >= (size_t)4 * 1024 * 1024 * 1024;
if (do_pre_touch) {
int ret = preTouchMemory(addr, length);
if (ret != 0) return ret;
}
// 在每个 RdmaContext(每块物理网卡一个)上注册 MR
// 注:源码存在并行注册分支(MC_ENABLE_PARALLEL_REG_MR),
// 启用后为每个 Context 创建独立线程并发注册以加速。此处展示串行路径。
for (size_t i = 0; i < context_list_.size(); ++i) {
int ret = context_list_[i]->registerMemoryRegion(addr, length,
access_rights);
if (ret) return ret;
}
// 收集每个 Context 的 lkey 和 rkey
for (auto &context : context_list_) {
buffer_desc.lkey.push_back(context->lkey(addr));
buffer_desc.rkey.push_back(context->rkey(addr));
}
// buffer_desc.lkey[0] = NIC-0 的本地密钥
// buffer_desc.lkey[1] = NIC-1 的本地密钥
// buffer_desc.rkey[0] = NIC-0 的远程密钥
// buffer_desc.rkey[1] = NIC-1 的远程密钥
// 写入 metadata server,供远端节点查询
buffer_desc.addr = (uint64_t)addr;
buffer_desc.length = length;
metadata_->addLocalMemoryBuffer(buffer_desc, update_metadata);
}
关键点:lkey 和 rkey 是按 NIC 维度存储的 vector,每个 NIC(即每个 RdmaContext)都独立为同一块内存注册 MR 并生成一对 key。远端发起传输时,需要知道目标地址落在哪个 buffer 上、用哪个 NIC,然后取对应的 rkey[device_id]。
2.3 RdmaContext:真正调用 Verbs API
// src/transport/rdma_transport/rdma_context.cpp:225-338
int RdmaContext::registerMemoryRegionInternal(void *addr, size_t length,
int access,
MemoryRegionMeta &mrMeta) {
// CPU 内存 -- 直接注册
mrMeta.addr = addr;
mrMeta.mr = ibv_reg_mr(pd_, addr, length, access);
// GPU 内存 + nvidia-peermem -- 同上
// GPU 内存 + 无 nvidia-peermem -- 通过 dma-buf 注册
// cuMemGetAddressRange → cuMemGetHandleForAddressRange
// → ibv_reg_dmabuf_mr(pd_, dmabuf_offset, length, addr, dmabuf_fd, access)
}
成功后 mrMeta 存储 void *addr(原始虚拟地址)和 struct ibv_mr *mr。
2.4 lkey/rkey 的读取
// src/transport/rdma_transport/rdma_context.cpp:375-391
uint32_t RdmaContext::rkey(void *addr) {
RWSpinlock::ReadGuard guard(memory_regions_lock_);
auto iter = findMemoryRegionContaining(reinterpret_cast<uintptr_t>(addr));
if (iter != memory_region_map_.end()) return iter->second.mr->rkey;
LOG(ERROR) << "Address " << addr << " rkey not found for " << deviceName();
return 0;
}
uint32_t RdmaContext::lkey(void *addr) {
RWSpinlock::ReadGuard guard(memory_regions_lock_);
auto iter = findMemoryRegionContaining(reinterpret_cast<uintptr_t>(addr));
if (iter != memory_region_map_.end()) return iter->second.mr->lkey;
LOG(ERROR) << "Address " << addr << " lkey not found for " << deviceName();
return 0;
}
findMemoryRegionContaining() 对 std::map<uintptr_t, MemoryRegionMeta> 做 upper_bound 二分查找,支持从一段大 MR 内的任意子地址反查所属 MR。
2.5 BufferDesc 的数据结构
// include/transfer_metadata.h:52-65
struct BufferDesc {
std::string name; // NUMA 位置,如 "CPU_0"
uint64_t addr; // 起始地址
uint64_t length;
std::vector<uint32_t> lkey; // 每 NIC 一个本地密钥
std::vector<uint32_t> rkey; // 每 NIC 一个远程密钥
// ... 其他协议字段
};
2.6 传输阶段:组装 WR 并投递
Step 1 — 切片并设置 source_lkey (rdma_transport.cpp:464-582):
// submitTransferTask() 中
slice->rdma.dest_addr = request.target_offset + offset;
slice->rdma.source_lkey =
local_segment_desc->buffers[buffer_id].lkey[device_id];
Step 2 — 解析远端 rkey (worker_pool.cpp:143-144):
// WorkerPool::submitPostSend() 中,从远端 peer 的段描述中找到 rkey
slice->rdma.dest_rkey =
peer_segment_desc->buffers[buffer_id].rkey[device_id];
Step 3 — 组装 SGE + WR,调用 ibv_post_send (rdma_endpoint.cpp:613-640):
// SGE 描述本地内存(数据从哪里读 / 写到哪里)
sge.addr = (uint64_t)slice->source_addr;
sge.length = slice->length;
sge.lkey = slice->rdma.source_lkey; // ← 本地 lkey
// WR 描述远端操作
// 注:完整 WR 还含 wr_id、num_sge、send_flags、wr.next 等字段,
// 此处略去以聚焦 lkey/rkey 的两个三元组关系
wr.opcode = (slice->opcode == READ)
? IBV_WR_RDMA_READ
: IBV_WR_RDMA_WRITE;
wr.sg_list = &sge;
wr.wr.rdma.remote_addr = slice->rdma.dest_addr; // ← 远端地址
wr.wr.rdma.rkey = slice->rdma.dest_rkey; // ← 远端 rkey
// 投递到 QP
ibv_post_send(qp_list_[qp_index], wr_list.data(), &bad_wr);
三、RDMA 操作的两个三元组
上面的代码清楚地展示了一个事实:一次 RDMA READ 或 WRITE 需要两组参数,而不是一个三元组。
3.1 两个三元组
| 方位 | 三元组 | 对应代码 | 语义 |
|---|---|---|---|
| 本地 | (local_addr, length, lkey) | sge.addr / sge.length / sge.lkey | 描述本地内存位置和访问凭证 |
| 远端 | (remote_addr, length, rkey) | wr.wr.rdma.remote_addr / slice->length / wr.wr.rdma.rkey | 描述远端内存位置和访问凭证 |
以 RDMA READ 为例:
- 远端三元组
(remote_addr, length, rkey)告诉本地 HCA “从远端的哪个地址、读多长、出示哪个凭证” - 本地三元组
(local_addr, length, lkey)告诉本地 HCA “读回来的数据写到哪里、用哪个凭证”
以 RDMA WRITE 为例:
- 本地三元组
(local_addr, length, lkey)告诉本地 HCA “从本地的哪个地址读数据” - 远端三元组
(remote_addr, length, rkey)告诉本地 HCA “写到远端的哪个地址、出示哪个凭证”
3.2 Slice 中的 RDMA 字段
// include/transport/transport.h:117-127
union {
struct {
uint64_t dest_addr; // 远端地址
uint32_t source_lkey; // 本地 lkey
uint32_t dest_rkey; // 远端 rkey
int lkey_index;
int rkey_index;
volatile int *qp_depth;
uint32_t retry_cnt;
uint32_t max_retry_cnt;
} rdma;
// ... 其他传输协议的字段
};
可以看到 dest_addr、source_lkey、dest_rkey 都在同一 struct 里,而 source_addr 在外层的 Slice 结构体中,length 也在外层。这五个字段正好构成了两个三元组。
3.3 为什么 rkey 不能单独映射地址
rkey 的设计哲学是权限和地址分离:
- rkey 只证明权限:本地 HCA 拿 rkey 向远端 HCA 证明 “我有权访问你的这块 MR”
- remote_addr 和 length 管范围:指定具体访问 MR 内的哪个起始偏移、读/写多长
这三者是一一绑定在 Work Request 里的:
wr.wr.rdma.remote_addr = xxx; // 访问哪个地址(偏移)
wr.wr.rdma.rkey = yyy; // 出示哪个凭证(权限)
// 范围由 sge.length 指定
IB Verbs 规范没有 “用 rkey 解析出地址” 的 API。远端地址必须由应用层显式传递。在 Mooncake 中,这个地址就是 TransferRequest::target_offset,在切片时填入 slice->rdma.dest_addr。
四、端到端数据流
[本地进程] [远端进程]
| |
| registerLocalMemory(buf) | registerLocalMemory(buf)
| → ibv_reg_mr() | → ibv_reg_mr()
| → lkey=0x123, rkey=0xABC | → lkey=0x456, rkey=0xDEF
| → 存入 metadata server(两端都能读) | → 存入 metadata server
| |
| submitTransfer(WRITE) |
| 从 metadata 查询远端 SegmentDesc: |
| remote_addr = 远端 buf 地址 |
| dest_rkey = 0xDEF |
| |
| 组装 SGE: |
| {本地 addr, len, lkey=0x123} |
| 组装 WR: |
| {remote_addr, rkey=0xDEF} |
| |
| ibv_post_send(QP) ─────────────────→ │ HCA 直接 DMA 写入远端内存
| │ (远端 CPU 全程不参与)
| ← ibv_poll_cq() = COMPLETED ──────── │
核心文件索引
| 文件 | 行号 | 内容 |
|---|---|---|
mooncake-transfer-engine/src/transfer_engine_impl.cpp | 511-534 | 遍历所有 Transport 注册内存 |
mooncake-transfer-engine/src/multi_transport.cpp | 506-511 | listTransports() 实现 |
mooncake-transfer-engine/src/transport/rdma_transport/rdma_transport.cpp | 175-304 | RDMA 内存注册 + 密钥收集 |
mooncake-transfer-engine/src/transport/rdma_transport/rdma_context.cpp | 225-338 | ibv_reg_mr() / ibv_reg_dmabuf_mr() |
mooncake-transfer-engine/src/transport/rdma_transport/rdma_context.cpp | 375-391 | lkey() / rkey() 查询方法 |
mooncake-transfer-engine/src/transport/rdma_transport/rdma_context.cpp | 393-413 | findMemoryRegionContaining() |
mooncake-transfer-engine/src/transport/rdma_transport/rdma_transport.cpp | 464-582 | submitTransferTask() 切片 + source_lkey |
mooncake-transfer-engine/src/transport/rdma_transport/worker_pool.cpp | 143-144 | dest_rkey 从 peer 解析 |
mooncake-transfer-engine/src/transport/rdma_transport/rdma_endpoint.cpp | 613-640 | SGE/WR 组装 + ibv_post_send() |
mooncake-transfer-engine/include/transport/transport.h | 58-67 | TransferRequest 定义 |
mooncake-transfer-engine/include/transport/transport.h | 117-127 | Slice::rdma union 成员 |
mooncake-transfer-engine/include/transfer_metadata.h | 52-65 | BufferDesc 含 lkey[]、rkey[] vector |
五、总结
-
MR 注册一次,产出一对 key:
ibv_reg_mr()对一段内存地址范围创建 MR,返回的ibv_mr里同时有lkey和rkey。 -
lkey 留给自己,rkey 交给别人:本地 HCA 在 WR 中用 lkey 访问本地内存;远端 HCA 用 rkey 访问这块内存。Mooncake 通过 metadata server 交换
BufferDesc(含lkey[]/rkey[]vector)。 -
一次 RDMA 操作需要两个三元组:
- 本地侧:
(local_addr, length, lkey)— 通过 SGE 描述 - 远端侧:
(remote_addr, length, rkey)— 通过wr.rdma描述
- 本地侧:
-
rkey 不能独立映射地址:地址和权限分离是 RDMA 的核心设计。
rkey只提供访问凭证,remote_addr和length显式指定访问范围,三者共同完整描述一次远端内存访问。
修订说明
2026-05-26 修订,基于 review-notes.md 对 7 处简化进行修正:
- Section 2.1:补充
length == 0零长度检查及checkOverlap的LOG(ERROR)调用。 - Section 2.2:修正 pre-touch 触发条件,从仅
>= 4GB改为源码中的三条件(NIC 数、CPU 核心数、内存大小)。 - Section 2.2:标注源码存在
MC_ENABLE_PARALLEL_REG_MR并行注册分支。 - Section 2.2:补充
IBV_ACCESS_RELAXED_ORDERING标志位(IBVERBS_1.8+)。 - Section 2.4:恢复
lkey()/rkey()方法中的LOG(ERROR)调用。 - Section 2.6:标注 WR 省略字段(
wr_id、num_sge、send_flags、wr.next)。 - Section 2.2:修正多 NIC 系统的中文表述,消除歧义。