使用 Swiss Tables 实现更快的 Go Maps

在现代编程语言中,map 是一个不可或缺的数据结构,而哈希表正是其背后的核心实现技术。作为计算机科学中最基础的数据结构之一,哈希表为 Go 等众多编程语言提供了高效的键值存储方案。

回溯历史,哈希表的故事要从 1953 年说起。那一年,IBM 的 Hans Peter Luhn 在一份内部备忘录中首次提出了这个开创性的想法:通过将数据项分散到不同的"桶"中,并用链表处理冲突来加快查找速度。这种方法后来被称为"链接法",成为了哈希表最经典的实现方式之一。

一年之后的 1954 年,Gene M. Amdahl、Elaine M. McGraw 和 Arthur L. Samuel 在开发 IBM 701 时提出了另一种巧妙的方案:当一个位置已被占用时,就尝试放入下一个空位。这种被称为"开放寻址"的技术在 1957 年由 W. Wesley Peterson 在其论文《随机访问存储的寻址》中得到了理论化和系统化的阐述。这就是我们今天所说的线性探测法。 · 面对这些已有数十年历史的经典数据结构,人们很容易认为它们已经发展到了极致,没有什么可以改进的空间了。但事实恰恰相反!计算机科学的研究仍在不断推进,无论是在算法复杂度的优化上,还是在充分利用现代 CPU 特性方面,都在持续取得突破。比如 Go 1.19 就用 Orson R. L. Peters 在 2015 年提出的模式消除快速排序取代了传统的快速排序算法,这是排序算法演进的最新例证。

哈希表的创新并未止步。2017 年,Google 的工程师团队 —— Sam Benzaquen、Alkis Evlogimenos、Matt Kulukundis 和 Roman Perepelitsa —— 带来了一个令人耳目一新的设计:Swiss Tables。这个巧妙的名字暗示了它的特点:就像瑞士军刀一样,既精巧又高效。这个设计在 2018 年通过 Abseil C++ 库开源,让更多开发者得以一睹其风采。

而现在,Go 1.24 版本迎来了一次重大更新:内置的 map 类型完全采用了 Swiss Table 的设计理念。这次革新不仅带来了性能的提升,更重要的是展示了如何将一个出色的想法改造成符合 Go 语言特性的实现。让我们一起深入了解这个精妙的设计,以及 Go 团队是如何应对实现过程中的各种挑战的。

开放寻址哈希表:基础知识

在深入 Swiss Tables 的细节之前,我们需要先理解它的基础 —— 开放寻址哈希表。想象一个巨大的停车场,每个车位就像数组中的一个槽位。当你想停车(存储一个键)时,你会根据车牌号(通过哈希函数)计算出一个理想的车位。如果这个车位已经被占用了(发生冲突),你就需要找下一个空位 —— 这就是开放寻址的核心思想。

在技术实现上,所有数据都存储在一个连续的数组中,每个位置我们称之为槽位。通过哈希函数,我们可以将任意键转换为一个整数,用这个整数来确定数据应该存储在哪个槽位。当出现冲突时,我们就按照某种固定的模式(探测序列)寻找下一个空槽位。这种设计的优雅之处在于它的简单性和内存局部性。让我们通过一个具体的例子来看看它是如何工作的。

示例

下面你可以看到一个具有 16 个槽位的哈希表后备数组,以及存储在每个槽位中的键(如果有)。值未显示,因为它们与此示例无关。

1
2
3
4
5
+------+----+----+----+----+----+----+----+----+----+----+----+----+----+----+----+----+
| 槽位 | 0  | 1  | 2  | 3  | 4  | 5  | 6  | 7  | 8  | 9  | 10 | 11 | 12 | 13 | 14 | 15 |
+------+----+----+----+----+----+----+----+----+----+----+----+----+----+----+----+----+
| 键   |    |    |    | 56 | 32 | 21 | 78 |    |    |    |    |    |    |    |    |    |
+------+----+----+----+----+----+----+----+----+----+----+----+----+----+----+----+----+

要插入新键,我们使用哈希函数来选择一个槽位。由于只有 16 个槽位,我们需要限制在这个范围内,所以我们将使用 hash(key) % 16 作为目标槽位。假设我们要插入键 98,且 hash(98) % 16 = 7。槽位 7 是空的,所以我们只需在那里插入 98。另一方面,假设我们要插入键 25,且 hash(25) % 16 = 3。槽位 3 是一个冲突,因为它已经包含键 56。因此我们不能在这里插入。

我们使用探测序列来找到另一个槽位。有各种众所周知的探测序列。最原始和最直接的探测序列是线性探测,它只是按顺序尝试连续的槽位。

所以,在 hash(25) % 16 = 3 的例子中,由于槽位 3 正在使用中,我们会接下来考虑槽位 4,它也在使用中。槽位 5 也是如此。最后,我们会到达空槽位 6,我们会在那里存储键 25。

查找遵循相同的方法。查找键 25 会从槽位 3 开始,检查它是否包含键 25(不包含),然后继续线性探测,直到在槽位 6 中找到键 25。

这个示例使用一个具有 16 个槽位的后备数组。如果我们插入超过 16 个元素会发生什么?如果哈希表用完空间,它将增长,通常是将后备数组的大小加倍。所有现有条目都会重新插入到新的后备数组中。

开放寻址哈希表实际上不会等到后备数组完全填满才增长,因为随着数组变得更满,每个探测序列的平均长度增加。在上面使用键 25 的示例中,我们必须探测 4 个不同的槽位才能找到一个空槽位。如果数组只有一个空槽位,最坏情况下的探测长度将是 O(n)。也就是说,你可能需要扫描整个数组。已使用槽位的比例称为负载因子,大多数哈希表定义了一个最大负载因子(通常为 70-90%),在此时它们将增长以避免非常满的哈希表的极长探测序列。

Swiss Table:并行探测的艺术

Swiss Table 也是一种开放寻址哈希表,但它引入了一个巧妙的创新:分组处理和并行探测。想象一下,如果我们能同时检查多个槽位,而不是一个接一个地查找,那会有多高效!这正是 Swiss Table 的核心思想。

在 Swiss Table 中,我们将整个数组划分为多个逻辑组,每组包含 8 个槽位。这个数字并非随意选择 —— 它与现代 CPU 的 SIMD(单指令多数据)指令集完美匹配,允许我们一次处理 8 个字节的数据。

除了存储实际数据的槽位外,每个组还配备了一个 64 位的"控制字"作为元数据。这个控制字中的每个字节对应组中的一个槽位,记录着该槽位的状态(空闲、已删除或使用中)以及一个额外的信息:如果槽位正在使用,该字节还会存储键的哈希值的低 7 位(我们称之为 h2)。这个看似简单的设计,却是 Swiss Table 高效性能的关键所在。

1
2
3
4
5
6
7
+-------+----+----+----+----+----+----+----+----+  +-------+----+----+----+----+----+----+----+----+
| 组 0  | 0  | 1  | 2  | 3  | 4  | 5  | 6  | 7  |  | 组 1  | 0  | 1  | 2  | 3  | 4  | 5  | 6  | 7  |
+-------+----+----+----+----+----+----+----+----+  +-------+----+----+----+----+----+----+----+----+
| 键    | 56 | 32 | 21 |    |    |    |    |    |  | 键    | 78 |    |    |    |    |    |    |    |
+-------+----+----+----+----+----+----+----+----+  +-------+----+----+----+----+----+----+----+----+
| h2    | 23 | 89 | 50 |    |    |    |    |    |  | h2    | 47 |    |    |    |    |    |    |    |
+-------+----+----+----+----+----+----+----+----+  +-------+----+----+----+----+----+----+----+----+

插入的工作方式如下:

  1. 计算 hash(key) 并将哈希分成两部分:上部 57 位(称为 h1)和下部 7 位(称为 h2)。
  2. 上部位(h1)用于选择要考虑的第一个组:在这种情况下为 h1 % 2,因为只有 2 个组。
  3. 在一个组内,所有槽位都同样有资格容纳键。我们必须首先确定是否有任何槽位已经包含这个键,在这种情况下,这是一个更新而不是新插入。
  4. 如果没有槽位包含该键,那么我们寻找一个空槽位来放置这个键
  5. 如果没有空槽位,那么我们通过搜索下一个组来继续探测序列。

查找遵循相同的基本过程。如果我们在步骤 4 中找到一个空槽位,那么我们知道插入会使用这个槽位,可以停止搜索。

步骤 3 是 Swiss Table 魔法发生的地方。我们需要检查组中是否有任何槽位包含所需的键。简单的方法是进行线性扫描并比较所有 8 个键。然而,控制字让我们可以更有效地做到这一点。每个字节包含该槽位的哈希的低 7 位(h2)。如果我们确定控制字中哪些字节包含我们正在寻找的 h2,我们就会有一组候选匹配。

换句话说,我们想要在控制字内进行逐字节的相等比较。例如,如果我们正在寻找键 32,其中 h2 = 89,我们想要的操作如下所示:

1
2
3
4
5
6
7
8
9
+--------+----+----+----+----+----+----+----+----+
| 测试字 | 89 | 89 | 89 | 89 | 89 | 89 | 89 | 89 |
+--------+----+----+----+----+----+----+----+----+
| 比较   | == | == | == | == | == | == | == | == |
+--------+----+----+----+----+----+----+----+----+
| 控制字 | 23 | 89 | 50 | -  | -  | -  | -  | -  |
+--------+----+----+----+----+----+----+----+----+
| 结果   | 0  | 1  | 0  | 0  | 0  | 0  | 0  | 0  |
+--------+----+----+----+----+----+----+----+----+

这是 SIMD(单指令多数据)硬件支持的操作,其中单个指令对较大值(向量)内的独立值执行并行操作。在这种情况下,当特殊的 SIMD 硬件不可用时,我们可以使用一组标准的算术和位运算来实现这个操作。

结果是一组候选槽位。h2 不匹配的槽位不会有匹配的键,因此可以跳过。h2 匹配的槽位是潜在的匹配,但我们仍然必须检查整个键,因为存在冲突的可能性(7 位哈希的冲突概率为 1/128,因此仍然相当低)。

这个操作非常强大,因为我们实际上并行执行了探测序列的 8 个步骤。这通过减少我们需要执行的平均比较次数来加速查找和插入。这种探测行为的改进使得 Abseil 和 Go 实现都能够增加 Swiss Table maps 相比之前的 maps 的最大负载因子,这降低了平均内存占用。

Go 的挑战:让 Swiss Table 适应 Go 的特性

将 Swiss Table 引入 Go 并非易事。Go 语言的 map 类型有着一些独特的特性和保证,这些都是 Go 开发者所熟悉和依赖的。要让 Swiss Table 在 Go 中发挥作用,我们需要解决一些关键挑战。

增量增长:平衡性能与延迟

想象一下这样的场景:你正在运行一个关键的网络服务,突然间,一个简单的 map 操作导致整个系统暂停了几百毫秒。这对于实时系统来说可能是灾难性的。

传统哈希表在增长时通常采用"一次性扩容"的策略:当空间不足时,创建一个两倍大的新数组,然后将所有现有元素重新哈希并复制过去。对于一个包含百万级条目的大型 map,这个过程可能需要相当长的时间。

1
2
3
// 传统哈希表增长示意
1GB map → 需要扩容 → 暂停服务 → 复制全部数据到2GB新空间 → 恢复服务
                      ↑_______可能需要几百毫秒_______↑

Go 作为一种注重实用性的语言,特别关注低延迟场景。因此,Go 的 map 采用了增量增长策略,将大型扩容操作分散到多次插入中,确保每次操作的延迟都在可控范围内。

然而,Swiss Table 的设计本身就假设一次性增长,因为它的探测序列依赖于表的总大小。这看似是一个难以调和的矛盾。

Go 团队的解决方案既优雅又实用:将一个大的 map 拆分成多个较小的 Swiss Tables。具体来说:

  1. 每个 Go map 内部由多个独立的 Swiss Tables 组成
  2. 每个表负责键空间的一个子集,最多存储 1024 个条目
  3. 使用哈希值的高位来决定键属于哪个表
  4. 当单个表需要增长时,只影响该表的条目,其他表不受影响

这种设计确保了即使在最坏的情况下,一次插入操作也只需要重新哈希 1024 个条目,而不是整个 map 的所有条目。这有效地将最大延迟限制在一个可接受的范围内,同时保留了 Swiss Table 的性能优势。

迭代期间的修改:保持一致性的艺术

想象你正在遍历一个装满书的书架,同时有人在添加、移除或更换书籍。这就是 map 迭代期间修改的场景。大多数哈希表实现,包括 Abseil 的 Swiss Tables,都采取了最简单的方案:完全禁止在迭代过程中修改 map。这就像在整理书架时贴上"请勿触摸"的标签。

但 Go 选择了一个更具挑战性的路径。Go 语言规范明确允许在迭代期间修改 map,并定义了一组精确的规则:

1
2
3
4
5
6
for k, v := range m {
    // 在这个循环中:
    // 1. 如果某个元素在被遍历到之前被删除,就不会被看到
    // 2. 如果某个元素在被遍历到之前被更新,会看到新值
    // 3. 新添加的元素可能会也可能不会被看到
}

这些规则看似简单,实现起来却颇具挑战性。传统的哈希表迭代方式是按照内存布局顺序遍历数组。但这种方法在 Go 的场景下会出现问题,特别是当 map 在迭代过程中发生增长时,内存布局会发生变化。

Go 团队提出了一个巧妙的解决方案:让迭代器保持对正在迭代的表的快照引用。这就像在整理书架时拍了一张照片,用照片来决定访问书籍的顺序。即使书架布局发生变化,我们仍然按照照片中的顺序进行。

但这个方案也带来了新的挑战:

  1. 新添加的元素:它们只存在于新的布局中,不在我们的"照片"里。这符合规范,因为规范允许新元素可能不被遍历到。
  2. 更新和删除:如果只看"照片",我们可能会看到已经不存在的书,或者错过了书的新版本。

解决方案是:使用旧表(我们的"照片")仅来决定遍历顺序,但在实际返回数据之前,我们会检查新表以确认元素是否仍然存在,并获取最新的值。这就像按照照片的顺序去找书,但每次都要确认书架上的实际情况。

这种设计确保了:

  • 遍历顺序保持稳定
  • 删除的元素不会被看到
  • 更新的元素会显示新值
  • 新添加的元素可能会被看到(如果它们恰好被添加到我们即将访问的位置)

虽然这种方案增加了实现的复杂性,但它为 Go 开发者提供了一个直观且可预测的 map 迭代行为。这再次展示了 Go 团队在设计语言特性时对实用性的重视:宁可在实现层面承担复杂性,也要为开发者提供简单直观的使用体验。

未来展望:Swiss Table 的进化之路

Swiss Table 的引入只是 Go map 优化旅程的一个里程碑,而非终点。在微基准测试中,新的 map 实现展现出了令人印象深刻的性能提升——某些操作比 Go 1.23 快了高达 60%。当然,由于 map 的使用场景极其多样化,性能改进的幅度也因情况而异,甚至在某些边缘场景下可能会有轻微的退步。但从整体来看,在真实应用程序的基准测试中,我们观察到了约 1.5% 的 CPU 时间平均改进。这个数字看似不大,但考虑到 map 操作在 Go 程序中的普遍性,这意味着几乎所有 Go 程序都能从中受益。

展望未来,我们看到了多个可能的优化方向:

1. 提升缓存局部性

现代计算机架构中,内存访问速度远远落后于 CPU 处理速度。当数据不在 CPU 缓存中时,获取这些数据会导致显著的延迟。我们计划研究如何重新组织 map 的内存布局,使相关数据更可能同时位于缓存中,从而减少缓存未命中的情况。这对于大型 map(无法完全放入 CPU 缓存的 map)尤为重要。

2. 扩展 SIMD 指令的使用

Swiss Table 的一个核心优势是能够利用 SIMD 指令并行处理多个槽位。目前,Go 1.24 已经为 amd64 架构实现了使用 8 字节 SIMD 指令的优化,但这只是开始:

  1. 扩展架构支持:将 SIMD 优化扩展到其他流行的处理器架构,如 ARM64
  2. 增加组大小:现代 SIMD 指令集通常支持至少 16 字节(有些甚至支持 32 或 64 字节)的并行操作。通过将组大小从 8 增加到 16 或更多,我们可以在一次操作中比较更多的槽位,进一步减少平均探测次数
1
2
3
4
5
6
7
// 当前的 8 槽位组与潜在的 16 槽位组比较
┌─────────────────────────────┐  ┌─────────────────────────────────────────────────────┐
│ 8 槽位组 (64位控制字)         │  │ 16 槽位组 (128位控制字)                                │
├─┬─┬─┬─┬─┬─┬─┬─┬─────────────┤  ├─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─────────────────────┤
│0│1│2│3│4│5│6│7│ 数据槽位...  │  │0│1│2│3│4│5│6│7│8│9│A│B│C│D│E│F│ 数据槽位...         │
└─┴─┴─┴─┴─┴─┴─┴─┴─────────────┘  └─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─────────────────────┘
  1次SIMD操作检查8个槽位          1次SIMD操作检查16个槽位

这些优化不仅会进一步提高 map 操作的性能,还可能降低内存使用量,因为更高效的探测意味着我们可以安全地使用更高的负载因子。

致谢

基于 Swiss Table 的 Go map 实现已经酝酿很久,并涉及许多贡献者。我要感谢 YunHao Zhang (@zhangyunhao116)、PJ Malloy (@thepudds) 和 @andy-wm-arthur 构建了 Go Swiss Table 实现的初始版本。Peter Mattis (@petermattis) 将这些想法与上述 Go 挑战的解决方案结合起来,构建了 github.com/cockroachdb/swiss,这是一个符合 Go 规范的 Swiss Table 实现。Go 1.24 内置 map 实现很大程度上基于 Peter 的工作。感谢社区中所有做出贡献的人!

0%