PaperReading

DRust

摘要

DSM因为内存一致性需要大量的同步操作而性能不佳

DRust是一种基于Rust编程语言实现的DSM

它利用了Rust编程语言内嵌的所有权模型

所有权模型自动限制了读写顺序

如果runtime可以利用这一特性,则内存一致性协议的实现将会被简化

文末会将DRust和GAM、Grappa对比,并且证明DRust的扩展性更好

简介

DSM不仅通过使用多个处理器提供了并行计算的能力,而且提供了统一且连续的内存视图,简化了分布式应用程序的开发

DSM早期因为网路速度低而性能不佳,随着网络技术的进步,DSM仍然未能达到令人满意的水平。其在可扩展性和与单机系统的比较下表现不佳,主要原因是其为了确保服务器之间的内存一致性,而进行了密集的同步操作

现在的DSM一般都遵循一个原则,对于每个要访问的数据块,该块要么位于一个节点上(允许读和写),要么复制到多个节点上(只允许读)。如果一个服务器要访问一个数据块,在此之前,DSM检查该块状态,使其在其他服务器上的副本失效,然后把数据块传输给请求的服务器。这个过程需要很多网络通信,即使用了RDMA,也很慢。所以减少同步次数,很关键。

Spark的RDD通过不可变性、粗粒度分区、惰性计算、血缘关系等机制,减少了同步次数,但是其通用性较低

现有的DSM采用了通用的方法来实现内存一致性,这使得其不考虑程序的语义信息,直接使用大量的同步操作。但是现在很多并发程序都是用SWMR原则来编写的,如果能够利用这种语义信息,那么就可以消除在访问数据之前检查远程数据块状态的需要,从而提高性能。那么问题是,如何把这样的语义信息提供给DSM来识别和利用呢?所以需要设计一种机制来使运行时能够将编程语言中的语义信息传递给DSM

AFIM和Midas通过程序员使用提供的API来指定哪个区域是SW的,这比较容易出错而且很麻烦。SWMR是所有权模型的一部分特性,而Rust编程语言就实现了所有权模型,故用Rust编写的DSM就会自动实现SWMR

所有权类型的基本概念是,每个值在其执行过程中都只有一个变量作为其所有者,一个值可以有多个不可变的引用,一个值只可以有一个可变的引用,且不允许其他引用存在。这很自然地实现了SWMR

因为Rust的编译器会在编译时检查所有权和引用规则,所以DSM就避免了这一步骤。即,在写操作时,编译器确保了此时没有其他写操作并且所有读操作都已经完成,故而DSM可以直接把数据块迁移到执行写操作的服务器上,而无需使该数据块在其他服务器上的副本失效;在读操作的时候,编译器确保了此时不会有并发的写操作,故避免了数据不一致的问题。

Drust通过利用Rust的所有权模型,实现了对象级别的并发访问,并自动将单机Rust程序转换为分布式版本。但是DRust的实现有两个难点

第一个。Rust的所有权类型是为单机环境设计的,其对象的内存地址在创建后不变,但是在分布式环境里面,对象是会在不同的服务器之间迁移或复制的,这就可能导致悬空指针,会出现问题。

DRust构建了一个跨服务器的全局堆(每个服务器管理全局地址空间中的一个分区),每个对象在全局空间中都有唯一的全局地址,任何服务器都可以访问。这就需要重新实现Rust的内存管理构造,以便在全局堆中分配和管理对象。为了确保服务器可以缓存对象,DRust设计了一个基于所有权模型的缓存一致性协议。

一致性协议就是读操作发生的时候,服务器可以缓存,但是不会更改对象的地址和值,写操作发生时,会把对象挪到执行写操作的服务器所管理的分区堆中,这使得使用旧地址的副本自动失效,后续读者读的时候会自动更新到新的地址

第二个。Rust的标准库和程序是为单机环境设计的,运行在服务器A上的程序无法在服务器B上创建线程,更不用说同步操作了。所以DRust重构了Rust中线程std::thread::spawn、通信通道std::sync::mpsc、共享状态锁std::sync::{Rwlock、Mutex}等库,并且接口相同。这些都是在前面所有权内存模型的基础上实现的。

除了重构标准库外,每个节点上都有一个运行时来监控资源使用情况,并且和全局控制器配合,实现内存管理和线程调度

DRust(2024)比GAM(2018)好,比Grappa(2015)更好,比单机环境差一点

GAM和Grappa比单机环境差多少?

所有权

在内存管理的安全性和数据共享方面,编程语言对于内存抽象层次和管理效率有不同的考量,比如Java就是提供了高层次的内存抽象,通过垃圾回收自动管理内存,虽然简化了编程,但是引入了额外的运行时开销;像C就是提供了低层次的内存控制,程序员需要手动管理内存分配和释放,虽然性能高,但是出错风险也增加了。Rust在性能和安全方面做了一个不错的权衡,对内存管理的抽象程度比较适中,其所采用的方法是所有权。

前面说到Rust为了性能而降低了内存抽象层次,但其安全问题也相应提升,为了保证程序的内存和线程安全,所有权通过在编译时进行严格的类型检查来完成。

每个对象都有生命周期,并且每个对象在任何时候都只能有一个所有者,编译器会静态跟踪每个对象的生命周期,一旦其所有者离开作用域,该对象就会被释放。

临时访问数据而不转移所有权时,程序需要创建引用,来借用对象的所有者permission,用完之后需要return。在安全性方面,引用的生命周期必须在对象的生命周期之内,这样就避免出现悬空指针;在共享数据方面,不可变引用可以有多个,但不许有可变引用的存在,若存在可变引用,则不允许有其他引用的存在。这些都是类型系统的限制(编译器来检查)

赋值、函数调用、线程创建、消息传递会出现所有权转移,这要求该对象必须没有其他借用

动机

DSM是为了帮助分布式编程的,让其像在单机环境下编程一样。核心就是内存一致性模型,再核心就是缓存一致性协议。模仿了多核CPU的硬件缓存一致性,通过在不同的服务器之间发送控制信息来同步内存状态,但是在物理上分离的服务器之间通信,其延迟太高了(为了说明延迟高,作者做了有GAM实验)。

利用所有权模型实现的缓存一致性,会好很多

硬件缓存一致性?如何实现?

设计

DRust是一个基于Rust编程语言的高效DSM模型,主要有Rust基础的DSM编程抽象和管理分布式物理资源的运行时两部分构成。

a-DRust的编程抽象

a-1内存管理

地址空间布局:分布式的栈大小等于每个服务器的栈大小,栈内每个线程对齐,不重叠;分布式的堆大小等于所有服务器的堆大小的总和

一致性协议:新线程的创建只传引用,解引用的时候才把对象拉到本地。读只copy(缓存数据),写会move(导致缓存自动失效)。pointer-coloring技术解决了本地写也会move的问题

Rust里面Reference、mut Referenc、Box pointer的布局是怎样的?

指针布局:从64bit扩大到了128bit,前16bit为color,新增的64bit用于记录写操作中的所有者地址,读操作中的缓存地址

a-2适配Rust标准库

std::thread:用spawn来产生线程,闭包被当作线程体,运行时根据负载来分配线程;所有权在子线程的创建和结束时与父线程之间进行交换,保证了类型、内存安全;还适配thread:scope

std::sync::mpsc:构建基于网络的消息队列,用于实现跨服务器的信息传递

std::sync::Rc std::sync::Arc:前者不变,后者参考不可变引用的修改

std::sync::Mutex std::sync::atomic:值分配在全局堆,原子类型存储指针,将操作封装成消息发送到存储值得服务器,以此实现同步;互斥锁也是只保留指针,对互斥锁的并发操作在存储互斥锁的服务器上进行序列化,以保证安全

关于标准库适配,有没有更详细的资料?

a-3亲和性注释

如果一个线程经常进行指针解引用,而该线程的执行server和数据存储的server不同,那就会有大量的网络通信,这个时候可以通过亲和性注释,让线程执行在数据存储的服务器上

实现了TBox,相比于Box,它要求owner和object必须在相同服务器的stack和heap上,比如用TBox实现了一个链表,那在解引用表头得时候,会把整个表都move/copy到本地服务器上,保证后续都是本地访问

实现了spawn_to,相比于spawn,它多了一个box参数,用于指定线程应该在哪个server上执行。比如有一个对象a要在线程里面经常被,访问,那这个线程就可以把box参数设置为a得box,那线程就会在a所在的server上执行,即,确保线程在其主要访问的对象所在的服务器上执行

b-DRust的运行时系统

b-1应用集成运行时

运行时库是链接到每个应用程序并在每个服务器上启动的核心组件

通信层:支持缓存一致性协议和跨服务器的内存访问,分别对应control plane和data plane,前者是two-sided verbs,后者是one-sided verbs,后者可以直接访问目标服务器的内存,前者需要得到确认信息

RDMA中的two-sided verbs和one-sided verbs是啥?

堆分配器:提供标准内存分配接口,返回全局地址;优先本地分配;本地内存不足,查询全局控制器,找到最闲服务器进行分配;回收时直接通过全局地址回收,而不需要全局控制器;cache是heap的一部分

线程调度器:在用户空间运行,优先本地分配;和全局控制器配合实现负载均衡;把新线程当作闭包,即函数指针和一些初始化参数,和全局控制器协作分配地址,然后执行闭包;采用合作式多任务处理,线程之间的上下文切换是非抢占式的;开发者使用await来主动让出控制流,长延迟操作时被动让出;上下文切换时函数调用,每个线程只需要少输寄存器;线程迁移时,需要把函数指针、寄存器状态、栈传送到目的服务器,因为地址是不重叠的,所以恢复寄存器后,就可以调用函数指针来继续执行;DRust会在编译期间生成用于状态传输的代码用于调度器进行迁移线程

这一块有没有详细的描述??

b-2全局控制器

控制器作为一个守护进程运行在程序启动的服务器上

定期向每个服务器发送心跳包,探测并记录资源使用情况;当运行时需要分配内存或线程创建时,先查询控制器,控制器会和目标服务器上的运行时协调,执行实际操作;维护全局表,记录每个线程的位置;如果内存或cpu使用率大于90%,会持续迁移消耗本地堆内存最多的线程,直到压力缓解,或者会迁移频繁访问远程对象的线程到它访问最多的服务器,或者空闲的服务器

b-3容错性

每个堆分区都有备份服务器,线程不需要备份,每次进行写操作,都会在所有权转移时执行一致性维护,当出现故障时,备份服务器变为主服务器,然后添加新的备份服务器

实现

DRust除了通信层用C语言实现外,其余都是用Rust实现。实现了Ref,MytRef,DBox 三个结构体,分别对应&,&mut,Box,还实现了copy、clone、drop的trait来支持一致性协议;为了适配未修改的Rust程序,还改了编译器,添加了额外的编译传递,将Rust引用和Box指针转换为DRust中的相应类型

通信层直接链接libibverbs库,实现快速且绕过内核的RDMA网络通信;实现了低级C库,覆盖基本的连接建立,并暴露高级的Rust接口;提供多种RDMA动词的高级Rust接口;主要使用单面的READ和WRITE动词进行数据传输,追求性能;使用双面的READ和WRITE来进行控制信息交换,确保建立和其他控制操作的可靠性;使用原子动词实现共享状态管理,确保数据的一致性;使用RC传输类型,确保可靠传输和严格的消息顺序

堆分配器基于Rust原生分配器实现,其将虚拟地址范围和堆分区范围对齐;线程调度器基于Tokio,其高效的用户线程管理和合作式调度机制;全局控制器负责管理集群中的所有线程,为了防止地址重叠,其会对线程的栈进行填充

局限性

DRust的一致性保证仅适用于安全的Rust代码,在不安全的代码块中,开发人员需要自己确保一致性

数据主要出于Mutex状态下时,性能退化为普通DSM

不支持地址空间布局随机化,但是只要DRust线程在每个服务器上共享相同的随机化地址空间布局,可以将栈和堆的分配委托给全局控制器

评估

评估前的准备

环境设置:集群一共有8个节点,每个节点是两个Intel Xeon E5-2640 v3处理器,每个处理器是8核,每个节点是128G RAM,每个节点配备一个40Gbps的Mellanox ConnectX-3 InfiniBand网络适配器,节点们通过一个100Gbps的Mellanox InfiniBand交换机连接;操作系统是Ubuntu19.04,内核是5.14;系统配置方面禁用了超线程、CPU频率缩放、OS安全缓存措施

评估方法:选择GAM和Grappa来与DRust做对比;会把评估的应用程序移植到每个系统上,并调参数,以达到最佳性能;GAM提供普通对象读写接口,把GAM导出为一个库,集成到Rust里面,通过挂钩指针解引用操作来使用GAM的API;Grappa通过代理访问共享内存,重新实现应用程序,用C++编写;

应用的选取:从内存需求和计算强度两个方面,选取了四个覆盖不同类型的典型数据中心应用程序

评估目的:DRust在分布式集群中加速应用程序的能力,以及随着服务器数量增加的扩展性

具体做法:

理想情况:随着节点数变多,吞吐量成比例提高

实际限制:应用程序并行性有限,DSM开销

评估结果

相对于GAM和Grappa,DRust都要好;相比于单机环境,DRust也要好

DataFrame:在每个操作中构建一个索引哈希表,用于跟踪输出列中的每个目标块到输入列中的所有原块的映射;索引表由所有索引构建线程和工作线程共享,索引构建线程并发地插入索引表,工作线程查询索引表并检索原块进行处理;出现大量写入和读取共享表的操作。引用传递和低计算强度使一致性开销显著。DRust轻量级一致性协议和亲和性注解;GAM缓存失效导致大量一致性流量;Grappa委托编程模型导致委托开销超过实际内存访问延迟。通过指针亲和力注解把来自同一列的块分组在一起,通过线程亲和力注解把工作线程和输入列共置一个服务器上,更快了。

SocialNet:添加了一个基线,在不同数量的节点上部署原始代码。需要将数据序列化为字节流进行网络传输,然后在接收端反序列化为可用格式,计算密集;DSM传递参数,而不是数据值,性能提高。DRust的一致性协议开销小

GEMM:高计算强度和相对不频繁的共享内存访问。矩阵变换和划分成较小的子矩阵进行并行处理,每个计算线程负责乘法子矩阵,并将其缓存在服务器的本地内存中,反复访问以计算结果。DRust和GAM可以有效将子矩阵缓存到本地内存,减少远程访问频率,Grappa无法有效缓存子矩阵,需要频繁进行远程访问。DRust首次远程访问子矩阵时,直接把数据复制到本地内存,无需复杂的同步操作,GAM需要维护数据副本的状态和位置

KV Store:计算强度低和内存局部性差。使用Mutex来同步工作线程之间的操作。DRust自适应均衡负载和单边RDMA原子操作;GAM双边RDMA;Grappa远程委托

DRust里面的Box解引用比Rust里面的Box解引用,性能低一点点。DRust的线程迁移也很快;DRust的缓存一致性开销也很小;

相关工作

软件DSM:Munin和TreadMarks用了放松的一致性模型;现在DSM很多都用RDMA这种硬件技术

分离式内存和远程内存:前者将内存资源从物理服务器中分离出来,形成独立的内存池,通过网络连接到多个计算节点;后者允许应用程序通过os内核或者运行时来访问远程服务器的上的内存

分布式编程抽象:Munin引入类型系统,定义了本地指针和全局指针,通过类型检查跟踪指针是否被共享;X10和UPC引入函数卸载接口,允许把计算任务从一个节点卸载到另一个节点,实现负载均衡;Ray通过任务图的方式管理任务并行性和依赖关系;Nu采用数据流模型;

硬件加速DSM:将任务卸载到定制硬件或通过硬件实现的协议来减少远程内存访问的延迟和提高内存一致性