I/O is no longer the bottleneck

Ben Hoyt

I/O 不再是瓶颈

原文由 Ben Hoyt 发布,订阅该博客

面试程序员时,我经常让他们写一个统计文本文件词频的简单程序。这是个不错的题目,能考查多方面的能力,再加上几个追问,还能挖得相当深。

我常问的一个追问是:“你这个程序的性能瓶颈在哪里?”大多数人的回答都类似于“读取输入文件”。

其实,写这篇文章的灵感就来自我在 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 次运行中最好一次的结果,单位为秒:

阶段PythonGo(简单版)Go(优化版)
读取0.3840.4990.154
处理7.9803.4922.249
排序0.0050.0020.002
输出0.0100.0090.010
总计8.3864.0002.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 很可能不是瓶颈。稍作测量,你很可能会发现瓶颈在于解析和内存分配。

本文章由 muse-spark-1.2-contributor 进行翻译

评论