SwissTable:高性能哈希表实现

本文介绍了一种新的高性能哈希表实现: SwissTable,详细阐述了 SwissTable 的设计理念、数据结构,并且比较了传统哈希表以及当前 Go 的 map 实现。原文:SwissTable: A High-Performance Hash Table Implementation[1]

alt
前言

字节跳动在 2022 年提出提案[2],建议 Golang 采用 SwissTable 作为其 map 实现。2023 年,Dolt 发表了一篇题为 SwissMap:更小、更快的 Golang 哈希表[3]的博文,详细介绍了他们设计的 swisstable,引起了广泛关注。Go 核心团队正在重新评估 swisstable 的设计,并在运行时[4]添加了相关代码。本文将深入探讨其原理,将其与 runtime map 进行比较,并了解为什么它可能成为 map 实现的标准。

本文不会全面解释 hashtableswisstable 的原理,需要读者对 hashtable 有基本的了解。

hashtable 通过使用哈希函数将 key 映射到某个 "位置",提供了从 key 到相应 value 的映射,从而可以直接检索所需的值。

传统哈希表

hashtable 通过使用哈希函数将 key 映射到某个 "位置",提供了从键到值的映射。然而,在将无限多个键映射到有限的内存空间时,再完美的哈希函数都无法避免冲突(两个不同的键将被映射到相同的位置)。为了解决这个问题,传统哈希表有几种冲突解决策略,最常见的是链式(chaining)和线性探测(linear probing)。

链式(chaining

链式是最常见的方法,如果多个键映射到同一位置,这些键和值就会存储在一个链表中。在查找过程中,哈希函数用于查找位置,然后遍历该位置的链表以查找匹配的键。其结构与此类似:

图 1:哈希表的链式实现

alt

链式处理简单易行,需要考虑的边界条件较少。数据插入和删除都很快捷,使用头部插入来添加新条目,调整下一个指针来删除数据。链式处理还能将过长的链转换为搜索树,以防性能下降。不过,链式处理对缓存不友好,如果冲突较多,性能就会受到影响。不同的槽位(slot)可能在内存中分布很广,导致数据结构的整体空间定位性差。

线性探测(linear probing

线性探测是另一种标准的哈希表冲突解决方案。与链式搜索不同,当发生哈希冲突时,从冲突位置开始依次搜索,直到找到空槽位或循环回到冲突位置。此时,它会调整条目大小并重新哈希。

图 2:线性探测

alt

查找的工作原理类似:计算键的哈希位置,然后从该位置开始比较每个键,跳过任何已删除条目,直到出现空槽位,表示键不在表中。删除使用墓碑标记。

图 3:线性探测查找

alt

线性探测的时间复杂度与链式探测相当。优点是对缓存友好,可通过数组等紧密数据结构实现。然而,它的缺点是:

  • 实现复杂, slot 有三种状态:占用、空闲和删除。
  • 冲突的连锁反应,造成比链式更频繁的大小调整,内存使用量可能更大。
  • 如果不能将冲突严重的区域转化为搜索树,查找过程降级为 O(n) 的可能性会更大。

由于线性探测在元素删除和冲突连锁反应方面存在困难,大部分库都采用链式方案。尽管线性探测存在一些缺点,但其对缓存的友好性和内存效率在现代计算机上具有显著的性能优势,因此被用于 Golang 和 Python 等语言中。

Go map 数据存储

我们回顾一下 Go Map 是如何存储数据的:

图 4:Go Map

alt

快速总结:

  • Go map 使用哈希函数将键映射到多个桶( bucket),每个桶都有固定数量的键值存储槽( slot)。
  • 每个存储桶最多可存储 8 个键值对。发生冲突时,冲突的键和值会存储在同一个桶中。
  • 使用哈希函数计算键的哈希值,并找到相应的桶。
  • 如果桶已满(8 个插位都已使用),就会生成一个溢出桶( overflow bucket),继续存储新的键值对。
  • 查找时,先计算键的哈希值,然后确定相应的桶,并检查桶内每个插槽。如果有溢出桶,也会按顺序检查其键。
SwissTable:高效哈希表实现

SwissTable 是一种基于改进的线性探测方法的 hashtable 实现,核心理念是通过增强哈希表结构和元数据存储来优化性能和内存使用。SwissTable 采用新的元数据控制机制,大大减少了不必要的键比较,并利用 SIMD 指令提高了吞吐量。

回顾两种标准哈希表实现,可以发现它们要么浪费内存、对缓存不友好,要么在冲突后的查找、插入和删除操作中性能下降。即使有 "完美哈希函数",问题依然存在,而次优哈希函数会大大增加键冲突的概率,并降低性能,甚至可能无法在数组中进行线性搜索。

业界一直在寻找一种对高速缓存友好又能防止查找性能下降的哈希表算法。许多人致力于开发更好的哈希函数,以接近 "完美哈希函数" 的质量,同时优化计算性能;还有人致力于改进哈希表结构,以平衡缓存友好性、性能和内存使用。swisstable[5]属于后者。

SwissTable 的时间复杂度类似于线性探测,而空间复杂度则介于链式探测和线性探测之间,参考实现主要基于 dolthub/swiss[6]

SwissTable 基本结构

虽然名字变了,但 swisstable 仍是 hashtable,采用了改进的线性探测方法来处理哈希冲突,底层结构类似于数组。现在,我们深入了解一下 swisstable 的结构:

type Map[K comparable, V any] struct {  
    ctrl     []metadata  
    groups   []group[K, V]  
    hash     maphash.Hasher[K]  
    resident uint32   
    dead     uint32   
    limit    uint32   
}
type metadata [groupSize]int8  
type group[K comparable, V any] struct {  
    keys   [groupSize]K  
    values [groupSize]V  
}

swisstable 中,ctrl 是元数据(metadata)数组,与 group[K, V] 数组相对应,每个组(group)有 8 个槽位(slot)。

哈希值中的 57 位称为 H1,用于确定起始分组,其余 7 位称为 H2,作为当前键的哈希值签名存储在元数据中,用于后续搜索和过滤。

与传统哈希表相比,swisstable 的主要优势在于名为 ctrl 的元数据。控制信息包括:

  • 槽位是否为空: 0b10000000
  • 槽位是否已删除: 0b11111110
  • 槽位中键的哈希签名 (H2): 0bh2

这些状态的唯一值允许使用 SIMD 指令,从而最大限度提高性能。

添加数据

swisstable 中添加数据的过程包括几个步骤:

  • 计算哈希值,并将其分为 h1h2。基于 h1 确定起始分组。
  • 通过 metaMatchH2 检查当前组元数据中是否有匹配的 h2。如果找到,则进一步检查匹配的键,如果匹配则更新值。
  • 如果没有找到匹配的键,则通过 metaMatchEmpty 检查当前组中的空槽。如果发现空槽,则插入新的键值对,并更新元数据和 resident 计数。
  • 如果当前组没有空槽位,则执行线性探测,检查下一组。
func (m *Map[K, V]) Put(key K, value V) {  
    if m.resident >= m.limit {  
        m.rehash(m.nextSize())  
    }  
    hi, lo := splitHash(m.hash.Hash(key))  
    g := probeStart(hi, len(m.groups))  
    for { // 内联查找循环  
        matches := metaMatchH2(&m.ctrl[g], lo)  
        for matches != 0 {  
            s := nextMatch(&matches)  
            if key == m.groups[g].keys[s] { // 更新  
                m.groups[g].keys[s] = key  
                m.groups[g].values[s] = value  
                return  
            }  
        }  
        matches = metaMatchEmpty(&m.ctrl[g])  
        if matches != 0 { // 插入  
            s := nextMatch(&matches)  
            m.groups[g].keys[s] = key  
            m.groups[g].values[s] = value  
            m.ctrl[g][s] = int8(lo)  
            m.resident++  
            return  
        }  
        g += 1 // 线性探测  
        if g >= uint32(len(m.groups)) {  
            g = 0  
        }  
    }  
}
func metaMatchH2(m *metadata, h h2) bitset {  
    return hasZeroByte(castUint64(m) ^ (loBits * uint64(h)))  
}
func nextMatch(b *bitset) uint32 {  
    s := uint32(bits.TrailingZeros64(uint64(*b)))  
    *b &= ^(1 << s)  
    return s >> 3   
}

虽然步骤不多,但却涉及复杂的位操作。通常,h2 需要依次与所有键进行比较,直到找到目标:

  • h2 乘以 0x01010101010101 得到一个 uint64 值,可同时与 8 个 ctrl 值进行比较。
  • meta 进行 xor 运算。如果元数据中存在 h2,则相应位将为 0。 metaMatchH2 函数可帮助我们理解这一过程。
func TestMetaMatchH2(t *testing.T) {
    metaData := make([]metadata, 2)
    metaData[0] = [8]int8{0x7f000x7f0000x7f}
    m := &metaData[0]
    h := 0x7f
    metaUint64 := castUint64(m)
    h2Pattern := loBits * uint64(h)
    xorResult := metaUint64 ^ h2Pattern
    fmt.Printf("metaUint64: %b\n", xorResult)
    r := hasZeroByte(xorResult)
    fmt.Printf("r: %b\n", r)
    for r != 0 {  
        fmt.Println(nextMatch(&r))  
    }
}
----
输出
// metaUint64: 00000000 11111110 11111110 11111110 0000000 01111111 01111111 00000000
// r: 10000000 00000000 00000000 00000000 10000000 00000000 00000000 10000000
// 0
// 3
// 7
SwissTable 的优势

回顾 SwissTable 的实现,可以发现几个主要优势:

  • 操作从 slot 转移到 ctrlctrl 更小,更容易放入 CPU 缓存,尽管多了一个定位 slot 的步骤,但还是加快了操作速度。
  • 记录哈希签名,减少无意义的密钥比较(这是线性探测性能下降的主要原因)。
  • slotctrl 进行批量操作可显著提高吞吐量。
  • 元数据和内存布局针对 SIMD 指令进行了优化,最大限度提高了性能。
  • slot 优化(如压缩大数据)可提高缓存命中率。

swisstable 解决了空间局部性问题,并利用现代 CPU 能力进行批量运算,大大提高了性能。

最后,在本地 MacBook M1(不支持 SIMD)上运行的基准测试表明,在大 map 场景下性能有显著提升。

图 5:官方 swisstable 基准测试

alt
结论

目前,swisstable 在 Go 中的官方实现仍在讨论中,也有一些社区实现,如 concurrent-swiss-map[7]swiss[8],不过还不够完美,在小 map 场景中 swisstable 的性能甚至可能不如 runtime_map。尽管如此,swisstable 在其他语言中展现出的潜力表明它值得期待。

参考资料
  • Dolthub: SwissMap [9]
  • SwissTable 原理: Abseil SwissTables [10]
  • cppcon 原始 SwissTable 提案 [11]
  • 改进 SwissTable 算法 [12]
  • 位操作入门: Stanford Bit Hacks [13]
  • 哈希函数比较测试 [14] An additional bit manipulation article: Fast Modulo Reduction
  • 另一篇位操作文章: 快速模运算 [15]

你好,我是俞凡,在Motorola做过研发,现在在Mavenir做技术工作,对通信、网络、后端架构、云原生、DevOps、CICD、区块链、AI等技术始终保持着浓厚的兴趣,平时喜欢阅读、思考,相信持续学习、终身成长,欢迎一起交流学习。为了方便大家以后能第一时间看到文章,请朋友们关注公众号"DeepNoMind",并设个星标吧,如果能一键三连(转发、点赞、在看),则能给我带来更多的支持和动力,激励我持续写下去,和大家共同成长进步!

参考资料
[1]

SwissTable: A High-Performance Hash Table Implementation: https://medium.com/plain-golang-tutorial/swisstable-a-high-performance-hash-table-implementation-3e13bfe8c79b

[2]

提案: https://github.com/golang/go/issues/54766

[3]

SwissMap:更小、更快的 Golang 哈希表: https://www.dolthub.com/blog/2023-03-28-swiss-map

[4]

runtime/map_swiss.go: https://github.com/golang/go/blob/6a730e1ef0b7f312fe01815086a2eb5a25739f2d/src/runtime/map_swiss.go#L7

[5]

swisstable: https://abseil.io/about/design/swisstables

[6]

dolthub/swiss: https://github.com/dolthub/swiss/tree/main

[7]

concurrent-swiss-map: https://github.com/mhmtszr/concurrent-swiss-map

[8]

swiss: https://github.com/dolthub/swiss

[9]

SwissMap: https://www.dolthub.com/blog/2023-03-28-swiss-map

[10]

Abseil SwissTables: https://abseil.io/about/design/swisstables

[11]

cppcon 原始 SwissTable 提案: https://www.youtube.com/watch?v=ncHmEUmJZf4

[12]

改进 SwissTable 算法: https://www.youtube.com/watch?v=JZE3_0qvrMg

[13]

Stanford Bit Hacks: http://graphics.stanford.edu/~seander/bithacks.html##ValueInWord

[14]

哈希函数比较测试: https://aras-p.info/blog/2016/08/09/More-Hash-Function-Tests

[15]

A fast alternative to the modulo reduction: https://lemire.me/blog/2016/06/27/a-fast-alternative-to-the-modulo-reduction

本文由 mdnice 多平台发布

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

俞凡 DeepNoMind

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值