I/O 已經不再是瓶頸
面試程式設計師時,我經常請他們寫一個簡單的程式來統計文字檔中的單字出現頻率。這是個不錯的題目,可以考驗多方面的能力,再加上一些追問,還能挖得相當深入。
我常追問的一題是:「你的程式效能瓶頸在哪裡?」大多數人都會回答類似「讀取輸入檔」之類的答案。
其實,會寫這篇文章是因為我在 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 次執行中表現最好的一次結果,單位為秒:
| 階段 | 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 版本速度快上許多,但也複雜得多。我們透過就地(in place)轉小寫並依單字邊界分割,來避免大部分的記憶體配置。這是最佳化 CPU 密集型程式碼的一個好經驗法則:減少記憶體配置。關於如何對這類程式做效能分析,請參考我的 count-words 最佳化文章。
我沒有展示最佳化後的 Python 版本,因為 Python 已經很難再進一步最佳化了!(我頂多把時間從 8.4 秒降到 7.5 秒)。它之所以能有這樣的速度,是因為核心操作其實是在 C 語言中執行的——這也是為什麼「Python 很慢」這件事常常無關緊要。
如你所見,在簡易版 Go 版本中,磁碟 I/O 只佔了執行時間的 14%。在最佳化版本中,我們同時加快了讀取和處理的速度,磁碟 I/O 更只佔了總時間的 7%。
我的結論是?如果你正在處理「大數據」,磁碟 I/O 很可能不是瓶頸。只要稍微量測一下,很可能會發現問題出在解析和記憶體配置上。
隨機一篇部落格
留言
登入後參與討論