跳到主要内容

ch13_6

13.6 Queue Pair (队列对)

好了,关于接收队列的复用,我们通过 SRQ 已经看到了一种解决方案。但这只是故事的一半——或者说,只是为了应对极端性能场景的「大招」。

在日常的 RDMA 通信中,真正的主角,那个你每时每刻都在打交道、必须亲手调教的对象,是 Queue Pair (QP)

如果说 SRQ 是为了解决「一方养多房」的难题,那么 QP 就是那个最基础、最不可或缺的「通信管道」本身。


QP 的本质:两条单向的逆行道

在直觉里,我们总觉得一个网络连接应该是一个管道——数据从这边流进去,从那边流出来。但在 RDMA 的世界里,为了极致的性能,这个模型被拆开了。

Queue Pair (QP) 是 InfiniBand 中实际用于发送和接收数据的对象。名字里的「Pair」(对)非常精准:它由两个完全独立的工作队列组成:

  1. Send Queue (SQ, 发送队列):你往这里投递请求,告诉网卡把数据发出去。
  2. Receive Queue (RQ, 接收队列):你往这里投递请求,告诉网卡「我有空地了,把收到的数据放这里」。

这是一个非常容易踩坑的认知点:发送和接收是完全解耦的

SQ 和 RQ 各自有自己的属性:

  • 它们能容纳多少个 Work Request (WR)?
  • 每个 WR 支持多少个 Scatter/Gather (SGE) 元素?
  • 完成后的状态写进哪个 CQ?

你可以把 SQ 做得很大,把 RQ 做得很小,或者反过来。只要符合硬件限制,内核不关心。

顺序保证与解耦

在同一个队列内部,顺序是严格保证的。你在 SQ 里按顺序投递 WR1、WR2,网卡一定先处理 WR1,再处理 WR2。RQ 同理。

但是,SQ 和 RQ 之间没有任何关系

你可以先往 SQ 里投递一个发送请求,然后再去 RQ 里投递接收请求;或者反过来。它们互不影响,就像两条平行的单行道。这一点在理解并发行为时至关重要。

Figure 13-5 展示了一个标准的 QP 结构。

Figure 13-5. QP (Queue Pair) (示意图:一个 QP 包含两个队列,一个箭头指出(Send),一个箭头指入)

当你在设备上创建一个 QP 时,它会获得一个在该 RDMA 设备上当前唯一的 qp_num。以后别人要找你这个管道通信,靠的就是这个号码。


QP 传输类型:不是所有的路都铺得一样好

InfiniBand 之所以复杂,是因为它不想只做一件事。它提供了好几种 QP 传输类型,每种都对应不同的场景和代价。

我们需要像选网卡一样,慎重地选择 QP 的类型。

1. Reliable Connected (RC) — 可靠连接

这是最常用、也是功能最全的类型。

  • 连接模式:一对一。一个 RC QP 必须连接到远端的一个特定 RC QP。
  • 可靠性:绝对保证。包丢了会自动重传,顺序乱了会自动重排,内容不对会自动纠错。
  • 传输机制:消息会在发送端根据路径 MTU 分片,在接收端重组。
  • 支持的操作:全餐 —— Send, RDMA Write, RDMA Read, Atomic 操作。

如果你在做存储、做数据库集群,你需要的一致性和强语义,选 RC 就对了。

2. Unreliable Connected (UC) — 不可靠连接

看起来和 RC 很像,也是一对一连接,也是点到点。但是它「砍」掉了重传机制。

  • 可靠性:不保证。如果一个消息里的任何一个包丢了,整个消息就丢了。
  • 支持的操作:Send 和 RDMA Write。
  • 为什么存在? 有些应用层自己有重传逻辑,或者只在乎吞吐不在乎偶尔丢包。少了硬件的重传开销,速度能更快一点。

3. Unreliable Datagram (UD) — 不可靠数据报

这是 RDMA 版的 UDP。

  • 连接模式:一对多。一个 UD QP 可以向子网内的任意 UD QP 发消息,甚至支持组播。
  • 可靠性:完全不保证。
  • 限制:消息大小受限于路径 MTU,不能分片。只支持 Send 操作,不支持 RDMA Read/Write。
  • 用途:这是 RDMA 里的「控制面」。比如我们在建立连接前,需要先交换一下信息,用 UD 最合适。

4. eXtended Reliable Connected (XRC) — 扩展可靠连接

还记得上一节讲的 SRQ 吗?XRC 就是 SRQ 的最佳搭档。

  • 场景:同一个节点的多个 QP(甚至多个进程的 QP)可以同时向远端的一个特定 SRQ 发送消息。
  • 目的:减少 QP 的数量。以前是一对一,核心数多了 QP 就爆炸;现在可以多对一。
  • 限制:这是给用户空间 应用用的特权,内核驱动通常不碰这个。

5. Raw Packet / Raw Ethertype — 原始包

这是给「黑客」用的。

  • 功能:允许客户端构建完整的二层(L2)头部,直接发原始数据。接收端 RDMA 设备不会剥离任何头部。
  • 用途:如果你想在 RDMA 网卡上跑自定义协议,或者做一些 weird 的网络实验,这就派上用场了。
  • 现状:大部分 RDMA 设备目前还不支持 Raw IPv6/Raw Ethertype。

特殊类型:管理用的 QP

除了传数据的,还有两个专门用来「管事」的 QP,每个端口自带的:

  • SMI / QP0:专门给子网管理 用的,处理管理数据包。
  • GSI / QP1:给通用服务 用的,比如通过 MAD 查询网卡属性。

创建 QP:搭建管道

说了这么多理论,现在我们动手创建一个 RC QP。

创建 QP 的核心函数是 ib_create_qp()。你需要准备好一个 PD (Protection Domain),以及一个描述 QP 属性的结构体 struct ib_qp_init_attr

这里有个实战例子:我们要创建一个 RC QP,发送队列和接收队列分别绑定不同的 CQ,容量都很小(只有 2 个 WR),方便演示。

struct ib_qp_init_attr init_attr;
struct ib_qp *qp;

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

/* 设置事件回调:当 QP 状态发生异步变化(比如错误)时调用 */
init_attr.event_handler = my_qp_event;

/* 发送队列容量:最多 2 个 WR */
init_attr.cap.max_send_wr = 2;

/* 接收队列容量:最多 2 个 WR */
init_attr.cap.max_recv_wr = 2;

/* 每个 WR 的 Scatter/Gather 元素数量限制 */
init_attr.cap.max_recv_sge = 1;
init_attr.cap.max_send_sge = 1;

/* 发送完成通知策略:每个 WR 完成都在 CQ 里产生通知 */
init_attr.sq_sig_type = IB_SIGNAL_ALL_WR;

/* QP 类型:RC */
init_attr.qp_type = IB_QPT_RC;

/* 绑定 CQ */
init_attr.send_cq = send_cq;
init_attr.recv_cq = recv_cq;

qp = ib_create_qp(pd, &init_attr);
if (IS_ERR(qp)) {
printk(KERN_ERR "Failed to create a QP\n");
return PTR_ERR(qp);
}

注意:这里 sq_sig_type 选了 IB_SIGNAL_ALL_WR。这意味着每发一个包,我都想收到通知。这在写测试程序时很方便,但在高性能生产环境中,你会为了性能选择 IB_SIGNAL_REQ_WR(手动控制什么时候发通知)来减少 CQ 中断。


QP 状态机:这就是那场著名的「俄罗斯方块」

创建出来的 QP 是个空壳子。它不能立刻收发数据,它必须经历一系列严格的状态转换。

这是 RDMA 开发中最让人头秃、也最容易出错的地方。你可以把 QP 想象成一个正在转场的舞台,灯光没开好之前,演员绝不能上场。

Figure 13-6 描绘了这张复杂的状态机图。

Figure 13-6. QP state machine (状态机图:Reset -> Init -> RTR -> RTS -> SQD / Error)

让我们按顺序走一遍:

1. Reset (重置状态)

  • 初始状态:所有刚创建的 QP 都在这里。
  • 能力:什么都不能干。不能发 Send,不能发 Recv。
  • 行为:所有进来的消息都被直接丢弃。
  • 用途:这是个「安全模式」,用来清空之前的配置。

2. Init (初始化状态)

  • 能力:还是不能发 Send 请求,但可以投递 Receive 请求了。
  • 行为:虽然你可以投递 Recv 请求,但它们不会被处理(因为还没连上)。所有进来的消息依然被丢弃。
  • 最佳实践:在这个阶段先往 RQ 里预投递几个 Receive Request。这样做是为了防止一个经典的竞态问题:当你把 QP 拉到 RTR 状态的一瞬间,远端的数据可能立刻就到了。如果你手慢没投递 Recv 请求,RNR (Receiver Not Ready) 错误就会立刻找上门来。

3. Ready To Receive (RTR, 准备接收)

  • 能力:可以处理 Receive 请求了。依然不能发 Send 请求。
  • 行为:进来的消息会被正式处理。
  • 事件:在这个状态下收到第一个消息时,会触发一个「通信建立」的异步事件。
  • 用途:如果你只想收不想发,可以停在这里。

4. Ready To Send (RTS, 准备发送)

  • 能力:全速运转。Send 和 Recv 请求都可以投递和处理。
  • 用途:这是 QP 的「战斗状态」。绝大多数正常工作的 QP 都停留在这里。

5. Send Queue Drained (SQD, 发送队列排空)

  • 能力:这是一个过渡态。QP 会把所有已经开始处理的 Send Request 发完,但拒绝新的发送请求。
  • 内部细节:分为 Draining(还在发)和 Drained(发完了)两个阶段。
  • 用途:当你需要修改某些 QP 属性,又不想把 QP 干掉时,可以先让它停下来。

6. Error (错误状态)

  • 触发:对于不可靠传输(UC/UD),如果发送队列出错,会进入 SQE 状态(此时接收队列还能用)。对于可靠传输(RC)或任何接收队列错误,QP 会直接掉进 Error 态。
  • 行为:所有未完成的 WR 全部被刷掉 并产生错误 WC。所有收到的消息直接丢弃。
  • 恢复:一旦进 Error 态,这基本上就废了。你必须手动把它改回 Reset 态,重新配置资源。

操控状态机:ib_modify_qp()

状态转换不是自动发生的,你需要调用 ib_modify_qp() 来推它一把。这个函数不仅改状态,还顺便配置该状态下必须的参数。

这是实战中最复杂的部分。让我们把一个刚创建的 QP 一路拉到 RTS 状态。

第一步:Reset -> Init

在进入 Init 前,我们要告诉它:用哪个端口?P_Key 是什么?

struct ib_qp_attr attr = {
.qp_state = IB_QPS_INIT,
.pkey_index = 0,
.port_num = port, /* 指定物理端口 */
.qp_access_flags = 0 /* 远端有没有权限对我的内存做 RDMA Read/Atomic */
};

ret = ib_modify_qp(qp, &attr,
IB_QP_STATE |
IB_QP_PKEY_INDEX |
IB_QP_PORT |
IB_QP_ACCESS_FLAGS);

if (ret) {
printk(KERN_ERR "Failed to modify QP to INIT state\n");
return ret;
}

第二步:Init -> RTR (准备接收)

这是最关键的一步。我们要告诉 QP:你要跟谁说话?

对于 RC QP 来说,必须配置远端的信息:对方的 LID 是多少?对方的 QP 号是多少?对方的 PSN (Packet Serial Number) 从几开始?

attr.qp_state = IB_QPS_RTR;
attr.path_mtu = mtu;
attr.dest_qp_num = remote->qpn; /* 对方的 QP 号 */
attr.rq_psn = remote->psn; /* 对方期望的起始 PSN */
attr.max_dest_rd_atomic = 1; /* 对方能同时发起多少个 RDMA Read/Atomic */
attr.min_rnr_timer = 12; /* 对方没准备好时,我等多久重试 */

/* Address Handle (AH) 属性:描述路由信息 */
attr.ah_attr.is_global = 0;
attr.ah_attr.dlid = remote->lid; /* 目标 LID */
attr.ah_attr.sl = sl; /* Service Level */
attr.ah_attr.src_path_bits = 0;
attr.ah_attr.port_num = port;

ret = ib_modify_qp(ctx->qp, &attr,
IB_QP_STATE |
IB_QP_AV | /* Address Vector */
IB_QP_PATH_MTU |
IB_QP_DEST_QPN |
IB_QP_RQ_PSN |
IB_QP_MAX_DEST_RD_ATOMIC |
IB_QP_MIN_RNR_TIMER);

if (ret) {
printk(KERN_ERR "Failed to modify QP to RTR state\n");
return ret;
}

第三步:RTR -> RTS (准备发送)

最后,把自己这一侧的发送参数配置好,就可以开闸放水了。

attr.qp_state = IB_QPS_RTS;
attr.timeout = 14; /* 传输超时时间 (4.096us * 2^14) */
attr.retry_cnt = 7; /* 最大重试次数 */
attr.rnr_retry = 6; /* 对方 RNR 时的重试策略 */
attr.sq_psn = my_psn; /* 我自己发送的起始 PSN */
attr.max_rd_atomic = 1; /* 我能同时发起多少个 RDMA Read/Atomic */

ret = ib_modify_qp(ctx->qp, &attr,
IB_QP_STATE |
IB_QP_TIMEOUT |
IB_QP_RETRY_CNT |
IB_QP_RNR_RETRY |
IB_QP_SQ_PSN |
IB_QP_MAX_QP_RD_ATOMIC);

if (ret) {
printk(KERN_ERR "Failed to modify QP to RTS state\n");
return ret;
}

走完这三步,这个 QP 才算真正活了。


Work Request (WR) 处理机制:流动的生命线

Q P 建好了,状态转完了,现在该往里填数据了。

Figure 13-7 展示了一个 Work Request 的生命周期。

Figure 13-7. Work Request processing flow (流程图:Post WR -> Driver/HW -> Processing -> Poll WC)

只要一个 WR 被投递 进队列,它就变成了 Outstanding(未完成) 状态。直到你在关联的 CQ 里 poll 到对应的 Work Completion (WC),这个 WR 才算寿终正寝。

发送队列的「信号」艺术

在 SQ 里,有一个很微妙的设计:不是所有的 Send Request 都会生成 WC

为了减少中断和 PCI-e 总线压力,你可以选择只给特定的 WR 打上标记(Signaled)。只有被标记的 WR(或者因为错误而被迫生成的 WC)才会出现在 CQ 里。

这就是为什么我们在 init_attr 里看到 sq_sig_type。如果你选了 IB_SIGNAL_ALL_WR,那是「图省事」模式;如果你选了 IB_SIGNAL_REQ_WR,你就必须自己在代码里精打细算,每发 N 个包手动给一个加信号。

但这有个坑:如果你对一个没标记的 WR 出错了,哪怕你不想要通知,硬件也会强制生成一个带错误状态的 WC。这是为了保命。

资源锁定:绝对不能碰的内存

当一个 WR 处于 Outstanding 状态时,你绝对不能动它用到的任何资源。

  • UD QP 发送:如果 WR 里带了一个 Address Handle (AH),在 WC 返回之前,你不能释放这个 AH。
  • 接收请求:如果你投递了一个 Receive WR,指向了一个 buffer,在 WC 返回告诉你「收完了」之前,你不能读这个 buffer。为什么?因为 DMA 可能正在往里写,或者还没开始写。此时读出来的数据是未定义的,甚至可能导致缓存一致性问题。

Fencing:栅栏机制

这是一个高级特性。想象一下这种场景:

  1. 你发起了一个 RDMA Read,把远端数据读回来。
  2. 你紧接着发了一个 Send,把刚才读到的数据发出去。

由于 QP 是流水线作业,如果硬件太激进,可能在 RDMA Read 还没把数据读完、还没写进内存的时候,Send 就已经从内存里取数据去发了。

结果就是:你发出去的是垃圾数据。

Fence(栅栏) 就是为了解决这个问题。如果你给 Send WR 加上了 Fence 标志,网卡就会保证:等这个 SQ 之前所有的 RDMA Read 和 Atomic 操作全部彻底完成后,再开始处理这个 Send 请求

这会牺牲一点性能,但能换回正确性。

错误处理

最后,不得不面对现实:网络总会出错的。

  • SQ 错误 (不可靠传输):如果是 UC/UD,发送出错只会把 SQ 挂进 SQE 态,RQ 还能正常收。你可以尝试恢复。
  • RQ 错误:一旦接收队列出问题(比如内存越界),整个 QP 会直接掉进 Error 态。这是不可恢复的,必须 Reset。

小结

Queue Pair 是 RDMA 世界的「阿凡达」。你所有的操作意图——读、写、发消息——最终都化作一个个 WR 被塞进它的肚子里。

理解它,需要分三个层次:

  1. 物理结构:两个队列(SQ/RQ),一个 PD,两个 CQ。
  2. 逻辑属性:你是选 RC(可靠)还是 UD(多播)?这决定了你能做什么操作。
  3. 生命周期:最复杂的部分。你必须严格按照 Reset -> Init -> RTR -> RTS 的节奏跳舞,少一步不行,快一步也不行。

这确实很繁琐,但这正是为了换取那份极致性能所必须付出的代价。