2017 年的某一天,Google
内部的性能团队做了一次全公司范围的
profiling。结果让人吃惊:整个 Google 的 C++ 服务中,大约 1%
的 CPU 时间花在了 std::unordered_map
的哈希表操作上。不是某个特定服务——是所有服务的平均值。
1% 听起来不多,但对于 Google 的规模,这意味着数以万计的 CPU 核心在做哈希表查找。如果能让哈希表快 2 倍,等于免费获得了几千台服务器。
Matt Kulukundis
和他的团队开始重新设计哈希表。他们的目标不是在学术上追求渐进复杂度的改进,而是在工程上榨干现代
CPU 的每一滴性能。最终的成果就是 Swiss Table——一个利用 SIMD
指令实现 16 路并行探测的开放寻址哈希表,后来成为 Abseil 库的
absl::flat_hash_map,并在 2018 年替换了 Google
内部几乎所有的 C++ 哈希表。
这不是渐进式的改进。在 Google 的实际工作负载上,Swiss
Table 比 std::unordered_map 快了 2
倍以上,同时内存占用更小。
一、std::unordered_map 到底慢在哪
要理解 Swiss Table 的设计动机,必须先理解
std::unordered_map
为什么慢。这不是实现质量的问题——是标准接口约束导致的结构性问题。
指针追逐的噩梦
C++ 标准要求 std::unordered_map
满足以下保证:
- 迭代器稳定性:插入新元素不会使已有元素的迭代器失效
- 引用稳定性:
map[key]返回的引用在元素存在期间始终有效 - 节点句柄:C++17 引入的
extract()要求元素可以在容器之间移动
这三条要求加在一起,意味着元素不能存储在连续数组中——因为数组扩容时会移动元素,导致指针和迭代器失效。所以
std::unordered_map 只能用链式哈希(separate
chaining):每个桶是一个链表,元素通过 new
分配在堆上。
// std::unordered_map 的内部结构(简化)
struct Node {
Node* next;
size_t hash_cache; // 缓存的哈希值
pair<Key, Value> kv; // 键值对
};
struct Bucket {
Node* head; // 指向链表头
};
// 查找过程:
// 1. hash(key) -> bucket_index (计算)
// 2. buckets[bucket_index].head (一次内存访问)
// 3. node->next, node->next, ... (每个节点一次内存访问)
// 4. 每个节点还要比较 key (又一次内存访问)每次查找至少需要 2 次指针解引用(桶指针 + 节点指针),冲突时更多。而每次指针解引用大概率是一次 cache miss——因为节点是独立分配的,在内存中的位置毫无规律。
在现代 CPU 上,一次 L1 cache hit 大约 1ns,一次 L3 cache miss 大约 40-80ns,一次内存访问大约 100ns。也就是说,一次哈希表查找如果有 2-3 次 cache miss,光等内存就要 200-300ns。相比之下,哈希计算本身可能只要 5-10ns。
内存访问延迟才是瓶颈,不是计算。
内存开销
每个节点至少包含:
next指针:8 字节- 缓存的哈希值:8 字节
- 键值对本身
malloc的 header:通常 16 字节
对于一个
unordered_map<int64_t, int64_t>,每个键值对
16 字节,但实际内存开销是 16 + 8 + 8 + 16 = 48
字节,还不算桶数组本身。内存利用率只有 33%。
更深层的问题:预取失效
现代 CPU 有硬件预取器(hardware prefetcher),能够检测到顺序或跨步(stride)的内存访问模式,提前把数据加载到缓存中。但链式哈希的内存访问模式是完全随机的——硬件预取器无能为力。
开放寻址(open addressing)的情况稍好一些:至少探测序列在同一片连续内存中,硬件预取器有机会帮忙。这就是 Swiss Table 选择开放寻址的根本原因。
二、Swiss Table 的核心思想
Swiss Table 的核心洞察可以用一句话概括:把”元素是否匹配”的判断从逐个比较变成 SIMD 批量筛选。
传统的开放寻址哈希表——无论是线性探测、二次探测还是 Robin Hood——每次探测都要:
- 读取槽位的 key
- 比较 key 是否相等
- 如果不等,移动到下一个槽位
每一步都可能触发 cache miss(key 可能很大),而且这些步骤是串行的。
Swiss Table 的做法是:在存储实际键值对之外,额外维护一个控制字节数组(control byte array)。每个槽位对应一个字节的元数据,记录该槽位的状态和哈希值的一部分。查找时,先用 SSE2 指令一次性比较 16 个控制字节,快速筛选出可能匹配的候选位置,再只对这些候选位置做完整的 key 比较。
关键的性能提升在于:16 个控制字节正好是 128
bit,恰好放进一个 SSE2 寄存器。一条
_mm_cmpeq_epi8 指令就能完成 16
路并行比较——这条指令的延迟只有 1 个时钟周期。
三、控制字节的位级设计
控制字节(control byte)是 Swiss Table 最精巧的设计。每个字节编码了三种信息之一:
// 控制字节的取值
#define CTRL_EMPTY ((int8_t)0xFF) // -1: 槽位为空
#define CTRL_DELETED ((int8_t)0x80) // -128: 槽位已删除(tombstone)
#define CTRL_SENTINEL ((int8_t)0xFE) // -2: 哨兵(表尾标记)
// 0x00 ~ 0x7F: 槽位已占用,值为哈希值的高 7 位(H2)位级布局:
bit: 7 6 5 4 3 2 1 0
+-+--+--+--+--+--+--+--+
FULL: |0| H2 hash (7 bits) | 范围 0x00..0x7F
+-+--+--+--+--+--+--+--+
EMPTY: |1| 1 1 1 1 1 1 1| = 0xFF
+-+--+--+--+--+--+--+--+
DEL: |1| 0 0 0 0 0 0 0| = 0x80
+-+--+--+--+--+--+--+--+
这个编码方式的巧妙之处在于:
最高位区分状态:bit 7 为 0 表示已占用(FULL),为 1 表示特殊状态(EMPTY/DELETED/SENTINEL)。一个简单的符号位检查就能判断。
EMPTY 的全 1 设计:EMPTY 是 0xFF,而新分配的内存用
memset(ctrl, 0xFF, ...)就能初始化所有槽位为空。SIMD 友好:检测空槽位只需要检查最高位是否为 1——
_mm_movemask_epi8直接提取所有字节的最高位,得到的就是”哪些位置是特殊状态”的掩码。
H1 和 H2 的分离
哈希值被分成两部分:
// 假设哈希值为 64 位
static inline size_t H1(size_t hash) {
return hash >> 7; // 高 57 位:用于定位 group
}
static inline int8_t H2(size_t hash) {
return hash & 0x7F; // 低 7 位:存入控制字节
}H1 决定从哪个 group 开始探测,H2 存储在控制字节中用于快速过滤。
为什么是 7 位?因为控制字节的最高位用作状态标志,剩下 7 位。7 位可以表示 128 个不同的值,对于一个 16 元素的 group,期望只有 16/128 = 12.5% 的误匹配率(即控制字节匹配但 key 不同)。这意味着在大多数情况下,SIMD 匹配后只需要做 0 或 1 次完整的 key 比较。
四、SIMD 探测:一条指令比较 16 个槽位
这是 Swiss Table 的性能核心。让我画出完整的匹配流程:
SSE2 匹配的实现
#include <immintrin.h>
typedef struct {
__m128i match; // SSE2 128-bit 寄存器
} GroupMatch;
// 在一个 group(16 个控制字节)中查找与 h2 匹配的位置
static inline uint32_t group_match(const int8_t* ctrl, int8_t h2) {
// 1. 将 h2 广播到 128-bit 寄存器的所有 16 个字节
__m128i needle = _mm_set1_epi8(h2);
// 2. 加载 16 个控制字节到另一个 128-bit 寄存器
__m128i haystack = _mm_loadu_si128((const __m128i*)ctrl);
// 3. 逐字节比较:相等的位置设为 0xFF,不等的设为 0x00
__m128i cmp = _mm_cmpeq_epi8(needle, haystack);
// 4. 提取每个字节的最高位,压缩成 16-bit 掩码
return (uint32_t)_mm_movemask_epi8(cmp);
}
// 查找空槽位(EMPTY 或 DELETED)
static inline uint32_t group_match_empty_or_deleted(const int8_t* ctrl) {
// 特殊状态的最高位都是 1,已占用的最高位是 0
// _mm_movemask_epi8 直接提取最高位
__m128i v = _mm_loadu_si128((const __m128i*)ctrl);
return (uint32_t)_mm_movemask_epi8(v);
}
// 查找空槽位(只匹配 EMPTY,不匹配 DELETED)
static inline uint32_t group_match_empty(const int8_t* ctrl) {
// EMPTY = 0xFF = -1,DELETED = 0x80 = -128
// 技巧:将每个字节视为有符号数,与 EMPTY(-1) 比较
__m128i v = _mm_loadu_si128((const __m128i*)ctrl);
__m128i special = _mm_set1_epi8((int8_t)CTRL_EMPTY);
return (uint32_t)_mm_movemask_epi8(_mm_cmpeq_epi8(v, special));
}让我们跟踪一次查找的完整过程:
// 查找 key 的过程(伪代码)
void* swiss_table_find(SwissTable* t, Key key) {
size_t hash = hash_function(key);
int8_t h2 = H2(hash); // 低 7 位
size_t group_index = H1(hash) % t->num_groups;
while (1) {
int8_t* ctrl = t->ctrl + group_index * 16;
// SIMD: 一条指令比较 16 个控制字节
uint32_t match = group_match(ctrl, h2);
// 遍历所有匹配的候选位置
while (match != 0) {
int pos = __builtin_ctz(match); // 最低位的 1 在哪里
size_t slot = group_index * 16 + pos;
// 只有在这里才做完整的 key 比较
if (key_equal(t->slots[slot].key, key)) {
return &t->slots[slot].value;
}
match &= match - 1; // 清除最低位的 1
}
// 如果这个 group 里有 EMPTY 槽位,说明 key 不存在
uint32_t empty = group_match_empty(ctrl);
if (empty != 0) {
return NULL; // 未找到
}
// 否则继续探测下一个 group
group_index = (group_index + 1) % t->num_groups; // 简化版
}
}为什么这很快
传统线性探测查找 N 个槽位需要 N 次比较。Swiss Table 查找 16 个槽位只需要:
- 1 次内存加载(16 字节控制字节,一个 cache line 内)
- 1 次 SIMD 比较(
_mm_cmpeq_epi8,1 cycle latency) - 1 次掩码提取(
_mm_movemask_epi8,1-3 cycle latency) - 对 0-2 个候选位置做完整 key 比较
也就是说,16 路探测的成本大约等于传统方式 1-2 路探测的成本。在负载因子 0.875 时,平均每次查找只需要探测 1-2 个 group,意味着大约 1-2 次 cache miss——而传统线性探测在相同负载因子下平均需要 4-8 次逐位比较。
五、三角探测与 Group 寻址
Swiss Table 不使用线性探测或二次探测,而是使用一种被称为三角探测(triangular probing)的方式:
// 探测序列:group 0, group 0+1, group 0+1+2, group 0+1+2+3, ...
// 即增量为 0, 1, 3, 6, 10, 15, 21, ...(三角数)
size_t probe_seq(size_t start, size_t step, size_t num_groups) {
// step 从 0 开始递增
// 偏移量 = step * (step + 1) / 2
return (start + step * (step + 1) / 2) % num_groups;
}这个序列的数学性质很好:当 group 数量是 2 的幂时,前 N 个探测位置恰好覆盖了所有 N 个 group(其中 N 是 group 的数量)。证明如下:
设 group 数量 = 2^k = N
探测序列的增量为 0, 1, 2, 3, ..., N-1
位置序列:start, start+1, start+1+2, start+1+2+3, ...
两个位置 i 和 j 的差:
sum(1..j) - sum(1..i) = (j-i)(i+j+1)/2
若 i != j 且 0 <= i,j < N = 2^k,
则 (j-i) 和 (i+j+1) 中恰好有一个是奇数,
所以 (j-i)(i+j+1)/2 不是 N 的倍数。
因此所有位置互不相同。
这保证了探测序列不会在 group 之间循环,每个 group 最多被访问一次。
实际的探测代码
// Abseil 中的探测序列实现
class probe_seq {
public:
probe_seq(size_t hash, size_t mask) {
pos_ = hash & mask; // 起始位置
stride_ = 0; // 初始步长
}
size_t offset() const { return pos_; }
void next() {
stride_ += kGroupWidth; // kGroupWidth = 16
pos_ = (pos_ + stride_) & mask_; // 三角探测
}
private:
size_t pos_;
size_t stride_;
size_t mask_;
};注意这里的 stride_ 每次增加
kGroupWidth(16),而不是 1。这是因为探测是以
group 为单位的:每次跳过 16 个槽位。实际上
pos_ + stride_ 的累计效果就是三角数序列乘以
16。
六、完整的 C 实现
下面是一个简化但功能完整的 Swiss Table 实现。为了清晰,我省略了一些优化(如 SSE2 的 fallback 路径和 growth_left 计数器),但保留了核心的 SIMD 匹配逻辑。
#include <immintrin.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
// 控制字节常量
#define CTRL_EMPTY ((int8_t)0xFF)
#define CTRL_DELETED ((int8_t)0x80)
#define GROUP_WIDTH 16
// 哈希分离
static inline size_t H1(size_t hash) { return hash >> 7; }
static inline int8_t H2(size_t hash) { return (int8_t)(hash & 0x7F); }
// 键值对
typedef struct {
uint64_t key;
uint64_t value;
} Slot;
// Swiss Table 结构
typedef struct {
int8_t* ctrl; // 控制字节数组(长度 = capacity + GROUP_WIDTH)
Slot* slots; // 键值对数组(长度 = capacity)
size_t capacity; // 总槽位数(必须是 GROUP_WIDTH 的倍数)
size_t size; // 已存储元素数
size_t growth_left; // 还能插入多少个元素
} SwissTable;
// ===== SIMD 匹配函数 =====
// 在 group 中查找与 h2 匹配的槽位,返回 16-bit 掩码
static inline uint32_t match_h2(const int8_t* ctrl, int8_t h2) {
__m128i group = _mm_loadu_si128((const __m128i*)ctrl);
__m128i target = _mm_set1_epi8(h2);
__m128i cmp = _mm_cmpeq_epi8(group, target);
return (uint32_t)_mm_movemask_epi8(cmp) & 0xFFFF;
}
// 查找空或已删除的槽位
static inline uint32_t match_empty_or_deleted(const int8_t* ctrl) {
__m128i group = _mm_loadu_si128((const __m128i*)ctrl);
return (uint32_t)_mm_movemask_epi8(group) & 0xFFFF;
}
// 查找空槽位
static inline uint32_t match_empty(const int8_t* ctrl) {
__m128i group = _mm_loadu_si128((const __m128i*)ctrl);
__m128i empty = _mm_set1_epi8(CTRL_EMPTY);
__m128i cmp = _mm_cmpeq_epi8(group, empty);
return (uint32_t)_mm_movemask_epi8(cmp) & 0xFFFF;
}
// ===== 探测序列 =====
typedef struct {
size_t pos;
size_t stride;
size_t mask; // capacity - 1
} ProbeSeq;
static inline ProbeSeq probe_seq_start(size_t h1, size_t mask) {
ProbeSeq seq;
seq.pos = h1 & mask;
seq.stride = 0;
seq.mask = mask;
return seq;
}
static inline void probe_seq_next(ProbeSeq* seq) {
seq->stride += GROUP_WIDTH;
seq->pos = (seq->pos + seq->stride) & seq->mask;
}
// ===== 简单哈希函数(生产环境请使用 wyhash 等) =====
static inline size_t hash_u64(uint64_t key) {
key ^= key >> 33;
key *= 0xFF51AFD7ED558CCDULL;
key ^= key >> 33;
key *= 0xC4CEB9FE1A85EC53ULL;
key ^= key >> 33;
return key;
}
// ===== 控制字节的镜像(cloned bytes) =====
// Swiss Table 在 ctrl 数组末尾额外复制了前 GROUP_WIDTH 个字节
// 这样探测序列在表尾 wrap around 时不需要特殊处理
static inline void set_ctrl(SwissTable* t, size_t index, int8_t value) {
t->ctrl[index] = value;
// 镜像:如果 index < GROUP_WIDTH,在末尾也写一份
if (index < GROUP_WIDTH) {
t->ctrl[t->capacity + index] = value;
}
}
// ===== 创建和销毁 =====
SwissTable* swisstable_new(size_t min_capacity) {
// 向上取到 GROUP_WIDTH 的倍数,且至少为 GROUP_WIDTH
size_t capacity = GROUP_WIDTH;
while (capacity < min_capacity) {
capacity <<= 1;
}
SwissTable* t = (SwissTable*)malloc(sizeof(SwissTable));
// ctrl 多分配 GROUP_WIDTH 字节用于镜像
t->ctrl = (int8_t*)aligned_alloc(16, capacity + GROUP_WIDTH);
t->slots = (Slot*)calloc(capacity, sizeof(Slot));
t->capacity = capacity;
t->size = 0;
// 负载因子上限约 7/8 = 87.5%
t->growth_left = capacity - capacity / 8;
// 初始化所有控制字节为 EMPTY
memset(t->ctrl, CTRL_EMPTY, capacity + GROUP_WIDTH);
return t;
}
void swisstable_free(SwissTable* t) {
if (t) {
free(t->ctrl);
free(t->slots);
free(t);
}
}
// ===== 查找 =====
Slot* swisstable_find(SwissTable* t, uint64_t key) {
size_t hash = hash_u64(key);
int8_t h2 = H2(hash);
ProbeSeq seq = probe_seq_start(H1(hash), t->capacity - 1);
while (1) {
// SIMD 匹配:一条指令比较 16 个控制字节
uint32_t match = match_h2(t->ctrl + seq.pos, h2);
while (match != 0) {
int bit = __builtin_ctz(match);
size_t idx = (seq.pos + bit) & (t->capacity - 1);
if (t->slots[idx].key == key) {
return &t->slots[idx];
}
match &= match - 1; // 清除最低位
}
// 如果 group 中有 EMPTY,说明 key 不存在
if (match_empty(t->ctrl + seq.pos) != 0) {
return NULL;
}
probe_seq_next(&seq);
}
}
// ===== 内部:找到插入位置 =====
static size_t find_insert_slot(SwissTable* t, size_t hash) {
ProbeSeq seq = probe_seq_start(H1(hash), t->capacity - 1);
while (1) {
uint32_t mask = match_empty_or_deleted(t->ctrl + seq.pos);
if (mask != 0) {
int bit = __builtin_ctz(mask);
return (seq.pos + bit) & (t->capacity - 1);
}
probe_seq_next(&seq);
}
}
// ===== 扩容 =====
static void swisstable_rehash(SwissTable* t) {
size_t old_capacity = t->capacity;
int8_t* old_ctrl = t->ctrl;
Slot* old_slots = t->slots;
size_t new_capacity = old_capacity * 2;
t->ctrl = (int8_t*)aligned_alloc(16, new_capacity + GROUP_WIDTH);
t->slots = (Slot*)calloc(new_capacity, sizeof(Slot));
t->capacity = new_capacity;
t->size = 0;
t->growth_left = new_capacity - new_capacity / 8;
memset(t->ctrl, CTRL_EMPTY, new_capacity + GROUP_WIDTH);
// 重新插入所有元素
for (size_t i = 0; i < old_capacity; i++) {
if (old_ctrl[i] >= 0) { // FULL: 最高位为 0
size_t hash = hash_u64(old_slots[i].key);
size_t idx = find_insert_slot(t, hash);
set_ctrl(t, idx, H2(hash));
t->slots[idx] = old_slots[i];
t->size++;
t->growth_left--;
}
}
free(old_ctrl);
free(old_slots);
}
// ===== 插入 =====
bool swisstable_insert(SwissTable* t, uint64_t key, uint64_t value) {
// 先查找是否已存在
Slot* existing = swisstable_find(t, key);
if (existing != NULL) {
existing->value = value; // 更新
return false;
}
// 需要扩容?
if (t->growth_left == 0) {
swisstable_rehash(t);
}
size_t hash = hash_u64(key);
size_t idx = find_insert_slot(t, hash);
// 如果覆盖的是 EMPTY(而非 DELETED),减少 growth_left
if (t->ctrl[idx] == CTRL_EMPTY) {
t->growth_left--;
}
set_ctrl(t, idx, H2(hash));
t->slots[idx].key = key;
t->slots[idx].value = value;
t->size++;
return true;
}
// ===== 删除 =====
bool swisstable_erase(SwissTable* t, uint64_t key) {
Slot* slot = swisstable_find(t, key);
if (slot == NULL) {
return false;
}
size_t idx = slot - t->slots;
// 判断是否可以直接标记为 EMPTY(而非 DELETED)
// 如果当前 group 中有 EMPTY 槽位,说明探测序列在此终止,
// 可以安全地设为 EMPTY 而不影响其他探测
uint32_t empty_mask = match_empty(t->ctrl + (idx & ~(GROUP_WIDTH - 1)));
bool was_never_full = (empty_mask != 0);
if (was_never_full) {
set_ctrl(t, idx, CTRL_EMPTY);
t->growth_left++;
} else {
set_ctrl(t, idx, CTRL_DELETED);
// 注意:DELETED 不增加 growth_left
}
t->size--;
return true;
}使用示例
int main(void) {
SwissTable* table = swisstable_new(64);
// 插入
for (uint64_t i = 0; i < 1000; i++) {
swisstable_insert(table, i, i * 100);
}
// 查找
Slot* s = swisstable_find(table, 42);
if (s != NULL) {
printf("found: key=%lu, value=%lu\n", s->key, s->value);
}
// 删除
swisstable_erase(table, 42);
swisstable_free(table);
return 0;
}编译时需要启用 SSE2(x86-64 默认开启):
gcc -O2 -msse2 -o swiss_table swiss_table.c七、Tombstone 处理与 growth_left 计数器
删除操作是所有开放寻址哈希表的痛点。Swiss Table 在这方面有一些独到的处理。
为什么需要 tombstone
在开放寻址中,如果直接把删除的槽位标记为 EMPTY,会截断其他元素的探测序列。考虑这个场景:
插入 A -> 位置 3
插入 B -> 位置 3 冲突,探测到位置 4
删除 A -> 位置 3 标记为 EMPTY
查找 B:
hash(B) -> 位置 3
位置 3 是 EMPTY -> 结论:B 不存在 (错误!)
所以删除的槽位必须标记为 DELETED(tombstone),表示”这里曾经有人,探测序列不要在此终止”。
tombstone 的累积问题
问题在于,tombstone 只能在 rehash 时清除。大量删除-插入操作后,tombstone 可能充斥整个表,导致:
- 探测序列变长——DELETED 不会终止探测
- 即使元素很少,growth_left 也可能耗尽——因为 DELETED 不会归还 growth_left
这就是 growth_left
计数器的设计动机。它跟踪的不是”还能存储多少元素”,而是”还能把多少个
EMPTY 变成 FULL”。DELETED 不计入——它已经不是 EMPTY 了。
// growth_left 的含义
// 初始值 = capacity * 7/8
// 插入到 EMPTY 位置 -> growth_left--
// 插入到 DELETED 位置 -> growth_left 不变
// 删除后设为 EMPTY -> growth_left++
// 删除后设为 DELETED -> growth_left 不变当 growth_left 降为 0 时触发 rehash。但 rehash 不一定扩容——如果当前元素数量远小于 capacity * 7/8(说明有大量 tombstone),Swiss Table 会做一次就地 rehash(same-size rehash),只清除 tombstone 而不增加容量。
智能删除:何时用 EMPTY 替代 DELETED
Swiss Table 的另一个优化是:如果被删除的槽位所在 group 中存在至少一个 EMPTY 槽位,就可以安全地把该位置标记为 EMPTY 而非 DELETED。
原因是:如果 group 中有 EMPTY,那么任何经过此 group 的探测序列都会在看到 EMPTY 时终止。把一个 FULL 变成 EMPTY 不会影响终止判断——EMPTY 的数量只增不减。
这个优化减少了 tombstone 的累积,降低了不必要的 rehash。
// Abseil 源码中的删除逻辑(简化)
void erase_slot(size_t index) {
size_t group_start = index & ~(kGroupWidth - 1);
// 检查当前 group 或相邻 group 是否有 EMPTY
auto empty_after = Group(ctrl_ + group_start).MatchEmpty();
auto empty_before = Group(ctrl_ + ((group_start - kGroupWidth) & mask_)).MatchEmpty();
bool was_never_full = empty_after || empty_before;
if (was_never_full) {
SetCtrl(index, kEmpty);
growth_left_++;
} else {
SetCtrl(index, kDeleted);
}
size_--;
}八、扩容策略与 Rehash
负载因子
Swiss Table 使用 7/8(87.5%)作为最大负载因子。这比
std::unordered_map 的默认 1.0
低,但比大多数开放寻址表的 0.5-0.75 高得多。
能容忍这么高的负载因子,正是因为 SIMD 匹配让探测成本极低。在 87.5% 负载因子下:
- 成功查找平均探测 1.56 个 group
- 失败查找平均探测 2.75 个 group
每个 group 的探测成本约等于一次内存访问(16 字节 SIMD load),所以即使需要探测 2-3 个 group,总成本也很可控。
2 的幂大小
容量始终保持为 2
的幂。这使得取模操作变成位与(& (capacity - 1)),避免了昂贵的整数除法。
// 取模用位与替代
size_t index = hash & (capacity - 1); // 等价于 hash % capacity
// 但这要求 capacity 是 2 的幂
// capacity = 16, 32, 64, 128, ...Rehash 的两种模式
void maybe_rehash(SwissTable* t) {
if (t->growth_left > 0) return;
// 如果实际元素数量 < capacity * 7/16,说明 tombstone 太多
// 做就地 rehash(不扩容)
if (t->size <= t->capacity * 7 / 16) {
rehash_in_place(t); // 清除 tombstone
} else {
rehash_grow(t); // 容量翻倍
}
}就地 rehash 的逻辑是:遍历所有 FULL 槽位,对每个元素重新计算位置。这比直接扩容节省内存,适用于”频繁删除-插入但总量稳定”的工作负载。
控制字节的镜像(Cloned Bytes)
Swiss Table 在 ctrl 数组末尾额外分配了 GROUP_WIDTH(16)个字节,将开头的 16 个字节复制过去:
ctrl: [0, 1, 2, ..., N-1, | 0, 1, 2, ..., 15]
^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^
实际控制字节 镜像(clone)
这是为了处理探测序列在表尾 wrap around
的情况。如果探测到位置 N-5,一次 SIMD load
会读取
[N-5, N-4, ..., N-1, 0, 1, ..., 10]——但连续内存中位置
N 之后是镜像的
ctrl[0..15],所以读到的正好是正确的数据。
没有这个镜像,就需要在每次 SIMD load 时检查边界、拆分加载、合并结果——代价太大了。
九、Benchmark:数字说话
以下数据来自 Google 2017 年 CppCon 演讲和后续社区的独立测试,测试平台为 x86-64(Skylake 以上)。
查找性能(纳秒/次操作)
| 哈希表实现 | 命中查找 | 未命中查找 | 插入 | 删除 |
|---|---|---|---|---|
std::unordered_map |
85-120 | 90-150 | 130-200 | 95-130 |
absl::flat_hash_map |
18-35 | 15-25 | 40-65 | 30-50 |
| Robin Hood (martinus) | 25-45 | 30-55 | 50-80 | 45-70 |
| Cuckoo (libcuckoo) | 30-50 | 35-60 | 60-90 | 40-65 |
boost::unordered_flat_map |
20-38 | 18-30 | 42-68 | 32-55 |
注意事项:
- 上述数字是在 10M 量级的
int64 -> int64映射上的典型值 - 实际性能受 key 大小、哈希函数质量、负载因子、访问模式影响很大
std::unordered_map在小表(< 100 元素,全部在 L1 缓存内)时差距会缩小- Robin Hood 在高负载因子下的最坏情况比 Swiss Table 更稳定
内存效率(字节/元素,key=int64, value=int64)
| 哈希表实现 | 负载因子 | 每元素实际字节 | 开销比 |
|---|---|---|---|
std::unordered_map |
~1.0 | ~48 | 3.0x |
absl::flat_hash_map |
0.875 | ~18.3 | 1.14x |
| Robin Hood | 0.8 | ~20 | 1.25x |
| Cuckoo | 0.5 | ~32 | 2.0x |
Swiss Table 的内存效率极高:每个元素只需要 16 字节(key+value)+ 1 字节(控制字节)+ ~1.3 字节(空槽位分摊) = ~18.3 字节。
为什么 Swiss Table 对 L1 缓存友好
一个关键的微架构细节:x86-64 的 L1 数据缓存行是 64 字节。16 个控制字节只占 16 字节——即使加上周围的控制字节,一次 SIMD load 最多跨两个 cache line。而键值对是只有在 SIMD 筛选出候选位置后才访问的,大大减少了无用的缓存污染。
对比
std::unordered_map:每次跟着链表指针跳转,大概率跳到完全不同的
cache line,甚至跳出 L3 缓存。这才是性能差异的根本原因。
十、Abseil flat_hash_map 源码关键片段
Abseil 的 absl::flat_hash_map 是 Swiss Table
的生产级实现。让我们看几个关键的代码片段。
raw_hash_set 的核心数据结构
// absl/container/internal/raw_hash_set.h(简化)
template <class Policy, class Hash, class Eq, class Alloc>
class raw_hash_set {
ctrl_t* ctrl_; // 控制字节数组
void* slots_; // 键值对数组(类型擦除)
size_t size_; // 已存储元素数
size_t capacity_; // 总容量
size_t growth_left_; // 剩余增长空间
// ...
};flat_hash_map 和 flat_hash_set
都是 raw_hash_set 的 policy 特化。Policy
决定了如何存储和访问键值对,但哈希表的核心逻辑——探测、匹配、扩容——都在
raw_hash_set 中。
Group 的 SIMD 抽象
// 控制字节组——封装了平台相关的 SIMD 操作
struct GroupSse2Impl {
__m128i ctrl;
explicit GroupSse2Impl(const ctrl_t* pos) {
ctrl = _mm_loadu_si128(reinterpret_cast<const __m128i*>(pos));
}
// 返回与 h 匹配的位掩码
BitMask<uint32_t, kGroupWidth> Match(h2_t h) const {
auto match = _mm_set1_epi8(static_cast<char>(h));
return BitMask<uint32_t, kGroupWidth>(
static_cast<uint32_t>(
_mm_movemask_epi8(_mm_cmpeq_epi8(match, ctrl))));
}
// 返回 EMPTY 位置的掩码
BitMask<uint32_t, kGroupWidth> MatchEmpty() const {
// 这里用了一个技巧:
// 只有 EMPTY(0xFF) 对 _mm_sign_epi8 是特殊的
return BitMask<uint32_t, kGroupWidth>(
static_cast<uint32_t>(
_mm_movemask_epi8(
_mm_cmpeq_epi8(
_mm_set1_epi8(static_cast<char>(kEmpty)),
ctrl))));
}
// 返回 EMPTY 或 DELETED 位置的掩码
BitMask<uint32_t, kGroupWidth> MatchEmptyOrDeleted() const {
// 所有特殊状态的最高位都是 1
// _mm_movemask_epi8 提取最高位
auto special = _mm_set1_epi8(static_cast<char>(kSentinel));
return BitMask<uint32_t, kGroupWidth>(
static_cast<uint32_t>(
_mm_movemask_epi8(
_mm_cmpgt_epi8_fixed(special, ctrl))));
}
};BitMask 的迭代器
BitMask 封装了对 16-bit 掩码的遍历,使用
__builtin_ctz(count trailing
zeros)来逐个提取匹配位:
template <class T, int SignificantBits>
class BitMask {
public:
explicit BitMask(T mask) : mask_(mask) {}
// 返回最低匹配位的索引
uint32_t LowestBitSet() const {
return static_cast<uint32_t>(__builtin_ctz(mask_));
}
// 清除最低匹配位
BitMask& RemoveLowestBit() {
mask_ &= mask_ - 1;
return *this;
}
explicit operator bool() const { return mask_ != 0; }
// 支持 range-for
class iterator {
// ...
};
private:
T mask_;
};find 的完整实现
template <class K>
iterator find(const K& key) {
auto hash = hash_ref()(key);
auto seq = probe(ctrl_, hash, capacity_);
while (true) {
Group g{ctrl_ + seq.offset()};
for (uint32_t i : g.Match(H2(hash))) {
// 注意 i 需要 wrap around
size_t idx = seq.offset(i);
if (ABSL_PREDICT_TRUE(PolicyTraits::apply(
EqualElement<K>{key, eq_ref()},
PolicyTraits::element(slots_ + idx)))) {
return iterator_at(idx);
}
}
if (ABSL_PREDICT_TRUE(g.MatchEmpty())) {
return end();
}
seq.next();
// 安全检查:不应该探测超过 capacity 个 group
assert(seq.index() <= capacity_ && "full table!");
}
}这里的 ABSL_PREDICT_TRUE 是
__builtin_expect
的封装,告诉编译器这个分支大概率为真——因为 H2 匹配后大概率
key 也匹配。
Portable Fallback
对于没有 SSE2 的平台(如 32 位 ARM),Abseil 提供了纯标量的 fallback:
struct GroupPortableImpl {
uint64_t ctrl; // 将 8 个字节打包到一个 uint64_t
explicit GroupPortableImpl(const ctrl_t* pos) {
// 只处理 8 个字节而非 16 个
memcpy(&ctrl, pos, sizeof(ctrl));
}
BitMask<uint64_t, kGroupWidth> Match(h2_t h) const {
// 用位运算模拟 SIMD 比较
auto x = ctrl ^ (kMsbs * h);
// 如果某字节为 0,则 (x - kLsbs) & ~x & kMsbs 的对应位为 1
return BitMask<uint64_t, kGroupWidth>(
(x - kLsbs) & ~x & kMsbs);
}
};这个 fallback 使用了经典的 SWAR(SIMD Within A Register)技巧:将多个字节打包到一个寄存器中,用位运算模拟并行比较。性能不如真正的 SIMD,但仍然比逐字节比较快得多。
十一、工程陷阱速查表
在实际使用 Swiss Table(或
absl::flat_hash_map)时,以下是需要注意的工程细节:
| 陷阱 | 说明 | 应对策略 |
|---|---|---|
| 迭代器失效 | 任何插入都可能触发 rehash,使所有迭代器失效。与
std::unordered_map 不同,后者只在 rehash
时失效 |
不要在插入的同时持有迭代器。如需遍历-修改,先收集再操作 |
| 指针/引用失效 | flat_hash_map 在 rehash
时移动元素,所有指向元素的指针和引用都会失效 |
如果需要稳定指针,使用
absl::node_hash_map(它在堆上分配节点,但会丢失
cache 友好性) |
| 哈希函数质量 | Swiss Table 假设哈希函数的每一位都是独立的。弱哈希函数(如取模)会导致 H2 全部相同,SIMD 匹配退化为全量 key 比较 | 使用 absl::Hash
或其他高质量哈希函数(wyhash、xxHash3) |
| reserve 的陷阱 | reserve(n) 会分配大约
n / 0.875 个槽位(向上取到 2
的幂),实际内存可能比预期大很多 |
计算实际容量时考虑 7/8 负载因子和 2 的幂对齐 |
| 大 value 类型 | flat_hash_map 内联存储键值对,如果 value
很大(>128 字节),空槽位浪费的内存会很多 |
大 value 考虑用
flat_hash_map<Key, unique_ptr<Value>>,或者换用
node_hash_map |
| 删除密集型工作负载 | 大量删除会累积 tombstone,即使 size 很小也可能触发 rehash | 如果删除远多于插入,考虑定期调用 rehash(0)
手动清理 |
| 内存对齐要求 | 控制字节数组需要 16 字节对齐(SSE2 要求),自定义分配器需要保证这一点 | 使用 aligned_alloc 或确保自定义 allocator
的对齐保证 |
| 遍历顺序 | 遍历顺序是未定义的,且每次 rehash 后可能改变。Abseil 甚至会故意在 debug 模式下随机化遍历顺序 | 不要依赖遍历顺序。如果需要有序遍历,用
btree_map |
| 异构查找 | flat_hash_map<string, V> 查找时,用
string_view 可以避免不必要的
string 构造 |
使用 absl::Hash
和透明比较器启用异构查找 |
| 小表退化 | 对于元素数 < 16 的表,只有一个 group,SIMD 匹配的固定开销占比较大 | 极小的映射(<10 个元素)考虑用排序数组 + 二分查找或线性扫描 |
十二、深度分析与个人思考
Swiss Table 的真正创新在哪里
在我看来,Swiss Table 最大的贡献不是”用 SIMD 加速哈希表”——这个想法并不新颖。2007 年的 Cuckoo Filter、2012 年的 SIMD-accelerated Cuckoo Hashing 都用了类似的技术。
Swiss Table 的真正创新在于控制字节的设计。用 1 个字节同时编码状态和哈希指纹,使得:
- 状态检查(EMPTY/DELETED/FULL)只需要看最高位
- 哈希匹配(H2)只需要一条 SIMD 指令
- 同一个 SIMD load 可以同时做状态检查和哈希匹配(通过不同的 mask 操作)
这让控制字节成了一个极其高效的布隆过滤器——以极低的成本排除 ~99% 的不匹配,只让 ~1% 的候选进入昂贵的 key 比较。
为什么 7-bit H2 是最优的
7 bit 不是随意选择的。假设 group 大小为 16:
- 6 bit H2:64 个可能值,误匹配率 16/64 = 25%。太高了,每次探测平均要做 0.25 次额外 key 比较。
- 7 bit H2:128 个可能值,误匹配率 16/128 = 12.5%。接受。
- 8 bit H2:不可能——控制字节只有 8 位,需要 1 位表示状态。
如果控制字节用 2 个字节(16 bit),可以存 15-bit H2,误匹配率降到 16/32768 < 0.05%。但这意味着一个 group 的控制字节从 16 字节变成 32 字节——需要 256-bit SIMD(AVX2),而 SSE2 只能处理 128-bit。更重要的是,控制字节的缓存行占用翻倍,得不偿失。
1 字节控制字节 + SSE2 是在当前硬件约束下的全局最优。
group 大小为什么是 16
16 恰好等于 SSE2 寄存器的字节数(128 bit / 8 = 16 byte)。这不是巧合——整个设计就是围绕 SSE2 构建的。
如果用 AVX2(256 bit),group 大小可以是 32。Abseil 团队实际上测试过——效果不明显。原因是:
- 87.5% 负载因子下,大部分查找只需要 1-2 个 group。把 group 从 16 扩到 32,减少的 group 探测次数有限。
- AVX2 的
_mm256_cmpeq_epi8延迟不比 SSE2 的_mm_cmpeq_epi8短。 - 32 字节的控制字节更容易跨 cache line。
这是典型的”微架构层面的工程权衡”。理论上 group 越大越好,实际上受限于缓存行大小和 SIMD 寄存器宽度。
与 Robin Hood Hashing 的对比
Robin Hood Hashing 是另一种高性能的开放寻址方案。它通过”劫富济贫”——让探测距离短的元素给探测距离长的元素让位——来减小最大探测长度。
在我的实际经验中:
- Swiss Table 在平均情况下更快:SIMD 的并行能力在中等负载因子下优势明显。
- Robin Hood 在最坏情况下更可预测:它保证最大探测长度是 O(log n),方差极小。Swiss Table 虽然也很好,但最坏情况下某个 group 可能全满。
- Robin Hood 的删除更简单:可以用”后移补位”(backward shift deletion)来避免 tombstone,而 Swiss Table 依赖 tombstone。
- Swiss Table 的内存布局更优:控制字节和键值对分离,让 SIMD 只需要加载 16 字节元数据,不碰键值对。Robin Hood 的元数据(displacement 值)通常和键值对存在一起。
对于绝大多数通用场景,Swiss Table 是更好的选择。但如果你在写一个需要严格延迟保证的系统(如交易引擎),Robin Hood 的方差更小可能更有价值。
对 Rust 生态的影响
Swiss Table 不仅影响了 C++ 生态。Rust 的标准库
HashMap 从 1.36 版本开始就使用了 Swiss Table
的变体(hashbrown),替换了之前的 Robin Hood 实现。
hashbrown 的作者 Amanieu d’Antras 基本上是把 Abseil 的 Swiss Table 用 Rust 重写了一遍,做了一些 Rust 特有的优化(如利用 Rust 的 move 语义简化 rehash)。这意味着每个 Rust 程序默认就在使用 Swiss Table。
Go 也在 1.24 版本中为其内置 map 引入了 Swiss Table 设计。可以说,Swiss Table 已经成为现代语言运行时的标准哈希表实现。
我的工程判断
如果让我为一个新项目选择哈希表实现,我的决策框架是:
默认选择:
absl::flat_hash_map(C++)或标准HashMap(Rust)。Swiss Table 在绝大多数工作负载下都是最优或接近最优的。需要稳定指针/迭代器:
absl::node_hash_map。牺牲一些 cache 友好性换取接口兼容性。需要并发:不要用 Swiss Table——它不是线程安全的。用
concurrent_hash_map(TBB)或分片加锁。Abseil 曾经考虑过并发版本,但最终没有发布,因为加锁的粒度很难做好。超大 value 类型:考虑
flat_hash_map<Key, unique_ptr<Value>>。内联存储大对象会浪费太多空槽位内存。硬实时系统:考虑 Cuckoo Hashing(最坏 O(1) 查找保证)或预分配的完美哈希。Swiss Table 的 amortized 性能很好,但偶尔的 rehash 会导致延迟尖刺。
Swiss Table 代表了一种工程哲学:不要和硬件对着干。现代 CPU 有 SIMD、有 cache、有分支预测——设计数据结构时应该顺着这些特性来,而不是只看渐进复杂度。这个教训适用于所有性能敏感的系统设计。
上一篇: Cuckoo Hashing:最坏 O(1) 查找的优雅设计 下一篇: 完美哈希:从理论到 gperf 实践
相关阅读: - 哈希表内部 - 向量化哈希:xxHash3 与 wyhash