温瑞林,樊 春,马银萍,王政丹,向广宇 ,付振新
(1.北京大学信息科学技术学院,北京 100871;2.北京大学计算中心,北京 100871;3.北京大学国家生物医学成像科学中心,北京 100871;4.鹏城实验室,广东 深圳 518055;5.北京大学软件与微电子学院,北京 102600)
Slurm[1]是一个在生产环境中广泛使用的任务调度系统。Slurm的运行效率很高,但是当对其进行功能扩展时,由于Slurm代码使用的是抽象能力较低的C语言且代码规范不太严谨等原因,Slurm的代码耦合度高、维护时间成本较大,添加新功能的效率也较低。因此,本文将Slurm的现有架构进行重构、解耦,使用具备较高抽象能力的C++进行重新实现,从而降低新功能的开发和维护成本,并基于此提出了一个新的任务调度系统SlurmX。
在SlurmX的设计过程中,本文参考了同样使用C++语言编程,并且采用面向对象方法实现架构和组件设计的高性能计算系统HTCondor[2]的一些设计思路,同时也遵循了Slurm的原有功能和程序逻辑。
本文将先对Slurm的功能和SlurmX的需求进行分析;再对SlurmX进行系统架构设计和具体的内部模块设计;最后对本文进行了总结,探讨了未来改进方向。
该资源调度系统要面向进行高性能计算的用户,假定用户具备一定程度的计算机知识,可以通过远程终端使用命令行操作该软件。因此,该资源调度系统除资源使用情况概览等部分场景外,仅需提供命令行操作接口。
本文提供如下几种命令行接口:
(1)提交单个计算任务的交互式接口。通过该接口,用户可以实时提交计算任务,指定任务相关参数和计算资源需求,并且该任务以交互方式运行,用户可以实时观察到该计算任务在远程执行节点上的输出。同时,用户也可以通过该接口对计算任务随时执行终止操作。
(2)提交批量计算任务的非交互式接口。通过该接口,用户可以通过编写一个带有多个计算任务的相关参数、计算资源需求以及各个任务之间依赖关系的配置文件,批量提交计算任务。这些计算任务以非交互方式运行,其数据会被转存到相应文件,用户可以通过查看这些文件的内容获取这些计算任务的输出内容。
(3)查询计算任务和节点状态等信息的接口。由于存在第(2)类非交互式命令行接口,因此需要有另外一个提供计算任务队列和执行状态查询的命令行接口。同时,注意到除了计算任务的查询接口之外,还有许多其他信息需要查询,如节点当前工作状态、资源使用情况和节点健康状态等。为了防止命令行接口出现碎片化情况,本文对这些查询接口统一进行了设计和实现。图1描述了SlurmX的用例图。
Figure 1 User case diagram of SlurmX图1 SlurmX用例图
由于是在集群环境下,数量庞大的服务器集群中,某些节点在长时间工作之后不可避免地会出现不可用的情况。这些不可用的情况有可能是由于该节点内部的硬件出现物理故障导致的,如磁盘出现坏道或网卡故障;也有可能是由于外部环境的影响导致的,如外部网络故障。当系统部分节点功能失效的情况出现时,该集群调度系统应当可以持续正常工作,并对这些故障做出妥当的处理;对于现有的正在非故障节点之上运行的计算任务,不应该出现任何可见的干扰;对于新提交的计算任务请求,应当能正确调度到非故障节点上运行;对于该资源调度系统的管理员,应当以合适的方式发出通知。
同时,对于临时性故障节点,在可恢复的情况下,为了性能效率考虑,资源调度系统的控制器应当尽量对现存的任务保持继续运行状态,而不是重新启动。
下面描述命令行接口之外的具体功能,该资源调度系统在命令行接口的后端侧需提供如下功能:
(1)该资源调度系统需具备严格的资源限制能力,即假设用户不可信,可能会尝试资源逃逸,该资源调度系统提供的资源限制能力应该可以防止这种情况的发生。
(2)该资源调度系统需提供多种资源调度算法,使得大量的计算任务可以被批量提交和批量调度。本文希望通过这些调度算法使集群中的各种计算资源得到高利用率。
(3)该资源调度系统需提供对于通用硬件资源的分配能力,如GPU、InfiniBand网卡和专用硬件加速器等。除了通用的CPU、内存资源之外,本文希望该资源调度系统能对那些专有硬件提供支持,对用户提供统一的分配接口。同时,本文还希望该资源调度系统具备一定的可扩展性,对于未来新的硬件,可以通过编写附加代码或是提供动态链接库的方式对其提供支持。
由于SlurmX所需实现的功能十分繁杂,本文仅抽出其中3个最重要的组件来阐明SlurmX中的架构设计:
(1)SlurmXd。该组件运行在计算节点之上,负责具体计算任务的生命周期管理、任务的I/O输出转发及资源隔离。其中,采用Linux提供的内核基础设施cgroups实现资源控制;采用LibEvent框架提供的事件循环实现各种信号、进程I/O的处理。
(2)SlurmCtlXd。该组件运行在集群的控制节点之上,负责集群节点生命周期的管理、任务队列的调度及管理、节点资源分配和前端SrunX发来的计算任务相关请求处理。
(3)SrunX。该组件运行在用户节点之上,负责处理用户计算任务需求输入,并向SlurmCtlXd节点进行资源请求,转发运行在SlurmXd节点上的软件输出。
各个组件使用远程过程调用RPC(Remote Process Call)在自定义的通信协议上进行通信。图2给出了SlurmX各组件之间交互关系的可视化形式。
Figure 2 Interaction among the components of SlurmX图2 SlurmX各组件之间交互关系
3.2.1 SrunX与SlurmCtlXd之间的通信协议及交互流程
SrunX首先接受用户任务信息输入。其中,任务信息包括所需要分配的CPU核数、内存大小、可执行路径和命令行参数。SrunX将其所需要分配的资源信息打包之后,发送至SlurmCtlXd。SlurmCtlXd返回分配结果,如果分配成功,回复中会包含对应的SlurmXd节点标识符和所分配资源对应的Token(唯一标识符);如果分配失败,会返回失败原因。
3.2.2 SrunX与SlurmXd之间的通信协议及交互流程
当SrunX向SlurmCtlXd请求分配资源成功,取得所分配资源的Token后,SrunX会根据SlurmXd节点标识符连接对应的SlurmXd节点,发送任务信息和所分配资源的Token。
SlurmXd在收到任务信息并检验资源Token的合法性之后,开始尝试运行任务。如果运行失败,SlurmXd向SrunX返回任务失败原因,SrunX向用户显示相关信息,结束运行,同时SlurmXd也结束该任务的处理流程;如果任务运行成功,SlurmXd开始持续向SrunX转发任务的输出信息,直到任务结束。当任务运行结束之后,SlurmXd会收集任务的结束信息,将其返回给SrunX,SrunX在接收到任务结束信息后,向用户显示相关信息,结束运行。
由于转发所运行任务的输出信息需要流式协议,因此在SrunX侧和SlurmXd侧都需要一个实现上述功能的状态机。
3.2.3 SlurmXd与SlurmCtlXd之间的通信协议及交互流程
SlurmCtlXd在确认SrunX请求的资源可分配之后,选择指定的SlurmXd节点分配资源并生成资源Token后,将该Token下发至SlurmXd节点。
由于SlurmXd承担了让该资源Token失效的责任,因此SlurmXd需要监控任务状态。当任务因为任何原因不再运行后,SlurmXd需要通知SlurmCtlXd该Token已经失效。
同时,SlurmXd在启动之后,需要主动向SlurmCtlXd发送注册请求,注册请求中包含SlurmXd节点的可分配资源等信息。SlurmCtlXd在收到来自SlurmXd的注册请求之后,会尝试与SlurmXd建立连接,并通过此链接的连接状态检测SlurmXd节点的健康状态。
本节主要介绍SlurmXd和SlurmCtlXd组件内部的详细设计。由于SrunX本质上是一个简单的客户端,故其内部设计略过不表。
4.1.1 内部各组件概览
SlurmCtlXd内部包含以下组件:
(1)XdNodeKeeper。该组件统一管理所有SlurmXd计算节点的连接情况。
(2)XdNodeMetaContainer。该组件统一维护所有SlurmXd计算节点的元数据信息(如任务列表、资源使用情况等),由于此类信息有着很高的查询和更新频率,在大规模任务调度时,会由于强竞争导致运行效率下降,因此本文专门抽象了一个类,对其进行高并发和查询方面的优化。
(3)gRPC Server。在目前的实现中,本文采用了Google的gRPC[3]框架作为底层的通信框架,同时为了避免与gRPC框架过高的耦合度,本文实现了一个gRPC Server类,将gRPC框架细节封装在该类中。
(4)GarbageCollector。由于C++不是一门带有Garbage Collection(无效内存资源回收)的语言,因此需要专门的类来处理运行过程中临时对象的释放。这个类中含有一个固定线程,通过多线程并发回收的方式降低延迟。
SlurmCtlXd各组件之间的协作情况如图3所示,XdNodeKeeper通过暴露的外部接口,在SlurmXd节点状态更新时,通知XdNodeMetaContainer修改节点数据,gRPC Server代理了所有外部请求的处理,对于增加新SlurmXd节点的请求,gRPC Server将其转发至XdNodeKeeper;对于其他请求中需要查询SlurmXd节点数据的行为,则通过XdNodeMetaContainer查询。XdNodeMetaContainer和XdNodeKeeper都通过GarbageCollector进行垃圾回收。
Figure 3 Component diagram of SlurmCtlXd图3 SlurmCtlXd内部组件图
4.1.2 XdNodeKeeper
作为资源调度系统的中心控制器,SlurmCtlXd最重要的功能之一便是监控作为计算节点的SlurmXd节点的健康状态。在SlurmCtlXd接收到SlurmXd节点注册请求的那一刻开始,SlurmCtlXd便需要向作为通信底层的gRPC框架注册状态监听信息,通过底层的gRPC Channel的状态,监测从发起链接到节点停止服务的SlurmXd全生命周期的状态。
此部分实现繁杂,但是其逻辑上只包含了检测SlurmX节点状态的功能,因此本文参考HTCondor中的Master-Worker[4]模型,结合gRPC做出了如下设计:
SlurmCtlXd作为中心控制器,其关心的信息只有节点是否存活,即ALIVE和DEAD这2种状态。但是,由于节点有可能由于网络波动出现临时性的链接中断,考虑到节点注册的高昂开销,便引入了另外一个状态TRANSIENT_FAILURE描述此种临时性的链接中断。
由于SlurmXd节点可能数量众多(一般以百计算),本文需要对每一个SlurmXd节点进行状态监控,但又不希望为每一个节点都分配一个线程来监听引起状态机变化的事件,这样不仅会引入额外的同步开销,而且不同线程之间同步代码逻辑的编写也要花费大量的时间。因此,本文采用了gRPC Channel提供的异步事件状态监听API:NotifyOnStateChange,从而可以在单线程中完成此项工作。NotifyOnStateChange通过一个用户提供名称为data的可修改参数标记所监听的事件,在该资源调度系统的场景中,使用针对SlurmXd节点的状态设计状态机模型来对SlurmXd节点的状态变化进行抽象,并在这个API中使用一个名称为tag的变量来标记每个SlurmXd节点对应的状态机。通过这个API,本文在一个监听异步事件的线程中,通过所触发事件对应的data参数来区分SlurmXd节点的身份。为了使代码书写具有良好的模块性,根据本文场景,将SlurmXd状态机分为2种类型,一种是正在建立连接的SlurmXd节点,本文将此类节点赋予一个类型tag:InitXd;另一种是已经建立连接的SlurmXd节点,本文将此类节点赋予一个类型tag:EstabXd。同时,还需要另外一个数据结构,用于记录已经建立连接的EstabXd节点的编号以及当其连接临时性失败后进入TRANSIENT_FAILURE状态时的重试次数等信息。因此,本文向NotifyOnStateChange提供的data参数由2部分组成,第1部分是类型tag,第2部分是保存编号和重试次数等信息的数据结构。
gRPC中用来对连接进行抽象的组件Channel的状态(如图4所示)有为IDLE(等待连接)、CONNECTING(正在连接中)、READY(连接已建立,可以在这个Channel上发送RPC请求)和TRANSIENT_FAILURE(连接中断,临时性失败)4个状态,但这并不符合上述3个状态的需求。所以,本文需要针对上述3个状态,编写底层的状态机处理逻辑,利用回调函数的形式转换成上层需要的SlurmXd的状态机。
Figure 4 State machine in XdNodeKeeper图4 XdNodeKeeper状态机
因此,本文针对gRPC Channel状态变化,设计了图4所示的状态机,用来将该gRPC Channel的状态机映射到SlurmXd节点的状态机,使用4个回调函数NodeIsUp()、NodeRecovered()、NodeIsTempDown()和NodeIsDown()来驱动SlurmXd节点状态机的状态变化。在这4个回调函数被调用时,会传递包括该SlurmXd节点编号在内的相关信息给外部XdNodeKeeper的使用者,使其可以区分该状态变化来自哪一个SlurmXd节点。
本文先给出该状态机中各个图形的含义。在图5中描述了状态及状态转移:状态机当前在状态B,前一个状态是A,由A状态到B状态的状态迁移是由Cond事件被触发导致的,该状态机现在执行Action动作。
Figure 5 Notation of the state diagram图5 状态机中图形的表示方法
在描述了状态机的图形含义后,再来阐述该SlurmXd状态机具体的实现细节。当XdNodeKeeper收到SlurmXd节点的注册请求之后,XdNodeKeeper为该SlurmXd节点新建一个状态机。
本文以图4所示的左上角的实心黑圆作为起始状态,其中,在向NotifyOnStateChange提供的data数据中,将Tag类型设置为InitXd,重试次数retry_count置为0,然后进入IDLE状态。进入IDLE状态后,尝试向SlurmXd节点发起连接,此时gRPC Channel进入CONNECTING状态。若SlurmXd节点因为端口仅单向开放等原因连接失败,则该状态机进入TRANSIENT_FAILURE状态,此时SlurmXd状态机将retry_count加1,并尝试重连,再次进入CONNECTING状态,如果重复连接N次(N为用户设置的次数,默认为3)还是失败,则状态机进入结束状态,由于此时Tag类型为InitXd,向注册请求返回失败信息,并释放该状态机相关资源。若SlurmXd节点连接成功,则该SlurmXd进入READY状态,此时为该SlurmXd节点分配节点编号;将其Tag类型设置为EstabXd,表示该节点已建立连接;通知该SlurmXd节点的注册者该节点已经成功注册,建立连接;通过NodeIsUp()回调函数,向外部的XdNodeKeeper使用者(主要是下文的XdNodeMetaContainer)通知一个新的SlurmXd节点已经成功连接;将retry_count重新设置为0。
由上文论述可知,一个Tag类型为EstabXd的SlurmXd节点(即已建立连接节点)的起始状态实际上是READY状态。EstabXd状态下的节点故障的处理逻辑和InitXd状态下的处理逻辑类似,仅仅在回调通知函数上出现了一些变化,这是为了方便上层应用做更详细的处理工作。
通过对gRPC Channel的状态进行有针对性的状态机设计,XdNodeKeeper将底层使用gRPC进行RPC调用和节点连接状态检测的繁杂实现细节成功封装。如图6所示,XdNodeKeeper对外只暴露了用来请求注册新的SlurmXd节点的RegisterXdNode接口和设置前文所述的4个回调函数的接口,并通过这4个回调函数对外汇报节点变化状态。通过极简的接口暴露,XdNodeKeeper成功实现了与SlurmCtlXd其他组件的解耦,避免了在后期底层gRPC通信代码发生更改时造成其他组件大规模的代码改动。
Figure 6 Relationship between XdNodeKeeper and gRPC图6 XdNodeKeeper与gRPC的关系
4.1.3 XdNodeMetaContainer
SlurmCtlXd中需要记录所有SlurmXd节点的信息,这些信息包括:当前节点状态、用来发起SlurmXd的RPC调用的gRPC stub以及该SlurmXd所有的资源信息、分配情况和该SlurmXd上所执行的所有任务信息。
这些信息面临着大量的查询和修改,因此,本文抽象出一个类,用于统一维护和保护这些信息。在SlurmCtlXd中,当SlurmXd节点状态发生变化时,希望可以在最短的时间内对该节点状态进行更新,使新旧信息更新的窗口期尽量缩短,后续到来的任务请求可以最快地按照更新后的节点状态进行资源分配。
但是,由于节点信息繁杂,对这些节点信息进行更改的时间代价较大,如果按照传统的读写锁算法,无论该读写锁是否选择读写者公平实现,当写者取得读写锁的独占权后,所有读者都必须等待写者完成后才可以开始临界区数据的读取,这种高昂的开销在一个高并发系统上是不可接受的。因此,本文需要确保SlurmXd节点数据的更新不会中断大量的节点数据查询。
同时,当任务数量上升至万级别后,这些任务的事件处理会带来短时间内的大量的以万甚至十万计的SlurmXd节点数据查询,考虑到现代CPU在Cache Line需要进行同步时时延差不多在100 ns左右[5],十万级别的对同一互斥锁的竞争就会产生秒级的延迟,因此本文要保证大量并发读安全的同时降低竞争开销。常用的读写锁往往采用2个互斥锁加上几个条件变量实现,其开销在临界区小于毫秒级的时候,开销并不比互斥锁低,因此使用读写锁在此场景下不但不会减小开销,反而会增大开销。
总结上述需求,本文用来维护SlurmXd节点相关数据的组件XdNodeMetaContainer需要满足如下条件:
(1)在SlurmXd节点发生事件更新时,该事件后续对于节点数据的查询能以最快的速度看到更新,不会被SlurmXd节点数据的更新阻塞。
(2)高度并行化的节点数据读取不会导致严重的同步性能开销。
经过对一些现有成熟技术的考察,如bRPC中的DoublyBufferedData[6],在XdNodeMetaContainer中,采用了双缓冲加Thread-Local互斥量的方法。在该组件中,将SlurmXd的节点数据进行“双拷贝”,分为前台数据和后台数据。当该组件进行初始化时,前台数据和后台数据都为空。当一个更新请求到来时,该组件先更新其后台数据。此时,前台数据仍可以被并发的查询请求正常访问,不会被后台的数据更新阻塞,即和后台的数据更新相互独立。
同时,该组件会为所有产生数据读取请求的进程创建一个Thread-Local(线程本地的)互斥量,而前台的所有针对该数据结构的读取请求都会对其所在线程的线程本地互斥量进行加锁。由于该互斥量位于线程本地存储空间内部,所以对该互斥量加锁并不会产生任何竞争。对于一个不存在竞争,即不需要进行CPU的不同核心间Cache Line同步的互斥量,其开销在50个CPU 指令周期左右,即17 ns左右[5],因此,当所有查询线程共用一个互斥量时,在高强度并发访问条件下,每次加锁有100 ns左右的开销[5],采用每线程一个线程本地互斥量的方法,有效地降低了时延,提高了性能。
在后台数据更新完毕之后,该组件使用C++的atomic库封装的CPU原子操作对前后台数据进行转换。在该原子操作后,前台数据为更新后的SlurmXd节点数据,后台数据为还未更新的正在被现有针对该数据结构进行查询的请求所使用的旧SlurmXd节点数据。因此,所有除现有正在进行的对该数据结构进行的查询之外的后续新请求都会看到更新后的数据。唯一会产生阻塞等待的步骤,是在对后台还未更新的旧数据进行更新之前,需要等待还在使用旧数据的所有请求完成。当所有使用旧数据的请求都结束之后,本文在此时对后台的数据副本进行更新操作。更新完成后,前台数据和后台数据即完成了最终的统一,此时,一个对于该数据结构的修改操作才算完成。
由于读取操作不会产生互斥锁的竞争,因此该数据结构在对于读取操作的多线程竞争保护上所花费的开销可以不计。对于修改操作,由于对后台数据可以立即进行修改,并且在修改之后后台数据立刻与前台数据互换,因此,修改操作对于所有并发的查询读取请求的同步要求只在前后台数据切换后,需要所有进行新的读取请求的CPU核心的Cache Line对该切换后的数据进行同步等待操作,无需中断还在对未更新的新后台数据进行查询的请求或者是长时间等待。该数据结构设计的本质是根据该数据结构读请求极多和写请求极少的特点,通过牺牲对数据结构修改的性能,来有效提升对高并发读取请求的性能。
4.1.4 GarbageCollector
从前述的2个组件XdNodeKeeper和XdNodeMetaContainer可以看到,这2个组件需要负责对SlurmXd状态机和SlurmXd节点相关信息的分配和清理。为了保障用来记录这些信息的内存不会发生泄漏,在该资源调度系统的实现中,采用了C++标准库中的共享指针shared_ptr[7]来对这些分配的资源进行引用计数。当XdNodeKeeper、XdNodeMetaContainer和所有使用这些资源的请求都对某个资源进行了共享指针释放之后,该资源的引用计数将下降为0,此时将释放这些资源。但是,这些资源包含的信息十分繁杂,对于这些资源的分配和释放清理都需要一定的时间开销。考虑到所有SlurmXd节点的状态机都在XdNodeKeeper的一个专有线程中进行维护,因此,本文不再处理该资源分配和释放的工作,否则会影响到SlurmXd节点状态信息更新的时效性。
通过GarbageCollector将所有资源清理的开销全部从时延敏感的执行路径中转移了,这样提高了整体的响应性能和任务吞吐量。
本节主要介绍SlurmXd中资源控制组件CgroupManager、任务管理组件TaskManager及设备抽象类Devices和设备资源分配器DevicesAllocator的实现。
4.2.1 CgroupManager
除了调度任务的执行顺序,资源调度系统最重要的一个作用是限制计算任务资源的使用,即任务使用的资源不能超过某一个系统或用户限定的数值。
Linux 2.6.24引入了一个很重要的特性cgroups(control groups)[8]。cgroups是Linux内核中的一个组件,通过该组件可以从操作系统的层面对应用施加资源使用限制。
Linux的cgroups有2个版本:v1和v2。CgroupManager目前采用v1版本实现,v1版本的cgroups的高层结构如图7所示。
Figure 7 Structure of cgroups图7 cgroups架构
cgroups为系统中许多可控制的资源,如CPU、Memory,提供控制器,控制器还可以派生出多个子控制器(在图7中为不同的cgroups hierarchy)。在Linux中,每个进程若是使用cgroups,在进程控制块中有一个数据结构css_set[9]记录该进程服从于哪些子系统控制器的管理。其中,多个进程可以和相同的css_set绑定,从而达成管理一个进程组的目的。
由于cgroups具备很高的配置灵活性,本文需要自己制定一些统一规则,将底层灵活的组件配置固化成一套可供上层组件便捷使用的抽象接口,以方便高层组件实现各种逻辑。
Figure 8 Abstraction model of cgroups图8 cgroups的抽象模型
因此,本文对于CgroupManager制定了如下的规则和限制:CgroupManager组件在初始化时会在所有指定需要使用的控制器系统(如CPU、Memory)的对应根控制器下创建一个CgroupManager使用的专有层级,同时约定在所有子系统的该专有层级下子目录结构是相同的。因此,本文实际上利用cgroups抽象出了一个具备不同层级结构,但是每个层级控制器相同的一个控制器系统层级结构。本文将这个抽象系统的根层级命名为CgroupManager Root Hierarchy。考虑该资源调度系统的实际场景,每个用户会提交一个计算任务,同时用户或者系统会对该任务施加资源限制条件。注意到这些计算任务之间实际为平级关系,因此,CgroupManager Root Hierarchy下只会具备多个平级的子层级,即不会具备子子层级,本文将这些子层级的单个实体命名为CgroupManager Leaf Hierarchy。
由上文所述可知,CgroupManager Leaf Hierarchy实际上是单个计算任务通过cgroups实现的一系列资源限制。图8给出了cgroups的抽象模型。
cgroups不仅提供了操作系统内核层面对系统资源的使用限制,也提供了对资源使用情况的查询能力。由于监控任务的各种资源使用情况也是该资源调度系统的重要功能,CgroupManager将cgroups底层资源的使用情况查询接口进行了一定程度的封装,对外提供了对资源的查询功能。图9描述了CgroupManager的类设计。通过该类,可以自由地对任务所使用的资源进行限制,例如将CPU限制在3个核心或是在任务所使用内存资源超过限制时由Linux的OOM Killer[10]将其终止。
Figure 9 CgroupManager class图9 CgroupManager类
4.2.2 TaskManager
TaskManager是SlurmXd中承担任务运行、任务管理和任务I/O重定向等功能的组件,是SlurmXd的核心组件。下面主要介绍该组件的设计和原理:
从上文的描述可以看出,TaskManager在运行时需要处理许多事件。这些事件包括但不限于:
(1)添加新任务的请求。
(2)转发来自前端SrunX的中断信号或输入到正在SlurmXd节点上运行的任务中。
(3)将正在运行的任务产生的输出转发到前端SrunX。
(4)处理来自SlurmXd所在节点的结束信号SIGINT或SIGRTERM以及负责计算任务结束时的回收工作。
TaskManager在处理大量事件的同时,还需特别注意保护在TaskManager中维护的所有任务信息在可能的多线程执行环境中不会因为并发修改和访问导致数据损毁,这就要求本文通过互斥量对相关的数据结构进行保护。
总结上文,有2个需求:一是可以便捷地处理大量事件;二是在处理大量事件的同时要注意对相关数据结构的并发保护。采用事件驱动模型可以处理上述需求。在事件驱动模型中,本文先将要监听的事件添加到一个监听列表,通过启动一个专有线程,不断地对这些事件进行轮询,检查事件是否已经发生,如果某个事件发生,则执行该事件对应的处理逻辑。同时,如果在某次轮询中,多个事件同时发生,则在该线程中顺序执行这些事件的处理逻辑。采用事件驱动模型的一个好处是,由于所有事件的处理逻辑实际上是串行执行的,因此不会存在多线程执行场景下需要实现很复杂同步逻辑的弊端。事件驱动模型的主要弊端在于,只有一个工作线程却需要处理所有事件逻辑。针对本文需求,使用了LibEvent库作为底层的事件通知库。图10描述了LibEvent的内部线程模型。
Figure 10 Internal threading model of LibEvent图10 LibEvent内部线程模型
明确了所使用的执行模型后,接下来介绍TaskManager的具体执行流程。在TaskManager初始化时,先利用LibEvent提供的注册事件API注册如下所述的事件及其对应的处理逻辑:
(1)SIGINT信号事件。该信号由用户输入,当用户在键盘上按下Ctrl+C或由其他软件发送该信号时,表明用户希望SlurmXd关闭。此时将不再接收任何新的任务请求,并向所有正在执行的任务下属的进程组发送SIGINT信号,等待所有任务结束回收并执行相应的清理工作之后再退出。
(2)SIGCHLD信号事件。当一个计算任务结束之后,由于SlurmXd进程为计算任务中实际执行进程的父进程,SlurmXd会收到一个SIGCHLD信号,表明有计算任务已经结束。SlurmXd收到信号之后会解析程序的返回值并统计程序相关信息后将其统一返回给用户前端执行程序SrunX,并由SrunX显示这些信息,表明程序已经结束。
(3)创建新任务请求事件。当用户通过SrunX发送任务信息和资源Token时,gRPC框架中对应的RPC请求处理逻辑会将该请求封装成一个包含任务信息、输出回调函数、任务结束回调函数和异步通知类的新任务请求结构体。本文规定由SrunX提交的任务信息必须包含如下信息:
①任务名称;
②任务可执行路径;
③任务参数;
④cgroups资源限制信息。
同时,为了达成转发任务输出的效果以及在任务结束时可以通知SrunX的目标,本文在新任务请求结构体中添加了输出回调函数和任务结束回调函数。这2个回调函数可以由调用TaskManager中添加新任务接口的调用方设置,这样设计的目的是为了保持和任务管理无关的逻辑与TaskManager解耦,即将涉及gRPC部分的代码实现排除在TaskManager的代码实现之外,从而保持代码的可维护性。同时,SrunX也有可能以流的形式传递多种控制信息,因此本文在SrunX侧和SlurmXd侧都针对该逻辑实现了状态机。
在实现中还有一个难点是使用TaskManager功能的类无法直接以同步方式调用LibEvent中的任务执行逻辑,这部分逻辑只有当事件触发时才会被执行,因此,为了在同步函数调用中能以异步方式获取新任务的添加结果,TaskManager采用了C++标准库中的std::future和std::promise[11]来提供异步通知新任务添加结果的功能,并通过跨线程单生产者单消费者队列进行新任务添加请求的传递,通过Linux提供eventfd来完成对新任务请求事件在LibEvent中的触发。
(4)任务输出事件。注意到该事件并未在TaskManager初始化时进行注册,这是因为每个任务输出事件必须和一个计算任务绑定,因此它是一种只有在生成新计算任务时才会注册的动态事件。当计算任务有输出产生时,该事件处理逻辑会调用与该计算任务所绑定的输出回调函数,从而进入上文中所述的逻辑,将输出转发至SrunX。
基于上文所述的内容,SlurmXd中核心组件TaskManager的功能得以实现。
4.2.3 Devices类、DevicesAllocator类和专有设备的适配
该资源调度系统需要对各类资源进行分配,这些资源包括所有机器通用的资源,如CPU、内存和外置的专有硬件(如不同型号的显卡和不同种类的网卡)。由于在不同的使用场景下设备种类是截然不同的,因此在实际应用场景中,很可能会出现场景变更一次,硬件变更一次的情况。而显然该资源调度系统的使用者希望该系统能适配多种类型的硬件,以避免每当更换平台时为适配新硬件对该资源调度系统的主体代码进行大规模更改。
因此,在进行此部分功能的模块设计时,需要考虑上文所述的使用者需求。当一个计算任务开始执行的时候,分配给该任务的各种硬件资源需要进行初始化。例如,通过上文设计的CgroupManager对CPU和内存资源进行限制,对通过PCIe接入的专有设备进行权限设置。当一个计算任务结束时,可能有相关设备资源需要清理或者重置,例如对GPU的功耗解除限制。同时,不同类型硬件的资源分配逻辑可能也不同,例如CPU或内存属于可以进行细粒度分配的资源类型,而PCIe设备则不支持细粒度分配,因此二者需要根据不同的逻辑进行分配。
因此,本文参考Adapter设计模式[12]将设备资源抽象为Devices类,该类提供的主要方法有Prepare和Cleanup。Devices类表示统一类型的一个资源或者多个资源归属于一个计算任务。当其归属的计算任务执行前,会调用该计算任务所拥有的Devices类的Prepare方法,硬件资源相关的初始化代码在该方法中。当其归属的计算任务结束后,会调用该计算任务所有拥有的Devices类的Cleanup方法,硬件资源相关的清理和释放代码在该方法中。
每一个Devices类对应一个DevicesAllocator类,该DevicesAllocator类提供的主要方法有Init、AllocateDevices和FreeDevices,其中,Init提供某个Devices类对应的专有硬件的初始化统计;AllocateDevices负责分配指定数量和特定子类型的某类型设备,返回对应的Devices类示例;FreeDevices负责释放AllocateDevices所分配的资源。所有DevicesAllocator类的Init方法会在SlurmXd进行初始化时被调用,AllocateDevices在创建单个新的计算任务请求资源时被调用,FreeDevices在计算任务结束后会被调用。
Figure 11 Devices class and DevicesAllocator class图11 Devices类和DevicesAllocator类
本文基于Slurm任务调度系统,通过引进面向对象的设计方法,对Slurm中的部分重要功能进行了抽象和模块化,并对Slurm中原有的架构进行了重新设计。本文将基于上述工作设计及实现的任务调度系统命名为SlurmX。相对于Slurm原有的过程化实现,SlurmX在保障高性能的情况下,有效降低了代码的复杂度和耦合度,提升了模块化程度,在工程方面降低了后续维护和添加自定义功能的开发成本。同时,由于Slurm是一个已经历经约二十年沉淀的项目,其功能繁复纷杂,SlurmX目前仅对Slurm中最为基础和重要的功能进行了重构,仍有许多功能还有待重构和设计。目前,SlurmX整体系统还未定型,当整体架构定型、实现完善及详细测试后将会开源,以方便感兴趣的科研机构进行验证和推广。