导读:Alluxio 项目诞生于 UC Berkeley AMP 实验室,自开源以来经过 7 年的不断开发迭代,支撑大数据处理场景的数据统一管理和高效缓存功能日趋成熟。然而,随着云原生人工智能(Cloud Native AI)的兴起,灵活的计算存储分离架构大行其道。在此背景下,用户在云上训练大规模深度学习模型引发的数据缓存需求日益旺盛。为此,阿里云容器服务团队与 Alluxio 开源社区和南京大学顾荣老师等人通力合作寻找相关解决方案,当前已经提供 K8s 上运行模型训练数据加速的基础方案,包括容器化部署、生命周期管理以及性能优化(持续中),从而降低数据访问高成本和复杂度,进一步助力云上普惠 AI 模型训练。
1. 背景介绍
近些年,以深度学习为代表的人工智能技术取得了飞速的发展,正落地应用于各行各业。随着深度学习的广泛应用,众多领域产生了大量强烈的高效便捷训练人工智能模型方面的需求。另外,在云计算时代,以 Docker、Kubernetes 以主的容器及其编排技术在应用服务自动化部署的软件开发运维浪潮中取得了长足的发展。Kubernetes 社区对于 GPU 等加速计算设备资源的支持方兴未艾。鉴于云环境在计算成本和规模扩展方面的优势,以及容器化在高效部署和敏捷迭代方面的长处,基于“容器化弹性基础架构+云平台 GPU 实例”进行分布式深度学习模型训练成为了业界生成 AI 模型
的主要趋势。
为了兼顾资源扩展的灵活性,云应用大多采用计算和存储分离的基本架构。其中,对象存储因为能够有效地降低存储成本、提升扩展弹性,经常用来存储管理海量训练数据。除了采用单一云上存储之外,很多云平台的用户因为安全合规、数据主权或者遗产架构方面的因素,大量数据还存储在私有数据中心。这些用户希望基于混合云的方式构建人工智能训练平台,利用云平台的弹性计算能力满足高速增长的 AI 业务模型训练方面的需求,然而这种“本地存储+云上训练”的训练模式加剧了计算存储分离架构带来的远程数据访问的性能影响。计算存储分离的基本架构虽然可以为计算资源和存储资源的配置和扩展带来更高的灵活性,但是从数据访问效率的角度来看,由于受限于网络传输带宽,用户不经调优简单使用这种架构通常会遇到模型训练性能下降的问题。
2. 常规方案面临的数据访问挑战
目前云上深度学习模型训练的常规方案主要采用手动方式进行数据准备,具体是将数据复制并分发到云上单机高效存储(例如,NVMe SSD)或分布式高性能存储(例如 GlusterFS 并行文件系统)上。这种由用户手工或者脚本完成的数据准备过程通常面临如下三个问题:
数据同步管理成本高:数据的不断更新需要从底层存储定期进行数据同步,这个过程管理成本较高;
云存储成本开销更多:需要为云上单机存储或高性能分布式存储支付额外费用;
大规模扩展更加复杂:随着数据量增长,难以将全部数据复制到云上单机存储;即使复制到 GlusterFS 这样的海量并行文件系统也会花费大量的时间。
基于容器和数据编排的模型训练架构方案
针对云上深度学习训练常规方案存在的上述问题,我们设计并实现了一种基于容器和数据编排技术的模型训练架构方案。具体系统架构如图 1 所示:

Kubernetes:是一种流行的深度神经网络训练容器集群管理平台,它提供了通过容器使用不同机器学习框架的灵活性以及按需扩展的敏捷性。阿里云容器服务 ACK(Alibaba Cloud Kubernetes)是阿里云提供的 Kubernetes 服务,可以在阿里云平台的 CPU、GPU、NPU(含光 800 芯片)、神龙裸金属实例上运行 Kubernetes 工作负载;
Kubeflow:是开源的基于 Kubernetes 云原生 AI 平台,用于开发、编排、部署和运行可扩展的便携式机器学习工作负载。Kubeflow 支持两种 TensorFlow 框架分布式训练,分别是参数服务器模式和 AllReduce 模式。基于阿里云容器服务团队开发的 Arena,用户可以提交这两种类型的分布式训练框架;
Alluxio:是面向混合云环境的开源数据编排与存储系统。通过在存储系统和计算框架之间增加一层数据抽象层,提供统一的挂载命名空间、层次化缓存和多种数据访问接口,可以支持大规模数据在各种复杂环境(私有云集群、混合云、公有云)中的数据高效访问。

我们使用 ResNet-50 模型与 ImageNet 数据集,数据集大小 144GB,数据以 TFRecord 格式存储,每个 TFRecord 大小约 130MB。每个 GPU 的 batch_size 设置为 256;
模型训练硬件选择的是 4 台 V100(高配 GPU 机型),一共 32 块 GPU 卡;
数据存储在阿里云对象存储服务中,模型训练程序通过 Alluxio 读取数据,并在读取过程中将数据自动缓存到 Alluxio 系统。Alluxio 缓存层级配置为内存,每台机器提供 40GB 内存作为内存存储,总的分布式缓存量为 160GB,没有使用预先加载策略。



Alluxio 文件操作引入多次 RPC 交互,在训练场景下引入性能开销。
Alluxio 不只是一个单纯的缓存服务。它首先是一个分布式虚拟文件系统,包含完整的元数据管理、块数据管理、UFS 管理(UFS 是底层文件系统的简称)以及健康检查机制,尤其是它的元数据管理实现比很多底层文件系统更加强大。这些功能是 Alluxio 的优点和特色,但也意味着使用分布式系统带来的开销。例如,在默认设置下使用 Alluxio 客户端来读一个文件,即便数据已经缓存在本地的 Alluxio Worker 中,客户端也会和 Master 节点有多次 RPC 交互来获取文件元信息以保证数据的一致性。完成整个读操作的链路额外开销在传统大数据场景下并不明显,但是深度面对学习场景下高吞吐和低延时的需求就显得捉襟见肘了。
Alluxio 的数据缓存和驱逐策略会频繁触发节点数据缓存震荡。
深度学习场景数据冷热经常不明显,因此每个 Alluxio Worker 都会完整读取数据。而 Alluxio 默认模式会优先数据本地读取,即使数据已经保存在 Alluxio 集群中,也会从其他缓存节点拉取到本地存一份副本。这个特性在我们的场景下会带来两个额外开销:
1.异步数据缓存的额外开销; 2.本地空间不足会触发自动数据驱逐的开销,特别当节点缓存数据接近饱和的情况下性能开销巨大。
基于 FUSE 进行文件系统的开发、部署、使用都很简单,但是默认性能并不理想,原因如下:
1.FUSE 读操作效率不高,每次 read 最多只能读 128KB,读一个 128MB 的文件需要 1000 次调用 read; 2.FUSE 读操作属于非阻塞行为,由 libfuse 非阻塞线程池处理,一旦并发请求数量远超过线程池 (max_idle_threads) 的大小,就会触发频繁的大量线程创建和删除,从而影响读性能。而在 FUSE 中,这个默认配置是 10; 3.元数据的频繁访问,因为 FUSE 内核模块是个桥梁角色,连接了应用程序和 Alluxio 的文件系统,而每一次读获取文件/目录的 inode 以及 dentry,FUSE 内核模块都会到 Alluxio 系统运行一趟,增加了系统压力。
Alluxio 和 FUSE 的集成(下文简称为 AlluxioFUSE)在深度学习中常见的多线程高并发场景下性能有待优化,甚至需要深度定制:
1.Alluxio 目前仅支持在 FUSE 中使用 direct_io 模式,而不能使用 kernel_cache 模式来借助 page cache 进一步提高 I/O 效率。这是因为 Alluxio 当前设计要求在多线程场景下,每个线程都必须使用自己的文件输入句柄(FileInputStream)。而如果打开 page cache,当前的 AlluxioFUSE 会有些并发预先读到 cache 的操作,从而产生报错; 2.据从被 Alluxio 客户端读入后,到进入 FUSE 要经历多次拷贝。这些额外的拷贝通常是由于 AlluxioFUSE 使用到的第三方 Java 库 API 限制; 3.AlluxioFUSE 实现中使用到的第三方库 JNRFuse 只能适配较低版本的 FUSE,并且在高并发场景下有较大的性能负担。 Kubernetes 对于 Alluxio 的线程池影响。
Alluxio 基于 Java 1.8 版本实现,其中的一些线程池的计算会依赖于
Runtime.getRuntime().availableProcessors(),但是在 Kubernetes 环境下,默认配置中 cpu_shares 的值为 2,而 JVM 对于 cpu 的核心数的计算公式 cpu_shares()/1024,导致结果是 1。这会影响 java 进程在容器内的并发能力。
寻找资源限制,包括线程池以及 JVM 在容器中的配置;
借助各级缓存,包括 FUSE 层和 Alluxio 元数据缓存;
避免额外开销,减少非必须的调用链路。比如避免不必要的元数据交互,引入上下文切换的 GC 线程和 compiler 进程;以及 Alluxio 内部的一些可以简化的操作。
alluxio.user.ufs.block.read.location.policy 默认值为
alluxio.client.block.policy.LocalFirstPolicy, 这表示 Alluxio 会不断将数据保存到 Alluxio 客户端所在的本地节点,就会引发其缓存数据接近饱和时,该节点的缓存一直处于抖动状态,引发吞吐和延时极大的下降,同时对于 Master 节点的压力也非常大。因此需要 location.policy 设置为 alluxio.client.block.policy.LocalFirstAvoidEvictionPolicy 的同时,指定 alluxio.user.block.avoid.eviction.policy.reserved.size.bytes 参数,这个参数决定了当本地节点的缓存数据量达到一定的程度后,预留一些数据量来保证本地缓存不会被驱逐。通常这个参数应该要大于 节点缓存上限 X (100% - 节点驱逐上限的百分比) 。
alluxio.user.file.passive.cache.enabled 设置是否在 Alluxi 的本地节点中缓存额外的数据副本。这个属性是默认开启的。因此,在 Alluxio 客户端请求数据时,它所在的节点会缓存已经在其他 Worker 节点上存在的数据。可以将该属性设为 false,避免不必要的本地缓存。
alluxio.user.file.readtype.default 默认值为 CACHE_PROMOTE。这个配置会有两个潜在问题,首先是可能引发数据在同一个节点不同缓存层次之间的不断移动,其次是对数据块的大多数操作都需要加锁,而 Alluxio 源代码中加锁操作的实现不少地方还比较重量级,大量的加锁和解锁操作在并发较高时会带来不小的开销,即便数据没有迁移还是会引入额外开销。因此可以将其设置为 CACHE 以避免 moveBlock 操作带来的加锁开销,替换默认的 CACHE_PROMOTE。
将 alluxio.user.metadata.cache.enabled 设置为 true, 可以在 Alluxio 客户端开启文件以及目录的元数据缓存,避免二次访问时仍需要通过 RPC 访问元数据的问题。结合分配给 AlluxioFUSE 的堆大小,用户可以配置 alluxio.user.metadata.cache.max.size 来设置最多缓存文件和目录的元数据数量,也可以配置 alluxio.user.metadata.cache.expiration.time 调整元数据缓存的有效时间。同时在每次选择读取数据的 Worker 节点时,Alluxio Master 节点也会不断去查询所有 Worker 节点的状态,这也会在高并发场景下引入额外开销。
将 alluxio.user.worker.list.refresh.interval 设置为 2min 或者更长。
读取文件也会不断更新 last accesstime,实际上在高并发的场景下,这会对 Alluxio Master 造成很大压力。我们通过修改 Alluxio 代码增加了开关,可以关闭掉 last accesstime 的更新。
Unix Socket 的方式好处在于隔离性好,不需要 Alluxio Client 和 Alluxio Worker 容器运行在同样的 Network,UTS,Mount 的 Namespace。但是它的性能比直接文件访问要差一些,同时会引发 netty 的 OutOfDirectMemoryError
而直接访问文件的方式则所以需要确保同一台机器上运行的 Alluxio Worker 和 AlluxioFUSE 的主机名和 IP 地址一致,同时要保证 Alluxio Client 和 Worker 共享同样缓存目录,这种方式性能更好同时更加稳定。但是它实际上牺牲了隔离性,需要二者共享 Network,UTS,Mount 的 Namespace
Runtime.getRuntime().availableProcessors() 控制的;而如果通过 Kubernetes 部署容器而不指定 cpu 资源的 request 数量,容器内 Java 进程读到 proc 文件系统下的 cpushare 数量为 2, 而此时的 availableProcessors() 来自于 cpu_shares()/1024,会被算成 1。实际上限制了容器内 Alluxio 的并发线程数。考虑到 Alluxio Client 属于 I/O 密集型的应用,因此可以通过 -XX:ActiveProcessorCount 设置处理器数目。这里的基本原则是 ActiveProcessorCount 尽量设置得高些。
调整 GC,JIT 线程
JVM 的缺省 GC,JIT 编译线程数量取决于 -XX:ActiveProcessorCount 的数量,但实际上也可以通过 -XX:ParallelGCThreads -XX:ConcGCThreads -XX:CICompilerCount 等参数配置,可以将其设置的小些,避免这些进程频繁的抢占切换,导致性能下降。





