RDMA 学习

围绕一次 RDMA Write,整理资源与地址、请求提交、完成队列以及驱动分工。重点区分请求提交成功、设备完成、远端得知数据到达,以及业务已经消费数据这几个时刻。

基本概念

RDMA(Remote Direct Memory Access,远程直接内存访问)允许网络适配器在已建立的映射和权限范围内,直接访问另一台主机的内存。以 RDMA Write 为例,发起端指定远端地址和访问密钥,接收端的网卡完成数据放置,不需要接收端 CPU 为每次写入执行一遍接收和复制。这不表示整个系统不再需要 CPU,资源建立、提交请求和消费数据仍然有软件参与。

verbs 是 RDMA 软件栈提供的资源管理与工作请求接口,Linux 用户态常用的设备无关接口库是 libibverbs。设备相关的用户态库负责实现这些接口,称为 provider。InfiniBand(IB)是包含链路、网络和传输机制的互联标准,原生支持 RDMA,并不是某一家厂商的私有协议。RoCE(RDMA over Converged Ethernet)则把 RDMA 传输放到以太网上。RoCEv1 使用以太网封装,RoCEv2 使用 UDP/IP 封装,能够经过 IP 路由。RDMA 编程指南和 RoCE 文档分别介绍了这些层次。

下文沿用 A 向 B 写数据的例子,两端使用 RC(Reliable Connection,可靠连接):通信端点一对一配对,设备执行协议确认、顺序控制和重试。可靠不等于任何故障下都能成功,重试耗尽等情况仍会使请求失败。请求与完成的主线先限定在普通主机内存,GPU 的同步条件放在补充部分。

内存、队列与保护域

打开 RDMA 设备会得到 ibv_context,它代表应用对某个 RDMA 设备的访问上下文。设备可以暴露多个端口,设备对象也不一定与物理网卡一一对应。RDMA 资料中的 HCA(Host Channel Adapter,主机通道适配器)指主机侧的适配器,下文统称网卡。

A 分配了 a_buffer,B 分配了 b_buffer,还需要把这两段内存注册成 MR(Memory Region,内存区域)。MR 描述设备可访问的地址范围、映射和权限。注册不是复制数据,也不要求这段虚拟地址对应的物理页连续。注册结果中的 lkey 用于本端请求访问本地 MR,rkey 则用于远端发起的 RDMA 访问。B 把地址和 rkey 告诉 A 后,A 才有了描述远端目标所需的信息。ibv_reg_mr()

QP(Queue Pair,队列对)是应用提交通信工作的对象,在 RC 中,一个 QP 与远端的一个 QP 配对。这里使用的普通 QP 包含发送工作队列 SQ(Send Queue)和接收工作队列 RQ(Receive Queue)。应用提交的工作描述叫 WR(Work Request,工作请求)。SQ 接受本端发起的工作,除了 Write,还可以是把远端内容读到本地的 RDMA Read,或由对端接收请求决定数据落点的 SEND。所以 ibv_post_send()名字里的 send 表示向 SQ 提交,并不只指 SEND 操作。

RQ 中放置预先提交的 Receive WR,供 SEND 等需要接收资源的操作使用。在 RC 中,需要接收资源的请求到达时,如果没有可匹配的 Receive WR,就会得到 RNR(Receiver Not Ready,接收端未就绪)响应。普通 Write 不依赖这类接收资源,具体区别在后文展开。

PD(Protection Domain,保护域)把 MR、QP 等资源组织在同一套本地访问保护关系中。本例中,A 的 QP 与它使用的本地 MR 属于同一个 PD,B 接受写入的 QP 与目标 MR 也属于同一个 PD。两台机器的 PD 是各自创建的对象,不需要有相同的编号。对普通 Write,B 的目标 MR 要允许远端写,注册时还必须同时允许本地写,接收该访问的 QP 也要开启相应权限。内存注册与 QP 属性分别控制这两层授权。

执行结果放在 CQ(Completion Queue,完成队列)。创建 QP 时,send_cq 和 recv_cq 分别指定发送侧和接收侧使用的 CQ,它们可以是同一个 CQ,多个 QP 也可以共享 CQ。CQ 关联到设备上下文,不属于某个 PD,因此同一设备上下文中不同 PD 的 QP 也能共享它。ibv_create_qp()接受 PD 和 CQ 参数,ibv_create_cq()则没有所属 PD 这个参数。

下面只画同一进程内的资源关系,PD X/Y 不是两台机器:

1
2
3
4
5
6
7
8
ibv_context
├── PD X
│ ├── MR X
│ └── QP X ──┐
├── PD Y │
│ ├── MR Y ├── 完成记录进入 CQ Z
│ └── QP Y ──┘
└── CQ Z

端口、地址与连接状态

建连时还需要区分几种标识。Port 是 RDMA 设备暴露的物理或逻辑端口,不是 TCP/UDP 端口。QPN(Queue Pair Number)是 QP 的编号,在指定远端设备和路径之后用它定位目标 QP。PSN(Packet Sequence Number)是传输协议中的包序号,RC 建连时双方交换初始 PSN,使发送端的起始序号与接收端期望的序号匹配。它不是应用请求的编号。

GID(Global Identifier,全局标识符)用于标识 RDMA 网络中的端口等对象,IB 和 RoCE 都会用到。在 RoCE 中,GID 表项与网口的 IP 地址及 RoCE 类型关联。ibv_query_gid()读取的是本地端口 GID 表中指定索引的值,选择索引时还要核对网口和 GID 类型,不能只看到地址相同就认为表项等价。LID(Local Identifier,本地标识符)则是 IB 子网内用于寻址的标识,由管理该子网拓扑与路径的 Subnet Manager 分配。RoCE 不使用 IB 的这套链路层地址,查询 RoCE 端口时 LID 显示为零,不能把它推广成所有 RDMA 端口的性质。标识符定义、RoCE GID 与 LID

普通 RC QP 通过 ibv_modify_qp()经历 RESET → INIT → RTR → RTS。INIT 设置本地端口和访问权限,RTR(Ready to Receive)配置远端 QPN、路径与接收起始 PSN,使 QP 具备接收条件,RTS(Ready to Send)再配置发送起始 PSN、重试等参数,使发送侧可以工作。它们是 QP 的状态转换,TCP 建连本身不会代替这些配置。接口手册列出了每次转换需要的属性。

RDMA Write 的基本链路

A 通过 rdma 往 B 写操作的流程,A 大概是:

  1. 通过 ibv_get_device_list、ibv_open_device、ibv_free_device_list 打开设备。
  2. 通过 ibv_query_gid 获得 gid。
  3. 通过 ibv_alloc_pd、ibv_create_cq 创建 pd 和 cq。
  4. 通过 posix_memalign 分配 a_buffer,并把它注册到 pd 中得到一个 mr。
  5. 创建一个 ibv_create_qp,需要指定一个 ibv_qp_init_attr。里面的 max_send_wr 指定了发送队列的深度。
  6. 调用 ibv_modify_qp,让 A 的 QP 从 RESET 到 INIT。
  7. 创建一个控制面的 fd 和套接口,链接到 B,把 A 的 qp 的 qp_num、a_psn、gid_text 发送过去。
  8. 从控制面的 fd 去读 B 的 qpn、psn。gid_text、buffer 的地址和 rkey。
  9. 用这些信息,设置 A 的 ibv_qp_attr,调用 ibv_modify_qp,把状态设置为 RTR。
  10. 用 ibv_modify_qp 把 A 的状态再设置为 RTS。
  11. 从控制面等待 B 也是设置好自己的 QP。
  12. 构造一个 ibv_sge,描述要发送的 buffer。构造一个 ibv_send_wr 封装这个 sge,然后设置 wr.rdma 字段,就是 b 那边的 address 和 rkey。
  13. 调用 ibv_post_send 发送。
  14. 使用一个循环,不停调用 ibv_poll_cq 去查询发送的情况。
  15. 清理:
    1. 关闭控制面 fd
    2. 销毁 qp、mr、buffer、cq、pd、context。

B 大概是:

  1. 和 A 一样。
  2. 启动控制面,listen 一个地址。
  3. 在 A 链接之后,得到 control_fd。
  4. 先从 control_fd 获得 A 那边的信息。
  5. 然后把自己这边的额信息发送过去。
  6. 用 ibv_modify_qp 切换到 RTR 状态。
  7. 用 ibv_modify_qp 切换到 RTS 状态。
  8. 告诉 A 自己已经 READY 了。
  9. 从控制面直到 A 已经完成了传输。
  10. 调用 __sync_synchronize
  11. 发送 OK 给 A。后面就销毁掉。

控制面的交互大概是:

  1. B 启动 TCP Server

    1
    2
    B: listen()
    B: accept()
  2. A 连接 B

    1
    A: connect()
  3. A 把自己的 QP 信息发给 B

    1
    2
    A -> B:
    <a_qpn> <a_psn> <a_gid>
  4. B 把自己的 QP 信息和 MR 授权发给 A

    1
    2
    B -> A:
    <b_qpn> <b_psn> <b_gid> <b_buffer_address> <b_rkey>
  5. A 和 B 分别设置自己的 QP

    1
    RESET -> INIT -> RTR -> RTS
  6. B 告诉 A,B 已经准备完成

    1
    2
    B -> A:
    READY
  7. A 执行一次 RDMA Write

    1
    A buffer --RDMA Write--> B buffer

    A 使用:

    • 本地 a_buffer、length、a_lkey
    • 远端 b_buffer_address、b_rkey,目的是能够获得授权写入 B buffer
  8. A 轮询 CQ,确认 RDMA Write 完成,然后通知 B

    1
    2
    A -> B:
    DONE
  9. B 检查自己的 buffer,验证成功后回复 A

    1
    2
    B -> A:
    OK
  10. A 和 B 关闭 TCP 连接并清理 RDMA 资源

注意,READY、DONE、OK 是这个 demo 自己定义的控制消息,不是 verbs API。原因是这里是单边语义,B 的 CPU 不参与复制,所以需要控制面协助通知 B 是否已经完成。

KVCache 的传输用什么语义比较好?单边语义的有几个好处:

  • 数据直接进入目标 KV Cache 地址,不需要中间接收 buffer。
  • 目标端不需要为每个数据块调用 ibv_post_recv()。
  • 目标端不需要持续处理 Receive CQ、补充 Receive WR。
  • 不会因为 Receive Queue 没有槽位而发生 RNR。
  • 源端可以把大量 TransferEntry 批量转成 RDMA Write WR。
  • 目标 CPU 不参与每一次数据搬运,适合大块 GPU/Host 内存复制。

请求提交与完成

前面的链路中,A 调用 ibv_post_send(),随后循环调用 ibv_poll_cq()。这两个调用之间,请求由谁保存、谁执行、谁报告完成,决定了 buffer 什么时候能复用,也决定了没有完成时应该检查哪一层。

下面仍然只看 A 向 B 的一次普通 Write。连接和注册已经完成,a_buffer 是源,b_buffer_address 和 b_rkey 来自控制协议。网卡需要从注册的源缓冲读取数据,数据长度在 MR 范围及设备支持范围内。用户态实现部分以 rdma-core v59.0 的 mlx5 provider 为核对版本,版本号用于限定源码,不代表业务环境使用这个版本。

WR 与 WQE

传统发送接口用 struct ibv_send_wr 表示 WR。它描述执行什么操作、本地数据在哪里,以及远端目标是什么。SGE(Scatter/Gather Element,散布/聚集元素)描述一段本地缓冲区,包含地址、长度和 lkey。一个 WR 可以引用多个 SGE,本例只有一段连续源数据,因此 num_sge 设为 1,并不是某个经验调优值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct ibv_sge sge = {
.addr = (uintptr_t)a_buffer,
.length = length,
.lkey = a_mr->lkey,
};

struct ibv_send_wr wr = {0};
wr.wr_id = request_id;
wr.sg_list = &sge;
wr.num_sge = 1;
wr.opcode = IBV_WR_RDMA_WRITE;
wr.send_flags = IBV_SEND_SIGNALED;
wr.wr.rdma.remote_addr = b_buffer_address;
wr.wr.rdma.rkey = b_rkey;

struct ibv_send_wr *bad_wr = NULL;
int ret = ibv_post_send(qp, &wr, &bad_wr);

request_id 由应用分配,放在 wr_id 中,用于在完成时找到原请求。这里假设 QP 创建时 sq_sig_all = 0,由每个 WR 选择是否报告正常完成。IBV_SEND_SIGNALED 明确要求本次请求报告完成,否则后面的等待就没有成功完成可取。这里的 signaling 指完成记录的生成,与是否唤醒一个阻塞线程是两件事。

WR 不直接等于设备执行的队列条目。用户态驱动读取 WR,按网卡格式生成 WQE(Work Queue Element,工作队列条目),写入设备使用的工作队列,再通知网卡有新工作。类似于异步 I/O 的提交描述符,WQE 描述的是一次待执行的工作。一条较大的 Write 会拆成多个网络包,WQE 不能按网络包来理解。

按照 ibv_post_send() 的约定,ret == 0 表示提交成功,非零值是提交阶段检测到的错误码。调用成功返回并不保证网卡已经读完 a_buffer,因此 A 此时还不能改写或释放源数据,也不能撤销相应注册。需要等待的是设备执行完成,不是用户态函数执行完。把源数据内嵌进 WQE 的 inline 方式,以及批量提交的失败边界,放在后面的补充中。

CQE 与 WC

设备完成这次 Write 后,把结果写入 QP 的 send_cq。设备格式的完成记录叫 CQE(Completion Queue Entry,完成队列条目)。它可以报告成功,也可以报告失败,所以看到完成记录不等于请求成功。

A 调用 ibv_poll_cq(),用户态驱动读取 CQE,再把设备格式整理成 verbs 接口中的 WC(Work Completion,工作完成信息),通过 struct ibv_wc 返回。CQE 与 WC 描述的是相应的完成结果,但一个是队列中的设备表示,一个是应用接口的表示。

本例只跟踪一个请求,所以一次最多取一个结果:

1
2
struct ibv_wc wc;
int count = ibv_poll_cq(cq, 1, &wc);

count == 0 表示这次没有取到结果,之后可以继续轮询。返回正数才有相应数量的 WC 可读,负数表示轮询调用出错。取到 WC 后,还要检查 wc.status,只有 IBV_WC_SUCCESS 才是请求成功,不能把 count > 0 当成 Write 成功。失败时也不能照常读取所有 WC 字段,手册明确保证有效的只有 wr_id、status、qp_num 和 vendor_err。

应用用 wc.wr_id 找回请求并更新状态。对于本例,取得成功完成后,A 就可以复用 a_buffer。这条生命周期规则来自 ibv_post_send(),而 B 的业务是否已经读过数据、是否处理成功,是另一层状态。

1
2
3
4
5
6
7
8
9
A 应用构造 WR
↓ ibv_post_send()
A 用户态驱动构造 WQE,发布到 SQ,通知网卡
↓
A、B 的网卡执行 RC Write 与协议确认
↓
A 的 CQ 中出现 CQE
↓ ibv_poll_cq()
A 应用取得 WC,检查状态,更新请求并复用源缓冲

RC 的协议确认由设备处理,不需要 B 的业务程序回复 OK,A 才能取得 Write 的完成。普通 Write 也不消耗 B 的 Receive WR,不会为这次写入在 B 端生成对应的 Receive WC。因此,“等待响应”至少要明确等的是设备的协议响应、本地完成,还是远端业务回复。

DONE、OK 与目标数据的使用

前面通过 TCP 发送的 DONE 和 OK 仍然有独立作用。A 在成功完成后发 DONE,把写入完成这件事通知 B。B 检查或消费数据后再返回 OK,A 才知道 B 执行到了应用协议约定的阶段。发送缓冲可复用与远端业务结束,不是同一个回收条件。

这里要把目标内存的条件交代完整。普通主机内存例子限定在设备与 CPU 之间支持 DMA 一致性的映射上,也就是设备写入能通过平台的一致性机制被 CPU 观察到。B 在得知传输完成后才读取目标区域,不能与仍在进行的写入并发访问。如果收取 DONE 的线程还要通知另一业务线程读数据,这次线程间交接仍需应用自己的同步协议。TCP 在这里承担通知,不能凭一条消息推出任意内存与设备组合的可见性保证。Linux DMA 文档区分了缓存一致性与访问顺序保证。

链路中列出的 __sync_synchronize()是 GCC 的全内存屏障,它约束 CPU 访存顺序,不负责等待某个 RDMA 请求完成,也不能代替非一致性 DMA 所需的缓存维护,更不能代替 GPU 的可见性同步。它本身没有表达“哪个远端请求已经完成”的信息。GCC 接口定义所提供的保证仅是屏障。后面把目标换成 GPU 时,需要重新建立设备写入与消费者之间的顺序,不能只沿用这个函数。

内核旁路与设备访问

上面的 ibv_post_send()和 ibv_poll_cq()仍然由 CPU 执行,但 mlx5 的普通用户态快速路径不需要为每次调用进入内核。这就是这里所说的 kernel bypass,内核旁路。

内核先参与资源准备。创建 QP/CQ、注册 MR、建立访问权限和映射等操作,通过 RDMA 子系统和设备驱动完成。mlx5 使用的内核模块包括 mlx5_ib 和 mlx5_core。资源准备好以后,进程可以在获准的范围内访问队列和设备映射,后续不用把每个 WR 再交给内核。Linux 用户态 verbs 文档明确区分了资源管理的慢路径和直接访问映射资源的快速路径。

用户态也有驱动,mlx5 对应的 provider 是 libmlx5。它运行在应用进程中,负责生成 WQE、维护队列、通知设备,以及读取和解释 CQE。因而,只确认内核建连路径正常,还不能排除用户态驱动路径中的问题。

这个边界可以从 v59.0 的 verbs.h直接看到:ibv_post_send()调用设备上下文中的 ops.post_send,ibv_poll_cq()调用 ops.poll_cq。沿 mlx5 实现继续看,分别进入 mlx5_post_send()和 mlx5_poll_cq()或对应 CQE 格式的变体。这一段是应用进程内的函数调用。

队列发布与 doorbell

doorbell 是主机通知设备有新工作的机制。mlx5 提交时既更新内存中的 doorbell record,记录队列的提交位置,也写入映射到用户态的设备区域,通知设备检查新工作。内存中的队列记录和向设备发出的通知不是同一处写入。

这两个动作需要顺序保证。如果设备先收到通知,而描述符内容还没有对设备可见,它就不能正确执行工作。v59.0 的 post_send_db()先调用 udma_to_device_barrier(),再更新 doorbell record 和进行 MMIO 写。源码注释明确说明,这个屏障用于保证描述符先写好。这里的 MMIO 虽然由用户态代码发起,仍然是在访问实际设备。

完成方向也有对应的顺序。mlx5 的 CQE 带有 ownership bit(所有权位),provider 根据这个位和当前队列位置,判断条目是否已经交给软件消费。确认可用后,再通过 udma_from_device_barrier()保证后续读取 CQE 内容的顺序,然后解析结果并更新消费位置。cq.c中的这条路径表明,ibv_poll_cq()是在读取和消费完成队列,并不是每次向固件同步发送一条查询命令。

FW(Firmware,固件)是运行在设备内部的软件,参与设备控制和内部状态管理。数据传输还由网卡的协议与 DMA 硬件引擎承担,不能把每个 WQE 都理解成设备上的一次软件 RPC。应用、用户态驱动、内核驱动、固件和设备硬件是不同层次,排障时需要说明证据覆盖了哪一层。

TCP 上交换 QPN、地址、密钥和 READY/DONE/OK,是应用之间的控制协议。内核驱动向本机网卡下发创建或修改 QP 的命令,是本机的设备控制路径。两者都可以叫控制面,但它们不是同一条链路。QP 配置成功,也不等于后续每个 WQE 的发布、执行和 CQE 的读取都已经验证成功。

RDMA 与 TCP 的协议栈对比

比较 RDMA 和 TCP 时,既要比较通信语义,也要比较这些语义由谁实现。TCP 提供可靠字节流,RDMA 提供对注册内存的访问和相应工作完成接口,它们不是同一层的两个协议。下面把范围限定为 Linux 普通 socket TCP 与 mlx5 硬件上的用户态 RC/RoCEv2。两者都使用普通主机内存,TCP 使用带复制的 send()/recv()路径,RDMA Write 不使用 inline。连接、设备队列和 MR 等资源已经准备好。

仍然让 A 把 a_buffer中的内容传给 B。TCP 这一边,A 把字节交给内核,由 B 调用 recv()放入 b_buffer。RDMA 这一边,A 提交包含 B 的目标地址与 rkey的 Write,网卡负责把数据放进指定位置。沿着这段数据看下去,用户态、内核和设备的分工就能对应起来。

用户态接口与内核协议处理

Linux socket TCP 与硬件 RDMA 的用户态、内核态和设备分层路径

图中的实线表示发送提交路径,虚线表示资源配置与设备管理关系,没有画接收、协议确认和完成返回。特别是设备内部的固件框,不表示每份载荷都必须先交给固件软件转发。

TCP 发送时,应用调用的 libc 接口主要封装系统调用。进入内核后,socket 层把操作交给 TCP 实现,由它维护连接、序号、重传、流量控制和拥塞控制状态,再经 IP 与网络发送层交给网卡驱动。一个 send()可以只接受部分数据,正返回值说明本次接受了多少字节,并不保证对端已经收到,更不保证对端应用已经处理。send 手册与 Linux TCP 说明

在这里选定的普通复制路径中,内核把已接受的载荷复制到自己持有的内存。因此,send()返回后,A 可以复用返回字节数所覆盖的源缓冲范围,后续发送和必要的重传不再依赖这份用户内存。Linux v6.12 的 tcp_sendmsg_locked()中可以看到复制分支。TCP 发送返回和 RDMA Write 完成都能与 buffer 生命周期相关,但它们并不是同一个执行阶段。

RDMA 发送则通过 libibverbs进入 libmlx5。provider 把 WR 转成 WQE,写队列和 doorbell,网卡接手后执行 RC 传输。本次提交没有经过 Linux 的 socket、TCP 或 UDP 数据收发路径。ibv_post_send()返回时,网卡仍然可以尚未读完源缓冲,所以要按前文的完成规则等待,才能复用数据。

RoCEv2 在线路上有 UDP/IP 头,不表示应用调用了 UDP socket,也不表示每个 RoCE 包都经过 Linux UDP 协议栈。这里的封装和 RC 传输由网卡处理,RC 的确认与重试也不是由 UDP 提供的。内核仍参与网口、地址和路径等配置,NVIDIA RoCE 文档明确区分了这些配置依赖与硬件数据路径。

这使内核承担的工作发生了变化。普通 TCP 中,它既准备资源,也持续处理连接的数据传输。硬件 RDMA 的内核路径主要建立受保护的访问条件,包括设备资源、MR、QP/CQ 和用户态映射,普通提交与轮询随后可以直接使用这些资源。阻塞等待完成通知、处理异步错误和销毁资源时,内核仍会参与,不能把快速路径的旁路推广成整个连接生命周期都不经过内核。

驱动、固件与硬件的分工

“内核、驱动、固件”不是三个互斥的位置。内核驱动运行在主机内核态,用户态驱动运行在应用进程里,固件运行在网卡内部。TCP 和 RDMA 的关键区别之一,是哪些设备队列由内核驱动操作,哪些可以交给用户态 provider 操作。

以同一套 mlx5 硬件为例,Linux 以太网路径中的 mlx5e 是 mlx5_core里的以太网驱动部分。它接收内核协议栈交来的 **sk_buff**,也就是保存包元数据、并关联载荷内存的内核对象。驱动建立 DMA 映射,准备发送描述符,再通过 doorbell 通知设备。发送结束后,它处理设备的完成记录,回收相应的映射和队列资源。v6.12 的 en_tx.c里同样能找到 WQE、CQE 和 mlx5e_poll_tx_cq()。

因此,WQE、CQE、doorbell 和 DMA 并不是只有 RDMA 才有。普通 TCP 应用没有直接看到这些设备队列,是因为它们由内核网络驱动管理。硬件 RDMA 则把获准的队列访问能力交给用户态 provider,并通过 verbs 暴露以工作请求为单位的完成语义。TCP 网卡发送队列的一条完成,不等于 TCP 对端已经确认对应字节,更不等于对端应用处理完成。

两条路径也都会使用网卡固件。内核驱动通过设备命令接口创建、配置和管理资源,mlx5 的 cmd.c明确把这类操作区分为驱动提交命令和 FW 执行命令。固件配合硬件实现设备功能,但公开的主机侧调用路径不足以确定某个型号、某个固件版本内部每项工作由微码还是固定逻辑完成。因此这里把包处理归到设备侧,不把每个 WQE 画成一次固件软件 RPC,也不把固件描述成只在开机时运行。

执行位置 Linux 普通 socket TCP mlx5 用户态 RC/RoCEv2
用户态应用与库 调用 socket 接口,处理字节流与业务协议 提交 WR,消费 WC,管理注册 buffer 与业务协议
用户态设备驱动 普通 libc socket 路径不直接操作网卡队列 libmlx5生成 WQE、发布 doorbell、消费 CQE
内核协议与资源层 管理 socket,并持续执行 TCP/IP 协议处理 RDMA 子系统管理资源、授权和映射,普通数据快路径旁路
内核设备驱动 逐批处理发送、接收和设备完成 创建、配置、销毁设备资源,并处理相应事件
网卡固件 参与设备控制与内部管理 参与设备控制与内部管理
网卡硬件 DMA、包收发及已启用的协议卸载 DMA、RC 协议处理、MR 访问检查和数据放置

TCP 一侧已经存在硬件卸载。例如 TSO(TCP Segmentation Offload,TCP 分段卸载)让网卡按内核给定的信息,把较大的待发送数据分成线路上的 TCP 段,校验和计算也可以交给网卡。这减少了主机工作,但不代表连接状态、重传和拥塞控制已经整体转移到设备。Linux 分段卸载文档描述了内核给驱动和硬件提供的分段信息。

接收路径与数据放置

普通复制型 TCP 与非 inline RDMA Write 的载荷缓冲路径

图中只画载荷经过的主要 buffer,不把 sk_buff元数据、设备内部暂存、协议头和完成记录算作另一份应用载荷。两条路径都通过 DMA 在网卡与主机内存之间搬数据,差别在于 DMA 指向哪段内存,以及 CPU 是否还要复制载荷。

TCP 的 B 端网卡先把包写到内核驱动准备的接收内存。驱动处理接收完成,把包交给内核网络栈。这里常用 NAPI,即 Linux 网络子系统组织驱动接收处理和发送回收的轮询机制。它可以由中断触发调度,也支持主动轮询,因此不能把普通 TCP 描述成“每来一个包,应用线程就被中断一次”。NAPI 运行在内核的处理上下文中,与用户线程调用 ibv_poll_cq()是不同层的轮询。NAPI 文档、mlx5 接收实现

内核 TCP 负责检查连接和序号、处理乱序、推进可读字节流。B 调用 recv()时,内核再把相应字节复制到该调用提供的用户缓冲。Linux v6.12 的 tcp_recvmsg_locked()中,普通接收分支调用 skb_copy_datagram_msg()完成这次复制。A 的一次 send()与 B 的一次 recv()没有消息边界上的一一对应关系,业务仍需自行判断这段数据何时收齐。

普通 RDMA Write 的 B 端则由网卡找到目标 QP,检查远端访问权限、rkey与地址范围,再把数据放入 B 已注册的目标区域。每次写入不需要经过 B 的内核 TCP/IP 栈,也不需要 B 调用 recv()把载荷搬进最终 buffer。B 仍要事先提供目标空间,事后按通知和可见性协议开始消费,不能因为省掉了接收复制就省掉数据生命周期管理。

至此能分清几种“完成”:TCP 的 send()返回表达内核接受数据,设备 TX 完成用于驱动回收发送资源,TCP ACK 表达对端传输层确认,recv()返回表达这次调用取出了字节。RDMA 的提交返回与 WC 则分别表达工作已提交和工作执行结果。两边都要通过额外的业务状态,才能知道 B 已经处理完这段数据。前面使用的 DONE/OK就是这种应用层交接。

其他实现路径补充

减少复制、内核旁路和协议卸载是不同的优化维度。例如 Linux 的 MSG_ZEROCOPY可以让 TCP 发送引用用户页面,减少用户态到内核的载荷复制,但仍使用内核 TCP 协议栈。应用还要等待 socket 错误队列中的回收通知才能复用页面,不能继续套用普通复制型 send()的生命周期规则。它也不保证每次都消除复制,内核文档明确允许退回复制路径,通知表达的是用户页面可以复用,而不是远端业务完成。MSG_ZEROCOPY 文档

RDMA 也不必然意味着设备执行了全部传输逻辑。Linux 的 RXE 是软件 RoCE 实现,其协议处理路径有内核软件参与,不能套用上图 mlx5 的硬件分工。另一方面,iWARP是一组能在 TCP 之上提供 RDMA 的协议,说明 RDMA 与 TCP 也不是天然互斥的选项。RFC 5040给出了 RDMA 协议位于 TCP 之上的分层关系。讨论某个系统的 CPU 开销或时延时,需要明确实际 API、软件实现和硬件能力,不能只凭“TCP”或“RDMA”这个名称推断。

完成队列的共享与进展

完成分发

单个请求只要找到自己的完成,多个 QP 共用 CQ 时,结果就会混在一起。应用正在等待请求 X,不代表下一次 poll 出来的 WC 就属于 X。完成处理代码要利用应用分配的 wr_id,必要时结合 qp_num,把每条结果交给对应请求的状态机。

这里有个决定实现方式的性质:poll 是消费,不是窥视。ibv_poll_cq()规定,每个取出的完成都会从 CQ 移除,不能再放回去。为了等待 X 而丢掉其他 WC,会让那些请求失去完成通知。CQ 空间是在消费完成记录时释放的,不会等应用的业务回调执行完才释放。

因此,请求的状态更新和等待逻辑需要分开。完成线程负责分发所有已经到达的结果,等待 X 的业务线程观察 X 的状态。wr_id 所指向的请求状态也必须活到完成处理结束,不能在请求提交后就回收或复用。

完成线程停顿与 CQ 溢出

把例子扩展为两个独立 QP:QP_X 的完成进入 CQ_X,QP_Y 的完成进入 CQ_Y,由同一个线程 T 轮流消费。只选两个 QP,是为了说明一条等待路径如何阻断另一条完成路径,不是在规定连接规模。

1
2
3
QP_X → CQ_X ─┐
├→ 完成线程 T → 各请求的状态
QP_Y → CQ_Y ─┘

假设 T 处理请求 X 时,进入一个只轮询 CQ_X 的循环,直到 X 完成才退出。X 暂时没有结果,T 就不会再消费 CQ_Y。但 QP_Y 已提交的工作仍在执行,并把结果写进 CQ_Y。

再假设这批工作会报告的完成数量超过 CQ_Y 的剩余容量,而且能在 T 恢复消费之前完成。当未消费的记录占满队列,设备还要写入下一条记录时,就发生 CQ overrun,完成队列溢出。这里特意选取了容量装不下这批结果的配置,不是容量建议,也不依赖停止消费后还能无限提交新工作。

这个时序中,溢出依赖消费停顿和足够多的完成记录同时成立。只有 X 一直没有完成,而没有别的完成写入队列,并不会把 CQ 填满。所谓“其他事件”也必须具体到进入这个 CQ 的工作完成,例如其他 QP 的发送完成或接收完成。没有必要假设同一个 RC QP 的后续操作可以任意越过前面的操作。

如果 X 与其他请求共用一个 CQ,且 T 等待 X 时继续正常 poll、分发其他 WC,X 缺少完成这件事本身就不会堵住 CQ。甚至可以只有 X 一个未完成请求,而 CQ 始终为空。此时占住的是请求及相关资源,和完成记录过多是不同的问题。

由这个例子可以直接得到完成线程的约束:负责推进多个 CQ 的线程,不应在单个请求上进入无法退出的局部等待。它可以更新请求状态,再唤醒业务线程,耗时业务回调也不宜直接阻塞完成循环。

CQ 容量能吸收突发,不能解决长期无人消费。容量要结合所有关联 QP 的完成数量和允许的消费停顿考虑,也要考虑错误完成。创建 CQ 时请求的是最低容量,实际容量应读取返回对象的 cqe 字段。ibv_create_cq()与 ibv_poll_cq()分别规定了容量和溢出语义。在普通 verbs 配置下,overrun 会触发 IBV_EVENT_CQ_ERR,该 CQ 不再可用,需要进入受影响资源的恢复流程,不能继续把它当成普通积压处理。

工作完成与 CQ 通知

前面都是忙轮询。如果希望没有工作时阻塞等待,可以创建 completion channel(完成通知通道),把 CQ 关联到它,通过 ibv_req_notify_cq()请求通知,再用 ibv_get_cq_event()等待哪个 CQ 需要处理。通知用于唤醒应用,实际结果仍保存在 CQ 中,醒来后还要 ibv_poll_cq()。

这几种对象不宜都笼统叫作“完成事件”:

对象 获取方式 表达的状态
工作完成 WC ibv_poll_cq() 哪个请求完成,以及成功或失败状态
CQ 通知 ibv_get_cq_event() 哪个 CQ 发出了通知,需要检查
异步状态或错误事件 ibv_get_async_event() QP、CQ 或设备等资源的状态变化,例如 IBV_EVENT_CQ_ERR

ibv_req_notify_cq(cq, 0)中的 0 表示不把通知限定为带 solicited 条件的完成。通知请求是一次性的,下一次通知需要重新请求,和 IBV_SEND_SIGNALED决定是否生成正常发送完成是两套机制。通知接口定义

ibv_get_cq_event()的示例给出的处理顺序是:在工作开始前请求通知,取得通知后确认事件、重新请求通知,然后把 CQ 排空,再进入下一轮等待。重新请求通知要在排空之前。如果先排空再请求,两者之间新到达的 CQE 就落在没有通知请求的窗口中,之后仅阻塞等通知会漏掉这次进展。

反过来,在重新请求通知之后、排空之前到达的 CQE,既能触发通知,又会被本轮 poll 取走。因此下一次取得通知时,即使 CQ 已经为空,也是合法时序。通知不是每条 CQE 的一一对应副本。如果是在已经有工作的 CQ 上切换到通知模式,也要在请求通知后先检查并排空已有结果,再决定是否阻塞。

从通道取出的通知必须用 ibv_ack_cq_events()确认,这个 ack 只确认通知事件,不消费工作完成,也不替代重新请求通知。异步资源事件则走另一条接口路径,不能用空 CQ 来判断是否存在设备或 QP 错误。阻塞等待通知涉及操作系统的等待与唤醒,前文不进入内核的说法限定在普通提交和轮询的快速路径。

完成状态与故障归因

请求重试耗尽可以正常生成一条失败 WC,这是“执行失败,但结果被送达”。CQ overrun 是传递结果的队列出了问题。长期没有拿到预期 WC,则只是“应用尚未获得结果”,还不能直接归入前面任何一种,更不能直接归因于固件。

仍然沿同一次 Write 检查,就能把证据串起来。先确认 ibv_post_send()返回什么、WR 是否要求报告完成,再确认 WQE 是否发布给设备、队列是否推进,以及 CQE 是否被某个完成线程取走却没有正确分发。失败 WC 的状态与 vendor_err、异步资源事件、设备侧诊断,则用来进一步区分权限、传输和设备状态问题。

内核创建和修改 QP 成功,只覆盖了资源配置路径。源码中 WQE 构造、doorbell 发布和 CQE 消费都有用户态 provider 参与,因此“数据路径绕过内核”不能作为“已经排除驱动”的证据。要把问题缩小到 FW,需要设备侧证据,或能区分上述环节的对照验证。一次空轮询只说明这次没有取到完成。

SEND/RECV 与远端通知

普通 Write 由 A 指定 B 的目标地址。SEND/RECV则是匹配发送请求与接收请求的双边消息语义:B 先调用 ibv_post_recv(),用 Receive WR 描述可用接收缓冲,A 再用 SEND 发消息,B 的网卡把消息放进匹配的接收缓冲,并报告接收完成。A 不需要知道 B 的具体 buffer 地址,目标位置由 B 的接收请求决定。操作说明

“双边”并不表示 B 的 CPU 要参与每个字节的复制。B 可以通过 ibv_post_recv()的链表参数预先提交一批 Receive WR,由后续消息逐条消耗,但完成后需要按协议补充接收资源。在 RC 上,SEND 到达时没有可匹配的 Receive WR,会触发 RNR 响应,发送端按 RNR 重试配置处理,耗尽时报告失败。

Write with Immediate仍按 A 提供的远端地址和 rkey写数据,同时携带一份 immediate data(即时数据),把它作为通知交给 B 的接收完成。它消耗 B 预先提交的 Receive WR,并产生 IBV_WC_RECV_RDMA_WITH_IMM。接收请求在这里提供的是通知所需的接收资源,数据正文仍写入远端地址指定的位置,不写进 Receive WR 的 SGE 所指定的缓冲。操作语义、扩展 WR 接口

操作 B 端数据地址由谁决定 是否消耗 B 的 Receive WR B 是否得到对应的接收完成
普通 RDMA Write A 提供的远端地址与 rkey 否 否
SEND B 预先提交的接收请求 是 是
Write with Immediate A 提供的远端地址与 rkey 是,用于通知 是,带即时数据

这也限定了前面讨论 KVCache 传输时的适用条件。普通 Write 避免的是每次写入对 Receive WR 的依赖,所以不会因缺少 Receive WR 而遇到 RNR,但 B 仍要负责目标缓存块的分配、授权、生命周期和就绪通知。改用 Write with Immediate 后,接收资源的供给又成为必要条件,缺少 Receive WR 同样会触发 RNR。

Write 能直接落入最终缓存地址,前提是这段内存可注册、可访问,并且 A 已获得目标位置。SEND 也能把最终目标 buffer 预先作为接收缓冲,并不必然多一次中转。对 KVCache,真正的选择在于由谁决定块的位置、怎样管理接收资源,以及用什么协议交接数据。前面的 TransferEntry只是应用组织传输条目的例子,不是 verbs 对象,批量提交也不是 Write 独有的能力。

接口与完成语义补充

发送完成策略与缓冲区生命周期

sq_sig_all非零时,所有发送侧 WR 都要求生成完成记录。它为零时,由各 WR 的 IBV_SEND_SIGNALED选择要报告的正常完成。没有设置 signaling 的请求称为 unsignaled WR,成功执行后不生成独立的成功完成记录,因此不能逐个等待它们的成功 WC。

Unsignaled 并不意味着请求不占发送资源,也不允许立即复用源数据。选择性 signaling 的实现还必须跟踪队列进度和可回收的请求范围,不能只减少 CQE 数量,却取消资源回收协议。本篇单次 Write 显式请求完成,就是为了让这个生命周期直接可见。

错误又是另一种情况。Unsignaled 抑制正常成功完成,不屏蔽失败完成。QP 进入错误状态后,未完成工作还会以 flush 错误结束,flush 在这里表示清理尚未完成的工作,不表示成功传输。Linux v6.12 的 RXE 完成实现直接引用了 InfiniBand 规范的这一要求,并分别处理正常、错误和 flush 完成。RXE 是 Linux 的软件 RoCE 实现,这里用它核对规范语义,不用它代替 mlx5 的硬件实现。CQ 本身已经出错时,结果交付不再有健康 CQ 的保证,不能继续用正常完成规则推理。

如果设置 IBV_SEND_INLINE,provider 会在提交过程中把源数据复制进 WQE。按照 ibv_post_send()的约定,调用返回后即可复用原始源缓冲,之后网卡使用的是 WQE 内的数据。这种方式受 QP 的 inline 容量限制,前面的例子没有使用它。源 buffer 可以复用,也不表示远端业务已经完成。

批量提交与扩展接口

ibv_post_send()可以提交由 next连接的 WR 链表。如果中途遇到能立即检测的错误,bad_wr指向失败项,之前的 WR 已经提交,失败项和后续项没有继续提交。因此处理返回错误时,要区分整批失败和前缀已经提交,不能盲目重试整条链表。提交接口

扩展的 ibv_wr_*接口用 ibv_wr_start()开始构造,通过若干 builder/setter 调用描述工作,最后用 ibv_wr_complete()提交。这里的 complete 是结束构造与提交,不是等待设备执行结束。它与传统链表接口的失败边界也不同:构造期间检测到错误后,ibv_wr_complete()会报告失败并放弃这一批提交。两套接口都需要通过完成机制管理成功提交的异步工作。扩展接口手册

GPUDirect RDMA 同步补充

GPUDirect RDMA允许支持的第三方设备直接访问 GPU 内存,让网卡传输不必先在普通主机内存中转。它增加了一条外部设备访问显存的路径,因此需要分别确认网卡完成、数据对 GPU 可见,以及消费数据的 GPU kernel 已经结束。后一个状态决定显存块什么时候能被覆盖或释放。NVIDIA GPUDirect RDMA 文档

CUDA 生成数据后交给网卡

假设 A 先用 CUDA 在显存中生成源数据,再让网卡读取。cuPointerSetAttribute()的 CU_POINTER_ATTRIBUTE_SYNC_MEMOPS属性以一次显存分配为作用范围。把它设为布尔值 1,要求涉及这块分配的 CUDA 同步内存操作真正同步完成,不再采用使这类 API 提前返回的优化。属性定义

NVIDIA 给出的例子是 cuMemcpyDtoD()后紧接一次外部设备读取。没有相应同步约束时,这类设备到设备复制允许在排入 GPU 调度后就向 CPU 返回,随后安排的网卡读取就会与尚未完成的复制冲突。启用该属性后,CUDA 复制的返回才提供这里所需的同步边界。属性按分配设置,通常不应在每次传输时重复配置。同步规则与示例

这个属性没有把所有 CUDA 工作改成同步执行。若数据由异步复制或 kernel 生成,仍需等待相应 stream 或 event 所表达的生产完成,再把源数据交给网卡。它也不负责把未来的外部 RDMA 写入自动发布给 GPU 消费者。

网卡写入后交给 GPU

方向换成网卡写 B 的显存后,关注点就变成 GPU 何时能观察到写入。GPUDirect 文档明确指出,与 RDMA 写入并发运行、又读取被覆盖内存的 kernel 会形成数据竞争,能够看到旧数据、部分数据或乱序到达的数据。CPU 上的通用内存屏障不能修复这种竞争。

cuFlushGPUDirectRDMAWrites(target, scope)用于等待针对目标 CUDA context 的外部写入对指定的 GPU 观察范围可见。这里的 CUDA context 是 CUDA 管理设备执行状态和资源的上下文,和 ibv_context是不同对象。target目前使用 CU_FLUSH_GPU_DIRECT_RDMA_WRITES_TARGET_CURRENT_CTX指定当前 CUDA context,scope指定要求哪个范围能一致地消费这些写入,例如拥有这段数据的设备 context,或所有 CUDA 设备 context。它不负责等待未来还没有发出的 RDMA 请求,也不表示 GPU 已经处理完数据。CUDA Driver API、参数枚举

这里的“按需 flush”有明确判断条件。用 cuDeviceGetAttribute()查询 CU_DEVICE_ATTRIBUTE_GPU_DIRECT_RDMA_WRITES_ORDERING,得到平台原生保证的可见范围。如果所需 scope 已经被它覆盖,文档规定 flush 是空操作,可以省略。如果需要显式的主机侧 flush,还要查询 CU_DEVICE_ATTRIBUTE_GPU_DIRECT_RDMA_FLUSH_WRITES_OPTIONS是否含有 CU_FLUSH_GPU_DIRECT_RDMA_WRITES_OPTION_HOST,确认设备支持这个 API。能力枚举不能用“所有 GPU 都需要 flush”或“所有 GPU 都自动可见”代替。

因此,一条由 CPU 协调的消费路径是:先通过通信完成协议确认本轮 RDMA 写入结束,再在需要且支持时建立所需 scope 的可见性,随后由 CPU 提交依赖这些数据的 CUDA 工作。GPU 工作结束后,才能按应用协议允许下一轮覆盖目标块。SYNC_MEMOPS与 flush 约束的是这条路径中的不同方向,不能互相替代。

CUDA 文档还单独指出:在支持多条硬件访问路径的平台上,不同路径可以属于不同的排序域。混合使用这些路径时,即便查询结果显示原生支持所需范围,也需要按具体路径建立额外顺序,不能把本节的单一路径推理直接推广过去。本文没有具体硬件配置,不据此判断某台机器是否需要显式 flush。

InfiniBand 流量分类补充

SL(Service Level,服务级别)是包携带的流量分类标记,VL(Virtual Lane,虚拟通道)是物理链路上用于区分排队与流控状态的逻辑通道。SL 表达流量属于哪一类,VL 决定它在这一跳走哪个逻辑通道,这两个编号不是同一个对象。

交换设备根据入端口、出端口和 SL,通过 SL-to-VL 映射表选取输出 VL,再按配置的优先级和权重在不同 VL 间仲裁。NVIDIA 的 IB QoS 文档描述了映射与仲裁机制。因此,相同 SL 不能脱离路径配置直接理解成全网固定的一条 VL。

即使映射到了不同 VL,它们仍共享同一条物理链路。可以理解为分了排队通道,但共用出口,增加逻辑通道并没有增加物理带宽。这一段讲的是 IB,RoCE 的以太网优先级与队列映射需要按对应配置判断,不能直接套用 IB 的 VL。

NUMA 亲和性补充:网卡、内存与 UPI

内存领域知识讨论过 CPU 访问远端 NUMA 内存的代价。换成 RDMA,发起内存访问的变成了网卡(NIC),但物理距离并没有消失。因此,除了线程在哪运行,还要看 NIC 接在哪个 Socket,以及 buffer 的物理页在哪个 NUMA 节点。

PCIe、UPI 与跨插槽数据路径

在常见的 Intel 双路服务器中,每颗 CPU 各自连接 DRAM 和 PCIe 设备,两个 Socket 通过 UPI(Ultra Path Interconnect,Intel Xeon 的跨插槽互联)传输数据并维护缓存一致性。下面假设一个 Socket 对应一个 NUMA 节点,0、1 只是示例编号:

1
2
3
4
5
DRAM 0 -- Socket 0 ===== UPI ===== Socket 1 -- DRAM 1
| |
PCIe PCIe
| |
NIC 0 NIC 1

如果 NIC 1 发送的数据需要从 DRAM 0 取回,数据路径就是 DRAM 0 → Socket 0 → UPI → Socket 1 → PCIe → NIC 1。这里经过的是处理器内部的内存控制器和 I/O 硬件,不是 CPU 核在执行 memcpy。DMA 省掉了 CPU 复制,却没有省掉跨插槽传输,这部分流量仍会与其他跨插槽访问竞争 UPI 带宽。

这也说明,PCIe 与 UPI 是这条路径上串联的两段路。要避开 UPI,应让 NIC 1 使用 DRAM 1 中的 buffer,或者改用 NIC 0 发送 DRAM 0 中的数据。先把数据从 DRAM 0 复制到 DRAM 1,只是把跨 UPI 的工作移到了复制阶段。

线程位置则是另一件事。buffer 与 NIC 都在节点 1 时,即使提交线程在节点 0,数据的 DMA 仍可以是本地的。但这个线程访问节点 1 上的队列内存、网卡映射或数据时,CPU 的访问路径仍然跨节点。RDMA Write 的接收端同样要检查 NIC 与目标 buffer 的位置,不能因为目标 CPU 不参与复制,就认为没有跨插槽流量。

PCIe P2P 指设备之间的直接访问,例如 NIC 读写 GPU 显存。拓扑允许时,事务可以在 PCIe 交换芯片内转发,但它并没有给普通 DRAM 增加一个绕过 UPI 的入口。真正改变上述连接方式的是 Socket Direct:支持它的网卡通过两条独立的 PCIe 连接分别接入两个 Socket,在正确选择和配置接口后,两侧都能获得本地 DMA 路径。双网络端口不等于双主机侧 PCIe 连接。

此外,跨 NUMA 不一定跨 Socket。Intel SNC(Sub-NUMA Clustering)能把一个 Socket 划成多个 NUMA 节点,同 Socket 内的跨节点访问不需要经过跨插槽 UPI,所以必须结合实际拓扑判断。

从 RDMA 设备查到物理拓扑

verbs 选择的是 mlx5_0 这样的 RDMA 设备,而不是 eth0 这样的 Linux 网口。RDMA 评估指标里的 mlx5_bond_0 则是某些 RoCE LAG(Link Aggregation,链路聚合)配置中的 RDMA 聚合设备。名称不包含 NUMA 归属,需要先查它对应的网口:

1
2
ibdev2netdev
rdma link show

输出中的 RDMA 端口号,也就是 ib_write_bw -i 的参数,与 NUMA 节点号无关。如果网口是 Linux 聚合接口 bond0,先用 cat /proc/net/bonding/bond0 找到 Slave Interface 成员,再逐个查询物理网口。下面的设备名均为示例,应替换成机器的实际输出:

1
2
3
4
5
nic=eth0
cat "/sys/class/net/$nic/device/numa_node"
cat "/sys/class/net/$nic/device/local_cpulist"
readlink -f "/sys/class/net/$nic/device"
lscpu -e=CPU,NODE,SOCKET

numa_node 是内核报告的设备所属节点,local_cpulist 是就近逻辑 CPU 列表,readlink 显示 PCI 设备路径。将节点号与 lscpu 的 NODE、SOCKET 两列对照,就能确定对应的 Socket。若 numa_node 返回 -1,表示归属未知,不是访问各节点的成本相同。

聚合设备自身的 sysfs 路径不能代替逐成员检查,单个 device链接不提供所有成员的拓扑。成员若分属不同 Socket,还要结合聚合实现确认实际使用的 PCIe 路径。RoCE LAG 与 Socket Direct 是不同机制,不能从一个 bond 名字推断出双 Socket 连接。

内存分配与 MR 注册

前面示例中的 posix_memalign只保证对齐。对常规、非按需分页的主机内存注册,ibv_reg_mr()会固定物理页并建立设备映射,但不会按 NIC 的位置自动迁移页面。

ODP(On-Demand Paging,按需分页)允许支持它的设备与驱动按需建立页面访问,ibv_reg_mr()提供 IBV_ACCESS_ON_DEMAND选项。因此“注册 MR”不能一概定义成“一次性固定所有物理页”。这里讨论的是普通预注册 buffer 的落点。注册接口

Linux 默认的本地分配策略下,新匿名页通常在首次写入触发缺页时才分配物理内存,这就是 first-touch。主线程在节点 0 初始化 buffer 后,再把发送线程绑到节点 1,已有页面并不会跟着移动。注册 MR 也可能触发尚未分配页面的缺页,所以应当先设置线程与内存策略,再分配、初始化 buffer,最后注册 MR。

假设查出的 NIC 属于节点 1,可以这样启动程序,两个 1 都来自刚才的节点查询结果,./rdma_demo 替换成实际程序:

1
numactl --cpunodebind=1 --membind=1 ./rdma_demo

numactl 不会替程序选网卡,程序仍需选择对应的 RDMA 设备。多网卡程序可以按节点建立 buffer 池,用 mbind 为各池设置内存分配策略,让生产数据的线程直接写入对应 NIC 的本地内存。

页面落点可以沿用前文的 move_pages 验证:传入 nodes == NULL 查询各页的实际节点。numastat -p 只有进程整体分布,不能证明某个 buffer 已经本地化。

性能验证时,固定 RDMA 设备、线程亲和性、消息大小和 QP 配置,只切换 buffer 所在节点,再比较吞吐和延迟,必要时结合平台的互联与 I/O 硬件计数器。perf mem 采样的是 CPU 访存,不能据此判断 NIC 的 DMA 有没有跨 Socket。

参考资料

接口与 mlx5 用户态源码固定到 rdma-core v59.0,TCP、mlx5 内核驱动与 RXE 源码固定到 Linux v6.12。原有在线文档核对日期为 2026-10-15,TCP/RDMA 分层对比补充核对于 2026-10-16。本文示例用于说明语义,不是特定网卡或固件的实测结论。