I/O 不再是瓶颈
面试程序员时,我经常让他们写一个统计文本文件词频的简单程序。这是个不错的题目,能考查多方面的能力,再加上几个追问,还能挖得相当深。
我常问的一个追问是:“你这个程序的性能瓶颈在哪里?”大多数人的回答都类似于“读取输入文件”。
其实,写这篇文章的灵感就来自我在 Gopher Slack 上回复某人的经历,他说:“我也注意到这里在切分整行等方面做了很多额外工作,只不过通常这些都比 I/O 快得多,所以我们不在意。”
我并不是要挑他的毛病……在分析 count-words 问题的性能之前,我也是这么想的。我们从小被灌输的不就是这个吗?“I/O 很慢。”
现在已经不是这样了!10 年或 20 年前磁盘 I/O 也许很慢,但在 2022 年,从磁盘顺序读取文件非常快。
到底有多快?我用这个方法测试了我开发用笔记本的读写速度,不过把 count=4096 设为 4096,也就是读写 4GB 数据。以下是在我这台 2022 款 Dell XPS 13 Plus(搭载 Samsung PM9A1 NVMe 硬盘,运行 Ubuntu 22.04)上的测试结果:
| I/O 类型 | 速度(GB/秒) |
|---|---|
| 读取(未缓存) | 1.7 |
| 读取(已缓存) | 10.8 |
| 写入(含同步时间) | 1.2 |
| 写入(不含同步) | 1.6 |
当然,系统调用相对较慢,但顺序读写时,每 4KB、64KB 或按你设置的缓冲区大小才需要一次系统调用。而通过网络的 I/O 依然很慢,尤其是非本地网络。
那么,像上面那样统计词频的程序,瓶颈究竟在哪里?在于对输入的处理或解析以及相关的内存分配:把输入切分成单词、转换为小写,以及用哈希表统计词频。
我改写了自己的 Python 和 Go 版 count-words 程序,让它们记录各个阶段的耗时:读取输入、处理(最慢的部分)、按出现频率排序,以及输出。我用的是一个 413MB 的文本文件,数据量不算小(由《钦定版圣经》文本拼接 100 份而成)。
以下是 3 次运行中最好一次的结果,单位为秒:
| 阶段 | Python | Go(简单版) | Go(优化版) |
|---|---|---|---|
| 读取 | 0.384 | 0.499 | 0.154 |
| 处理 | 7.980 | 3.492 | 2.249 |
| 排序 | 0.005 | 0.002 | 0.002 |
| 输出 | 0.010 | 0.009 | 0.010 |
| 总计 | 8.386 | 4.000 | 2.414 |
这里的排序和输出耗时可以忽略不计:因为输入是 100 份相同文本的拼接,去重后的单词数量相对较少。顺带一提,这在面试中也是个很有意思的追问。有些候选人会说排序会是瓶颈,因为它是 O(N log N),而输入处理是 O(N)。但他们很容易忽略,我们面对的是两个不同的 N:文件中单词的总数,和去重后单词的数量。
Python 版本的核心代码其实就几行:
content = sys.stdin.read()
counts = collections.Counter(content.lower().split())
most_common = counts.most_common()
for word, count in most_common:
print(word, count)在 Python 里当然也可以逐行读取,但会稍慢一些,所以这里我直接把整个文件读入内存再一次性处理。
简单版 Go 实现采用了同样的做法,不过 Go 标准库里没有collections.Counter,所以“按出现次数排序”这部分得自己实现。
优化版 Go 实现要快得多,但也复杂得多。我们通过原地转换小写和按单词边界切分,避免了大部分内存分配。这也是优化 CPU 密集型代码的一条经验法则:减少内存分配。关于如何做性能分析,可以看我的 count-words 优化文章。
我没有给出优化版的 Python 实现,因为 Python 很难再进一步优化了!(我把时间从 8.4 秒降到了 7.5 秒)。它之所以能有这样的速度,是因为核心操作都是在 C 代码中执行的——这也正是“Python 很慢”常常无关紧要的原因。
可以看到,在简单版 Go 实现中,磁盘 I/O 仅占运行时间的 14%。而在优化版中,我们同时加快了读取和处理,磁盘 I/O 仅占总时间的 7%。
我的结论是什么?如果你在处理“大数据”,磁盘 I/O 很可能不是瓶颈。稍作测量,你很可能会发现瓶颈在于解析和内存分配。
随机一篇博客
评论
登录后参与讨论