RDMA 全链路拆解:从 ibv_reg_mr 到硬件,再到 AI 训练平台的实战落点
RDMA 全链路拆解:从 ibv_reg_mr 到硬件,再到 AI 训练平台的实战落点
RDMA 这套东西之所以让人望而生畏,不是因为哪个概念特别难,而是因为它把三个本来独立的知识域压进了同一条调用链:Linux 内核系统调用机制、内存管理、网络硬件。单独看每个术语都不复杂,叠在一起才显得信息量爆炸。
这篇文章分三大块:
- 调用链——从
ibv_reg_mr一路走到 HCA 硬件,每一层在做什么; - 术语地图——把所有黑话按类别拆开,每个只给一句人话;
- 实战落点——AI 训练云平台上,这条链路到底体现在哪些日常工作里。
先正一个名。 中文社区里偶尔能看到 “ubverbs” 这个写法,它不是标准名称。内核里承接用户态 verbs 请求的模块叫
ib_uverbs(Userspace Verbs),源码在drivers/infiniband/core/uverbs*.c。本文统一用ib_uverbs。
关联阅读: 这条链路上的故障怎么观测,我在 《RDMATracer 拆解:13 个 eBPF 探针如何补上 AI 集群的内核盲区》 里写过。那篇讲的是"怎么看见这条链路上的失败",这篇讲的是"这条链路本身长什么样"——两篇对着读效果更好。本文第 2.2 节提到的
__ib_umem_get、ib_peer_umem_get正是那篇里 fleet 级EFAULT事故的现场。
一、软件栈分层架构
| 层级 | 所在空间 | 典型组件 | 核心职责 |
|---|---|---|---|
| 应用层 | 用户态 | 用户程序 / NCCL | 调用 ibv_* 系列 API |
| libibverbs | 用户态 | rdma-core 的 libibverbs.so |
提供 verbs 抽象 API,组装命令、发起系统调用 |
| Provider 库 | 用户态 | libmlx5.so、librxe.so 等 |
硬件特定逻辑(UHW 扩展),实现用户态 Doorbell 直写 |
| 字符设备接口 | 内核入口 | /dev/infiniband/uverbsN |
用户态与内核态的"海关",承接 ioctl/write |
| ib_uverbs | 内核态 | drivers/infiniband/core/uverbs*.c |
解析 ioctl 命令,管理对象生命周期,做对象/方法/属性分发 |
| ib_core | 内核态 | drivers/infiniband/core/ |
设备注册、GID 管理、内存固定、错误处理 |
| 厂商内核驱动 | 内核态 | mlx5_ib.ko、irdma.ko 等 |
实现 struct ib_device_ops,操作硬件 |
| RDMA 硬件 | — | HCA / NIC | 执行 DMA、队列处理 |
整套架构的核心思想是控制面与数据面分离:慢路径(资源创建销毁)走 ioctl 进内核,快路径(数据收发)通过 mmap 映射的 MMIO 寄存器直接操作硬件,完全绕过内核。
1 | 用户态 内核态 硬件 |
二、三个典型场景的调用链
2.1 场景一:设备枚举与打开
一个容易搞错的细节:ibv_get_device_list() 主要是读 /sys/class/infiniband/ 目录做枚举,并不走 ioctl。真正触发系统调用的是 ibv_open_device()。
1 | 用户态 内核态 |
关键点: ibv_open_device 拿到 cmd_fd(uverbs 字符设备描述符)后,后续所有控制面操作都复用这个 fd。这个 fd 的生命周期也是内核做资源回收的锚点——进程退出、fd 关闭时,挂在这个 context 下的所有 QP/MR/PD/CQ 都会被自动清理。
2.2 场景二:资源创建(以 ibv_reg_mr 为例)
这是最能体现完整调用链的场景。ibv_alloc_pd、ibv_create_cq、ibv_create_qp 的路径结构与之相同,只是命令码不同。
用户态侧(rdma-core)
1 | ibv_reg_mr(pd, addr, length, access) |
ibv_command_buffer 的设计值得一提:通过 next 指针支持多缓冲区链表,hdr 统一管理属性元信息,UHW(User HW Data)空间用于厂商扩展——这样厂商私有参数不用污染通用 ABI。
内核态侧
1 | sys_ioctl |
时序图
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 完全不参与的情况下读写这块用户内存。要做到这一点,内核必须保证两件事:
- 这些页不能被换出或迁移——否则网卡 DMA 到一半页没了,直接数据损坏;
- 网卡要拿到虚拟地址 → 物理地址(或 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_PIN与FOLL_GET互斥;FOLL_PIN是FOLL_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 | ibv_post_send(qp, wr, bad_wr) |
ibv_poll_cq 同理:CQ 缓冲区也映射在用户空间,直接轮询即可。这就是 RDMA "零拷贝、内核旁路"的底层实现。
例外: 软件 RDMA(如
siw、rxe)没有真实的 MMIO Doorbell,post_send会退化成通知内核模块处理。这属于模拟实现,不是主流路径,性能特征完全不同——用它做功能验证可以,做性能评估会得出完全错误的结论。
三、ioctl 契约:命令结构与演进
3.1 UAPI 头文件
核心命令格式定义在 include/uapi/rdma/rdma_user_ioctl_cmds.h:
1 |
|
两个 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_OBJECT 配 UVERBS_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_handle、qp_handle 只是个整数编号,内核收到后查表还原出真实对象指针。这样做有两个好处:不泄露内核地址,以及进程退出时能按表回收、防止资源泄漏。
这里有个实现细节值得一提:这张表历史上是 idr,后来被 Matthew Wilcox 的系列补丁转成了 XArray,但字段名和 API 里的 “idr” 字样都保留了下来(ufile->idr 现在是个 struct xarray)。所以你在新内核源码里看到 idr 这个词,它指的其实是 XArray。转换后的典型操作:
1 | /* 分配:先占一个 NULL 槽位,commit 时再换成真对象 */ |
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 | 旧路径(write): |
新旧对比:
| 维度 | 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 | 慢路径(控制面): |
这直接决定了 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 建议的理解顺序
- 先懂四个资源对象:PD → MR → QP → CQ。它们是整条链路存在的目的,一切系统调用都是为了创建和管理它们;
- 再看慢路径三个通用机制:ioctl、mmap、pin pages。搞清"为什么创建资源必须进内核(安全 + 页表),而发数据不用(队列已映射出来)";
- 最后看快路径的 Doorbell + MMIO:理解"写一个寄存器值通知硬件"这个动作,RDMA 的核心魔法就全通了。
六、AI 训练平台上,这条链路体现在哪
一个需要先纠正的认知:在云平台/训练平台岗位上,“懂 RDMA” 通常不等于 “会写 verbs 程序”,而是 “懂它的工作机制后知道在哪里调、调坏了去哪查”。
你大概率不会天天手写 ibv_post_send——那层逻辑已经被 NCCL 封装掉了——但你做的每个决策、调的每个参数、排查的每次掉速,最终都会传导到这条链路上。
6.1 日常工作全景
| 工作方向 | 占比(经验值) | 具体任务 | 典型工具 |
|---|---|---|---|
| 网络基础设施建设 | ~20% | RoCE 交换机 PFC/ECN 配置、网卡驱动安装、MTU 调整 | 交换机 CLI、mlnx_tune、ip link set |
| 通信性能调优 | ~30% | NCCL 环境变量注入、GPUDirect RDMA、拓扑感知调度 | NCCL_IB_HCA、nccl-tests |
| 故障排查与降级 | ~35% | PFC 风暴/死锁、端口宕机、吞吐掉速定位 | ibstat、perfquery、ethtool -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 | export NCCL_DEBUG=INFO # 必开:日志里 NET/IB 才是走了 RDMA |
⚠️ 三个需要订正的常见写法
整理这部分时我对着 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 | NCCL INFO NET/IB : Using [0]mlx5_1:1/RoCE ← ✅ 正确,走 RDMA |
静默降级是最大的陷阱:训练不会挂,只会变慢。用户的感知是"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 | ibstat # 端口状态 Active/Down |
分层排障逻辑——这正是前面链路知识的直接应用:
| 层 | 典型现象 | 排查手段 |
|---|---|---|
| 物理层 | 端口 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_HCA、NCCL_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 | # 启动钩子:动态探测并注入(必须在 command 里执行,不能写在 env 字段) |
这里有个物理约束值得单独强调: 探测逻辑必须放在容器的启动命令里,不能写在 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_get 用 get_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 在自己的拓扑上实测 |
参考
- 内核源码:
drivers/infiniband/core/uverbs_ioctl.c、uverbs_uapi.c、rdma_core.c、umem.c;include/uapi/rdma/rdma_user_ioctl_cmds.h - Userspace verbs access — The Linux Kernel documentation
- IB/core: SG IOCTL based RDMA ABI(Matan Barak 的 13 patch 系列)
- uverbs: Convert idr to XArray(Matthew Wilcox)
- RDMA/core: Fix double destruction of uobject
- mm/gup: track dma-pinned pages: FOLL_PIN — LWN
- pin_user_pages() and related calls — Kernel docs
- NCCL Environment Variables — NVIDIA 官方文档
- rdma-core(GitHub)
- 本站关联:《RDMATracer 拆解:13 个 eBPF 探针如何补上 AI 集群的内核盲区》



