开源 gpunetio examples 01 解析 gpunetio_verbs_put_bw

-1. 代码逻辑主线

针对 https://github.com/NVIDIA-DOCA/gpunetio/tree/main/examples/gpunetio_verbs_put_bw/

把服务器端和客户端,两端的核心逻辑分别提炼如下。


-1.1. 服务器端逻辑提炼(被动方)

概括:分配可被远端写的内存 → 把"钥匙"交给客户端 → 空转等待 → 校验数据。

执行流程(verbs_server()

复制代码
① create_verbs_resources(cfg, &resources)
     └─ 打开本机 mlx5 网卡 + GPU 设备,创建 PD / CQ / QP(RC 模式)

② create_local_memory_object(&resources)
     └─ 对 16 档消息大小(1B~1MB):
         · calloc 分配 CPU 内存 buffer(大小 = 512线程 × 消息大小),清零
         · ibv_reg_mr 注册 MR,权限含 REMOTE_WRITE
         → 得到 16 组 (buffer地址, rkey)

③ oob_verbs_connection_server_setup()
     └─ socket + bind(INADDR_ANY:2000) + listen + accept
         → 阻塞等待客户端 TCP 连入,获得 conn_socket

④ exchange_params_with_remote_peer(&resources)
     └─ 通过 TCP:
         · 主动发送 16 组 (addr, rkey) 给客户端
         · 双向交换 QPN / GID / LID

⑤ connect_verbs_qp(&resources)
     └─ 用对端信息把 QP 状态机推到 RTS,RDMA 链路就绪

⑥ signal(SIGINT/SIGTERM, handler) + while(!server_force_quit);
     └─ ★ 测试全程空转------单边 RDMA WRITE 无需服务器参与
         退出完全依赖用户 Ctrl-C

⑦ server_validate_test(&resources)
     └─ 逐字节校验:第 idx 档 buffer 每字节应 == idx+1

⑧ 清理:dereg MR → free buffer → 销毁 QP/CQ/PD → 关 socket

关键特征

  • 数据路径零参与:不 post receive、不 poll CQ、不发任何 RDMA 操作;
  • 唯一"产出"是给客户端的 (addr, rkey);
  • 唯一"验证"是结束时的内存内容比对;
  • 退出方式是人工 Ctrl-C(无完成通知协议)。

-1.2. 客户端逻辑提炼(主动方)

概括:GPU 显存备好数据源 → 拿到服务器"钥匙" → 把 QP 交给 GPU kernel → CUDA 线程直接发 RDMA WRITE 并计时打表。

执行流程(verbs_client()

复制代码
① create_verbs_resources(cfg, &resources)
     └─ 同服务器:建 PD/CQ/QP,另记录 nic_handler、exec_scope

② create_local_memory_object(&resources)
     └─ 对 16 档消息大小:
         · doca_gpu_mem_alloc 分配 GPU 显存 buffer
         · cudaMemset 填特征值 idx+1(供服务器校验)
         · GPUDirect 注册:优先 ibv_reg_dmabuf_mr,失败回退 ibv_reg_mr(nvidia-peermem)

③ oob_verbs_connection_client_setup(server_ip, &conn_socket)
     └─ TCP connect 到服务器(-c 参数):2000

④ exchange_params_with_remote_peer(&resources)
     └─ 接收服务器 16 组 (remote_addr, remote_rkey) + 交换 QPN/GID/LID

⑤ connect_verbs_qp(&resources)   → QP 推到 RTS

⑥ 准备 GPU 执行环境
     ├─ cudaStreamCreateWithFlags(cudaStreamNonBlocking)
     ├─ cuEventCreate(e_start, e_end)        ← kernel 计时
     ├─ doca_gpu_verbs_get_qp_dev() → qp_gpu  ← ★ 把 QP 导出为 GPU 设备侧句柄
     └─ 若 CPU_PROXY 模式:pthread 起一个代敲 Doorbell 的伴生线程

⑦ 主测试循环:for idx in 16 档消息大小
     ├─ gpunetio_verbs_put_bw(...)          ← warmup 一次
     ├─ cudaStreamSynchronize
     ├─ cuEventRecord(e_start)
     ├─ gpunetio_verbs_put_bw(...)          ← 正式测试
     ├─ cuEventRecord(e_end) → Synchronize → ElapsedTime → et_ms
     └─ 计算并打印:BW[Gbps] = size×iters×8/et_ms/1e9;MsgRate[Mpps]

⑧ 清理:停 proxy 线程 → 销毁 event/stream → dereg MR → 释放显存 → 关连接

GPU kernel 逻辑(put_bw,真正发数据的地方)

复制代码
每个 CUDA 线程(全局编号 tidx):
  for idx = tidx; idx < num_iters; idx += 总线程数   ← grid-stride 均摊迭代
      ① doca_gpu_dev_verbs_put(qp_gpu,
             远端 = {remote_buf + size*tidx, rkey},     ← 写到服务器哪
             本地 = {gpu_buf  + size*tidx, lkey},       ← 从显存哪读
             size) → out_ticket
         即:GPU 线程直接组 WQE(RDMA_WRITE) + 敲 Doorbell
      ② poll CQ 确认完成:
         THREAD 模式 → 每线程 poll 自己的 ticket
         WARP  模式 → lane0 只 poll 全 warp 最后一张 ticket(批量确认)
      ③ __syncthreads()  ← 流控,防止 SQ 被打爆

关键特征

  • CPU 只在控制路径(建链、计时、打表),数据路径全在 GPU;
  • 每线程私有数据区(+ size*tidx 偏移)→ 天然无锁并发;
  • lkey/rkey 均 htobe32 转大端(WQE 网络字节序要求);
  • 每档先 warmup 再正式测,CUDA event 精确计时。

-1.3. 两端对照一览

阶段 服务器端 客户端
内存 CPU calloc,清零 GPU 显存,填 idx+1
MR 权限用途 被远端写(REMOTE_WRITE) 被网卡读(GPUDirect DMA)
TCP 角色 listen/accept,发 addr+rkey connect,收 addr+rkey
QP→RTS connect_verbs_qp connect_verbs_qp
测试中 空转(while 等 Ctrl-C) GPU kernel 发 WRITE + poll CQE
完成机制 无(单边操作不感知) 本地 CQE ticket 轮询
输出 Ctrl-C 后校验结果 16 档 BW/Mpps 表格
退出 信号驱动 跑完自动退出

0. 运行example

0.1. 启动服务器端

在终端1中,运行服务器端:

ruler@ruler-Super-Server:~/ex_latency_qlink/tmp01/local/lib$ ls

libdoca_gpunetio_host.so libdoca_gpunetio_host.so.4 libdoca_gpunetio_host.so.4.0.1

ruler@ruler-Super-Server:~/ex_latency_qlink/tmp01/local/lib export LD_LIBRARY_PATH=PWD

bash 复制代码
#转入 gpunetio lib 的安装目录:

cd ruler@ruler-Super-Server:~/ex_latency_qlink/tmp01/local/lib
export LD_LIBRARY_PATH=$PWD
# 运行服务器端
DOCA_GPUNETIO_LOG=6 ./gpunetio_verbs_put_bw -g 41:00.0 -d mlx5_0

等待;

0.2. 运行客户端

在终端2中,运行客户端,通过 -c 参数,既说明本进程是客户端,同时 -c 后边跟着 服务器端 IP 地址,即服务器上参与 RDMA 通信的 RoCE 网卡的 IP 地址:

bash 复制代码
cd ruler@ruler-Super-Server:~/ex_latency_qlink/tmp01/local/lib
export LD_LIBRARY_PATH=$PWD
cd ../examples/
DOCA_GPUNETIO_LOG=6 ./gpunetio_verbs_put_bw -g 41:00.0 -d mlx5_0 -c 192.168.0.101

0.3. 参数解析

-g-d 永远指运行该命令的本机自己的设备------在哪台机器上启动,就填哪台机器的 GPU 和网卡。

B 机器(服务器端)启动时:

bash 复制代码
# 在 B 上执行
sudo ./gpunetio_verbs_put_bw \
    -d mlx5_0 \        # B 机器自己的 RDMA 网卡(在 B 上跑 ibv_devices 查到的名字)
    -g 17:00.0 \       # B 机器自己的 GPU PCIe 地址(在 B 上跑 nvidia-smi topo -m 查到)
    -l 3

A 机器(客户端)启动时:

bash 复制代码
# 在 A 上执行
sudo ./gpunetio_verbs_put_bw \
    -c 192.168.100.2 \ # 这是 B 的 IP------整个命令行里唯一一个"对端"信息
    -d mlx5_0 \        # A 机器自己的网卡
    -g af:00.0 \       # A 机器自己的 GPU PCIe 地址
    -l 3

几个要点

  1. 整个命令行中,唯一涉及对端的参数就是客户端的 -c <服务器IP>-d-g-l 全是本机资源,两端可以完全不同:

    • A 的网卡可以叫 mlx5_0,B 的可以叫 mlx5_2
    • A 的 GPU 可以在 af:00.0,B 的可以在 17:00.0
    • 两端的 GID index 也可以不同(只要各自选对 RoCEv2 的那个)。
  2. 为什么服务器也要 -g 虽然这个例子里服务器的数据 buffer 用的是 CPU 内存(calloc),但两端的 QP 都是通过 GPUNetIO verbs 的公共代码(create_verbs_resources)创建的,该函数会用 -g 指定的 PCIe 地址打开本机 GPU、初始化 doca_gpu 上下文。所以服务器端同样要填B 自己的 GPU,不能省略或乱填。

  3. 查询方法(各自在本机执行):

    bash 复制代码
    ibv_devices            # 查 -d 用的网卡名
    nvidia-smi topo -m     # 查 -g 用的 GPU PCIe 地址(左上角 GPU 行对应的 bus id)
    ls /sys/bus/pci/devices/ | grep -i nvidia  # 备选

总结:-d = 本机网卡,-g = 本机 GPU,-c(仅客户端)= 对端 IP。

1. gpunetio_verbs_put_bw

源码

这个示例共 3 个源文件:gpunetio_verbs_put_bw_main.cpp(入口/参数解析,通过-c 参数来确定为 客户端)、gpunetio_verbs_put_bw_sample.cpp(客户端与服务器端主逻辑)、gpunetio_verbs_put_bw_kernel.cu(GPU 侧 CUDA kernel)。下面先讲整体架构,再分别解析两端行为并逐行注释。


1.1. gpunetio_verbs_put_bw 的工作

gpunetio_verbs_put_bw 是一个 GPU 发起的单边 RDMA WRITE(PUT)带宽压测 程序,类似 perftest 里的 ib_write_bw,区别在于:

  • 客户端 :RDMA Queue Pair(QP)和 Completion Queue(CQ)被"导出"到 GPU 显存,由 CUDA kernel 里的 GPU 线程直接构造 WQE、敲 Doorbell、轮询 CQE(GPUNetIO / GDRCopy 类技术),CPU 只负责建链和计时,完全不在数据路径上。
  • 服务器端 :纯粹是被动接收方。它分配一块 CPU 内存注册成 MR,把地址和 rkey 通过 TCP 带外(OOB)通道发给客户端,然后空转等待------RDMA WRITE 是单边操作,服务器的 CPU/NIC 软件栈零参与,最后只做数据校验。

一次运行覆盖 16 种消息大小(1B → 1MB),每个大小先 warmup 再正式测,输出带宽(Gbps)和消息速率(Mpps)。

复制代码
                TCP (OOB, 交换 addr/rkey/QPN/GID/LID)
   Server ═══════════════════════════════════════ Client
   (CPU buf)  ◄══════ RDMA WRITE (单边PUT) ══════  GPU kernel 直接发包
     被动收        NIC ◄──── PCIe ──── GPU 显存

1.2. 入口:main.cpp 逐行注释

cpp 复制代码
int main(int argc, char **argv) {
    struct verbs_config verbs_cfg;          // 配置结构体(is_server、设备名、迭代数等)
    doca_error_t status;
    int exit_status = EXIT_FAILURE;
    int option;

    /* 默认配置 */
    verbs_cfg.is_server = true;             // 默认以服务器模式运行
    verbs_cfg.gid_index = DEFAULT_GID_INDEX;// RoCE 的 GID 索引(选 IP 版本/优先级)
    verbs_cfg.num_iters = NUM_ITERS;        // 每种消息大小的迭代次数,默认 2048
    verbs_cfg.cuda_threads = CUDA_THREADS_BW; // CUDA 线程总数(带宽测试默认值)
    verbs_cfg.nic_handler = DOCA_GPUNETIO_VERBS_NIC_HANDLER_AUTO; // Doorbell 由谁敲:自动
    verbs_cfg.exec_scope = DOCA_GPUNETIO_VERBS_EXEC_SCOPE_THREAD; // GPU 提交粒度:每线程

    while ((option = getopt(argc, argv, "c:d:e:g:i:l:p:")) != -1) {
        switch (option) {
            case 'c': {                     // -c <server_ip>:带此参数即为客户端
                verbs_cfg.server_ip_addr = optarg;
                verbs_cfg.is_server = false;
                break;
            }
            case 'd': {                     // -d mlx5_X:指定 RDMA 网卡
                verbs_cfg.nic_device_name = optarg;
                break;
            }
            case 'e': {                     // -e 执行范围:0=THREAD(每线程独立收发)
                int exec_scope = std::atoi(optarg);   // 1=WARP(整个 warp 协作)
                if (exec_scope != DOCA_GPUNETIO_VERBS_EXEC_SCOPE_THREAD &&
                    exec_scope != DOCA_GPUNETIO_VERBS_EXEC_SCOPE_WARP) {
                    DOCA_LOG(LOG_ERR, "Exec policy must be included between 0 and 2");
                    return 1;
                }
                verbs_cfg.exec_scope = (enum doca_gpu_dev_verbs_exec_scope)exec_scope;
                break;
            }
            case 'g': {                     // -g <GPU PCIe 地址>,如 03:00.0
                verbs_cfg.gpu_pcie_addr = optarg;
                break;
            }
            case 'i': {                     // -i 迭代次数,必须是 CUDA 线程数的整数倍
                verbs_cfg.num_iters = std::atoi(optarg);
                if ((verbs_cfg.num_iters % verbs_cfg.cuda_threads) != 0) { ... return 1; }
                break;
            }
            case 'l': {                     // -l GID 索引
                verbs_cfg.gid_index = std::atoi(optarg);
                break;
            }
            case 'p': {                     // -p NIC handler:0 AUTO / 1 CPU_PROXY /
                verbs_cfg.nic_handler = ...std::atoi(optarg);  // 2 GPU_SM_DB / 6 GPU_BF
                if (verbs_cfg.nic_handler == DOCA_GPUNETIO_VERBS_NIC_HANDLER_GPU_SM_BF) {
                    // 本示例不支持 BlueFlame 模式(GPU 直接写 BF 寄存器)
                    DOCA_LOG(LOG_ERR, "NIC handler BlueFlame not supported in this example");
                    return 1;
                }
                break;
            }
            default: ... /* 打印 Usage */
        }
    }

    if (verbs_cfg.is_server) {
        status = verbs_server(&verbs_cfg);  // 服务器路径(下文第四节)
        ...
    } else {
        status = verbs_client(&verbs_cfg);  // 客户端路径(下文第五节)
        ...
    }
    ...
}

关键分叉就一个:不带 -c 就是 server,带 -c 就是 client,两端跑同一个二进制。


1.3. 两端共用的铺垫逻辑(sample.cpp 前半部分)

1.3.1 全局变量与报表格式

cpp 复制代码
cudaStream_t cstream = NULL;                 // 客户端用的 CUDA stream
int message_size[NUM_MSG_SIZE] = {1, 64, 128, 256, 512, 1024, 2048, 4096,
                                  8192, 16384, 32768, 65536, 131072, 262144,
                                  524288, 1048576};  // 16 档消息大小
volatile bool server_force_quit = false;     // 服务器退出标志(信号处理器置位)

1.3.2 本地内存对象创建 create_local_memory_object() ------ 两端行为在此分叉

这是理解两端差异的核心函数:

cpp 复制代码
static doca_error_t create_local_memory_object(struct verbs_resources *resources) {
    size_t host_page_size = get_page_size();
    ...
    for (int idx = 0; idx < NUM_MSG_SIZE; idx++) {     // 每种消息大小各建一个 buf+MR
        resources->data_mr[idx] = NULL;
        size = (size_t)(resources->cuda_threads * message_size[idx]);
        // 每个 CUDA 线程独占 message_size[idx] 字节,互不重叠 → 无需同步即可并发写
        ALIGN_SIZE(size, host_page_size);              // 按页对齐(注册 MR 需要)

        if (resources->cfg->is_server) {
            // ===== 服务器端:数据落在 CPU 内存 =====
            resources->data_buf[idx] = (uint8_t *)calloc(size, 1); // 分配并清零
            memset(resources->data_buf[idx], 0, size);
        } else {
            // ===== 客户端:数据落在 GPU 显存 =====
            status = doca_gpu_mem_alloc(resources->gpu_dev, size, host_page_size,
                                        DOCA_GPU_MEM_TYPE_GPU,
                                        (void **)&(resources->data_buf[idx]), NULL);
            cudaMemset(resources->data_buf[idx], idx + 1, size);
            // 源缓冲区按档位填 idx+1(第 0 档全填 1,第 1 档全填 2......)
            // → 服务器端校验时就知道每个 buffer 应该是什么值

            /* GPU 显存注册给网卡:优先走 dmabuf(新内核机制) */
            status = doca_gpu_get_dmabuf_fd(resources->gpu_dev, resources->data_buf[idx],
                                            size, &dmabuf_fd);
            if (status == DOCA_SUCCESS) {
                resources->data_mr[idx] = ibv_reg_dmabuf_mr(
                    resources->verbs_pd, 0, size, (uint64_t)resources->data_buf[idx],
                    dmabuf_fd,
                    IBV_ACCESS_LOCAL_WRITE | IBV_ACCESS_REMOTE_WRITE
                    | IBV_ACCESS_RELAXED_ORDERING);   // 允许网卡本地写/远端写/乱序
            }
        }

        if (resources->data_mr[idx] == NULL) {
            // 兜底:dmabuf 失败则走传统 ibv_reg_mr(GPU 显存时依赖 nvidia-peermem;
            // 服务器端 CPU 内存天然走这里)
            resources->data_mr[idx] = ibv_reg_mr(
                resources->verbs_pd, resources->data_buf[idx], size,
                IBV_ACCESS_LOCAL_WRITE | IBV_ACCESS_REMOTE_WRITE
                | IBV_ACCESS_RELAXED_ORDERING);
        }
        if (resources->data_mr[idx] == NULL) { ... goto exit_error; }
    }
    return DOCA_SUCCESS;
}

注意这里体现的架构差异

  • 服务器 的 buffer 是 calloc 出来的普通 CPU 内存------它是 RDMA WRITE 的目标 (被远端写),所以给了 IBV_ACCESS_REMOTE_WRITE
  • 客户端 的 buffer 在 GPU 显存------它是 WRITE 的,且需要 GPUDirect RDMA(dmabuf 或 nvidia-peermem)让网卡能直接 DMA 读显存。

1.3.3 带外参数交换 exchange_params_with_remote_peer()

RDMA 建链前必须通过 TCP socket 交换对端信息:

cpp 复制代码
static doca_error_t exchange_params_with_remote_peer(struct verbs_resources *resources) {
    if (resources->cfg->is_server) {
        // 服务器主动发送:16 个 buffer 各自的 (地址, rkey)
        // ------客户端 RDMA WRITE 需要知道"往哪写、用什么钥匙"
        for (int idx = 0; idx < NUM_MSG_SIZE; idx++) {
            uint64_t local_addr = (uint64_t)resources->data_buf[idx];
            send(resources->conn_socket, &local_addr, sizeof(uint64_t), 0);
            send(resources->conn_socket, &resources->data_mr[idx]->rkey, sizeof(uint32_t), 0);
        }
    } else {
        // 客户端接收:存到 remote_data_buf[] / remote_data_mkey[],
        // 之后作为 kernel 里 put 操作的远端地址参数
        for (int idx = 0; idx < NUM_MSG_SIZE; idx++) {
            recv(resources->conn_socket, &resources->remote_data_buf[idx], ...);
            recv(resources->conn_socket, &resources->remote_data_mkey[idx], ...);
        }
    }

    // 双向交换 QP 号、GID(RoCE 路由用)、LID(IB 路由用),用于把 QP 状态机推到 RTS
    send(..., &resources->local_qp_number, ...);  recv(..., &resources->remote_qp_number, ...);
    send(..., &resources->gid.raw, ...);          recv(..., &resources->remote_gid.raw, ...);
    send(..., &resources->lid, ...);              recv(..., &resources->dlid, ...);
    return DOCA_SUCCESS;
}

1.4. 服务器端行为解析

1.4.1 服务器做了什么、没做什么

阶段 行为
初始化 创建 PD/CQ/QP 等 verbs 资源(create_verbs_resources);分配 CPU 内存并注册 MR(给远端写权限)
建链 监听 TCP,接受客户端连接;把自己的 buf 地址 + rkey 发给客户端;交换 QPN/GID/LID;connect_verbs_qp 把 QP 推到 RTS
测试期间 什么都不做 ------while (server_force_quit == false); 空转。RDMA WRITE 是单边操作,数据由客户端 GPU 主动推过来,网卡硬件直接 DMA 进服务器内存
结束 用户 Ctrl-C 后逐字节校验数据是否正确,然后清理资源

⚠️ 一个值得注意的设计:服务器没有任何"测试完成"的通知机制,靠用户观察客户端输出结束后按 Ctrl-C 退出,退出前顺手做数据校验。

1.4.2 verbs_server() 逐行注释

cpp 复制代码
doca_error_t verbs_server(struct verbs_config *cfg) {
    doca_error_t status = DOCA_SUCCESS, tmp_status = DOCA_SUCCESS;
    struct verbs_resources resources = {0};  // 资源大杂烩:PD/QP/CQ/MR/socket 等
    int server_sock_fd = -1;

    resources.conn_socket = -1;
    resources.num_iters = cfg->num_iters;        // 校验时要知道每个 buffer 多大
    resources.cuda_threads = cfg->cuda_threads;  // 同上:buf 大小 = 线程数 × 消息大小
    resources.enable_umem_cpu = false;

    /* 1. 建 verbs 基本资源:打开 mlx5 设备、分配 PD、建 CQ、建 QP(RC 模式) */
    status = create_verbs_resources(cfg, &resources);
    if (status != DOCA_SUCCESS) { ...; return status; }

    /* 2. 分配 16 个 CPU buffer 并注册 MR(见 3.2 服务器分支) */
    status = create_local_memory_object(&resources);
    if (status != DOCA_SUCCESS) { ...; goto server_cleanup; }

    /* 3. TCP 带外通道:socket/bind/listen/accept,阻塞等客户端连入 */
    ret = oob_verbs_connection_server_setup(&server_sock_fd, &resources.conn_socket);
    if (ret != 0) { ...; goto server_cleanup; }

    /* 4. 通过 TCP 发送 16 组 (buf_addr, rkey),交换 QPN/GID/LID */
    status = exchange_params_with_remote_peer(&resources);
    if (status != DOCA_SUCCESS) { ...; goto server_cleanup; }

    /* 5. 用对端 QPN/GID/LID 把 QP 状态机 RESET→INIT→RTR→RTS,RDMA 链路就绪 */
    status = connect_verbs_qp(&resources);
    if (status != DOCA_SUCCESS) { ...; goto server_cleanup; }

    /* 6. 注册信号处理器:Ctrl-C / kill 时优雅退出 */
    server_force_quit = false;
    signal(SIGINT, signal_handler);
    signal(SIGTERM, signal_handler);

    /* 7. 测试期间服务器空转------RDMA WRITE 单边完成,无需服务器任何参与。
          唯一的"退出"方式是用户看到客户端打完后按 Ctrl-C */
    while (server_force_quit == false);

    /* 8. 校验收到的数据:第 idx 个 buffer 的每个字节都应等于 idx+1 */
    status = server_validate_test(&resources);
    if (status != DOCA_SUCCESS) DOCA_LOG(LOG_ERR, "Server data validation failed");

server_cleanup:
    /* 9. 清理:注销 MR、释放内存、销毁 QP/CQ/PD、关 socket */
    tmp_status = destroy_local_memory_objects(&resources);
    tmp_status = destroy_verbs_resources(&resources);
    oob_verbs_connection_server_close(server_sock_fd, resources.conn_socket);
    return status;
}

配套的两个小函数:

cpp 复制代码
/* 数据校验:客户端把第 idx 档的源 buffer 全部填成 idx+1,
   所以服务器收到的第 idx 个 buffer 每字节都该等于 idx+1 */
static doca_error_t server_validate_test(struct verbs_resources *resources) {
    for (int idx = 0; idx < NUM_MSG_SIZE; idx++) {
        for (int pos = 0; pos < (int)resources->cuda_threads * message_size[idx]; pos++) {
            if (resources->data_buf[idx][pos] != (idx + 1)) {
                DOCA_LOG(LOG_ERR, "Validation error: buffer %d pos %d ...", ...);
                return DOCA_ERROR_BAD_STATE;
            }
        }
    }
    DOCA_LOG(LOG_WARNING, "Validation successfull! Data received correctly from client");
    return DOCA_SUCCESS;
}

/* 信号处理:置退出标志,主循环下一轮就会跳出 */
static void signal_handler(int signum) {
    if (signum == SIGINT || signum == SIGTERM) {
        DOCA_LOG(LOG_INFO, "Signal %d received, preparing to exit!", signum);
        server_force_quit = true;
    }
}

1.5. 客户端行为解析

1.5.1 客户端是"真正干活"的一端

阶段 行为
初始化 建 verbs 资源 + GPU 显存分配 16 个源 buffer(各填特征值 idx+1),经 GPUDirect RDMA 注册给网卡
建链 TCP 连服务器;接收服务器的 16 组 (addr, rkey) 作为 RDMA WRITE 的目的地;交换 QPN/GID/LID;QP 推 RTS
测试 把 QP 导出成 GPU 设备侧句柄 qp_gpu;对每种消息大小:warmup 一次 → 正式跑一次,用 CUDA event 精确测 kernel 耗时,算出 Gbps / Mpps 打表
收尾 停 CPU proxy 线程(如有)、销毁 event/stream、清理资源

1.5.2 verbs_client() 逐行注释

cpp 复制代码
doca_error_t verbs_client(struct verbs_config *cfg) {
    struct verbs_resources resources = {0};
    CUevent e_start = NULL, e_end = NULL;   // CUDA event,用于给 kernel 计时
    float et_ms = 0.0f;
    const unsigned long num_messages = cfg->num_iters * NUM_QP; // 每种大小的总消息数
    pthread_t thread_id;                    // CPU proxy 模式下的轮询线程
    struct cpu_proxy_args args;
    struct doca_gpu_dev_verbs_qp *qp_gpu;   // QP 的 GPU 设备侧"影子"句柄

    resources.conn_socket = -1;
    resources.num_iters = cfg->num_iters;
    resources.cuda_threads = cfg->cuda_threads;
    resources.nic_handler = cfg->nic_handler;   // 谁敲 Doorbell:AUTO/CPU_PROXY/GPU_SM_DB
    resources.scope = (enum doca_gpu_dev_verbs_exec_scope)cfg->exec_scope; // THREAD/WARP
    resources.enable_umem_cpu = false;

    /* 1. 建 verbs 资源(含关联 GPU 设备 doca_gpu) */
    status = create_verbs_resources(cfg, &resources);
    ...

    /* 2. 分配 16 个 GPU 显存 buffer,填充特征值,dmabuf/reg_mr 注册(见 3.2 客户端分支) */
    status = create_local_memory_object(&resources);
    ...

    /* 3. TCP 主动连接服务器 */
    ret = oob_verbs_connection_client_setup(cfg->server_ip_addr.c_str(),
                                            &resources.conn_socket);
    ...

    /* 4. 收服务器的 (addr, rkey) × 16,交换 QPN/GID/LID */
    status = exchange_params_with_remote_peer(&resources);
    ...

    /* 5. QP 推到 RTS */
    status = connect_verbs_qp(&resources);
    ...

    /* 6. 创建非阻塞 CUDA stream(GPUNetIO 要求) */
    cuda_ret = cudaStreamCreateWithFlags(&cstream, cudaStreamNonBlocking);
    ...

    /* 7. 创建两个 CUDA event 用于 kernel 计时 */
    cu_result = cuEventCreate(&e_start, CU_EVENT_BLOCKING_SYNC);
    cu_result = cuEventCreate(&e_end, CU_EVENT_BLOCKING_SYNC);
    ...

    if (resources.qp == NULL) { ...; goto destroy_events; }

    DOCA_LOG(LOG_INFO, "Launching gpunetio_verbs_put_bw kernel with %d CUDA Blocks, ...", ...);

    /* 8. 若选 CPU_PROXY 模式:GPU 写 WQE 到内存,但 Doorbell 由这个 CPU 线程代敲
          (适用于网卡不支持 GPU 直通 Doorbell 的场景) */
    if (resources.nic_handler == DOCA_GPUNETIO_VERBS_NIC_HANDLER_CPU_PROXY) {
        args.qp_cpu = resources.qp->qp_gverbs;
        args.exit_flag = (uint64_t *)calloc(1, sizeof(uint64_t));
        *(args.exit_flag) = 0;
        int result = pthread_create(&thread_id, NULL, progress_cpu_proxy, (void *)&args);
        ...
    }

    /* 9. 打印表头 */
    printf(RESULT_LINE); printf(RESULT_FMT_G); printf("\n"); printf(RESULT_LINE);

    /* 10. 关键一步:把 CPU 侧 QP 导出为 GPU 设备侧对象。
           之后 kernel 里的 GPU 线程就能直接读写 SQ/CQ 的 doorbell record、WQE 区 */
    status = doca_gpu_verbs_get_qp_dev(resources.qp->qp_gverbs, &qp_gpu);
    ...

    /* 11. 主测试循环:16 档消息大小逐档测试 */
    for (int idx = 0; idx < NUM_MSG_SIZE; idx++) {

        /* 11a. Warmup:同一 kernel 先空跑一遍,预热 ICACHE / TLB / QP 流水线,
               也让服务器 buffer 被预先写入(保证后续校验有数据) */
        status = gpunetio_verbs_put_bw(
            cstream, qp_gpu,
            resources.num_iters,                       // 总迭代次数
            VERBS_CUDA_BLOCK,                          // block 数
            resources.cuda_threads / VERBS_CUDA_BLOCK, // 每 block 线程数
            message_size[idx],                         // 本档消息大小
            resources.data_buf[idx],                   // 本地 GPU 源地址
            htobe32(resources.data_mr[idx]->lkey),     // 本地 lkey(转大端,WQE 要求)
            (uint8_t *)(resources.remote_data_buf[idx]),   // 服务器目的地址
            htobe32(resources.remote_data_mkey[idx]),      // 服务器 rkey(大端)
            resources.scope);
        ...
        cudaStreamSynchronize(cstream);   // 等 warmup 完成

        /* 11b. 正式测试:event 打点 → 起 kernel → 打点 → 等结束 */
        cu_result = cuEventRecord(e_start, cstream);
        status = gpunetio_verbs_put_bw(cstream, qp_gpu, ... /* 参数同 warmup */);
        cu_result = cuEventRecord(e_end, cstream);
        cu_result = cuEventSynchronize(e_end);
        cu_result = cuEventElapsedTime(&et_ms, e_start, e_end);  // kernel 耗时(毫秒)

        /* 11c. 计算并打印结果
           bw(Gbps)   = 消息大小 × 总消息数 ÷ 耗时(ms→s) × 8 bit ÷ 1e9
           msgrate    = 总消息数 ÷ 耗时(ms→s) ÷ 1e6  → Mpps */
        double bw = (double)((double)((message_size[idx] * num_messages) / et_ms * 1000.0f) *
                             ((double)8.0) / BW_FORMAT_FACTOR);
        double msgrate = (double)(num_messages / et_ms * 1000.0f / 1000000.0f);
        printf(REPORT_FMT_EXT, message_size[idx], resources.num_iters, bw, msgrate, (double)et_ms);
        printf("\n");
    }

stop_thread:
    /* 12. 停 CPU proxy 线程:置退出标志 + join */
    if (resources.nic_handler == DOCA_GPUNETIO_VERBS_NIC_HANDLER_CPU_PROXY) {
        *((volatile uint64_t *)args.exit_flag) = 1;
        pthread_join(thread_id, NULL);
    }

destroy_events:
    /* 13. 销毁 CUDA 对象 */
    if (cstream != NULL) cudaStreamDestroy(cstream);
    if (e_start != NULL) cuEventDestroy(e_start);
    if (e_end != NULL) cuEventDestroy(e_end);

client_cleanup:
    /* 14. 关 TCP、注销 MR、释放 GPU 显存、销毁 verbs 资源 */
    oob_verbs_connection_client_close(resources.conn_socket);
    tmp_status = destroy_local_memory_objects(&resources);
    tmp_status = destroy_verbs_resources(&resources);
    return status;
}

1.6. CUDA kernel 逐行注释(只在客户端 GPU 上运行)

这是整个 example 的灵魂------网络 I/O 完全由 GPU 线程完成

cpp 复制代码
// scope 模板参数:THREAD(每线程独立完成一次 put+poll)或 WARP(一个 warp 协作)
template <enum doca_gpu_dev_verbs_exec_scope scope>
__global__ void put_bw(struct doca_gpu_dev_verbs_qp *qp,  // GPU 侧 QP(含 SQ/CQ/dbr 地址)
                       uint32_t num_iters, uint32_t data_size,
                       uint8_t *src_buf, uint32_t src_buf_mkey,   // 本地 GPU 源 + lkey
                       uint8_t *dst_buf, uint32_t dst_buf_mkey) { // 远端目的 + rkey
    doca_gpu_dev_verbs_ticket_t out_ticket;   // put 返回的"票号",之后按票号 poll CQE
    uint32_t lane_idx = doca_gpu_dev_verbs_get_lane_id();  // warp 内 lane 编号
    uint32_t tidx = threadIdx.x + (blockIdx.x * blockDim.x); // 全局线程号

    /* grid-stride 循环:num_iters 次迭代摊到所有线程,每次步进 gridDim×blockDim。
       由于 num_iters 是线程总数整数倍(main 中强制),每个线程分到相同迭代数 */
    for (uint32_t idx = blockIdx.x * blockDim.x + threadIdx.x; idx < num_iters;
         idx += (blockDim.x * gridDim.x)) {

        /* 核心:一次 RDMA WRITE(PUT)。
           GPU 线程直接:在 SQ 里写 WQE(RDMA_WRITE  opcode,含远端 addr/rkey、
           本地 addr/lkey、长度)→ 敲 Doorbell 通知网卡 → 网卡从 GPU 显存 DMA
           读出数据发向对端。CPU 全程不参与。
           - RESOURCE_SHARING_MODE_GPU:QP 专由 GPU 用
           - NIC_HANDLER_AUTO:自动选 Doorbell 提交方式
           - 每个线程写自己专属的 data_size 区域(src/dst 都按 tidx 偏移),无线程间冲突 */
        doca_gpu_dev_verbs_put<DOCA_GPUNETIO_VERBS_RESOURCE_SHARING_MODE_GPU,
                               DOCA_GPUNETIO_VERBS_NIC_HANDLER_AUTO, scope>(
            qp,
            doca_gpu_dev_verbs_addr{.addr = (uint64_t)(dst_buf + (data_size * tidx)),
                                    .key = (uint32_t)dst_buf_mkey},   // 远端地址 + rkey
            doca_gpu_dev_verbs_addr{.addr = (uint64_t)(src_buf + (data_size * tidx)),
                                    .key = (uint32_t)src_buf_mkey},   // 本地地址 + lkey
            data_size, &out_ticket);

        /* 完成轮询,两种 scope 二选一: */
        if (scope == DOCA_GPUNETIO_VERBS_EXEC_SCOPE_THREAD) {
            // THREAD 模式:每个线程 poll 自己那张票的 CQE,确认本次 WRITE 已被网卡取走完成
            if (doca_gpu_dev_verbs_poll_cq_at<
                    DOCA_GPUNETIO_VERBS_RESOURCE_SHARING_MODE_GPU>(qp, out_ticket) != 0) {
                // CQE 出错(非 0 状态),debug 时打印
            }
        }

        if (scope == DOCA_GPUNETIO_VERBS_EXEC_SCOPE_WARP) {
            // WARP 模式:整个 warp 32 线程协作提交,票号连续;
            // 只需 lane 0 poll 最后一张票(out_ticket + 31),
            // 其完成即隐含前 31 个也完成 → 减少 CQ 轮询开销
            if (lane_idx == 0) {
                if (doca_gpu_dev_verbs_poll_cq_at<
                        DOCA_GPUNETIO_VERBS_RESOURCE_SHARING_MODE_GPU>(
                        qp, out_ticket + DOCA_GPUNETIO_VERBS_WARP_SIZE - 1) != 0) {
                    // CQE 出错
                }
            }
        }

        __syncthreads();  // block 内同步,保证所有线程完成本轮后再进下一轮,
                          // 避免 SQ 被个别快线程冲爆(流控)
    }
}

kernel 启动封装(extern "C",供 sample.cpp 调用):

cpp 复制代码
doca_error_t gpunetio_verbs_put_bw(cudaStream_t stream, struct doca_gpu_dev_verbs_qp *qp,
                                   uint32_t num_iters, uint32_t cuda_blocks,
                                   uint32_t cuda_threads, uint32_t data_size,
                                   uint8_t *src_buf, uint32_t src_buf_mkey,
                                   uint8_t *dst_buf, uint32_t dst_buf_mkey,
                                   enum doca_gpu_dev_verbs_exec_scope scope) {
    /* 先检查有无遗留 CUDA 错误,防止把旧错误算到本 kernel 头上 */
    result = cudaGetLastError(); ...

    /* 按 scope 选择模板实例化版本启动 kernel */
    if (scope == DOCA_GPUNETIO_VERBS_EXEC_SCOPE_THREAD)
        put_bw<DOCA_GPUNETIO_VERBS_EXEC_SCOPE_THREAD><<<cuda_blocks, cuda_threads, 0, stream>>>(
            qp, num_iters, data_size, src_buf, src_buf_mkey, dst_buf, dst_buf_mkey);
    else if (scope == DOCA_GPUNETIO_VERBS_EXEC_SCOPE_WARP)
        put_bw<DOCA_GPUNETIO_VERBS_EXEC_SCOPE_WARP><<<cuda_blocks, cuda_threads, 0, stream>>>(
            qp, num_iters, data_size, src_buf, src_buf_mkey, dst_buf, dst_buf_mkey);

    /* 检查启动本身是否出错(参数非法、资源不足等) */
    result = cudaGetLastError(); ...
    return DOCA_SUCCESS;
}

1.7. 两端行为对比总结

维度 服务器端 客户端
启动方式 不带 -c -c <server_ip>
数据 buffer CPU 内存(calloc),清零 GPU 显存(doca_gpu_mem_alloc),按档填 idx+1
内存注册 普通 ibv_reg_mr,开放 REMOTE_WRITE 优先 ibv_reg_dmabuf_mr(GPUDirect),失败回退 nvidia-peermem
OOB 角色 TCP 服务端,发送 addr/rkey TCP 客户端,接收 addr/rkey
测试中角色 空转(单边操作零参与) GPU kernel 直接发 RDMA WRITE 并 poll CQE
计时/报表 CUDA event 测 kernel 耗时,输出 Gbps/Mpps
结束方式 Ctrl-C → 校验数据 16 档跑完自动退出

几个设计要点值得记住:

  1. 每线程私有数据区buf + data_size * tidx 的偏移设计让所有 CUDA 线程的 RDMA 操作互不重叠,天然无锁。
  2. ticket 机制 :GPUNetIO 的 put 异步返回票号,WARP 模式下只查最后一张票即可批量确认,这是warp 协作模式省 CQ 读开销的关键。
  3. 大端转换htobe32(lkey/rkey) 是因为 WQE 字段按网络字节序填写。
  4. 服务器无完成通知:这是单边 benchmark 的典型写法,代价是结束靠人工 Ctrl-C。
  5. CPU_PROXY 模式:当网卡/平台不支持 GPU 直敲 Doorbell 时,GPU 只写 WQE,由伴生 CPU 线程代敲 doorbell------这是 GPUNetIO 对硬件兼容性的兜底设计。

2. lkey 和 rkey 概述

lkeyrkey 都是注册内存区域(MR)时网卡返回的两把"钥匙",区别在于谁来用。结合这个 example 讲:

2.1. 基本概念

调用 ibv_reg_mr() / ibv_reg_dmabuf_mr() 注册一块内存后,返回的 struct ibv_mr 里有两个字段:

字段 全称 使用者 用途
lkey Local Key 本机发起的操作 本机网卡访问这块内存的凭证
rkey Remote Key 对端发起的单边操作 允许远端网卡读写这块内存的凭证

核心规则:

  • 自己用自己的 lkey------任何 WQE 里引用的本地内存,都必须带本地 MR 的 lkey;
  • rkey 要交给对方------别人想 RDMA READ/WRITE 你的内存,必须持有你的 rkey;
  • rkey 同时承载了权限检查 :注册时给的 IBV_ACCESS_REMOTE_WRITE 等 flag,决定了持 rkey 的远端能做什么操作。

2.2. 在本 example 中的具体流向

服务器端:注册 → 把 rkey 送出去

cpp 复制代码
// 注册 CPU buffer,开放远端写权限 → 得到 mr->rkey
resources->data_mr[idx] = ibv_reg_mr(pd, buf, size,
    IBV_ACCESS_LOCAL_WRITE | IBV_ACCESS_REMOTE_WRITE | IBV_ACCESS_RELAXED_ORDERING);

// 通过 TCP 把 (buffer地址, rkey) 发给客户端
send(conn_socket, &local_addr, sizeof(uint64_t), 0);
send(conn_socket, &resources->data_mr[idx]->rkey, sizeof(uint32_t), 0);

服务器从此不再使用这对 key------它不发起任何 RDMA 操作(lkey 用不上),rkey 的使命就是交给客户端。

客户端:收 rkey → 填进 WQE

cpp 复制代码
// 收下来存着
recv(conn_socket, &resources->remote_data_buf[idx], ...);   // 远端地址
recv(conn_socket, &resources->remote_data_mkey[idx], ...);  // 远端 rkey

// 启动 kernel 时,两个 key 一起传进去
gpunetio_verbs_put_bw(...,
    resources.data_buf[idx],  htobe32(resources.data_mr[idx]->lkey),   // 本地:lkey
    remote_data_buf[idx],     htobe32(remote_data_mkey[idx]),          // 远端:rkey
    ...);

kernel 里:一次 put 同时用两把 key

cpp 复制代码
doca_gpu_dev_verbs_put(qp,
    {.addr = dst_buf + ..., .key = dst_buf_mkey},   // ← 服务器的 rkey:授权"写到对方那"
    {.addr = src_buf + ..., .key = src_buf_mkey},   // ← 客户端自己的 lkey:授权"从我这读"
    data_size, &out_ticket);

最终生成的 RDMA WRITE WQE 里:

WQE 字段 填的 key 网卡动作
本地内存段(SGE) 客户端的 lkey 客户端网卡 DMA 读客户端显存
远端地址段 服务器的 rkey 服务器网卡校验 rkey 权限后 DMA 写服务器内存

2.3. 为什么要分两把 key

  • lkey 防本地错误:进程不能随便传个虚拟地址就让网卡 DMA------地址必须落在已注册的 MR 里且 key 匹配,防止越界访问和保护域(PD)间的串扰。
  • rkey 防远端滥用:远端发起单边操作时,对端网卡硬件会检查 rkey 是否有效、对应地址范围是否覆盖本次访问、注册时是否给了 REMOTE_WRITE/READ 权限。没有 rkey 或权限不符,操作直接被网卡拒绝(产生 error CQE)。
  • 解耦:一块内存可以只给远端读(只开放 REMOTE_READ)而不给写,权限模型清晰。

2.4. 本例中的两个细节

① 大端转换 :代码里两边都用了 htobe32(...)

cpp 复制代码
htobe32(resources.data_mr[idx]->lkey)       // 本地 lkey
htobe32(resources.remote_data_mkey[idx])    // 远端 rkey

WQE 的字段按网络字节序(大端)填写,所以两个 key 进 WQE 前都要转换。

② 什么时候不需要 rkey :双边 SEND/RECV 操作只用 lkey(引用自己的收发 buffer),因为数据写到对端哪个 buffer 是由接收方预先 post 的 receive WQE 决定的,发起方无需知道对端 key。而本例是单边 PUT,发起方必须直接指定"远端地址 + rkey",这正是单边操作的标志性特征。

2.5. 总结

lkey 给自己用,rkey 给对方用;一次单边 RDMA WRITE,发起方的 WQE 里同时装着"自己的 lkey"(读哪)和"对端的 rkey"(写哪),两把钥匙分别由两端的网卡硬件校验。

3. lkey 和 rkey 详述

这个问题分两半:"如何使用"(软件层面怎么传递、填哪里)和**"如何验证"**(谁在什么时候检查、错了会怎样、怎么动手验证)。下面都结合本例讲。


3.1. 具体如何使用

3.1.1. 获取:注册 MR 的副产品

cpp 复制代码
struct ibv_mr *mr = ibv_reg_mr(pd, buf, size,
        IBV_ACCESS_LOCAL_WRITE | IBV_ACCESS_REMOTE_WRITE | IBV_ACCESS_RELAXED_ORDERING);

uint32_t my_lkey = mr->lkey;   // 本机用
uint32_t my_rkey = mr->rkey;   // 给对端用

同一个 MR 同时产生两个 key。lkey/rkey 只在注册它的那个 PD(Protection Domain)内有效。

3.1.2. lkey 的使用:填进本地内存段(SGE)

任何 WQE 引用本地内存时,SGE(Scatter-Gather Entry)三要素是 {addr, length, lkey}

复制代码
本例客户端 put 的本地侧:  { gpu_buf + size*tidx,  size,  htobe32(lkey) }
双边 SEND 的本地 buffer: { recv_buf,             size,  lkey }   ← 同样用 lkey

规则:只要 WQE 碰到本机内存,就带本机 MR 的 lkey

3.1.3. rkey 的使用:交换 + 填进远端地址段

复制代码
注册方(服务器):ibv_reg_mr → rkey ──TCP带外──► 对端(客户端)
客户端:把 rkey 连同远端地址填进 WQE 的远端段:
        { remote_buf + size*tidx,  htobe32(rkey) }   ← 仅单边 READ/WRITE/ATOMIC 需要

注意:rkey 不能靠猜或计算 ,必须通过带外通道(TCP、RDMA CM、MPI 等)从对方那里拿到。这也是本例 exchange_params_with_remote_peer() 存在的唯一原因。

3.1.4. 字节序

WQE 按网络字节序填写,所以两个 key 进 WQE 前都要 htobe32()(本例客户端就是这么做的)。


3.2. 如何验证(校验机制)

lkey/rkey 的校验不在软件里,全部由网卡硬件(RNIC)在数据路径上完成,分两侧、四类检查:

3.2.1. 校验时机与主体

复制代码
客户端发 WRITE:
  ┌─ 客户端网卡(校验 lkey)──────────────┐
  │ 处理 WQE 时,对本侧 SGE 检查:          │
  │  ① key 匹配:lkey 是否对应当前 PD 下某个有效 MR
  │  ② 范围:addr+len 是否完全落在 MR 注册区间内
  │  ③ 权限:该 MR 是否允许本地读(DMA 读默认允许,
  │         若 MR 注册时没给 LOCAL_WRITE 又被当写目标则拒)
  │  ④ 状态:MR 未被 dereg、PD 未销毁
  └────────────────────────────────────────┘
                    │ 网络上只传数据 + (远端addr, rkey)
                    ▼
  ┌─ 服务器网卡(校验 rkey)──────────────┐
  │ 数据包到达时检查:                      │
  │  ① key 匹配:rkey 是否对应本机有效 MR
  │  ② 范围:远端 addr+len 是否落在 MR 区间内
  │  ③ 权限:WRITE 操作要求 MR 注册时带
  │         IBV_ACCESS_REMOTE_WRITE(本例给了)
  │  ④ QP 状态、PSN 序列等链路层检查
  └────────────────────────────────────────┘

任何一项不过,本次操作被拒绝,后续行为见下。

3.2.2. 校验失败的后果

失败类型 产生什么
lkey 错/越界/权限不足(本地侧) 发起方收到 error CQE:IBV_WC_LOC_PROT_ERR
rkey 错/越界/权限不足(对端侧) 发起方收到 error CQE:IBV_WC_REM_ACCESS_ERR(对端无任何 CQE------单边操作对端本来就不感知)
任何 error CQE 出现后 QP 进入 error 状态 ,之后 post 的所有 WQE 全部 flush(IBV_WC_WR_FLUSH_ERR),必须重建 QP

本例 kernel 里的 doca_gpu_dev_verbs_poll_cq_at(...) != 0 检查的就是这个:非 0 即 CQE 带错误状态(示例里只在 ENABLE_DEBUG 时打印,生产代码应记录 status 字段)。

3.2.3. 动手验证实验(推荐,5 分钟见效)

在本例代码上改两处做对照实验:

实验 A:验证 rkey 校验------客户端把 rkey 故意改坏:

cpp 复制代码
// verbs_client() 里,启动 kernel 前加一行
resources.remote_data_mkey[0] ^= 0x1;   // 篡改第 0 档的 rkey

预期现象:第 1 档(1B)测试时 poll_cq_at 返回非 0(REM_ACCESS_ERR),QP 崩溃,后续档位全部失败;服务器端数据校验报第 0 个 buffer 数据不对。

实验 B:验证权限检查------服务器注册时去掉写权限:

cpp 复制代码
// create_local_memory_object() 服务器分支的 ibv_reg_mr 权限改为
IBV_ACCESS_LOCAL_WRITE   // 去掉 REMOTE_WRITE

预期:所有档位的 WRITE 都被服务器网卡拒绝,客户端全部 REM_ACCESS_ERR。

实验 C:验证范围检查------把远端地址偏移到 MR 之外:

cpp 复制代码
(uint8_t *)(resources.remote_data_buf[idx]) + size   // 越过一个 buffer 的边界

预期:同样 REM_ACCESS_ERR(地址+长度超出 MR 注册区间)。

观察手段 :kernel 里打开 #define ENABLE_DEBUG 1 可以看到 "Error CQE!",或改用 CPU 侧 ibv_poll_cq 打印 ibv_wc_status_str(cqe.status) 拿到具体错误名。

3.2.4. 辅助认知:mlx5 上的一个常见巧合

在 Mellanox/NVIDIA 网卡上你会发现同一个 MR 的 lkey 和 rkey 数值往往相等 (硬件实现如此),但语义不同,不能互换依赖------跨厂商网卡时不保证相等,写代码时必须严格区分。


3.3. 总结

lkey rkey
怎么得到 本机 ibv_reg_mr 返回 对端注册后带外交换给你
填在哪 WQE 本地 SGE 段 WQE 远端地址段(仅单边操作)
谁校验 本机网卡,处理 WQE 时 对端网卡,数据包到达时
校验内容 key 匹配、地址范围、权限、MR 有效 同左 + 操作类型(READ/WRITE/ATOMIC)权限
失败表现 LOC_PROT_ERR REM_ACCESS_ERR,QP 进 error 态
软件要做什么 配对填对即可,校验不用管 正确交换、配对使用;poll CQE 检查错误

4. 如何生成和检验 lkey/rkey

从内核驱动源码确认了关键信息,可以完整回答了。先说结论:完整校验算法固化在网卡硬件里,厂商不公开全部细节;但 mlx5 的 key 结构和校验语义是公开可查的

4.1. 先明确:校验发生在哪里

lkey/rkey 的校验不在 CPU、不在驱动,而在 RNIC 硬件的数据路径上------每次处理 WQE(本地侧)或收到 RDMA 包(远端侧)时由硬件流水线完成。这就是为什么它是零开销的:不是"算法被调用",而是"每个 DMA 请求都穿过多级硬件检查门"。

4.2. mlx5 的 key 结构(公开,来自内核源码)

32 位的 mkey 由两部分拼接而成:

复制代码
mkey = (mkey_index << 8) | mkey_7_0
  ├─ 高 24 位 [31:8]:mkey index ------ 固件分配,是"内存保护表项"的下标
  └─ 低 8 位 [7:0] :mkey_7_0  ------ 驱动填入的 tag(代次/标签字节)

直接证据在 drivers/net/ethernet/mellanox/mlx5/core/mr.cmlx5_core_create_mkey():固件返回 mkey_index,最终 key 由软件提供的 mkey_7_0mlx5_idx_to_mkey(mkey_index) 按位或拼成。驱动里也有 #define MLX5_24BIT_MASK 印证 index 是 24 位宽。

这个结构直接决定了校验算法的第一层:不是哈希、不是搜索,而是 O(1) 的"索引 + 标签比对"

4.3. 硬件校验的完整判定序列

以 mlx5 为例,每个 DMA 请求经过的检查(CREATE_MKEY 命令把 memory_key_mkey_entry(mkc)写入网卡,其中包含 start_addrlen、PD、访问权限位等字段):

复制代码
输入:DMA 请求 {key, addr, len, 操作类型, 所属QP}

① 索引定位:index = key[31:8]
   → 直接下标访问网卡内的 MPT(Memory Protection Table)表项
   → 表项无效/已销毁 → 拒绝

② 标签比对:请求 key[7:0]  vs  表项中存储的 mkey_7_0
   → 不等 → 拒绝(这是防"陈旧 key"的机制:
     MR 注销后 index 可能被回收复用,但新 MR 的 tag 字节不同,
     旧 rkey 自然失效)

③ PD 匹配:QP 的 PD  ==  MR 表项里的 PD
   → 不等 → 拒绝(跨保护域隔离,进程 A 的 key 不能让进程 B 用)

④ 范围检查:addr ≥ mkc.start_addr  且  addr + len ≤ start_addr + mkc.len
   → 越界 → 拒绝(含 64 位地址回绕的边界处理)

⑤ 权限检查:操作类型 vs 表项权限位
   → 本地 DMA 读/写 → 需对应 local 权限
   → 远端 WRITE     → 需 REMOTE_WRITE 位(本例服务器注册时给的)
   → 远端 READ      → 需 REMOTE_READ 位
   → 不满足 → 拒绝

⑥ 地址转换:通过 MTT(Memory Translation Table)做
   虚地址 → 物理地址 的页级翻译,页表项也带访问位
   → 任何一页不可达/权限不符 → 拒绝

①~⑤ 全部通过才执行 DMA。远端 rkey 校验与本地 lkey 校验走的是同一套表项和逻辑,区别只是触发点不同:lkey 在处理 WQE 的 SGE 时查,rkey 在解析到达报文的 RETH(RDMA Extended Transport Header,里面携带远端地址+rkey+长度)时查。

4.4. 失败后的错误上报

硬件拒绝后会生成带 syndrome 的 error CQE,mlx5 侧的对应关系:

拒绝原因 发起方 CQE 状态
②~⑤ 任一失败(本地 lkey 侧) IBV_WC_LOC_PROT_ERR
②~⑤ 任一失败(远端 rkey 侧) IBV_WC_REM_ACCESS_ERR
MR 已被注销后使用 同上(tag/表项失效)

同时 mlx5 会在 dmesg/health buffer 里记录 "mkey violation" 类事件,之后 QP 进入 error 态,后续 WQE 全部被 flush。

4.5. 为什么这样设计(算法层面的总结)

设计点 目的
index 高位直接索引 校验是 O(1),不打断线速 DMA 流水线
tag 低 8 位 注销-重建场景下让旧 key 立刻失效(ABA 防护)
PD 字段绑定 进程/租户间的保护域隔离
start_addr + len 范围检查 把"权限"精确限制在注册的字节区间
权限位与操作类型矩阵 READ/WRITE/ATOMIC 粒度授权

总结:校验算法 = "高 24 位索引硬件保护表 → 低 8 位标签防伪 → PD/范围/权限三重比对 → MTT 页级翻译",全部在网卡内一次通过式完成;任何一环失败,发起方收到 LOC_PROT_ERR 或 REM_ACCESS_ERR。不同厂商(Intel irdma、Broadcom bnxt_re)的 key 位布局和表项格式各不相同,但语义上都是这同一件事。

如果你想深入某一部分(比如 verbs_common.h 里的 create_verbs_resources/connect_verbs_qp 具体怎么建 QP,或 GPUNetIO doca_gpu_dev_verbs_put 内部如何组 WQE),告诉我,我可以继续往下挖。

gpunetio/tree/main/examples

相关推荐
博览鸿蒙2 小时前
FPGA开发使用的语言
fpga开发·fpga
明德扬3 小时前
机器人多摄像头如何做FPGA多路视频聚合?
学习·fpga开发·fpga
Eloudy18 小时前
GPUNetIO开源实现与FPGA的 RoCEv2 通信延迟实验
gpu·fpga·rdma·roce·doca
unicrom_深圳市由你创科技1 天前
FPGA仿真正常,上板为何出错?
fpga
gwf2161 天前
AI训练RDMA性能Profiling:瓶颈定位与调优方法论
芯片设计·rdma·nccl·拥塞控制·ai集群·rnic·gpudirect
Eloudy2 天前
RDMA 乒乓示例:CX5 <-> CX6 DX(QSFP28 直连)
rdma·roce·doca
Eloudy2 天前
cpu rdma 与 gpunetio 的关系
gpu·rdma·roce·doca
HHFQ2 天前
Linux RDMA命令行工具使用手册
rdma
Eloudy2 天前
fpga-latency 指标分析与触发机制
fpga