WORT: Write Optimal Radix Tree for Persistent Memory Storage Systems
FAST17的一篇文章,介绍了内存索引中使用基数树保证数据一致性的方法。阅读本文的目的是通过该文章了解内存索引的基本知识。可以尝试将radix tree结构来解决文件数据一致性。
持久内存(Persistent Memory)=非易失内存(Non-Volatile Memory)
摘要
radix tree可能是更适合NVM的索引结构,因为不涉及tree rebalancing和节点粒度的更新。但是直接应用radix tree是不合适的(后文会讲)。本文提出了write optimal radix tree以及write optimal adaptive radix tree两种适用于NVM的树结构,每次更新使用8B原子写更新,因此在减少了数据重复复制的同时保证了数据一致性。
Introduction
最近的NVM内存索引结构研究基本都是B-tree的变体,块设备的failure atomicity unit大小等于disk block大小,但是对于通过内存总线访问的NVM设备,这个大小需要保证在8B或者不能大于一个cacheline的大小。(这里提到了PCI总线,需要了解一下)
小的failure atomicity unit虽然减少了数据持久化的开销,但是在数据一致性上的开销会变得很大。主要原因是现代处理器上会对写内存指令进行重排序(cacheline粒度),那么为了保证内存写操作的顺序,就需要使用memory fence和cacheline flush指令配合。
目前出现的基于索引结构的B-tree都是为了尽可能减少代价高昂的cacheline flush和memory fence指令,然而并不能保证b-tree的节点有序性,而且在节点分裂时会涉及多个节点的修改,这些修改也需要log起来。
那么虽然现在都是基于B-tree的设计,但是radix tree也不失为一个选择,那么优势在哪里?首先,radix tree的插入操作不需要比较,只需要插入的前缀即可;此外,不需要在节点粒度上进行rebalancing和更新;而且key的插入和删除操作需要一个8B的更新操作,完美契合NVM的更新粒度。然而原始的radix tree很少应用在memory或者cache管理中,因此radix tree通过一个路径压缩算法,将多个节点结合起来形成一个唯一的搜索路径。但是路径压缩涉及了节点的分裂和merge操作,并不适用于PM。
因此本文提出了三种radix tree结构:
- WORT
WORT实现了一个8B更新的路径压缩算法,与现有的路径压缩具有同样的效果。同时对于节点的split和merge操作,通过cacheline flush和memory fence指令实现。因此WORT对于PM进行了优化,因为每次更新只需要一个8B的原子写,因此无需任何副本。
剩下的两种基于Adaptive Radix Tree,ART通过自适应节点类型转换方案来tradeoff搜索性能(节点数量)和节点利用率,可以根据节点利用率动态改变节点的大小。但是比较麻烦的是,ART不保证8B更新。
- WOART
WOART重新设计了ART的自适应节点类型,同时补充了memory fence和cacheline flush指令。
- ART+CoW
针对一致性的问题,使用CoW机制进行处理,而且在radix tree中的CoW开销小于b-tree的CoW开销。
实验表明,本文提出的三种radix tree的变体在插入和搜索以及memcached的合成负载中的表现均优于B-tree的变体,但是在范围查询方面比B-tree的变体表现要差。
PM的一致性问题以及B-tree的变体
PM的一致性
在PM中的一致性需要额外的内存写顺序约束。不同于DRAM+disk的indexing,DRAM中的tree node副本可以不考虑内存写顺序对其随意修改,因为是一个易失性的副本,它的持久副本一直在disk中,而且通过disk block unit更新。也就是说,即使出现掉电等意外,DRAM中的数据直接就消失了,不会影响disk中的持久副本。
然而8B原子写的PM可以理解成会把更新操作分开,也就是说在一定程度上不能保证更新操作的原子性。因此才需要严格保证PM的数据一致性。
另一个重要的方面是write size,在传统的B-tree中,插入或者删除节点会导致大量的节点移动,因为节点之间需要保证顺序,因此这样涉及的数据更新可能会比failure atomicity unit的大小8B要大。因此有序性这个特点在B-tree的PM变体中并不能保证。
传统的解决方法是logging或者CoW(Copy-on-Write),CoW不在原数据上进行修改,而是在原数据的副本上进行处理,并且在副本处理完成一段时间后,把原数据无效化(这里是推理得出,因为如果把新数据同步到原数据上,这样无法从根本上解决一致性问题)。logging和CoW有效的本质在于可以使用连续的8B原子验证来检验数据有效性。虽然有效,但是明显的需要多余的copy操作(CoW)和重复的写操作(logging,NVM读写不均衡以及磨损性质)。
针对PM的B±trees
本章针对PM的关于B-tree的变体进行一个总结。
CDDS B-tree
CDDS B-tree在节点更新时创造一个副本,有点类似于CoW,保证recoverability和一致性,因此会出现非常多的dead节点,同时使用了大量的memory fence & cacheline flush指令,因此在插入和搜索性能上表现不佳。
S. Venkataraman, N. Tolia, P. Ranganathan, and R.H. Campbell. Consistent and Durable Data Structures for Non-Volatile Byte-Addressable Memory. In Proceedings of the 9th USENIX Conference on File and Storage Technologies (FAST), 2011.
NVTree
NVTree通过追加更新的方式减少了memory fence和cacehline flush指令的数目,同时所有的叶子结点都存在PM中,因此,NVTree只需要两个cacheline flush,一个flush entry一个flush count,因此带来了性能提升,这也带来了两个后果:第一,叶子结点是无序的;第二,系统故障是内部节点很可能丢失。NVTree的一些局限:第一,内部节点必须要求存在连续的内存块中来利用cache的局部性,第二,父节点的每次分裂都会导致内部节点的重构。
J. Yang, Q. Wei, C. Chen, C. Wang, and K. L. Yong. NV-Tree: Reducing Consistency Cost for NVM-based Single Level Systems. In Proceedings of the 13th USENIX Conference on File and Storage Technologies (FAST), 2015.
FPTree
FPTree也把内部节点存在易失性内存中,叶子结点存在PM中,FPTree有效的利用了硬件事务内存来处理内部节点的并发访问。FPTree提出了通过fingerprints来减少cache miss,fingerprints是每个叶子结点key的1Bhash值,在查询之前先扫描fingerprints可以减少key的访问因此减少了cache miss比例。局限性是一旦系统崩溃,还是需要重建内部节点。
I. Oukid, J. Lasperas, A. Nica, T. Willhalm, and W. Lehner. FPTree: A Hybrid SCM-DRAM Persistent and Concurrent B-Tree for Storage Class Memory. In Proceedings of the 2016 ACM SIGMOD International Conference on Management of Data (SIGMOD), 2016.
wB+Tree
追加更新,内部节点和叶子结点都存放在PM中,因为内部节点必须要求有序,所以wB+tree使用slot array来存储内部节点的顺序(index表示),相当于间接排序。因为index比较小,所以7个key index的更新可以通过8B原子写保证原子性,wB+Tree也通过使用8B的位图增加节点容量,如果使用位图,wB+Tree至少使用4次cacheline flushes,如果使用slot array排序,这个数字会减少到2。
虽然cacheline flush明显减少,但是会增加额外开销(slot array),同时,wB+Tree在节点分裂时需要logging或者是CoW机制保证,意味着大量的开销。
S. Chen and Q. Jin. Persistent B±Trees in Non-Volatile Main Memory. In Proceedings of the VLDB Endowment (PVLDB), 2015.
PM中的radix tree
Radix Tree一般分为两种:原始的和使用了路径压缩算法的。先介绍原始的radix tree。
这里看原文吧,比较好懂。
与B-tree的区别在于:
- radix tree的高度固定,但是高度一般情况下要高于B±tree;
- radix tree的结构与插入顺序无关,但是与key的分布有关。B±tree的变体会根据节点的数量动态调整树结构使其保持平衡状态,但是radix tree的结构以及节点数量是固定不变的。而节点的使用率也和key的分布有关系,如果key的分布稀疏,那么使用率肯定要降低。
因为这些问题,决定了radix tree在索引数据结构中并不流行(因为有B-tree),但是看起来在PM中大有可为。首先,radix tree支持遍历树结构而不执行任何的key比较操作;也就是说key决定了唯一的搜索路径;相比来说B±tree需要将每个访问节点的搜素key与其他节点进行比较,这样可能会影响cache的性能,进而导致性能表现上的不同。
对于插入操作也是一样,因为radix tree的节点数量固定,因此并不需要涉及比较操作,而且radix tree节点不存储key,因此天然不需要排序。而相比B-tree来说,需要保持key的顺序,而且需要涉及节点的分裂和合并操作,需要一致性机制保证机制,同时也不适合PM的物理性能(读写不均衡,写磨损)。
radix tree中的原子写
作者对radix tree做出了两点修改,通过8B原子写保证了数据一致性。
第一是保证对pointer的写按照一个特定顺序完成。这个特定顺序就是把第一个遇到的NULL指针替换成下一层节点地址的操作放在最后来做,相当于我把后面的节点都建好,最后在commit到可能改变树结构的那个位置。因为赋值操作是一个8B的原子写,所以不需要任何的log。需要注意的是,这里我们还是需要使用cacheline flush & memory fence,因为必须要保证后面的节点已经建立完,才能把建好的子树连接到原来的树上。
radix tree中的路径压缩
本节介绍了radix tree中的路径压缩,以及作者对于PM上应用radix tree的修改。
虽然确定的树结构会带来良好的性能表现,但是key分布对树结构和内存利用率也有非常大的影响,如果key的分布稀疏,那么会浪费大量的内存空间。假设一个key的chunk size是8bit,那么一层的一个节点就会对应256个pointer(8B),所以如果使用一个没有相同前缀的节点,它的所需要的空间就是8B * 256,那么简单的思路是减少chunk size,但是这样会导致搜索路径变长。
假设key的前缀唯一,那么实际上后面的中间节点完全不必创建。因此也是一个“懒加载”思想的应用,就是说只有在共同前缀出现的时候,我们才去创建多余的中间节点。而具体的实现方法有以下三种:悲观法,乐观法以及混合法。
路径压缩会在中间节点前面加上一个数据结构Header
struct Header {
unsigned char depth; // 中间节点所在的深度
unsigned char PrefixLen; // 乐观路径压缩使用的前缀长度
unsigned char PrefixArr[6]; // 悲观路径压缩使用的前缀数组
};
悲观法的好处是可以立刻删除不匹配的key,而乐观法的优点在于更少的内存占用,但是推迟了key的比较,在插入和搜索的性能上会有欠缺。混合法则是当共有前缀长度小于特定长度时使用悲观法,反之使用乐观法。
作者给出了一个混合模式的路径压缩步骤,这篇文章写的很清楚了,可以参考,本文就不再赘述。
会发现使用的还是8B的Header
。
上图是使用路径压缩的radix tree的插入例子(原文的例子)。
WORT: Write Optimal Radix Tree
在NVM上应用路径压缩的radix tree实际意义不大,因为涉及节点的频繁分裂以及合并。因此作者提出了一个适用于NVM的radix tree路径压缩算法。使用了一个所谓的’node depth information’来通过8B原子写来实现路径压缩算法。下面是一个例子:
上图是在节点更新时如果出现crash导致数据不一致的情景。
原始的radix tree和B-tree变体保证一致性的要求是,节点的更新必须是原子的,但是8B原子写不能cover这种情况,就需要通过额外的机制保证(logging等)。但是radix tree在一定程度上可以容忍这种情况。具体的实现思路是:保证depth和prefix_len一起更新(8B),那么这样有以下的公式:
depth_parent + prefix_len_parent + 1 = child_depth
也就是说如果这个公式不成立,那么就认为出现了不一致的问题,这个时候,可以从这个节点出发随便找两个key来确定最长前缀。此时新建一个节点恢复源节点的状态,在替换的时候使用cacheline flush & memory fence指令保证数据正常刷回,保证数据以及radix tree结构的一致性。