一、为什么 RDMA 重新站到舞台中央

大模型训练把网络从"辅助组件"推成了"算力的一部分"。一个 175B 参数的模型做一次数据并行同步,AllReduce 的通信量在 GB 量级;当单卡算力增长放缓而集群规模持续扩张时,通信墙(communication wall)直接决定 MFU(Model FLOPs Utilization)。

传统 TCP/IP 路径的代价是结构性的:一次 send() 要经历用户态到内核态的拷贝、协议栈处理、再到网卡 DMA,中间还有中断与上下文切换。在 200Gbps 链路上打满线速,纯内核协议栈往往要吃掉多个 CPU 核。RDMA(Remote Direct Memory Access)的价值在于三件事同时成立:内核旁路(kernel bypass)、零拷贝(zero copy)、CPU 卸载(protocol offload)——数据从本地用户态内存直接落到远端用户态内存,CPU 只负责下发工作请求。

但 RDMA 不是"换一个 API 就变快"的银弹。它把复杂度从内核搬到了应用:内存必须先注册、连接必须显式管理、完成语义必须自己处理、拥塞控制交给网络。下面从语义、代码、到生产陷阱逐层拆开。

二、三种传输语义:先选对模型

RDMA 提供三类操作,选错语义是初学者第一个坑:

语义 是否需要对端 CPU 参与 是否知道地址 典型用途
Send / Recv 需要(对端必须预 Post Receive) 否 控制面、小消息、带立即数通知
Write 不需要 需要远端 rkey + 虚拟地址 数据推送、梯度传输
Read 不需要 需要远端 rkey + 虚拟地址 拉取远端状态、参数服务器

关键差异在于 Write/Read 是单边操作(one-sided):远端 CPU 完全不参与,不产生中断,甚至不知道这次传输发生过。这是 RDMA 性能魔力的来源,也是调试噩梦的根源——远端进程崩溃了,你这边可能还在成功写入一块已经无人看管的内存。

传输类型(QP 类型)同样要选:

  • RC(Reliable Connected):可靠、有序、支持全部语义。AI 训练的事实标准。
  • UC(Unreliable Connected):不支持 Read,可能丢包。少用。
  • UD(Unreliable Datagram):类似 UDP,支持多播。用于连接建立阶段的地址发现(如通过 UD 交换 RC 连接信息)。

三、Verbs 核心对象模型

在写代码之前,必须理解五个对象的关系:

Protection Domain (PD)
  ├── Memory Region (MR)  → 注册后得到 lkey / rkey
  └── Queue Pair (QP) = Send Queue (SQ) + Receive Queue (RQ)
        └── 绑定到 Completion Queue (CQ)
  • PD:隔离域,MR 和 QP 必须在同一 PD 内才能互相引用。
  • MR:把一段虚拟内存钉住(pin)并交给网卡,返回 32 位 lkey(本地访问)和 rkey(远端访问)。没有注册的缓冲区无法被 RDMA 访问——这是零拷贝的前提,也是最大的成本来源。
  • QP:通信端点,等价于一条连接的收发队列。SQ 上放 Work Request(WR),RQ 上必须预先 Post Receive 缓冲区。
  • CQ:完成队列,WR 完成后生成一个 CQE(Completion Queue Entry)。轮询 CQ 是唯一获知完成的方式,没有回调,没有中断(除非你主动 arm 一个)。

四、代码实战:建立一个 RC 连接并做单边写

下面是从零建立 RC QP 并做 RDMA Write with Immediate 的精简骨架。省略了设备枚举与错误处理,聚焦主干逻辑。

4.1 打开设备与注册内存

#include <infiniband/verbs.h>

struct ibv_context *ctx;
struct ibv_device **dev_list = ibv_get_device_list(NULL);
ctx = ibv_open_device(dev_list[0]);          // 打开第一个 HCA

struct ibv_pd *pd = ibv_alloc_pd(ctx);

// 关键:缓冲区必须页对齐,否则注册后仍可能被限制访问粒度
size_t buf_size = 4 * 1024 * 1024;
void *buf;
posix_memalign(&buf, sysconf(_SC_PAGESIZE), buf_size);
memset(buf, 0, buf_size);                    // 必须先触碰,确保页已映射

struct ibv_mr *mr = ibv_reg_mr(
    pd, buf, buf_size,
    IBV_ACCESS_LOCAL_WRITE  |
    IBV_ACCESS_REMOTE_WRITE |
    IBV_ACCESS_REMOTE_READ
);
// mr->lkey 本地用,mr->rkey 发给对端用于单边操作

posix_memalign 与 memset 这两步不是形式主义。注册内存会触发缺页并把页钉死,未对齐的缓冲区可能导致网卡按页粒度访问时越界;未触碰的页在注册后如果发生写时复制(COW),物理页换掉而网卡仍持有旧页地址,就会出现静默数据损坏。这是 RDMA 最经典的隐蔽 bug。

4.2 创建 CQ 与 QP

struct ibv_cq *cq = ibv_create_cq(ctx, 1024, NULL, NULL, 0);

struct ibv_qp_init_attr qp_attr = {0};
qp_attr.send_cq = cq;
qp_attr.recv_cq = cq;
qp_attr.cap.max_send_wr  = 512;   // SQ 深度,直接决定在途请求数
qp_attr.cap.max_recv_wr  = 512;   // RQ 深度,必须 >= 对端并发 Send 数
qp_attr.cap.max_send_sge = 4;     // 每个 WR 最多聚合几个散布段
qp_attr.cap.max_recv_sge = 4;
qp_attr.cap.max_inline_data = 256;// 小数据内联进 WR,省一次 DMA 读取
qp_attr.qp_type = IBV_QPT_RC;     // 可靠连接

struct ibv_qp *qp = ibv_create_qp(pd, &qp_attr);

max_inline_data 是个被低估的优化项:小于该阈值的 payload 会随 WR 一起通过 PCIe 写入网卡,网卡无需再发起一次 DMA 回读主机内存,小消息延迟可降数百纳秒。

4.3 状态机迁移(最容易被忽视的环节)

QP 不是创建完就能用,必须严格按 RESET → INIT → RTR → RTS 迁移:

// RESET -> INIT
struct ibv_qp_attr attr = {0};
attr.qp_state        = IBV_QPS_INIT;
attr.port_num        = 1;
attr.pkey_index      = 0;
attr.qp_access_flags = IBV_ACCESS_REMOTE_WRITE | IBV_ACCESS_REMOTE_READ;
ibv_modify_qp(qp, &attr,
    IBV_QP_STATE | IBV_QP_PKEY_INDEX | IBV_QP_PORT | IBV_QP_ACCESS_FLAGS);

// INIT -> RTR(Ready To Receive):此处填入对端信息
attr.qp_state              = IBV_QPS_RTR;
attr.path_mtu              = IBV_MTU_4096;      // RoCE 通常 1024/2048
attr.dest_qp_num           = remote_qpn;        // 对端 QP 号
attr.rq_psn                = remote_psn;        // 对端起始包序列号
attr.max_dest_rd_atomic    = 16;                // 允许对端的在途 Read 数
attr.min_rnr_timer         = 12;                // RNR NAK 重传间隔
attr.ah_attr.is_global     = 1;                 // RoCEv2 必须走 GRH
attr.ah_attr.grh.dgid      = remote_gid;
attr.ah_attr.grh.sgid_index= 1;                 // 注意:RoCEv2 常被误配为 0
attr.ah_attr.port_num      = 1;
ibv_modify_qp(qp, &attr, IBV_QP_STATE | IBV_QP_AV | IBV_QP_PATH_MTU |
    IBV_QP_DEST_QPN | IBV_QP_RQ_PSN | IBV_QP_MAX_DEST_RD_ATOMIC | IBV_QP_MIN_RNR_TIMER);

// RTR -> RTS(Ready To Send)
attr.qp_state      = IBV_QPS_RTS;
attr.sq_psn        = local_psn;
attr.timeout       = 14;          // 重传超时 ~4.096us * 2^14
attr.retry_cnt     = 7;           // 传输层重试次数
attr.rnr_retry     = 7;           // RNR 重试,0 表示无限重试
attr.max_rd_atomic = 16;
ibv_modify_qp(qp, &attr, IBV_QP_STATE | IBV_QP_TIMEOUT | IBV_QP_RETRY_CNT |
    IBV_QP_RNR_RETRY | IBV_QP_SQ_PSN | IBV_QP_MAX_QP_RD_ATOMIC);

sgid_index 是 RoCEv2 部署的高频故障点。InfiniBand 一般用 index 0,而 RoCEv2 的 RoCEv1/UDP 封装 GID 表位置依赖驱动与配置,ibv_devinfo -v 里那个带 IPv4 映射地址的 GID 才是正确索引。配错的表现非常迷惑:QP 能建起来,但第一个包就丢,抓包看是 UDP 目的 MAC 不对。

4.4 发起单边写 + 立即数

// 对端必须预先 Post Receive,否则立即数无处安放 -> RNR NAK
static void post_recv(struct ibv_qp *qp, struct ibv_mr *mr) {
    struct ibv_sge sge = { .addr = (uintptr_t)mr->addr,
                           .length = 64, .lkey = mr->lkey };
    struct ibv_recv_wr wr = {0}, *bad = NULL;
    wr.wr_id   = 0x1234;      // 自定义标识,会在 CQE 中原样返回
    wr.sg_list = &sge;
    wr.num_sge = 1;
    ibv_post_recv(qp, &wr, &bad);
}

static void rdma_write_imm(struct ibv_qp *qp, struct ibv_mr *mr,
                           uint64_t remote_addr, uint32_t rkey) {
    struct ibv_sge sge = { .addr = (uintptr_t)mr->addr,
                           .length = 4096, .lkey = mr->lkey };
    struct ibv_send_wr wr = {0}, *bad = NULL;
    wr.wr_id      = 0x5678;
    wr.sg_list    = &sge;
    wr.num_sge    = 1;
    wr.opcode     = IBV_WR_RDMA_WRITE_WITH_IMM;  // 写 + 通知
    wr.send_flags = IBV_SEND_SIGNALED;           // 生成 CQE
    wr.imm_data   = htonl(0xCAFE);
    wr.wr.rdma.remote_addr = remote_addr;        // 对端 mr->addr
    wr.wr.rdma.rkey        = rkey;               // 对端 mr->rkey
    ibv_post_send(qp, &wr, &bad);
}

WRITE_WITH_IMM 是工程上最常用的组合:数据走单边写落到远端内存(不消耗对端 CPU),同时立即数触发一个 CQE 唤醒对端(对端仍需预 Post 一个 Receive 来接这个立即数)。数据传输零 CPU,通知一次中断,这个组合几乎是所有 RDMA 中间件(NCCL、UCX、RPC 框架)的通用范式。

4.5 轮询完成队列

struct ibv_wc wc;
int ne;
do {
    ne = ibv_poll_cq(cq, 1, &wc);
} while (ne == 0);

if (ne < 0 || wc.status != IBV_WC_SUCCESS) {
    fprintf(stderr, "CQE error: %s\n", ibv_wc_status_str(wc.status));
    // 一旦出现 WC 错误,该 QP 基本进入不可用状态,必须重建
}
// wc.wr_id 对应你下发时的标识;wc.opcode 区分是 Send/Write/Recv

注意 ibv_poll_cq 返回值三种语义:>0 取到的 CQE 数,0 表示空,<0 表示出错。不要用 ne == 0 判断错误。另外,一旦某个 WR 产生错误 CQE,该 QP 通常会进入 error state,正确做法是整体重建而不是继续下发。

五、被低估的成本:内存注册

ibv_reg_mr 不是免费的。它要做三件事:锁页(mlock)、建立虚拟地址到物理地址的映射表(MTT)、把映射表写入网卡缓存。实测单次注册 4MB 内存约 5–20 微秒,且是同步阻塞操作。

这直接引出几条工程结论:

  1. 绝不收发路径上注册内存。必须做内存池 + MR 缓存,启动时注册大块,运行时从池里切分。
  2. 警惕注册缓存膨胀。长期运行的进程如果按请求粒度缓存 MR,物理页被钉死的总量会超过 RLIMIT_MEMLOCK,表现为注册失败而非 OOM,很难归因。
  3. 考虑 ODP(On-Demand Paging)。现代网卡支持按需分页,注册时不再全量钉页,缺页由网卡触发 fault 回调。它解决了灵活性与内存占用,但引入了不可预测的缺页延迟,高频小消息场景下得不偿失。
  4. 大页(HugePage)能显著降低 MTT 压力。同样 1GB 内存,4KB 页需要 26 万个表项,2MB 大页只需 512 个,网卡 TLB 命中率天差地别。

六、拥塞控制:RDMA 最危险的部分

RDMA 要求无损网络,而以太网上实现无损靠的是 PFC(Priority Flow Control)。问题在于 PFC 是逐跳暂停机制:当下游交换机缓冲区将满,会向上游发暂停帧。在大规模 fat-tree 拓扑下,这会产生 head-of-line blocking,严重时形成 PFC 死锁——多个交换机互相等待对方释放缓冲区,整个网络静默停摆。

生产级的解法是 DCQCN(Data Center Quantized Congestion Notification)三件套:

  • ECN:交换机在队列超阈值时给报文打 Congestion Encountered 标记;
  • CNP(Congestion Notification Packet):接收端收到 ECN 后回送 CNP 给发送端;
  • 速率调节:发送端按 AI(加性增)/ MD(乘性减)调整 QP 发送速率,整个过程在网卡硬件完成。

部署 checklist:

# 1. 确认网卡工作在 RoCEv2 且开启 DCQCN
cma_roce_mode -d mlx5_0 -p 1            # 应输出 RoCE v2
mlxconfig -d <pci_addr> q | grep -i dcqcn

# 2. 交换机侧 ECN 阈值(以 SONiC/Cumulus 为例)
#    ECN 标记阈值必须严格小于 PFC 触发阈值,否则来不及降速就已丢包
# 3. 统计验证:tx_pause 应接近 0,rp_cnp_ignored 不应持续增长
ethtool -S <iface> | grep -E 'pause|cnp|ecn'

一个实用的判据:如果 PFC 暂停帧持续增长,说明 ECN 没生效或阈值配错——DCQCN 调好了,PFC 应该只是最后的安全网,而不是常态。

七、AI 训练中的真实用法:NCCL 视角

在 NCCL 里,RDMA 不是用来做点对点 send/recv 的,而是承载 Ring AllReduce 的流水线。N 张卡构成一个环,每张卡把本地梯度切成 N-1 块,经过 2(N-1) 步完成规约。每一步的传输量是 total_grad / N,通信量与 N 无关——这是 ring 算法能线性扩展的原因。

关键工程细节:

  • GPUDirect RDMA:网卡绕过主机内存,直接从 GPU 显存 DMA。省掉一次 GPU→CPU→GPU 的拷贝,实测能把 AllReduce 延迟降低 30%–40%。需要 nvidia-peermem 内核模块,且网卡与 GPU 必须在同一 PCIe switch 下才能发挥最佳效果——NUMA/PCIe 拓扑决定性能上限。
  • 多轨(multi-rail)聚合:单机 8 卡通常配 4–8 张网卡,NCCL 会把一条逻辑连接条带化到多张网卡上。这里最容易踩的坑是网卡与 GPU 的亲和性配错,导致数据跨 CPU socket 走 UPI,带宽被砍半。用 nvidia-smi topo -m 核对 NIC 与 GPU 的亲和关系(应是 PIX/PXB 而非 SYS)。
  • 小消息走 Send,大消息走 Write:NCCL 内部对不同尺寸切换协议,避免小消息上单边语义的连接管理开销。

八、生产环境避坑清单

  1. RQ 深度必须足够。对端并发 Send 数超过 max_recv_wr 会触发 RNR NAK,性能断崖式下跌。宁可开大。
  2. 连接建立用带外通道。QP 号、PSN、GID、rkey 通常需要一个 TCP 控制面交换。别指望 RDMA 自举。
  3. 永远校验 CQE status。IBV_WC_SUCCESS 之外的错误意味着 QP 已不可用。
  4. 监控 MTT 与 pinned memory。/proc/<pid>/status 的 VmLck 是被钉住的内存量,接近 RLIMIT_MEMLOCK 就是告警信号。
  5. 超时参数别照抄。timeout=14 适合低延迟无损网络,跨机房或丢包率高的环境要调大,否则重传风暴会把链路打满。
  6. 别在 RC 上做大规模扇出。N 个节点全互联需要 N² 条 QP,MR 与 QP 内存开销指数级增长。超大规模应考虑 UD 或共享连接的中间件(如 UCX)。

九、结语

RDMA 的本质是一次权衡的转移:它把协议栈复杂度、拷贝开销、CPU 占用从内核和驱动那里拿走,交给了应用程序员和运维。你换来的是 200Gbps 线速下 2–3 微秒的端到端延迟和接近零的 CPU 占用,代价是必须自己管理内存生命周期、连接状态机、拥塞响应和故障恢复。

对 AI 训练集群而言,这笔交易是划算的——通信效率直接换算成 MFU,而 MFU 换算成真金白银的训练成本。但对普通业务而言,除非你的瓶颈确实在网络延迟或 CPU 协议栈开销上,否则引入 RDMA 的复杂度很可能超过收益。

判断标准很简单:先做 profiling,确认中断、拷贝、上下文切换确实占据了你无法接受的开销,再考虑 RDMA。 否则你只是把一个简单的问题换成了复杂的问题。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
0.424733s