RDMA 全链路拆解:从 ibv_reg_mr 到硬件,再到 AI 训练平台的实战落点

RDMA 这套东西之所以让人望而生畏,不是因为哪个概念特别难,而是因为它把三个本来独立的知识域压进了同一条调用链:Linux 内核系统调用机制、内存管理、网络硬件。单独看每个术语都不复杂,叠在一起才显得信息量爆炸。

这篇文章分三大块:

  1. 调用链——从 ibv_reg_mr 一路走到 HCA 硬件,每一层在做什么;
  2. 术语地图——把所有黑话按类别拆开,每个只给一句人话;
  3. 实战落点——AI 训练云平台上,这条链路到底体现在哪些日常工作里。

先正一个名。 中文社区里偶尔能看到 “ubverbs” 这个写法,它不是标准名称。内核里承接用户态 verbs 请求的模块叫 ib_uverbs(Userspace Verbs),源码在 drivers/infiniband/core/uverbs*.c。本文统一用 ib_uverbs

关联阅读: 这条链路上的故障怎么观测,我在 《RDMATracer 拆解:13 个 eBPF 探针如何补上 AI 集群的内核盲区》 里写过。那篇讲的是"怎么看见这条链路上的失败",这篇讲的是"这条链路本身长什么样"——两篇对着读效果更好。本文第 2.2 节提到的 __ib_umem_getib_peer_umem_get 正是那篇里 fleet 级 EFAULT 事故的现场。


一、软件栈分层架构

层级 所在空间 典型组件 核心职责
应用层 用户态 用户程序 / NCCL 调用 ibv_* 系列 API
libibverbs 用户态 rdma-core 的 libibverbs.so 提供 verbs 抽象 API,组装命令、发起系统调用
Provider 库 用户态 libmlx5.solibrxe.so 硬件特定逻辑(UHW 扩展),实现用户态 Doorbell 直写
字符设备接口 内核入口 /dev/infiniband/uverbsN 用户态与内核态的"海关",承接 ioctl/write
ib_uverbs 内核态 drivers/infiniband/core/uverbs*.c 解析 ioctl 命令,管理对象生命周期,做对象/方法/属性分发
ib_core 内核态 drivers/infiniband/core/ 设备注册、GID 管理、内存固定、错误处理
厂商内核驱动 内核态 mlx5_ib.koirdma.ko 实现 struct ib_device_ops,操作硬件
RDMA 硬件 HCA / NIC 执行 DMA、队列处理

整套架构的核心思想是控制面与数据面分离:慢路径(资源创建销毁)走 ioctl 进内核,快路径(数据收发)通过 mmap 映射的 MMIO 寄存器直接操作硬件,完全绕过内核。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
     用户态                          内核态                   硬件
┌──────────────┐ ┌────────────────┐
│ ibv_xxx() │ ioctl() │ ib_uverbs │ ops ┌──────────┐
│ libibverbs │ ───────────► │ 解析 / 分发 │ ───────► │ mlx5_ib │──► HCA
│ + provider │ /dev/infini │ │ └──────────┘
└──────────────┘ band/uverbsN└────────┬───────┘ ▲
▲ │ ║
│ ▼ ║
┌──────────────┐ ┌────────────────┐ ║
│ Doorbell │◄═══ mmap ════│ ib_core │ ║
│ MMIO 直写 │ │ (资源管理) │ ║
└──────┬───────┘ └────────────────┘ ║
║ ║
╚═══════════ 快路径:PCIe DMA,完全绕过内核 ════════════════╝

二、三个典型场景的调用链

2.1 场景一:设备枚举与打开

一个容易搞错的细节:ibv_get_device_list() 主要是读 /sys/class/infiniband/ 目录做枚举,并不走 ioctl。真正触发系统调用的是 ibv_open_device()

1
2
3
4
5
6
7
8
9
10
用户态                                          内核态
ibv_open_device(device)
└─► 遍历 provider(libmlx5.so 等)
└─► provider 的 open_device 回调
└─► open("/dev/infiniband/uverbsN") ──► 字符设备 open 回调
└─► ioctl(cmd_fd, RDMA_VERBS_IOCTL, &hdr) ──► ib_uverbs_ioctl()
└─► UVERBS_METHOD_QUERY_CONTEXT / GET_CONTEXT
└─► 初始化 struct ib_ucontext
└─► 通过 mmap 把 Doorbell 区域、CQ 缓冲区
映射到用户空间

关键点: ibv_open_device 拿到 cmd_fd(uverbs 字符设备描述符)后,后续所有控制面操作都复用这个 fd。这个 fd 的生命周期也是内核做资源回收的锚点——进程退出、fd 关闭时,挂在这个 context 下的所有 QP/MR/PD/CQ 都会被自动清理。

2.2 场景二:资源创建(以 ibv_reg_mr 为例)

这是最能体现完整调用链的场景。ibv_alloc_pdibv_create_cqibv_create_qp 的路径结构与之相同,只是命令码不同。

用户态侧(rdma-core)

1
2
3
4
5
6
7
8
9
10
11
ibv_reg_mr(pd, addr, length, access)
├─► 参数校验(PD 有效性、access 合法性)
└─► 调用 provider 的 reg_mr 回调(如 mlx5 的 verbs.c)
└─► ibv_cmd_reg_mr()
└─► DECLARE_COMMAND_BUFFER_LINK(cmd, UVERBS_OBJECT_MR,
UVERBS_METHOD_MR_REG, ...)
├─ 填充 struct ib_uverbs_ioctl_hdr(object_id、method_id)
├─ 填充 struct ib_uverbs_attr[](PD 句柄、addr、length、
│ access_flags 等)
└─ 若 provider 有硬件特定参数,附加到 UHW 扩展段
└─► ioctl(context->cmd_fd, RDMA_VERBS_IOCTL, &cmd->hdr)

ibv_command_buffer 的设计值得一提:通过 next 指针支持多缓冲区链表,hdr 统一管理属性元信息,UHW(User HW Data)空间用于厂商扩展——这样厂商私有参数不用污染通用 ABI。

内核态侧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
sys_ioctl
└─► uverbs 字符设备的 .unlocked_ioctl = ib_uverbs_ioctl
├─► copy_from_user(&hdr, ...)
├─ 校验 hdr.length(必须等于 hdr + attrs 的总长度)
├─ 校验 object_id / method_id / num_attrs
└─► 在 uapi radix tree 中查找对应的处理函数
└─► uapi_key:低 6 位 attr_id,中间 5 位 method_id,高 5 位 object_id
└─► UVERBS_OBJECT_MR + UVERBS_METHOD_MR_REG
└─► 对应的 uverbs 方法实现
├─ uobj_get_obj_read(pd, UVERBS_OBJECT_PD, ...)
│ └─ 通过句柄表把用户态句柄转回内核对象指针
├─ ib_check_mr_access()
└─► device->ops.reg_user_mr(pd, start, length, ...)
└─► mlx5_ib_reg_user_mr(以 mlx5 为例)
├─ ib_umem_get():pin_user_pages_fast() 钉住内存页
├─ DMA 映射,构建 MTT(内存翻译表)
└─ 硬件命令创建 MKey
└─► 通过 attr 的 PTR_OUT 把 mr_handle 写回用户空间

时序图

sequenceDiagram
    participant App as 应用程序
    participant Lib as libibverbs+provider
    participant Uver as ib_uverbs(内核)
    participant Drv as mlx5_ib.ko
    participant HW as HCA硬件
    App->>Lib: ibv_reg_mr(pd, addr, len, access)
    Lib->>Lib: 组装 ib_uverbs_ioctl_hdr + attrs
    Lib->>Uver: ioctl(fd, RDMA_VERBS_IOCTL, &hdr)
    Uver->>Uver: copy_from_user + radix tree 查找
    Uver->>Uver: 句柄表翻译 pd_handle → ib_pd*
    Uver->>Drv: ops.reg_user_mr(pd, ...)
    Drv->>Drv: pin_user_pages_fast() 钉住内存
    Drv->>Drv: DMA 映射 + 填充 MTT
    Drv->>HW: 硬件命令创建 MKey
    HW-->>Drv: MKey
    Drv-->>Uver: 返回 mr 内核对象
    Uver->>Uver: 句柄表分配 mr_handle
    Uver-->>Lib: ioctl 返回(attrs 中带 mr_handle)
    Lib-->>App: 返回 struct ibv_mr*

深入一层:ib_umem_get 到底做了什么

这是整条链路上最容易出事的一步,值得单独展开。

内存注册的本质是:让网卡能在 CPU 完全不参与的情况下读写这块用户内存。要做到这一点,内核必须保证两件事:

  1. 这些页不能被换出或迁移——否则网卡 DMA 到一半页没了,直接数据损坏;
  2. 网卡要拿到虚拟地址 → 物理地址(或 IOVA)的映射——这就是 MTT 内存翻译表。

第一件事由 pin_user_pages_fast() 完成。这里有个版本变迁值得知道:

  • Linux 5.6 之前,ib_umem_get() 用的是 get_user_pages() / get_user_pages_fast()FOLL_LONGTERM 标志;
  • Linux 5.6 起,转为 pin_user_pages_fast(),内部会置上 FOLL_PIN

这个转换不是改名那么简单。FOLL_PIN 引入了独立的引用计数语义(每个页按 GUP_PIN_COUNTING_BIAS 递增),目的是让内核能够区分"这个页只是被临时引用"和"这个页正被 DMA 长期钉住"。规则也随之变严:

  • FOLL_PINFOLL_GET 互斥;
  • FOLL_PINFOLL_LONGTERM前提——这正是 ib_umem_get() 必须转换的原因;
  • 解除固定必须用 unpin_user_page(),不能用 put_page()

另外,ODP(On-Demand Paging)路径被刻意排除在这次转换之外——ODP 的语义本来就是"允许缺页",不需要长期钉住,所以它继续走 get_user_pages()

一个 2024 年才浮出水面的坑: pin_user_pages_fast() 可能临时返回 -EAGAIN。原因是它回落到 gup-slow 路径后,尝试把页从 ZONE_MOVABLE 迁出时,因为瞬时的引用计数偏高而迁移失败。社区为此讨论过重试逻辑,Jason Gunthorpe 的意见是 pin_user_pages() 应该直接睡眠等迁移完成而不是把错误抛给调用方。

这类"偶发的 ibv_reg_mr 失败"在应用层看就是一句莫名其妙的报错——而这恰恰是 RDMATracer 那类内核侧观测工具存在的意义:同一个 ENOMEM/EFAULT,可能来自页迁移、来自 peer-memory 子系统、来自厂商驱动的整型溢出,应用日志分辨不了,内核侧的层级证据能

2.3 场景三:数据路径(ibv_post_send)

关键结论:ibv_post_send 在主流硬件(如 mlx5)上是纯用户态操作,完全不经过系统调用。

1
2
3
4
5
6
7
8
ibv_post_send(qp, wr, bad_wr)
└─► provider 的 post_send(如 mlx5 用户态实现)
├─ 把 struct ibv_send_wr 转化为硬件格式的 WQE
├─ 写入用户态可直接访问的 SQ(Send Queue)环形缓冲区
│ (该队列已在 ibv_create_qp 时通过 mmap 映射到用户空间)
└─ 向 Doorbell 寄存器(MMIO 映射)写入一个值"敲响门铃"
└─ HCA 通过 PCIe DMA 读取 WQE,直接读写用户内存,
完成数据传输,无需内核介入

ibv_poll_cq 同理:CQ 缓冲区也映射在用户空间,直接轮询即可。这就是 RDMA "零拷贝、内核旁路"的底层实现。

例外: 软件 RDMA(如 siwrxe)没有真实的 MMIO Doorbell,post_send 会退化成通知内核模块处理。这属于模拟实现,不是主流路径,性能特征完全不同——用它做功能验证可以,做性能评估会得出完全错误的结论。


三、ioctl 契约:命令结构与演进

3.1 UAPI 头文件

核心命令格式定义在 include/uapi/rdma/rdma_user_ioctl_cmds.h:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#define RDMA_IOCTL_MAGIC  0x1b
#define RDMA_VERBS_IOCTL \
_IOWR(RDMA_IOCTL_MAGIC, 1, struct ib_uverbs_ioctl_hdr)

struct ib_uverbs_attr {
__u16 attr_id; /* 属性编号,如 UVERBS_ATTR_REG_MR_HANDLE */
__u16 len; /* 仅用于指针类属性和句柄数组 */
__u16 flags; /* UVERBS_ATTR_F_MANDATORY / F_VALID_OUTPUT */
union { /* enum_data / reserved */ };
union {
__aligned_u64 data; /* 内联数据 / 指针 / 句柄 / 句柄数组指针 */
__s64 data_s64; /* FD_IN / FD_OUT 类型属性 */
};
};

struct ib_uverbs_ioctl_hdr {
__u16 length; /* 总长度(含 attrs) */
__u16 object_id; /* 如 UVERBS_OBJECT_MR / UVERBS_OBJECT_CQ */
__u16 method_id; /* 如 UVERBS_METHOD_MR_REG / CQ_CREATE */
__u16 num_attrs;
__aligned_u64 reserved1;
__u32 driver_id; /* 厂商 ID,用于 UHW 扩展命名空间 */
__u32 reserved2;
struct ib_uverbs_attr attrs[];
};

两个 flag 值得记住:UVERBS_ATTR_F_MANDATORY 标记用户必须提供的输入,UVERBS_ATTR_F_VALID_OUTPUT 标记内核确实写了输出。

driver_id 字段当年是有争议的。 评审时 Christoph Lameter 提出反对:驱动应该已经由 ioctl 传入的 fd 决定了,再传一个 driver_id 是冗余。最终保留的理由挺实用主义——它主要是给 strace 和调试工具用的:既然 ABI 本身是驱动相关的,带上 driver_id 才能让外部工具正确解析结构体内容。

3.2 "对象-方法-属性"三级寻址模型

内核把所有 verbs 操作组织成一个三维空间:

含义 例子
Object 一类 RDMA 资源 UVERBS_OBJECT_CQ
Method 该对象支持的操作 UVERBS_METHOD_CQ_CREATE
Attr 操作的参数 UVERBS_ATTR_CREATE_CQ_NUM_CQE

这种设计本质上是把 ioctl 从"每个命令一个结构体"升级成了通用的、基于属性的 TLV 协议。内核侧用 radix tree 检索 API 定义,键值编码:

1
uapi_key =  [ 高 5 位: object_id ][ 中 5 位: method_id ][ 低 6 位: attr_id ]

同一棵树同时管理 ioctl 与 write 两种路径的 API 定义。

驱动和核心代码通过一组宏声明自己的树:DECLARE_UVERBS_NAMED_OBJECTUVERBS_TYPE_ALLOC_IDR 声明对象,UVERBS_METHOD(...) 挂方法,再用 UAPI_DEF_CHAIN_OBJ_TREE_NAMED 串成 const struct uapi_definition[] 数组。销毁类方法有个常用简写 DECLARE_UVERBS_NAMED_METHOD_DESTROY,2018 年由 Parav Pandit 统一给 PD、MR、MW、AH、flow、RWQ 间接表、XRCD 这些"销毁时无需返回值"的对象加上。

radix tree 的并发模型很干净,值得学: 这棵树在设备注册时(ib_uverbs_add_one())一次性构建完毕,之后到最后一个 kref 释放前全程只读,因此不需要 RCU。安全性由文件生命周期保证——ioctl 执行期间,VFS 层持有 struct file 的引用,file->device 的 kref 不可能归零。用"构建一次 + 只读"换掉整套同步开销,是很典型的内核优化思路。

3.3 句柄表:用户态永远拿不到内核指针

内核用一张句柄表维护 “内核对象指针 ↔ 用户态不透明句柄” 的映射。用户态拿到的 mr_handleqp_handle 只是个整数编号,内核收到后查表还原出真实对象指针。这样做有两个好处:不泄露内核地址,以及进程退出时能按表回收、防止资源泄漏

这里有个实现细节值得一提:这张表历史上是 idr,后来被 Matthew Wilcox 的系列补丁转成了 XArray,但字段名和 API 里的 “idr” 字样都保留了下来(ufile->idr 现在是个 struct xarray)。所以你在新内核源码里看到 idr 这个词,它指的其实是 XArray。转换后的典型操作:

1
2
3
4
5
6
7
/* 分配:先占一个 NULL 槽位,commit 时再换成真对象 */
xa_alloc(&uobj->ufile->idr, &uobj->id, NULL, xa_limit_32b, GFP_KERNEL);

/* 查找:RCU 读锁 + kref_get_unless_zero,因为 kfree 随时可能发生 */
uobj = xa_load(&ufile->idr, id);
if (!uobj || !kref_get_unless_zero(&uobj->ref))
uobj = ERR_PTR(-ENOENT);

XArray 内嵌自旋锁,所以 struct ib_uverbs_file 里原来那个单独的 idr_lock 被整个删掉了。

一个后续修复揭示了这里的微妙之处: 2020 年发现过一个 use-after-free——用户态在 RCU 宽限期内并发请求同一个 uobject,会导致移除路径被走两次、对象被多 put 一次。修法是移除后让 uobject 保持写锁定状态,用 UVERBS_LOOKUP_DESTROY 而不是 UVERBS_LOOKUP_WRITE。根因就在于 XArray 的 RCU 可见性:即使已经从表里擦除,其他线程在宽限期内仍可能看到它。

3.4 从 write()ioctl() 的演进

早期内核的 ABI 是把命令结构体直接 write() 到 uverbs 设备,命令码藏在结构体第一个字段里:

1
2
3
4
旧路径(write):
write(cmd_fd, &cmd, sizeof(cmd))
└─► ib_uverbs_write()
└─► 按 cmd.command 字段分发到 ib_uverbs_reg_mr 等处理函数

新旧对比:

维度 write() 旧 ABI ioctl() 新 ABI
命令定义 每命令一个固定结构体,ABI 硬编码 属性化 TLV,向后兼容性强
厂商扩展 UHW 内嵌缓冲区,位置固定 独立 UHW 命名空间,driver_id 区分
参数校验 手写校验逻辑 内核按 attr spec 统一自动校验
新功能落地 必须改 UAPI 头 + 全量发版 只需注册新的 method / attr
现状 保留为兼容层(UVERBS_API_METHOD_IS_WRITE) 新代码统一走 ioctl

rdma-core 里 cmd_ioctl.c 是新路径实现,cmd_write.c 保留旧 ABI 兼容;ibv_command_buffer 结构同时服务两种路径,靠 uhw_in_idx 等字段记录旧 write 接口的 UHW 缓冲区位置。

⚠️ 关于版本号的一个订正。 网上常见"ioctl 接口在 v4.17 引入"的说法,这个口径不准确。实际演进是分阶段的:

  • 设计上有过两个竞争方案——Intel 的 Sean Hefty 在 2016 年提过一个更通用的 “urdma” 框架,最终合入的是 Mellanox 的 Matan Barak 那套 13 patch 系列;
  • 该接口最初是以实验性 Kconfig 选项的形式落地的,时间在 4.14 / 4.15 前后;
  • 真正的 uapi radix tree 重构在 2018 年底才完成,对应 4.20 / 5.0 窗口。

所以准确说法是 “在 4.14–5.0 这个窗口里分阶段成型”,而不是某个单一版本。要精确到 commit,建议直接查 git log -- drivers/infiniband/core/uverbs_ioctl.c uverbs_uapi.c


四、快路径 / 慢路径的本质区别

理解这条链路后,最重要的一个工程直觉是:控制面与数据面走的是完全不同的通道。

1
2
3
4
5
6
7
慢路径(控制面):
App → libibverbs → ioctl() → ib_uverbs → ib_core → 厂商驱动 → 硬件
QP/CQ/MR/PD 创建销毁 · 频率低 · 允许上下文切换开销 · 微秒~毫秒级

快路径(数据面):
App → provider 用户态驱动 → MMIO Doorbell 直写 → HCA DMA 用户内存
post_send / poll_cq · 频率极高 · 零系统调用 · 亚微秒级延迟

这直接决定了 RDMA 程序的典型形态:

初始化阶段集中完成所有资源创建(吃一次慢路径开销),运行期热循环里只有 ibv_post_send / ibv_poll_cq(纯用户态,零系统调用)。

反过来说,任何在热循环里注册/注销 MR 的代码都是性能灾难——ibv_reg_mr 要进内核、要钉页、要建 MTT、要发硬件命令,单次开销可能是一次 post_send 的几千倍。这也是为什么各类 RDMA 通信库都会做 MR 缓存(registration cache)。


五、术语地图:每个词一句人话

5.1 RDMA 核心对象(要管理的"资源")

术语 人话解释
Verbs RDMA 的标准操作集合。名字来自"动词"——每个函数都是一个动作(注册、创建、发送……)
PD(Protection Domain) 资源隔离的"文件夹"。所有 QP、MR 必须挂在一个 PD 下,不同 PD 的资源互相看不见,防止串门
MR(Memory Region) 被网卡"户口登记"过的一块内存。注册后网卡拿到虚拟→物理地址映射,可以不经 CPU 直接读写
QP(Queue Pair) 通信端点,本质是一对环形队列(SQ 发送 + RQ 接收)。应用往队列塞任务,网卡从队列取任务
SQ / RQ QP 里那对队列:Send Queue 放"我要发数据"的任务,Receive Queue 放"我准备好收数据"的缓冲区
WQE(Work Queue Element) 队列里的一个任务条目。它不是数据本身,而是描述数据在哪、多长、用什么密钥的"任务单"
CQ(Completion Queue) 任务完成回执箱。网卡每干完一件活,就往这里扔一个 CQE
CQE 一条具体回执,含成功/失败状态、操作类型、wr_id(对上是你当初提交的哪个任务)
LKey / RKey MR 注册成功后网卡发的两把钥匙:LKey 给本机网卡用,RKey 给对端——对端拿 RKey 就能直接读写你的内存

5.2 系统调用与内存管理机制(RDMA 借用的通用工具)

术语 人话解释
ioctl Linux 的"万能杂项系统调用"。允许用户态向驱动传一个自定义结构体,驱动按里面的命令码自己解析
write()(旧路径) ioctl 的前辈:早期直接把命令结构体 write() 到设备,按结构体首字段分发。现在只剩兼容层
mmap 把文件/设备内存映射进用户进程地址空间,之后访问那段地址就等于直接操作底层资源,不再需要系统调用
MMIO 把硬件寄存器映射成内存地址。CPU 执行一条普通的内存写指令,实际效果是直接写网卡寄存器
Doorbell MMIO 的经典应用:网卡上一小块寄存器区,程序往里写个值(含 QP 号和队列指针),网卡就知道"这个 QP 有新任务了"——如同按门铃
pin pages 把指定页锁在物理内存、禁止换出。MR 注册必须做这步,否则网卡 DMA 时页被换走就是数据损坏
DMA 硬件不经 CPU 直接读写内存的能力。RDMA 数据传输全靠网卡 DMA,这是"零拷贝"的物理基础
idr / XArray 内核里"整数句柄 ↔ 对象指针"的映射表。用户态拿到的 mr_handle 就是它发的编号(见 §3.3)
radix tree 内核的高效检索树。ib_uverbs 用它按"对象+方法+属性"三级编码定位处理函数

5.3 软件栈组件

术语 人话解释
rdma-core 整个用户态 RDMA 软件栈的项目总名(一个 GitHub 仓库),装包时看到的就是它
libibverbs rdma-core 最核心的库,提供所有 ibv_* API。名字带 “IB”,但对 InfiniBand / RoCE / iWARP 都通用
libmlx5 / libefa / libirdma 厂商专属用户态驱动库(称为 provider),处理硬件私有逻辑。libibverbs 动态加载它们,像插件
ib_uverbs 内核里承接用户态 ioctl/write 请求的模块,负责命令解析、对象管理、安全检查
ib_core 内核 RDMA 子系统的公共底座:设备注册、GID 管理等,不直接对用户态开放接口
mlx5_ib.ko NVIDIA/Mellanox 网卡的内核驱动,实现 ib_device_ops,被 ib_uverbs 调用
UHW(User HW data) ioctl 命令里预留的"厂商扩展区",provider 把硬件私有参数塞进去,内核原样透传给驱动

5.4 一个贯穿所有名词的类比

把整套机制想象成一家自助快递驿站 + 仓库:

  • PD 是仓库的隔离分区,MR 是里面登记过台账的某个货架区——登记时要"钉住"货架不被挪走(pin pages),并配一把钥匙(LKey / RKey);
  • QP 是驿站的两个窗口:寄件窗口(SQ)和取件窗口(RQ);
  • WQE 是你填的快递单——写清楚货在哪个货架、多重、用哪把钥匙;
  • CQ 是回执信箱,CQE 是单条回执(“已签收”/“派送失败”);
  • Doorbell 是驿站门口那个门铃:快递单塞进窗口后按一下,仓库(网卡)就来取单,你可以去干别的了;
  • ibv_post_send 就是"填单 + 按门铃"这两个动作,全程发生在你家门口(用户态),不用打电话给总部(内核);
  • ioctl 慢路径则是开业前去工商局(内核)办执照、租仓库——手续繁琐,但只办一次

5.5 建议的理解顺序

  1. 先懂四个资源对象:PD → MR → QP → CQ。它们是整条链路存在的目的,一切系统调用都是为了创建和管理它们;
  2. 再看慢路径三个通用机制:ioctl、mmap、pin pages。搞清"为什么创建资源必须进内核(安全 + 页表),而发数据不用(队列已映射出来)";
  3. 最后看快路径的 Doorbell + MMIO:理解"写一个寄存器值通知硬件"这个动作,RDMA 的核心魔法就全通了。

六、AI 训练平台上,这条链路体现在哪

一个需要先纠正的认知:在云平台/训练平台岗位上,“懂 RDMA” 通常不等于 “会写 verbs 程序”,而是 “懂它的工作机制后知道在哪里调、调坏了去哪查”。

你大概率不会天天手写 ibv_post_send——那层逻辑已经被 NCCL 封装掉了——但你做的每个决策、调的每个参数、排查的每次掉速,最终都会传导到这条链路上。

6.1 日常工作全景

工作方向 占比(经验值) 具体任务 典型工具
网络基础设施建设 ~20% RoCE 交换机 PFC/ECN 配置、网卡驱动安装、MTU 调整 交换机 CLI、mlnx_tuneip link set
通信性能调优 ~30% NCCL 环境变量注入、GPUDirect RDMA、拓扑感知调度 NCCL_IB_HCAnccl-tests
故障排查与降级 ~35% PFC 风暴/死锁、端口宕机、吞吐掉速定位 ibstatperfqueryethtool -S
监控与可观测 ~10% RDMA 端口指标采集、丢包/重传告警 Prometheus exporter、ibdump
容器化集成 ~5% K8s RDMA 设备透传、Device Plugin、镜像打包 SR-IOV / Device Plugin

6.2 基础设施侧:把集群"修通"

RoCEv2 无损网络配置是核心。 RoCEv2 跑在普通以太网上,但 RDMA 对丢包近乎零容忍——丢包触发重传,重传风暴一来训练直接卡死。所以必须在交换机侧配 PFC(优先级流控)ECN(显式拥塞通知),网卡侧配对应参数,两侧必须对齐

典型踩坑现场: ibstat 显示端口 DOWN,dmesg 出现 Failed to configure PFC: Invalid argument——根因多半是交换机没开启对应优先级(通常 priority 3 或 4)的 PFC,或两侧 buffer 分配策略不一致。

MTU 要端到端调齐: RoCEv2 要求全链路 MTU ≥ 4096。主机侧 ip link set dev <网卡> mtu 9000,交换机侧要逐跳确认——漏掉一跳,整条路径按最小 MTU 走,而且往往不报错,只是慢

IB vs RoCE 选型经验: 千卡级用 RoCEv2 性价比更高;追求万卡级极致规模和稳定性时 IB 仍更成熟——RoCE 在配置运维复杂度、多租户隔离、故障定位工具链成熟度上目前还落后一截。

6.3 性能侧:把带宽"跑满"

主战场是 NCCL 环境变量调优。NCCL 内部通过 ibverbs 走 RDMA,所以调 NCCL 本质上就是在间接调这条链路。

1
2
3
4
5
6
export NCCL_DEBUG=INFO                 # 必开:日志里 NET/IB 才是走了 RDMA
export NCCL_IB_HCA=mlx5_1,mlx5_2,mlx5_3,mlx5_4 # 指定网卡,选错性能腰斩
export NCCL_IB_GID_INDEX=3 # RoCEv2 常配,但见下方订正
export NCCL_NET_GDR_LEVEL=PHB # GPUDirect RDMA,用字符串不用数字
export NCCL_IB_TC=106 # 流量类别/DSCP,让交换机走无损队列
export NCCL_IB_QPS_PER_CONNECTION=8 # 每连接 QP 数,提升吞吐

⚠️ 三个需要订正的常见写法

整理这部分时我对着 NVIDIA 官方文档核了一遍,发现几个流传很广但已经过时或本身就不对的配置:

订正一:NCCL_NET_GDR_LEVEL=1 是错的。

这个变量自 NCCL 2.4.0 起由 NCCL_IB_GDR_LEVEL 改名而来(功能自 2.3.4 可用),取值应该用字符串:

取值 含义(GPU 与 NIC 的最大距离)
LOC 永不使用 GDR
PIX GPU 与 NIC 在同一 PCI switch 下
PXB 经过多级 PCI switch
PHB 同一 NUMA 节点,流量经过 CPU
SYS 跨 NUMA 的 SMP 互联也用(即始终启用)

整数形式是为了向后兼容才保留的,官方明确不推荐——因为路径类型的数值含义随版本变过。历史上有文档记载的整数语义是 2/3/4/5(默认 2),1 并不对应一个有意义的档位。写 =1 大概率不是你想要的效果。另外 >4 一律按 SYS 处理。

顺带两个相关变量也值得知道:

  • NCCL_NET_GDR_READ——控制发送方向是否用 GDR。2.4.2 之前默认关闭,意味着发送数据会先在 CPU 内存里中转一次;
  • NCCL_NET_GDR_C2C——针对 CPU 经 C2C 互联连 GPU 的形态(如 GH200/GB200),2.26 默认 0,2.27 起默认 1

注意 PIX/PXB/PHB/SYS 这组字符串在 NCCL_P2P_LEVEL 里也用,但那里描述的是 GPU↔GPU 距离,不是 GPU↔NIC。别看串了。

订正二:NCCL_IB_GID_INDEX=3 在新版 NCCL 上可能不必要。

这个变量的默认值已经从 0 改成了 -1(约 2.23.4 起的文档口径)。-1 表示 NCCL 根据设备的活动链路层自动选择正确的 GID index。 也就是说新版 NCCL 在多数场景下能自己选对,硬编码 3 反而可能在某些节点上选错。

正确做法是用 show_gids 确认——它会列出 DEV / PORT / INDEX / GID / IPv4 / version 各列,RoCEv2(IPv4)那一行的 INDEX 才是你要的值。这个值在不同网卡、不同节点上可能不同(比如某些节点 IPv6 全开,fe80 链路本地地址占了靠前的 GID 槽位),所以它天然是动态配置,见第七节。

订正三:GDR 收益别用固定倍数描述。

“开启 GPUDirect RDMA 能把梯度同步时间缩到 1/10” 这类说法在厂商材料里常见,但它高度依赖消息大小、拓扑距离和并行策略。准确的表述是:GDR 省掉了 GPU 显存 → CPU 内存 → 网卡 这次中转,在大消息、GPU 与 NIC 拓扑接近(PIX/PXB)时收益显著,在小消息或跨 NUMA 时收益有限甚至为负。真实收益请用 nccl-tests 在自己的拓扑上实测。

判断 NCCL 到底有没有走 RDMA

唯一可靠的方法是看日志。 NCCL_DEBUG=INFO 输出里找这两行:

1
2
NCCL INFO NET/IB : Using [0]mlx5_1:1/RoCE     ← ✅ 正确,走 RDMA
NCCL INFO NET/Socket : Using [0]eth0 ← ❌ 静默降级到 TCP

静默降级是最大的陷阱:训练不会挂,只会变慢。用户的感知是"MFU 怎么上不去",实际是 RDMA 根本没启用。吞吐差距通常在一个数量级上下(具体取决于网络配置和模型通信量)。

拓扑感知调度

大规模训练里跳数决定一切。同样的 400G 网卡,跨 Pod 通信要比同 Leaf 下多走几跳。K8s 调度器原生不理解 RDMA 拓扑,所以需要调度扩展:把同一 Job 的 Pod 调度到 RDMA 拓扑距离尽可能近的节点和网卡上。

导轨(rail)对齐的核心思想:把所有"同一卡位"的 GPU 划为一个 rail,每个 rail 直连同一组交换机,让跨节点通信尽量一跳可达

多租户隔离是云平台特有的活:不同租户的 RDMA 流量走不同优先级队列,防止一个租户的通信突发把另一个租户的训练打挂。

6.4 故障侧:把问题"定位清楚"

这部分占日常相当大比重,也最考验对链路的理解——因为故障现象和根因之间往往隔着好几层

PFC 风暴与死锁是万卡集群的头号杀手。 RoCEv2 依赖 PFC 做无损传输,但水线配置不当会引发反压帧在整网传播,吞吐断崖式下跌;复杂拓扑或路由不对称还可能触发 PFC 死锁,训练任务永久挂起。

典型现场: 训练跑到一半所有节点通信同时卡死,ethtool -S <网卡> | grep pfc 看到某个优先级的 Pause 帧收发计数暴涨——基本可以确诊。

排障工具箱:

1
2
3
4
5
6
7
8
ibstat                                # 端口状态 Active/Down
perfquery -x # 端口计数器:symbol error、VL15 dropped
ethtool -S <网卡> | grep pfc # PFC 帧计数
cat /sys/class/infiniband/mlx5_0/ports/1/counters/* # 内核侧计数器
show_gids # 确认 GID index(见订正二)
ibping <对端> # 连通性测试
ib_write_bw -d mlx5_0 -s 1048576 # 带宽压测:必须用大包,小包会被噪声淹没
nccl-tests all_reduce_perf # 训练语义的通信性能压测

分层排障逻辑——这正是前面链路知识的直接应用:

典型现象 排查手段
物理层 端口 Down、链路抖动 ibstat、看交换机端口
配置层 PFC/ECN 不一致、MTU 不匹配 对比两端配置、查 PFC 计数器
传输层 吞吐掉速、重传激增 ib_write_bw 压测、ibdump 抓包
内核层 ibv_reg_mr 偶发失败、驱动路径错误 eBPF 内核侧追踪(见 RDMATracer)
应用层 NCCL 超时、训练卡死 NCCL_DEBUG=INFO 看是否回退、是否死锁

注意第四行——内核层长期是这套分层里的空白带。传统工具链在"配置层"和"应用层"两端都有覆盖,中间那段驱动执行路径既没有计数器也没有日志,这正是上一篇文章讲的那个盲区。

6.5 容器化的杂活

K8s 上跑分布式训练还有一堆 RDMA 特有的工程问题:

  • 容器要能访问宿主机 /dev/infiniband/* 设备(SR-IOV 或 Device Plugin 方案);
  • 训练镜像里要装 rdma-core 和厂商库,Dockerfile 里那串 apt-get install libnl-3-dev ... 就是干这个的;
  • Device Plugin 挂掉会导致 Pod 一直 ContainerCreating,这是高频故障。

七、静态层 vs 动态层:为什么 NCCL 变量不能写死

结论先行: RDMA 相关配置分两层——机器级基础层是静态的(驱动、Device Plugin、PFC/ECN、MTU),装一次管所有 Job;Job 级通信层是动态的(NCCL 环境变量、网卡选择、GID index),每次调度落到不同节点,都得根据"这次分到了哪些 GPU 和网卡"现场生成

层级 配置内容 生效范围 需动态调整 负责方
机器级基础层 MOFED/DOCA 驱动、SR-IOV VF、Device Plugin 每台节点 ❌ 装一次 Network Operator / 节点运维
集群网络层 交换机 PFC 水线、ECN 阈值、MTU、缓冲区 整网 ❌ 全局统一 网络工程师
调度决策层 节点分配、GPU-NIC 亲和性、同机架聚合 每次 Job 调度 ✅ 每 Job 都变 K8s 调度器(拓扑感知)
Job 级通信层 NCCL_IB_HCANCCL_IB_GID_INDEX、GDR 等级 每个 Pod ✅ 必须动态生成 训练平台启动逻辑

7.1 为什么不能写死在 Pod 模板里

NCCL_IB_HCA 必须动态的根本原因:GPU 和网卡有亲和性。 一台 8 卡机通常配 4 张网卡,物理设计上 GPU0/1 走 mlx5_0、GPU2/3 走 mlx5_1……如果硬编码 NCCL_IB_HCA=mlx5_0,而这次调度分到的是 GPU6/7 所在的 NUMA 节点,本来该走 mlx5_3 的通信全被打到 mlx5_0,跨 NUMA 访问加上网卡错配,吞吐直接掉一半。

所以更稳妥的做法是把所有可用网卡都交给 NCCL 让它按亲和性自己挑(NCCL_IB_HCA=mlx5_1:1,mlx5_2:1,mlx5_3:1,mlx5_4:1)——但前提是这几张卡确实存在且命名一致,这个前提在异构集群里并不总成立。

NCCL_IB_GID_INDEX 更典型。 如订正二所说,这个值在不同网卡、不同节点上可能不同。所以正确做法是启动时读 sysfs 现场探测:

1
2
3
4
5
6
7
# 启动钩子:动态探测并注入(必须在 command 里执行,不能写在 env 字段)
DEV=$(ls /sys/class/infiniband/ | head -1)
GID=$(awk '$2 !~ /^fe80/ {print NR-1; exit}' \
/sys/class/infiniband/$DEV/ports/1/gids/0)
export NCCL_IB_HCA=$DEV
export NCCL_IB_GID_INDEX=$GID
exec torchrun ...

这里有个物理约束值得单独强调: 探测逻辑必须放在容器的启动命令里,不能写在 Pod 的 env 字段。原因很直接——静态 env 在 Pod 创建时就固化了,没有能力在运行时读取节点的 sysfs。这条约束本身就证明了"一套配置适配所有节点"在物理上不可行。

7.2 平台侧要做的三件事

1. 拓扑感知调度——保证"分到的资源本身是合理的"。默认 K8s 调度器只看资源数量不看位置,可能把一个 8 卡 Job 的 Pod 调到跨机架的两台机器上。需要调度扩展:过滤出网络距离近的节点组合、启用 NUMA 亲和(保证 Pod 内的 GPU 和 RDMA 网卡在同一 NUMA)、感知导轨拓扑。

2. 启动钩子脚本——让每个 Pod “自认知”。就是上面那段探测逻辑,在每台机器上执行结果都不同,天然适配"调度到哪台就适配哪台"。

3. 自动发现兜底——即便什么都不配,NCCL 也会自己扫 /sys/class/infiniband/ 找网卡、扫 GPU 拓扑建通信环。生产上更稳的模式是 “平台注入推荐值 + 允许用户覆盖”

7.3 四个常见坑点速查

坑点 现象 根因 解法
NCCL_IB_HCA 硬编码 部分节点正常、部分掉速 50% GPU 序号与网卡亲和关系在不同节点不一致 交给 NCCL 自己挑,或启动脚本动态读取
GID Index 静态写死 部分 Pod 报 Invalid GID 或建连失败 不同网卡可用 GID 位置不同 启动命令里动态探测,排除 fe80;新版 NCCL 可直接用默认 -1
NCCL 静默降级 TCP 训练不挂但 MFU 极低 IB 初始化失败被 catch 后 fallback 到 Socket NCCL_DEBUG=INFO 确认是 NET/IB 而非 NET/Socket
拓扑感知调度没启用 同 Job Pod 被分散到远端节点 调度器不理解 RDMA 拓扑 接入拓扑感知调度,或用 LeaderWorkerSet 强制同机架

八、一句话收束

调用链:

ibverbs(用户 API)→ libibverbs + provider(命令组装与 UHW 扩展)→ ioctl(RDMA_VERBS_IOCTL)(唯一的系统调用入口)→ ib_uverbs(radix tree 分发 + 句柄表翻译)→ ib_core + 厂商驱动(实际硬件操作)。

这条链路只承担资源管理职责;数据收发完全走 mmap 出来的 Doorbell MMIO 通道——这正是 RDMA 高性能设计的精髓。

术语:

这些名词陌生不是因为它们难,而是因为内核、内存、硬件三个域的行话被压进了一条链路。先分类,再串场景,迷雾自然会散。

岗位:

训练平台对 RDMA 的要求是**“系统性理解 + 定点调优”,不是"逐行实现"。你是这条链路的使用者和运维者**,不是它的开发者——但不理解链路,故障来了就只能瞎猜,因为每一层出问题的表现完全不同。

配置:

静态层装一次管全局,动态层每次调度现场生成。 训练平台的职责是让用户感受不到这个切换:提交 Job 时自动拿到最优的 NCCL 环境变量组合,背后靠拓扑感知调度加启动钩子脚本共同兜底。


附:本文订正的几个常见说法

# 常见说法 实际情况
1 内核模块叫 “ubverbs” 标准名称是 ib_uverbs(Userspace Verbs)
2 ioctl 接口在 v4.17 引入 分阶段成型于 4.14–5.0 窗口:4.14/4.15 以实验性 Kconfig 落地,uapi radix tree 重构在 2018 年底
3 内核用 idr 管理句柄 底层已转为 XArray,但字段名与 API 里的 “idr” 字样保留
4 ib_umem_getget_user_pages Linux 5.6 起改用 pin_user_pages_fast()(FOLL_PIN);解除固定必须用 unpin_user_page()
5 NCCL_NET_GDR_LEVEL=1 整数形式已不推荐;应使用字符串 LOC/PIX/PXB/PHB/SYS。变量自 2.4.0 由 NCCL_IB_GDR_LEVEL 改名
6 NCCL_IB_GID_INDEX=3 必配 默认值已从 0 改为 -1(自动选择);应用 show_gids 确认,或依赖自动探测
7 GDR 能把同步时间缩到 1/10 收益高度依赖消息大小与拓扑距离,须用 nccl-tests 在自己的拓扑上实测

参考