ToplingDB NestLoudsTrie 索引

  1. 背景ToplingDB 是 topling 开发的 KV 存储引擎,fork 自 RocksDB,进行了很多改造,其中最重要的部件是 ToplingZipTable, 是 BlockBasedTable 的代替品,性能更高而内存占用更低。ToplingZipTable 使用 CO-Index 与 PA-Zip 实现索引和数据的存储。CO-Index 指 Compressed Ordered Index, 是一类内存压缩的索引,无需解压,在压缩的形态下可以对索引直接搜索,并且搜索速度极快。从 String 类型的 Key,搜索出一个密实 的 整数 ID。PA-Zip 指 Point Accessible Zip, 无需 BlockCache,可以非常快速地 按 ID 定点访问单条数据。本文描述 CO-Index 家族中最通用的索引:NestLoudsTrie2. Succinct 数据结构Succinct Data Structure是一种能够在接近于信息论下限的空间内来表达对象的技术,通常使用位图来表示,用定义在位图上的 rank 和 select 操作来定位。rank 和 select 可以通过空间占用极少的索引来实现,理论上,只需要 O() 的索引空间就可以用 O(1)  的时间复杂度实现 rank 和 select 操作,但是这个 O(1) 的常数项很大。在实践中,我们会通过稍多一点的空间,换取更小的 O(1) 常数项。著名的开源项目 SDSL-Lite 中有各种 Succinct 数据结构的实现,是一个大而全的 Succinct 项目。3. topling-zip 简介与 SDSL-Lite 相反,有着 10 余年历史的 ToplingDB 的底层核心库 topling-zip 在 Succinct 上并不追求大而全,而是“小而精”,一切以实用为目的,性能优先。topling-zip 中的 Succinct 最初是专为 NestLoudsTrie 实现的,而 NestLoudsTrie 是 Topling FSA(DFA+NFA) 家族的一份子,同时也是自动机压缩算法的一个 Building Block目前开源的 topling-zip 只包含 Topling FSA 中适用于数据库索引的部分,不包含正则表达式引擎及各种字符串匹配算法的部分(例如多正则引擎及专利的拼音纠错算法)topling 的所有数据结构在设计上都都支持 mmap: 数据在内存中的格式和在文件中的格式完全相同。不仅如此,topling 的所有构件在设计上都严格贯彻“高内聚”、“低耦合”的原则,这也是 Unix 哲学:每个构件只做一件事并且做好这件事。就最基础的 Succinct: 支持 rank select 的 bitmap 来看,topling 的性能远高于广为人知的 SDSL-LiteRank-Select Benchmark(对比 SDSL-Lite),这个对比是 5,6 年前测的,现在 Topling 比当初还有一定提升4. Succinct Trie4.1. Succinct TreeTree 的传统实现中,每个结点包含两个指针:struct Node { Node firstChild, nextSibling; };
    每个结点占用 2 ptr,如果我们对传统方法进行优化,结点指针用最小的 bits 数来表达,N 个结点就需要 2∗ 个 bits。对比传统基本版和传统优化版,假设共有 216 个结点(包括 null 结点),传统优化版需要 2 bytes,传统基本版需要 4/8 bytes。对比传统优化版和Succinct,假设共有 10亿(~ 230 )个结点。传统优化版每个指针占用 =30 bits,总内存占用:  ≈ 7.5GB。如果使用 Succinct,n 个结点的 Tree 空间占用是 O(2n) bits,这个例子中空间占用就是: ≈ 312.5MB (每个结点 2.5 bits,其中 0.5 bits 是 rank-select 索引占用的空间),差距是 30 倍!4.2. Unary 编码Succinct Tree 中,每个结点被编码为 111110 序列,其中 1 的个数代表该结点的孩子数量,叶节点没有孩子,所以叶节点的编码就只有单个 0。这叫做 Unary Degree Sequence。4.3. 深度优先 or 宽度优先Tree 最常见的遍历顺序是深度优先和宽度优先,相应地,Succinct Tree 也有深度优先和宽度优先编码,不管是深度优先还是宽度优先,单个结点的 bit 编码都是 Unary Degree Sequence。深度优先编码能支持更多的高级操作,但是需要 bitmap 支持除了 rank 和 select 之外的其它操作(findopen 和 findclose,这里不深入展开)。宽度优先实际上是按层遍历,所以叫做 LOUDS (Level Order Unary Degree Sequence),它的好处是只需要 rank 和 select 操作,可以实现很高的性能,坏处是支持的高级操作少。幸运的是,作为 DB 索引,LOUDS 支持的操作刚好够用4.3. Trie 结点在 Tree 结点的基础上添加了 LabelTrie 结点的传统实现是struct Node { Map children; };
    前面 Tree 的firstChild + nextSibling 实际上就是个单向链表,Trie 为每个子节点添加了一个 Key。这个 map children 实现从概念上非常简洁明确,但是即便按照传统的实现方式,其冗余空间占比也过高。我们注意到,作为 Tree,其结点数 == 边数 + 1,即NodeNum == EdgeNum + 1,除了根节点,每个结点有且仅有一条入边。所以,我们可以这样定义 Node:struct Node {
    char incomingLabel;
    Node firstChild, nextSibling;
    };
    这个 Node,编译器为了对齐,incomingLabel 实际上会占用一个指针宽度,很浪费空间,但是相比前面的 map children 也小了很多。如果使用 Succinct,其 Tree 结构空间占用是 2.5n 个 bit,同时,其结点是用 [0,�) 的整数编号的,所以,我们把 IncomingLabel 保存在一个独立的 char 数组中,从而,每个结点的空间占用就是 10.5 个 bit。
    ToplingDB NestLoudsTrie 索引_第1张图片
  2. Patricia Trie作为字符串索引,Trie 树的大部分结点都只有一个孩子,所以不管是传统的 Trie 树,还是 Succinct Trie 树,这都是一个极大的浪费,不光浪费存储空间,还浪费 CPU 时间。所以,几十年以前,有人就提出了一个优化方案,把所有连接的并且只有一个孩子的结点,压缩成一个结点,同时把这些结点上的 Label 也连接成一个字符串。这就是 Patricia Trie,这个压缩方式叫做路径压缩。6. 多层嵌套Succinct Trie 还有一个神奇的能力,可以从孩子结点导航到父节点,更正式的描述:可以在 O(1) 的时间内,从孩子结点的 id 计算出父节点的 id有了这个能力,我们就可以从任何一个结点 S 出发,向根节点行走,把行走路径上的 IncomingLabel 拼接起来,就得到了结点 S 所代表的字符串。这样,我们就可以在 Patricia 的基础上,进一步降低存储空间:把 Patricia 中压缩路径上的字符串保存到另一个 Patricia 中,并且可以进行多层嵌套。这就是 NestLoudsTrie。
    ToplingDB NestLoudsTrie 索引_第2张图片
  3. Topling NestLoudsTrieTopling NestLoudsTrie 实现了作为 DB 索引需要的一切操作,并且进行了非常细致的优化,Benchmark 进行单项测试:每秒钟可执行 100 万次搜索操作Iterator 每秒可扫描 500 万个 KeySuccinct 一直被大家诟病性能低下,但是经过我们的不懈努力,在 ToplingDB 中,实现了性能与空间的双重效能。8. ReorderNestLoudsTrie 中保存的 N 个 Key 会映射到整数区间 [0,�) ,美中不足的是,因为宽度优先,所以 Key 和 n 的对应关系不是 Bytewise 字典序(深度优先的自然序就是字典序)。可以通过花费更多的 CPU 时间,实现字典序映射的操作,但是我们追求的第一目标是性能。所以,在创建 ToplingZipTable 时,我们需要一个 Reorder 操作,把 Value 顺序重排成 NestLoudsTrie 的整数映射顺序。这些计算都是在创建 SST 时完成的,提高读取/搜索的性能。这个美中不足的幸运之处在于,虽然不是字典序,但是接近字典序,接近字典序,对于空间局部性非常重要,特别是在 Iter 扫描时。9. 优化适配层如前所述,Topling 的所有组件均是“高内聚”,“低耦合”,NestLoudsTrie 原本属于 DFA 家族,与 ToplingDB 没有任何关系。要将它适配到 ToplingDB 的 CO-Index,适配层就需要一定开销。9.1. memcpyNestLoudsTrie 的搜索、扫描性能很高,这就使得哪怕适配层仅仅是把扫描出来的只有几十字节长的 key memcpy 到 ToplingDB,这个 memcpy 所占的时间也相当可观(在火焰图中清晰可见)。ToplingDB/RocksDB 中需要在 UserKey 之后拼接上 8 字节的 Tag(Seq, ValueType) 得到 InternalKey,而NestLoudsTrie 中保存的是 UserKey,所以需要将 UserKey 拷贝一次再进行拼接。所以,我们为 NestLoudsTrie Iterator 的内部 buffer 预留一些额外空间(目前16字节),从而适配层就可以直接将 Tag 放到这个预留空间中,免去了这个额外的 memcpy。更进一步,因为 NestLoudsTrie 是 readonly 的,我们可以确定 Iterator 的最大内存用量,从而 Iterator 就只需要一块内存,这并不是为了节省内存用量,而是为了优化 CPU Cache,降低 CPU 消耗!在这个优化之后,原本不需要适配层,直接实现的 UintIndex 也享受到了这个优化,也在 Iterator 中预留额外空间,省掉 memcpy,把这个 buffer 直接 inline 在 UintIndex::Iter 中,整个 UintIndex::Iter 也只有一块内存。9.2. 虚函数 & Layout 优化COIndex::Iterator 和 NestLoudsTrie::Iterator 是两套体系,两者之间进行适配,即便省去了 memcpy,接口转化的虚函数调用还是难以避免的。下面这个 key() 原本是个虚函数,在 Iterator scan 的火焰图中占比并非微不足道。
    ToplingDB NestLoudsTrie 索引_第3张图片

ToplingDB NestLoudsTrie 索引_第4张图片
接下来的优化稍微有点违反“高内聚”,“低耦合”了,所以必须在可控范围之内。NestLoudsTrie::Iter 的 m_word 成员继承自 BaseDFA::Iter,相当于 vector,fstring 相当于 string_view/Slice,我们精心设计对象布局,让所有 COIndex::Iter 派生类的 key 都在同一个偏移处,并且,针对 NestLoudsTrie::Iter,让其 m_word 成员的 ptr, len 正好对准到其它 COIndex 的 m_key{ptr,len} 成员。当然,这些数据成员的对准都要有编译期检查!这样,只需要 Iter::Next/Prev 是虚函数就可以了,实现这个优化还有更重要的一个原因:UintIndex 和 FixLenIndex 的扫描更快,扫描单条数据只需要几个纳秒,如果 key() 是虚函数,增加的开销相当可观。不能因为 NestLoudsTrie 不在 COIndex 体系而拖累别人。最开始没有用现在这个对准 m_key 和 m_word 的方案,而是把 m_word 的 data 指针和 size 拷贝到 m_key,仍有一些不必要的开销,在火焰图中可以观察到。【完】

你可能感兴趣的:(ToplingDB NestLoudsTrie 索引)