图3a和图3b展示了两个与基准应用遍历有关的钩子的吞吐量增量。图3c展示了B树的深度对两个钩子的延迟的影响。当从系统调用派发层发起查找时,最大可以提升1.25倍。由于每次查找仍然会存在文件系统和块层的开销,因此提升并不明显。提升完全来自于消除跨内核带来的开销。存储设备的延迟接近1us,我们希望从派发钩子上获得更大的提升。另一方面,通过绕过几乎整个软件栈,从NVMe驱动程序重新发出的命令可大大减少后续I/O请求的计算量,这种方式可以提升2.5倍速度,并减少49%的延迟。但当添加更多的线程时,反而降低了相对吞吐量的提升,这是因为在达到CPU饱和之前(线程数为6),基准应用也会从中(增加线程)受益。一旦基准达到CPU饱和之后,由重新分配驱动程序而节省的计算量会变得更加明显。由于树的每一级都包含了可以廉价发起的请求,因此吞吐量的提高将随着树的深度而不断扩大。
图3:从不同的内核层发起查找时,B树深度和吞吐量的关系
图3a:使用read系统调用查找,使用系统调用层钩子(可以看到线程对性能的提升并不大)
图3b:使用read系统调用查找,使用NVMe驱动层钩子(由于绕过了BIO和文件系统层,因此性能提升明显)
图3c:使用read系统调用单线程查找,使用系统调用层和NVMe驱动层钩子
图3d:使用io_uring系统调用单线程查找,使用NVMe驱动钩子(B树深度越深,可节省的I/O计算就越多)
io_uring怎么样?前面实验中使用了Linux标准的同步read系统调用。这里我们使用更高效、批量化的io_uring submission路径来重复这些测试场景,使用单线程来驱动B树查找。像之前一样,我们在NVMe驱动中重新发起查询请求,使用未修改的io_uring调用执行批量I/O,并绘制吞吐量提升图。图3d展示了在驱动中发起查找后的(相对于应用基准的)吞吐量提升。
如预期的一样,增加批处理调用的数目(在每个io_uring调用中的系统调用的数目)会提升吞吐量,这是因为更高的批处理会增加驱动发起的请求的数目。例如,可以廉价地发起只有一个请求(B树的每一层)的批处理,而对于大小为8的批处理,B树的每一层会节省8个并发请求。因此将钩子靠近设备有利于标准的同步read调用,并使得io_uring调用更加高效。使用深度树时,BPF与io_uring结合使用可将吞吐量提高2.5倍以上; 且三个相关的查找也可以实现1.3–1.5倍的提升。
设计存储BPF上述实验已经告诉我们使用BPF优化高速存储设备操作速度的原因。但为了达到这些目标,需要尽早执行I/O重提交,理想情况是在内核的NVMe中断处理器内部。但这也为使用BPF查找如键-值存储这样的实用系统带来巨大挑战。
我们设想构建一个可以提供比BPF更高层的接口库,以及新的BPF钩子,且尽可能早于存储I/O completion路径(类似XDP)[21]。该库可能包含用于加速访问和操作特定数据结构的BPF函数,如B树和日志结构合并树(LSM)。
在内核中,在每个块I/O结束后,NVMe驱动中断处理器可能会触发这些BPF函数。通过给予这些函数访问块数据原始缓冲的功能,可以从存储的块中抽取文件偏移,并立即使用这些偏移发起I/O。它们还可以通过后续返回给应用的缓冲来过滤、投影和汇总块数据。通过将应用定义的数据结构推送到内核,这些函数可以在应用有限参与的情况下遍历持久化数据结构。与XN不同,XN目的是实现完整的系统,而这些存储BPF函数主要是为了定义构成应用数据结构的存储块布局。
我们概括了初步设计中需要关注的主要考量点和挑战,我们相信通过这种方式可以实现目标,而无需对Linux内核进行实质性的重构。
安装&执行。为了加速相关访问,我们的库使用一个特殊的ioctl安装了一个BPF函数。安装后,会对应用发起I/O时使用的文件描述符"打标签"(tagged),传递到NVMe层时也会携带该标签,后续会在触发NVMe设备中断处理器的内核I/O completion路径上检查该标签。对于每个打标签的submission/completion请求,我们的NVMe中断处理器钩子会将读存储块传递到BPF函数中。