從 Go 1.2 到 1.18 的效能表現
最近我透過將GoAWK——我用 Go 寫的 AWK 直譯器——從樹狀遍歷直譯器切換為 bytecode 編譯器搭配虛擬機器直譯器,藉此提升了效能。
在做這件事的過程中,我覺得看看 Go 本身這些年來效能提升了多少,應該會很有趣。
用 Go 寫的程式變快有很多原因:Go 團隊與外部貢獻者改進了編譯器,也對 runtime、垃圾回收機制和標準函式庫進行了最佳化。下面是將 GoAWK 分別用從 1.2(我能下載到的最早版本)到 1.18(目前還在 beta 階段)的各個 Go 正式版本來編譯時的效能比較。
我用兩個代表 AWK 兩種極端用途的程式來測試 GoAWK:I/O 與字串處理,以及數值運算。
首先是 countwords,這是一個字串處理任務,會統計輸入中每個單字出現的次數並印出單字與次數。這正是 AWK 腳本的典型用途。輸入資料是將《欽定版聖經》(King James Bible)重複串接 10 次後的版本(我之前用過來做效能比較)。程式碼如下:
{
for (i=1; i<=NF; i++)
counts[tolower($i)]++
}
END {
for (k in counts)
print k, counts[k]
}第二個程式是 sumloop,是一個緊湊的迴圈,會把迴圈計數器重複累加到一個變數上好幾次。這其實不太算是 AWK 的典型用法:
BEGIN {
for (i=0; i<10000000; i++)
sum += i+i+i+i+i
}第一張圖表中的時間數據,是在我那台 x86-64 Linux 筆電上以秒為單位的執行時間(取三次執行中的最佳成績)。我也附上了每個 Go 版本編譯出的 GoAWK 執行檔大小圖表。
我用一個Python 腳本來全部執行並測量時間。圖表如下(如果偏好表格,也可以看表格版):


看來 Go 1.3 時期就摘掉了不少唾手可得的成果!版本說明文件提到當時對 runtime、垃圾回收機制以及堆疊處理方式做了大幅改動。之後 countwords 一路穩定進步到 Go 1.7,sumloop 則持續進步到 1.9。之後直到現在的 1.18,就只有非常緩慢的逐步提升。
沒有深入研究太多,我猜 countwords 的進步(至少在 1.3 之後)主要是來自標準函式庫的改進,而屬於「CPU 密集型」的 sumloop 則是受惠於編譯器的最佳化。
最近的一項改進是在 1.17 版中,改以暫存器傳遞函式參數與回傳值,而非透過堆疊。這對 GoAWK 舊的樹狀遍歷直譯器來說是個顯著的提升,我在某個 microbenchmark 上看到速度提升了 38%,所有 microbenchmark 平均則快了 17%。
有趣的是,在 GoAWK 新的虛擬機器實作中,改以暫存器傳遞參數的這項變動並沒有帶來明顯的提升。這是因為虛擬機器是用一個大型的 switch 陳述式來分派不同的 opcode,幾乎沒有什麼函式呼叫。相較之下,樹狀遍歷直譯器中每個運算式都需要對 eval 進行(遞迴)函式呼叫,因此才能獲得大幅的效能提升。
展開來查看舊版樹狀遍歷直譯器的基準測試結果(表格)。整體趨勢大致相同,不過可以在 1.17 看到因改用暫存器傳遞而造成的下降幅度。

我很期待有人能改進 Go 的 regexp 套件的效能,它相當慢。由於 AWK 腳本大量使用正規表示式,這對在實際腳本中使用 GoAWK 來說會有很大的影響。也許哪天我會自己來試試看。
整體來說,countwords 現在的速度大約是用 Go 1.2 編譯時的 5 倍,而 sumloop 則快了 14 倍!(不過我最初發布 GoAWK 時,Go 已經到 1.11 版了,所以並沒有享受到早期那些巨大的效能躍進。)
對於 Go 這樣持續積極開發的編譯器來說,只要靠等待、讓別人去做那些艱難的工作,就能獲得效能提升,還滿不錯的。:-)
隨機一篇部落格
留言
登入後參與討論