[论文翻译] XRP: In-Kernel Storage Functions with eBPF

XRP: In-Kernel Storage Functions with eBPF

XRP: 利用 eBPF 的核内存储函数 [Paper] [Slides] [Code]

Yuhong Zhong1, Haoyu Li1, Yu Jian Wu1, Ioannis Zarkadas1, Jeffrey Tao1, Evan Mesterhazy1, Michael Makris1, Junfeng Yang1, Amy Tai2, Ryan Stutsman3, and Asaf Cidon1
1Columbia University, 2Google, 3University of Utah

16th USENIX Symposium on Operating Systems Design and Implementation (OSDI’22). July 11–13, 2022 • Carlsbad, CA, USA

摘要

随着微秒级 NVMe 存储设备的出现, Linux 内核存储栈的开销变得非常大, 访问时间几乎翻倍. 我们提出了 XRP, 一个允许应用程序从 NVMe 驱动程序中的 eBPF 钩子(hook)执行用户定义的存储函数(例如索引查找或聚合)的框架, 可以安全地绕过大部分内核的存储栈. 为了保留文件系统语义, XRP 将少量内核状态传播到 NVMe 驱动程序钩子, 在那里用户注册的 eBPF 函数被调用. 我们展示了两个键值存储, BPF-KV, 一个简单的 B+-树键值存储, 以及 WiredTiger, 一个流行的日志结构化合并树存储引擎, 如何利用 XRP 显着提高吞吐量和降低延迟.

1 介绍

随着新的高性能存储技术的兴起, 如 3D XPoint 和低延迟 NAND, 新的 NVMe 存储设备现在可以实现高达 7 GB/s 的带宽和低至 3µs 的延迟[11, 19, 24, 26]. 在如此高的性能下, 内核存储栈成为阻碍应用程序观察到的延迟和 IOPS 的开销的主要来源. 对于最新的 3D XPoint 设备, 内核的存储栈将 I/O 延迟翻倍, 并且导致了更大的吞吐量开销(§2.1). 随着存储设备变得更快, 内核的相对开销势必会恶化.
  现有的解决这个问题的方法往往是激进的, 需要侵入式的应用程序级的更改或新的硬件. 通过 SPDK[82] 等库的完全内核绕过允许应用程序直接访问底层设备, 但这些库也迫使应用程序实现自己的文件系统, 放弃隔离性和安全性, 并轮询 I/O 完成, 这在 I/O 利用率低时会浪费 CPU 周期. 其他研究表明, 当可调度线程数超过可用内核数[54]时, 使用 SPDK 的应用程序会遭受高平均和尾端延迟, 并严重降低吞吐量; 我们在 §6 中证实了这一点, 表明在这种情况下, 应用程序使用 SPDK 确实遭受了 3 倍的吞吐量损失.
  与这些方法相比, 我们寻求一种易于部署的机制, 可以提供对新兴快速存储设备的快速访问, 在使用现有内核和文件系统时, 不需要专门的硬件, 也不需要对应用程序进行重大更改. 为此, 我们依靠 BPF (Berkeley Packet Filter[67, 68]), 它允许应用程序将简单的函数 offload (卸载)到 Linux 内核[8]. 与内核旁路类似, 通过将应用程序逻辑嵌入内核栈深处, BPF 可以消除与内核-用户跨越和关联上下文切换相关的开销. 与内核旁路不同, BPF 是一种操作系统支持的机制, 它可以保证隔离性, 不会因为忙等待(busy-waiting)而导致低利用率, 并允许大量线程或进程共享相同的内核, 从而提高整体利用率.
  Linux 内核对 BPF 的支持使其成为一个有吸引力的接口, 允许应用程序加速存储 I/O. 但是, 使用 BPF 来加速存储会带来一些独特的挑战. 与现有的数据包过滤和跟踪用例不同, 其中每个 BPF 函数都可以在特定数据包或系统跟踪上以独立的方式运行——例如, 网络数据包首部指定它们下面的流——存储 BPF 函数可能需要与其他并发应用程序级操作同步或需要多个函数调用来遍历大型磁盘数据结构, 我们将这种工作负载模式称为 I/O 的"重提交" (§2.3) . 不幸的是, 重提交所需的状态, 例如访问控制信息或关于单个存储块如何适应它们所属的更大数据结构的元数据, 在较低层不可用.
  为了应对这些挑战, 我们设计并实现了 XRP (eXpress Resubmission Path), 一个使用 Linux eBPF 的高性能存储数据路径. XRP 的灵感来自最近高效的 Linux eBPF 网络钩子[28], XDP. 为了最大限度地提高性能, XRP 在 NVMe 驱动程序的中断处理程序中使用了钩子, 从而绕过了内核的块、文件系统和系统调用层. 这允许 XRP 在每个 I/O 完成时直接从 NVMe 驱动程序触发 BPF 函数, 从而能够快速重提交遍历存储设备上其他块的 I/O.
  XRP 的关键挑战是底层 NVMe 驱动程序缺乏高层提供的上下文. 这些层包含的信息包括谁拥有块(文件系统层)、如何解释块的数据, 以及如何遍历磁盘上的数据结构(应用层).
  我们的见解是, 许多支持现实世界数据库的存储优化数据结构[10, 12, 20, 27, 44, 66, 70, 80]——例如磁盘 B- 树、日志结构化合并树, 和日志段——通常是在一小组大文件上实现的, 而且它们的更新频率比读取频率低几个数量级; 我们在 §3 中验证了这一点. 因此, 我们只聚焦 XRP 于包含在一个文件中的操作和在磁盘上具有固定布局的数据结构. 因此, NVMe 驱动程序只需要最少量的文件系统映射状态, 我们称之为元数据摘要; 此信息足够小, 可以从文件系统传递到 NVMe 驱动程序, 这样它就可以安全地执行 I/O 重提交. 这使得 XRP 可以安全地支持一些最流行的磁盘数据结构.
  我们提出了 XRP 在 Linux 上的设计和实现, 支持 ext4, 可以很容易地扩展到其他文件系统. XRP 使 NVMe 中断处理程序能够基于用户定义的 BPF 函数重提交存储 I/O.
  我们用 XRP 扩充了两个键值存储: BPF- KV, 一个基于 B+-树的键值存储, 是为支持 BPF 函数而定制设计的; 以及 WiredTiger 的日志结构化合并树, 它被用作 MongoDB 的存储引擎[27]之一. 使用磁盘上具有三个索引级别的 B+-树, 通过多线程在 BPF-KV 上随机读取 512 B 对象, XRP 比 read() 的吞吐量高 47%-94%, p99 延迟低 20%-34%. XRP 还可以比内核旁路更有效地在应用程序之间共享内核: 当两个线程共享同一内核时, 它能够提供比 SPDK 好 56% 的 p99 延迟. 此外, 在 YCSB [41] 下, XRP 能够将 WiredTiger 的性能持续提高高达 24%. 我们在 https://github.com/xrp-project/XRP 上开源了 XRP 以及我们对 BPF-KV 和 WiredTiger 的更改.
  我们做出以下贡献.

  1. 新的数据路径. XRP 是第一个支持使用 BPF 将存储函数 offload 到内核的数据路径.
  2. 性能. 与普通系统调用相比, XRP 将 B-树查找的吞吐量提高了 2.5 倍.
  3. 利用率. XRP 提供接近内核旁路的延迟, 但与内核旁路不同的是, 它允许内核被相同的线程和进程高效地共享.
  4. 可扩展性. XRP 支持不同的存储用例, 包括不同的数据结构和存储操作(例如, 索引遍历、范围查询、聚合).

2 背景和动机

在本节中, 我们将展示为什么 Linux 内核正在成为快速 NVMe 设备的主要瓶颈, 并提供关于 BPF 的入门知识.

2.1 软件现在是存储的瓶颈

3D Xpoint[1] 和低延迟 NAND[26] 等新媒介催生了新的 NVMe 存储设备, 这些设备表现出个位数微秒的延迟和数百万 IOPS [11, 19, 24, 26]. 访问这些设备时, 内核存储栈正成为主要的性能瓶颈. 图 1 显示了在不同存储设备上发出 512 B 随机读取 I/O 时在 Linux 栈中花费的时间百分比. 虽然第一代快速 NVMe 设备 (第一代 Intel Optane 或 Z-NAND) 的软件开销不可忽略 (约 15%), 但对于最新一代设备 (Intel Optane SSD P5800X), 软件开销约占每个读取请求延迟的一半. 随着存储设备变得更快, 内核的相对开销只会变得更糟.
在这里插入图片描述

时间都去哪儿了? 表 1 显示了在 Optane P5800X 上使用 O_DIRECT 发出随机 512 B 读取时在不同存储层中花费的时间. 本节中使用的实验设置是一台配备 6 核 i5-8500 3 GHz 和 16 GB 内存的服务器, 使用 Ubuntu 20.04 和 Linux 5.8.0. 我们还禁用处理器低功耗模式(C-states)和睿频加速(Turbo Boost), 使用最大性能调控器, 并禁用 KPTI[30]. 实验表明, 最昂贵的层是文件系统 (ext4) , 其次是块层 (bio) 和内核跨越, 总软件开销占平均延迟的 48.6%.
在这里插入图片描述

为什么不直接绕过内核? 消除内核开销的一种方法是完全绕过它[7, 65, 82, 83], 只留下向 NVMe 驱动程序发送请求的成本和设备的延迟. 然而, 内核旁路并不是万能的: 每个用户都被赋予了对设备的完全访问权限; 它们还必须构建自己的用户空间文件系统[73, 74]. 这意味着没有机制来强制执行细粒度隔离或在访问同一设备的不同应用程序之间共享容量. 此外, 用户空间应用程序没有有效的方法来接收 I/O 完成时的中断, 因此应用程序必须直接轮询设备完成队列以获得高性能. 因此, 当 I/O 不是瓶颈时, 内核不能在进程之间共享, 这将导致严重的利用率不足和 CPU 浪费. 此外, 当多个轮询线程共享同一个处理器时, 它们之间的 CPU 争用加上缺乏同步将导致所有轮询线程经历降级的尾端延迟和显著降低的总体吞吐量. 最近的工作强调了这个问题[54], 我们在 §6.2 中重复了它.

2.2 BPF 入门

BPF (Berkeley Packet Filter) 是一个接口, 允许用户 offload 一个简单的函数由内核执行. Linux 的 BPF 框架称为 eBPF (extended BPF, 扩展BPF)[23]. Linux eBPF 通常用于包过滤(如 TCPdump)[5, 6, 28, 52]、负载均衡和包转发[5, 18, 25, 60]、跟踪[2, 4, 50]、数据包导向[46]、网络调度[53,58]和网络安全检查[15]. 函数在安装时由内核验证以确保它们是安全的; 例如, 检查它们以确保它们不包含太多指令、无界循环或对越界内存地址[29]的访问. 在验证之后(通常需要几秒钟或更短的时间), eBPF 函数就可以正常被调用.

2.3 BPF 的潜在效益

BPF 可以是一种避免内核空间和用户空间之间的数据移动的机制,用于逻辑查找需要一系列会生成应用程序不直接需要的中间数据(如在指针追踪工作负载中)的"辅助" I/O 请求时. 例如, 要遍历 B-树索引, 每一级查找遍历内核的整个存储栈, 只有当应用程序获得指向树中下一个子节点的指针时, 才会将其丢弃. 代替从用户空间执行的一系列系统调用, 每个中间指针查找都可以由 BPF 函数执行, BPF 函数解析 B-树节点以查找指向相关子节点的指针. 然后内核将提交 I/O 以获取下一个节点. 将这样的 BPF 函数序列链接起来可以避免遍历内核层和移动数据到用户空间的开销.
  其他流行的磁盘数据结构,如日志结构化合并树(log-structured merge trees, LSM 树)[70], 也有这样的辅助指针查找, 可以使用 BPF 函数加速. 可以从这种方法中获益的其他类型的操作包括范围查询、迭代器和其他类型的聚合(例如, 获取键值对范围内的最大值或平均值). 在所有这些情况下, 最终只需要将存储系统可能访问的单个结果或一小部分对象返回给应用程序.
  在辅助 I/O 工作负载中重提交(分派) I/O 的 BPF 函数可以放置在内核的任何层. 图 2 显示了用于正常用户空间调度和 BPF 重提交钩子两个可能位置的 I/O 路径: 在系统调用层和 NVMe 驱动程序中. Zhong 等人[85]通过测量在深度为 10 的磁盘 B-树上查找查询的速度, 比较了两个位置上重提交钩子对工作负载和辅助 I/O 的性能提升. 比较的基准是通过 read 系统调用读取 I/O. 表 2 总结了结果.
在这里插入图片描述
在这里插入图片描述

最佳案例加速. 从 NVMe 驱动程序调度 I/O 请求可以显著降低延迟(高达 49%)并相应加速(高达 2.5 倍), 因为它几乎绕过了整个内核软件栈. 另一方面, 正如预期的那样, 从系统调用调度层发出 BPF 函数只能提供 1.25 倍的最大加速, 因为请求只受益于消除内核边界跨越, 这只占内核开销的 5-6% (表 1). 在达到 CPU 饱和后, 根据工作负载中的线程数, 重新发布 NVMe 驱动程序提交的计算节省能转化为吞吐量提高 1.8-2.5 倍[85].
  在内核中的任何位置放置 eBPF 钩子可提高 1.2–2.5 倍的吞吐量. 然而, 将 I/O 调度推到尽可能靠近存储设备的位置会显著提高遍历的性能. 因此, 为了获得尽可能高的速度, XRP 的重提交钩子应该驻留在 NVMe 驱动程序中.

io_uring 如何? io_uring 是一个新的 Linux 系统调用框架[9], 它允许进程提交批量异步 I/O 请求, 从而平摊用户-内核跨越. 然而, 使用 io_uring 提交的每个 I/O 仍然要经过表 1 所示的所有层, 因此每个单独的 I/O 仍然会带来完整的存储栈开销. 事实上, BPF I/O 重提交在很大程度上是对 io_uring 的补充: io_uring 可以有效地提交一批 I/O, 这些 I/O 触发内核中由 BPF 管理的不同 I/O 链.
  图 3 显示了在 NVMe 驱动程序中使用 io_uring 和 BPF 钩子时的吞吐量提升. I/O 链长度表示 I/O 的总数, 包括初始 I/O 和重提交的 I/O. 图 3 显示, 对于小批量大小, BPF 可以将 io_uring 的吞吐量提高 1.5 倍, 随着批量大小的增加, 吞吐量可提高 2.5 倍.
在这里插入图片描述

  总之, BPF 可以使传统的读取和 io_uring 系统调用受益. 通过将钩子放置在内核 NVMe 驱动程序中, BPF 可以将传统 I/O 和单线程 io_uring 的吞吐量提高 2.5 倍.

3 设计挑战和原则

如前一节所示, I/O 重提交必须尽可能靠近设备, 以获得最大效益. 在 NVMe 软件栈中, 这是 NVMe 中断处理程序. 然而, 从缺少文件系统层上下文的 NVMe 中断处理程序中执行重提交会带来两大挑战.

挑战 1: 地址转换和安全. NVMe 驱动程序无法访问文件系统元数据. 在索引遍历的示例中, XRP 向特定块发出读 I/O 并执行 BPF 函数, 该函数将提取它要查询的下一个块的偏移量. 然而, 这个偏移量对于 NVMe 层是没有意义的, 因为它不能在无法访问文件元数据和区块的情况下告诉偏移量对应哪个物理块. 即使应用程序开发人员努力嵌入物理块地址以避免文件系统偏移量的转换, 这会很麻烦, BPF 函数也可以访问设备上的任何块, 包括属于用户没有访问权限的文件的块.

挑战2: 并发和缓存. 用 XRP 实现文件系统发出的并发读写是一个挑战. 从文件系统发出的写入只会反映在页面缓存中, 这对 XRP 是不可见的. 此外, 与读取请求同时发出的任何修改数据结构布局的写入(例如, 修改指向下一个块的指针)都可能导致 XRP 意外获取错误数据. 这两个问题都可以通过加锁来解决, 但是从 NVMe 中断处理程序中访问锁可能代价高昂.

观察: 大多数磁盘上的数据结构是稳定的. 这两个挑战都使得实现任意并发 BPF 存储函数变得困难. 然而, 我们观察到许多存储引擎(例如, LSM 树和 B-树)的文件保持相对稳定. 有些数据结构根本不会就地修改磁盘上的存储结构. 例如, 一旦 LSM 树将它的索引文件(称为 SSTable)写入磁盘, 它们在被删除之前是不可变的[12,27,44]. 访问这些不可变的磁盘存储结构需要较少的同步工作. 类似地, 尽管一些磁盘上的 B-树索引实现支持就地更新, 但它们的文件区段(extent)长时间保持稳定. 我们在 MariaDB 上运行 TokuDB[20] 的 24 小时 YCSB[41] (40% 读取, 40% 更新, 20% 插入, Zipfian 0.7) 实验中验证了这一点, 该实验使用分形树(磁盘上的 B-树变体)作为查找索引. 我们发现索引文件的区段平均每 159 秒才更改一次, 其中 24 小时内只有 5 次取消映射任何块的区段更改, 这使得可以在 NVMe 驱动程序中缓存文件系统元数据, 而没有频繁更新的开销. 我们还观察到, 在所有这些存储引擎中, 索引都存储在少量大文件上, 并且每个索引不跨越多个文件.

设计原则. 这些观察和实验为以下设计原则提供了依据.

  • 一次一个文件. 我们最初将 XRP 限制为仅在单个文件上发布链式重提交. 这大大简化了地址转换和访问控制, 并将我们需要向下推送到 NVMe 驱动程序的元数据降到最低 (元数据摘要, §4.1.3).
  • 稳定的数据结构. XRP 以布局 (即指针) 在很长一段时间 (即秒或更长时间) 内保持不变的数据结构为目标. 此类数据结构包括许多流行的商业存储引擎中使用的索引, 如 RocksDB[44]、LevelDB[12]、TokuDB[12] 和 WiredTiger[27]. 由于在 NVMe 层中实现锁的成本很高, 我们最初也不打算支持在遍历或迭代数据结构期间需要锁的操作.
  • 用户管理的缓存. XRP 没有页面缓存的接口, 因此如果在内核页面缓存中缓冲块, 则 XRP 函数不能安全地并发运行. 这种约束是可以接受的, 因为流行的存储引擎通常实现自己的用户空间缓存[20, 27, 39, 44]; 通常, 他们这样做是为了微调缓存和预取策略, 并以有应用意义的方式缓存数据 (例如, 缓存键值对或数据库行而非物理块).
  • 慢路径回退. XRP 是尽力而为的; 如果由于某种原因 (例如, 区段映射变得过时) 遍历失败, 则应用程序必须重试或回退到使用用户空间系统调用调度 I/O 请求(时的状态).

4 XBR 的设计与实现

本节介绍了 XRP 在 Linux eBPF 和 ext4 中的设计和实现. 我们描述了在中断处理程序中启用 XRP 重提交逻辑的内核修改, 以及如何修改应用程序以使用 XRP. 我们还讨论了 XRP 的同步和调度限制.

4.1 重提交逻辑

XRP 的核心是用重提交逻辑扩充了 NVMe 中断处理程序, 该逻辑由 BPF 钩子、文件系统转换步骤, 以及在新物理偏移处构造和重提交下一个 NVMe 请求组成(图 4). 我们对 Linux 内核的修改包括约 900 行代码: 约 500 行用于 BPF 钩子和 NVMe 驱动程序的更改, 约 400 行用于文件系统转换步骤.
在这里插入图片描述
  当 NVMe 请求完成时, 设备会生成一个中断, 使内核上下文切换到中断处理程序. 对于在中断上下文中完成的每个 NVMe 请求, XRP 调用其关联的 BPF 函数 (图 4 中的 bpf_func_0) , 其指针存储在内核 I/O 请求结构体 (即 struct bio) 的字段中. 调用 BPF 函数后, XRP 调用元数据摘要, 它通常是文件系统状态的摘要, 使 XRP 能够转换下一次重提交的逻辑地址. 最后, XRP 通过在 NVMe 请求中设置相应字段来准备下一个 NVMe 命令重提交, 并将请求附加到该核心的 NVMe 提交队列 (SQ) 中.
  对于特定的 NVMe 请求, 根据 NVMe 请求中注册的特定 BPF 函数确定的后续完成所需的次数, 调用重提交逻辑. 例如, 对于遍历树状数据结构, BPF 函数将重提交分支节点的 I/O 请求, 并在找到叶子节点时结束重提交. 在我们当前的原型中, 在完成将控制权返回给应用程序之前, 对重提交的次数没有硬性限制; 但这样的限制对于防止无限执行是必要的. 通过在每个 I/O 请求描述符中维护重提交计数器, 可以强制执行硬限制. 由于无法从用户空间或 XRP 的 BPF 程序访问 I/O 请求描述符, 因此即使 XRP 具有多个执行请求重提交的 BPF 函数, 其硬重提交限制也无法被用户覆盖. BPF 函数的上下文是每个请求的, 而元数据摘要在所有内核中断处理程序的所有调用之间共享. 对元数据摘要的安全并发访问依赖于读-拷贝-更新(read-copy-update, RCU) (§4.1.3) .

4.1.1 BPF 钩子

XRP 引入了一种新的 BPF 类型 (BPF_PROG_TYPE_XRP), 其签名如清单 1 所示——任何与签名匹配的 BPF 函数都可以被钩子调用. §5 给出了一个具体的 BPF 函数, 该函数与我们应用程序中使用的签名相匹配. 例如, 对于磁盘上的数据结构的遍历, BPF 函数通常包含从块中提取下一个偏移量的逻辑.
在这里插入图片描述
  BPF_PROG_TYPE_XRP 程序需要一个包含五个字段的上下文, 这些字段被分成被 BPF 调用者检查或修改的字段 (中断处理程序中的重提交逻辑), 以及 BPF 函数私有字段. 外部访问的字段包括 data, 用于缓存从磁盘读取的数据 (例如, 等待 BPF 函数解析的 B-树页面). done 是一个布尔值, 它通知重提交逻辑是返回给用户还是继续重提交 I/O 请求. next_addrsize 是逻辑地址数组及其对应的大小, 指示下一个逻辑地址以供重提交.
  为了支持带有扇出的数据结构, 可以提供多个 next_addr 值. 默认情况下, 我们将扇出限制为 16; 磁盘上的数据结构将其组件与设备页面的小倍数对齐, 因此我们没有遇到每次完成时需要更高的扇出. 例如, 链哈希表桶可能被实现为单个物理页面的链, 而磁盘上链接列表的元素可能以物理页面的粒度实现. 将相应的大小字段设置为零则不会发出 I/O.
  scratch 是对用户和 BPF 函数私有的暂存空间. 它可用于将用户的参数传递给 BPF 函数. 此外, BPF 函数可以使用它在 I/O 重提交之间存储中间数据, 并将数据返回给用户. 例如, 在第一次 BPF 调用中, 应用程序可以将搜索关键字存储在暂存缓冲区中, 以便 BPF 函数可以将其与磁盘块中的关键字进行比较, 以便找到下一个偏移量. 当 I/O 链到达 B-树的叶子节点时, BPF 函数会将键值对放置在暂存缓冲区中, 以将其返回给应用程序. 为了简单起见, 我们假设暂存缓冲区的大小始终为 4 KB. 我们发现, 4 KB 的暂存缓冲区足以支持生产键值存储的 BPF 函数 (§5) . 如果 BPF 函数的中间数据无法放入暂存缓冲区, 则 BPF 函数还可以使用 BPF 映射来存储更多数据. 每个 BPF 上下文对一个 NVMe 请求都是私有的, 因此在处理 BPF 上下文状态时不需要加锁. 让用户提供暂存缓冲区 (而不是使用 BPF 映射) 可以避免进程和函数必须调用 bpf_map_lookup_elem 来访问暂存缓冲区的开销.

4.1.2 BPF 校验器

BPF 校验器通过跟踪存储在每个寄存器中的值的语义来确保内存安全[14]. 有效值可以是标量或指针. SCALAR_TYPE 表示无法解引用的值. 校验器定义了各种指针类型; 其中的大多数都包含了除不能越界访问要求外的额外约束. 例如, PTR_TO_CTX 是指向 BPF 上下文的指针类型. 它只能使用常量偏移量解引用, 这样校验器就可以识别一个内存操作访问的哪一上下文字段. 每个 BPF 函数类型还定义了一个回调函数 is_valid_access(), 以对上下文访问执行其他检查, 并返回上下文字段的值类型. PTR_TO_MEM 描述指向固定大小内存区域的指针. 它支持使用可变偏移量解引用, 只要访问始终在边界内. BPF_PROG_TYPE_XRP 上下文的 datascratch 字段为 PTR_TO_MEM, 其余字段为 SCALAR_TYPE. 我们增强了校验器, 以允许 BPF_PROG_TYPE_XRPis_valid_access() 回调将数据缓冲区或便签缓冲区的大小传递给校验器以便其执行边界检查. 我们与 Linux eBPF 的维护人员讨论了我们对校验器的修改建议, 他们认为这是合理的.

4.1.3 元数据摘要

在传统的存储栈中, 磁盘上数据结构中的逻辑块偏移量由文件系统转换, 以便识别下一个要读取的物理块. 此转换步骤还强制执行访问控制和安全, 防止读取到未映射到打开文件的区域. 在 XRP 中, 查找的下一个逻辑地址由 BPF 调用后的 next_addr 字段给出. 然而, 将这个逻辑地址转换为物理地址是有挑战性的, 因为中断处理程序没有文件的概念, 也不执行物理地址转换.
  为了解决这个问题, 我们实现了元数据摘要, 这是文件系统和中断处理程序之间的一个细小的接口, 允许文件系统共享其逻辑-到-物理块的映射到中断处理程序, 使基于 eBPF 的磁盘上重提交是安全的. 元数据摘要由两个函数组成 (清单 2) . 更新函数当逻辑-到-物理映射更新时, 在文件系统中被调用. 查找函数在中断处理程序中被调用; 它返回给定偏移量和长度的映射. 查找函数还通过阻止 BPF 函数请求重提交打开的文件之外的块来强制执行访问控制. 打开文件的 inode 地址被传递给中断处理程序, 以便查询元数据摘要. 如果检测到无效的逻辑地址, XRP 将立即返回用户空间并带有错误代码. 然后, 应用程序可以返回到正常的系统调用以再次尝试其请求.
在这里插入图片描述
  这两个函数对于每个文件系统都是特定的, 即使对于特定的文件系统, 也可能有多种方法来实现元数据摘要, 从而在易于实现和性能之间进行权衡. 例如, 在我们的 ext4 实现中, 元数据摘要由区段状态树的缓存版本组成, 它存储物理到逻辑块的映射. 该缓存树由接口的更新函数和查找函数访问, 并使用读-拷贝-更新 (RCU) 进行并发控制. RCU 使查找函数无需加锁且快速 (平均 96 ns).
  为了使缓存树与 ext4 中的 extent 区段保持最新, 在 ext4 的两个位置调用更新函数: 每当从主分区区段树中插入或删除 extent 区段时. 为了防止发生竞争条件当一个区段在飞行中读取时被修改, 我们为每个区段维护一个版本号以跟踪其更改. 在读取数据之后, 但在将其传递给 BPF 函数之前, 执行第二次元数据摘要查找. 如果相应的区段不再存在或其版本号已更改, XRP 将中止操作. 由于应用程序级同步通常会防止同时对文件的同一区域进行并发修改和查找, 因此只有当应用程序存在错误或恶意时, 才会出现版本不匹配.
  另一种更简单的 ext4 元数据摘要的实现可以简单地传递到 ext4 中现有的区段树更新和访问函数. 在这种情况下, 更新函数将是一个空操作, 因为 ext4 已经保持其区段树的最新状态. 然而, 这样的实现在查找路径上会慢得多, 因为 ext4 中的区段查找函数获取了一个自旋锁, 这在中断处理程序中会非常昂贵.
  目前, XRP 只支持 ext4 文件系统, 但元数据摘要可以很容易地实现于其他文件系统. 例如, 在 f2fs[64] 中, 逻辑-到-物理块映射存储在节点地址表 (node address table, NAT) 中. 与 ext4 实现类似, 其元数据摘要的实现可以缓存 NAT 的本地副本, 这将在 lookup_mapping 中进行查询. 然后, update_mapping 需要在 f2fs 中 NAT 更新的位置被调用.

4.1.4 重提交 NVMe 请求

查找物理块偏移后, XRP 准备下一个 NVMe 请求. 由于此逻辑发生在中断处理程序中, 为了避免准备 NVMe 请求所需的 (缓慢的) kmalloc 调用, XRP 重用刚刚完成的请求的现有 NVMe 请求结构体 (即 struct nvme_command) . XRP 只需将现有 NVMe 请求的物理扇区和块地址更新为从映射查找中导出的新偏移量. 重新使用 NVMe 请求结构体以立即重提交是安全的, 因为无论是用户空间还是 XRP BPF 程序都无法访问原始 NVMe 请求结构体.
  虽然 struct bpf_xrp 支持最多 16 个扇出, 但在当前实现中, 重提交的 I/O 请求只能获取与初始 NVMe 请求一样多的物理段. 例如, 如果初始 NVMe 请求仅获取单个块, 则该请求的所有后续重提交只能获取单个物理段. 在重提交链期间, 如果 BPF 调用在 next_addr 中返回多个有效地址, XRP 将中止请求. 可以通过在第一个 I/O 请求中分配和设置 16 个虚拟 NVMe 命令来解决此限制, 以便后续重提交可以在必要时表示扇出.

4.2 同步限制

BPF 目前只支持一个有限的自旋锁用于同步. 校验器只允许 BPF 程序一次获取一个锁, 并且必须在返回之前释放锁. 此外, 用户空间应用程序不能直接访问这些 BPF 自旋锁. 相反, 它们必须调用 bpf() 系统调用; 该系统调用可以读取或写入锁保护的结构, 同时在操作期间持有锁. 因此, 需要跨多个读写进行同步的复杂修改无法在用户空间中完成.
  用户可以使用 BPF 原子操作实现自定义自旋锁. 这允许 BPF 函数和用户空间程序直接获取任何自旋锁. 然而, 终止约束禁止 BPF 函数自旋以无限等待自旋锁. 另一个同步选项是 RCU. 由于 XRP BPF 程序在 NVMe 中断处理程序中运行, 不能被抢占, 事实上它们已经在 RCU 读端临界区中.

4.3 与 Linux 调度器的交互

进程调度器. 有趣的是, 我们观察到, 像 Optane SSD 这样的微秒级存储设备也会干扰 Linux 的 CFS, 当多个进程共享同一内核时, 即使所有 I/O 都是从用户空间发出的. 例如, 在 I/O 密集型进程和计算密集型进程共享同一内核的情况下, 由 I/O 密集型进程生成的 I/O 中断将在计算密集型进程的时间片中处理. 这可能会导致密集型进程 CPU 饥饿; 在我们实验中最坏的情况下, 计算量大的进程只获得了"公平"分配 CPU 时间的 34%. 我们通过实验验证了在使用较慢的存储设备时不会发生这种情况, 因为它生成中断的频率要低得多. 虽然 XRP 通过生成中断链加剧了这个问题, 但这个问题并不是 eBPF 特有的, 也可能由网络驱动的中断引起[59]. 我们把这个问题留给以后的工作.

I/O调度器. XRP 绕过了位于块层的 Linux 的 I/O 调度器. 然而, noop 调度器已经是 NVMe 设备的默认 I/O 调度器, 如果需要公平性, NVMe 标准支持硬件队列仲裁[17].

5 案例研究

为了使用 XRP, 应用程序使用清单 3 所示的接口. 应用程序调用 libbpf[13] 函数 bpf_prog_load, 以加载 BPF_PROG_TYPE_XRP 类型的 BPF 函数, 将其 offload 到驱动程序中, 调用 read_xrp 将特定 BPF 函数应用于读取请求. 应用程序可以使用 XRP 加载多个 BPF 函数. 例如, 数据库可以加载一个函数用于从磁盘上的值筛选和计算聚合, 另一个函数用于 GET 点查找. XRP 允许应用程序将多个 BPF 函数加载到内核中, 并指定要在每个 read_xrp 系统调用中使用的 BPF 函数. 我们提出了两个关于如何修改应用程序以使用 XRP 的案例研究.
在这里插入图片描述

5.1 BPF-KV

我们构建了一个简单的键值存储, 称为 BPF-KV, 使用它我们可以根据其他基准线评估 XRP: Linux 的同步和异步系统调用以及内核旁路 (SPDK[82]). BPF-KV 被设计为存储大量小对象, 并且即使在统一的访问模式下也能提供良好的读取性能. BPF-KV 使用 B+-树索引来查找对象的位置, 对象本身存储在未排序的日志中. 为了简单起见, BPF-KV 使用固定大小的键 (8 B) 和值 (64 B). 索引和日志都存储在一个大文件中. 索引节点使用简单的页面格式, 标题后面跟着键, 后面跟着值. 叶子节点包含指向下一个叶节点的文件偏移量, 从而实现范围查询和聚合的高效索引遍历. 对象大小是固定的, 因此更新会在未排序的日志中发生. 新插入的项目将追加到日志中; 它们的索引最初存储在内存哈希表中. 哈希表填满后, BPF-KV 将其与磁盘上的 B+树文件合并.

缓存. BPF-KV 为索引块和对象实现了用户空间 DRAM 缓存. 为了减少查找所需的 I/O 数量, BPF-KV 缓存了 B+树索引的前 k k k 级. 如果对象数量足够多, 则不可能将整个索引放入缓存中. 考虑使用 BPF-KV 存储 100 亿 64 B 对象的情况. 在 BPF-KV 的索引中, 每个节点是 512 B (匹配 Optane SSD 的访问粒度); 因此, 树具有 31 个扇出 (即, 每个内部节点可以存储指向 31 个子节点的指针). 因此, 100 亿个对象需要 8 个级别的索引. 在 DRAM 中安装 6 个索引级别是昂贵的, 需要 14 GB, 而安装 7 个或更多级别则变得昂贵得令人望而却步 (437 GB 或更多 DRAM). 因此, 为了支持大量键名, BPF-KV 每次查找至少需要 3-4 个来自存储器的 I/O, 包括从磁盘获取实际键值对的最终 I/O. 还要注意, 在许多真实世界的键值存储 (例如, RocksDB[45]、DocumentDB[78]、SplinterDB[40]、TokuDB[20]) 中, 缓存索引的硬内存预算是常见的, 因为索引缓存通常与系统中需要内存的其他部分 (如过滤器和对象缓存) 竞争.
  BPF-KV 还维护最热门键值对的最近最少使用 (least recent use, LRU) 对象缓存. 在磁盘上查找对象之前, BPF-KV 首先检查它是否存储在对象缓存中. 如果没有, 它将检查是否在内存哈希表中对其进行了索引. 如果在内存哈希表中找不到该项, 它将通过访问索引的前 k k k 个缓存级别来查找该对象. 一旦遇到未缓存的索引节点, 它就会完成索引和磁盘上的最终查找.
  为了找到没有 XRP 的对象, BPF-KV 遍历 B-树, 直到使用每个级别的 I/O 请求找到所需的值. 例如, 如果索引包含 7 个级别, 并且前 3 个级别被缓存并从 DRAM 中读取, 则遍历将发出 4 个 I/O 来导引树的其余部分, 然后发出最后一个 I/O 来从日志中获取对象.

BPF 函数. 清单 4 显示了 BPF-KV 中用于查找键值对的 BPF 函数. 为了简单起见, 我们省略了处理日志中的最终查找的代码. struct node 定义大小为 512 B 的 B+-树索引节点的布局. BPF 函数 bpfkv_bpf 首先提取暂存缓冲区中存储的目标键, 然后线性搜索当前节点中的槽, 以查找下一个要读取的节点.
在这里插入图片描述

接口修改. 我们用 read_xrp 替换 read 调用. 在调用 read_xrp 之前, BPF-KV 首先为暂存空间分配一个缓冲区, 并计算开始查找的偏移量.

范围查询. BPF-KV 支持返回可变数量的对象的范围查询. 我们实现了一个作为状态机运行的 BPF 函数, 允许在对象返回到应用程序进行处理时暂停和恢复操作. BPF 函数状态 (包括范围的开始和结束以及检索到的对象) 存储在暂存空间中 (最多 32 个 72 字节的键值对) . 在初始调用时, 函数遍历到包含起始键的叶子节点. 一旦找到范围中的第一个键, 函数就会将叶子节点存储在暂存空间中, 并请求包含相应值的块. 在下一次 BPF 调用时, 函数将值存储在暂存空间中, 并继续对缓存的叶子节点进行索引扫描. 当叶子节点被完全读取后, 函数使用节点的下一个叶文件偏移量提交对下一个叶子节点的请求. 函数在三种情况下返回到应用程序: 1) 函数到达超过范围末尾的键; 2) 函数到达索引的末尾; 3) 该函数用从日志中读取的值填充暂存空间. 在最后一种情况下, 应用程序可以处理这些值并使用范围查询状态重新调用 BPF 函数, 从而允许范围查询从其停止的位置恢复.

聚合. BPF-KV 还支持聚合操作, 如 SUMMAXMIN. 我们在 BPF 范围查询函数之上实现这些操作, 方法是设置一个比特位, 使函数执行相应的聚合, 而不是返回单个值. 由于聚合查询返回单个结果, 所以在暂存空间中存储值不会限制 BPF 函数可以请求的 I/O 重提交次数.

5.2 WiredTiger

WiredTiger 是一个流行的键值存储, 它是 MongoDB 的默认后端[27]. 我们使用它作为案例研究, 因为它是一个相对简单和开源的用于实际生产的键值存储. WiredTiger 提供了一种使用 LSM 树的选项, 其中数据被划分为不同的级别; 每个级别包含一个文件. 每个文件都使用 B-树索引, 其中键值对嵌入到树的叶子节点中. 文件是只读的; 更新和插入被写入内存中的缓冲区. 当缓冲区已满时, 数据将写入新文件. 我们将 B-树页面大小配置为与 Optane SSD 的块大小 (512 B) 相同. 我们对 WiredTiger 的修改是大约 500 行代码, 主要包括缓冲区分配、扩展函数签名和包装 XRP 系统调用. XRP 有助于加速从磁盘服务的读取, 并且它不会影响更新或插入, 而这些更新或插入总是由 WiredTiger 的内存缓冲区吸收.

BPF函数. 为了使用 XRP, WiredTiger 安装了一个类似于清单 4 所示的 BPF 函数. 不同之处在于, 为了从当前页面找到下一个查找地址, BPF 函数包含 WiredTiger B-树页面解析代码的一个端口. 这个解析逻辑替换了清单 4 中的 for 循环.
  WiredTiger BPF 函数还进行了一些修改, 以使 BPF 程序正确编译并通过 BPF 校验器. 修改主要包括在循环上添加边界以避免无限循环, 屏蔽指针以消除越界访问, 以及初始化局部变量以防止访问未初始化的寄存器. 我们还使用 BPF 逐函数 (function-by-function) 验证功能[3]将复杂函数分解为几个简单的子函数. 这允许独立验证 BPF 函数, 因此已验证的函数在被其他函数调用时不需要另一轮验证. 逐函数验证功能还支持更复杂的 BPF 程序, 而不会超出校验器对函数长度的限制.

缓存. WiredTiger 为其 B-树内部页面和叶子页面维护一个最近最少使用的 (LRU) 缓存. 当查找新的键值对时, WiredTiger 将整个查找路径 (包括缓存中的叶子页面) 缓存起来. 为了符合 WiredTiger 缓存语义, 上一节中描述的 BPF 函数还返回所有遍历的页面, 以便 WiredTiger 可以缓存它们. BPF 函数将遍历的页面存储在其上下文的暂存缓冲区中. 当暂存缓冲区耗尽时, BPF 函数将停止重提交请求, 并立即返回用户空间. 在 WiredTiger 将这些页面添加到其缓存中之后, 它将再次调用 read_xrp 以从上一页开始继续查找. 由于我们将暂存缓冲区的大小设置为 4 KB, 因此 BPF 函数可以在暂存缓冲区中存储多达 6 次遍历的 512 B 页面, 这为搜索键等必要的元数据留出了空间.

接口修改. 为了将 WiredTiger 与 XRP 集成, 我们用 read_xrp 替换了正常的 read 调用. 当下一页面不在缓存中并且需要从磁盘读取时, 调用 read_xrp. WiredTiger 的逐出策略强制只有没有任何缓存子页的页面才能被逐出, 因此任何未缓存的页面都不会有缓存子页面. 因此, 调用 read_xrp 从磁盘读取所有剩余路径是安全的, 而无需再次检查应用程序级缓存. 如果 read_xrp 由于任何原因失败, WiredTiger 将返回正常查找路径. 我们为每个 WiredTiger 会话分配一个数据和暂存缓冲区, 以避免为每个请求分配和释放缓冲区的开销. WiredTiger 会话一次同步处理一个请求, 从而避免了并发问题.

6 评估

在本节中, 我们试图回答以下问题:

  1. 使用 BPF 进行存储的开销是多少 (§6.1)?
  2. XRP 如何扩展到多个线程 (§6.2)?
  3. XRP 可以支持哪些类型的操作 (§6.3)?
  4. XRP 能否加速真实世界的键值存储 (§6.4)?

实验设置. 所有的实验都是在一台 6 核 i5-8500 3GHz 服务器上进行的, 该服务器具有 16 GB 内存, 使用 Ubuntu 20.04 和带有 Intel Optane 5800X 原型的 Linux 5.12.0. 所有实验都使用 O_DIRECT, 关闭超线程, 禁用处理器低功耗模式(C-states)和睿频加速(Turbo Boost), 使用最大性能调节器, 并启用 KPTI[30]. 我们在实验中使用 WiredTiger 4.4.0.

基准线. 我们比较了以下配置: (a) XRP, (b) SPDK (一种流行的内核旁路库), (c) 标准 read() 系统调用, 和 (d) 标准 io_uring 系统调用.

6.1 BPF-KV

延迟. 为了回答第一个评估问题, 我们在一个基准上测量了 BPF-KV 的性能, 该基准执行了一百万次读取操作, 键名随机抽取, 概率均匀. 实验改变了存储在磁盘上的树的层数. 在本小节中, 我们禁用数据对象和索引节点的缓存, 以聚焦于查找磁盘项的开销. 测量的平均延迟如表 3 所示. 最左边的列表示查找索引中的键所需的链 I/O 数 (不包括最终数据查找). 例如, 如果操作数为 4, 那么 BPF-KV 配置了深度为 4 的磁盘上的树, 并且还需要发出一个 I/O 以从日志中获取键值对.
在这里插入图片描述
  这个实验有一些成果. 首先, XRP 比 read() 改善了延迟, 因为 XRP 在遍历索引或从索引移动到日志时会保存一个或多个存储层遍历. 事实上, 可以看到 XRP 的延迟每增加一次 I/O 操作就增加约 3.5-3.9 µs, 这与设备的延迟接近 (表1) . 这意味着 XRP 为重提交的请求实现了接近最佳的延迟. io_uring 也是如此: 在同步提交 I/O 请求而不进行批处理的情况下, read() 和 io_uring 几乎是等价的. 其次, SPDK 表现出比 XRP 更好的延迟, 因为 XRP 必须经过内核的存储栈一次才能启动索引遍历, 而 SPDK 完全绕过内核. 尽管如此, 当 B+-树的深度增加时, XRP 的边际增加延迟接近 SPDK (2.6 µs-3.4 µs) . 因此, 在 6 级索引的情况下, XRP 仅比 SPDK 慢 45%, 而 read() 比 SPDK 慢 142%. 重要的是, XRP 在不重排序轮询的情况下实现了这一点. 这意味着, 与 SPDK 不同, 进程可以继续高效地使用 CPU 内核进行其他工作; XRP 对 CPU 时间的使用仅限于在后台重提交 I/O 和保持 I/O 设备利用率高所需的时间.
  图 5a 和图 5b 分别显示了 XRP 的第 99 百分位延迟和 99.9% 延迟. 当使用单个线程运行时, 与平均延迟结果类似, 与 read() 和 io_uring 相比, XRP 将第 99 百分位延迟和 99.9 百分位等待时间减少了 30%. 请注意, 我们的实验是作为一个闭环运行的, 因此 XRP 的吞吐量高于 read() 和 io_uring. 在相同的吞吐量下, XRP 将显示出比这些基准线的额外改进. 有趣的是, 当线程数超过内核数(6) 3 个以上时, SPDK 的 99.9% 延迟会显著增加. 这是由于使用 SPDK 时, 所有线程都忙于轮询, 无法与其他线程有效共享同一内核. 为此, 我们测量延迟大于或等于 1 ms 的请求的百分比, 并在图 5c 中显示数据. 结果表明, SPDK 在 7 个线程中有 0.03% 的此类请求, 当线程数达到 24 个时, 这个百分比增加到 0.28%. 相比之下, io_uring、read() 和 XRP 的此类请求始终少于 0.01%.
在这里插入图片描述

吞吐量. 图 6a 显示了 XRP 的吞吐量. 正如预期的那样, 随着索引深度的增加, XRP 的加速比标准系统调用更高. 图 6b 和 6c 分别显示了不同数量的线程 (深度索引为 3 和 6) 的吞吐量加速. 这两幅图都显示, 即使 I/O 和 XRP BPF 函数在多个内核上进行了扩展, XRP 相对于发出标准系统调用的速度也不会降低. 同样, 当线程数为 9 或更高时, XRP 提供的吞吐量等于或高于 SPDK.
在这里插入图片描述

6.2 线程扩展

由于存储应用程序通常使用大量访问 I/O 设备的并发线程, 例如为了处理并发请求和执行后台垃圾回收[12, 20, 27, 44], XRP 需要能够在大量线程下提供良好的尾端延迟和吞吐量. 我们分析了 XRP 如何作为一些线程的函数进行扩展, 并将其与 SPDK 进行比较. 我们运行了一个开环实验, 其中负载量与 Intel 设备的最大带宽相匹配 (512 B 随机读取时为 5M IOPS) . 图 7a 使用 6 个磁盘索引级别来比较 XRP (与 io_uring 集成) 与 SPDK (BPF-KV) 的吞吐量, 其中每个线程代表不同的租户. 两个主要观察结果是: 1) 当使用 6 个工作线程 (机器上的 CPU 核数) 时, SPDK 和 XRP 都可以实现接近硬件极限的吞吐量 (灰色虚线); 2) 一旦线程数超过 CPU 内核数, SPDK 的吞吐量就会稳步下降, 而 XRP 仍然提供稳定的吞吐量. SPDK 的吞吐量崩溃源于其基于轮询的方法; SPDK 线程永远不会让步, 将调度留给 Linux 的 CFS, CFS 在粗略的 6ms 时间片内工作. 然而, 空闲的 XRP 线程会自动将 CPU 交给繁忙的线程, 因此更多的 CPU 周期花费到了实际的工作上. 图 7b 显示了作为负载函数的 12 个工作线程下的吞吐量延迟关系. 由于线程比 CPU 内核多, SPDK 中的平均延迟和尾端延迟也会显著增加, 因为每个线程等待调度的时间比 XRP 中的更长.
在这里插入图片描述

6.3 范围查询

图 8 比较了使用 XRP 运行范围查询与使用 read() 系统调用执行查询的平均延迟和吞吐量. 在这两种情况下, 范围查询执行单个索引遍历以查找第一个对象, 并遍历索引的叶子节点以查找后续对象的地址. 在本实验中, 索引深度为 6. 尽管 XRP 范围查询在每个系统调用只能检索 32 个对象, 但结果显示这只增加了微不足道的开销. 作为聚合长度的函数, XRP 的性能加速保持相对恒定, 因为 XRP 仅对检索到的 32 个值执行一次存储栈遍历.
在这里插入图片描述

6.4 WiredTiger

为了了解 XRP 是否能为真实世界的数据库带来好处, 我们评估了 YCSB 上使用和不使用 XRP 的 WiredTiger 的性能[41]. 我们运行不同的 YCSB 工作负载, 因此它们的运行时间大致相同: YCSB A、B、C 和 E 使用 10M 操作, D 使用 50M 操作, E 使用 3M 操作. 基准线 WiredTiger 使用 pread() 读取 B-数页面, 而带有 XRP 的 WiredTiger 使用read_xrp(). 我们用 10 亿个键值对填充数据库, 并将键值的大小设置为 16 B. 数据库的总大小为 46 GB. WiredTiger 在缓存使用接近满时, 运行驱逐线程来驱逐页面, 我们将驱逐线程的数量设置为 2.

吞吐量. 图 9 显示了具有不同缓存大小和不同客户端线程数的 WiredTiger 的总吞吐量. 我们为 WiredTiger 配置了 512 MB、1 GB、2 GB 和 4 GB 的缓存大小, 以确保 WiredTiger 可以缓存至少 1% 的数据库, 同时不会耗尽机器上的所有可用内存. 我们最多运行 3 个客户端线程以避免上下文切换. 结果表明, XRP 可以将大多数工作负载的速度提高 1.25 倍. 吞吐量的提高主要受缓存大小的影响. 当缓存大小变大时, 速度通常会降低. 一般来说, XRP 在 WiredTiger 上提供的加速比在 BPF-KV 上要低, 因为 WiredTiger 在从快速 NVM 存储中读取数据方面的优化程度低于 BPF-KV, 并且仅将其总时间的 63% 用于 I/O. 特别是, XRP 没有在 YCSB D 和 YCSB E 上提供显著的提升. 这是因为 YCSB D 遵循最新的发行版, 其中新插入的项目是最受欢迎的项目. 由于新的插入总是写入内存缓冲区, 所以大多数读取操作都是从 YCSB D 中的这些缓冲区读取的. 另一方面, YCSB E 只有插入和扫描. WiredTiger 支持通过迭代器接口进行扫描, 一次只能查找一个键值对. XRP 只能有助于查找扫描操作的第一个键值对, 因为其余的键值对大多要么驻留在同一个叶子节点上, 要么只需要一个额外的 I/O 来获取下一个叶结点.
在这里插入图片描述
  为了研究访问分布对 XRP 的影响, 我们使用变化的 Zipfian 常数和均匀分布运行 YCSB C. 图 10a 显示, 由于缓存命中率的增加, 当 Zipfian 常数变大 (即, 分布更加倾斜) 时, XRP 的效益会降低. 注意, 大于 0.99 的偏斜表示非常高的偏斜水平. 我们还看到, WiredTiger 的吞吐量增益低于使用统一 YCSB C 的 BPF-KV. 这也是因为WiredTiger 将 37% 的总时间用于非 I/O 操作.
在这里插入图片描述

尾端延迟. 我们在固定负载下测量 WiredTiger 在有和没有 XRP 的情况下的尾部读取延迟: YCSB A、B、C、D、F 每个客户端线程 20 kop/s, YCSB E 每个客户端线程 5 kop/s. 由于 YCSB E 具有扫描而不是读取, 因此我们为其设置了较低的负载, 并测量尾部扫描延迟而不是尾部读取延迟. 图 10b 显示了 XRP 可以将第 99 百分位延迟降低 40%. 与吞吐量类似, 第 99 百分位延迟改善主要随着缓存大小的增大而降低, XRP 对 YCSB D 和 E 没有显著影响.

7 相关工作

有四个相关的工作领域: (a) 使用 BPF 加速 I/O (通常是网络), (b) 内核旁路系统, © 近存储计算, 以及 (d) 可扩展操作系统和库文件系统.

用于 I/O 的 BPF. 有大量的系统和框架使用 BPF 来加速 I/O 处理, 主要集中在联网和跟踪用例[2, 4–6, 15, 18, 25, 28, 37, 46, 49, 50, 52]. XDP[28] 与 XRP 最密切相关, 它通过在 NIC 驱动程序的 RX 路径中添加钩子来加速网络 I/O. 然后, 它为 eBPF 程序提供了一个接口, 可以过滤、重定向或反弹数据包.
  现有系统没有使用 BPF 从内核内重提交存储请求. Koartis 等人[62]提出了一种系统, 该系统使用 eBPF 函数作为提交分类存储请求的接口, 以避免跨越网络. 在他们的系统中, 重提交来自位于主机上的用户空间服务, 而不是内核本身提供的服务, 因为网络是主要瓶颈 (而不是内核软件栈) . ExtFUSE[36] 允许 Linux 上的用户空间文件系统将 BPF 函数加载到内核中, 以服务低级文件系统请求, 从而消除不必要的上下文切换. 虽然 ExtFUSE 加速了用户空间文件系统, 但对于已经使用标准内核文件系统 (例如 ext4) 的应用程序, 它没有提供任何性能优势, 因为它不允许应用程序绕过内核的存储栈. BMC[49] 使用 BPF 通过在主机上拦截网络路径上的数据包来加速内存缓存. 然后, BPF 函数可以访问一个单独的基于内核的小型缓存, 该缓存用作一级缓存, 不与用户空间内存缓存应用程序同步. Zhong 等人[85]提供了使用 BPF 从内核内部加速存储的动机, 但没有提供具体的设计、实现或评估.

内核旁路. 为了减少内核在处理 I/O 时的开销, 几个库和操作系统已被设计允许用户直接访问 I/O 设备[7, 33, 34, 42, 47, 57, 65, 69, 71, 72, 82–84]. 与我们的工作最相关的是, Intel 的 SPDK[82] 是一个流行的内核旁路存储库. 通常, 允许用户直接访问 I/O 的缺点是应用程序必须直接轮询 I/O 以获得高性能. 这意味着核心不能在进程之间共享, 这会导致在 I/O 不是瓶颈的情况下严重的利用不足.

近存储计算. 有几种系统允许应用程序将其存储函数卸载到嵌入或连接到存储设备的处理器[16, 22, 31, 38, 43, 51, 55, 61, 63, 74, 75, 77, 81]. 这种方法的缺点是它需要专用存储设备、专用硬件或两者兼而有之.

可扩展操作系统和库文件系统. 我们的方法让人想起 20 世纪 90 年代的可扩展操作系统和库文件系统. 可扩展操作系统 (例如 SPIN[35] 和 VINO[76, 79]) 允许通过用户定义的函数扩展内核功能. 例如, 客户端可以编写内核扩展, 从磁盘读取和解压缩视频帧. 另一种相关方法是库文件系统, 如 XN[48,56]. 与 XRP 类似, XN 允许用户空间库文件系统将不受信任的元数据转换函数加载到内核中, 同时在不了解文件系统数据结构的情况下保证磁盘块保护. 这些方法需要使用专用的操作系统和文件系统, 而 XRP 与 Linux 及其标准文件系统兼容. ExtOS[32] 是一个较新的可扩展操作系统, 通过在将数据复制到用户空间或另一个文件之前使用 BPF 函数过滤数据, 最大限度地减少了 read()splice() 中的数据移动, 但它仍然会导致整个存储栈的开销, 并且不允许重提交 I/O 请求.

8 结论和未来工作

BPF 有潜力通过将计算移动到更接近设备的位置来加速使用快速 NVMe 设备的应用程序. XRP 允许应用程序编写可以重提交相关存储请求的函数, 以实现接近内核旁路的加速, 同时保留操作系统集成的优势. 除了快速查找, 我们设想 XRP 可以用于许多类型的函数, 如压缩、压缩和重复数据消除. 此外, 未来 XRP 可以作为其他需要将计算移动到更接近存储的使用情况 (如可编程存储设备和网络存储系统) 的通用接口来开发. 例如, XRP 可以用作一个接口, 它可以动态地支持内核内卸载, 以及将函数卸载到智能存储设备或 FPGA. 我们计划探索的另一个方向是网络存储. XRP 存储函数可以与 XDP 网络函数链接, 以创建绕过内核网络和存储路径的数据路径.

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值