过去十年以来,我在一家专业产品公司里度过了自己的宝贵岁月,专注于构建高性能 I/O 系统。作为从业者,我有幸看到存储技术的迅速发展,工作中的实际感受更像是做团队动员。
今年,我换了份工作,加入了一家拥有众多从业背景不同的工程师的大型企业。特别让我惊讶的是,虽然这些同事也非常聪明睿智,但大部分都对如何充分运用现代存储技术的性能优势存在误解。换句话说,他们知道存储技术在不断改进,但却不清楚究竟是怎么个改进法。
在反思这种脱节问题时,我意识到之所以长期存在这种误解,主要是一旦人们开始用基准来验证自己的假设,那么数据也将在一定程度上证明这种假设、或者至少看起来能够证明。
这种误解的常见示例包括:
“好吧,我们可以在这里复制内存并执行高成本计算,毕竟这样能够帮我们节约一次成本更高的 I/O 操作。”
“我正在设计一套需要快速运行的系统,所以必须得运行在内存内。”
“如果拆分成多个文件,速度会受到影响,因为它会造成随机 I/O 模式。我们需要进行优化,在按顺序访问的同时通过单一文件读取。”
DirectI/O 非常慢,而且仅适用于某些非常专业的应用程序。如果没有缓存,那肯定会拖累性能。”
但是,如果大家关注过现代 NVMe 设备的规格,就会发现商用设备的延迟早已压缩到微秒范围内,而吞吐量则达到每秒数 GB,能够支持数十万随机 IOPS。性能这么强,为什么认识总是上不去?
在本文中,我们将证明尽管硬件在过去十年中发生了巨大的变化,但软件 API 却没有——或者至少还不够好。作为上一代的遗留物,API 充斥着内存副本、内存分配、过于乐观的预读缓存以及各种成本高昂的操作,这一切都导致我们无法充分利用现代存储设备。
在本文撰稿过程中,我很高兴能够从英特尔公司拿到一款下一代 Optane 设备。虽然这款设备在市场上并不常见,但却代表着存储产品的全面加速趋势。本文接下来列出的所有性能数字,都来自这款 Optane 产品。
为了节约时间,我将把重点放在读取性能上。写入也有自己的独特需求与问题,我打算在之后的文章中单独讨论。
概述
传统基于文件的 API 存在三个主要问题:
I/O 本身很昂贵
之所以要执行大量高成本操作,是因为“I/O 本身很昂贵”。
以往的 API 在需要读取未缓存在内存中的数据时,往往会产生页面错误。接下来,在数据准备好之后,又要引发一咨断。最后,对于传统基于系统调用的读取操作,我们还需要向用户缓冲区中添加一个副本;而对于基于 mmap 的操作,则必须更新虚拟内存映射。
这些操作本身的成本都不低:页面错误、中断、副本或者虚拟内存映射更新等等。但就几年前的情况来看,它们仍然比 I/O 本身成本低,资源消耗量约为百分之一,所以当时用它们来替代 I/O 确有道理。随着设备延迟接近微秒级别,情况开始发生了巨大变化。这些操作现在与 I/O 操作本身的性能量级已经处于同一区间。因此通过快速计算,我们可以得出简单的结论:即使是在最糟糕的情况下,与设备本身进行通信带来的成本,只占总体繁忙成本的不足一半。当然,性能浪费还不止于此,这就引出了第二个问题:读取扩增。
读取扩增
这里我们会稍稍介绍一些细节(例如文件描述符使用的内存、Linux 中的各种元数据缓存等),但既然现代 NVMe 支持多种并发操作,我们没有理由像过去那样认定从多个文件处读取数据、就一定比从单一文件中读取数据更昂贵。当然,数据的总读取量仍然重要,也将永远重要。
操作系统按页面粒度读取数据,这意味着其一次只能读取至少 4kB 数据。这意味着如果您需要读取被分为两个文件(每个 512 字节)的 1kB 读取块,则实际上是在读取 8kB 块来交付实际上 1kB 的数据,即浪费掉了 87% 的读取数据。实际上,操作系统还会执行默认设置为 128kB 的预读,以期在之后需要剩余数据时为您节约周期资源。但如果您从不采用这种方法(类似于经常发生的随机 I/O),则相当于读取了 256kB 却只得到 1kB 的有用数据,而浪费掉了其中 99%。
如果大家带着这个问题——即从多个文件读取基本上不会比从单一文件处读取更慢——进行验证,很可能会发现这个结论真实有效。但这仅仅是因为读取扩增使得有效读取的数据量增加了很多。
由于问题的核心在于操作系统页面缓存,那么如果纯使用 Direct I/O 打开文件,而其他所有条件都保持不变,会发生怎样的情况?遗憾的是,恐怕速度也不会更快。而这也引出了我们的第三个、也是最后一个问题:
传统 API 无法利用并发性优势
文件被视为字节的顺序流,而且数据是否处于内存内对于读取器是完全透明的。传统 API 会一直等待读取器开始请求未驻留在内存中的数据,而后才发出 I/O 操作。但在预读机制的加持下,I/O 操作可能大于用户的实际请求操作,而这还只是问题的一个侧面。
此外,尽管现代存储设备已经速度极快,但仍然远较 CPU 要慢。在设备等待 I/O 操作返回时,CPU 并未执行任何操作。
传统 API 缺乏并发性,会导致 CPU 在等待 I/O 返回时处于闲置状态。
使用多个文件其实是朝着正确方向迈出了一步,因为这能够更高效地使用并发处理机制:一个读取器在等待时,另一读取器仍然可以继续执行。但如果不加设计,这种方式反而有可能放大前面的问题:
多个文件意味着设立多个预读缓冲区,因此增加了随机 I/O 浪费因子。
在基于线程轮询的 API 当中,多个文件代表着多个线程,因此放大了每项 I/O 操作所能完成的工作量。
更不用说,大多数情况下我们并不想要这样的方式,毕竟在环境起步阶段并没有那么多文件可用。
寻求更好的 API
以往,我曾经撰写过不少关于革命性创新的文章。但作为一种较低级别的接口,以上还只是 API 难题中的一小部分。原因包括:
如果使用缓冲文件,则通过 io_uring 调度的 I/O 仍然会遇到之前列出的大多数问题。
Direct I/O 有很多问题,而且 io_uring 作为原始接口天然无法回避以下情况:内存必须正确对齐,以及正确的内存读取位置。
这也是一种低水平且原始的处理思路。为了实现缓冲,我们需要累积 I/O 并分批调度。这就要求制定何时执行操作策略,并引入某种形式的事件循环,也只有这样它才能与提供相应机制的框架更好地协同工作。
为了解决 API 问题,我们设计了 Glommio(原 Scipio),一套面向 I/O 的 Direct I/O 核心线程 Rust 库。Glommio 建立在 io_uring 的基础之上,并支持多种可保证 Direct I/O 发挥作用的高级功能,例如注册缓冲区以及基于轮询(无中断)的补全功能。为了易于理解,Glommio 其实是以类似于标准 Rust API(我们将也在比较中使用标准 Rust API)的方式支持 Linux 页面缓存支持的缓冲文件,但它的目标只有一个——全力支撑起 Direct I/O 的预期效果。
Glommio 当中包含两个文件类:随机读取文件与流。
随机访问文件将位置作为参数,因此我们无需维护搜索游标。更重要的是,随机访问文件不会将缓冲区作为参数。相反,它们使用 io_uring 的预注册缓冲区来分配缓冲区并返回给用户。这意味着其中不存在内存映射、不存在复制到用户缓冲区的操作——只有从设备到 Glommio 缓冲区的副本,而用户会获得指向该缓冲区的引用计数指针。而且因为我们明确知晓这是随机 I/O,所以并不需要刻意读取比实际请求量更多的数据。
在另一方面,流假定您最终需要遍历整个文件内容,因此可以承受使用较大块大小与预读因子的负担。
流在设计上与 Rust 中的默认 AsyncRead 非常相似,甚至也实现了 AsyncRead 的特征,同样能够将数据读取至用户缓冲区。基于 Direct I/O 扫描的所有优势仍然存在,只是其内部预读缓冲区与用户缓冲区之间存在一套副本。这也算是使用标准 API 这一重要便利性所带来的一种“必要之恶”吧。
如果需要额外的性能,Glommio 还在流中提供一个 API,此 API 也将公开原始缓冲区,从而节约额外的副本。
对扫描进行测试
为了演示各项 API,Glommio 提供一款 示例程序,通过所有 API(包括缓冲、Direct I/O、随机、顺序等)的多种设置对 I/O 性能做出评估。
我们先从一个大小约为内存容量 2.5 倍的文件开始,第一步是将其作为普通缓冲文件进行顺序读取:
Buffered I/O: Scanned 53GB in 56s, 945.14 MB/s
考虑到此文件无法被完整容纳在内存当中,所以这种方式其实也很正常,而其中的性能优势主要体现在英特尔 Optane 出色的性能以及 io_uring 后端身上。每当分派 I/O 时,其有效并行度始终为 1;而且尽管 OS 页大小为 4kB,但预读可以让我们有效增加 I/O 大小。
事实上,如果我们尝试使用 Direct I/O API(4kB 缓冲区,并行度为 1)来模拟相似的参数,结果将令人失望。这也“证实”了我们对于 Direct I/O 速度缓慢的怀疑。
Direct I/O: Scanned 53GB in 115s, 463.23 MB/s
但事实真是如此吗?正如前文已经提到,Glommio 的 Direct I/O 文件流可以采用显式预读参数。如果主动式 Glommio 会在当前读取的位置之前发出 I/O 请求,即可利用到设备的并行性优势。
Glommio 的预读与操作系统层级的预读有所不同:我们的目标在于利用并行性,而不仅仅是增加 I/O 大小。Glommio 不会消耗完整个预读缓冲区之后,才发起新的批处理请求;相反,它会在缓冲区内容被完全消耗掉之后立即调度一项新请求,并始终尝试保持固定数量的运行中缓冲区,具体如下图所示。
当我们用尽一个缓冲区的同时,已经获得了另一个缓冲区。这种方式能够增加并行度并保持较高的并行效果。
与之前的预期相符,一旦我们通过设置预读因子正确使用并行性优势,Direct I/O 不仅能够与缓冲 I/O 的性能相匹配,甚至速度反而更快。
Direct I/O, read ahead: Scanned 53GB in 22s, 2.35 GB/s
此版本仍然使用 Rust 的 AsyncReadExt 接口,该接口强制从 Glommio 缓冲区生成一套指向用户缓冲区的额外副本。
使用 get_buffer_alignedAPI,我们可以对缓冲区进行原始访问,从而避免最后一次内存复制。如果我们在当前读取测试中使用此 API,那么性能将得到 4% 的显著提升。
Direct I/O,glommioAPI: Scanned 53GB in 21s, 2.45 GB/s
最后一步是增加缓冲区大小。这是一项顺序扫描,所以我们不需要受到 4kB 缓冲区大小的限制(除非需要与操作系统页缓冲版本进行比较)。
现在,我们将在下一项测试中使用 Glommio 与 io_uring 总结幕后发生的所有操作:
每项 I/O 请求的大小为 512kB
其中大部分请求(5 项)处于并行状态
内存已经预先完成分配与预注册
不会对用户缓冲区执行额外的复制
io_uring 被设置为轮询模式,意味着不存在内存副本、没有中断、没有上下文切换
结果如何?
Direct I/O,glommioAPI, large buffer: Scanned 53GB in 7s, 7.29 GB/s
这一性能要比标准缓冲方法高出 7 倍以上。更重要的是内存利用率从未超过我们设置的预读因子乘以缓冲区大小,在本示例中为 2.5MB。
随机读取
众所周知,扫描会给操作系统页缓存带来负面影响。我们该如何使用随机 I/O 进行操作?为了测试如何在 20 秒内读取尽可能多的内容,首先需要将我们限制在可用内存(1.65GB)的前 10% 部分:
Buffered I/O: size span of 1.65 GB, for 20s, 693870 IOPS
对于 Direct I/O:
Direct I/O: size span of 1.65 GB, for 20s, 551547 IOPS
Direct I/O 的速度比缓冲读取慢 20%。尽管完全从内存内读取数据的速度确实更高,但二者之间的性能差异真的不算大。实际上,更值得大家重视的一点在于,缓冲版本需要保留 1.65GB 的常驻内存才能实现这样的性能;而 Direct I/O 只使用 80kB(20x4kB 缓冲区),因此我们显然更应该选择后者、解放出宝贵的内存资源用在其他地方。
任何一位性能工程师都会强调,良好的读取性能基准首先需要读取到足够充塞介质的数据。毕竟“存储速度很慢”,因此如果现在我们从整个文件中读取数据,那么缓冲性能将急剧下降 65%。
Buffered I/O: size span of 53.69 GB, for 20s, 237858 IOPS
与预期一样,Direct I/O 始终保持着相同的性能与相同的内存利用率,与读取的数据量无关。
Direct I/O: size span of 53.69 GB, for 20s, 547479 IOPS
如果选择规模更大的扫描作为比较点,那么 Direct I/O 反而比缓冲文件快 2.3 倍。
总结
现代 NVMe 设备改变了如何在有状态应用程序中获取最佳 I/O 执行性能的方式。这种趋势已经出现了一段时间,但到目前为止,我们必须承认这样一个事实:API(特别是高级 API)还未发展到能够与设备、特别是最近的 Linux 内核层相匹配的水平。但只要匹配正确的 API 集,Direct I/O 完全可以成为新的高性能选项。
最新一代存储设备,例如最新一代英特尔 Optane,完全可以提供与需求相符的性能。我们已经很难找到标准缓冲 I/O 全面压制 Direct I/O 的真实应用场景。
在扫描方面,基于 Direct I/O 的定制优化型 API 的性能更是一骑绝尘。尽管缓冲 I/O 标准 API 能够在适合全内存环境的随机读取方面拥有 20% 的执行速度优势,但这是以 200 倍的驻留内存量为代价换来的。到底是否划算,大家肯定已经有了自己的判断。
没错,需要额外性能加持的应用程序确实可以继续缓存部分数据,但 Glommio 正提供一种更为简便的方法,能够将专用缓存与 Direct I/O 结合起来以实现优势兼顾。
原文链接
https://itnext.io/modern-storage-is-plenty-fast-it-is-the-apis-that-are-bad-6a68319fbc1a
评论 2 条评论