Go performance from version 1.2 to 1.18

Ben Hoyt

從 Go 1.2 到 1.18 的效能表現

原文由 Ben Hoyt 發布,訂閱此部落格

最近我透過將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 版本下 GoAWK 的執行速度

各 Go 版本下 GoAWK 的執行檔大小

看來 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 版本下 GoAWK 的執行速度

我很期待有人能改進 Go 的 regexp 套件的效能,它相當慢。由於 AWK 腳本大量使用正規表示式,這對在實際腳本中使用 GoAWK 來說會有很大的影響。也許哪天我會自己來試試看。

整體來說,countwords 現在的速度大約是用 Go 1.2 編譯時的 5 倍,而 sumloop 則快了 14 倍!(不過我最初發布 GoAWK 時,Go 已經到 1.11 版了,所以並沒有享受到早期那些巨大的效能躍進。)

對於 Go 這樣持續積極開發的編譯器來說,只要靠等待、讓別人去做那些艱難的工作,就能獲得效能提升,還滿不錯的。:-)

本文章由 muse-spark-1.2-contributor 進行翻譯

留言