3、RDMA编程基础:libibverbs库介绍、设备查询、PD创建、QP创建与修改
好,咱们正式开始动手写RDMA代码了。说实话,很多新手一上来就被各种概念砸晕——保护域、完成队列、工作请求……其实没那么复杂。今天我就带你把这些基础组件一个一个捋清楚。
3.1 libibverbs库:RDMA编程的基石
libibverbs是RDMA编程的核心库。说白了,它就是一套用户态API,让你能直接操作RDMA网卡(HCA)。我刚开始接触时,总觉得它跟socket编程很像,但用起来才发现——完全不是一回事。
这个库主要提供几个能力:
- 设备发现与查询——找到机器上有哪些RDMA设备
- 资源管理——创建PD、MR、QP、CQ这些核心对象
- 数据传输——提交WR(工作请求)、轮询CQ(完成队列)
你想想看,如果没有libibverbs,你就得直接跟内核的verbs驱动打交道,那代码量……嗯,我当年试过一次,写了两天还没调通一个简单的pingpong。
核心头文件:
#include <infiniband/verbs.h>
所有RDMA编程都从这个头文件开始。它定义了ibv_开头的所有数据结构与函数。
3.2 设备查询:找到你的HCA
写RDMA程序的第一步,就是看看机器上有哪些可用的RDMA设备。我习惯用ibv_get_device_list()来获取设备列表。
来看一段最基础的设备查询代码:
#include <stdio.h>
#include <infiniband/verbs.h>
int main() {
struct ibv_device **dev_list;
int num_devices, i;
// 获取设备列表
dev_list = ibv_get_device_list(&num_devices, NULL);
if (!dev_list) {
fprintf(stderr, "获取设备列表失败\n");
return -1;
}
printf("发现 %d 个RDMA设备:\n", num_devices);
for (i = 0; i < num_devices; i++) {
printf(" 设备 %d: %s\n", i, ibv_get_device_name(dev_list[i]));
}
// 释放设备列表
ibv_free_device_list(dev_list);
return 0;
}
这段代码很简单,但有个坑——ibv_get_device_list()返回的列表,用完一定要释放。我曾经忘了释放,结果程序跑久了内存泄漏,排查了半天才发现是这里的问题。
小技巧:可以用ibv_get_device_name()获取设备名,比如"mlx5_0"、"mlx5_1"。但注意,这个名称跟ibstat命令看到的端口名不一样,别搞混了。
3.3 打开设备与查询设备属性
找到设备后,下一步就是打开它。用ibv_open_device()来打开,然后可以查询它的详细属性。
struct ibv_context *context;
struct ibv_device_attr device_attr;
// 打开第一个设备
context = ibv_open_device(dev_list[0]);
if (!context) {
fprintf(stderr, "打开设备失败\n");
return -1;
}
// 查询设备属性
if (ibv_query_device(context, &device_attr)) {
fprintf(stderr, "查询设备属性失败\n");
return -1;
}
printf("设备属性:\n");
printf(" FW版本: %s\n", device_attr.fw_ver);
printf(" 最大QP数: %d\n", device_attr.max_qp);
printf(" 最大CQ数: %d\n", device_attr.max_cq);
printf(" 最大MR大小: %lu\n", device_attr.max_mr_size);
这里有个细节——ibv_open_device()返回的是ibv_context指针。这个context就是后续所有操作的"通行证"。我习惯把它叫做ctx,几乎所有RDMA代码里都能看到这个变量名。
注意:不同厂商的HCA,支持的属性可能不一样。比如Mellanox的卡支持GID表,但有些软RoCE设备就不支持。写代码时最好做兼容性检查。
3.4 PD(保护域)的创建
PD,全称Protection Domain,保护域。它是个啥?你可以把它理解成一个"安全沙箱"。同一个PD内的资源才能互相访问。
创建PD很简单:
struct ibv_pd *pd;
pd = ibv_alloc_pd(context);
if (!pd) {
fprintf(stderr, "分配PD失败\n");
return -1;
}
嗯,就一行代码。但它的作用可大了——MR(内存区域)、QP(队列对)、SRQ(共享接收队列)都必须关联到一个PD。不同PD之间的资源不能混用。
我在项目中遇到过一个问题:两个线程各自创建了PD,然后A线程的QP想访问B线程的MR,结果一直报错。查了半天才发现是PD不匹配。所以记住:同一个通信链路的所有资源,必须在同一个PD下创建。
PD的作用总结:
- 隔离不同应用的资源
- 控制内存访问权限
- 一个PD可以关联多个QP和MR
- 不同PD的资源不能互相访问
3.5 QP(队列对)的创建与修改
QP是RDMA通信的核心。它由两个队列组成:发送队列(SQ)和接收队列(RQ)。数据发送走SQ,接收走RQ。
3.5.1 创建QP
创建QP需要先初始化一个ibv_qp_init_attr结构体:
struct ibv_qp_init_attr qp_init_attr;
struct ibv_qp *qp;
memset(&qp_init_attr, 0, sizeof(qp_init_attr));
qp_init_attr.send_cq = cq; // 发送完成队列
qp_init_attr.recv_cq = cq; // 接收完成队列(可以跟发送共用)
qp_init_attr.qp_type = IBV_QPT_RC; // RC:可靠连接
qp_init_attr.cap.max_send_wr = 16; // 最大发送WR数
qp_init_attr.cap.max_recv_wr = 16; // 最大接收WR数
qp_init_attr.cap.max_send_sge = 1; // 最大发送SGE数
qp_init_attr.cap.max_recv_sge = 1; // 最大接收SGE数
qp = ibv_create_qp(pd, &qp_init_attr);
if (!qp) {
fprintf(stderr, "创建QP失败\n");
return -1;
}
这里有几个关键点:
- QP类型:RC(可靠连接)最常用,UC(不可靠连接)和UD(不可靠数据报)用得少
- CQ:完成队列,用来通知你操作完成了。可以SQ和RQ共用同一个CQ
- cap:容量参数,设太小会导致操作失败,设太大浪费内存
我的经验:max_send_wr和max_recv_wr一般设16或32就够了。除非你确定需要更高的并发,否则别设太大。我曾经设了1024,结果内存占用暴涨,性能反而没提升。
3.5.2 修改QP状态
QP创建后,不能直接用。它需要经过一系列状态转换:
| 状态 | 说明 | 下一步 |
|---|---|---|
| RESET | 初始状态 | → INIT |
| INIT | 初始化完成,可以添加接收WR | → RTR |
| RTR | 准备好接收数据 | → RTS |
| RTS | 准备好发送和接收数据 | 正常工作 |
| SQD | 发送队列 drained | 修改属性 |
| SQE | 发送队列错误 | 需要重置 |
| ERR | 错误状态 | 需要重置 |
状态转换通过ibv_modify_qp()实现。来看一个从RESET到INIT的例子:
struct ibv_qp_attr qp_attr;
int flags;
memset(&qp_attr, 0, sizeof(qp_attr));
qp_attr.qp_state = IBV_QPS_INIT;
qp_attr.pkey_index = 0;
qp_attr.port_num = 1;
qp_attr.qp_access_flags = IBV_ACCESS_LOCAL_WRITE | IBV_ACCESS_REMOTE_WRITE;
flags = IBV_QP_STATE | IBV_QP_PKEY_INDEX | IBV_QP_PORT | IBV_QP_ACCESS_FLAGS;
if (ibv_modify_qp(qp, &qp_attr, flags)) {
fprintf(stderr, "QP状态转换失败 (RESET -> INIT)\n");
return -1;
}
然后从INIT到RTR:
qp_attr.qp_state = IBV_QPS_RTR;
qp_attr.path_mtu = IBV_MTU_1024;
qp_attr.dest_qp_num = remote_qpn; // 对端QP号
qp_attr.rq_psn = 0; // 接收端包序列号
qp_attr.max_dest_rd_atomic = 1; // 最大未完成读/原子操作
qp_attr.min_rnr_timer = 12; // RNR超时时间
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;
if (ibv_modify_qp(qp, &qp_attr, flags)) {
fprintf(stderr, "QP状态转换失败 (INIT -> RTR)\n");
return -1;
}
最后从RTR到RTS:
qp_attr.qp_state = IBV_QPS_RTS;
qp_attr.timeout = 14; // 本地超时时间
qp_attr.retry_cnt = 7; // 重试次数
qp_attr.rnr_retry = 7; // RNR重试次数
qp_attr.sq_psn = 0; // 发送端包序列号
qp_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;
if (ibv_modify_qp(qp, &qp_attr, flags)) {
fprintf(stderr, "QP状态转换失败 (RTR -> RTS)\n");
return -1;
}
避坑指南:我曾经在RTR到RTS转换时忘了设置sq_psn,结果对端一直收不到数据。排查了两天才发现——PSN必须跟对端协商一致。另外,timeout值别设太小,否则网络抖动一下QP就挂了。
3.6 清理资源
程序退出时,记得释放所有资源。顺序很重要:
ibv_destroy_qp(qp);
ibv_dealloc_pd(pd);
ibv_close_device(context);
ibv_free_device_list(dev_list);
先销毁QP,再释放PD,最后关闭设备。顺序反了可能会报错。
本章核心要点:
- libibverbs是RDMA编程的基础库,所有操作都通过它完成
- 设备查询用
ibv_get_device_list+ibv_open_device - PD是资源隔离的边界,同一通信链路的所有资源必须在同一个PD下
- QP创建后需要经过RESET→INIT→RTR→RTS的状态转换才能使用
- 状态转换时,flags参数必须包含所有要修改的属性,漏一个就报错
好了,这一章的内容就到这儿。下一章咱们会讲MR(内存注册)和实际的收发操作。到时候就能真正跑起来数据了,想想还有点小激动呢。