糟糕的 POSIX IO

POSIX I/O 是大规模系统 IO 性能限制的最主要因素。淘汰 POSIX I/O 是高性能计算专家们的共同愿望。为了应对大规模系统的性能危机,专家们从 I/O 转发层、用户空间 I/O、新 I/O 接口等不同角度做出了补救。

为了回答为什么 POSIX I/O 制约了现有系统的 IO 性能和未来超大规模 IO 系统的设计空间,最大的办法是研究一下 POSIX I/O 真正的含义。

本文翻译自 《WHAT’S SO BAD ABOUT POSIX I/O?》

理解今天的 POSIX I/O

从抽象的层次来讲,我们通常把POSIX I/O APIPOSIX I/O semantics(POSIX IO 语义)混为一谈,统称为 POSIX I/O。

POSIX I/O API 是我们最熟悉的,因为它就是应用程序读写数据调用的接口,包括open()/close()/read()/write()/lseek()等等。POSIX I/O API 是当今应用程序和程序库的一个必要组成部分。

POSIX I/O 语义相较之下熟悉的人就很少了,它定义了在 API 被调用的时候什么事情是可以保证的、什么事情是不能保证的。例如,当write()调用正常返回时,POSIX IO 语义保证了数据一定被提交到了某个持久性存储中。虽然write()是对数据是否写入的保证看起来很简单,但是这些语义会在大规模系统中引发严重的性能问题,尽管这些保证对于系统来说不是必要的。

API 及其语义的组合产生了POSIX I/O 的一些不同特性,这些特性对可伸缩性和性能具有可测量的不利影响。

POSIX I/O 是有状态的

一个典型的 IO 程序的流程如下:

  1. 使用open()打开文件;
  2. 然后read()文件数据;
  3. 接着seek()到文件中的新位置;
  4. 在该位置write()一些数据;
  5. 最后close()文件。

文件描述符是这个过程的核心; 如果没有先open()来获取文件描述符,就无法读取或写入文件;下一次读或写的位置由最后一次read()/write()/seek()决定。 因此,POSIX I/O 是有状态的;读取和写入数据由一些持久状态控制,该状态由操作系统以文件描述符的形式维护。

因为操作系统必须为每个想要读取或写入的进程跟踪其文件描述符的状态,当数百万或数十亿的进程试图在同一个文件系统上进行 I/O时,这种由 POSIX 提供的有状态的 I/O 模型就成为了一个主要的系统扩展瓶颈。多节点并发打开文件和单节点性能对比如下图所示:

在大多数并行文件系统上,打开文件的成本与客户端数量呈线性关系。也就是说,在使用 POSIX 接口实际读取或写入数据之前,有状态的 POSIX IO 模型就会引发性能损失,而当系统规模扩张时,这种性能损失会变得越来越明显。

POSIX I/O 元数据

POSIX I/O 还规定了所有文件必须具有的特定元数据集。例如,POSIX I/O API chmod()stat(),或者同名shell命令。这些调用操纵 POSIX 规定所有文件必须拥有的元数据,例如文件的所属用户和所属组,用户和组的文件访问权限,以及文件创建和最后修改时间等属性。

虽然 POSIX 风格的元数据确实实用,但它非常死板且不灵活。例如,文件的所有权和访问权限在包含科学数据的目录(例如,每个进程的文件检查点)中通常是相同的,但 POSIX 要求文件系统必须独立地跟踪每个文件。同时,这种元数据设计对于许多(科学计算的)数据集描述不充分,通常导致需要在 README 中描述一些额外的元数据信息。

在大规模系统中支持 POSIX 元数据模式是困难的:任何试图在一个包含百万级别文件的目录中进行ls -l列举的人都可以证明这一点。尽管如此,POSIX 元数据模式的不灵活性通常让用户使用其他管理方法,从编写每个目录中的README文件或精心命名的文件到复杂的元数据管理系统,如 JAMOiRODS或者Nirvana

POSIX I/O 有强一致性

前两节都在讲 POSIX IO API,也许标准 POSIX IO 对扩展性最大的限制不在于其 API,而在于其语义。思考POSIX 2008 中对 write() 函数的特殊要求:

write() 对一个常规文件写操作成功返回时:

  • 任何对该文件的成功读请求的字节位置都将被写操作修改,并返回写操作结束时的字节位置,直到其他操作修改了字节位置。
  • 对文件中相同字节位置的、任何后续成功的write()操作都将覆盖该文件数据。

也就是说,写操作必须强一致性的:一个 write() 操作需要阻塞应用程序知道系统可以保证其他 read() 调用可以看到刚写入的数据。虽然在写入本地磁盘的单个工作站上完成此操作并不太繁重,但在分布式文件系统上保证这种强一致性非常具有挑战性。

保证 POSIX 一致性的代价

所有现代操作系统都使用页缓存来减少保证 POSIX 一致性带来的延迟。页缓存使得操作系统不必将应用程序阻塞直到数据写入慢速、非易失性存储介质中,而是允许 write() 操作将数据写入页缓存中就返回,并将控制权交还给应用程序。操作系统追踪包含未下刷到磁盘数据的脏页,并将这些脏页从内存中异步写回到其目标文件中。 因为操作系统跟踪缓存页面,POSIX强一致性保证仍然满足。同时,如果read()调用读取的数据页在内存中已经存在,操作系统将直接返回内存数据。

Unfortunately, page cache becomes much more difficult to manage on networked file systems because different client nodes who want to read and write to the same file will not share a common page cache. For example, consider the following I/O pattern:

不幸的是,页缓存在网络文件系统上变得更加难以管理。因为想要读取和写入同一文件的不同客户端节点不共享页缓存。 例如,请考虑以下 I/O 模式:

  1. A 节点对一个文件写入数据,但是只写入本机页缓存中,并未更新到服务器;
  2. B 节点在 A 的数据更新前读取了同一文件的数据;
  3. 此时 B 并未获取到 A 对文件的更改;
  4. 所以 B 调用 read() 获取的数据与 A 的数据不一致。
  5. 违背了 POSIX 一致性。

为了遵守 POSIX 的强一致性,并行或者网络文件系统必须确保在客户端脏页从页缓存刷新到后端存储服务器之前,没有节点能够读取数据。 这意味着并行文件系统必须要:

  1. 不使用页缓存,这会增加小文件的 IO 延迟,因为应用程序会阻塞直到数据通过网络写入后端存储;
  2. 违反(或者”放松”) POSIX 一致性:在为大多数合理 IO 负载提供一致性。例如,当不同节点修改同一文件非重叠部分。但是如果两个节点试图修改同一个文件的相同部分,则一致性无法保证。
  3. 实现复杂的锁定机制,以确保一个节点修改的文件(或文件的一部分)其他节点无法读取。

所有这些选择都是可行的,但它们有不同的权衡。 例如:

  1. Cray公司的DataWarp文件系统使用方法1来提供简单、无锁、强一致的 IO 语义。由于每个 write() 操作不论大小必须通过网络传输,因此其处理小事务时会提高整体延时。但 DataWarp 的存储后端使用NVMe的存储介质降低性能损失。
  2. NFS使用方法2并保证 close-to-open 一致性:文件中的数据仅在文件关闭后到文件打开之前保持一致。如果两个节点同时打开同一个文件,则它们可能会各自保留脏页,由客户端应用程序确定没有两个节点尝试修改同一页面。
  3. PVFS2(现为OrangeFS)使用方法1和2的组合:它不使用页面缓存,但它也不会序列化重叠写入来保证一致性。只要不同节点不尝试同时修改重叠字节范围,这就有利于确保一致性。这种一致性保证比NFS样式的一致性更强, NFS 只有在修改非重叠页面时才一致。
  4. Lustre 和 GPFS 这样的并行文件系统使用方法3实现复杂的分布式锁机制,以确保在允许另一个节点执行重叠 IO 之前总是刷新脏页。当应用程序使用分布式锁不恰当造成锁竞争时,锁机制会限制系统的可扩展性。

为了说明这一点,考虑对文件系统以不同传输大小进行带宽测试,这些文件系统采用不同的方法获得 POSIX 一致性:

图中蓝色数据显示无锁无缓存的实现方法(对应于方法)受 IO 大小的影响。该实现无法从回写缓存中受益,因为异步回写可以合并小块写到大块 RPC 调用中。 类似地,实现分布式锁机制(方法4)在写入大小小于锁粒度时发生锁竞争,从而抵消了回写高速缓存带来的性能优势。 只有当违背或者放松 POSIX 一致性要求并且无锁、使用本地缓存时,系统性能在所有 IO 尺寸下能始终保持高水平。

POSIX 一致性模型还使其他两个可伸缩性限制 (有状态性质和元数据模式) 性能表现更加糟糕。

例如,POSIX IO 的有状态性质与分布式锁相结合意味着并行文件系统通常必须跟踪每个文件的每个区域上的每个锁的状态。随着越来越多的计算节点打开文件并获得读或写锁,管理哪些客户端节点拥有哪些文件的哪些字节范围的锁对分布式文件系统是一种沉重的负担。

同样,mtimeatime 等元数据在客户端读写时必须完全正确的更新来保证完整的一致性。 这实际上要求每次读取文件时,也必须写入文件最后访问时间(atime)。 可以想象,这对管理 POSIX 元数据的并行文件系统施加了很大的负担。

为 HPC 挣脱 POSIX I/O 的限制

从整体上讲,POSIX I/O API 和语义对于具有串行处理器、工作负载的个人计算机系统非常有意义。但是,HPC 提出了高度并发的挑战(最大的文件系统必须直接服务20,000个客户端)并且通常是多租户(大型系统通常运行数十或数百个独立的工作任务)。然而在 HPC 中,很多情况下 POSIX I/O 的强一致性并不是绝对必要的。例如:

  1. 在极端规模下,应用程序从不依赖文件系统来提供并行 I/O 的完全一致性。从以往的系统中可以观察出来,当发生锁竞争时系统性能都会非常差,应用程序需要确保没有两个进程同时尝试写入一个文件的相同部分。
  2. 在多租户系统中,两个独立的作业同时写一个共享文件的情况非常罕见。因此,保证整个系统中所有数据的完全一致性通常是过度的。文件的一致性域通常可以缩小到实际操作文件的一小部分计算节点。处理其他不同文件的工作无需保证全局一致性,进而受到性能惩罚。

POSIX I/O 的 API 和语义是阻止符合 POSIX 标准的文件系统有效扩展到未来超大规模超级计算系统的主要障碍。事实上,这些障碍并不是什么新鲜事。分布式系统中使用的文件系统充满了完全 POSIX 合规性的例外,以解决这些问题:

  • Lustre 的 local flock option 将一致性域减少到单个计算节点;
  • 每个文件系统都有一个 noatime 选项,以减轻维护 POSIX 元数据强一致性带来的负担;
  • Cray 的 Data Virtualization Service 提供了一个可以放松并行打开操作状态选项。

然而,HPC 业界正在证明 POSIX I/O 根本不是 HPC 所需要的。 当然,下一步是确定 POSIX I/O的哪些方面是必不可少的,方便的和不必要的,然后构建一组新的 I/O API,语义和存储系统,为未来的超大规模系统提供服务。

展望后 POSIX I/O 时代

幸运的是,回答这些问题一直是业界研究和开发的重点。 业界在为 I/O 定义全新的API方面取得了很大进展,包括 S3 和 Swift,它们通过剥离大部分 POSIX 复杂的语义来获得可扩展性和吞吐量。 当然,这些简化的语义仍然适用于超大规模的WORM工作负载,但其简化设计也阻止它们成为HPC应用程序的可行替代方案。

HPC 领域努力在高可扩展性和灵活语义之间寻找更好的折衷,而且一些高性能对象存储正在大力发展,着眼于HPC市场。 英特尔的 Distributed Asynchronous Object Store (DAOS) 已由 The Next Platform 详细介绍,并仍处于主动和开放式开发阶段。 最近,希捷与欧洲地平线2020计划合作开始开发Mero,高性能的、异步 exascale 对象存储。 这两个系统都将POSIX API 及其语义都视为辅助功能,这些功能是在更丰富的API上实现的,这种 API 避免了异步和事务语义组合的强一致性。 通过提供较低级别的 I/O 接口,由应用程序自己在POSIX 简单性和极端并发性和可扩展性进行权衡。

也就是说,即使是最成熟的高性能性能对象存储目前仍处于研发阶段。 因此,这些系统还没有形成标准 API 或语义上,虽然一些高价值的极端规模应用程序可能愿意重新编写应用程序代码以最有效地利用这种新的存储系统,但很难想象未来会出现大面积放弃POSIX I/O API 的现象。

当然,POSIX I/O API和 POSIX I/O 语义并不是不可分割的。 未来的 I/O系统完全有可能提供 POSIX I/O API 的重要部分以实现应用程序的可移植性,但同时,显着改变底层语义以避开它所带来的可扩展性瓶颈。 MarFS project, also previously detailed at The Next Platform 就是一个很好的例子,因为它在 S3 或类似 Swift 的 put/get 语义之上定义了 POSIX API。 虽然MarFS并未尝试解决性能问题,但它确实展示了一种有趣的方法,可以在不强制重写应用程序 I/O 例程的情况下超越POSIX。

同样的项目如 nfs ganesha over RGW

因此,超大规模 I/O的未来并不黯淡。 有许多方法可以超越 POSIX I/O 的限制而无需重写应用程序,这是一个在行业和学术界都积极研究的领域。 硬件技术(包括存储级存储介质,如 3D XPoint) 和软件实现(如用户空间 I/O 框架,包括 SPDKMercury)的进步继续为并行 I/O性能的创新铺平了道路,并创造了巨大的机会。