前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >用Rust实现RDMA

用Rust实现RDMA

作者头像
王璞
发布2021-04-06 16:12:29
1.9K0
发布2021-04-06 16:12:29
举报
文章被收录于专栏:深度技术专栏深度技术专栏

用Rust实现RDMA

RDMA是常用于高性能计算(HPC)领域的高速网络,在存储网络等专用场景也有广泛的用途。RDMA最大的特点是通过软硬件配合,在网络传输数据的时候,完全不需要CPU/内核参与,从而实现高性能的传输网络。最早RDMA要求使用InfiniBand (IB)网络,采用专门的IB网卡和IB交换机。现在RDMA也可以采用以太网交换机,但是还需要专用的IB网卡。虽然也有基于以太网卡用软件实现RDMA的方案,但是这种方案没有性能优势。

RDMA在实际使用的时候,需要采用特定的接口来编程,而且由于RDMA在传输数据的过程中,CPU/内核不参与,因此很多底层的工作需要在RDMA编程的时候自行实现。比如RDMA传输时涉及的各种内存管理工作,都要开发者调用RDMA的接口来完成,甚至自行实现,而不像在socket编程的时候,有内核帮忙做各种缓存等等。也正是由于RDMA编程的复杂度很高,再加上先前RDMA硬件价格高昂,使得RDMA不像TCP/IP得到广泛使用。

本文主要介绍我们用Rust对RDMA的C接口封装时碰到的各种问题,并探讨下如何用Rust对RDMA实现safe封装。下面首先简单介绍RDMA的基本编程方式,然后介绍下采用Rust对RDMA的C接口封装时碰到的各种技术问题,最后介绍下后续工作。我们用Rust实现的RDMA封装已经开源,包括rdma-sysasync-rdma,前者是对RDMA接口的unsafe封装,后者是safe封装(尚未完成)。

RDMA编程理念

先首先简要介绍下RDMA编程,因为本文重点不是如何用RDMA编程,所以主要介绍下RDMA的编程理念。RDMA的全称是Remote Direct Memory Access,从字面意思可以看出,RDMA要实现直接访问远程内存,RDMA的很多操作就是关于如何在本地节点和远程节点之间实现内存访问。

RDMA的数据操作分为“单边”和“双边”,双边为send/receive,单边是read/write,本质都是在本地和远程节点之间共享内存。对于双边来说,需要双方节点的CPU共同参与,而单边则仅仅需要一方CPU参与即可,对于另一方的CPU是完全透明的,不会触发中断。根据上述解释,大家可以看出“单边”传输才是被用来传输大量数据的主要方法。但是“单边”传输也面临这下列挑战:

  1. 由于RDMA在数据传输过程中不需要内核参与,所以内核也无法帮助RDMA缓存数据,因此RDMA要求在写入数据的时候,数据的大小不能超过接收方准备好的共享内存大小,否则出错。所以发送方和接收方在写数据前必须约定好每次写数据的大小。
  2. 此外,由于RDMA在数据传输过程中不需要内核参与,因此有可能内核会把本地节点要通过RDMA共享给远程节点的内存给交换出去,所以RDMA必须要跟内核申请把共享的内存空间常驻内存,这样保证远程节点通过RDMA安全访问本地节点的共享内存。
  3. 再者,虽然RDMA需要把本地节点跟远程节点共享的内存空间注册到内核,以防内核把共享内存空间交换出去,但是内核并不保证该共享内存的访问安全。即本地节点的程序在更新共享内存数据时,有可能远程节点正在访问该共享内存,导致远程节点读到不一致的数据;反之亦然,远程节点在写入共享内存时,有可能本地节点的程序也正在读写该共享内存,导致数据冲突或不一致。使用RDMA编程的开发者必须自行保证共享内存的数据一致性,这也是RDMA编程最复杂的关键点。

总之,RDMA在数据传输过程中绕开了内核,极大提升性能的同时,也带来很多复杂度,特别是关于内存管理的问题,都需要开发者自行解决。

RDMA的unsafe封装

RDMA的编程接口主要是C实现的rdma-core,最开始我们觉得用Rust的bingen可以很容易生成对rdma-core的Rust封装,但实际中却碰到了很多问题。

首先,rdma-core有大量的接口函数是inline方式定义,至少上百个inline函数接口,bindgen在生成Rust封装时直接忽略所有的inline函数,导致我们必须手动实现。Rust社区有另外几个开源项目也实现了对rdma-core的Rust封装,但是都没有很好解决inline函数的问题。此外,我们在自行实现rdma-core的inline函数Rust封装时,保持了原有的函数名和参数名不变。

其次,rdma-core有不少宏定义,bindgen在生成Rust封装时也直接忽略所有的宏定义,于是我们也必须手动实现一些关键的宏定义,特别是要手动实现rdma-core里用宏定义实现的接口函数和一些关键常量。

再有,rdma-core有很多数据结构的定义用到了union,但是bindgen对C的union处理得不好,并不是直接转换成Rust里的union。更严重的是rdma-core的数据结构里还用到匿名union,如下所示:

代码语言:javascript
复制
struct ibv_wc {
    ...
	union {
		__be32		imm_data;
		uint32_t	invalidated_rkey;
	};
    ...
};

由于Rust不支持匿名union,针对这些rdma-core的匿名union,bindgen在生成的Rust binding里会自动生成union类型的名字,但是bindgen自动生成的名字对开发者很不友好,诸如ibv_flow_spec__bindgen_ty_1__bindgen_ty_1这种名字,所以我们都是手动重新定义匿名union,如下所示:

代码语言:javascript
复制
#[repr(C)]
pub union imm_data_invalidated_rkey_union_t {
    pub imm_data: __be32,
    pub invalidated_rkey: u32,
}

#[repr(C)]
pub struct ibv_wc {
    ...
    pub imm_data_invalidated_rkey_union: imm_data_invalidated_rkey_union_t,
    ...
}

再次,rdma-core里引用了很多C的数据结构,诸如pthread_mutex_tsockaddr_in之类,这些数据结构应该使用Rust libc里定义好的,而不是由bindgen再重新定义一遍。所以我们需要配置bindgen不重复生成libc里已经定义好的数据结构的Rust binding。

简单一句话总结下,bindgen对生成rdma-core的unsafe封装只能起到一半作用,剩下很多工作还需要手动完成,非常细碎。不过好处是,RDMA接口已经稳定,此类工作只需要一次操作即可,后续几乎不会需要大量更新。

RDMA的safe封装

关于RDMA的safe封装,有两个层面的问题需要考虑:

  • 如何做到符合Rust的规范和惯例;
  • 如何实现RDMA操作的内存安全。

首先,关于RDMA的各种数据结构类型,怎样才能封装成对Rust友好的类型。rdma-core里充斥着大量的指针,绝大多数指针被bindgen定义为*mut类型,少部分定义为*const类型。在Rust里,这些裸指针类型不是Sync也不是Send,因此不能多线程访问。如果把这些裸指针转化为引用,又涉及到生命周期问题,而这些指针指向的数据结构都是rdma-core生成的,大都需要显式的释放,比如struct ibv_wq这个数据结构由ibv_create_wq()函数创建,并由ibv_destroy_wq()函数释放:

代码语言:javascript
复制
struct ibv_wq *ibv_create_wq(...);

int ibv_destroy_wq(struct ibv_wq *wq);

但是用Rust开发RDMA应用的时候,Rust代码并不直接管理struct ibv_wq这个数据结构的生命周期。进一步,在Rust代码中并不会直接修改rdma-core创建的各种数据结构,Rust代码都是通过调用rdma-core的接口函数来操作各种RDMA的数据结构/指针。所以对Rust代码来说,rdma-core生成的各种数据结构的指针,本质是一个句柄/handler,这个handler的类型是不是裸指针类型并不重要。于是,为了在Rust代码中便于多线程访问,我们把rdma-core返回的裸指针类型都转换成usize类型,当需要调用rdma-core的接口函数时,再从usize转换成相应的裸指针类型。这么做听上去很hack,但背后的原因还是很显而易见的。进一步,对于在rdma-core中需要手动释放的资源,可以通过实现Rust的Drop trait,在drop()函数中调用rdma-core相应的接口实现资源自动释放。

其次,关于RDMA的内存安全问题,这部分工作尚未完成。目前RDMA的共享内存访问安全问题在学术界也是个热门研究课题,并没有完美的解决方案。本质上讲,RDMA的共享内存访问安全问题是由于为了实现高性能网络传输、绕过内核做内存共享带来的,内核在内存管理方面做了大量的工作,RDMA的数据传输绕过内核,因此RDMA无法利用内核的内存管理机制保证内存安全。如果要把内核在内存管理方面的工作都搬到用户态来实现RDMA共享内存访问安全,这么做的话一方面复杂度太高,另一方面也不一定有很好的性能。

在实际使用中,人们会对RDMA的使用方式进行规约,比如不允许远程节点写本地节点的共享内存,只允许远程节点读。但即便是只允许远程读取,也有可能有数据不一致的问题。比如远程节点读取了共享内存的前半段数据,本地节点开始更新共享内存。假定本地节点更新的数据很少而远程节点读取的数据很多,因此本地节点更新的速度比远程节点读取的速度快,导致有可能本地节点在远程节点读后半段数据前更新完毕,这样远程节点读取的是不一致的数据,前半段数据不包括更新数据但是后半段包括更新数据。远程节点读到的这个不一致的数据,既不是先前真实存在的某个版本的数据,也不是全新版本的数据,破坏了数据一致性的保证。

针对RDMA内存安全问题,一个常见的解决方案是采用无锁(Lock-free)数据结构。无锁数据结构本质上就是解决并发访问下保证内存安全问题,当多个线程并发修改时,无锁数据结构保证结果的一致性。针对上面提到的远程读、本地写的方式,可以采用Seqlock来实现。即每块RDMA的共享内存空间关联一个序列号(sequence number),本地节点每次修改共享内存前就把序列号加一,远程节点在读取开始和结束后检查序列号是否有变化,没有变化说明读取过程中共享内存没有被修改,序列号有变化说明读取过程中共享内存被修改,读到了有可能不一致的数据,则远程节点重新读取共享内存。

如果要放宽对RDMA的使用规约,即远程节点和本地节点都可以读写共享内存的场景,那么就需要采用更加复杂的算法或无锁数据结构,诸如Copy-on-WriteRead-Copy-Update等。内核中大量使用Copy-on-Write和Read-Copy-Update这两种技术来实现高效内存管理。这方面的工作有不少技术难度。

后续工作

下一步在完成对RDMA的safe封装之后,我们规划用Rust实现对RDMA接口函数的异步调用。因为RDMA都是IO操作,非常适合异步方式来实现。

对RDMA接口函数的异步处理,最主要的工作是关于RDMA的完成队列的消息处理。RDMA采用了多个工作队列,包括接收队列(RQ),发送队列(SQ)以及完成队列(CQ),这些队列一般是RDMA的硬件来实现。其中发送队列和接收队列的功能很好理解,如字面意思,分别是存放待发送和待接收的消息,消息是指向内存中的一块区域,在发送时该内存区域包含要发送的数据,在接收时该内存区域用于存放接收数据。在发送和接收完成后,RDMA会在完成队列里放入完成消息,用于指示相应的发送消息或接收消息是否成功。用户态RDMA程序可以定期不定期查询完成队列里的完成消息,也可以通过中断的方式在CPU收到中断后由内核通知应用程序处理。

异步IO本质上都是利用Linux的epoll机制,由内核来通知用户态程序某个IO已经就绪。对RDMA操作的异步处理,方法也一样。RDMA是通过创建设备文件来实现用户态RDMA程序跟内核里的RDMA模块交互。在安装RDMA设备和驱动后,RDMA会创建一个或多个字符设备文件,/dev/infiniband/uverbsN,N从0开始,有几个RDMA设备就有几个uverbsN设备文件。如果只有一个那就是/dev/infiniband/uverbs0。用户态RDMA程序要实现针对RDMA完成队列的异步消息处理,就是采用Linux提供的epoll机制,对RDMA的uverbsN设备文件进行异步查询,在完成队列有新消息时通知用户态RDMA程序来处理消息。

关于RDMA的封装,这块工作我们还没有完成,我们打算把RDMA的safe封装以及对RDMA的共享内存管理都实现,这样才能方便地使用Rust进行RDMA编程,同时我们欢迎有感兴趣的朋友一起参与。

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 用Rust实现RDMA
    • RDMA编程理念
      • RDMA的unsafe封装
        • RDMA的safe封装
          • 后续工作
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档