OSDI‘22 BEST PAPER“XRP: In-Kernel Storage Functions with eBPF“阅读笔记

原文链接:https://www.usenix.org/conference/osdi22/presentation/zhong

背景和动机

1、动机实验配置

CPU

6-core i5-8500 3GHz

DRAM

16GB

Ubuntu

20.04

Linux

5.8.0

Processor C-states

no

turbo boost

no

governor

maximum performance

KPTI

no

2、软件开销成为存储性能瓶颈

如下图,像3D-Xpoint这样的新介质和低延迟NAND可以让新的NVMe设备表现出个位数微秒级延迟和1e6(十万)级别的IOPS。从访问一代快速NVMe设备开始,软件开销占比就达到15%,到现在访问最新的NVMe设备时,软件开销占比达到50%。

作者进一步对内核软件开销进行分解,其中使用了O_DIRECT绕过了page cache。如下表,开销最大的层是文件系统层(ext4),其次是块设备层(bio)和kernel crossing(上下文切换?),整体软件开销占比达到48.6%。

那么,为什么不直接绕过内核呢?

这种办法并非灵丹妙药:这意味着把存储设备的访问完全暴露给应用,同时应用必须实现自己的文件系统——这意味着没有保证数据孤立的机制,不同应用无法共享同一个设备的空间。此外,用户态应用也无法接受中断——这意味着当I/O并非性能瓶颈时,CPU资源会被浪费,利用率低下。而且当多个polling的线程共享一个CPU时,CPU竞争+缺乏同步机制会导致所有polling的线程的尾端延迟巨大,带宽巨低。

3、BPF简介

BPF(Berkeley Packet Filter)是一个允许用户把一个简单的函数注入内核执行的接口。Linux中实现的BPF框架叫eBPF。函数在注入前,需要经过几秒钟的检验(verification),随后这个eBPF函数就可以被正常调用了。

BPF的潜在优势

当一个请求需要进行多次I/O查找(resubmission)时,BPF可以避免内核态和用户态之间的数据迁移。例如,查找B树的索引时,需要先从根节点查起,直到找到叶子节点。而这一过程若使用多个系统调用,则每一次系统调用都要遍历整个内核存储栈。而若使用BPF函数,可以把刚刚的查找函数注入NVMe驱动层,这样只有第一次查找需要遍历整个内核存储栈,之后的查询结果只需要返回驱动层即可。二者的对比如下图:

B-Tree Lookup from User Space

B-Tree Lookup With an In-Kernel Function

其他数据结构(LSM树)、其他操作(范围查询、迭代、计算统计数据)也可以从这种机制中获益。这些操作的特点是有大量“中间“(或者说”备用“)的I/O操作,而对用户而言只需要最后的单个结果或者I/O返回的一小部分对象。

除了在NVMe驱动层,BPF函数也可以放在其他层,如系统调用。下图是普通的系统调用和两个使用BPF函数分发(或者重发)I/O操作时的不同路径。

作者在21年HotOS上的文章中比较了BPF函数在这两个不同地方时获得的性能收益,如下表,比较的基准是read()系统调用。

当CPU达到饱和态后,在NVMe驱动层重发I/O请求带来的提升最终转化为1.8x-2.5x的带宽增大。(具体增大多少取决于工作集中线程的数目)

扩展:线程的数目如何影响带宽的提升呢?

当CPU未饱和时,线程越多,提升越小。而CPU饱和后,提升更加明显。下图6个线程后CPU饱和。

BPF函数越靠近底层,性能提升越大。所以,XRP的重发hook放在NVMe驱动层。

前文提到的都是同步I/O,那么异步I/O的表现如何呢?

Linux内核新提出的io_uring可以实现批量下发异步I/O,一定程度上摊销了用户态陷入内核态的开销。然而每一个I/O请求依然要穿过整个内核存储栈。事实上,io_uring和BPF I/O重发可以相互补充:io_uring可以高效地批量下发I/O请求,而每个I/O请求触发不同的I/O链,eBPF函数处理这样的I/O链。下图是和只使用io_uring相比,使用io_uring+BPF hook时的带宽提升。

注:I/O Chain Length指初始I/O和重发的I/O总数(如,B-树的深度);batch size指在每个io_uring调用中打包的系统调用数。

设计与实现

本论文中,XRP基于Linux的eBPF和ext4文件系统。

1、修改NVMe驱动中的中断控制器:实现重发I/O

当NVMe请求完成时,设备向主机发送中断,进入中断处理器,此时XRP通过bio调用BPF函数,访问元数据摘要,进行地址转化,最后准备下一个NVMe请求,并重发,把请求放入NVMe提交队列(SQ)中。

具体重发的次数是由NVMe请求注册的特定BPF函数决定的。例如查找一个数状的结构,那么遇到中间节点时BPF函数会重发,直到遇到叶子节点时终止。目前的XRP实现中,对重发次数没有硬限制。如果需要实现,可以在I/O请求描述器中增加一个重发计数器。这个计数器既不能被用户访问,也不能被XRP的BPF函数访问,所以即使多个BPF函数执行重发,计数器也不会超量。

BPF函数的上下文以请求为单位的,而元数据摘要则所有核和中断控制器共享,它的同步访问由RCU(read-copy-update)控制。

1.1 BPF hook

BPF的每个类型对应对应一种程序,XRP引入了新的BPF类型——BPF_PROG_TYPE_XRP。它的签名如下:

任何符合这个签名的BPF程序都可以被这个hook调用。下一部分会展示一个具体的符合该签名的BPF程序。

BPF_PROG_TYPE_XRP程序的上下文有五个域:

data

缓存从磁盘读上来的数据(如一个B-树页)

done

标识重发是否完成的布尔变量

next_adr

下一个块的逻辑地址的数组

size

下一个块的大小的数组(0表示没有I/O)

scratch

一个缓冲区,用于用户向BPF函数传递参数、或者BPF函数存储I/O重发过程中的中间数据、或者向用户返回数据。

这个buffer的大小为4KB,若需要存储更多的中间数据,BPF函数可以使用BPF map。

每个BPF上下文都为一个NVMe 请求所私有,因此使用BPF上下文时不需要锁。而且,由用户提供一个scratch缓冲区,而非使用BPF map,避免了进程和函数不得不调用bpf_map_lookup_elem来访问scratch buffer。

1.2 BPF Verifier

BPF的寄存器分为scalar和pointer两种,其中pointer有不同的类型,如PTR-TO-MEM,指向一块固定大小的内存区域。在XRP上下文中,data和scratch的类型是PTR TO MEM,而剩下的是scalar。BPF Verifier会根据数据类型检测寄存器的数据,防止访问区域外的数据。

此外,作者还实现了XRP类型对应的is_valid_acces()函数,可以检测对上下文的访问是否正确以及返回上下文域的值的类型。

1.3 元数据摘要

中断控制器并没有文件以及逻辑地址到物理地址的转换的概念。作者在此实现了“元数据摘要“,即一个中断控制器与文件系统之间的接口,这样,文件系统就可以和中断控制器共享地址映射了从而支持基于eBPF的盘上重发。

元数据摘要包括两个部分,如下图:

更新函数:当地址映射被更新时,由文件系统调用

查询函数:由中断控制器调用,返回给定偏移和长度的地址映射。同时也实现了访问控制。

这两类函数的实现方式多种多样,例如在本文中,作者实现了ext4的extent状态树的缓存,因此元数据摘要中包括了这个状态树的版本号。同时使用RCU进行并发控制——查询函数可以很快而且无锁。

当extent更新的同时还有查找在进行,extent的版本号会更新。查找到的数据在返回BPF函数前会进行二次查询,若发现版本号不一致,则放弃本次操作。

不过,一般应用不会允许查找与更新同一块区域同时进行,所以若发生这种情况要么是应用代码出错,要么是恶意应用。

当然,也可以不实现extent状态树缓存——这样update函数什么也不用做。但是,此时查询需要先获得自旋锁,会变慢很多。

目前的XRP只支持ext4文件系统。但如f2fs等的元数据摘要也可以轻易实现。

1.4 重发NVMe请求

为了避免调用kmalloc的开销,XRP重用旧的NVMe请求结构,在重发时仅仅修改physical sector和block address。

不过,重发的I/O请求只能抓取和旧I/O请求一样多的物理段。如果BPF函数返回了多个指针(next_addr),而NVMe请求不支持这多物理段的抓取,那么XRP会抛弃这个请求。

2、同步控制

BPF目前仅支持自旋锁,而且同时只能获得一个锁,且在结束前必须释放这个锁。用户应用也无法直接访问这个锁,必须经过系统调用bpf()来读写锁保护的区域。因此,需要在多个读和写之间同步的复杂的修改无法在用户态实现。

用户也可以使用BPF的原子操作自己实现自旋锁,这样用户和BPF程序都可以直接获得这个锁。但是,BPF函数不能一直等待锁被释放——无法通过verifier。

另一种实现同步的方式是用RCU,XRP BPF程序在NVMe中断控制器中实现,这本身就是不可抢占的,所以它们已经在RCU的读关键区了。

3、和Linux调度器的交互

进程调度器:

作者观察到,超低延迟存储和Linux的CFS调度器存在冲突,例如当I/O密集进程和计算密集进程共享一个核时,I/O密集进程产生的中断可能打断计算密集进程的运行,导致计算密集进程被饿死。最坏的情况下,计算密集进程只能获得34%的CPU时间。而当使用较慢的设备时,则不存在这个问题。XRP进一步加剧了这个问题——产生多个链式中断。作者把这一问题留给未来的工作解决。

I/O调度器:

XRP bypass了Linux的I/O调度器。不过,noop调度器目前已经是NVMe设备的默认I/O调度器。而若需要保证公平,它们在硬件队列中有仲裁机制。

案例分析

如下图,应用可以通过调用这些函数来使用XRP。

bpf_prog_load:一个已有的函数。用于加载BPF_PROG_TYPE_XRP类型的BPF函数到驱动中。

read_xrp:用户可以用它把一个特定的BPF函数应用到一个具体的请求上。

在这一部分中将介绍两个案例,以表明应用应该做什么样的修改来使用XRP。

1、BPF-KV

BPF-KV是作者构造的一个简单的KV存储,它用来存储小对象并且提供优秀的读性能。其中,BPF-KV的索引结构是B+树,而对象存储在一个无序的日志(log)中。简单起见,BPF-KV使用固定大小的key(8B)和value(64B)。索引和日志一起被放在一个大文件里。索引节点(index node)使用简单的页结构(每个index node就是一个页):头+key+value;叶子节点包含一个指向下一个叶子节点的文件偏移量。对象的大小固定(64B),所以对象可以在log中被就地更新,新插入的项会被附加在log后面,它们的索引一开始存储在内存的哈希表中,而当哈希表满后,BPF-KV会将其和盘上的B+树文件混合。

1.1 缓存

为了减少查找时的I/O数量,BPF-KV把头k层B+树索引缓存到内存中。当object足够多时,不可能在内存中缓存全部的索引。若BPF-KV用于存储10 billiion 64B对象,index node的大小是512B(和Optane SSD的访问粒度匹配),因此,每个中间节点可以存储31的指向孩子的指针。因此,10 billion的object需要8层index。

注:

设B+树有n层,则叶子节点层有31n-1个指向object的指针,而object有1010个,解得n=8.

把6层放到DRAM里面已经很贵了(14GB),更别说7层(436GB)或更多了。所以,为了支持更多的key,在每次查询中,BPF-KV会需要至少3~4个I/O(包括最终的I/O)。

除此之外,BPF-KV还有一个LRU对象缓存,里面存储了最近最常使用的key-value对。

当查询一个object时,先查找LRU对象缓存,若没找到,则在hash表中查找index,若没找到,则在前k层缓存的index中查找。若遇到了不在内存中index,则在磁盘中查询。

1.2 BPF函数

下图是在BPF-KV中使用的BPF函数,此处忽略了最终步(查询log)的部分。struct node地能够以了B+树的index node的layout(大小为512B)。

BPF函数bpfkv_bpf首先从scratch中提取目标key,然后搜索当前节点找到要读的下一个节点。

1.3 接口修改

作者把read()系统调用换成了read_xrp(),在调用read_xrp之前,BPF-KV会先分配scratch空间,计算开始查询的offset。

1.4 范围查询

BPF-KV支持返回一些object的范围查询。在scratch中,存储了BPF函数的状态(查询的范围)和已接受的object。scratch最多顿出32个72 byte的key-value对。第一次调用时,函数找到具有开始key的叶子节点,随后把叶子节点存在scratch中,随后在缓存中叶子节点中查找,当叶子节点读完后,函数提交下一个叶子节点的查找,依次类推。当找到范围末端/查完整个index集/scratch空间满了之后,函数返回。

1.5 统计计算(Aggregation)

BPF-KV支持如SUM、MAX和MIN的统计计算。这个计算是基于返回查询的,在范围查询的上层设置了一个bit标识是否要统计计算。计算后的值存在scratch中。

2 WiredTiger

WiredTiger是MongoDB默认的KV存储。其使用LSM树组织数据:数据被分到不同的层,每一层都有一个单独的文件,每个文件中使用B-树索引(页大小为512B),而KV对存在树的叶子节点中。这些文件是只读的,更新和插入都会先写入内存的buffer中,buffer满之后,数据会写入一个新的文件。作者修改了大约500行代码,包括buffer分配、扩展函数签名和封装XRP系统调用。

XRP加速磁盘读,不会影响更新和插入。

2.1 BPF函数

WiredTiger安装了和BPF-KV中类似的BPF函数,不同点在于查找下一个node的指针时,WiredTiger的BPF函数把原有的for循环换成解析WiredTiger的B树节点页的端口。

2.2 缓存

WiredTiger使用LRU链表把一部分内部节点和叶子节点缓存在cache中。当查询一个新的KV对时,WiredTiger把整个查询路径(包括叶子节点)缓存下来。为了遵从WiredTiger的语义,上文中提到的BPF函数额外返回了所有查询的节点,这样WiredTiger就可以缓存它们了。这些查询到的节点会放在scratch里,当scratch满了后,BPF函数直接返回。WiredTiger把这些节点加入cache后,它再次调用read_xrp,继续查询。

scratch的大小是4KB,所以它一共可以存6个查到的512B大小的节点(预留了1KB存必要的元数据)。

2.3 接口修改

作者把普通的read系统调用全部换成了read_xrp。WiredTiger的缓存策略保证了未缓存的节点没有已缓存的儿子,所以可以放心调用read_xrp返回剩余的读路径上的节点。如果read_xrp失败了,WiredTiger会调用旧的查询办法。data和scratch会事先分好以避免分配和释放缓冲区的开销。WiredTiger同时只处理一个请求,以避免并发。

评估测试

CPU

6-core i5-8500 3GHz server

DRAM

16GB

Ubuntu

20.04

Linux

5.12.0

SSD

Intel Optane 5800X

page cache

no(绕过了page cache)

hyper-threading

no

processor C-states

no(不支持进入低功耗模式)

turbo boost(睿频加速)

no(以保证CPU频率稳定)

governor

maximum performance(让CPU性能最高)

KPTI

yes(保证BPF程序无法窃取内核信息)

WiredTiger

4.4.0(目前最新版本11.1.0)

Baselines:
(a) XRP
(b) SPDK
(c) read()
(d) io_uring syscalls

SPDK没有文件系统,作者如何使用SPDK进行重发I/O的测试?自己实现了一个文件系统吗?

1、在存储场景下使用BPF的开销有多大?


1.1 延迟


在本实验中,作者执行了{10}^6次随机读操作。为了关注查询盘上数据的开销,作者关闭了内存的缓存(缓存对象和缓存index)。平均延迟的对比如下表,最左一列的I/O数不包括获得最终要的数据的最后一次I/O。


 
1)因为XRP省去了大部分软件栈的开销,相比read(),XRP的性能更好。而且,可以看到每增加一次index I/O,XRP的延迟增加3.5~3.9μs——约等于设备的延迟,侧面证明XRP几乎实现了重发请求的最优性能。
注意,重发request是同步的,所以io_uring和read的表现差不多。
2)SPDK的性能比XRP要好,因为在第一次I/O时,XRP依然要穿过整个I/O栈。但是XRP不需要使用polling,进程可以继续利用CPU完成其他事情。

下图是99和99.9的尾端延迟随着线程数的增加的变化。


 
当线程数超过9之后,SPDK的99.99尾端延迟剧烈增加,因为它们的线程使用轮询,无法高效共享CPU。作者还测试了请求延迟大于等于1ms的占比,如下图:


当线程数为7时,SPDK有0.03%的请求延迟大于1ms,而当线程数达到24时,这个比例达到0.28%。而其他几个机制,这个比例一直低于0.01%。

1.2 带宽(注意单位是KOPs)


如下图,当index深度增加时,XRP的带宽提升和标准的系统调用相比更高了。

 
为什么SPDK的带宽下降那么剧烈?

下图是当index深度分别为3和6时,随着线程数提升的带宽变化。可见当I/O和XRP函数被扩展到多个核时,XRP的带宽提升相比标准系统调用并没有下降。此外,当线程数超过9后,XRP的带宽和SPDK相等甚至更高。


 
文中提到,这一部分的实验产生负载是closed loop方式,而下一部分是open loop,二者的差异在哪里?closed loop是会根据性能调节产生的负载?为什么要一开始用closed loop产生负载呢?更加真实吗?

2、XRP扩展到多线程场景下表现如何?


现在的存储应用常常使用大量线程访问存储设备,因此XRP也需要在多线程下表现良好。在本实验中,作者进行了一次open loop实验,负载量和Intel设备的最大带宽一致(5M IOPS),如下图,index深度为6时,XRP(内部是io_uring)和SPDK在BPF-KV中的带宽随线程数的变化。


 
当使用6个线程时,SPDK和XRP都可以达到硬件允许的最大带宽。而线程数增多后,SPDK的带宽开始下滑。因为SPDK的轮询线程必须等到Linux CFS调度(6ms)后才能被迫放弃CPU,而XRP的空闲线程可以主动放弃CPU,让CPU做有意义的工作。
下图是12个线程时,随着负载的变化,带宽和延迟之间的关系变化。可见SPDK中平均延迟和尾端延迟都比XRP增长更快。

3、XRP在范围查询的场景下表现如何?


每次范围查询时,都先执行一次index检索找到第一个对象,之后遍历叶子节点找到后面的对象。树的深度为6,尽管XRP一次只能检索32个对象,但由下图可见和read相比,XRP提高的延迟很稳定。


 

4、XRP可以加速真实世界的KV存储吗?


作者使用YCSB产生负载,在WiredTiger中评估了XRP的性能。作者使用了YCSB A、YCSB B、YCSB C、YCSB D、YCSB E、YCSB F六种不同的负载,每种负载运行的时间接近。A、B、C、F中有10M个操作,D中有50M个,E中有3M个。
baseline:
pread()
read_xrp()
configure:
key-value中key和value都16B,总大小为46GB。KV对一共有10^9对。当cahce块满时,WiredTiger会把页剔除。使用2个剔除线程。

4.1 带宽


 
大部分工作集中,XRP有稳定的带宽提升,其中最大提升1.25倍。缓存越大,XRP的提升越小。因为WiredTiger仅花费63%的时间在I/O操作上,所以相比BPF-KV,XRP对WiredTiger的提升更小。此外,在YCSB D和YCSB E中,XRP的提升不明显。YCSB D中,最新插入的对象总是最常被访问,所以大部分访问都在cache中完成了。而YCSB E的大多数操作是遍历和插入,在遍历场景下,XRP只能在获取遍历的初始KV对时有收益,因为剩下的KV对一般在同一个叶子节点里或者只需要一次额外的I/O就可以获得下一个叶子节点。
作者进一步探究了负载的偏移对XRP性能的影响。如下图是调整TCSB C中Zipfian分布的不同常数和均匀分布的性能提升对比,可见负载偏移越大,XRP的提升越小(注,正常这个常数>0.99后说明负载偏移程度非常大了)。



4.2 尾端延迟


作者让每个YCSB A\B\C\D\F的每个线程以20kop/s的速率下发负载,而YCSB E以5kop/s的速率下发负载。如下图,XRP最多可以减少40%的尾端读延迟(YCSB E是scan延迟)。和带宽类似,随着cache size大小增大,XRP的尾端延迟减少下降了。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值