3、RDMA编程模型:Verbs API概述、libibverbs库安装、ibv_open_device、ibv_alloc_pd、ibv_create_cq、ibv_create_qp

好,咱们正式开始动手写代码了。这一章我会带你过一遍RDMA编程最核心的Verbs API,以及那几个你绕不开的基础对象:设备、保护域、完成队列和队列对。说白了,这就是RDMA程序的“地基”。

我个人习惯把Verbs API理解成“网卡的驱动程序接口”。你想想看,你要让网卡帮你做内存搬运,总得有个方式告诉它“搬哪块内存”、“搬多少”、“搬完怎么通知我”。Verbs就是干这个的。

3.1 Verbs API 概述

Verbs API,全称是“RDMA Verbs”。它定义了一套标准接口,让用户态程序能直接跟RDMA网卡(比如Mellanox的ConnectX系列)打交道。这套接口在Linux上由libibverbs库提供。

我刚开始接触RDMA时,最困惑的就是:为什么叫“Verbs”?后来才明白,它把网卡操作抽象成了“动作”——比如“发送”、“接收”、“读取”、“写入”。每个动作就是一个Verb。嗯,这么一想就通了。

Verbs API主要分两类:

  • 管理类Verbs:负责设备打开、资源分配、连接建立。比如咱们这章要讲的ibv_open_device、ibv_alloc_pd、ibv_create_cq、ibv_create_qp。
  • 数据类Verbs:负责实际的数据传输。比如ibv_post_send(发起发送)、ibv_post_recv(发起接收)、ibv_poll_cq(轮询完成事件)。

这里有个关键点:管理类Verbs通常只在初始化阶段调用一次,而数据类Verbs是热点路径,性能敏感。我在项目中遇到过有人把ibv_alloc_pd放在循环里调用,结果性能惨不忍睹——这玩意儿创建一次就够了,别反复搞。

3.2 libibverbs库安装

在开始写代码之前,得先把libibverbs装上。不同发行版安装方式略有不同,但核心步骤差不多。

Ubuntu/Debian系:

sudo apt-get update
sudo apt-get install libibverbs-dev ibverbs-utils rdma-core

CentOS/RHEL系:

sudo yum install libibverbs-devel ibverbs-utils rdma-core

装完之后,可以用下面这条命令验证一下:

ibv_devinfo

如果能看到你的RDMA网卡信息(比如端口状态、GID、MTU等),那就说明装好了。我曾经在一台新机器上跑ibv_devinfo,结果啥也没输出——后来发现是内核模块没加载。执行 modprobe mlx5_coremodprobe mlx5_ib 就好了。你如果也遇到类似情况,先检查一下内核模块。

小提示: 如果你用的是Mellanox网卡,建议同时安装mlx5-rdma工具集。它能帮你做更细粒度的调试和诊断。

3.3 ibv_open_device:打开RDMA设备

好,库装好了,咱们开始写代码。第一步就是打开RDMA设备。

ibv_open_device 的作用是获取一个RDMA设备的句柄。这个句柄后续所有操作的基础。它的函数签名长这样:

struct ibv_context *ibv_open_device(struct ibv_device *device);

参数是一个 ibv_device 指针,返回值是一个 ibv_context 指针。怎么拿到 ibv_device 呢?通常用 ibv_get_device_list

struct ibv_device **dev_list;
struct ibv_context *ctx;
int num_devices;

dev_list = ibv_get_device_list(&num_devices);
if (!dev_list || num_devices == 0) {
    fprintf(stderr, "没有找到RDMA设备\n");
    return -1;
}

// 通常打开第一个设备
ctx = ibv_open_device(dev_list[0]);
if (!ctx) {
    fprintf(stderr, "打开设备失败\n");
    ibv_free_device_list(dev_list);
    return -1;
}

printf("成功打开设备: %s\n", ibv_get_device_name(dev_list[0]));

// 用完记得释放
ibv_free_device_list(dev_list);

这里有个坑:ibv_get_device_list 返回的列表是动态分配的,用完必须调用 ibv_free_device_list 释放。我见过有人忘了这步,导致内存泄漏——虽然泄漏不大,但长期运行的程序还是要注意。

注意: 在多网卡环境下,别硬编码设备索引。我建议用 ibv_get_device_name 打印所有设备名,然后根据名称选择目标设备。比如用 strcmp 匹配 "mlx5_0" 或 "mlx5_1"。

3.4 ibv_alloc_pd:分配保护域

设备打开了,接下来要分配保护域(Protection Domain,简称PD)。PD是RDMA中一个非常重要的概念——它把内存注册、队列对、完成队列等资源“圈”在一起,形成一个隔离域。说白了,同一个PD里的资源才能互相访问。

函数签名很简单:

struct ibv_pd *ibv_alloc_pd(struct ibv_context *context);

参数是之前打开的 ibv_context,返回值是一个 ibv_pd 指针。

struct ibv_pd *pd = ibv_alloc_pd(ctx);
if (!pd) {
    fprintf(stderr, "分配保护域失败\n");
    // 别忘了关闭设备
    ibv_close_device(ctx);
    return -1;
}

PD分配成功后,后续创建QP、CQ、MR(内存区域)时都要传入这个PD。我习惯把PD看作“资源容器”——所有需要互相通信的资源都放在同一个PD里。如果你有多个独立的通信通道,可以创建多个PD,互不干扰。

我曾经在一个项目中,为了图省事,所有QP都共用一个PD。结果后来要加一个安全隔离的需求,不得不重构代码,把PD拆开。嗯,提前规划好PD的粒度,能省不少事。

3.5 ibv_create_cq:创建完成队列

完成队列(Completion Queue,简称CQ)是RDMA的“通知机制”。当你发起一个数据传输操作(比如发送或接收),网卡完成之后,会把完成事件放到CQ里。你的程序通过轮询CQ来知道操作是否完成。

函数签名:

struct ibv_cq *ibv_create_cq(struct ibv_context *context, int cqe, 
                              void *cq_context, 
                              struct ibv_comp_channel *channel, 
                              int comp_vector);

参数说明:

  • context:设备上下文
  • cqe:CQ的容量,即最多能存放多少个完成事件
  • cq_context:用户自定义上下文,通常传NULL
  • channel:完成事件通道,用于异步通知。如果不需要,传NULL
  • comp_vector:中断向量号,用于多队列优化。通常传0

一个典型的创建示例:

struct ibv_cq *cq = ibv_create_cq(ctx, 1024, NULL, NULL, 0);
if (!cq) {
    fprintf(stderr, "创建完成队列失败\n");
    ibv_dealloc_pd(pd);
    ibv_close_device(ctx);
    return -1;
}

这里 cqe = 1024 表示CQ最多能容纳1024个完成事件。这个值要根据你的实际并发量来设。设太小了,网卡完成事件放不下,会丢事件;设太大了,浪费内存。我一般按“最大未处理WR数量 * 2”来估算,留点余量。

核心要点: CQ是RDMA编程中性能调优的关键点之一。CQ的大小、轮询方式(阻塞 vs 非阻塞)、中断向量分配,都会直接影响吞吐量和延迟。

3.6 ibv_create_qp:创建队列对

队列对(Queue Pair,简称QP)是RDMA通信的“核心引擎”。一个QP包含两个队列:发送队列(SQ)和接收队列(RQ)。你往SQ里放发送请求,往RQ里放接收请求,网卡就会自动处理。

创建QP比前面几个步骤稍微复杂一点,因为它需要先填充一个属性结构体:

struct ibv_qp_init_attr qp_attr;
memset(&qp_attr, 0, sizeof(qp_attr));

qp_attr.send_cq = cq;           // 发送完成队列
qp_attr.recv_cq = cq;           // 接收完成队列(可以和发送共用同一个CQ)
qp_attr.qp_type = IBV_QPT_RC;   // 可靠连接(RC)模式
qp_attr.cap.max_send_wr = 128;  // 发送队列深度
qp_attr.cap.max_recv_wr = 128;  // 接收队列深度
qp_attr.cap.max_send_sge = 1;   // 每个发送请求最多关联的SGE数
qp_attr.cap.max_recv_sge = 1;   // 每个接收请求最多关联的SGE数

struct ibv_qp *qp = ibv_create_qp(pd, &qp_attr);
if (!qp) {
    fprintf(stderr, "创建队列对失败\n");
    ibv_destroy_cq(cq);
    ibv_dealloc_pd(pd);
    ibv_close_device(ctx);
    return -1;
}

这里有几个关键点:

  • QP类型IBV_QPT_RC 是可靠连接,最常用。还有UC(不可靠连接)、UD(不可靠数据报)等,但RC是主流。
  • 队列深度max_send_wrmax_recv_wr 决定了QP能同时“在途”多少个请求。深度越大,流水线越深,但内存开销也越大。我一般从128开始调,压测时再根据实际吞吐量调整。
  • SGE数量:SGE(Scatter/Gather Element)描述了一块内存缓冲区。大多数场景下,一个请求对应一个SGE就够了。如果你要做“零拷贝”聚合发送,才需要多个SGE。

我记得第一次调QP深度时,设了个4096,结果程序跑起来内存暴涨,还频繁报错。后来才发现,队列深度不是越大越好——它受网卡硬件资源限制。查一下网卡规格,再设一个合理的值。

经验之谈: 创建QP之后,它处于“初始化”状态,还不能直接收发数据。你需要先调用 ibv_modify_qp 把它切换到“就绪”状态(RTR和RTS)。这部分内容我会在后续章节详细讲。

3.7 资源释放的顺序

最后,提一下资源释放的顺序。很多新手会搞反,导致程序崩溃或资源泄漏。正确的顺序是:

  1. 先销毁QP:ibv_destroy_qp(qp)
  2. 再销毁CQ:ibv_destroy_cq(cq)
  3. 再释放PD:ibv_dealloc_pd(pd)
  4. 最后关闭设备:ibv_close_device(ctx)

为什么是这个顺序?因为QP依赖于CQ和PD,CQ依赖于PD,PD依赖于设备。你如果先关了设备,再销毁QP,那QP销毁时访问设备上下文就会出问题。嗯,这个顺序我踩过坑,后来就记住了。

好了,这一章的内容就到这里。你掌握了Verbs API的基本概念,学会了安装libibverbs,也亲手创建了设备、PD、CQ和QP。下一章,我会带你做内存注册,然后真正开始收发数据。