背景:本文基于对 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 显式编写向量化代码,虽然体验相当痛苦,但至少能对生成的机器代码有更好的控制:
关键优化点:
- 数据对齐: 使用 256 位寄存器(AVX2)需要在 32 位边界对齐数据
- 广播空白字符: 预留 6 个寄存器保存所有广播的空白字符
- 循环展开: 显式展开向量化循环 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/s | 100% |
| 顺序读取(热缓存) | 12.8 GB/s | - |
| 标准 C 实现 | 278 MB/s | 17% |
| 初步优化(分支预测) | 330 MB/s | 21% |
wc -w 工具 | 245 MB/s | 15% |
| 手写 AVX2 向量化 | 1.45 GB/s | 91% |
| AVX2(冷缓存) | ~1.1 GB/s | 69% |