一、概述
我们上一篇博客介绍了RDMA的相关概念,本博客在此基础之上,使用 verbs 编程,实现 RDMA 文件传输(演示代码已开源(Github:rdma-sendfile)。
如果你还不了解QP、WR、MR等 RDMA基本要素,可以先花五分钟看看RDMA要素介绍。
RDMA有IB、RoCE、iWARP三种协议,然而,上层的用户接口都是Verbs,Verbs 可以看作HCA对主机提供的RDMA功能接口。
RDMA 文件传输主要涉及两个部分的编码:
- 控制消息的传递
- 文件数据的传输
RDMA 提供了单边模式、双边模式两种工作模式,对应send/recv、write/read 四种操作,它们的特点和使用场景我们可以总结如下:
| 维度 | 双边操作(Send/Recv) | 单边操作(RDMA Read/Write) |
|---|---|---|
| 远端CPU参与 | 需要(预先发布Recv WR) | 不需要(完全无感) |
| 完成通知 | 双方都产生CQE | 仅发起方产生CQE |
| 需要的信息 | 只需本地缓冲区 | 需要远端地址 + rkey |
| 适用消息大小 | 小消息(控制类) | 大消息(数据类) |
| 典型应用 | 控制消息、元数据交换 | 批量数据传输 |
所以,在我们的 RDMA 文件传输助手实现中,控制消息的传递使用双边模式 send/recv,而数据传输则使用单边的 write。主要的思路是,先建链,并使用双边模式传递控制消息,然后复用链路进行单边模式 write 文件传输。
二、控制消息传递
控制消息: remote_addr、rkey
要搞清楚控制消息传递如何实现,我们先要搞清楚所谓的控制消息到底有什么?我们最终需要通过单边模式write来实现大批量的数据传输,主机写入远端内存的之前,需要知道远端内存缓冲区地址和rkey ,这也就是RDMA文件传输助手中最关键的控制消息。
双边模式并不需要提供远端地址和rkey就可以进行通信,只需要远端预先发布recv WR,我们可以根据这个特点进行元数据交换,然后主机根据拿到的元数据,进行单边write操作。
RDMA 的 send和recv 的工作流程如下:

- 初始化:双方打开RDMA设备,分配PD、注册MR并获取对应的lkey,创建QP、CQ等
c
//建链与初始化
static void initialize_connection(struct rdma_cm_id *cm_id) {
struct ibv_pd *pd = ibv_alloc_pd(cm_id->verbs); //分配PD
struct ibv_comp_channel *channel = ibv_create_comp_channel(cm_id->verbs); //创建完成事件通道
struct ibv_cq *cq = ibv_create_cq(cm_id->verbs, 1024, NULL, channel, 0); //创建CQ
struct ibv_qp_init_attr qp_attr = {
.send_cq = cq,
.recv_cq = cq,
.qp_type = IBV_QPT_RC,
.cap = {
.max_send_wr = 1024,
.max_recv_wr = 1024,
.max_send_sge = 1,
.max_recv_sge = 1
}
};
rdma_create_qp(cm_id, pd, &qp_attr); //创建QP
conn_manger_t *conn = calloc(1, sizeof(*conn));
conn->pd = pd;
conn->qp = cm_id->qp;
conn->cq = cq;
conn->channel = channel;
posix_memalign((void **)&conn->recv_buffer, 4096, CTRL_BUF_SIZE);
posix_memalign((void **)&conn->send_buffer, 4096, CTRL_BUF_SIZE);
//注册MR
conn->recv_mr = ibv_reg_mr(pd, conn->recv_buffer, CTRL_BUF_SIZE,
IBV_ACCESS_LOCAL_WRITE | IBV_ACCESS_REMOTE_WRITE);
conn->send_mr = ibv_reg_mr(pd, conn->send_buffer, CTRL_BUF_SIZE,
IBV_ACCESS_LOCAL_WRITE | IBV_ACCESS_REMOTE_READ);
cm_id->context = conn;
}
- 远端预先建构并发布recv WR,并将recv WR推入RQ中
c
static int post_recv(conn_manger_t *conn) {
//构建recv WR
struct ibv_sge sge;
memset(&sge, 0, sizeof(sge));
sge.addr = (uintptr_t)conn->recv_buffer;
sge.length = CTRL_BUF_SIZE;
sge.lkey = conn->recv_mr->lkey; //lkey用于操作本地的MR
struct ibv_recv_wr recv_wr, *bad_recv_wr = NULL;
memset(&recv_wr, 0, sizeof(recv_wr));
recv_wr.wr_id = (uintptr_t)conn;
recv_wr.sg_list = &sge;
recv_wr.num_sge = 1;
conn->recv_outstanding = 1;
//将recv WR推送到RQ
if (ibv_post_recv(conn->qp, &recv_wr, &bad_recv_wr)) {
conn->recv_outstanding = 0;
perror("ibv_post_recv");
return -1;
}
return 0;
}
说明:SGE(Scatter/Gather Element),用于向硬件描述一块内存区域:
c
struct ibv_sge {
uint64_t addr; // 内存缓冲区的起始虚拟地址
uint32_t length; // 缓冲区长度(字节数)
uint32_t lkey; // 本地内存密钥(由注册MR时获得)
};
- 本机构建send WR(包含控制消息),将send WR推入SQ中
c
static int post_send(conn_manger_t *conn, uint32_t length) {
if (length == 0 || length > CTRL_BUF_SIZE)
return -1;
//构建send WR
struct ibv_sge sge;
memset(&sge, 0, sizeof(sge));
sge.addr = (uintptr_t)conn->send_buffer;
sge.length = length;
sge.lkey = conn->send_mr->lkey; //lkey操作本地MR
struct ibv_send_wr send_wr, *bad_send_wr = NULL;
memset(&send_wr, 0, sizeof(send_wr));
send_wr.wr_id = (uintptr_t)conn;
send_wr.opcode = IBV_WR_SEND; //操作码:IBV_WR_SEND
send_wr.send_flags = IBV_SEND_SIGNALED;
send_wr.sg_list = &sge;
send_wr.num_sge = 1;
conn->send_outstanding = 1;
//将send WR推送到SQ
if (ibv_post_send(conn->qp, &send_wr, &bad_send_wr)) {
conn->send_outstanding = 0;
perror("ibv_post_send");
return -1;
}
return wait_send_done(conn);
}
- RDMA硬件完成通信,产生WC
- 本机和远端轮询CQ,回收WC
c
static int poll_cq_batch(conn_manger_t *conn) {
struct ibv_wc wcs[POLL_BATCH];
int n = ibv_poll_cq(conn->cq, POLL_BATCH, wcs); //轮询CQ,获取一批WC
//遍历检查WC状态
for (int i = 0; i < n; i++) {
struct ibv_wc *wc = &wcs[i];
if (wc->status != IBV_WC_SUCCESS) {
conn->send_outstanding = 0;
conn->recv_outstanding = 0;
conn->write_inflight = 0;
continue;
}
//操作码对应
if (wc->opcode == IBV_WC_RDMA_WRITE) { //这个部分是单边write 消费WC用的,先不需要管
int credits = (int)wc->wr_id;
if (credits <= 0)
credits = 1;
conn->write_inflight -= credits;
if (conn->write_inflight < 0)
conn->write_inflight = 0;
} else if (wc->opcode == IBV_WC_SEND) { //WC回收,计数清除
conn->send_outstanding = 0;
} else if (wc->opcode & IBV_WC_RECV) { //同上
conn->recv_outstanding = 0;
}
}
return n;
}
三、文件数据传输
我们通过双边模式交换控制消息(远端地址、rkey等)之后,现在主机(发送端)拥有了足够的信息执行单边write了,前面我们已经介绍了双边模式下使用的函数,其中许多都可以重复使用,比如PD分配与MR的注册、轮询CQ等。
单边数据write的过程如下:

- 分配 PD,注册待传输数据的 MR
- 用 chunks 批量构建send WR
将 MR 切成很多个 chunks(用SGE向RDMA硬件描述每块 chunk 的内存区域),将已经取得的远端地址remote_addr和rkey等写入WR中,批量推入SQ,多个WR inflight。
c
static int post_rdma_file(conn_manger_t *conn) {
size_t offset = 0;
size_t size = conn->src_size;
uint32_t lkey = conn->src_mr->lkey;
//用chunks批量构建WR
while (offset < size) {
//write窗口满了
while (conn->write_inflight >= WR_WINDOW) {
cq_wait_progress(conn);
}
// 计算本次分块长度
size_t remain = size - offset;
uint32_t len = (remain > CHUNK_SIZE) ? CHUNK_SIZE : (uint32_t)remain;
struct ibv_sge sge = {
.addr = (uint64_t)(uintptr_t)(conn->src_buf + offset),
.length = len,
.lkey = lkey
};
struct ibv_send_wr send_wr = {
.wr_id = 1,
.opcode = IBV_WR_RDMA_WRITE, //操作码是IBV_WR_RDMA_WRITE
.send_flags = IBV_SEND_SIGNALED,
.sg_list = &sge,
.num_sge = 1,
.wr.rdma = {
.remote_addr = conn->remote_addr + offset,
.rkey = conn->remote_rkey
}
};
conn->write_inflight++;
ibv_post_send(conn->qp, &send_wr, NULL);
offset += len;
}
while (conn->write_inflight > 0) {
if (cq_wait_progress(conn) < 0)
return -1;
}
return 0;
}
- RDMA硬件完成发送,产生WC
- 轮询CQ,回收WC
c
//poll_cq_batch() 中处理单边write的分支
if (wc->opcode == IBV_WC_RDMA_WRITE) {
int credits = (int)wc->wr_id;
if (credits <= 0)
credits = 1;
conn->write_inflight -= credits; //inflight的减去完成的WC数目
if (conn->write_inflight < 0)
conn->write_inflight = 0;
}
四、测试
我使用了两台虚拟机,使用siw(Soft-iWARP) 。siw 是 Linux 内核中一个纯软件实现的 RDMA 设备驱动,它允许你在标准以太网卡上运行 RDMA 应用,无需专用的 RDMA 网卡硬件进行测试
以下结果仅供参考:
| 测试场景 | 吞吐量 | 结论 |
|---|---|---|
跨机 siw RDMA(官方 ib_write_bw) |
~12 MB/s | siw 跨机性能上限,受限于软件模拟 TCP/IP 协议栈 |
| 跨机 siw RDMA(demo, 8MB WRITE) | ~9 MiB/s | 与官方基准同一量级,代码实现无明显性能缺陷 |