背景
vivo AI 计算平台是在 2018 年底开始着手建设的,致力于解决统一高性能训练环境、大规模分布式训练、计算资源的高效利用调度等痛点。经过将近四年的持续迭代,平台建设和落地取得了很大进展,由当初服务深度学习为主,到现在演进成包含 VTraining、VServing、VContainer 三大模块,对外提供模型训练、模型推理和容器化等能力,成为 vivo AI 领域的核心基础平台。VTraining 是一站式的通用训练平台,基于 Kubernetes 集群搭建,支持多种框架的大规模分布式训练,并配备 PB 级别规模的分布式存储。现在 VTraining 的用户已经超过 700 人,覆盖了推荐,语音,视觉,NLP,影像等方向核心业务。VTraining 模型训练需要处理数百万亿字节的数据,但通常是音频和图像文件等海量小文件。模型训练需要多次运行 epoch 来进行迭代,因此会频繁地访问数据。此外, 还需要通过不断向 GPU 供给数据来让 GPU 处于忙碌状态。既要优化 I/O 又要保持 GPU 所需的吞吐量并非易事。本文将分享轩辕文件存储缓存的设计原理以及在 Vtraining 平台的性能加速应用。
轩辕文件存储系统
用户态文件系统框架
文件系统提供了通用的应用程序的访问数据的接口,一般分为两种实现,一种是内核在用户态实现了文件系统;另外一种是内核在自己的内核态实现了文件系统,这也是内核的一部分,在内核态实现这个文件系统避免了消息在用户态和内核态之间的切换,具备比较高的性能。但随着内核态文件系统(xfs/ext4)的复杂性的增加,在用户态开发文件系统变得比较流行,同时在用户态开发比较容易维护,即使 crash 了也不会导致 kernel crash。如果是基于内核态文件系统 xfs crash 了,整个 kernel 就会 crash。大部分用户态文件系统开发都是基于 Fuse。Fuse 有两部分组成:fuse 驱动和用户态的 daemon.fuse。驱动是由内核的 fuse 设备驱动(/dev/fuse)这个字符设备驱动充当代理,针对不同的文件系统实现提供 kernel 和用户态 daemon 的通信桥梁;用户态 daemon 是从/dev/fuse 设备读取,然后处理这些请求,最后把处理的就结果写回到/dev/fuse 设备。轩辕文件系统也是基于 Fuse 进行开发的用户态文件系统。
fuse 实现用户态文件系统的框架
轩辕文件存储系统
轩辕文件存储是 vivo 互联网基础平台存储团队开发给内部使用的存储系统,是一款面向云原生设计的高性能文件系统。它采用了”数据“与”元数据“分离的存储架构,从而实现文件系统的分布式设计。主要的核心架构如下:
轩辕文件存储架构
主要包括三个特性组件:
客户端 vivo_juicefs
vivo_juicefs 是基于开源社区 juicefs 开发的存储客户端,提供了丰富的 API,兼容了当前大多数主流应用平台,可以在最上层无缝对接大数据,机器学习,人工智能等平台,为其提供海量、弹性、低价的高性能存储。在数据存储和元数据引擎上采用了可插拔架构,能对接各种各样的对象存储,用户可以根据自己需求选择合适的底层存储。最主要 vivo_juicefs 的缓存策略也十分灵活,很大程度上能解决存储性能不足的问题,本文着重讲解客户端 vivo_juicefs 的缓存功能。
元数据引擎
由于 vivo_juicefs 元数据引擎可插拔特性,可以使用户会选择性能较好的元数据引擎来加速元数据访问,比如 redis,Tikv 等高性能数据库。轩辕文件存储的元数据引擎是 vivo 公司自研的一款具备高性能,高稳定性,多数据模型等特性的分布式磁盘 KV 数据库,虽然达不到 redis 那么高的性能,但是会大大减少成本压力,并针对 AI 特征业务做更多的应用场景适配。
数据存储引擎
数据存储引擎使用的是 vivo 公司自研的轩辕对象存储。轩辕对象存储提供了海量,安全,低成本,高性能,高可靠的存储服务解决方案。目前提供了多种语言 SDK,能使开发者快速接入存储集群,也能更好对接 vivo_juicefs 客户端。
为什么需要缓存
当前计算平台基本都是存储计算分离框架,一般采用分布式存储集群来存储模型所需要的海量数据,我们先考虑没有缓存的情况,如下:
它会存在以下问题:
首先,从 I/O 和工作流的角度来看,数据是串行处理的,所有的数据访问操作都必须通过分布式存储和训练集群之间的网络,由于 I/O 操作的吞吐量受限于网络速度,GPU 会出现空转等待的情况,这使得 I/O 成为性能瓶颈。
其次,当训练规模较大时,所有训练节点需要同时访问远端分布式存储 server,会对存储系统造成巨大的负载压力。此时由于高并发访问,分布式存储 server 很可能会出现拥塞,请求延时过高,进而导致任务训练的低下。
最后,为了节省成本,远端分布式存储介质一般采用比较便宜的 HDD,这在降低成本的同时,无疑增加了 IO 的开销,特别是在海量小文件场景,性能瓶颈就更加突出。但是采用较多高性能磁盘(比如 nvme),成本又难以控制。
那在 Vtraining 平台中,轩辕文件系统是怎么解决性能问题的呢?答案数缓存。对于一个由对象存储和数据库组合驱动的文件系统,缓存是本地客户端与远端服务之间高效交互的重要纽带。读写的数据可以提前或者异步载入缓存,再由客户端在后台与远端服务交互执行异步上传或预取数据。相比直接与远端服务交互,采用缓存技术可以大大降低存储操作的延时并提高数据吞吐量, 提升存储性能。
轩辕文件系统缓存
轩辕文件系统客户端 vivo_juicefs 实现了多种缓存机制来降低访问的时延和提高吞吐量,包括内存预读缓冲区,本地磁盘缓存以及分布式缓存。
内存预读缓冲区
vivo_juicefs 客户端在读数据的时候是先把数据读到内存读缓冲区,然后返回给业务程序。其内部实现高效的预读算法,其下次读取数据有一定的概率在内存读缓冲区命中,从而提高访问性能。vivo_juicefs 的启动参数--buffer-size 控制着读缓冲区的的大小,进而决定了读取文件以及预读的内存数据量。因此在面对高并发读场景的时候,可以适当的对--buffer-size 进行扩容,能有效提升性能。
本地读缓存
vivo_juicefs 客户端会根据应用读数据的模式,自动做预读和缓存操作以提高顺序读的性能。数据会缓存到本地文件系统中,可以是基于硬盘、SSD 或者内存的任意本地文件系统。如果希望保证应用程序首次访问数据的时候就能获得已缓存的性能,vivo_juicefs 也提供命令对缓存数据进行预热。注意本地读缓存最好选用高性能磁盘(比如 nvme),特别是在 IO 密集型的场景,因为本地缓存是除了内存数据访问的第一层,它能减少网络带来的延时开销,但如果磁盘的性能不高,并且缓存命中率较高的话会造成磁盘负载过高,存储性能反而会下降。
分布式缓存集群
本地读缓存时延最小,能提供较好的存储性能,但它存在以下限制:
各节点缓存磁盘容量有限,存储的缓存数据是有限的,当节点上的训练任务所需的数据多于缓存磁盘容量,是必然会出现缓存 miss 的,极端情况下会造成节点缓存失效。
相同的训练数据可能会被不同的训练任务共享, 这些任务很有可能被调度到不同节点上,这样会造成多个节点的缓存里面有同一份缓存数据,对于集群来说缓存资源利用率不高,缓存的有效数据变少,也会加剧缓存失效。
很多时候需要通过数据集提前预热,以提升第一次访问数据时的性能。本地缓存这种方式就对预热不是很友好。首先你不知道训练任务调度会调度到哪个节点(如果没有指定节点调度),你就不知道该在哪个节点上预热;其次,任务下次训练换了节点,又得再一次预热。
可以利用分布式缓存集群解决上述问题,使用分布式缓存集群有以下好处:
分布式缓存集群相当于把集群中节点的磁盘组成一块巨大的磁盘,并且同样的数据在集群中只要缓存一份,就能给不同节点的任务使用,完美解决了缓存磁盘不足和有效数据低的问题。
对于预热数据加速来说也非常友好,只要在独立缓存集群任意节点进行预热,就能把数据预热到集群中,并且任务无论调度到哪个节点都无需第二次预热。
分布式集群能使用访问数据的 IO 压力分散到各个节点,不会出现像本地缓存单节点瓶颈性能问题。
当然相对来说分布式缓存的集群的直接性能要比本地性能稍差一点,因为有网络开销,但是它能大大提高缓存的命中率进而提高存储的性能。另外,由于缓存数据能被共享 ,同样数据量的缓存成本只有本地缓存的 1/n(n 为节点数)。在实际使用中,分布式缓存应该使用比远端存储更好的性能磁盘从而才能达到性能提升的效果(比如使用 ssd 和 nvme,比 HDD 高一个量级)。
下面我们来说一下分布式缓存集群的设计原理:
轩辕文件系统的分布式缓存集群实在客户端 vivo_juicefs 实现的。我们可以通过参数--cache-group 设置 vivo_juicefs 的缓存组。同一个局域网内相同缓存组的客户端,它们会把监听在内网 IP 的随机端口汇报给元数据服务,进而发现其它客户端,并通过内网通信共享缓存。每隔一段时间(可以通过参数设置),vivo_juicefs 会向元数据服务发起请求,获取最新的缓存组信息,从而调整缓存组节点的信息。
如图所示,缓存组内的客户端会组成一个一致性哈希环。每个数据块都会根据一致性哈希计算出负责其存储的成员节点,当 vivo_juicefs 发起查询该数据的时候,会从该节点上获取数据(如果是本节点直接获取,如果是其他节点则通过 rpc),如果数据尚未缓存在节点上,直接从远程存储集群读取并缓存在本地。缓存组内如果发生了成员节点增删,数据会向哈希环的临近节点做迁移(为了防止波动,实际会等待成员变更后约 5 分钟,方执行迁移操作)。可见缓存组的成员变更,只影响到少量数据块的缓存命中率。在缓存组一致性哈希环的实现中,也采用了虚拟节点(virtual node)的概念,确保数据分布均衡,避免因数据迁移产生访问热点,影响缓存组性能。
在缓存组的中,如果每个 vivo_juicefs 客户端都是参与分布式缓存集群的建设。如果遇到 vivo_juicefs 不是常驻的情况下,特别是 VTraining 平台下训练任务的 vivo_juicefs 客户端会不断销毁、重建,会让哈希环极其不稳定,缓存数据不断迁移,从而导致缓存集群利用率低。类似这种动态创建伸缩的计算集群, 我们可以创建独立的分布式缓存集群来解决上述问题。在同一个缓存中,可以存在两种不同角色的节点。当 vivo_juicefs 挂载参数中有--no-sharing 参数时,如同字面意思,这个节点虽然属于同一个缓存组,但却不分享自己的缓存数据,不会参与缓存哈希环的建设,只会向缓存集群索取数据。另外一种即不带参数--no-sharing 的节点则组成一个独立的分布式缓存集群,提供缓存数据给同一缓存组的 no-sharing 节点使用。这样动态创建伸缩的计算集群可以使用带--no-sharing 的 vivo_juicefs 客户端,然后专门准备固定数量的机器挂载 vivo_juicefs(不带--no-sharing)组成分布式缓存集群,由于计算集群的节点不参与缓存的建设,也就不会导致哈希缓存的组的成员变迁而影响整体缓存集群的效率,如图设计:
缓存数据一致性
轩辕文件存储只支持 close-to-open 一致性保证,即当两个及以上客户端同时读写相同的文件时,客户端 A 的修改在客户端 B 不一定能立即看到。但是,一旦这个文件在客户端 A 写入完成并关闭,之后在任何一个客户端重新打开该文件都可以保证能访问到最新写入的数据,不论是否在同一个节点。
我们在 close-to-open 一致性得基础上来讨论一下缓存数据是怎么和远端存储数据保持一致的。轩辕文件系统当前只支持缓存数据,每次访问数据之前,首先得到该数据得元数据,然后通过元数据先确定缓存中有没有该数据,有的话从缓存中读取,没有则从远端存储读取。我们这里用一个实例来说明。假设 damon.txt 文件在某一时刻是由对象 A、B、C 组成,这个信息记录在元数据中。我们要获取 damon.txt 文件,首先获取元数据知道它是由对象 A、B、C 组成,然后查缓存中有没有 A、B、C 对象缓存文件(对象映射到缓存系统是文件,文件名对应对象名,文件数据对应对象数据,一个对象名在挂载的文件系统中全局唯一),有直接读取缓存文件返回,没有从远端存储访问。由于轩辕对象的元数据服务是支持事务的,所以对于访问之前的修改缓存都是可见的,因为元数据服务已经记录,符合 close-to-open 一致性。所以缓存数据的一致性是通过元数据服务提供的事务来保证的。
轩辕存储系统缓存的应用
在 VTraining 训练平台中,如图所示:
训练任务的客户端配置了内存缓存,本地缓存,并且和高性能的分布式缓存集群的客户端都隶属于一个缓存组,但是训练任务的客户端带--no-sharing 参数不参与分布式缓存的建设。这样训练任务对应有 3 级缓存:计算节点的内存缓存,计算节点的磁盘缓存和缓存组的分布式缓存,可以根据具体应用的访问特点配置各个层级的缓存介质和空间大小。
本地缓存和分布式缓存集群存应该尽量存储热数据,这样可以用最少的磁盘成本达到较高的缓存命中率。在 VTraining 平台中每个物理缓存节点都有一个叫 cache_service 的服务对缓存数据进行管理,它内部实现了一个高效的缓存淘汰策略,在磁盘容量不足的情况下能根据数据的冷热程度智能的淘汰数据,最终达成缓存数据都是热数据的目的。当前在 VTrianing 平台中,分布式缓存集群与远端存储的容量比大概为 1:50,但是缓存的命中率超过 90%,大大提高了访问性能,进而也提高了 Vtraining 训练集群 GPU 的利用率。在缓存策略全面上线后,各个业务的用户都反馈良好,训练效率都有显著的提升,下图展示了图像训练任务缓存使用前后效率的对比:
致谢
感谢杭州果汁数据科技 JuiceFS 团队的苏锐、davies、朱唯唯等和 vivo 互联网基础平台存储团队的肖博、龚兵、于相洋、韩姜、储敏等对轩辕文件存储在 Vtraining 平台设计和落地过程中的大力支持。
作者介绍:
彭毅格,vivo AI 研究院计算平台组的资深工程师,曾就职于华为、深信服等公司;关注 K8s、容器、存储等领域。
评论