首页 > IT基础架构 > 服务器/存储 > I/O已经不再是性能瓶颈
在面试程序员时,我经常要求对方编写一个简单程序,计算文本文件中各单词出现的频率。这是个很有趣的题目,不仅能测试各种编程技能,也足以引出更多极具深度的扩展议题。
我常会问,“这个程序中的性能瓶颈是什么?”大多数人的回答则是,“从输入文件中读取内容。”
我一直觉得这回答挺对的,但后来在网络论坛上看到了另一种思路:“我发现整个代码行的执行是分成多个步骤的,各个步骤都对应着额外的操作。只是这些一般会比I/O更快,所以我们不太在意。”
刚开始我不以为意,但之后抱着好奇,我认真分析了词频计算的性能细节……原来我们真的错了,大家习以为常的“I/O很慢”观念早已被颠覆!
没错,十几、二十年前的磁盘I/O确实很慢。但现在已经2023年了,磁盘的按序文件读取已经非常快。
到底有多快?我用这种方法[1]测试了一下自己这台做开发的笔记本电脑。其中count=4096 ,代表读写大小为4 GB。在这台2022款戴尔XPS 13 Plus、三星PM9A1 NVMe驱动器再加Ubuntu 22.04的开发系统组合上,测试结果为:
| I/O类型 | 速度(GB/s) |
|---|---|
| 读取(未缓存) | 1.7 |
| 读取(已缓存) | 10.8 |
| 写入(包括同步时间) | 1.2 |
| 写入(不包括同步) | 1.6 |
当然,系统调用相对较慢。但当顺序读取或写入时,每4 KB或64 KB(依缓冲区大小而定)才对应一次系统调用。真正慢的其实是网络I/O,特别是非本地网络。
这么说来,谁才是那个词频计算程序的真正性能瓶颈?答案是,输入处理/解析以及相关内存分配,具体包括:将输入拆分成单词,转换成小写,并使用哈希表计算频率。
我修改了自己的Python和Go单词计算程序,这样就能确切记录过程中各个阶段所消耗的时间:读取输入、处理(最慢的部分)、按频率排序和输出。我对一个413 MB大小的文件执行了词频计算,里头包含钦定版《圣经》的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版[2]的核心可以归纳成以下几行代码:
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简单版[3]用的也是相同的方法,只是Go标准库中没有collections.Counter[4],所以我们需要自己动手实现频率排序。
优化后的Go版本速度明显更快,但也要复杂得多[5]。我们先把全文转换为小写字母,之后在适当的单词边界上做拆分,借此减少内存分配操作。是的,减少内存分配是优化CPU绑定代码的一个妙招,感兴趣的朋友不妨多试试。
这里之所以没有Python的优先后版本,是因为我发现Python代码很难做进一步优化(最多就是从8.4秒优化到7.5秒)。之所以速度快,是因为Python的核心操作是在C代码中进行的——所以Python自身的慢往往不影响大局。
可以看到,Go简单版中的磁盘I/O只占整体运行时间的14%;在优化版中,我们进一步提高了读取和处理速度,这样磁盘I/O甚至只占总运行时长的7%。
所以我的结论是:如果各位处理的是大量数据,那磁盘I/O可能并不是性能瓶颈。只需要稍加测试,就会发现解析和内存分配才是拖累速度的“元凶”。
相关链接:
来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/70024924/viewspace-2934196/ ,如需转载,请注明出处,否则将追究法律责任。