SwissTable:高性能哈希表实现
本文介绍了一种新的高性能哈希表实现: SwissTable,详细阐述了 SwissTable 的设计理念、数据结构,并且比较了传统哈希表以及当前 Go 的 map 实现。原文:SwissTable: A High-Performance Hash Table Implementation
前言
字节跳动在 2022 年提出提案,建议 Golang 采用 SwissTable 作为其 map 实现。2023 年,Dolt 发表了一篇题为 SwissMap:更小、更快的 Golang 哈希表的博文,详细介绍了他们设计的 swisstable
,引起了广泛关注。Go 核心团队正在重新评估 swisstable
的设计,并在运行时添加了相关代码。本文将深入探讨其原理,将其与 runtime map
进行比较,并了解为什么它可能成为 map
实现的标准。
本文不会全面解释 hashtable
或 swisstable
的原理,需要读者对 hashtable
有基本的了解。
hashtable
通过使用哈希函数将 key
映射到某个 "位置",提供了从 key
到相应 value
的映射,从而可以直接检索所需的值。
传统哈希表
hashtable
通过使用哈希函数将 key
映射到某个 "位置",提供了从键到值的映射。然而,在将无限多个键映射到有限的内存空间时,再完美的哈希函数都无法避免冲突(两个不同的键将被映射到相同的位置)。为了解决这个问题,传统哈希表有几种冲突解决策略,最常见的是链式(chaining
)和线性探测(linear probing
)。
链式(chaining
)
链式是最常见的方法,如果多个键映射到同一位置,这些键和值就会存储在一个链表中。在查找过程中,哈希函数用于查找位置,然后遍历该位置的链表以查找匹配的键。其结构与此类似:
图 1:哈希表的链式实现
链式处理简单易行,需要考虑的边界条件较少。数据插入和删除都很快捷,使用头部插入来添加新条目,调整下一个指针来删除数据。链式处理还能将过长的链转换为搜索树,以防性能下降。不过,链式处理对缓存不友好,如果冲突较多,性能就会受到影响。不同的槽位(slot
)可能在内存中分布很广,导致数据结构的整体空间定位性差。
线性探测(linear probing
)
线性探测是另一种标准的哈希表冲突解决方案。与链式搜索不同,当发生哈希冲突时,从冲突位置开始依次搜索,直到找到空槽位或循环回到冲突位置。此时,它会调整条目大小并重新哈希。
图 2:线性探测
查找的工作原理类似:计算键的哈希位置,然后从该位置开始比较每个键,跳过任何已删除条目,直到出现空槽位,表示键不在表中。删除使用墓碑标记。
图 3:线性探测查找
线性探测的时间复杂度与链式探测相当。优点是对缓存友好,可通过数组等紧密数据结构实现。然而,它的缺点是:
实现复杂,
slot
有三种状态:占用、空闲和删除。冲突的连锁反应,造成比链式更频繁的大小调整,内存使用量可能更大。
如果不能将冲突严重的区域转化为搜索树,查找过程降级为
O(n)
的可能性会更大。
由于线性探测在元素删除和冲突连锁反应方面存在困难,大部分库都采用链式方案。尽管线性探测存在一些缺点,但其对缓存的友好性和内存效率在现代计算机上具有显著的性能优势,因此被用于 Golang 和 Python 等语言中。
Go map 数据存储
我们回顾一下 Go Map
是如何存储数据的:
图 4:Go Map
快速总结:
Go
map
使用哈希函数将键映射到多个桶(bucket
),每个桶都有固定数量的键值存储槽(slot
)。每个存储桶最多可存储 8 个键值对。发生冲突时,冲突的键和值会存储在同一个桶中。
使用哈希函数计算键的哈希值,并找到相应的桶。
如果桶已满(8 个插位都已使用),就会生成一个溢出桶(
overflow bucket
),继续存储新的键值对。查找时,先计算键的哈希值,然后确定相应的桶,并检查桶内每个插槽。如果有溢出桶,也会按顺序检查其键。
SwissTable:高效哈希表实现
SwissTable
是一种基于改进的线性探测方法的 hashtable
实现,核心理念是通过增强哈希表结构和元数据存储来优化性能和内存使用。SwissTable 采用新的元数据控制机制,大大减少了不必要的键比较,并利用 SIMD 指令提高了吞吐量。
回顾两种标准哈希表实现,可以发现它们要么浪费内存、对缓存不友好,要么在冲突后的查找、插入和删除操作中性能下降。即使有 "完美哈希函数",问题依然存在,而次优哈希函数会大大增加键冲突的概率,并降低性能,甚至可能无法在数组中进行线性搜索。
业界一直在寻找一种对高速缓存友好又能防止查找性能下降的哈希表算法。许多人致力于开发更好的哈希函数,以接近 "完美哈希函数" 的质量,同时优化计算性能;还有人致力于改进哈希表结构,以平衡缓存友好性、性能和内存使用。swisstable属于后者。
SwissTable
的时间复杂度类似于线性探测,而空间复杂度则介于链式探测和线性探测之间,参考实现主要基于 dolthub/swiss。
SwissTable 基本结构
虽然名字变了,但 swisstable
仍是 hashtable
,采用了改进的线性探测方法来处理哈希冲突,底层结构类似于数组。现在,我们深入了解一下 swisstable
的结构:
在 swisstable
中,ctrl
是元数据(metadata
)数组,与 group[K, V]
数组相对应,每个组(group
)有 8 个槽位(slot
)。
哈希值中的 57 位称为 H1,用于确定起始分组,其余 7 位称为 H2,作为当前键的哈希值签名存储在元数据中,用于后续搜索和过滤。
与传统哈希表相比,swisstable
的主要优势在于名为 ctrl
的元数据。控制信息包括:
槽位是否为空:
0b10000000
槽位是否已删除:
0b11111110
槽位中键的哈希签名 (H2):
0bh2
这些状态的唯一值允许使用 SIMD 指令,从而最大限度提高性能。
添加数据
在 swisstable
中添加数据的过程包括几个步骤:
计算哈希值,并将其分为
h1
和h2
。基于h1
确定起始分组。通过
metaMatchH2
检查当前组元数据中是否有匹配的h2
。如果找到,则进一步检查匹配的键,如果匹配则更新值。如果没有找到匹配的键,则通过
metaMatchEmpty
检查当前组中的空槽。如果发现空槽,则插入新的键值对,并更新元数据和resident
计数。如果当前组没有空槽位,则执行线性探测,检查下一组。
虽然步骤不多,但却涉及复杂的位操作。通常,h2
需要依次与所有键进行比较,直到找到目标:
将
h2
乘以0x01010101010101
得到一个 uint64 值,可同时与 8 个ctrl
值进行比较。与
meta
进行xor
运算。如果元数据中存在h2
,则相应位将为 0。metaMatchH2
函数可帮助我们理解这一过程。
SwissTable 的优势
回顾 SwissTable 的实现,可以发现几个主要优势:
操作从
slot
转移到ctrl
,ctrl
更小,更容易放入 CPU 缓存,尽管多了一个定位slot
的步骤,但还是加快了操作速度。记录哈希签名,减少无意义的密钥比较(这是线性探测性能下降的主要原因)。
对
slot
的ctrl
进行批量操作可显著提高吞吐量。元数据和内存布局针对 SIMD 指令进行了优化,最大限度提高了性能。
slot
优化(如压缩大数据)可提高缓存命中率。
swisstable
解决了空间局部性问题,并利用现代 CPU 能力进行批量运算,大大提高了性能。
最后,在本地 MacBook M1(不支持 SIMD)上运行的基准测试表明,在大 map 场景下性能有显著提升。
图 5:官方 swisstable
基准测试
结论
目前,swisstable
在 Go 中的官方实现仍在讨论中,也有一些社区实现,如 concurrent-swiss-map 和 swiss,不过还不够完美,在小 map 场景中 swisstable
的性能甚至可能不如 runtime_map
。尽管如此,swisstable
在其他语言中展现出的潜力表明它值得期待。
参考资料
Dolthub: SwissMap
SwissTable 原理: Abseil SwissTables
位操作入门:Stanford Bit Hacks
哈希函数比较测试An additional bit manipulation article: Fast Modulo Reduction
另一篇位操作文章:快速模运算
你好,我是俞凡,在 Motorola 做过研发,现在在 Mavenir 做技术工作,对通信、网络、后端架构、云原生、DevOps、CICD、区块链、AI 等技术始终保持着浓厚的兴趣,平时喜欢阅读、思考,相信持续学习、终身成长,欢迎一起交流学习。为了方便大家以后能第一时间看到文章,请朋友们关注公众号"DeepNoMind",并设个星标吧,如果能一键三连(转发、点赞、在看),则能给我带来更多的支持和动力,激励我持续写下去,和大家共同成长进步!
版权声明: 本文为 InfoQ 作者【俞凡】的原创文章。
原文链接:【http://xie.infoq.cn/article/2e2a8434f2896c2d520fc82b2】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论