Skip to content

📒 高性能网络

Abstract

随着 AI 的发展和数据中心规模的不断扩大,高性能网络的需求日益增长。本篇笔记是对高性能网络方向的综述,包括 RDMA、DPDK、RoCE 等技术。

我们将:

  • 从熟悉的传统网络栈开始,分析其不足之处。
  • 介绍 RDMA 基本概念,并通过 IB Verbs 编程实践。
  • 概览 Linux 内核中的 RDMA 子系统 rdma_core 源码。
  • 了解 RDMA 的两种实现:InfiniBand 和 RoCE。
  • 引入 DPDK 和 SPDK,讨论其与 RDMA 的关系。

传统网络栈的不足

Non-blocking Socket

在课堂上,我们学习过 Socket 编程的基本方法。在实践中,更多应用使用 non-blocking socket:

/* Example server code flow to initiate listen */
struct addrinfo *ai, hints;
int listen_fd;

memset(&hints, 0, sizeof hints);
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE;
getaddrinfo(NULL, "7471", &hints, &ai);

listen_fd = socket(ai->ai_family, SOCK_STREAM, 0);
bind(listen_fd, ai->ai_addr, ai->ai_addrlen);
freeaddrinfo(ai);

fcntl(listen_fd, F_SETFL, O_NONBLOCK);
listen(listen_fd, 128);

/* Example server code flow to accept a connection */
struct pollfd fds;
int server_fd;

fds.fd = listen_fd;
fds.events = POLLIN;

poll(&fds, -1);

server_fd = accept(listen_fd, NULL, 0);
fcntl(server_fd, F_SETFL, O_NONBLOCK);

/* Example of server receiving data from client */
struct pollfd fds;
size_t offset, size, ret;
char buf[4096];

fds.fd = server_fd;
fds.events = POLLIN;

size = sizeof(buf);
for (offset = 0; offset < size; ) {
    poll(&fds, -1);

    ret = recv(client_fd, buf + offset, size - offset, 0);
    offset += ret;
}

在高并发场景下,non-blocking socket 是必不可少的:

  • 并发和响应性: 等待网络操作时可以处理其他任务,单个线程能够处理多个连接。
  • 资源利用率: 通过 selectpoll 等 I/O 多路复用技术,高效处理多个连接。与阻塞式模型为每个连接创建一个线程相比,non-blocking socket 减少了线程开销。

Socket 的不足

在分析不足前,首先肯定 Socket 的两大优点:

  • 通用性: 适用于各种网络设备和协议。
  • 易用性: 编程简单,这是非常重要的一点。提供更高性能的 API 都比 Socket 更难编程。类比 C/C++ 与汇编,对于大多数程序员来说,编写 C/C++ 程序的性能更高。因此选择 Socket 之外的 API 时,需要有明确的需求。

但 Socket 无法满足高性能网络的三点需求:

  • 避免内存拷贝: 我们分别考虑发送和接收端的处理过程

    • 发送端: send() 返回时,应用程序可以重用缓冲区。然而只有对方 ACK,才能确保数据成功送达。网络栈有两种选择:阻塞,等待 ACK 返回;立即返回,但需要将数据拷贝到内核缓冲区,等待 ACK 释放。
    • 接收端: 网络适配器收到数据包后放入内核缓冲区,否则丢弃。要避免拷贝,唯一的方法就是在 send() 前调用 recv(),然而这会阻塞接收端,且在大部分情况下无法满足。

    可以看到,操作系统网络栈不得不维护缓冲区,造成数据在应用程序和网络栈之间的拷贝。

    如果能够支持将数据直接写入特定的内存区域,将显著提升某些应用的性能。例如:数据库可能希望将收到的记录合并到表中。

  • 异步操作: Socket API 以同步方式运行。要使用 Socket 进行异步操作,就会产生额外的拷贝。

  • 直接访问硬件:


待整理

RDMA 基本概念

注册内存区域

RDMA 通信前,需要先注册内存区域 MR(memory region),供 RDMA 设备访问:

  • 内存页面必须被 Pin 住不可换出。
  • 注册时获得 L_Key(local key)R_Key(remote key)。前者用于本地访问,后者用于远程访问。

交换信息

在进行 RDMA 通信前,通信双方需要交换 R_Key 和 QP 等信息。可以先通过以太网建立 TCP 连接,或者使用 rdma_cm 管理 RDMA 连接。

异步通信

RDMA 基于三个队列进行异步通信:

  • Send、Receive 队列用于调度工作(work)。这两个队列也合称为 Queue Pair(QP)
  • Completion 队列用于通知工作完成。

RDMA 通信流程如下:

  • 应用程序将 WR(work request,也称为 work queue element)放入(post)到 Send 或 Receive 队列。
  • WR 中含有 SGE(Scatter/Gather Elements),指向 RDMA 设备可以访问的一块 MR 区域。在 Send 队列中指向发送数据,Receive 队列中指向接收数据。
  • WR 完成后,RDMA 设备创建 WC(work completion,也称为 completion queue element)放入 Completion 队列。应用向适配器轮询(poll)Completion 队列,获取 WC。

对于一个应用,QP 和 CQ 可以是多对一的关系。QP、CQ 和 MR 都定义在一个 Protection Domain(PD) 中。

rdma_queue_pair
RDMA 通信队列
InfiniBand Technology Overview - SNIA

访存模式

RDMA 支持两种访存模式:

  • 单边(one-sided):read、write、atomic 操作。
    • 被动方注册一块内存区域,然后将控制权交给主动方;主动方使用 RDMA Read/Write 操作这块内存区域。
    • 被动方不会使用 CPU 资源,不会知道 read、write 操作的发生。
    • WR 必须包含远端的虚拟内存地址R_key,主动方必须提前知道这些信息。
  • 双边(two-sided):send、receive 操作。
    • 源和目的应用都需要主动参与通信。双方都需要创建 QP 和 CQ。
    • 一方发送 receive,则对端需要发送 send,来消耗(consume)这个 receive。
    • 接收方需要先发送自己接收的数据结构,然后发送端按照这个数据结构发送数据。这意味着接收方的缓冲区和数据结构对发送方不可见。

在单个连接中,可以混用并匹配(mix and match)这两种模式。

内核旁路

RDMA 提供内核旁路(kernel bypass)功能:

  • 原先由 CPU 负责的分片、可靠性、重传等功能,现在由适配器负责。
  • RDMA 硬件和驱动具有特殊的设计,可以安全地将硬件映射到用户空间,让应用程序直接访问硬件资源。
  • 数据通路直接从用户空间到硬件,但控制通路仍然通过内核,包括资源管理、状态监控和清理等。保证系统安全稳定。

Question

事实上,通信双方的 QP 被直接映射到了用户空间,因此相当于直接访问对方的内存。

如果你对操作系统和硬件驱动有一些了解,不妨想一想下面的问题:

  • 如何才能让应用程序直接访问硬件资源,同时实现操作系统提供的应用隔离和保护呢?
  • 如果在两个独立的虚拟内存空间(可能在不同物理机、不同架构上)之间建立联系?

RDMA 编程

具体地说,学习的是 libibverbs 库。

Quote

接下来通过 NVIDIA Docs 提供的例子来学习 RDMA 编程。

IB Verbs 编程

Quote

Note

相关头文件为 infiniband/verbs.h,Debian 软件包为 libibverbs-dev

代码见 RDMA_RC_example.c

准备阶段:

  • resource_create():创建资源,包括 PD、MR、QP、CQ 等。
  • connect_qp():通信双方交换信息,包括 LID、QP_NUM、RKEY 等,将 QP 状态更改为 INIT、RTR、RTS。
    • sock_sync_data():通过 TCP 通信交换信息。
    • modify_qp_to_init()
    • post_receive():预置接收队列,也可以放在通信阶段。
    • modify_qp_to_rtr()
    • modify_qp_to_rts()
    • 同步点
flowchart TD
 subgraph s1["resource_create()"]
  n27@{ shape: "rounded", label: "ibv_query_port()" }
  n26@{ shape: "hex", label: "ibv_port_attr" }
  n25@{ shape: "hex", label: "ibv_mr" }
  n17@{ shape: "hex", label: "char *" }
  n16@{ shape: "rounded", label: "ibv_get_device_name()" }
  n15@{ shape: "rounded", label: "ibv_get_device_list()" }
  n14@{ shape: "hex", label: "ibv_device" }
 n1@{ shape: "hex", label: "ibv_pd" }
 n2@{ shape: "rounded", label: "ibv_alloc_pd()" }
 n3@{ shape: "hex", label: "ibv_context" }
 n4@{ shape: "hex", label: "buf" }
 n3 --- n2
 n2 --- n1
 n5@{ shape: "rounded", label: "ibv_open_device()" }
 n5 --- n3
 n6@{ shape: "rounded", label: "ibv_create_cq()" }
 n3 --- n6
 n7@{ shape: "hex", label: "mr_flags" }
 n8@{ shape: "rounded", label: "ibv_reg_mr" }
 n4 --- n8
 n7@{ shape: "fr-rect", label: "mr_flags<br/>=IBV_ACCESS_REMOTE_READ|..." } --- n8
 n1 --- n8@{ shape: "rounded", label: "ibv_reg_mr()" }
 n9@{ shape: "hex", label: "ibv_qp_init_attr" }
 n10@{ shape: "hex", label: "ibv_cq" }
 n6 --- n10
 n9@{ shape: "hex", label: "ibv_qp_init_attr" }
 n10 ---|"send_cq, recv_cq"| n9
 n11@{ shape: "fr-rect", label: "qp_type<br/>=IBV_QPT_RC" }
 n11 --- n9
 n12@{ shape: "rounded", label: "ibv_create_qp()" }
 n9 --- n12
 n13@{ shape: "hex", label: "ibv_qp" }
 n12 --- n13
 end
 n15 --- n14@{ shape: "hex", label: "ibv_device **" }
 n14 --- n16
 n16 --- n17
 n14 --- n5
 subgraph s2["connect_qp()"]
  n36@{ shape: "rounded", label: "sock_sync_data()" }
  subgraph s3["cm_con_data_t"]
   n24@{ shape: "hex", label: "buf" }
   n23@{ shape: "hex", label: "lid" }
   n22@{ shape: "hex", label: "qp_num" }
   n20@{ shape: "hex", label: "rkey" }
   n21@{ shape: "hex", label: "gid" }
  end
  n18@{ shape: "hex", label: "ibv_gid" }
  n19@{ shape: "rounded", label: "ibv_query_gid()" }
 end
 n3 --- n19
 n19 --- n18
 n18 --- n21
 n8 --- n25
 n25 --- n20
 n4 --- n24
 n13 --- n22
 n27 --- n26
 n3 --- n27
 n26 --- n23
 n29 --- n30
 n13 --- n30
 n28 --- n29
 subgraph s5["post_receive()"]
  n33@{ shape: "rounded", label: "ibv_post_recv" }
  n32@{ shape: "hex", label: "ibv_recv_wr" }
  n31@{ shape: "hex", label: "ibv_sge" }
 end
 n25 ---|"lkey"| n31
 n4 --- n31
 subgraph s4["modify_qp_to_init, rts()"]
  n28@{ shape: "fr-rect", label: "qp_state<br/>=IBV_QPS_INIT" }
  n30@{ shape: "rounded", label: "ibv_modify_qp()" }
  n29@{ shape: "hex", label: "ibv_qp_attr" }
 end
 n31 --- n32
 n32 --- n33
 subgraph s6["modify_qp_to_rtr()"]
  n34@{ shape: "rounded", label: "Rounded Rectangle" }
  n35@{ shape: "hex", label: "ibv_qp_attr" }
 end
 n22 --- n35
 n23 --- n35
 n35 --- n34@{ shape: "rounded", label: "ibv_modify_qp()" }
 s3 --- n36

通信阶段:

  • post_send():创建并发送 WR,WR 的类型取决于 opcode
  • poll_completion():轮询得到 WC。
flowchart TD
 subgraph s1["post_send()"]
  n12@{ shape: "rounded", label: "ibv_post_send()" }
  n7@{ shape: "fr-rect", label: ".opcode<br/>IBV_WR_SEND<br/>IBV_WR_RDMA_READ<br/>IBV_WR_RDMA_WRITE" }
  n1@{ shape: "hex", label: "ibv_sge" }
  n2@{ shape: "hex", label: "ibv_send_wr" }
 end
 n3@{ shape: "hex", label: "ibv_mr" }
 n3 ---|".lkey"| n1
 n4@{ shape: "hex", label: "buf" }
 n4 --- n1
 n1 --- n2
 subgraph s2["IBV_WR_SEND only"]
  n5@{ shape: "hex", label: ".rkey" }
  n6@{ shape: "hex", label: ".remote_addr" }
 end
 s2 ---|".wr.rdma"| n2
 n7 --- n2
 subgraph s3["poll_completion()"]
  n11@{ shape: "rounded", label: "assert()" }
  n10@{ shape: "rounded", label: "ibv_poll_cq()" }
  n9@{ shape: "hex", label: "ibv_wc" }
 end
 n8@{ shape: "hex", label: "ibv_cq" }
 n9 --- n10
 n8 --- n10
 n10 ---|".status == IBV_WC_SUCCESS"| n11
 n2 --- n12

该程序演示了下面的操作:

  • resource_create():服务端把 SEND operation 字符串放在缓冲区 res->buf 中。
  • connect_qp():交换资源信息,远端信息放入 res->remote_props。交换内容包括 res->buf 的地址。Client 向 Server 发送一个 Receive。
  • post_send():Server 发送一个 Send。该 WR 的构成:
    • .sg_list->addrres->buf,即 Server 的缓冲区地址。
    • .wr.rdma.remote_addrres->remote_props.addr,即 Client 的缓冲区地址。
  • poll_completion():Client 收到并显示信息 SEND operation
  • Server 再将缓冲区内容修改为 RDMA read operation
  • post_send():Client 发送一个 read 操作,读取到 RDMA read operation。因为这是单边操作,Server 不会知道。
  • Client 将缓冲区内容修改为 RDMA write operation
  • post_send():Client 发送一个 write 操作,写入到 Server 的缓冲区。
  • Server 打印缓冲区内容,为 RDMA write operation

深入 IB Verbs 技术规范

参考链接中的编程手册将 IB Verbs 的所有细节都讲解得很清楚。

RDMA_CM

Note

相关头文件为 rdma/rdma_cma.h,Debian 软件包为 librdmacm-dev

代码见 mckey.c

RDMA_CM 用于管理 RDMA 连接,包装了使用 socket 编程交换 QP、R_Key 等信息的过程,减少代码量。它的接口与 Socket 类似:

rdma_listen()
rdma_connect()
rdma_accept()

与 Socket 编程的比较:

  • 操作异步进行,通过 rdma_event_channel 进行事件通知。
  • rdma_cm_id(identifier)与 fd 类似,用于标识连接。
  • 使用 rdma_bind_addr()rdma_cm_idsockaddr 绑定,类似 bind()

本例为多播通信,需要使用 rdma_join_multicast()rdma_leave_multicast() 进出多播组。

flowchart TD
 subgraph s1["run()"]
  subgraph s2["connect_evnets() [LOOP]"]
   subgraph s5["join_handler()"]
    n14@{ shape: "rounded", label: "ibv_create_ah()" }
    n15@{ shape: "rounded", label: "inet_ntop()" }
   end
   subgraph s4["addr_handler()"]
    n12@{ shape: "rounded", label: "rdma_join_multicast()" }
    n13@{ shape: "rounded", label: "ibv_post_recv()" }
   end
   subgraph s3["cma_handler()"]
   end
   n9@{ shape: "rounded", label: "rdma_ack_cm_evnet()" }
   n11@{ shape: "rounded", label: "rdma_get_cm_event()" }
   n10@{ shape: "hex", label: "rdma_cm_event" }
  end
  n8@{ shape: "rounded", label: "rdma_bind_addr()" }
  n7@{ shape: "hex", label: "char *" }
  n6@{ shape: "rounded", label: "getaddrinfo()" }
  n5@{ shape: "hex", label: "sockaddr" }
  n2@{ shape: "rounded", label: "rdma_create_id()" }
  n1@{ shape: "hex", label: "rdma_cm_id" }
 end
 n3@{ shape: "hex", label: "rdma_event_channel" }
 n4@{ shape: "rounded", label: "rdma_create_event_channel" }
 n4@{ shape: "rounded", label: "rdma_create_event_channel()" } --- n3
 n3 --- n2
 n2 --- n1
 n6 --- n5
 n7 --- n6
 n5 ---|"src_addr"| n8
 n1 --- n8
 n11 --- n10
 n10 --- n9
 s3 --- s4
 s3 --- s5
 n10 --- s3
 n3 --- n11
 n1 --- s3
 n16@{ shape: "rounded", label: "rdma_leave_multicast()" }
 n1 --- n16
 n5 ---|"dst_addr"| n12
 n12 --- n16

RDMA Verbs

Note

相关头文件为 rdma/rdma_verbs.h,Debian 软件包为 librdmacm-dev,相关 API 一般以 rdma_ 开头。

srq.c 是一个使用 SRQ(Shared Receive Queue)的例子。

mlx5dv 与 DevX

Note

相关头文件为 infiniband/mlx5dv.h,相关的 API 一般以 mlx5dv_ 开头。

mlx5dv(Direct Verbs)API 支持从用户空间直接访问 mlx5 驱动设备的资源,绕过 libibverbs 的数据通路,直接暴露低级别的数据通路。

兼容性

从 Connect-IB 开始的所有 Mellanox 网卡设备(包括 Connect-IB、ConnectX-4、ConnectX-4Lx、ConnectX-5 等)都实现了 mlx5 API。

DevX API 疑似是 Mellanox 开发的 mlx5dv 的前身,其仓库上次更新时间为 2018 年,当年 mlx5dv 也并入了 rdma-core。现在 DevX 成为了 mlx5dv 的一部分。

这部分的文档少得可怜。目前仅有的文档来自 rdma-core/providers/mlx5/man 的 mlx5 Programmer's Manual。我们先对 DevX API 的各个部分建立大概的印象:

  • DevX(见 mlx5dv_devx_obj_create):目标是使用户空间驱动程序尽可能独立于内核。当设备提供新的功能和命令时,不需要内核更改就能使用。

    • DEVX 对象代表底层的固件对象。
    • 创建 DEVX 对象后,可以通过 mlx5dv_devx API 进行查询、修改或销毁。

    这一部分的数据结构都是固件相关的二进制数据,编程时采用大量宏定义和位操作。需要仔细查看具体的设备文档。

  • Direct Rule(见 mlx5dv_dr_flow):让应用程序直接控制设备的所有包转发(packet steering)功能,定义复杂的流表规则,包括计数、封装、解封装等。

DMA 示例

使用 mlx5dv API 执行 DMA 拷贝:

  • Host:
    • 用 IB Verb 创建设备上下文、PD
    • 使用 mlx5dv_devx_umem_reg() 注册用户 DMA 内存,获得一个 struct mlx5dv_devx_umem
    • 使用 MLX5_CMD_OP_CREATE_MKEY 调用 mlx5dv_devx_obj_create() 获得 MKEY。
    • 使用 mlx5dv_devx_general_cmd() 执行 MLX5_CMD_OP_ALLOW_OTHER_VHCA_ACCESS,允许其他 VHCA 访问内存。
  • Device:
    • 用 IB Verb 创建设备上下文、PD 和 MR。
    • 从 Host 获取相关数据
    • 使用 MLX5_CMD_OP_CREATE_GENERAL_OBJECTMLX5_GENERAL_OBJ_TYPE_MKEY 调用mlx5dv_devx_obj_create() 创建 MR 别名。

Linux RDMA 运维

ib_read_bw
show_gids

文献阅读

EuroSys’20 StRoM: Smart Remote Memory

基于 FPGA 的 SmartNIC。扩展 IB Verbs,将 RPC 操作 Offload 到远端 SmartNIC 上执行。好处是能够将多次 RTT 的操作合并为一次 RTT,到靠近数据的地方执行。

系统由三个部分构成:

  • RoCE 网络栈
  • StRoM 可编程 Kernel
  • DMA 引擎

论文中的典型应用场景:

  • KV 存储:在 NIC 上通过 DMA 读哈希表和获取数据。

一些知识点:

  • RDMA 操作使用虚拟内存地址,但是 PCIe 访问主存使用物理内存地址。因此 SmartNIC 上需要一个 TLB。

Resources on BlueField 2 Smart NICs: https://gist.github.com/aagontuk/cf01763c8ee26383afe10f51c9cd2984

  • 交换芯片 BlueField:用于 DPU 产品线,简单来说就是网卡上有独立的 CPU,可以处理网络流量。例如,下面是 BlueField-2 DPU 的架构图:
rdma_bf2_arch
BlueField-2 DPU 架构
Nvidia Bluefield DPU Architecture - DELL

RoCE 原理

Quote

RoCE 协议存在 RoCEv1 和 RoCEv2 两个版本,取决于所使用的网络适配器。

  • RoCE v1:基于以太网链路层实现的 RDMA 协议 (交换机需要支持 PFC 等流控技术,在物理层保证可靠传输)。
  • RoCE v2:封装为 UDP(端口 4791) + IPv4/IPv6,从而实现 L3 路由功能。可以跨 VLAN、进行 IP 组播了。RoCEv2 可以工作在 Lossless 和 Lossy 模式下。
    • Lossless:适用于数据中心网络,要求交换机支持 DCB(Data Center Bridging)技术。

RoCE 包格式

rdma_packet_infiniband roce_packet_version
RoCE 包格式
RoCE 指南 - FS

DPDK

问题记录

编译相关

  • 编译 DPDK 时

    Generating drivers/rte_common_ionic.pmd.c with a custom command
    FAILED: drivers/rte_common_ionic.pmd.c
    ...
    Exception: elftools module not found
    

    解决方法:pip 安装 pyelftools 模块

其他相关知识

  • DMA 机制:包括 IOVA、VFIO 等。
  • Linux 内存管理机制:包括 Hugepage、NUMA 等。
  • Linux 资源分配机制:包括 cgroup 等。
  • 基础线程库 pthread。

  • Non-Uniform Memory Access (NUMA):非一致性内存访问。

    • 与 UMA 相比的优劣:内存带宽增大,需要编程者考虑局部性
    • 每块内存有
      • home:物理上持有该地址空间的处理器
      • owner:持有该块内存值(写了该块内存)的处理器
    • Linux NUMA:https://docs.kernel.org/mm/numa.html
      • Cache Coherent NUMA
      • 硬件资源划分为 node
        • 隐藏了一些细节,不能预期同个 node 上的访存效率相同
        • 每个 node 有自己的内存管理系统
        • Linux 会尽可能为任务分配 node-local 内存
        • 在足够不平衡的条件下,scheduler 会将任务迁移到其他 node,造成访存效率下降
      • Memory Policy
    • Linux 实践
      • numactl
      • lstopo
  • Hugepage