1 简介
以数据为中心的系统,例如数据库、键值存储 (KVS) 和机器学习引擎,已经成为现代 I/O 栈不可或缺的一部分 [12、19、32、43、53、55]。 这些系统的良好性能通常需要存储优化,例如 I/O 调度、差异化和缓存。 然而,这些优化是以次优方式实现的,因为它们与系统实现紧密耦合,并且由于缺乏全局上下文而可能相互干扰。 例如,区分前台和后台 I/O 以减少尾部延迟等优化具有广泛的适用性; 然而,当今它们在 KVS 中的实现方式(例如,SILK [16])需要对系统有深入的了解,并且不能跨其他 KVS 移植。 同样,部署在共享基础设施上的应用程序的优化可能会由于彼此不了解而发生冲突 [27、51、61、62]。
在本文中,我们认为有更好的方法来实现这种存储优化。 我们介绍了 PAIO,这是一个用户级框架,通过采用软件定义存储 (SDS) 社区 [38] 的想法,可以构建可移植且普遍适用的存储优化。 关键思想是通过拦截和处理应用程序执行的 I/O,在应用程序外部实现优化,作为数据平面阶段。 然后,这些优化由逻辑上集中的管理器(控制平面)控制,该管理器具有防止它们之间干扰所必需的全局上下文。 PAIO 不需要对内核进行任何修改(这对部署至关重要)。 使用 PAIO,可以将复杂的存储优化与当前系统分离,例如 I/O 差异化和调度,同时获得类似于甚至优于耦合实现优化的结果。
构建 PAIO 并非易事,因为它需要解决当前解决方案不支持的多项挑战。 要在应用程序外部执行复杂的 I/O 优化,PAIO 需要将上下文向下传播到 I/O 堆栈,从高级 API 向下传播到以更小粒度执行 I/O 的较低层。通过结合传播的上下文[36],使应用程序级信息能够传播到数据平面阶段,只需更改少量代码且无需修改现有 API。
PAIO 需要设计新的抽象,允许区分和调解用户空间 I/O 层之间的 I/O 请求。 这些抽象必须促进各种存储优化的实施和可移植性。 PAIO 通过四个主要抽象来实现这一点。 执行对象是一个可编程组件,它将单个用户定义的策略(例如速率限制或调度)应用于传入的 I/O 请求。 PAIO 使用上下文对象来表征和区分请求,通过通道将 I/O请求、执行对象和上下文对象连接起来。 为了确保跨独立存储优化的协调(例如,公平性、优先级),具有全局可见性的控制平面通过使用规则微调执行对象。
有了这些新功能和抽象,系统设计人员可以使用 PAIO 开发定制的 SDS 数据平面阶段。 为了证明这一点,我们在两个用例下验证了 PAIO。 首先,我们在 RocksDB [9] 中实现一个阶段,并演示如何通过编排前台和后台任务来防止延迟峰值。 结果表明,与基线 RocksDB 相比,启用 PAIO 的 RocksDB 在不同的工作负载和测试场景(例如,不同的存储设备,有和没有 I/O 带宽限制)下将第 99% 的延迟提高了 4 倍,并且在以下情况下实现了类似的尾部延迟性能 与 SILK [16] 相比。 我们的方法表明,复杂的 I/O 优化(例如 SILK 的 I/O 调度程序)可以从原始层分离到一个独立的、更易于维护和可移植的阶段。 其次,我们将 PAIO 应用于 TensorFlow [11],并展示如何在 ABCI 超级计算机 [1] 的真实共享存储场景下实现动态的每个应用程序带宽保证。 结果显示所有启用 PAIO 的 TensorFlow 实例都达到了它们的带宽目标。 这表明 PAIO 可以通过系统范围的可见性和整体控制来执行存储策略。
总之,本文做出以下贡献:
- PAIO,一个用于构建可编程和动态自适应数据平面阶段的用户级框架(§3-§7)。 PAIO 可在 https://github.com/dsrhaslab/paio 上公开获得。
- 实施两个阶段以(1)减少 LSM KVS 中的延迟峰值; (2) 在共享存储设置下实现每个应用程序的带宽保证 (§8)。
- 证明 PAIO 在合成和真实场景下的性能和适用性的实验结果(§9)。
2 动机与挑战
我们现在描述系统特定 I/O 优化的问题以及这些问题如何推动 PAIO 的方案。
问题 1:紧耦合优化。 大多数 I/O 优化都是单一用途的,因为它们紧密集成在每个系统的核心中 [16、29、50]。 实施这些优化需要深入了解系统的内部操作模型和深入的代码重构,限制了它们在同样受益于它们的系统之间的可维护性和可移植性。 例如,为了减少基于行业标准 LSM 的 KVS RocksDB 的尾部延迟峰值,SILK 提出了一个 I/O 调度程序来控制前台和后台任务之间的干扰。 然而,在 RocksDB 上应用这种优化需要更改由数千个 LoC 组成的几个核心模块,包括后台操作处理程序、内部排队逻辑和线程池 [5、15]。 此外,将此优化移植到其他 KVS(例如,LevelDB [21]、PebblesDB [47])并非易事,因为即使它们共享相同的高级设计,内部 I/O 逻辑在不同实现中也不同(例如,数据 结构 [20, 47],压实算法 [34, 47])。
解决方案:解耦优化。 I/O 优化应该从系统的内部逻辑中分离出来并移动到专用层,从而在不同的场景中变得普遍适用和可移植。
由此产生的挑战:刚性接口。 解耦优化是有代价的,因为我们失去了系统特定优化中存在的粒度和内部应用程序知识。 具体来说,传统 I/O 堆栈的操作模型需要各层通过不易扩展的刚性接口进行通信,从而丢弃了可用于在不同粒度级别对请求进行分类和区分的信息 [13]。 例如,让我们考虑图 1 中描述的 I/O 堆栈,它由一个应用程序、一个 KVS 和一个符合 POSIX 的文件系统组成。 从 KVS 提交的 POSIX 操作可以源自不同的工作流程,包括前台 (a) 和后台流程,如刷新 (b) 和压缩 (c)。 然而,文件系统只能观察请求的大小和类型(即读取和写入),因此无法推断其来源。 在较低层(例如,文件系统,KVS 和文件系统之间的层)实施 SILK 的 I/O 调度程序,将使优化可移植到其他 KVS 解决方案。 但是,它是无效的,因为它无法区分前台和后台操作。
解决方案:信息传播。 应用程序级信息必须在各个层中传播,以确保解耦优化可以提供与系统特定优化相同级别的控制和性能。
由此产生的挑战:内核级层。 虽然在内核(例如,文件系统、块层)上实施 SILK 的 I/O 调度程序会提升其在其他 KVS 解决方案中的适用性,但它也有几个缺点。 首先,为了将应用程序级信息传播到这些层,它需要打破用户到内核(即 POSIX)和内核内部接口(例如,VFS、块层、页面缓存),从而降低可移植性和兼容性 [13]。 此外,内核级开发比用户级 [42、56] 更受限制且更容易出错。 最后,这些优化在 kernelbypass 存储堆栈(例如 SPDK [10]、PMDK [8])下将无效,因为 I/O 请求直接从应用程序(用户空间)提交到存储设备。
解决方案:在用户级别启动。 I/O 优化应该在专用的用户级层实施,促进跨不同系统和场景的可移植性,并简化跨层的信息传播。
问题 2:部分可见性。 孤立实施的优化忽略了竞争相同存储资源的其他系统。 在共享基础架构(例如,云、HPC)下,这种协调的缺乏会导致优化冲突 [27、62]、I/O 争用以及应用程序和存储后端的性能变化 [51、61]。
解决方案:全局控制。 优化应了解周围环境并协调运行,以确保对 I/O 工作流和共享资源的整体控制。
3 PAIO 简而言之
  PAIO 是一个框架,使系统设计人员能够构建定制的 SDS 数据平面阶段。 使用 PAIO 构建的数据平面阶段针对给定用户级别层的工作流,实现请求的分类和区分以及根据用户定义的存储策略执行不同的存储机制。 此类策略的示例可以简单的限制贪婪租户的速率以实现资源公平,也可以是更复杂的,例如协调具有不同优先级的工作流以确保持续的尾部延迟。 PAIO 的设计基于五个核心原则。
  一般适用性。 为了确保跨不同 I/O 层的适用性,PAIO 阶段与内部系统逻辑分离,这与紧密耦合的解决方案相反。
  可编程构建块。 PAIO 遵循分离的设计,将 I/O 机制与管理它们的策略分开,并为构建新的存储优化提供必要的抽象以处理以上请求。
  细粒度的 I/O 控制。 PAIO 以不同的粒度级别对 I/O 请求进行分类、区分和强制执行,从而使一组广泛的策略能够应用于 I/O 堆栈。
  阶段协调。 为确保各阶段对资源的访问协调一致,PAIO 公开了一个控制接口,使控制平面能够动态调整每个阶段以适应新的策略和工作负载变化。
  低侵入性。 移植 I/O 层以使用 PAIO 不需要对代码进行少量更改。
3.1 PAIO 中的抽象
  PAIO 使用四个主要抽象,即执行对象、通道、上下文和规则。
  执法对象。 执法对象是一种独立的、单一用途的机制,它将自定义 I/O 逻辑应用于传入的 I/O 请求。 此类机制的示例范围从性能控制和资源管理(例如令牌桶和缓存)、数据转换(例如压缩和加密)到数据管理(例如,数据预取、分层)。 这种抽象为系统设计者提供了灵活性和可扩展性,以开发为执行特定存储策略而定制的新机制。
  通道。 通道是一种类似流的抽象,请求通过它流动。 每个通道包含一个或多个执行对象(例如,对同一组请求应用不同的机制)和将请求映射到要执行的相应执行对象的区分规则。
  上下文对象。 上下文对象包含表征请求的元数据。 它包括一组元素(或分类器),例如工作流 ID(例如线程 ID)、请求类型(例如读取、打开、放置、获取)、请求大小和请求上下文,用于 表达给定请求的附加信息,例如确定其来源、上下文等。 对于每个请求,PAIO 生成相应的 Context 对象,用于通过相应的 I/O 机制对请求进行分类、区分和强制执行。
  规则。 在 PAIO 中,规则表示控制数据平面阶段状态的操作。 规则由控制平面提交,分为三种类型:管家规则管理内部阶段组织,区分规则分类和区分I/O请求,执行规则根据工作负载变化调整执行对象。
3.2 高层架构
图 2 概述了 PAIO 的高级架构。 它遵循一种解耦设计,将在外部控制平面实施的政策与在数据平面阶段实施的实施政策的机制分开。 PAIO 以用户级别的 I/O 层为目标。 阶段嵌入在层中,拦截所有 I/O 请求并执行用户定义的策略。 为实现这一目标,PAIO 分为四个主要部分。
阶段接口。 应用程序通过阶段接口(§6.1)访问阶段,该接口在提交到下一个 I/O 层(即 App3 →PAIO →文件系统)之前将所有请求路由到 PAIO。 对于每个请求,它都会生成一个具有相应 I/O 分类器的上下文对象。
差异化模块。 差异化模块 (§4) 根据请求的上下文对象对请求进行分类和区分。 为了确保以细粒度区分请求,我们结合了上下文传播 [36] 的想法,使应用程序级信息(只能由层本身访问)传播到 PAIO,从而扩大了可以执行的策略集。
执行模块。 执行模块 (§5) 负责对请求应用实际的 I/O 机制。 它由通道和执行对象组成。 对于每个请求,该模块选择应该处理它的通道和执行对象。 强制执行后,请求返回到原始数据路径并提交到下一个I/O层(文件系统)
控制接口。 PAIO 公开了一个控制接口(§6.1),使控制平面能够(1)通过创建通道、执行对象和差异化规则来编排阶段生命周期,以及(2)通过持续监控和微调来确保满足所有策略 阶段。 控制平面提供全局可见性,确保阶段得到整体控制。 公开此接口允许阶段由现有控制平面 [22、35、54] 管理。
3.3 请求生命中的一天
在深入研究 PAIO 的内部模块之前,我们首先说明它如何编排给定层的工作流程。 我们考虑图 3 中描述的 I/O 堆栈,它由一个应用程序、RocksDB、一个 PAIO 阶段和一个 POSIX 兼容的文件系统组成; 以及执行以下策略:“将 RocksDB 的刷新操作的速率限制为 X MiB/s”。 RocksDB 的后台工作流生成刷新和压缩作业,这些作业被转换为提交给文件系统的多个 POSIX 操作。 刷新在写入中进行转换,而压缩在读取和写入中进行。
在启动时,RocksDB 初始化 PAIO 阶段,该阶段连接到已部署的控制平面。 控制平面提交内务处理规则以创建通道和执行对象,该对象将请求速率限制在 X MiB/s ➀ 。 它还提交不同规则 ➁ 来确定阶段应处理哪些请求,即基于刷新的写入。 §4 和 §5 分别给出了区分和执行过程如何工作的细节。
在执行时,RocksDB 传播给定操作创建的上下文 ⓿ 并将所有写操作重定向到 PAIO ❶ 。 通过 ❶ ,我们确保只有写操作在 PAIO 上被强制执行,而对于 ⓿ ,我们将刷新标记的写操作与其他可以由压缩作业触发的写操作区分开来。 在基于刷新的写入时,将创建一个上下文对象及其请求类型(写入)、上下文(刷新)和大小,并随请求一起提交到阶段 ❶ 。 然后,该阶段选择要使用的通道 ❷ ,将请求入队 ❸ ,并选择执行对象来为请求提供服务 ❹ ,将请求的速率限制在 X MiB/s (❺) 。在执行请求 ❻ 之后,原始写操作被提交到文件系统。
控制平面持续监控和微调数据平面阶段。 它会定期从阶段收集处理请求的吞吐量 ➂ 。 基于此指标,控制平面可以调整实施对象以确保刷新操作以 X MiB/s 的速度流动,从而生成具有新配置的实施规则 ➃ 。
4 I/O 区分
PAIO 的区分模块提供了在不同粒度级别(即每个工作流、请求类型和请求上下文)对请求进行分类和区分的方法。 区分请求的过程分三个阶段实现。
启动时间。 在启动时,用户定义请求如何区分以及谁应该处理每个请求。 首先,它通过指定应使用哪些 I/O 分类器来区分请求进行区分粒度的定义。 例如,为了提供每个工作流的区分,PAIO 仅考虑上下文的工作流 ID 分类器,而根据请求的上下文和类型来区分请求,它同时使用请求上下文和请求类型分类器。 其次,用户将特定的 I/O 分类器归于每个通道以确定给定通道接收的请求集。 表 1 提供了此规范的示例:channel1 仅接收来自 flow1 的请求,而 channel2 仅处理来自后台任务的读取请求; channel3 从 flow5 接收基于压缩的写入。 要生成将请求映射到通道的唯一标识符,可以将分类器连接成一个字符串或散列成一个固定大小的标记 (§7)。 此外,此过程可以由控制平面设置(即区分规则)或在阶段创建时配置。
执行时间/b>。 第二阶段将提交到该阶段的 I/O 请求分类,并将它们路由到相应的通道以执行。 这是通过两个步骤实现的。 通道选择。 对于每个伴随着上下文对象的传入请求,PAIO 选择必须为其提供服务的通道(图 3、2)。 PAIO 验证上下文的 I/O 分类器并将请求映射到要执行的相应通道。 该映射按照分化过程的第一阶段中的描述完成。
执行对象选择。 由于每个通道可以包含多个执行对象,类似于通道选择,PAIO 选择正确的对象来为请求提供服务(图 3、4)。 对于每个请求,通道都会验证上下文的分类器并将请求映射到相应的执行对象,然后该对象将使用其 I/O 机制(§5)。
上下文传播。 可以通过观察原始 I/O 请求访问多个 I/O 分类器,例如工作流 ID、请求类型和大小。 但是,只有提交 I/O 请求的层才能访问的应用程序级信息可用于扩展要在 I/O 堆栈上实施的策略。 如图 1 所示,此类信息的一个示例是操作上下文,它允许确定给定请求的来源或上下文,即它是否来自前台或后台任务、刷新或压缩或其他。
因此,PAIO 可以将附加信息从目标层传播到各阶段。 它结合了上下文传播的想法,上下文传播是一种常用技术,使系统能够沿其执行路径转发上下文 [36、37、41、62],并应用它们来确保对请求的细粒度控制。 为实现这一点,系统设计人员检测可访问信息的目标层的数据路径,并通过进程的地址空间、共享内存或线程局部变量使其可供该阶段使用。 该信息包含在作为请求上下文分类器的上下文对象的创建中。 如果不使用此方法传播上下文,则需要在可以找到信息的位置和将其提交到阶段之间更改所有核心模块和功能签名。
例如,考虑图 3 的 I/O 堆栈。为了确定 RocksDB 后台工作流提交的 POSIX 操作的来源,系统设计人员检测 RocksDB 负责管理刷新或压缩作业 ⓿ 的关键路径以捕获它们的上下文。 然后将此信息传播到阶段接口,其中使用所有 I/O 分类器创建上下文对象,包括请求上下文,并提交到阶段 ❶。 请注意,此步骤是可选的,因为对于不需要执行额外信息的策略可以跳过此步骤。
5 I/O 执行
执行模块为开发将在请求上使用的实际 I/O 机制提供构建块。 它由多个通道组成,每个通道包含一个或多个执行对象。
如图 3 所示,请求被移动到选定的通道并放置在提交队列 ➌ 中。 对于每个出队请求,PAIO 选择正确的执行对象 ➍ 并应用其 I/O 机制 ➎ 。 这些机制的示例包括令牌桶、缓存、加密方案等; 我们在 §6.3 中讨论了如何构建执行对象 由于有多种机制可以更改原始请求的状态,例如数据转换(例如,加密、压缩),因此在此阶段,执行对象会生成一个封装请求更新版本的结果 ,包括其内容和大小。 然后 Result 对象返回到阶段接口,对它进行解组、检查并将其路由到原始数据路径 ➏ 。 在此过程之后,PAIO 确保请求满足指定策略的目标。
优化。 根据要采用的策略和机制,PAIO 可以仅使用其 I/O 分类器来强制执行请求。 虽然数据转换直接适用于请求的内容,但性能驱动的机制(例如令牌桶和调度程序)只需要强制执行特定的请求元数据(例如,类型、大小、优先级、存储路径)。 因此,为了避免增加系统执行的开销,PAIO 允许仅在必要时将请求的内容复制到阶段的执行路径。
6 PAIO 接口及使用
我们现在详细介绍 PAIO 如何与 I/O 层和控制平面交互,如何将 PAIO 集成到用户层,以及如何构建执行对象。
6.1 接口
阶段接口。 PAIO 提供了一个应用程序接口来建立 I/O 层和 PAIO 内部机制之间的连接。 如表 2 所示,它提供了两个功能: paio_init 初始化一个阶段,该阶段连接到控制平面以进行内部阶段管理并定义应如何处理工作流; enforce 拦截来自层的请求并将它们沿着关联的 Context 对象路由到阶段(§6.2 详细说明了应如何拦截请求并将其提交给 PAIO)。 执行请求后,阶段输出执行结果,层恢复原来的执行路径。
控制界面。 阶段和控制平面之间的通信是通过五个调用实现的,如表 2 所示。stage_info 调用列出了有关阶段的信息,包括阶段标识符和进程标识符 (PID)。 基于规则的调用用于管理和调整数据平面阶段。 管家规则 (hsk_rule) 管理阶段生命周期(例如,创建通道和执行对象),区分规则 (dif_rule) 将请求映射到通道和执行对象,执行规则 (enf_rule) 动态调整给定执行的内部状态 对象 (id) 根据工作负载和策略变化。 控制平面还通过收集调用监视阶段,收集所有工作流的关键性能指标(例如,IOPS、带宽),并可用于调整数据平面阶段。
此接口使控制平面能够定义 PAIO 阶段如何处理 I/O 请求。 尽管如此,与数据平面阶段的可靠性相关的问题以及冲突策略的解决是控制平面的责任 [38],因此与本文正交。
6.2 在用户层集成PAIO
移植 I/O 层以使用 PAIO 阶段可能需要几个步骤。
将 PAIO 与上下文传播结合使用。 要将一个阶段集成到一个层中,系统设计人员通常需要:
- 使用paio_init在目标层创建舞台。
- 检测关键数据路径,其中层级信息可访问,并在创建上下文对象时将其传播到阶段。 这可能需要创建额外的数据结构。
- 创建将与请求一起提交到舞台的上下文对象。 它可以包括工作流 ID、请求类型和大小,以及传播的信息。
- 对提交到下一层前需要强制执行的I/O操作添加强制调用。 例如,要强制执行给定层的 POSIX 读取操作,所有读取调用都需要先路由到 PAIO,然后再提交到文件系统。
- 通过检查从强制返回的结果对象验证请求是否成功强制执行,并恢复执行路径。
透明地使用 PAIO。 当不需要上下文传播时,可以在 I/O 层(例如应用程序和文件系统)之间透明地使用 PAIO 阶段。 PAIO暴露了面向层的接口(例如POSIX),对于先提交给PAIO再提交给底层的,使用 LD_PRELOAD 代替原来在顶层的接口调用(例如应用程序调用的读写调用) (例如,文件系统)[7]。 每个支持的调用都定义了创建 Context 对象的逻辑、将请求提交到阶段、验证结果并调用原始 I/O 调用。 这使图层能够在不更改任何代码行的情况下使用 PAIO。
6.3 构建执行对象
PAIO 向系统设计人员公开了一个简单的 API 来构建执行对象,如表 2 所示。
• obj_init。 创建一个具有初始状态的执行对象,其中包括其类型和初始配置。
• obj_config。 提供调整旋钮以使用新状态 s 更新强制对象的内部设置。 这使控制平面能够动态地适应工作负载变化和新策略。
• obj_enf。 实现要应用于请求的实际 I/O 逻辑。 在应用其逻辑后,它返回一个包含请求 ® 更新版本的结果。 它还接收一个上下文对象 (ctx),用于对 I/O 请求采用不同的操作。
默认情况下,PAIO 保留目标系统的操作逻辑(例如,排序、错误处理),因为执行对象和提交给 PAIO 的操作都遵循同步模型。 虽然开发异步执行对象是可行的,但需要确保保留正确性和容错保证。
…
个人总结
应用 IO 带上了属性,并增加 PAIO 层,解析属性,针对不同属性进行不同的处理。