在数据中心领域,远程直接内存访问(英语:remote direct memory access,RDMA)是一种绕过远程主机操作系统内核访问其内存中数据的技术,由于不经过操作系统,不仅节省了大量CPU资源,同样也提高了系统吞吐量、降低了系统的网络通信延迟,尤其适合在大规模并行计算机集群中有广泛应用。在基于NVMe over Fabric的数据中心中,RDMA可以配合高性能的NVMe SSD构建高性能、低延迟的存储网络。
Red Hat和甲骨文公司等软件供应商已经在其最新产品中支持这些API,截至2013年,工程师也已开始开发基于以太网的RDMA网络适配器。Red Hat Enterprise Linux和Red Hat Enterprise MRG已支持RDMA。微软已在Windows Server 2012中通过SMB Direct支持RDMA。
RDMA 原理
传统的基于Socket套接字(TCP/IP协议栈)的网络通信需要经过操作系统协议栈。数据在系统中搬来搬去,因此占用了大量的CPU和内存资源,也加大了网络延时。RDMA解决了传统Socket通信的痛点,采用了Kernel Bypass的工作方式,减少了CPU和内存的占用,也降低了网络延时。
目前RDMA有三种不同的硬件实现,分别是InfiniBand、iWARP(internet wide area RDMA Protocol)、RoCE(RDMA over Coverged Ethernet)。
- Infiniband
支持RDMA的新一代网络协议。 由于这是一种新的网络技术,因此需要支持该技术的NIC和交换机。 - RoCE
一个允许在以太网上执行RDMA的网络协议。 其较低的网络标头是以太网标头,其较高的网络标头(包括数据)是InfiniBand标头。 这支持在标准以太网基础设施(交换机)上使用RDMA。 只有网卡应该是特殊的,支持RoCE。
RoCE v1是一种链路层协议,允许在同一个广播域下的任意两台主机直接访问。
RoCE v2是一种Internet层协议,即可以实现路由功能。 - iWARP
一个允许在TCP上执行RDMA的网络协议。 IB和RoCE中存在的功能在iWARP中不受支持。 这支持在标准以太网基础设施(交换机)上使用RDMA。
关键概念
** QP(Queue Pair) **
每对QP由Send Queue(SQ)和Receive Queue(RQ)构成,这些队列中管理着各种类型的消息。QP会被映射到应用的虚拟地址空间,使得应用直接通过它访问RNIC网卡。
** CQ(Complete Queue) **
- 完成队列包含了发送到工作队列(WQ)中已完成的工作请求(WR)。每次完成表示一个特定的 WR执行完毕(包括成功完成的WR和不成功完成的WR)。完成队列是一个用来告知应用程序已结束的工作请求的信息(状态、操作码、大小、来源)的机制。
- CQ有n个完成队列实体(CQE)。CQE的数量在CQ创建的时候被指定。
- 当一个CQP被轮询到,它就从CQ中被删除。
- CQ是一个CQE的先进选出(FIFO)队列。
- CQ能服务于发送队列、接收队列或者同时服务于这两种队列。多个不同QP中的工作请求(WQ)可联系到同一个CQ上。
** MR(Memory Region) **
- 内存注册机制允许应用程序申请一些连续的虚拟内存空间或者连续的物理内存空间,将这些内存空间提供给网络适配器作为虚拟的连续缓冲区,缓冲区使用虚拟地址。
- 内存注册进程锁定了内存页。(为了防止页被替换出去,同时保持物理和虚拟内存的映射)在注册期间,操作系统检查被注册块的许可。注册进程将虚拟地址与物理地址的映射表写入网络适配器。在注册内存时,对应内存区域的权限会被设定。权限包括本地写、远程读、远程写、原子操作、绑定。
- 每个内存注册(MR)有一个远程的和一个本地的key(r_key,l_key)。本地key被本地的HCA 用来访问本地内存,例如在接收数据操作的期间。远程key提供给远程HCA用来在RDMA操作期间允许远程进程访问本地的系统内存。同一内存缓冲区可以被多次注册(甚至设置不同的操作权限),并且每次注册都会生成不同的key。
** HCA **
- Opening an HCA 打开HCA,准备好HCA供消费者使用。一旦打开了一个HCA设备,只有关闭它以后,才能再次打开。
- HCA属性 HCA属性是设备特征,这些属性必须可以被消费者获取。
- 修改HCA属性 HCA允许修改一组==受限制的==HCA属性。这些可以修改的属性主要是性能信息和错误计数器管理性息。其他大部分属性或是不可修改的,或是通过General Services Interface / Fabric Management Interface进行操作。
- 关闭HCA 将HCA恢复到初始条件下,同时注销打开HCA时分配的资源。
** 寻址 **
- 源端地址 CI(Channel Interface)需要存储每个HCA有效的LID和GID。
- 目的地址 对于RC服务类型来说,目的地址被保存在本地QP的属性中。
- Loopback 由于自寻址的需要,HCA需要支持Loopback。Loopback仅支持于一个HCA中,同一个端口下的QP之间进行。
** Protection Domain **
- PD通过在QP/SRQ与MR之间建立联系,获得HCA访问主存的权限。此外,PD还可以用来关联QP和未绑定的内存窗口,用来控制HCA访问主系统内存。
- 分配保护域 当创建QP,注册MR,分配MW,创建Address Handle时需要分配PD。
- 释放保护域 如果PD仍然与任何队列对、内存区域、内存窗口、SRQ或地址句柄相关联,则不应释放它。如果尝试这样做,则谓词将立即返回一个错误。
RDMA 工作流程
- 当一个应用执行RDMA读或写请求时,不执行任何数据复制。在不需要任何内核内存参与的条件下,RDMA请求从运行在用户空间中的应用中发送到本地NIC(网卡)。
- NIC读取缓冲的内容,并通过网络传送到远程NIC。
- 在网络上传输的RDMA信息包含目标虚拟地址、内存钥匙和数据本身。请求既可以完全在用户空间中处理(通过轮询用户级完成排列) ,又或者在应用一直睡眠到请求完成时的情况下通过系统中断处理。RDMA操作使应用可以从一个远程应用的内存中读数据或向这个内存写数据。
- 目标NIC确认内存钥匙,直接将数据写人应用缓存中。用于操作的远程虚拟内存地址包含在RDMA信息中。
RDMA API
RDMA API (Verbs)主要有两种操作方式,One-Sided RDMA。包括RDMA Reads, RDMA Writes, RDMA Atomic。这种模式下的RDMA访问完全不需要远端机的任何确认;Two-Sided RDMA。包括RDMA Send, RDMA Receive。这种模式下的RDMA访问需要远端机CPU的参与。
Two-Side RDMA
- 首先,A和B都要创建并初始化好各自的QP,CQ
- A和B分别向自己的WQ中注册WQE,对于A,WQ=SQ,WQE描述指向一个等到被发送的数据;对于B,WQ=RQ,WQE描述指向一块用于存储数据的Buffer。
- A的RNIC异步调度轮到A的WQE,解析到这是一个SEND消息,从Buffer中直接向B发出数据。数据流到达B的RNIC后,B的WQE被消耗,并把数据直接存储到WQE指向的存储位置。
- AB通信完成后,A的CQ中会产生一个完成消息CQE表示发送完成。与此同时,B的CQ中也会产生一个完成消息表示接收完成。每个WQ中WQE的处理完成都会产生一个CQE。
** 双边操作与传统网络的底层Buffer Pool类似,收发双方的参与过程并无差别,区别在零拷贝、Kernel Bypass,实际上对于RDMA,这是一种复杂的消息传输模式,多用于传输短的控制消息。 **
RDMA通信流程
- 获取RDMA设备列表(ibv_get_device_list)
1
2
3
4
5
6
7
8
9/* 1 获取设备列表 */
int num_devices;
struct ibv_device **dev_list = ibv_get_device_list(&num_devices);
if (!dev_list || !num_devices)
{
fprintf(stderr, "failed to get IB devices\n");
rc = 1;
goto main_exit;
} - 打开一个RDMA设备,获取一个上下文(ibv_open_device ibv_context)
1
2
3
4
5
6
7
8
9/* 2 打开设备,获取设备上下文 */
struct ibv_device *ib_dev = dev_list[0];
res.ib_ctx = ibv_open_device(ib_dev);
if (!res.ib_ctx)
{
fprintf(stderr, "failed to open device \n");
rc = 1;
goto main_exit;
} - 释放RDMA设备列表占用的资源(ibv_free_device_list)
1
2
3
4/* 3 释放设备列表占用的资源 */
ibv_free_device_list(dev_list);
dev_list = NULL;
ib_dev = NULL; - 查询RDMA设备端口信息(ibv_query_port ibv_port_attr)
1
2
3
4
5
6
7/* 4 查询设备端口状态 */
if (ibv_query_port(res.ib_ctx, 1, &res.port_attr))
{
fprintf(stderr, "ibv_query_port on port failed\n");
rc = 1;
goto main_exit;
} - 分配一个Protection Domain (ibv_alloc_pd ibv_pd)
1
2
3
4
5
6
7
8/* 5 创建PD(Protection Domain) */
res.pd = ibv_alloc_pd(res.ib_ctx);
if (!res.pd)
{
fprintf(stderr, "ibv_alloc_pd failed\n");
rc = 1;
goto main_exit;
} - 创建一个Complete Queue (ibv_create_cq ibv_cq)
1
2
3
4
5
6
7
8
9/* 6 创建CQ(Complete Queue) */
int cq_size = 10;
res.cq = ibv_create_cq(res.ib_ctx, cq_size, NULL, NULL, 0);
if (!res.cq)
{
fprintf(stderr, "failed to create CQ with %u entries\n", cq_size);
rc = 1;
goto main_exit;
} - 注册一块Memory Region (ibv_reg_mr ibv_mr)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21/* 7 注册MR(Memory Region) */
int size = MSG_SIZE;
res.buf = (char *)malloc(size);
if (!res.buf)
{
fprintf(stderr, "failed to malloc %Zu bytes to memory buffer\n", size);
rc = 1;
goto main_exit;
}
memset(res.buf, 0, size);
int mr_flags = IBV_ACCESS_LOCAL_WRITE | IBV_ACCESS_REMOTE_READ | IBV_ACCESS_REMOTE_WRITE;
res.mr = ibv_reg_mr(res.pd, res.buf, size, mr_flags);
if (!res.mr)
{
fprintf(stderr, "ibv_reg_mr failed with mr_flags=0x%x\n", mr_flags);
rc = 1;
goto main_exit;
}
fprintf(stdout, "MR was registered with addr=%p, lkey=0x%x, rkey=0x%x, flags=0x%x\n",
res.buf, res.mr->lkey, res.mr->rkey, mr_flags); - 创建一个Queue Pair (ibv_create_qp ibv_qp)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19/* 8 创建QP(Queue Pair) */
struct ibv_qp_init_attr qp_init_attr;
memset(&qp_init_attr, 0, sizeof(qp_init_attr));
qp_init_attr.qp_type = IBV_QPT_RC;
qp_init_attr.sq_sig_all = 1;
qp_init_attr.send_cq = res.cq;
qp_init_attr.recv_cq = res.cq;
qp_init_attr.cap.max_send_wr = 1;
qp_init_attr.cap.max_recv_wr = 1;
qp_init_attr.cap.max_send_sge = 1;
qp_init_attr.cap.max_recv_sge = 1;
res.qp = ibv_create_qp(res.pd, &qp_init_attr);
if (!res.qp)
{
fprintf(stderr, "failed to create QP\n");
rc = 1;
goto main_exit;
}
fprintf(stdout, "QP was created, QP number=0x%x\n", res.qp->qp_num); - 交换控制信息 (使用Socket)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25/* 9 交换控制信息 */
struct cm_con_data_t local_con_data; // 发送给远程主机的信息
struct cm_con_data_t remote_con_data; // 接收远程主机发送过来的信息
struct cm_con_data_t tmp_con_data;
local_con_data.addr = htonll((uintptr_t)res.buf);
local_con_data.rkey = htonl(res.mr->rkey);
local_con_data.qp_num = htonl(res.qp->qp_num);
local_con_data.lid = htons(res.port_attr.lid);
if (sock_sync_data(server_ip, sizeof(struct cm_con_data_t), (char *)&local_con_data, (char *)&tmp_con_data) < 0)
{
fprintf(stderr, "failed to exchange connection data between sides\n");
rc = 1;
goto main_exit;
}
remote_con_data.addr = ntohll(tmp_con_data.addr);
remote_con_data.rkey = ntohl(tmp_con_data.rkey);
remote_con_data.qp_num = ntohl(tmp_con_data.qp_num);
remote_con_data.lid = ntohs(tmp_con_data.lid);
/* save the remote side attributes, we will need it for the post SR */
res.remote_props = remote_con_data;
fprintf(stdout, "Remote address = 0x%" PRIx64 "\n", remote_con_data.addr);
fprintf(stdout, "Remote rkey = 0x%x\n", remote_con_data.rkey);
fprintf(stdout, "Remote QP number = 0x%x\n", remote_con_data.qp_num);
fprintf(stdout, "Remote LID = 0x%x\n", remote_con_data.lid); - 转换QP状态(ibv_modify_qp)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44/* 10 转换QP状态 */
// RESET -> INIT
struct ibv_qp_attr attr;
int flags;
memset(&attr, 0, sizeof(attr));
attr.qp_state = IBV_QPS_INIT;
attr.port_num = 1; // IB 端口号
attr.pkey_index = 0;
attr.qp_access_flags = IBV_ACCESS_LOCAL_WRITE | IBV_ACCESS_REMOTE_READ | IBV_ACCESS_REMOTE_WRITE;
flags = IBV_QP_STATE | IBV_QP_PKEY_INDEX | IBV_QP_PORT | IBV_QP_ACCESS_FLAGS;
rc = ibv_modify_qp(res.qp, &attr, flags);
if (rc)
fprintf(stderr, "failed to modify QP state to INIT\n");
//INIT -> RTR(Ready To Receive)
memset(&attr, 0, sizeof(attr));
attr.qp_state = IBV_QPS_RTR;
attr.path_mtu = IBV_MTU_256;
attr.dest_qp_num = res.remote_props.qp_num;
attr.rq_psn = 0;
attr.max_dest_rd_atomic = 1;
attr.min_rnr_timer = 0x12;
attr.ah_attr.is_global = 0;
attr.ah_attr.dlid = res.remote_props.lid;
attr.ah_attr.sl = 0;
attr.ah_attr.src_path_bits = 0;
attr.ah_attr.port_num = 1;
flags = IBV_QP_STATE | IBV_QP_AV | IBV_QP_PATH_MTU | IBV_QP_DEST_QPN | IBV_QP_RQ_PSN | IBV_QP_MAX_DEST_RD_ATOMIC | IBV_QP_MIN_RNR_TIMER;
rc = ibv_modify_qp(res.qp, &attr, flags);
if (rc)
fprintf(stderr, "failed to modify QP state to RTR\n");
//RTR -> RTS(Ready To Send)
memset(&attr, 0, sizeof(attr));
attr.qp_state = IBV_QPS_RTS;
attr.timeout = 0x12;
attr.retry_cnt = 6;
attr.rnr_retry = 0;
attr.sq_psn = 0;
attr.max_rd_atomic = 1;
flags = IBV_QP_STATE | IBV_QP_TIMEOUT | IBV_QP_RETRY_CNT | IBV_QP_RNR_RETRY | IBV_QP_SQ_PSN | IBV_QP_MAX_QP_RD_ATOMIC;
rc = ibv_modify_qp(res.qp, &attr, flags);
if (rc)
fprintf(stderr, "failed to modify QP state to RTS\n");- 状态:RESET -> INIT -> RTR -> RTS
- 要严格按照顺序进行转换
- QP刚创建时状态为RESET
- INIT之后就可以调用ibv_post_recv提交一个receive buffer了
- 当QP进入RTR(ready to receive)状态以后,便开始进行接收处理
- RTR之后便可以转为RTS(ready to send),RTS状态下可以调用ibv_post_send
- 创建发送任务/接收任务(ibv_send_wr/ibv_recv_wr)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23/* 11 创建发送任务ibv_send_wr */
struct ibv_send_wr sr;
struct ibv_sge sge;
struct ibv_send_wr *bad_wr = NULL;
int rc;
/* prepare the scatter/gather entry */
memset(&sge, 0, sizeof(sge));
sge.addr = (uintptr_t)res->buf;
sge.length = MSG_SIZE;
sge.lkey = res->mr->lkey;
/* prepare the send work request */
memset(&sr, 0, sizeof(sr));
sr.next = NULL;
sr.wr_id = 0;
sr.sg_list = &sge;
sr.num_sge = 1;
sr.opcode = opcode;
sr.send_flags = IBV_SEND_SIGNALED;
if (opcode != IBV_WR_SEND)
{
sr.wr.rdma.remote_addr = res->remote_props.addr;
sr.wr.rdma.rkey = res->remote_props.rkey;
}- 该任务会被提交到QP中的SQ(Send Queue)中
- 发送任务有三种操作:Send,Read,Write。Send操作需要对方执行相应的Receive操作;Read/Write直接操作对方内存,对方无感知。
- 把要发送的数据的内存地址,大小,密钥告诉HCA
- Read/Write还需要告诉HCA远程的内存地址和密钥
- 提交发送任务/接收任务(ibv_post_send/ibv_post_recv)
1
2
3
4rc = ibv_post_send(res->qp, &sr, &bad_wr);
if (rc)
fprintf(stderr, "failed to post SR\n");
return rc; - 轮询任务完成信息(ibv_poll_cq)
1
2
3
4
5
6
7
8/* 13 轮询任务结果 */
struct ibv_wc wc;
int poll_result;
int rc = 0;
do
{
poll_result = ibv_poll_cq(res->cq, 1, &wc);
} while (poll_result == 0);
QP状态转换
Reset State
- 该状态为QP新创建时的初始状态
- 在不删除QP的情况下,仅能通过Modify Queue Pair Attributes verb跳出该状态
- 该状态下向QP提交WR,将会返回错误,远端到来的消息也会直接被忽略
- 通过利用verbs修改QP属性,可以将任何状态的QP转换为Reset状态。
Initialized State
- 仅能从Reset状态进入该状态
- 在不删除QP的情况下,仅能通过Modify Queue Pair Attributes verb跳出该状态
- 该状态下,RQ可以接受WR,但不会处理远端到来的消息,并将到来的消息丢弃
- 该状态下,向SQ发送WR会返回错误。
Ready To Receive(RTR) State
- 在RTR状态下,RQ可以接受WR,从远端到来的消息也会正常处理
- 该状态下,向SQ发送WR会返回错误。
Ready To Send(RTS) State
- 在RTS状态下,请求端和应答端面向连接的服务类型的通道已经建立
- 仅能由RTR和SQD状态进入该状态
- 在不删除QP的情况下,仅能通过Modify Queue Pair Attributes verb跳出该状态
- 该状态下,向QP发送的WR会被正常处理,发送WR的verb也不会返回错误
- 该状态下,从远端到来的消息也正常处理。
Send Queue Drain(SQD) State
- 该状态下,向QP发送的WR会被正常处理,发送WR的verb也不会返回错误
- 该状态下,从远端到来的消息也正常处理
- 仅能由RTS进入该状态
- 当转移到该状态时,未处理的消息不能再处理,未处理完的消息必须处理完
- 当所有应答都已收到时,如果有事件提醒的请求,则会生成一个附加的异步事件
a. 消费者可以利用异步事件来确定状态转移的完成
b. 为确保安全的修改QP的属性,必须在接收到异步事件后在进行属性的更改。 - 该状态下,提交到QP的WR会入队,但不会被处理
- 在SQ还没有Drained之前,SQD到RTS的状态转换不被允许,在该状况下转移,CI会报告一个立即的错误。
Send Queue Error(SQE) State
- 该状态适用于除RC QP之外的所有QP
- 该状态仅会由RTS跳入,当处理SQ的WR时,发生完成错误(Completion Error),会造成该转移
- 在该状态下,RQ可以接受WR,从远端到来的消息也会正常处理
- 发生了完成错误的WR必须通过CQ返回正确的完成错误码
- 由于SQ中的WR可能部份或全部执行,因此,接收端的状态是未知的
- 在SQ中导致完成错误的WR的下一条WR,必须通过CQ返回刷新错误(Flush Error)的完成状态
- 该状态下,可以通过Modify Queue Pair Attributes verb跳到RTS状态、Reset状态、Error状态。
Error State
- QP上的所有正常处理全部停止
- 由于WR发生完成错误,导致跳入该状态时,必须通过CQ返回完成错误码
- 该状态下,从远端收到的数据会被丢弃
- 在QP中导致完成错误的WR的下一条WR,必须通过CQ返回刷新错误(Flush Error)的完成状态。
One-Side RDMA
- 首先A、B建立连接,QP已经创建并且初始化。
- 数据被存档在A的buffer地址VA,注意VA应该提前注册到A的RNIC,并拿到返回的r_key,相当于RDMA操作这块buffer的权限。
- A把数据地址VA,key封装到专用的报文传送到B,这相当于A把数据buffer的操作权交给了B。同时A在它的WQ中注册进一个WR,以用于接收数据传输的B返回的状态。
- B在收到A的送过来的数据VA和r_key后,RNIC会把它们连同存储地址VB到封装RDMA READ,这个过程A、B两端不需要任何软件参与,就可以将A的数据存储到B的VB虚拟地址。
- B在存储完成后,会向A返回整个数据传输的状态信息。
** 单边操作传输方式是RDMA与传统网络传输的最大不同,只需提供直接访问远程的虚拟地址,无须远程应用的参与其中,这种方式适用于批量数据传输。 **