RDMA技术整理4 RDMA编程入门

连通测试

物理拓扑:服务器6和8之间有一根直连网线;两台服务器各有一张cx5网卡,支持RoCe

  1. 使用ibdev2netdev查看设备:

    image-20220426191053641

  2. 使用show_gids看看网卡支持的RoCe版本

    image-20220426191319616

    这里的IPv4地址是通过nmtui进行配置的

  3. 服务器8根据如上的信息使用ib_send_bw -d mlx5_1 -x 2,作为信号的接收端;另一台服务器6使用sudo ib_send_bw -d mlx5_3 192.168.42.8 --report_gbits -F -x 2进行信息的发送;(5_3是服务器6里面有ipv4地址并且up的设备)

  4. 测试结果:

    接收端:

    image-20220426191807687

    发送端:

    image-20220426191822803

RDMA 常用命令

参考自https://blog.csdn.net/bandaoyu/article/details/115798693

命令 操作 备注
ibv_devinfo 显示device信息(简略)
ibv_devinfo mlx5_0 显示设备mlx5_0的详细信息
ibv_devinfo -v 显示网卡信息(详细)
ibv_devices 列出device
ibvdev2netdev 显示device和网口的对应关系 mellonx的命令,intel的需要阅读用户说明自己根据他们的脚本编写类似的命令
show_gids 显示gid列表 mellonx的命令,intel的需要阅读用户说明自己根据他们的脚本编写类似的命令
show_drop 查看端口包丢弃情况 mellonx的命令,intel的需要阅读用户说明自己根据他们的脚本编写类似的命令
ibstatus 查看核更改网卡工作模式:Ethernet 或infiniband模式
ibv_asyncwatch 监视 InfiniBand 异步事件
iblinkinfo.pl 或 iblinkinfo 显示光纤网络中所有链路的链路信息
sminfo 用法sminfo –help;查询 IB SMInfo 属性
ibstat 或 ibsysstat 查询 InfiniBand 设备状态或 IB 地址上的系统状态
hca_self_test.ofed RDMA网卡自测 mellonx
/etc/infiniband/info Mellanox OFED 安装的信息 mellonx
cat /etc/infiniband/openib.conf 看自动加载的模块列表 mellonx
`lspci grep Mellanox` 检查Mellanox网卡是否安装和版本

RDMA 编程入门

RDMA 编程入门1

这部分来自the-geek-in-the-corner01_basic-client-server

在上述配置中的运行方法和连通实验类似:

  1. 在两个服务器使用make;

  2. 服务器8作为server使用./server,程序告知端口号是36436;

  3. 服务器6作为client使用./client 192.168.42.8 36436;这里的IP地址和上面连通测试使用的IP地址一样

  4. 运行结果:

    server8,服务端:

    1
    2
    3
    4
    5
    6
    7
    temp@R750-427Server8:~/worker/the-geek-in-the-corner/01_basic-client-server$ ./server
    listening on port 36436.
    received connection request.
    received message: message from active/client side with pid 21871
    connected. posting send...
    send completed successfully.
    peer disconnected.

    server6,客户端:

    1
    2
    3
    4
    5
    6
    7
    temp@R750-427Server6:~/worker/the-geek-in-the-corner/01_basic-client-server$ ./client 192.168.42.8 36436
    address resolved.
    route resolved.
    connected. posting send...
    send completed successfully.
    received message: message from passive/server side with pid 14356
    disconnected.

    这部分代码的目标是连接两个应用程序,让他们能够交换数据。这部分的关键是QP pair和CP,连接的每一端都有发送-接受队列和一个完成队列。构建队列并且对他们相互连接的步骤如下:

    • 创建保护域(关联队列对、完成队列、内存注册等)、完成队列和发送-接收队列对。
    • 确定队列对的地址。
    • 将地址传送到另一个节点(通过某些带外机制)。
    • 将队列对转换为“随时可以接收”(RTR) 状态,然后转换为“准备发送”(RTS) 状态。
    • 根据需要发布发送、接收等操作

    主动端(请求端)和被动端(响应端)的具体步骤如下:

    被动端:

    1. 创建一个事件通道,以便我们可以接收 rdmacm 事件,例如连接请求和连接建立的通知。
    2. 绑定到地址。
    3. 创建侦听器并返回端口/地址。
    4. 等待连接请求。
    5. 创建保护域、完成队列和发送-接收队列对。
    6. 接受连接请求。
    7. 等待建立连接。
    8. 根据需要发布操作。

    主动端:

    1. 创建一个事件通道,以便我们可以接收 rdmacm 事件,例如地址解析、路由解析和连接建立的通知。
    2. 创建连接标识符。
    3. 解析对等方的地址,这会将连接标识符绑定到本地 RDMA 设备。
    4. 创建保护域、完成队列和发送-接收队列对。
    5. 解析到对等方的路由。
    6. 连接。等待建立连接。
    7. 根据需要发布操作。

双方将共享相当数量的代码 - 被动端的步骤 1、5、7 和 8 大致相当于主动端的步骤 1、4、7 和 8。一旦建立了连接,与套接字一样,双方都是对等的。利用连接需要我们在队列对上发布操作。接收操作(不出所料)发布在接收队列上。在发送队列上,我们发布发送请求、RDMA 读/写请求和原子操作请求。


Passive/Server side

上面交代了被动端设置的部分,现在是详细的代码部分。因为几乎所有的内容都是异步处理的;因此这项工作首先需要构建一个事件处理循环和一组时间处理器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <rdma/rdma_cma.h>

#define TEST_NZ(x) do { if ( (x)) die("error: " #x " failed (returned non-zero)." ); } while (0)
#define TEST_Z(x) do { if (!(x)) die("error: " #x " failed (returned zero/null)."); } while (0)

static void die(const char *reason);

int main(int argc, char **argv)
{
return 0;
}

void die(const char *reason)
{
fprintf(stderr, "%s\n", reason);
exit(EXIT_FAILURE);
}

接下来设置一个事件通道,创建一个ramdacm ID(相当于套接字),绑定之后再循环中等待事件,这部分是修改main()函数:

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
static void on_event(struct rdma_cm_event *event);

int main(int argc, char **argv)
{
struct sockaddr_in addr;
struct rdma_cm_event *event = NULL;
struct rdma_cm_id *listener = NULL;
struct rdma_event_channel *ec = NULL;
uint16_t port = 0;

memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;

TEST_Z(ec = rdma_create_event_channel());
TEST_NZ(rdma_create_id(ec, &listener, NULL, RDMA_PS_TCP));
TEST_NZ(rdma_bind_addr(listener, (struct sockaddr *)&addr));
TEST_NZ(rdma_listen(listener, 10)); /* backlog=10 is arbitrary */

port = ntohs(rdma_get_src_port(listener));

printf("listening on port %d.\n", port);

while (rdma_get_cm_event(ec, &event) == 0) {
struct rdma_cm_event event_copy;

memcpy(&event_copy, event, sizeof(*event));
rdma_ack_cm_event(event);

if (on_event(&event_copy))
break;
}

rdma_destroy_id(listener);
rdma_destroy_event_channel(ec);

return 0;
}

ec 是指向 rdmacm 事件通道的指针。listener是指向侦听器的 rdmacm ID 的指针。我们在创建它时指定了RDMA_PS_TCP,这表明我们需要一个面向连接的可靠队列对。RDMA_PS_UDP将指示无连接、不可靠的队列对。

然后,我们将此 ID 绑定到套接字地址。通过将端口(addr.sin_port)设置为零,我们指示 rdmacm 选择一个可用端口。我们还指出,我们希望侦听任何可用 RDMA 接口/设备上的连接。

我们的事件循环从 rdmacm 获取事件,确认事件,然后对其进行处理。未能确认事件将导致rdma_destroy_id() 阻塞。连接的被动端的事件处理程序仅对三个事件感兴趣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static int on_connect_request(struct rdma_cm_id *id);
static int on_connection(void *context);
static int on_disconnect(struct rdma_cm_id *id);

int on_event(struct rdma_cm_event *event)
{
int r = 0;

if (event->event == RDMA_CM_EVENT_CONNECT_REQUEST)
r = on_connect_request(event->id);
else if (event->event == RDMA_CM_EVENT_ESTABLISHED)
r = on_connection(event->id->context);
else if (event->event == RDMA_CM_EVENT_DISCONNECTED)
r = on_disconnect(event->id);
else
die("on_event: unknown event.");

return r;
}

从上述代码结合前面的main函数break的条件可以看出来,这里只关注on_event里面的三种事件,如果不是这三种就会继续循环(等待);

rdmacm 允许我们将 void *上下文指针与 ID 相关联。我们将使用它来附加连接上下文结构:

1
2
3
4
5
6
7
8
9
struct connection {
struct ibv_qp *qp;

struct ibv_mr *recv_mr;
struct ibv_mr *send_mr;

char *recv_region;
char *send_region;
};

它包含一个指向队列对(冗余,但略微简化了代码)、两个缓冲区(一个用于发送,另一个用于接收)和两个内存区域(用于发送/接收的内存必须“注册”到谓词库)的指针。当我们收到连接请求时,如果尚未构建动词上下文,我们首先构建该上下文。然后,在构建了连接上下文结构之后,我们预先发布了我们的接收信息(稍后会详细介绍),并接受连接请求:

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
static void build_context(struct ibv_context *verbs);
static void build_qp_attr(struct ibv_qp_init_attr *qp_attr);
static void post_receives(struct connection *conn);
static void register_memory(struct connection *conn);

int on_connect_request(struct rdma_cm_id *id)
{
struct ibv_qp_init_attr qp_attr;
struct rdma_conn_param cm_params;
struct connection *conn;

printf("received connection request.\n");

build_context(id->verbs);
build_qp_attr(&qp_attr);

TEST_NZ(rdma_create_qp(id, s_ctx->pd, &qp_attr));

id->context = conn = (struct connection *)malloc(sizeof(struct connection));
conn->qp = id->qp;

register_memory(conn);
post_receives(conn);

memset(&cm_params, 0, sizeof(cm_params));
TEST_NZ(rdma_accept(id, &cm_params));

return 0;
}

我们推迟构建谓词上下文,直到收到第一个连接请求,因为rdmacm listener ID 不一定绑定到特定的 RDMA 设备(以及关联的谓词上下文)。但是,我们收到的第一个连接请求将在id->verbs` 处具有有效的谓词上下文结构。构建谓词上下文涉及设置静态上下文结构、创建保护域、创建完成队列、创建完成通道以及启动线程以从队列中提取完成:

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
struct context {
struct ibv_context *ctx;
struct ibv_pd *pd;
struct ibv_cq *cq;
struct ibv_comp_channel *comp_channel;

pthread_t cq_poller_thread;
};

static void * poll_cq(void *);

static struct context *s_ctx = NULL;

void build_context(struct ibv_context *verbs)
{
if (s_ctx) {
if (s_ctx->ctx != verbs)
die("cannot handle events in more than one context.");

return;
}

s_ctx = (struct context *)malloc(sizeof(struct context));
//设置静态上下文结构、创建保护域、创建完成队列、创建完成通道
s_ctx->ctx = verbs;

TEST_Z(s_ctx->pd = ibv_alloc_pd(s_ctx->ctx));
TEST_Z(s_ctx->comp_channel = ibv_create_comp_channel(s_ctx->ctx));
TEST_Z(s_ctx->cq = ibv_create_cq(s_ctx->ctx, 10, NULL, s_ctx->comp_channel, 0));
TEST_NZ(ibv_req_notify_cq(s_ctx->cq, 0));

TEST_NZ(pthread_create(&s_ctx->cq_poller_thread, NULL, poll_cq, NULL));
}

使用完成通道允许我们阻塞轮询器线程等待完成。我们创建完成队列,并将cqe设置为 10(cqeibv_create_cq 函数里面的第二个参数),表示我们希望在队列上留出 10 个条目的空间。此数字应设置得足够大,以便队列不会溢出。轮询器在通道上等待,确认完成,重新排列完成队列(使用 ibv_req_notify_cq()),然后从队列中提取事件,直到没有事件留下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static void on_completion(struct ibv_wc *wc);

void * poll_cq(void *ctx)
{
struct ibv_cq *cq;
struct ibv_wc wc;

while (1) {
TEST_NZ(ibv_get_cq_event(s_ctx->comp_channel, &cq, &ctx));
ibv_ack_cq_events(cq, 1);
TEST_NZ(ibv_req_notify_cq(cq, 0));

while (ibv_poll_cq(cq, 1, &wc))
on_completion(&wc);
}

return NULL;
}

回到我们的连接请求。构建动词上下文后,我们必须初始化队列对属性结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
void build_qp_attr(struct ibv_qp_init_attr *qp_attr)
{
memset(qp_attr, 0, sizeof(*qp_attr));

qp_attr->send_cq = s_ctx->cq;
qp_attr->recv_cq = s_ctx->cq;
qp_attr->qp_type = IBV_QPT_RC;

qp_attr->cap.max_send_wr = 10;
qp_attr->cap.max_recv_wr = 10;
qp_attr->cap.max_send_sge = 1;
qp_attr->cap.max_recv_sge = 1;
}

我们首先将结构清零,然后设置我们关心的属性。 send_cqrecv_cq分别是发送和接收完成队列。qp_type 设置为表明我们想要一个可靠的、面向连接的队列对。队列对功能结构 qp_attr->cap 用于与动词驱动程序协商最小功能。在这里,我们请求十个挂起的发送和接收(在它们各自的队列中的任何时间),以及每个发送或接收请求一个分散/收集元素(SGE;实际上是一个内存位置/大小元组)。建立队列对初始化属性后,我们调用 rdma_create_qp() 来创建队列对。然后我们为我们的连接上下文结构(结构连接)分配内存,并为我们的发送和接收操作分配/注册内存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const int BUFFER_SIZE = 1024;

void register_memory(struct connection *conn)
{
conn->send_region = malloc(BUFFER_SIZE);
conn->recv_region = malloc(BUFFER_SIZE);

TEST_Z(conn->send_mr = ibv_reg_mr(
s_ctx->pd,
conn->send_region,
BUFFER_SIZE,
IBV_ACCESS_LOCAL_WRITE | IBV_ACCESS_REMOTE_WRITE));

TEST_Z(conn->recv_mr = ibv_reg_mr(
s_ctx->pd,
conn->recv_region,
BUFFER_SIZE,
IBV_ACCESS_LOCAL_WRITE | IBV_ACCESS_REMOTE_WRITE));
}

在这里,我们分配两个缓冲区,一个用于发送,另一个用于接收,然后用动词注册它们。我们指出需要对这些内存区域进行本地写入和远程写入访问。连接请求事件处理程序中的下一步(变得相当长)是预先发布接收。在接受连接之前必须发布接收工作请求 (WR) 的原因是,基础硬件不会缓冲传入消息 — 如果接收请求尚未发布到工作队列,则传入消息将被拒绝,对等方将收到接收器未就绪 (RNR) 错误。我将在另一篇文章中进一步讨论这个问题,但现在只需说必须在发送之前发布接收。我们将通过在接受连接之前发布收到的邮件,并在建立连接后发送发送来强制执行此操作。发布接收需要我们构建一个接收工作请求结构,然后将其发布到接收队列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void post_receives(struct connection *conn)
{
struct ibv_recv_wr wr, *bad_wr = NULL;
struct ibv_sge sge;

wr.wr_id = (uintptr_t)conn;
wr.next = NULL;
wr.sg_list = &sge;
wr.num_sge = 1;

sge.addr = (uintptr_t)conn->recv_region;
sge.length = BUFFER_SIZE;
sge.lkey = conn->recv_mr->lkey;

TEST_NZ(ibv_post_recv(conn->qp, &wr, &bad_wr));
}

(任意)wr_id字段用于存储连接上下文指针。最后,完成所有这些设置后,我们已准备好接受连接请求。这是通过调用rdma_accept()来完成的。 我们需要处理的下一个事件是RDMA_CM_EVENT_ESTABLISHED,这表示已建立连接。这个处理程序很简单 - 它只是发布一个发送工作请求:

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
int on_connection(void *context)
{
struct connection *conn = (struct connection *)context;
struct ibv_send_wr wr, *bad_wr = NULL;
struct ibv_sge sge;

snprintf(conn->send_region, BUFFER_SIZE, "message from passive/server side with pid %d", getpid());

printf("connected. posting send...\n");

memset(&wr, 0, sizeof(wr));

wr.opcode = IBV_WR_SEND;
wr.sg_list = &sge;
wr.num_sge = 1;
wr.send_flags = IBV_SEND_SIGNALED;

sge.addr = (uintptr_t)conn->send_region;
sge.length = BUFFER_SIZE;
sge.lkey = conn->send_mr->lkey;

TEST_NZ(ibv_post_send(conn->qp, &wr, &bad_wr));

return 0;
}

这与我们用于发布接收的代码没有根本的不同,除了发送请求指定了操作码。此处,IBV_WR_SEND指示必须与对等体上的相应接收请求匹配的发送请求。其他选项包括 RDMA 写入、RDMA 读取和各种原子操作。在 wr.send_flags 中指定IBV_SEND_SIGNALED表示我们需要此发送请求的完成通知。

我们要处理的最后一个 rdmacm 事件是RDMA_CM_EVENT_DISCONNECTED,我们将在其中执行一些清理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int on_disconnect(struct rdma_cm_id *id)
{
struct connection *conn = (struct connection *)id->context;

printf("peer disconnected.\n");

rdma_destroy_qp(id);

ibv_dereg_mr(conn->send_mr);
ibv_dereg_mr(conn->recv_mr);

free(conn->send_region);
free(conn->recv_region);

free(conn);

rdma_destroy_id(id);

return 0;
}

我们所要做的就是处理从完成队列中提取的完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void on_completion(struct ibv_wc *wc)
{
if (wc->status != IBV_WC_SUCCESS)
die("on_completion: status is not IBV_WC_SUCCESS.");

if (wc->opcode & IBV_WC_RECV) {
struct connection *conn = (struct connection *)(uintptr_t)wc->wr_id;

printf("received message: %s\n", conn->recv_region);

} else if (wc->opcode == IBV_WC_SEND) {
printf("send completed successfully.\n");
}
}

回想一下,在 post_receives() 中,我们设置了对连接上下文结构的wr_id。就是这样!建造很简单,但不要忘记-lrdmacm。此处提供了被动端/服务器和主动端/客户端的完整代码。(在之后的部分作者讨论了优化)

参考资料