免责声明: 本文涉及对 GPU 驱动的修改,作者不对任何因此产生的后果负责。
两个月前,我通过安装打补丁的驱动,成功为 RTX 5090 启用了 PCIe P2P 支持。就像我在那篇博客最后说的,另一个美好的愿望是,让 5090 这类消费级 GPU 也支持 GPUDirect RDMA,进一步减少跨机通信需要的 CPU 参与。而今天借助 Codex,我居然把这个愿望实现了。
技术背景
GPUDirect RDMA 的核心思想就是让 GPU 可以和网卡直接通信,绕过 CPU 和系统内存进行数据传输。既然驱动已经提供了 GPU 之间互相通信的能力,那么让网卡用上应该没有本质的困难。NVIDIA 也有专门的文档描述如何使用此功能:Developing a Linux Kernel Module using GPUDirect RDMA,看起来第三方的硬件也是可以用上这个特性的,更何况是第一方的 IB 网卡。
从 CUDA 11.4 开始,NVIDIA 提供了内核模块 nvidia-peermem(最早叫作 nv-peer-mem,甚至不和驱动一起分发)来提供 GPUDirect RDMA 的支持。它的原理是,当构建 NVIDIA DKMS 模块时,如果系统中已经安装了 OFED 驱动,则 peermem 模块就能接入 RDMA 内核栈,让网卡驱动把 GPU 内存注册成 RDMA 可访问的内存区域。这里就产生了一些隐式的依赖,也给我这个运维带来了不少麻烦(如果安装的顺序不正确,或者中途升级了 OFED,那么就可能导致 peermem 模块无法加载,GPUDirect 功能静默失效)。
Linux 对于此类能将自己的内存共享给其他设备的外设,其实有自己的解决方案:dma-buf 子系统。根据 The Evolution and Implementation of GPUDirect RDMA 中的时间轴,2021 年内核的 RDMA 子系统增加了对 dma-buf 的支持,从此不同的设备驱动不再需要互相耦合,而可以通过统一的接口进行注册、映射地址,最终发起 IO。近年来的 NVIDIA 开源驱动(主要是 12.8 以后)在较新的内核上,也支持通过 dma-buf 来实现 GPUDirect RDMA 的功能。
由于 dma-buf 子系统中传递的都是 buffer 的 fd,CUDA API 中提供了 cuMemGetHandleForAddressRange 来将 GPU 上的内存(指针)转换为可用的文件描述符。然而,即使在启用了 P2P 的 5090 上,调用此 API 也会直接返回 CUDA_ERROR_NOT_SUPPORTED。此外,使用文档中的 CU_DEVICE_ATTRIBUTE_DMA_BUF_SUPPORTED 对设备进行查询,也会得到 0。
考虑到和 5090 同源的 RTX Pro 6000 是支持 GPUDirect RDMA 的,我猜想 GPU 的硬件上可能并没有缺失功能(也没有必要做这个限制),而又是老黄的刀法。也就是说,软件链路上(用户态 libcuda 或者内核态 nvidia 模块)一定有地方拦住了这个功能。
最终结论(TL;DR)
问题的关键不在内核态驱动中,而在用户态的 libcuda.so。在我的环境下,最小可工作的 patch 是复制 libcuda.so.590.48.01,并修改一个字节:
file offset: 0x42d69b
old: 0x40
new: 0x60
即对应的 X86 反汇编从 or $0x40,%edx 变成 or $0x60,%edx。
这会在 libcuda 的私有 device structure 里多设置一个 0x20 bit。这个 bit 会影响:
CU_DEVICE_ATTRIBUTE_DMA_BUF_SUPPORTEDCU_DEVICE_ATTRIBUTE_GPU_DIRECT_RDMA_SUPPORTEDCU_DEVICE_ATTRIBUTE_GPU_DIRECT_RDMA_WITH_CUDA_VMM_SUPPORTEDcuMemGetHandleForAddressRange(... DMA_BUF_FD, flags=0)cuMemCreate(... CU_MEM_CREATE_USAGE_GPU_DIRECT_RDMA ...)
此后,使用 LD_LIBRARY_PATH 加载这个修改过的 libcuda.so 即可。如果使用 NCCL,则可能还需要对应调整环境变量:
NCCL_NET_GDR_LEVEL=SYS
端到端验证中,24 卡(三台机器,每机一张 400Gbps NDR IB 卡)进行 allreduce 的性能(busbw),从约 8.87 GB/s 提升到了 19.93 GB/s。并且可以从日志中看到如下的信息,证明 NCCL 确实检测到并且在使用 GPUDirect RDMA 功能:
node1:496222:496276 [7] NCCL INFO DMA-BUF is available on GPU device 7
node1:496222:496272 [3] NCCL INFO DMA-BUF is available on GPU device 3
node1:496222:496270 [1] NCCL INFO DMA-BUF is available on GPU device 1
...
node1:496222:496297 [6] NCCL INFO Channel 01/0 : 6[6] -> 14[6] [send] via NET/IB/0/GDRDMA
node2:2686390:2686463 [7] NCCL INFO Channel 01/0 : 15[7] -> 14[6] via P2P/direct pointer
node1:496222:496297 [6] NCCL INFO Channel 01/0 : 14[6] -> 6[6] [receive] via NET/IB/0/GDRDMA
node3:2526722:2526797 [6] NCCL INFO Channel 01/0 : 22[6] -> 7[7] [send] via NET/IB/0/GDRDMA
node1:496222:496297 [6] NCCL INFO Channel 00/0 : 6[6] -> 22[6] [send] via NET/IB/0/GDRDMA
node2:2686390:2686464 [6] NCCL INFO Channel 00/0 : 14[6] -> 23[7] [send] via NET/IB/2/GDRDMA
node3:2526722:2526801 [2] NCCL INFO Channel 00/0 : 18[2] -> 23[7] via P2P/direct pointer
node1:496222:496298 [5] NCCL INFO Channel 00/0 : 5[5] -> 0[0] via P2P/direct pointer
node3:2526722:2526801 [2] NCCL INFO Channel 01/0 : 18[2] -> 23[7] via P2P/direct pointer
node3:2526722:2526798 [5] NCCL INFO Channel 00/0 : 21[5] -> 16[0] via P2P/direct pointer
node3:2526722:2526798 [5] NCCL INFO Channel 01/0 : 21[5] -> 16[0] via P2P/direct pointer
...
node2:2686390:2686476 [3] NCCL INFO Connected all rings, use ring PXN 0 GDR 1
node2:2686390:2686474 [5] NCCL INFO Connected all rings, use ring PXN 0 GDR 1
node2:2686390:2686475 [4] NCCL INFO Connected all rings, use ring PXN 0 GDR 1
node2:2686390:2686473 [6] NCCL INFO Connected all rings, use ring PXN 0 GDR 1
node3:2526722:2526813 [0] NCCL INFO Connected all rings, use ring PXN 0 GDR 1
node3:2526722:2526812 [1] NCCL INFO Connected all rings, use ring PXN 0 GDR 1
node2:2686390:2686472 [7] NCCL INFO Connected all rings, use ring PXN 0 GDR 1
调试过程
下面的内容几乎都是 Codex 生成的,调试也是它独立完成的,大概消耗了一个小时。
我在其中唯一的作用,是在它每次想要 sudo 的时候,检查一下命令内容,并决定是不是批准。过程基于驱动 NVIDIA 590.48.01、CUDA 13.1 进行。
背景:为什么一开始看 kernel driver
RTX 5090 在官方驱动里本来不暴露 CUDA DMA-BUF / GPUDirect RDMA capability。IB perftest 里如果使用:
--use_cuda_dmabuf
它会走 CUDA driver API:
cuDeviceGetAttribute(&is_supported,
CU_DEVICE_ATTRIBUTE_DMA_BUF_SUPPORTED,
cuda_ctx->cuDevice)
把这段检查硬注释掉继续跑,后面仍然失败:
cuMemGetHandleForAddressRange error=801
801 是 CUDA_ERROR_NOT_SUPPORTED。这说明问题不是 perftest 自己的前置检查,而是 CUDA driver API 真的拒绝了 dma-buf export。
当时的初始假设是:open GPU kernel module 里某个 capability 没打开, 导致用户态 CUDA 判断 RTX 5090 不支持 dma-buf。
第一阶段:在 kernel driver 里强行打开 DMABUF capability
先从 kernel module 里能看到的 DMABUF capability 入手。
关键位置包括:
osinit.csubdevice_ctrl_gpu_kernel.crmapi_cache.ccontrol.cnv-dmabuf.c
做过的实验包括:
-
在
osinit.c里打印并强制:nv->dma_buf_supported = NV_TRUE;dmesg 能看到:
DMABUF osinit: status=0x0 raw_cap=1 dma_buf_supported=1 before force DMABUF osinit: forced dma_buf_supported=1 -
在
subdevice_ctrl_gpu_kernel.c里对:NV2080_CTRL_GPU_INFO_INDEX_DMABUF_CAPABILITY强制返回:
NV2080_CTRL_GPU_INFO_INDEX_DMABUF_CAPABILITY_YES -
在
rmapi_cache.c里避免 RM cache 把这个 capability 缓存成 NO。 -
在
control.c里打印 RM control 调用,确认用户态请求和 kernel 返回。 -
在
nv-dmabuf.c里打印nv_dma_buf_create(),用于确认最终有没有进入 dma-buf export 路径。
结果:kernel 里看起来已经是 YES,但 CUDA API 仍然报告:
CU_DEVICE_ATTRIBUTE_DMA_BUF_SUPPORTED = 0
CU_DEVICE_ATTRIBUTE_GPU_DIRECT_RDMA_SUPPORTED = 0
cuMemGetHandleForAddressRange(...) = CUDA_ERROR_NOT_SUPPORTED
这一步非常关键:它说明 kernel 侧 NV2080_CTRL_GPU_INFO_INDEX_DMABUF_CAPABILITY
不是唯一 gate,甚至不是最后那个决定 CUDA API 行为的 gate。
第二阶段:写 CUDA probe,把问题缩小到 libcuda
写了一个很小的 CUDA driver API probe,检查这些 attribute:
CU_DEVICE_ATTRIBUTE_POSIX_FILE_DESCRIPTOR_SUPPORTED
CU_DEVICE_ATTRIBUTE_GPU_DIRECT_RDMA_WITH_CUDA_VMM_SUPPORTED
CU_DEVICE_ATTRIBUTE_GPU_DIRECT_RDMA_SUPPORTED
CU_DEVICE_ATTRIBUTE_GPU_DIRECT_RDMA_FLUSH_WRITES_OPTIONS
CU_DEVICE_ATTRIBUTE_GPU_DIRECT_RDMA_WRITES_ORDERING
CU_DEVICE_ATTRIBUTE_DMA_BUF_SUPPORTED
CU_DEVICE_ATTRIBUTE_HOST_ALLOC_DMA_BUF_SUPPORTED
原始结果大概是:
POSIX_FD_SUPPORTED = 1
GPU_DIRECT_RDMA_WITH_CUDA_VMM = 0
GPU_DIRECT_RDMA_SUPPORTED = 0
GPU_DIRECT_RDMA_FLUSH_WRITES_OPTIONS = 1
GPU_DIRECT_RDMA_WRITES_ORDERING = 100
DMA_BUF_SUPPORTED = 0
HOST_ALLOC_DMA_BUF_SUPPORTED = 0
cuMemGetHandleForAddressRange flags=0 = CUDA_ERROR_NOT_SUPPORTED
注意这里 POSIX_FD_SUPPORTED=1,说明 CUDA VMM 普通 fd export 能力和
dma-buf / GPUDirect RDMA gate 不是同一个东西。
这时继续只改 kernel 没有效果,所以开始反汇编 libcuda.so.590.48.01。
第三阶段:反汇编 cuDeviceGetAttribute
先找 cuDeviceGetAttribute:
public wrapper: 0x30ca40
real target: 0x31bef0
attribute read: 0x25ce00
cuDeviceGetAttribute 内部有一个 attribute jump table。关键分支包括:
attr 103 POSIX_FD_SUPPORTED -> 0x25d676
attr 110 GPU_DIRECT_RDMA_WITH_CUDA_VMM -> 0x25d6f6
attr 116 GPU_DIRECT_RDMA_SUPPORTED -> 0x25d604
attr 124 DMA_BUF_SUPPORTED -> 0x25cf91
其中 attr 116 的逻辑很直接:
xor %eax,%eax
testb $0x20,0x5f14(%rdi)
je ...
cmpb $0x0,global_gdr_enabled
setne %al
也就是说,GPU_DIRECT_RDMA_SUPPORTED 至少要满足:
dev_struct[0x5f14] & 0x20 != 0
global_gdr_enabled != 0
attr 124 也会检查类似条件:
testb $0x8,0x5e80(...)
testb $0x20,0x5f14(...)
cmpb $0x0,global_gdr_enabled
所以问题逐渐变成:RTX 5090 的 libcuda device structure 里,为什么缺了
0x5f14 的 0x20 bit?
第四阶段:直接 poke libcuda 的私有 device structure
为了验证这个猜想,没有马上 patch 文件,而是写了一个进程内 poke 程序。它通过 dladdr(cuDeviceGetAttribute) 找到当前进程加载的 libcuda base,然后定位 libcuda 的 device table:
device_slot = base + 0x5e32260 + 0x328 + dev * 8
device_struct = *(uintptr_t *)device_slot
再临时改三个位置:
dev_struct[0x5f14] |= 0x20;
dev_struct[0x5e80] |= 0x08;
*(base + 0x5e38650) = 1;
这些 magic number 的定位过程是这样的:
- 先从
cuDeviceGetAttribute反汇编看 attribute 逻辑
比如 CU_DEVICE_ATTRIBUTE_GPU_DIRECT_RDMA_SUPPORTED 分支里有:
testb $0x20,0x5f14(%rdi)
cmpb $0x0,0x5e38650(...)
这说明它在检查某个 device struct 的 0x5f14 偏移,bit 是 0x20,还检查一个全局 byte。
DMA_BUF_SUPPORTED 分支里也有:
testb $0x8,0x5e80(...)
testb $0x20,0x5f14(...)
cmpb $0x0,0x5e38650(...)
所以 0x5f14 bit 0x20 和 0x5e80 bit 0x08 是从 attribute 分支直接读出来的。
- device table 是从 API wrapper 里推出来的
cuDeviceGetAttribute 的真实实现里有多处:
lea ... # 0x5e32260
然后根据 CUdevice dev ordinal 去索引一个指针表。结合运行时观察,实际是:
device_slot = base + 0x5e32260 + 0x328 + dev * 8;
device_struct = *(uintptr_t *)device_slot;
这个不是符号告诉我的,是看它怎么从 dev ordinal 找内部对象,再用 poke 程序验证出来的。
改之前,RTX 5090 上 dev+0x5f14 是 0x58,改之后变成 0x78,测试结果立刻变成:
GPU_DIRECT_RDMA_WITH_CUDA_VMM = 1
GPU_DIRECT_RDMA_SUPPORTED = 1
DMA_BUF_SUPPORTED = 1
cuMemGetHandleForAddressRange(flags=0) = CUDA_SUCCESS
同时 dmesg 开始出现:
nv_dma_buf_create: dma_buf_supported=1 ... mappingType=0 ...
这一步证明了两件事:
- kernel 的 dma-buf export 路径本身能被走到;
- CUDA API 失败的主要原因是 libcuda 用户态私有 flag 没设置。
第五阶段:做 shim,只作为调试工具
接着写了一个 libcuda.so shim,它会加载真实的 libcuda.so.590.48.01,然后在这些调用前后 patch 私有 device structure:
cuInitcuDeviceGetcuDeviceGetAttributecuMemGetHandleForAddressRangecuMemCreate
shim 能让 probe 成功,也能让 perftest 走到:
Calling ibv_reg_mr_ex with dmabuf(offset=0, size=..., addr=..., fd=...)
但是 shim 后来被放弃作为最终方案,原因是:
- CUDA runtime 和 NCCL 通过
cuGetProcAddress()使用很多 driver API; - 一个不完整的 libcuda forwarding shim 很容易漏函数;
-
漏函数时 CUDA runtime / NCCL 会报:
API call is not supported in the installed CUDA driver
所以 shim 只适合作为 reverse engineering / probe 工具,不适合作为 NCCL 性能测试方案。
第六阶段:寻找真正的初始化代码
poke 证明了要改 dev+0x5f14 的 0x20 bit。
下一步是找这个字段在哪里初始化。
在 libcuda.so.590.48.01 里搜索对 0x5f14 的引用,最终找到一个非常关键的
device initialization 片段:
0x42d690: movzbl 0x5f14(%rdi),%eax
0x42d697: mov %eax,%edx
0x42d699: or $0x40,%edx
0x42d69c: cmpl $0x8,0xc60(%rdi)
0x42d6a3: mov %dl,0x5f14(%rdi)
这段代码的意思是:
flags = dev->field_5f14;
flags |= 0x40;
if (dev->field_c60 <= 8) {
dev->field_5f14 = flags;
...
}
RTX 5090 的这个字段本来会被初始化成含 0x40,但不含 0x20。而暴露我们需要的 CUDA attribute 分支需要的是 0x20。
因此最小 patch 很自然:把 or $0x40 改成 or $0x60。也就是保留原来的 0x40,再额外加上 0x20。机器码上:
83 ca 40 -> 83 ca 60
只改中间那个 immediate byte:
file offset 0x42d69b: 0x40 -> 0x60
第七阶段:用 patched libcuda 副本验证
没有直接改系统 libcuda,而是复制了一份:
libcuda-onebyte-patch/libcuda.so.590.48.01
libcuda-onebyte-patch/libcuda.so.1 -> libcuda.so.590.48.01
libcuda-onebyte-patch/libcuda.so -> libcuda.so.590.48.01
使用方式:
export LD_LIBRARY_PATH=/path/to/libcuda-onebyte-patch:${LD_LIBRARY_PATH:-}
CUDA probe 结果:
GPU_DIRECT_RDMA_WITH_CUDA_VMM = 1
GPU_DIRECT_RDMA_SUPPORTED = 1
DMA_BUF_SUPPORTED = 1
cuMemGetHandleForAddressRange(flags=0) = CUDA_SUCCESS
这个 patched copy 比 shim 更适合 NCCL,因为它仍然是完整的官方 libcuda, 只是改了一个初始化 immediate。
方法迁移
如果 CUDA 版本变了,显然这个静态 patch 不能假定仍然可用。不过上面 Codex 的分析流程其实足够通用:寻找 cuDeviceGetAttribute 中,某个特定的 attribute 的检查逻辑,找到对应的 bit flag 和全局变量,然后反过来在 libcuda 里搜索这个 bit flag 的初始化位置,最后根据需要修改控制流。
我还让 Codex 帮我生成了一个扫描某些 pattern 并尝试自动 patch 的脚本:auto_patch_libcuda_dmabuf.py。欢迎尝试,但请务必先备份原始 libcuda.so,并且在测试环境中多加验证。
总结
基于 GPT-5.5 的 Codex 在本次调试过程中,呈现出了非常强的分析和调试能力,特别是对于二进制中控制流的理解。我原本完全没有预期它能在一个小时内解决此问题;当然这也要归因于 NVIDIA 没有真的砍掉硬件功能,也没有尝试在驱动里设置什么复杂的障碍。