土法炼钢兴趣小组的算法知识备份

Swiss Table:Google 的 SIMD 加速哈希表

目录

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 满足以下保证:

  1. 迭代器稳定性:插入新元素不会使已有元素的迭代器失效
  2. 引用稳定性map[key] 返回的引用在元素存在期间始终有效
  3. 节点句柄: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。

内存访问延迟才是瓶颈,不是计算。

内存开销

每个节点至少包含:

对于一个 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——每次探测都要:

  1. 读取槽位的 key
  2. 比较 key 是否相等
  3. 如果不等,移动到下一个槽位

每一步都可能触发 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
       +-+--+--+--+--+--+--+--+

这个编码方式的巧妙之处在于:

  1. 最高位区分状态:bit 7 为 0 表示已占用(FULL),为 1 表示特殊状态(EMPTY/DELETED/SENTINEL)。一个简单的符号位检查就能判断。

  2. EMPTY 的全 1 设计:EMPTY 是 0xFF,而新分配的内存用 memset(ctrl, 0xFF, ...) 就能初始化所有槽位为空。

  3. 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 的性能核心。让我画出完整的匹配流程:

Swiss Table 控制字节布局与 SIMD 匹配流程

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. 1 次内存加载(16 字节控制字节,一个 cache line 内)
  2. 1 次 SIMD 比较(_mm_cmpeq_epi8,1 cycle latency)
  3. 1 次掩码提取(_mm_movemask_epi8,1-3 cycle latency)
  4. 对 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 可能充斥整个表,导致:

  1. 探测序列变长——DELETED 不会终止探测
  2. 即使元素很少,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% 负载因子下:

每个 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

注意事项:

内存效率(字节/元素,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_mapflat_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 个字节同时编码状态和哈希指纹,使得:

  1. 状态检查(EMPTY/DELETED/FULL)只需要看最高位
  2. 哈希匹配(H2)只需要一条 SIMD 指令
  3. 同一个 SIMD load 可以同时做状态检查和哈希匹配(通过不同的 mask 操作)

这让控制字节成了一个极其高效的布隆过滤器——以极低的成本排除 ~99% 的不匹配,只让 ~1% 的候选进入昂贵的 key 比较。

为什么 7-bit H2 是最优的

7 bit 不是随意选择的。假设 group 大小为 16:

如果控制字节用 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 团队实际上测试过——效果不明显。原因是:

  1. 87.5% 负载因子下,大部分查找只需要 1-2 个 group。把 group 从 16 扩到 32,减少的 group 探测次数有限。
  2. AVX2 的 _mm256_cmpeq_epi8 延迟不比 SSE2 的 _mm_cmpeq_epi8 短。
  3. 32 字节的控制字节更容易跨 cache line。

这是典型的”微架构层面的工程权衡”。理论上 group 越大越好,实际上受限于缓存行大小和 SIMD 寄存器宽度。

与 Robin Hood Hashing 的对比

Robin Hood Hashing 是另一种高性能的开放寻址方案。它通过”劫富济贫”——让探测距离短的元素给探测距离长的元素让位——来减小最大探测长度。

在我的实际经验中:

对于绝大多数通用场景,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 已经成为现代语言运行时的标准哈希表实现。

我的工程判断

如果让我为一个新项目选择哈希表实现,我的决策框架是:

  1. 默认选择absl::flat_hash_map(C++)或标准 HashMap(Rust)。Swiss Table 在绝大多数工作负载下都是最优或接近最优的。

  2. 需要稳定指针/迭代器absl::node_hash_map。牺牲一些 cache 友好性换取接口兼容性。

  3. 需要并发:不要用 Swiss Table——它不是线程安全的。用 concurrent_hash_map(TBB)或分片加锁。Abseil 曾经考虑过并发版本,但最终没有发布,因为加锁的粒度很难做好。

  4. 超大 value 类型:考虑 flat_hash_map<Key, unique_ptr<Value>>。内联存储大对象会浪费太多空槽位内存。

  5. 硬实时系统:考虑 Cuckoo Hashing(最坏 O(1) 查找保证)或预分配的完美哈希。Swiss Table 的 amortized 性能很好,但偶尔的 rehash 会导致延迟尖刺。

Swiss Table 代表了一种工程哲学:不要和硬件对着干。现代 CPU 有 SIMD、有 cache、有分支预测——设计数据结构时应该顺着这些特性来,而不是只看渐进复杂度。这个教训适用于所有性能敏感的系统设计。


上一篇: Cuckoo Hashing:最坏 O(1) 查找的优雅设计 下一篇: 完美哈希:从理论到 gperf 实践

相关阅读: - 哈希表内部 - 向量化哈希:xxHash3 与 wyhash


By .