☰
RDMA队列对QP状态机详解:从WQE到门铃的传输与排障
2026/10/20 13:45:42 网站建设 项目流程

1. 从“队列对”说起:RDMA 传输的最小调度单元

RDMA(远程直接内存访问)把“数据拷贝”这件事从 CPU 上卸载下去,但“谁负责调度、在哪个队列里排队、以什么顺序完成”这些事,仍然有一套严谨的软件语义。Queue Pair(队列对,简称 QP)就是这套语义的核心载体。一个 QP 本质上由发送队列(Send Queue,SQ)和接收队列(Receive Queue,RQ)两个队列组成,之所以叫“对”,是因为 RDMA 通信总是双向的——你既要能发出数据,也要能收进数据,而且这两个队列绑定在同一个连接上下文上。

很多刚接触 RDMA 的同学会把 QP 理解成“类似 TCP socket 的东西”。这个类比在“一条连接一个 QP”的层面是成立的,但深入之后会发现它们完全不是一个层级:socket 上有内核协议栈的缓冲区、拥塞控制、半连接队列这些细节,而 QP 是一个更纯粹的“软件驱动 + 硬件队列”接口。你往队列里放的是工作请求(Work Request,WR),硬件拿走之后变成工作队列条目(Work Queue Entry,WQE),完成后在完成队列(Completion Queue,CQ)里留下一份完成信息(Work Completion,WC)。

我通常把整个 RDMA 资源模型画成四层:最底层是主机通道适配器(HCA),上面挂着若干端口;每个端口有若干设备上下文和事件通道;设备上下文下有保护域(Protection Domain,PD)、CQ、事件通道等资源;而 QP 则是挂在 PD 底下的具体通信实体。这里有个容易被忽略的点:一个 QP 必须归属于一个 PD,PD 又连接着内存区域(Memory Region,MR),这样硬件在做 RDMA 读、原子操作的时候,才能找到本地的地址映射和权限信息。如果你曾在ibv_create_qp时遇到ENOMEM或者“protection domain mismatch”这类问题,十有八九是 QP、PD、CQ 三者的归属关系没有对齐。

另一个常见误解是认为“QP 越多越好”。在真实项目里,我见过有人为了追求极致并发,把每个 CPU 核都单独建一个 QP,结果产生数千个 QP。这些 QP 会占用 HCA 内部的硬件上下文条目,而且每个 QP 在状态切换时还要额外开销。更合理的设计往往是“线程数 × 少数几个 QP”,配合共享接收队列(SRQ)来均衡消息分配。后续章节我会写到具细节,这里先让大家把 QP 当做一个“被严格管理、双向复用、由硬件直接消费队列条目”的传输通道来理解。

2. WQE、门铃与完成队列:一次网络消息在 QP 里的完整旅程

QP 的队列里到底放着什么?答案是 WQE。你在软件里调用ibv_post_send提交工作请求,软件会把请求翻译成硬件能识别的一段描述符,这段描述符就是 WQE。一个典型的发送 WQE 至少包含:操作码(send、rdma_write、rdma_read、atomic 等)、本地地址和长度(sge 列表)、远端目标信息(远端地址、R_key),以及一些控制位(例如是否需要信号量完成通知,也就是 signaled bit)。

许多第一次写 RDMA 代码的人都犯过同一个错:以为跟普通网卡一样,ibv_post_send返回成功就是数据发出去了。实际上它只是把 WQE 放进发送队列,并“钉”在 HCA 可以访问的内存上。真正让硬件开始处理这批条目的动作,是写门铃寄存器(doorbell register)。也就是说,软件更新共享内存里的队列头指针,然后写一次门铃寄存器,通知硬件“队列里有新工作了”。

这个机制的意义一方面在于减少中断:一个进程可以批量填充几十条 WQE,只敲一次门铃,硬件连续处理完一批再触发完成交付;另一方面在于安全性:硬件和软件之间的队列缓冲区是共享内存,如果不通过门铃同步,软件写在共享内存里的内容可能不会被硬件及时看到(还涉及内存屏障和写合并的问题)。我见过有性能调优项目专门把多个小消息合并成一个大的批处理窗口再敲一次门铃,整体吞吐能提升两成左右,但代价是单条消息的延迟略微抬升。所以,延迟敏感型服务和吞吐敏感型服务在“门铃频率”上的选择是完全不同的。

发送侧的完成路径走的是 CQ。ibv_poll_cq是轮询接口,程序从 CQ 里取回 WC,判断操作成功还是失败。这里有一个几乎所有人都知道、但很少有人真的放在心上的问题:出错状态下,已经提交到队列里的很多条 WR 可能全部以错误完成的形式冲刷到 CQ。你逐条 poll 的时候不能只看第一条就停止,否则会留下大量“莫名丢失”的请求,再次出错时排查起来非常痛苦。

接收侧的逻辑稍许不同:接收队列必须预先填充好接收缓冲区,硬件收到数据并把载荷写进缓冲区之后,也会生成完成条目。如果 RQ 为空而导致消息到达,就会触发 RNR(Receiver Not Ready)重试机制——这个话题与状态机有直接关系,我会在后面专门展开。另外,多个 QP 可以共享接收队列,也就是 SRQ(Shared Receive Queue)。SRQ 的好处是接收缓冲区由多个连接共用,避免每个 QP 都囤积接收缓冲导致内存浪费,也免去频繁为短连接 pre-post 接收请求。但使用 SRQ 后,完成条目里会带srq和qp_num两个字段来告诉你这份接收数据属于哪个 QP,连接管理逻辑要额外处理身份归属。

理解了 WQE、门铃、CQ、SRQ 这些底层组件,你才能真正理解为什么 QP 状态机里会有“允许发送 / 允许接收 / 禁止发送”的区分——因为这些状态直接决定硬件对队列的处理行为。

3. QP 类型怎么选:RC、UC、UD、XRC 的行为差异与落地场景

RDMA 标准里规定了多种传输类型,最常用的是四种:可靠连接(RC)、不可靠连接(UC)、不可靠数据报(UD)和扩展可靠连接(XRC)。它们的核心差异如下:

QP 类型可靠性连接模型支持 RDMA 读写/原子?典型场景
RC可靠、按序一对一的点对点连接是存储、数据库高频事务、需要强一致的大块数据传输
UC不重传、按序(尽力)一对一的点对点连接是能容忍偶发丢包但有顺序要求的中低端场景
UD不可靠、可能失序/丢包无连接,一个 QP 可向任意目标发消息否(仅 send/recv)多对多控制平面、集群管理、多播场景
XRC可靠一对多(发送端 QP × 多个远端共享 RQ)是HPC 大规模 MPI 环境,解决接收队列扩展性问题

选型时有个非常关键的分岔:是否需要 RDMA 写(remote write)或者原子操作。UD 类型只支持 send/recv,因为 send 操作只需要在接收端显式预留缓冲区,硬件不需要了解远端内存地址和 R_key;而 RDMA write/read/atomic 必须携带远端地址与 R_key,还要依靠连接建立后的信任关系来确保安全,这只有 RC 和 UC 这种连接型 QP 具备。

我第一次在一个大规模集群项目里做选型时,被 UD 的双模特性震撼到:它虽然“无连接”,但发送端仍需知道目标的 QP 号和 Q_Key;而且 UD 有一个很妙的用途——多播。集群里想给所有节点快速散播状态,用 UD multicast 一条消息就能到达很多 peer,不需要像 RC 那样给每个 peer 建一条连接。不过 UD 的缺点同样明显:消息大小受限(MTU 大小),且完全不保证成功,业务侧必须自己加确认和重传。

XRC 是 HPC 场景的常客。它在“发起端 QP 对远端的多个接收 QP”的模型上做了一个很聪明的折中:远端只需要一个共享的接收队列,多个发起 QP 都能往这个队列里投递消息,但每个 QP 仍有自己独立的发送队列和完成队列。这意味着,100 个进程之间若全部互连,原来需要 100×99 个 RC QP,现在只要 100 个发起 QP 加少量 SRQ 即可,扩展性提升一个数量级。当然,代价是接口复杂不少,且错误隔离粒度更粗——一个接收队列出错可能影响所有使用它的人。

选型并不存在“越可靠越好”的绝对答案。我曾在某个场景里把 RC 改成 UD,配合上层业务自带的确认与重传逻辑,延迟基本没变,但连接数量下降了两个数量级,运维复杂度大幅降低。关键是先想清楚上层协议有没有重传和保序能力,再做选择。

4. 状态机逐条拆边:Reset、Init、RTR、RTS、SQD、Error

QP 最让人痛苦的部分就是状态机。这个状态机在所有传输类型下都存在,但 RC 场景里你几乎每一步都必须走过,在 UC/UD 里有些转换会省略或者只能跳到错误态。先把全集列出来:

  • Reset:初始状态,硬件上下文存在但没有配置任何网络信息。QP 号在这个阶段往往已经分配,但是发不出任何包。
  • Init:完成本地端口初始化,配置了 pkey、port、访问权限。此状态下,只允许向 RQ 发布接收请求,不允许发布发送请求。
  • RTR(Ready to Receive):已经知道对端 QP 号码、PSN,路径属性也配好。此状态下硬件可以接收远端报文,但仍然不能发送。
  • RTS(Ready to Send):本地序列号等信息配置完成,可以正常收发。
  • SQD(SQ Drain):发送队列被排空的过程态,常用于修改部分连接属性。
  • Error:错误态,硬件检测到不可恢复问题,所有在工作队列里的请求都会被冲刷到 CQ。
  • SQEr(SQ Error):只在发送队列侧出错时的中间态,部分实现中会用到。

常见的正常转换路径是Reset → Init → RTR → RTS,也就是建连标准三部曲;运行时报错可能进入SQEr或Error;要从错误中恢复,一般先把 QP 设回Reset,重新走一遍建连流程。

4.1 Reset 到 Init:配置什么、为什么必须逐个设置

Reset 到 Init 的修改需要携带四类信息:目标状态、pkey_index、port_num、qp_access_flags。pkey 是分区键,等价于传统网络里的 VLAN 划分;访问权限位则决定了你这个 QP 是否允许对端对它做远程读、远程写、远程原子操作。这一步不涉及对端信息,所以是纯本地操作。

值得注意的是,ibv_modify_qp的语义是“在指定掩码位下修改指定属性”,不是一个原子化的“状态总切换”命令。命令的掩码必须与你传入的属性完全匹配,否则会返回EINVAL。见过不少初学者写出ibv_modify_qp(qp, &attr, 0)后一脸茫然,其实掩码就是告诉驱动“你到底改了哪些字段”。

4.2 Init 到 RTR:真正建立连接的那一步

Init 到 RTR 的修改需要的信息最多,因为这一步要把对端信息填进本地硬件:对端 QP 号、对端 PSN、路径 MTU、路径属性(GID/LID、SL、端口号等)。对 RoCE 系统来说,路径属性主要来自交换“GID 索引和 GID 内容”;对 InfiniBand 来说,还包含 LID 和 SL。RTR 之后,硬件已经知道“收到哪个序号段的数据包应该放到我的哪些接收缓冲区里”。

一个容易踩的坑是 GID 选择。多网卡或多 VLAN 环境下,交换 GID 时如果把gid_index传错,会出现“能建 QP、但链路不通、卡在 RTR 或 RTS 完成前”的现象。我们通常在连接两端先通过控制通道(比如 TCP)交换本地 QP 信息、GID、PSN,再按这个信息去设置 RTR。

4.3 RTR 到 RTS:把发送侧打开

RTR 状态下还不能 post_send;只有推进到 RTS,发送队列才允许提交发送类操作。RTS 配置包括本地的发送序号 sq_psn、超时时间、重传次数、RNR 重试次数、以及并发 RDMA 读/原子操作的最大数(max_rd_atomic)。

这一阶段本质上是硬件在“准备好接收”和“准备好发送”之间做同步。在 RC 场景下,如果对端还没有进入 RTR,你这边即使到了 RTS 发出去数据,对端硬件也不会接收成功,从而触发 RNR 或丢包重传。所以实际工程里,两端推进状态机的时机往往要用“对端完成通知 + 本地确认”来保证先后顺序。

4.4 RTS 到 SQD:发送队列排空与属性修改

RTS 并不是只能往前走到错误态。标准允许在网络正常运行时把 QP 切到 SQD,也就是让发送队列排空,但不影响接收。这个设计的本意是:你想修改某些连接参数(比如max_rd_atomic、访问权限、路径 MTU 等)而又不想断开重新建连,就可以执行RTS → SQD → RTS流程。

这里有一个很多人忽略的时序:RTS 到 SQD 是“开始排空”,当 SQ 里最后一个已发出的发送请求被确认后,SQD 才算真正到达。你在代码里只能通过轮询所有已发 WR 的完成事件来判断“SQ 已经空了”,而不是修改调用返回成功就立刻改参数。否则可能直接影响还没来得及处理的在途 WR。

4.5 进入 Error 的路径与后果

错误态并不是用户可以随意进入的状态,而是由硬件主动抬起的。常见触发包括:超过重传计数(retry_cnt)、RNR 重试次数超限、本地内存权限错误、远端 QP 号或 PSN 不匹配等。QP 一旦进入 Error,未来的发送和接收操作都被禁止,所有队列里尚未处理的 WR 都会被以错误状态冲刷到 CQ。

恢复方式只有一条:把 QP 重新设回 Reset,重新走一遍Init → RTR → RTS。如果你的应用使用 SRQ,还要额外处理那些“为旧会话准备的接收缓冲”是否要重新 pre-post 到新的 QP 上。关于错误恢复的细节,我会在第六节结合参数一起讲。

5. 三次状态切换的代码级演示:手工建连全过程

理论研究半天,不如把状态机跑一遍。下面用的是 InfiniBand Verbs API(libibverbs 的底层接口),手工完成 RC QP 的建立。第一步是创建 QP:

struct ibv_qp *qp; struct ibv_qp_init_attr qp_init_attr; 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; /* 可靠连接 */ qp_init_attr.sq_sig_all = 0; /* 每个 WR 是否产生完成信号,由 WR 单独指定 */ qp_init_attr.cap.max_send_wr = 128; qp_init_attr.cap.max_recv_wr = 128; qp_init_attr.cap.max_send_sge = 1; qp_init_attr.cap.max_recv_sge = 1; qp = ibv_create_qp(pd, &qp_init_attr); if (!qp) { perror("ibv_create_qp"); exit(1); }

然后是状态机顺序推进的三次ibv_modify_qp。代码里通常把第一阶段放在“本地初始化”部分,把后面两步放在“交换完对端信息”之后。

struct ibv_qp_attr attr; int mask; /* Reset -> Init */ memset(&attr, 0, sizeof(attr)); attr.qp_state = IBV_QPS_INIT; attr.pkey_index = 0; attr.port_num = 1; attr.qp_access_flags = IBV_ACCESS_REMOTE_WRITE | IBV_ACCESS_REMOTE_READ | IBV_ACCESS_REMOTE_ATOMIC; mask = IBV_QP_STATE | IBV_QP_PKEY_INDEX | IBV_QP_PORT | IBV_QP_ACCESS_FLAGS; if (ibv_modify_qp(qp, &attr, mask)) { perror("modify qp to init"); exit(1); } /* Init -> RTR */ memset(&attr, 0, sizeof(attr)); attr.qp_state = IBV_QPS_RTR; attr.path_mtu = IBV_MTU_4096; attr.dest_qp_num = remote_qp_num; attr.rq_psn = remote_psn; attr.ah_attr.dgid = remote_gid; attr.ah_attr.sl = 0; attr.ah_attr.port_num = 1; mask = IBV_QP_STATE | IBV_QP_PATH_MTU | IBV_QP_DEST_QPN | IBV_QP_RQ_PSN | IBV_QP_AV; if (ibv_modify_qp(qp, &attr, mask)) { perror("modify qp to rtr"); exit(1); } /* RTR -> RTS */ memset(&attr, 0, sizeof(attr)); attr.qp_state = IBV_QPS_RTS; attr.timeout = 14; /* 4.096us * 2^14,约 67ms */ attr.retry_cnt = 7; attr.rnr_retry = 7; attr.sq_psn = local_psn; attr.max_rd_atomic = 1; mask = IBV_QP_STATE | IBV_QP_TIMEOUT | IBV_QP_RETRY_CNT | IBV_QP_RNR_RETRY | IBV_QP_SQ_PSN | IBV_QP_MAX_ATOMIC; if (ibv_modify_qp(qp, &attr, mask)) { perror("modify qp to rts"); exit(1); }

这段代码里最容易出的问题是对端信息的取值来源。两端都需要把自己的qp_num、psn、gid通过一个带外通道(TCP 或共享内存)交给对方。很多资料直接写“假设 remote_qp_num 已知”,但工程上这个值怎么来、什么时候来,才是真正决定你每次上线会不会崩溃的关键。

timeout字段的语义是一个指数编码的等待时间:实际超时 = 4.096 us × 2^timeout。设 14 大约 67 ms。这个数值不是随便拍的,它要根据链路距离和承载网络的时延来计算。如果在跨数据中心的长距离链路上,14 可能偏小,会导致大量IBV_WC_RETRY_EXC_ERR。生产上通常通过实测链路的往返时延乘以一个倍数来确定。

再看Init → RTR阶段的一个细节:IBV_QP_AV掩码表示你在用地址向量(Address Vector)设置路径。RC 场景下也有只基于 LID 的路径模式,但在 RoCEv2 下基本都是基于 GID 的地址向量,所以工程代码常见写法就是填ah_attr.dgid和port_num。

一个更实用的建议:用ibv_query_qp可以在任意时刻查看当前 QP 状态和所有属性。每次修改后立即打印一次状态,调试初期最好每改一次就打印一次。因为三次modify_qp的失败原因各不相同,打印能帮你快速定位是哪一步配置错误,而不是靠猜。

6. 超时、重试与 RNR:连接质量在状态机外的映射

QP 状态机能够健康地运行,很大程度上依赖于三个参数:timeout、retry_cnt、rnr_retry。

timeout前面已经提过,是发送方等待接收方确认的最长时限。如果超时,发送方会认为这条消息可能已丢失,触发重传。retry_cnt是同一报文的最大重传次数,超过之后 QP 会进入错误状态,并在 CQ 里产生IBV_WC_RETRY_EXC_ERR之类的错误完成。

rnr_retry是另一个容易被忽略的参数:当接收端 RQ 里没有预先挂好接收缓冲区,硬件收到数据后无法投递,它不会默默丢弃,而是会给发送方回一个 RNR 信号——相当于说“我现在没准备好,你过会儿再试”。发送方收到 RNR 后,会按照rnr_retry指定的次数重试。如果rnr_retry设为 0,很多实现的含义是“不做重试”;设为 7 则保留大约 7 次机会。

这两个重试的语义不同,我在排查时经常遇到有人把它们混为一谈:

参数针对情况达到上限后的动作
timeout没有收到确认/响应触发重传,并累加 retry_cnt
retry_cnt同一请求重传次数到上限QP 进入 Error
rnr_retry接收端 RQ 未就绪按退避策略重试,到上限后 QP 进入 Error

在实际项目里,rnr_retry的调优通常与接收缓冲区预分配策略一起做。如果接收侧总是来不及 pre-post 接收请求,可选方案有:加大接收队列深度、用 SRQ 统一预分配、或者让发送侧降速。我自己的经验是 SRQ 能极大缓解 RNR,因为接收缓冲是共享池,不像每个 QP 各自为政时容易在某一刻“恰好全部用完”。但 SRQ 的缺点是接收完成后你不知道它是哪个连接的消息,所以在多路复用场景里,要在接收缓冲里预留一个头部字段写明 QP 信息或连接 ID。

还有一个更隐蔽的问题:连接质量导致的错误不一定立刻表现为 QP 状态机变化。比如远端主机重启时,本地 QP 可能长时间停在 RTS,直到对端有反应才在超时计数中走向 Error。所以生产系统里,除了 QP 状态机本身,还要做应用层面的心跳和断连判断——把“物理链路故障”尽快转化为“QP 错误”并启动重建,否则客户端会一直挂着等一个永远不会到来的完成事件。

7. 排障实录:当 QP 停在奇怪的地方时我做了什么

这一节分享两个实际排障思路,用来演示方法论。第一个案例是性能测试中不断出现IBV_WC_RETRY_EXC_ERR。错误完成出现在发送 CQ,但 QP 状态看起来还是 RTS,日志里也没有内核错误。一开始从网络质量入手排查,链路测试却一切正常。

后来用ibv_query_qp打印了完整的 QP 属性,才发现timeout只有 8,换算下来只有 1ms 左右。当时测试环境的节点经过了两层交换,链路 RTT 约 0.5ms,加上接收侧的软件处理抖动,经常超过 1ms 的等待窗口。把timeout调整到 14 之后,错误完成数量立刻归零。这个案例的教训是:状态机正确并不代表参数正确,参数配置直接影响状态机能不能稳定运行。

第二个案例更有代表性:某个组件升级后,所有新建连接都卡在Init → RTR之前,连一次ibv_modify_qp都没有完全成功。检查代码后发现原因不在 RDMA 协议层,而是带外交换的对端信息里 GID 全部填成了 0。因为在多网卡机器上,ibv_query_gid会返回多个 GID,新版驱动把默认端口变了,而代码还在硬编码port_num = 1的假设。这也是为什么我现在总是强制在代码里显式打印所有网卡的 port 与 GID 索引,而不是想当然地取第一个。

最后分享一个小方法,很多老手其实都在用:把 QP 状态转换的每一个ibv_modify_qp调用封装成带上下文日志的函数,日志里包含当前状态、目标状态、错误码、预期掩码。不要等到线上出问题才打日志,开发阶段就把这套日志函数接好。状态机本身并不复杂,复杂的永远是与它对不上号的代码逻辑和网络假设。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询