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 改成讀寫 4GB 的資料量。以下是在搭載 Samsung PM9A1 NVMe 硬碟、執行 Ubuntu 22.04 的 2022 年款 Dell XPS 13 Plus 上的測試結果:

I/O 類型速度 (GB/s)
讀取(未經快取)1.7
讀取(經快取)10.8
寫入(含同步時間)1.2
寫入(不含同步)1.6

當然,系統呼叫相對來說比較慢,但循序讀寫時,每 4KB、64KB 或無論你的緩衝區大小是多少,才需要做一次 syscall。而且透過網路的 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 版本速度快上許多,但也複雜得多。我們透過就地(in place)轉小寫並依單字邊界分割,來避免大部分的記憶體配置。這是最佳化 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 進行翻譯

留言