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

向量化哈希:xxHash3 与 wyhash 的 SIMD 实现

目录

当你用 cp 复制一个 10GB 的文件时,xxhash 命令可以在 0.3 秒内算出校验和——速度接近内存带宽的理论极限。而传统的 MD5 需要 20 秒,SHA-256 需要 40 秒。

这个量级的性能差异不是算法的渐近复杂度能解释的——它来自于对指令级并行的极致利用。现代非密码学哈希函数的核心设计目标之一就是:让 CPU 的向量单元满载运转

这篇文章深入分析两个当今最快的通用哈希函数——xxHash3wyhash——的 SIMD 设计。它们代表了两种截然不同的向量化哲学:xxHash3 是”显式 SIMD,多累加器并行”;wyhash 是”隐式 ILP,靠乘法器吞吐”。

下图对比了两者的架构差异:

xxHash3 vs wyhash 架构对比

一、为什么哈希函数需要 SIMD

现代 CPU 的执行瓶颈

一个标量哈希函数(如 FNV-1a)的循环体大致是:

for (int i = 0; i < len; i++) {
    hash ^= data[i];
    hash *= 0x01000193;
}

这个循环有严重的数据依赖:第 \(i+1\) 次迭代的输入依赖第 \(i\) 次的输出。CPU 的乱序执行引擎无法并行处理多次迭代——每次乘法必须等前一次完成(3-4 个时钟周期)。

对于一个 4GHz 的 CPU: - 乘法延迟 3 cycles → 每秒 \(4 \times 10^9 / 3 \approx 1.3 \times 10^9\) 次迭代 - 每次处理 1 字节 → 吞吐量约 1.3 GB/s

而内存带宽可达 50+ GB/s。哈希函数成了瓶颈。

两种并行化策略

策略一:多累加器(xxHash3)

使用 \(k\) 个独立的累加器,每个处理不同的数据块。消除了数据依赖,让 CPU 的多个执行端口同时工作。用 SIMD 指令把 \(k\) 个累加器打包到向量寄存器中。

策略二:宽乘法(wyhash)

利用 64x64→128 bit 乘法指令。一次乘法处理 128 bit 输入(两个 64-bit 字),而乘法单元的吞吐量为每周期 1 次。在 8 字节/cycle 的有效处理速率下,已经接近 L1 cache 的读取带宽。

二、xxHash3 架构详解

设计演进

Yann Collet 是 LZ4 和 Zstandard 的作者,也是 xxHash 系列的设计者。演进路线:

XXH3 根据输入长度选择不同的代码路径:

输入长度 策略 原因
0-3 B 特殊处理 避免分支和内存对齐问题
4-8 B 单次混合 一次 64-bit 读取 + 乘法
9-16 B 两次混合 两次 64-bit 读取,分别混合后合并
17-128 B 累加器对 多组 128-bit 读取,逐对累加
129-240 B 扩展累加 类似上面,但更多轮次
241+ B SIMD 主循环 8 个 64-bit 累加器,1024 字节一个 stripe

SIMD 主循环(长输入)

核心数据结构:8 个 64-bit 累加器,用 AVX2 时正好填满一个 __m256i 寄存器的两半(或两个 __m128i)。

每个 stripe(1024 字节 = 16 个 64 字节块)的处理:

// xxHash3 SIMD 主循环(简化,AVX2 版本)
// acc: 8 个 64-bit 累加器(两个 __m256i)
// input: 当前 stripe 的 1024 字节
// secret: 预生成的密钥字节

void xxh3_accumulate_avx2(__m256i *acc, const uint8_t *input,
                          const uint8_t *secret) {
    for (int i = 0; i < 16; i++) {  // 16 个 64-byte 块
        // 加载 64 字节输入
        __m256i data_lo = _mm256_loadu_si256((__m256i*)(input + i*64));
        __m256i data_hi = _mm256_loadu_si256((__m256i*)(input + i*64 + 32));

        // 加载对应的 secret 字节
        __m256i key_lo = _mm256_loadu_si256((__m256i*)(secret + i*8));
        __m256i key_hi = _mm256_loadu_si256((__m256i*)(secret + i*8 + 32));

        // XOR: data ^ key
        __m256i xored_lo = _mm256_xor_si256(data_lo, key_lo);
        __m256i xored_hi = _mm256_xor_si256(data_hi, key_hi);

        // 32x32→64 乘法(取低 32 位和高 32 位相乘)
        // _mm256_mul_epu32: 取每个 64-bit lane 的低 32-bit 相乘
        __m256i prod_lo = _mm256_mul_epu32(xored_lo,
                          _mm256_shuffle_epi32(xored_lo, 0x31));
        __m256i prod_hi = _mm256_mul_epu32(xored_hi,
                          _mm256_shuffle_epi32(xored_hi, 0x31));

        // 累加:acc += data(不是 xored,是原始 data!)
        acc[0] = _mm256_add_epi64(acc[0], data_lo);
        acc[1] = _mm256_add_epi64(acc[1], data_hi);

        // 累加乘法结果
        acc[0] = _mm256_add_epi64(acc[0], prod_lo);
        acc[1] = _mm256_add_epi64(acc[1], prod_hi);
    }
}

关键设计点:

  1. 数据和密钥 XOR 后再乘法:提供密钥相关的混合
  2. _mm256_mul_epu32:利用 32x32→64 乘法的天然扩散性
  3. 同时累加原始数据:增加扩散,防止乘法结果为 0 时丢失信息
  4. _mm256_shuffle_epi32 交叉:让高 32-bit 和低 32-bit 交叉相乘

Scramble(扰乱)

每处理 16 个 stripe(16KB)后,执行一次 scramble 防止累加器模式化:

void xxh3_scramble_avx2(__m256i *acc, const uint8_t *secret) {
    for (int i = 0; i < 2; i++) {
        __m256i key = _mm256_loadu_si256((__m256i*)(secret + i*32));
        // acc ^= (acc >> 47)
        acc[i] = _mm256_xor_si256(acc[i],
                 _mm256_srli_epi64(acc[i], 47));
        // acc ^= key
        acc[i] = _mm256_xor_si256(acc[i], key);
        // acc *= PRIME32_1
        __m256i prime = _mm256_set1_epi32(0x9E3779B1);
        acc[i] = _mm256_mullo_epi32(acc[i], prime);
    }
}

终结化

8 个累加器最终合并为一个 64-bit 或 128-bit 输出。合并过程使用 mergeAccs:把累加器两两异或混合,再做最终的乘法折叠。

三、wyhash 的极简哲学

核心操作:MUM

wyhash 的全部精华浓缩在一个操作里:Multiply-and-Mix (MUM)

static inline uint64_t wymum(uint64_t a, uint64_t b) {
    __uint128_t r = (__uint128_t)a * b;
    return (uint64_t)r ^ (uint64_t)(r >> 64);
}

一次 64x64→128 bit 乘法,然后高 64 位和低 64 位 XOR。这个操作有极好的混合效果——128 位乘法的每个输出位都依赖于多个输入位。

完整的 wyhash 实现

// wyhash v4.2 — 完整实现(约 40 行核心代码)
#include <stdint.h>
#include <string.h>

static inline uint64_t wyread8(const uint8_t *p) {
    uint64_t v; memcpy(&v, p, 8); return v;
}
static inline uint64_t wyread4(const uint8_t *p) {
    uint32_t v; memcpy(&v, p, 4); return v;
}
static inline uint64_t wyread3(const uint8_t *p, size_t k) {
    return ((uint64_t)p[0] << 16) | ((uint64_t)p[k >> 1] << 8) | p[k - 1];
}

static inline uint64_t wymum(uint64_t a, uint64_t b) {
    __uint128_t r = (__uint128_t)a * b;
    return (uint64_t)r ^ (uint64_t)(r >> 64);
}
static inline uint64_t wymix(uint64_t a, uint64_t b) {
    return wymum(a, b);
}

// 默认 secret(可以自定义)
static const uint64_t wyp[4] = {
    0xa0761d6478bd642fULL, 0xe7037ed1a0b428dbULL,
    0x8ebc6af09c88c6e3ULL, 0x589965cc75374cc3ULL
};

uint64_t wyhash(const void *key, size_t len, uint64_t seed) {
    const uint8_t *p = (const uint8_t *)key;
    seed ^= wymix(seed ^ wyp[0], wyp[1]);

    uint64_t a, b;
    if (len <= 16) {
        if (len >= 4) {
            a = (wyread4(p) << 32) | wyread4(p + ((len >> 3) << 2));
            b = (wyread4(p + len - 4) << 32) | wyread4(p + len - 4 - ((len >> 3) << 2));
        } else if (len > 0) {
            a = wyread3(p, len);
            b = 0;
        } else {
            a = b = 0;
        }
    } else if (len <= 48) {
        // 3 轮 MUM
        a = wyread8(p) ^ wyp[1];
        b = wyread8(p + 8) ^ seed;
        seed = wymix(a, b);
        if (len > 16) {
            a = wyread8(p + 16) ^ wyp[2];
            b = wyread8(p + 24) ^ seed;
            seed = wymix(a, b);
        }
        if (len > 32) {
            a = wyread8(p + 32) ^ wyp[3];
            b = wyread8(p + 40) ^ seed;
            seed = wymix(a, b);
        }
        a = wyread8(p + len - 16) ^ wyp[1];
        b = wyread8(p + len - 8) ^ seed;
    } else {
        // 长输入:每次处理 48 字节(3 组 MUM 并行)
        uint64_t s1 = seed, s2 = seed;
        do {
            seed = wymix(wyread8(p) ^ wyp[1], wyread8(p + 8) ^ seed);
            s1 = wymix(wyread8(p + 16) ^ wyp[2], wyread8(p + 24) ^ s1);
            s2 = wymix(wyread8(p + 32) ^ wyp[3], wyread8(p + 40) ^ s2);
            p += 48;
            len -= 48;
        } while (len > 48);
        seed ^= s1 ^ s2;
        // 处理尾部
        a = wyread8(p + len - 16) ^ wyp[1];
        b = wyread8(p + len - 8) ^ seed;
    }

    return wymix(wyp[1] ^ len, wymix(a ^ wyp[1], b ^ seed));
}

为什么 wyhash 不用显式 SIMD

wyhash 的长输入路径使用 3 组独立的 MUM,但没有使用 SSE/AVX 指令。原因:

  1. 乘法器吞吐:现代 x86 CPU 有 1 个 64-bit 乘法器,吞吐量为每周期 1 次。3 组独立乘法可以流水线化(延迟 3 cycle,吞吐 1 cycle)。
  2. SIMD 乘法效率低:AVX2 没有 64x64→128 bit 乘法指令。_mm256_mul_epu32 只做 32x32→64,扩散效果差很多。
  3. 代码简洁性:标量代码在所有平台上都能编译,不需要 #ifdef 分发。

wyhash 的设计哲学是:用最少的代码做到足够好,而不是用最复杂的代码做到最好

四、AES-NI 加速哈希

为什么 AES 指令适合做哈希

Intel AES-NI 提供了 aesenc(一轮 AES 加密)指令。它的特性:

一条 aesenc 指令比 “XOR + 乘法 + 移位” 的手写混合效果更好、更快。

aHash 的设计

Rust 生态中最流行的高性能哈希库 ahash 在支持 AES-NI 的平台上使用以下策略:

// aHash 的核心思路(简化)
__m128i ahash_short(const void *data, size_t len, __m128i key) {
    __m128i input = load_input(data, len);
    // 两轮 AES 加密就足够了
    __m128i mixed = _mm_aesenc_si128(input, key);
    mixed = _mm_aesenc_si128(mixed, key);
    return mixed;
}

__m128i ahash_long(const void *data, size_t len, __m128i key) {
    __m128i acc0 = key;
    __m128i acc1 = _mm_xor_si128(key, _mm_set1_epi64x(len));

    const __m128i *blocks = (const __m128i *)data;
    size_t nblocks = len / 32;

    for (size_t i = 0; i < nblocks; i++) {
        __m128i b0 = _mm_loadu_si128(blocks + i*2);
        __m128i b1 = _mm_loadu_si128(blocks + i*2 + 1);
        // 两个独立的 AES 链,利用乱序执行并行
        acc0 = _mm_aesenc_si128(acc0, b0);
        acc1 = _mm_aesenc_si128(acc1, b1);
    }

    // 合并两个累加器
    return _mm_aesenc_si128(acc0, acc1);
}

aHash 的巧妙之处:使用 AES 指令但不做真正的 AES 加密——只是借用了 aesenc 指令的混合效果。

ARM 上的替代:VMULL

ARM 没有 AES-NI 的直接等价物(虽然有 ARM AES 指令,但不是所有 ARM 芯片都支持)。ARM 的替代方案是 VMULL(多项式乘法),由 CLHASH 算法使用。

五、短键优化:哈希表的真正战场

为什么短键最重要

在哈希表的实际使用中,键长度的分布高度偏斜:

超过 128 字节的键在哈希表中极为罕见。因此,短键的延迟(而非长输入的吞吐量)才是哈希表性能的关键。

各哈希函数的短键延迟对比

测试条件:Intel i9-13900K,单线程,GCC 13 -O3

8-byte key latency (cycles):
  wyhash          ████  5
  aHash(AES-NI)   █████  6
  xxHash3          ████████  8
  FarmHash         █████████  9
  MurmurHash3-x64 ████████████  12
  CityHash         █████████████  13
  SipHash-1-3      ████████████  12
  SipHash-2-4      ██████████████████  18

16-byte key latency (cycles):
  wyhash          ██████  6
  aHash(AES-NI)   ███████  7
  xxHash3          ██████████  10
  MurmurHash3-x64 █████████████████  17
  SipHash-1-3      ████████████████  16
  SipHash-2-4      ████████████████████████  24

观察: - wyhash 在短键上最快(一次 MUM 操作就够了) - aHash 几乎一样快(一条 aesenc 指令) - xxHash3 比 wyhash 稍慢(短键路径有更多分支) - SipHash 明显更慢,但提供了安全性保证

wyhash 的短键优化技巧

// wyhash 处理 4-8 字节键的巧妙方法
if (len >= 4) {
    // 从头部读 4 字节,从尾部读 4 字节
    // 对于 len=4,5,6,7,8 都只需要 2 次内存读取
    a = (wyread4(p) << 32) | wyread4(p + ((len >> 3) << 2));
    b = (wyread4(p + len - 4) << 32) | wyread4(p + len - 4 - ((len >> 3) << 2));
}

这个技巧避免了对键长度的分支:无论键是 4、5、6、7 还是 8 字节,都执行相同的代码路径。读取可能重叠(例如 4 字节的键会读两次同样的数据),但这无所谓——避免分支预测失败比节省一次读取更重要。

六、长输入吞吐量基准

测试条件:AMD Ryzen 9 7950X,单线程,数据在 L1/L2/L3/DRAM 中

Throughput (GB/s) — data in L1 cache (32KB):
  xxHash3(AVX2)  ████████████████████████████████████████  42.0
  xxHash3(SSE2)  ████████████████████████████  28.0
  wyhash         ██████████████████████████████████  34.0
  aHash(AES-NI)  ████████████████████████████████  32.0
  BLAKE3(AVX2)   ████████████  12.0
  SipHash-2-4    ███████  7.5

Throughput (GB/s) — data in DRAM:
  xxHash3(AVX2)  ████████████████████████████████████████  28.0
  wyhash         ████████████████████████████████████  25.0
  BLAKE3(AVX2)   ███████████████████  7.0
  SipHash-2-4    ██████  2.5

关键洞察:当数据在 DRAM 中时,xxHash3 和 wyhash 的差距缩小——因为内存带宽成了瓶颈,不是 CPU 计算。AVX-512 版本的 xxHash3 在某些平台上可以进一步提升,但也面临降频的问题。

七、SIMD 哈希的可移植性问题

平台碎片化

指令集 x86 覆盖率 ARM 覆盖率 关键指令
SSE2 ~100% (2001+) N/A _mm_mul_epu32, _mm_cmpeq_epi8
AVX2 ~85% (2013+) N/A _mm256_mul_epu32, 256-bit ops
AVX-512 ~30% (2017+) N/A 512-bit ops, 但会降频
NEON N/A ~100% (ARMv7+) vmull_u32, vceqq_u8
SVE/SVE2 N/A ~20% (ARMv8.2+) 可变宽度向量
AES-NI ~90% (2010+) ~70% (ARMv8+) aesenc / AESE

运行时分发(Runtime Dispatch)

xxHash3 和 wyhash 都使用编译期 #ifdef 选择指令集。更高级的方案是运行时分发:

// 运行时 SIMD 分发
typedef uint64_t (*hash_fn)(const void*, size_t, uint64_t);

static hash_fn select_impl(void) {
    if (cpu_supports_avx2())
        return xxh3_avx2;
    else if (cpu_supports_sse2())
        return xxh3_sse2;
    else
        return xxh3_scalar;
}

// GCC 的 ifunc 属性可以自动做运行时分发
uint64_t xxh3_hash(const void *data, size_t len, uint64_t seed)
    __attribute__((ifunc("select_impl")));

八、设计自己的 SIMD 哈希:核心原则

如果你需要为特定场景设计哈希函数,以下是关键原则:

原则一:消除循环依赖

每个累加器的更新必须独立于其他累加器。数据依赖链越短越好。

// 差:串行依赖链
for (i = 0; i < n; i++)
    acc = mix(acc, data[i]);    // 延迟 = n * mix_latency

// 好:并行累加器
for (i = 0; i < n; i += 4) {
    acc0 = mix(acc0, data[i]);    // 4 路并行
    acc1 = mix(acc1, data[i+1]);  // 延迟 = n/4 * mix_latency
    acc2 = mix(acc2, data[i+2]);
    acc3 = mix(acc3, data[i+3]);
}

原则二:选择正确的混合操作

操作 延迟(cycles) 吞吐(ops/cycle) 扩散质量
XOR 1 3 无(线性)
ADD 1 3 弱(进位传播)
ROTATE 1 2 无(位置置换)
MUL 64x64→128 3 1 极好
AESENC 128→128 4 1 极好(SAC)
_mm_mul_epu32 5 1

经验法则:乘法和 AES 提供扩散,XOR 和 ADD 用于合并

原则三:短键和长键分开处理

短键的性能瓶颈是延迟(cycle count),长键的瓶颈是吞吐(bytes/cycle)。用 if/switch 分流不同长度的代码路径。

原则四:确保终结化充分混合

多个累加器合并为最终输出时,需要确保每个累加器的信息都影响到输出的每个 bit。常见做法:

// 不够好:简单 XOR
result = acc0 ^ acc1 ^ acc2 ^ acc3;
// 问题:acc0 和 acc1 的相同 bit 会相消

// 更好:级联混合
uint64_t result = wymum(acc0 ^ acc1, acc2 ^ acc3);
result = wymum(result ^ len, secret);

九、工程陷阱清单

陷阱 症状 解法
假设 AVX-512 一定更快 开启 AVX-512 后反而变慢 Intel 某些型号会降频;benchmark 验证
不对齐的 SIMD 加载 某些平台上性能骤降或崩溃 使用 _mm_loadu 而非 _mm_load,或确保数据对齐
忽略短键路径 哈希表场景性能不如预期 短键用标量快速路径,长键才上 SIMD
用 SIMD 乘法替代标量 128-bit 乘法 扩散效果差,质量下降 _mm_mul_epu32 只有 32x32→64,不等于 64x64→128
终结化混合不充分 SMHasher 测试失败 至少做两轮 MUM 或 AES
跨平台结果不一致 大小端、浮点差异 使用 memcpy 加载,不做类型双关
AES-NI 可用性假设 在旧 CPU 上崩溃 运行时检测 CPUID,提供标量回退

十、总结与个人思考

xxHash3 和 wyhash 代表了哈希函数设计的两个极端:

我的三个观察:

一、ILP 比 SIMD 更普适。 wyhash 没用一条 SIMD 指令,却能达到极高的吞吐——因为它利用了 CPU 乱序执行引擎的指令级并行(ILP)。ILP 不需要特殊的指令集支持,在所有现代 CPU 上都有效。相比之下,显式 SIMD 需要处理可移植性、编译器向量化失败、指令集检测等一堆工程问题。

二、128-bit 乘法是被低估的原语。 一条 mul 指令提供了 128 位的扩散,而 AVX2 的 _mm256_mul_epu32 只提供 32→64 位的扩散。在目前的 x86 和 ARM 上,标量 128-bit 乘法的”性价比”远高于 SIMD 乘法。这也是为什么 wyhash、Rapidhash 等基于 MUM 的哈希函数能以极少的代码达到极高的性能。

三、最好的 SIMD 哈希可能不需要你写 SIMD。 如果编译器足够聪明(LTO + PGO + auto-vectorization),标量代码中的多累加器模式可以自动向量化。Go 的 runtime hash 就是这样——它用纯 Go 写多累加器,依靠编译器生成 SIMD 代码。在选择”手写 SIMD intrinsics”之前,先试试让编译器帮你做。


上一篇: 密码学哈希 vs 非密码学哈希 下一篇: 红黑树 vs AVL

相关阅读: - Swiss Table:SIMD 加速哈希表 - SIMD 字符串处理进阶 - SIMD 算法设计模式


By .