文章摘要
该文章介绍了一种静态搜索树结构,通过优化内存布局和缓存利用,将静态数组上的搜索速度提升至传统二分查找的40倍,并详细阐述了S-tree与B-tree的实现原理及性能优化方法。
文章总结
好的,作为一名专业的中文编辑,我将对您提供的英文文章进行中文重述。我会保留核心细节,同时删减与主题无关或过于琐碎的内容,使其成为一篇清晰、连贯的中文技术文章。
静态搜索树:比二分查找快40倍
本文介绍如何实现一个静态搜索树(S+树),用于对已排序数据进行高吞吐量的搜索。我们将以Algorithmica网站上的介绍为起点,通过一系列优化手段,将其性能推向极限。最终,我们实现了比标准二分查找快40倍以上的查询速度。
1. 问题与动机
问题定义:给定一个已排序的32位无符号整数列表,我们需要构建一个数据结构,能够快速回答“大于等于某个查询值的最小元素是什么”的查询。我们优化的核心指标是吞吐量,即每秒能处理的独立查询数量。
动机:这项工作的一个主要应用是生物信息学中的索引,例如对人类基因组(约30亿个碱基对)的DNA序列进行索引。后缀数组是一种经典的数据结构,而本项目的目标就是加速在后缀数组中的搜索过程。
2. 从二分查找开始
作为性能基线,我们首先测试了Rust标准库中的二分查找。随后,我们实现了Eytzinger布局。这种布局将数据重新排列,使得二分查找树的前几层节点在内存中紧密排列,从而可以利用预取技术隐藏内存访问延迟。实验表明,当数据量远大于L3缓存时,Eytzinger布局比标准二分查找快约4倍。
3. S树与S+树
二分查找和Eytzinger布局每次只使用一个缓存行(64字节)中的一个值,效率低下。S树(静态B树)通过将多个树层打包到一个缓存行中来解决这个问题。一个节点包含16个值,代表一个17叉搜索树。当加载一个缓存行时,我们可以同时进行4次比较,大大提高了缓存利用率。
S+树是S树的一个变种,它将所有数据都存储在叶子节点,并在内部节点中复制这些值。这简化了搜索逻辑,因为结果总是在叶子层找到。
4. 优化节点内搜索 (find 函数)
节点内搜索的目标是在16个已排序的值中找到第一个大于等于查询值的元素。
- 线性扫描:简单但性能不佳,因为分支预测器难以预测。
- 自动向量化:通过计算小于查询值的元素个数来避免分支。编译器可以自动将其向量化为SIMD指令,性能提升显著。
- 手动SIMD优化:通过手动编写SIMD指令,我们进一步精简了汇编代码,移除了不必要的指令(如
vpshufd),最终将节点内搜索的指令数从10条减少到5条,性能再次提升。
5. 优化搜索过程
- 批处理:这是最重要的优化之一。通过同时处理多个查询(例如128个),CPU可以并行地发出多个内存请求,从而有效隐藏内存访问延迟。批处理将吞吐量从115纳秒/查询提升到45纳秒/查询。
- 预取:在处理当前节点时,显式地预取下一个需要访问的节点。这有助于进一步隐藏内存延迟,尤其是在数据量超过L2缓存时,将吞吐量提升至约30纳秒/查询。
- 指针运算优化:通过使用字节指针而非数组索引,并预先将索引乘以节点大小(64字节),我们减少了汇编代码中的乘法指令,获得了微小的性能提升。
- 层间交错:为了平衡CPU计算和内存访问的负载,我们将多个批次的查询交错执行。例如,在处理批次A的最后一层(内存密集型)的同时,处理批次B的前几层(CPU密集型)。这种交错策略将吞吐量提升至约24纳秒/查询。
6. 优化树布局
- 左最大树:传统的S+树节点存储其右子树的最小值。我们改为存储左子树的最大值。这种“左最大”布局在某些边界情况下能更直接地导向包含答案的节点,从而减少一次不必要的缓存行读取,将性能提升至约22纳秒/查询。
- 节点大小:尝试将每个节点存储15个值(分支因子为16)而非16个值(分支因子为17)。这简化了索引计算(乘法变为移位),在数据量较小时略有提速,但会增加约一倍的数据结构空间开销。
- 内存布局:尝试了反向布局和全量布局,但性能均未优于我们之前使用的紧凑布局。
7. 前缀分区
为了进一步提速,我们尝试根据查询值的高位比特进行分区,为每个分区构建独立的搜索树。
- 全量布局:为每个分区构建完整的树,但空间浪费严重。
- 紧凑布局:将各分区的树紧凑地存储,但查询时需要额外跟踪分区信息,导致性能下降。
- 第一层压缩:仅对树的第一层进行压缩,后续层保持全量。这种方法在空间效率和查询速度之间取得了较好的平衡。
- 重叠树:允许相邻分区的树共享存储空间,以减少内存占用,尤其适用于分区大小不均匀的情况。
- 前缀映射:使用一个映射表来记录每个分区在紧凑树中的起始位置。这种方法能有效处理非均匀数据,但引入了一次额外的内存访问。
结论:尽管前缀分区在某些场景下能带来微小的性能提升,但其增加的复杂性和对数据分布的依赖性,使其不如直接使用层间交错查询来得简单有效。
8. 多线程比较
当使用6个线程并行查询时,吞吐量从单线程的27纳秒/查询提升至7纳秒/查询。此时,性能瓶颈已从单核计算能力转移到了总的内存带宽上。
9. 总结
通过一系列优化,我们将4GB输入数据上的查询时间从二分查找的1150纳秒/查询降低到了优化后S树的27纳秒/查询,实现了超过40倍的加速。其中,批处理和预取是贡献最大的优化手段。层间交错则进一步平衡了计算与内存访问的负载。虽然前缀分区和节点大小调整也带来了一些提升,但它们的收益相对有限,且增加了复杂性。
未来工作包括:探索分支搜索、插值搜索、数据压缩、支持范围查询、对查询进行排序以利用空间局部性,以及将本工作集成到快速的后缀数组搜索方案中。
评论总结
根据评论内容,主要观点和论据如下:
观点一:Eytzinger布局与二叉堆的相似性
- 评论3指出,Eytzinger布局与二叉堆的存储方式相同(根节点在索引1,子节点在2i和2i+1),但二叉堆不维护平衡二叉树,仅保证父节点小于子节点的键值关系。
- 关键引用:
- "This is exactly what is done in good old binary heaps"
- "Binary heaps don't support efficient search for a particular key"
观点二:Eytzinger布局的缓存优势
- 评论3认为,该布局将树的前几层紧密排列,有利于缓存和分页,搜索路径仅需访问少量页面。
- 关键引用:
- "The first few layers of the tree will all fit into a single VM page"
- "If the search path from root to leaf is 3k, it should touch only three pages"
观点三:Eytzinger布局的实际性能
- 评论4提出,对于常规规模的数据,Eytzinger布局性能不如排序数组。
- 关键引用:
- "On normal sized data Eytzinger is worse than sorted"
其他评论
- 评论1仅表达感谢("Thanks for sharing this"),无实质观点。
- 评论2提及van Emde Boas树,但未展开论证("My first instinct is van Emde Boas tree")。
总结:评论主要围绕Eytzinger布局与二叉堆的关联、其缓存友好性及实际性能表现展开,存在不同观点:部分肯定其理论优势,另一部分质疑其实用性。