图1展示了这种情况,可以看到硬件的I/O的延迟比例降低了,由于设备硬件和系统软件的增长,使得存储设备的速度越来越快。从下图中可以看到,在第一代NVMe设备中已经可以观测到软件带来的开销(10-15%延迟开销)。在新一代设备中,软件开销则占到了几乎一半的读I/O延迟。
图1:使用512B随机读下的内核延迟开销。HDD为Seagate Exos X16, NAND为 Intel Optane 750 TLC NAND, NVM-1是第一代Intel Optane SSD (900P), NVM-2是第二代Intel Optane SSD (P5800X)
开销源。为了降低软件开销,我们需要通过发起随机512B read()调用(设置O_DIRECT选项)来测量不同软件层的平均延迟,测试环境为Intel Optane SSD Gen 2 prototype (P5800X),6核i5-8500 3GHz,16GB内存,系统为Ubuntu20.04,内核Linux 5.8.0。本文使用的都是该配置。我们仅用了处理器的C-states和睿频加速技术,并使用最大性能调节器。表1展示了耗时最多的为文件系统层(此处为ext4),随后是用户空间和内核空间之间的转换。
表1:使用第二代Intel Optane SSD测试512B随机read()调用的平均延迟
内核旁路允许应用直接将请求提交到设备,有效消除了除给NVMe提交请求以及设备本身造成的延时[22, 33, 44, 45]之外的开销。但消除这些层的代价也比较高昂。内核只能将任务委派给整个设备进行处理,此时必须在裸设备[40, 41]上实现应用自己的文件系统。但即使这么做,也无法保证不可信进程之间的文件和容量共享的安全性。最终,由于缺少有效的应用层中断派发,意味着应用必须采用轮询来保证高负载,最终导致无法有效地在多个进程间共享核,而在频繁轮询时也会造成I/O浪费。
最近采用了一种称为io_uring[7]的系统调用来优化I/O提交造成的开销。它使用批量和异步I/O submission/completion路径(参见图2)来分摊跨内核造成的开销,并且避免使用调度器交互来阻塞内核线程。但每个提交的I/O仍然会经过所有的内核层(如表1所示),因此,在访问高速存储设备时,每个I/O仍然会造成大量软件开销(见下文)。
BPF是解药?随着高速网络的崛起,为Berkeley Packet Filter(BPF)带来了新的机会,由于不需要将报文拷贝到用户空间,因此可以高效地处理报文,同时应用也可以安全地在内核中对报文进行操作。一般的网络场景包括报文过滤[4,5,21,30],网络跟踪[2,3,29],负载均衡[4,12],报文引导[27]以及网络安全检查[9]等。 BPF也被用作一种在访问分类存储时避免多网络交叉的方法[35]。可以使用解释器或JIT编译器来运行用户定义的函数。
存储使用BPF。可以预见,在发起独立的存储请求时,可以使用BPF来移除内核存储栈以及内核空间和用户空间之间的数据交互。很多存储应用包含很多"辅助"I/O请求,如索引查询。这些请求最主要的一个特点是它们会占用I/O带宽以及CPU时间来获取永远不会返回给用户的数据。例如,对一个B树索引的查找其实是一系列指针查找,用来生成对用户数据页的最终I/O请求。每个查找都会经应用到内核存储栈,仅仅是为了在应用简单处理之后丢弃数据。其他类似的场景包括数据库迭代器,它会顺序扫描表,直到某个属性满足条件[15],或图数据库执行深度优先查找[24]。
好处。我们设计了一个在使用B+树(索引数据库的常用数据结构)的磁盘上执行查找的性能测试。为了简化,实验会假设索引的叶子包含用户数据(而非指针[36])。我们假设B树的每个节点都位于独立的磁盘页,意味着对于一颗深度为d的B树,需要从磁盘读取d个页。B树查找的核心操作是通过解析当前页来找出下一个磁盘页的偏移量,并对下一页发起读操作,即"指针查找"。传统上,一个B树查找需要在用户空间连续执行d个指针查找。为了提高基准,我们从内核堆栈中的两个钩子之一重新发出连续的指针查找:系统调用派发层(主要消除跨内核开销)或completion路径上的NVMe驱动中断处理器。图2显示了两个钩子的派发路径以及正常的用户空间派发路径。这两个钩子用来代理eBPF钩子,我们最终将使用它们来完成用户定义的功能。
图2:应用的派发路径和两个内核钩子