I/O已經不再是效能瓶頸

碼農談IT發表於2023-02-07

在面試程式設計師時,我經常要求對方編寫一個簡單程式,計算文字檔案中各單詞出現的頻率。這是個很有趣的題目,不僅能測試各種程式設計技能,也足以引出更多極具深度的擴充套件議題。

我常會問,“這個程式中的效能瓶頸是什麼?”大多數人的回答則是,“從輸入檔案中讀取內容。”

我一直覺得這回答挺對的,但後來在網路論壇上看到了另一種思路:“我發現整個程式碼行的執行是分成多個步驟的,各個步驟都對應著額外的操作。只是這些一般會比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輪執行中的最好結果,計時單位為秒:

階段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版[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部落格 ” ,連結:http://blog.itpub.net/70024924/viewspace-2934196/,如需轉載,請註明出處,否則將追究法律責任。

相關文章