背景:本文基于对 Ben Hoyt 观点的验证实验,探讨在典型编程场景(如词频统计)中,I/O 是否仍然是性能瓶颈。

一、引言:I/O 瓶颈论之争

近年来,有一种观点逐渐流行:I/O 不再是典型编程问题的瓶颈。支持者认为,在过去十年中,顺序磁盘读取速度提升了数个数量级,而 CPU 单核性能的增长却相对停滞。因此,CPU 处理而非 I/O 操作,成为了性能瓶颈。

这个观点听起来很有说服力,但事实真的如此吗?让我们通过实际实验来验证。

二、实验环境设置

硬件配置

  • CPU: AMD Zenver2 架构(支持 AVX2)
  • 编译器: GCC 12 / Clang(-O3 -march=native 优化)
  • 测试数据: 425 MB 文本文件(100 份《詹姆斯王版圣经》的副本)

磁盘读取性能基准

使用标准方法测试顺序读取速度:

  • 冷缓存: 1.6 GB/s
  • 热缓存: 12.8 GB/s(最优测试结果)

这个基准相当惊人。如果 I/O 真的不是瓶颈,那么即使是单线程的词频统计,也应该能够达到 1.6 GB/s 的处理速度。

三、优化之旅:从标准实现到向量化

优化的 C 实现

首先,我们测试了 Ben Hoyt 博客中提到的优化版 C 实现(optimized.c):

$ time ./optimized < bible-100.txt > /dev/null

real    0m1.525s
user    0m1.477s
sys     0m0.048s

结果: 278 MB/s(热缓存)

这个结果相当令人失望。即使经过优化,也只能达到磁盘顺序读取速度的 17%(热缓存下甚至更低)。

初步优化:分支预测

分析代码后发现,热点循环中存在大量分支,包括早期退出逻辑,这阻碍了编译器的向量化:

for (; i < num_process; i++) {
    char c = buf[i];
    if (c <= ' ') {
        break;
    }
    if (c >= 'A' && c <= 'Z') {
        c += ('a' - 'A');
        buf[i] = c;
    }
    hash *= FNV_PRIME;
    hash ^= (uint64_t)c;
}

优化策略: 将大小写转换逻辑移出循环

for (int i = 0; i < BUF_SIZE; ++i) {
    buf[i] = buf[i] >= 'A' && buf[i] <= 'Z' ?
             buf[i] - 'A' + 'a' : buf[i];
}

改进结果: 330 MB/s(使用 Clang 以获得更好的向量化)

虽然有所提升,但距离冷缓存顺序读取速度仍有 5 倍差距。

四、简化问题:词频 vs 词计数

此时我意识到,从词频统计中榨取更多性能可能空间有限。哈希表的缓存未命中、局部性问题等,即使优化最多也只能带来 20% 的提升。

让我们转而研究一个信息性更强的基准:只统计词数而不维护频率表——没有复杂的哈希表操作。

使用标准工具 wc -w

理论上,这种专用工具应该非常快:

$ time wc -w < bible-100.txt > /dev/null

real    0m1.758s
user    0m1.718s
sys     0m0.040s

结果: 245.2 MB/s

为什么这么慢?

查看手册后发现,wc 实际上在做不同的事情:

  • Ben Hoyt 的代码只在空格 ' ' 处分割
  • wc' ''\n''\t' 甚至特定于语言环境的字符处分割

额外的逻辑带来了显著的开销。

五、向量化:充分利用现代 CPU

理论基础

如果前提是磁盘速度在过去十年中迎头赶上,我们也应该使用同一时期的新 CPU 特性。这意味着:向量化一切

  • AVX2: 已问世近十年
  • AVX-512: 2017 年起面向普通用户

遗憾的是,编译器很难自动向量化词计数程序。这恰恰证明了 Ben Hoyt 的观点:磁盘速度"免费"提升了数个数量级,但现代编译器并不会神奇地生成快得多的机器代码。将分支密集的标量程序转换为向量化机器代码确实困难。

位掩码技术

词计数的一部分可以轻松自动向量化:假设我们有一个 128 位寄存器,可以存储 16 个连续字符。通过将空格预先广播到寄存器中,然后执行单个 VPCMPEQB 比较操作,即可轻松定位空白字符:

       | 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 |
input: | h o w   m a n y   w o r d s   a | r e   h e r e ?
 mask: | 0 0 0 1 0 0 0 0 1 0 0 0 0 0 1 0 |

位操作技巧

获得向量寄存器中的掩码后,如何统计词数?答案是使用 Move Byte Mask 技巧(来自 Cosmopolitan libc 的 strlen 实现)。

相关指令 PMOVMSKB 将长位掩码移动到 32 位 int 中,然后可以使用常规位操作。

Find First Set (ffs) 指令可以迭代设置位:

int mask = 0b0100000100001000;
int prev = 0;
while (mask) {
    int curr = __builtin_ffs(mask);
    if (curr > prev + 1)
        printf("Word start at %d\n", curr);
    prev = curr;
    if (curr == 32)
        break;
    mask = (mask >> curr) << curr;
}

输出:

Word start at 4
Word start at 9
Word start at 15

六、手写 AVX2 实现

实现细节

使用 immintrin.h 显式编写向量化代码,虽然体验相当痛苦,但至少能对生成的机器代码有更好的控制:

关键优化点:

  1. 数据对齐: 使用 256 位寄存器(AVX2)需要在 32 位边界对齐数据
  2. 广播空白字符: 预留 6 个寄存器保存所有广播的空白字符
  3. 循环展开: 显式展开向量化循环 4 次,每次迭代处理 128 字节数据

性能测试

首先验证正确性:

$ ./wc-avx2 < bible-100.txt
82113300
$ wc -w < bible-100.txt
82113300

✅ 结果一致

性能测试(热缓存):

$ time ./wc-avx2 < bible-100.txt
82113300

real    0m0.227s
user    0m0.186s
sys     0m0.041s

结果: 1.45 GB/s(热缓存)

冷缓存测试

$ sysctl -w vm.drop_caches=3
$ time ./wc-avx2 < bible-100.txt
82113300

real    0m0.395s
user    0m0.196s
sys     0m0.117s

七、性能对比与结论

性能汇总

实现方式处理速度相对磁盘读取速度(冷缓存)
顺序读取(冷缓存)1.6 GB/s100%
顺序读取(热缓存)12.8 GB/s-
标准 C 实现278 MB/s17%
初步优化(分支预测)330 MB/s21%
wc -w 工具245 MB/s15%
手写 AVX2 向量化1.45 GB/s91%
AVX2(冷缓存)~1.1 GB/s69%
最后修改:2026 年 01 月 06 日
如果觉得我的文章对你有用,请随意赞赏