基于RDMA的RPC实现

  • 项目起源于分布式系统与中间件的课程作业,自己实现一个分布式系统相关的项目。
  • 由于课程内容中有大量涉及 RPC 的内容,考虑自己实现一个 RPC 的框架。
  • 现如今有很多优秀的 RPC 框架,此次主要基于 RDMA 来进行实现,参考相关论文。
  • 后续会基于 Netty 实现 RPC,顺便了解 Netty 通信框架的基础知识。

背景介绍

  • 在数据中心运行的新型大规模分布式系统的出现给RPC系统增加了额外的压力。例如,HDFS,Zookeeper或OpenFlowcontain等系统要求每秒处理大量RPC请求的集中式RPC服务(例如,名称,调度程序,控制器)。 此外,一些提供低延迟数据访问的新系统,如RAM-Cloud 或Tango,要求超低RPC延迟,同时仍然要求RPC系统扩展到高容量的请求。
  • 不幸的是,当今基于云的系统中使用的RPC实现很难满足这些要求。 例如,HDFS和Zookeeper中的RPC服务通常可以在200到500μs之间的延迟时间内每秒处理100-200K操作。 其他系统(如Tango中使用的系统)表现更好(60-70μs时600K op / s),但在所有这些情况下,RPC系统的性能远低于硬件(CPU,网络)所能提供的性能。 实际上,我们发现这些系统既不会使网络饱和,也不会使CPU饱和。 最近,已经在网络堆栈级别讨论了类似的低效率。 为了克服这些问题,已经建议在用户空间中实现网络堆栈,同时减少开销。
  • RDMA 的出现,就是为了解决网络传输中客户端与服务器端数据处理的延迟而产生的。它将数据直接从一台计算机的内存传输到另一台计算机,无需双方操作系统的介入。这允许高吞吐、低延迟的网络通信,尤其适合在大规模并行计算机集群中使用。RDMA通过网络把资料直接传入计算机的内存中,将数据从一个系统快速移动到远程系统内存中,而不对操作系统造成任何影响,这样就不需要用到多少计算机的处理能力。它消除了数据包在用户空间和内核空间复制移动和上下文切换的开销,因而能解放内存带宽和CPU周期用于改进应用系统性能。

想法

  • 通过使用远程直接内存访问(RDMA)将RPC处理与用户空间中的网络处理集成在一起。 我们提供DaRPC,这是一种高吞吐量低延迟RPC框架,专为提高数据中心大规模分布式系统的性能而量身定制。 DaRPC中使用的关键思想是协调多核系统中RPC处理和网络的计算和内存资源分配。 这种方法与构建RPC系统的传统方法形成对比,其中RPC消息处理是独立于内核中的网络处理实现的。通过将RPC处理和网络视为联合优化问题,我们可以避免上下文切换,缓存未命中并实现高度并行性以及超低延迟。
    image
  • 核心思想:将曾经在内核态实现的网络堆栈移植到用户态进行实现,不经过内核态的处理直接和硬件模块(CPU,NIC,Memory)进行通信.

前提背景知识

  • 为了使用 RDMA 协议进行数据直取,也就是为了利用设备的 RDMA 特性,在应用层定义了一个抽象概念 :verbs。
    verbs 不是 API,libibverbs 定义了调用支持 RDMA 设备的函数,被称作为 IB verbs API。因此,出现了 verbs 编程,其实也就是一般理解的 RDMA 编程。

核心概念

Memory Registration(MR) | 内存注册

  • 前提要求:
    • 在数据传输过程中,应用程序不能修改数据所在的内存。
    • 操作系统不能对数据所在的内存进行page out操作 – 物理地址和虚拟地址的映射必须是固定不变的。
  • 内存注册方式:
    • 创建两个key (local和remote)指向需要操作的内存区域
    • 注册的keys是数据传输请求的一部分
  • 注册完成以后,获得相应的属性:
    • context : RDMA操作上下文
    • addr : MR被注册的Buffer地址
    • length : MR被注册的Buffer长度
    • lkey:MR被注册的本地key
    • rkey:MR被注册的远程key
  • Memory Registration只是RDMA中对内存保护的一种措施,只有将要操作的内存注册到RDMA Memory Region中,这快操作的内存就交给RDMA 保护域来操作了。这个时候我们就可以对这快内存进行操作,至于操作的起始地址、操作Buffer的长度,可以根据程序的具体需求进行操作。我们只要保证接受方的Buffer 接受的长度大于等于发送的Buffer长度。

Queues | 队列

  • 发送队列(SQ)和接收队列(RQ),完成队列(CQ)。其中,SQ和RQ通常成对创建,被称为Queue Pairs(QP)。
  • RDMA是基于消息的传输协议,数据传输都是异步操作。 RDMA操作其实很简单,操作流程大致如下:
    • Host提交工作请求(WR)到工作队列(WQ): 工作队列包括发送队列(SQ)和接收队列(RQ)。工作队列的每一个元素叫做WQE, 也就是WR。
    • Host从完成队列(CQ)中获取工作完成(WC): 完成队列里的每一个叫做CQE, 也就是WC。
    • 具有RDMA引擎的硬件(hardware)就是一个队列元素处理器。 RDMA硬件不断地从工作队列(WQ)中去取工作请求(WR)来执行,执行完了就给完成队列(CQ)中放置工作完成(WC)。
  • 使用生产者消费者模型来理解就是:
    • Host生产WR, 把WR放到WQ中去
    • RDMA硬件消费WR(执行请求处理)
    • RDMA硬件生产WC, 把WC放到CQ中去
    • Host消费WC

RDMA 支持的操作

RDMA Send | RDMA发送(/接收)操作 (Send/Recv)
  • 跟TCP/IP的send/recv是类似的,不同的是RDMA是基于消息的数据传输协议(而不是基于字节流的传输协议),所有数据包的组装都在RDMA硬件上完成的,也就是说OSI模型中的下面4层(传输层,网络层,数据链路层,物理层)都在RDMA硬件上完成。
  • 以 SEND 的具体流程为例:
    • 第1步:系统A和B都创建了他们各自的QP的完成队列(CQ), 并为即将进行的RDMA传输注册了相应的内存区域(MR)。 系统A识别了一段缓冲区,该缓冲区的数据将被搬运到系统B上。系统B分配了一段空的缓冲区,用来存放来自系统A发送的数据。
    • 第二步:系统B创建一个WQE并放置到它的接收队列(RQ)中。这个WQE包含了一个指针,该指针指向的内存缓冲区用来存放接收到的数据。系统A也创建一个WQE并放置到它的发送队列(SQ)中去,该WQE中的指针执行一段内存缓冲区,该缓冲区的数据将要被传送。
    • 第三步:系统A上的HCA总是在硬件上干活,看看发送队列里有没有WQE。HCA将消费掉来自系统A的WQE, 然后将内存区域里的数据变成数据流发送给系统B。当数据流开始到达系统B的时候,系统B上的HCA就消费来自系统B的WQE,然后将数据放到该放的缓冲区上去。在高速通道上传输的数据流完全绕过了操作系统内核。
    • 第四步:当数据搬运完成的时候,HCA会创建一个CQE。 这个CQE被放置到完成队列(CQ)中,表明数据传输已经完成。HCA每消费掉一个WQE, 都会生成一个CQE。因此,在系统A的完成队列中放置一个CQE,意味着对应的WQE的发送操作已经完成。同理,在系统B的完成队列中也会放置一个CQE,表明对应的WQE的接收操作已经完成。如果发生错误,HCA依然会创建一个CQE。在CQE中,包含了一个用来记录传输状态的字段。
RDMA Read | RDMA读操作 (Pull)
  • RDMA读操作本质上就是Pull操作, 把远程系统内存里的数据拉回到本地系统的内存里。
RDMA Write | RDMA写操作 (Push)
  • RDMA写操作本质上就是Push操作,把本地系统内存里的数据推送到远程系统的内存里。

其他核心概念

SGE
  • RDMA编程中,SGL(Scatter/Gather List)是最基本的数据组织形式。 SGL是一个数组,该数组中的元素被称之为SGE(Scatter/Gather Element),每一个SGE就是一个Data Segment(数据段)。RDMA支持Scatter/Gather操作,具体来讲就是RDMA可以支持一个连续的Buffer空间,进行Scatter分散到多个目的主机的不连续的Buffer空间。Gather指的就是多个不连续的Buffer空间,可以Gather到目的主机的一段连续的Buffer空间。
  • 数据结构
struct ibv_sge {
        uint64_t        addr;
        uint32_t        length;
        uint32_t        lkey;
};
- addr: 数据段所在的虚拟内存的起始地址 (Virtual Address of the Data Segment (i.e. Buffer))
- length: 数据段长度(Length of the Data Segment)
- lkey: 该数据段对应的L_Key (Key of the local Memory Region)
ivc_post_send接口
  • ibv_post_send() - post a list of work requests (WRs) to a send queue 将一个WR列表放置到发送队列中
  • ibv_post_recv() - post a list of work requests (WRs) to a receive queue 将一个WR列表放置到接收队列中
#include <infiniband/verbs.h>

int ibv_post_send(struct ibv_qp *qp, 
                  struct ibv_send_wr *wr,
                  struct ibv_send_wr **bad_wr);
  • ibv_post_send()将以send_wr开头的工作请求(WR)的列表发布到Queue Pair的Send Queue。 它会在第一次失败时停止处理此列表中的WR(可以在发布请求时立即检测到),并通过bad_wr返回此失败的WR。
  • 在调用ibv_post_send()之前,必须填充好数据结构wr。 wr是一个链表,每一个结点包含了一个sg_list(i.e. SGL: 由一个或多个SGE构成的数组), sg_list的长度为num_sge。
RDMA 提交 WR 流程
  • 第一步:创建SGL。wr链表中的每一个结点都包含了一个SGL,SGL是一个数组,包含一个或多个SGE。通过ibv_post_send提交一个RDMA SEND 请求。这个WR请求中,包括一个sg_list的元素。它是一个SGE链表,SGE指向具体需要发送数据的Buffer。
  • 第二步:使用PD进行内存保护。发送一段内存地址的时候,我们需要将这段内存地址通过Memory Registration注册到RDMA中。也就是说注册到PD内存保护域当中。一个SGL至少被一个MR保护, 多个MR存在同一个PD中。如图所示一段内存MR可以保护多个SGE元素。
  • 第三步:一个SGL数组包含了3个SGE, 长度分别为N1, N2, N3字节。我们可以看到,这3个buffer并不连续,它们Scatter(分散)在内存中的各个地方。调用ibv_post_send()将SGL发送到wire上去。RDMA硬件读取到SGL后,进行Gather(聚合)操作,于是在RDMA硬件的Wire上看到的就是N3+N2+N1个连续的字节。换句话说,通过使用SGL, 我们可以把分散(Scatter)在内存中的多个数据段(不连续)交给RDMA硬件去聚合(Gather)成连续的数据段。

RDMA 语义

  • RDMA有两种基本操作:
    • Memory verbs: 包括RDMA read、write和atomic操作。这些操作指定远程地址进行操作并且绕过接收者的CPU。单边操作(只需要一边进行主动操作)
    • Messaging verbs:包括RDMA send、receive操作。这些动作涉及的接收方CPU,发送的数据被写入由接收方的CPU先前发布的接受所指定的地址。双边操作(发送方发送消息,接收方需要预先发布应用程序缓冲区,指示在何处想要接收数据)
  • 与Socket模型相比,RDMA完全将数据传输操作与控制操作分开。 这有利于预分配通信资源(例如,固定DMA的存储器)并实现数据传输而无需操作系统参与,这对于实现超低延迟是关键。

jVerb

  • 使用jVerbs作为本机RDMA API,我们决定既不牺牲可用的通信语义,也不牺牲最小的网络延迟。 jVerbs提供对所有独有RDMA功能的访问。
  • 为了实现jVerbs中最低的延迟,我们在访问网络硬件时采用与Natvie C用户库相同的技术。对于所有性能关键的操作,Native C Verbs库通过三个队列与RDMA网络设备交互:发送队列,接收队列和完成队列。这些队列代表硬件资源,但是被映射到用户空间以避免内核参与访问它们。
  • jVerbs充分利用Java的堆外内存直接从JVM中访问这些设备队列。堆外内存分配在垃圾收集器控制之外的单独区域中,但可以通过常规Java内存API(ByteBuffer)访问。在jVerbs中,我们使用标准内存映射I / O将设备硬件资源映射到堆外内存。 postSend()或postRecv()等快速路径操作是通过将工作请求直接序列化到映射队列来实现的。所有操作都完全用Java实现,避免了昂贵的JNI调用或对JVM的修改。

SVC (Stateful Verb Calls)

  • 即使jVerbs通过直接访问设备硬件来避免JNI的开销,一个剩余的开销源来自将工作请求序列化到映射队列中。 考虑到现代互连的单位数网络延迟,序列化的成本很容易达到几微秒。 为了解决这个问题,jVerbs采用了一种称为有状态动词调用(SVC)的机制。
  • 此机制直接在API级别上显示:jVerbs不是执行Verbs调用,而是返回一个有状态对象,该对象表示对给定参数值集的Verbs调用。 应用程序使用exec()执行SVC对象,使用result()来检索调用结果。
Verbs v = Verbs.open();
/* post the work requests */
v.postSend(sendQueue, workRequestList).exec().free();
/* check if operation has completed */
while (v.pollCQ(cq, pList).exec().result() == 0);
  • SVC的一个关键优势是可以多次缓存和重新执行它们。 但是,在执行之前,应用程序必须使用valid()函数验证SVC对象是否仍处于有效状态。 从语义上讲,SVC对象的每次执行都与针对SVC对象的当前参数状态评估的jVerbs调用相同。 但是,只有在第一次执行对象时才需要创建执行SVC对象时所需的任何序列化状态。 后续调用使用已建立的序列化状态,因此执行速度会快得多。 一旦不再需要SVC对象,就可以使用free()API调用释放资源。
  • 某些SVC对象允许在创建对象后更改参数状态。 例如,如果需要,应用程序可以更改postSend()和postRecv()返回的SVC对象的地址和偏移量。 在内部,这些对象以递增方式更新其序列化状态。 只有在不扩展序列化状态的情况下,才允许对SVC对象进行修改。 因此,不允许向SVC postSend()对象添加新的工作请求或Scatter/Gather元素。
  • Stateful Verb Calls为应用程序提供了一个缓解序列化成本的句柄。 在许多情况下,应用程序可能只需要创建少量SVC对象,以匹配他们打算使用的不同类型的动词调用。 重新使用这些对象有效地将序列化成本降低到几乎为零
Verbs v = Verbs.open();
/* create SVCs */
RdmaSVC post = v.postSend(sq, wrlist);
RdmaSVC poll = v.pollCQ(cq, plist);
post.exec();
while (poll.exec().result() == 0);
/* modify the work requests */
post.getWR(0).getSG(0).setOffset(32);
/* post again */
post.exec();
while (poll.exec().result() == 0);

参考文献