Go 內聯優化能讓程式快多少?

煎魚發表於2022-06-21

大家好,我是煎魚。

最近週末在家學習時看到 @Dave Cheney 的《Inlining optimisations in Go》還是有不少養分的,翻譯分享給大家,有所修整、刪減。

這是一篇介紹 Go 編譯器如何實現內聯的文章,以及這種優化將如何影響你的 Go 程式碼。

接下來和煎魚一起開始吸取知識。

什麼是內聯?

內聯是將較小的函式合併到它們各自的呼叫者中的行為。其在不同的計算曆史時期的做法不一樣,如下:

  • 早期:這種優化通常是由手工完成的。
  • 現在:內聯是在編譯過程中自動進行的一類基本優化之一。

為什麼內聯很重要?

內聯是很重要的,每一門語言都必然會有。具體的原因如下:

  • 它消除了函式呼叫本身的開銷。
  • 它允許編譯器更有效地應用其他優化策略。

核心來講,就是效能更好了。

函式呼叫的開銷

基本知識

在任何語言中呼叫一個函式都是有代價的。將引數編入暫存器或堆疊(取決於ABI),並在返回時反轉這一過程,這些都是開銷。

呼叫一個函式需要將程式計數器從指令流中的一個點跳到另一個點,這可能會導致流水線停滯。一旦進入函式,通常需要一些前言來為函式的執行準備一個新的堆疊框架,在返回撥用者之前,還需要一個類似的尾聲來退掉這個框架。

Go 中的開銷

在 Go 中,一個函式的呼叫需要額外的成本來支援動態堆疊的增長。在進入時,goroutine 可用的堆疊空間的數量與函式所需的數量進行比較。

如果可用的堆疊空間不足,序言就會跳轉到執行時邏輯,通過將堆疊複製到一個新的、更大的位置來增加堆疊。

一旦這樣做了,執行時就會跳回到原始函式的起點,再次進行堆疊檢查,現在通過了,然後繼續呼叫。通過這種方式,goroutines可以從一個小的堆疊分配開始,只有在需要時才會增加。

這種檢查很便宜,只需要幾條指令,而且由於goroutine的堆疊以幾何級數增長,檢查很少失敗。因此,現代處理器中的分支預測單元可以通過假設堆疊檢查總是成功來隱藏堆疊檢查的成本。在處理器錯誤預測堆疊檢查並不得不丟棄它在投機執行時所做的工作的情況下,與執行時增長goroutine堆疊所需的工作成本相比,管道停滯的成本相對較小。

Go 裡的優化

雖然每個函式呼叫的通用元件和 Go 特定元件的開銷被使用投機執行技術的現代處理器很好地優化了,但這些開銷不能完全消除,因此每個函式呼叫都帶有效能成本,超過了執行有用工作的時間。由於函式呼叫的開銷是固定的,較小的函式相對於較大的函式要付出更大的代價,因為它們每次呼叫的有用工作往往較少。

因此,消除這些開銷的解決方案必須是消除函式呼叫本身,Go 編譯器在某些條件下通過用函式的內容替換對函式的呼叫來做到這一點。這被稱為內聯,因為它使函式的主體與它的呼叫者保持一致。

改善優化的機會

Cliff Click 博士將內聯描述為現代編譯器進行的優化,因為它是常量傳播和死程式碼消除等優化的基礎。

實際上,內聯允許編譯器看得更遠,允許它在特定函式被呼叫的情況下,觀察到可以進一步簡化或完全消除的邏輯。

由於內聯可以遞回應用,優化決策不僅可以在每個單獨的函式的上下文中做出,還可以應用於呼叫路徑中的函式鏈。

進行內聯優化

不允許內聯

內聯的效果可以通過這個小例子來證明:

package main

import "testing"

//go:noinline
func max(a, b int) int {
    if a > b {
        return a
    }
    return b
}

var Result int

func BenchmarkMax(b *testing.B) {
    var r int
    for i := 0; i < b.N; i++ {
        r = max(-1, i)
    }
    Result = r
}

執行這個基準可以得到以下結果:

% go test -bench=. 
BenchmarkMax-4   530687617         2.24 ns/op

從執行結果來看,max(-1, i)的成本大約是 2.24ns,感覺效能不錯。

允許內聯

現在讓我們去掉 //go:noinline pragma 的語句,再看看不允許內聯的情況下,效能是否會改變。

如下結果:

% go test -bench=. 
BenchmarkMax-4   1000000000         0.514 ns/op

兩個結果對比一看,2.24ns 和 0.51ns。差距至少一倍以上,根據 benchstat 的建議,內聯情況下,效能提高了 78%。

如下結果:

% benchstat {old,new}.txt
name   old time/op  new time/op  delta
Max-4  2.21ns ± 1%  0.49ns ± 6%  -77.96%  (p=0.000 n=18+19)

這些改進從何而來?

首先,取消函式呼叫和相關的前導動作是主要的改進貢獻者。其將 max 函式的內容拉到它的呼叫者中,減少了處理器執行的指令數量,並消除了幾個分支。

現在 max 函式的內容對編譯器來說是可見的,當它優化 BenchmarkMax 時,它可以做一些額外的改進。

考慮到一旦 max 被內聯,BenchmarkMax 的主體對編譯器而言就會有所改變,與使用者端看到的並不一樣。

如下程式碼:

func BenchmarkMax(b *testing.B) {
    var r int
    for i := 0; i < b.N; i++ {
        if -1 > i {
            r = -1
        } else {
            r = i
        }
    }
    Result = r
}

再次執行基準測試,我們看到我們手動內聯的版本與編譯器內聯的版本表現一樣好。

如下結果:

% benchstat {old,new}.txt
name   old time/op  new time/op  delta
Max-4  2.21ns ± 1%  0.48ns ± 3%  -78.14%  (p=0.000 n=18+18)

現在,編譯器可以獲得 max 內聯到 BenchmarkMax 的結果,它可以應用以前不可能的優化方法。

例如:編譯器注意到 i 被初始化為 0,並且只被遞增,所以任何與 i 的比較都可以假定 i 永遠不會是負數。因此,條件 -1 > i 將永遠不會為真。

在證明了 -1 > i 永遠不會為真之後,編譯器可以將程式碼簡化為:

func BenchmarkMax(b *testing.B) {
    var r int
    for i := 0; i < b.N; i++ {
        if false {  // 注意已為 false
            r = -1
        } else {
            r = i
        }
    }
    Result = r
}

並且由於該分支現在是一個常數,編譯器可以消除無法到達的路徑,只留下如下程式碼:

func BenchmarkMax(b *testing.B) {
    var r int
    for i := 0; i < b.N; i++ {
        r = i
    }
    Result = r
}

通過內聯和它所釋放的優化,編譯器已經將表示式 r = max(-1, i) 簡化為 r = i

這個例子非常不錯,很好的體現了內聯的優化過程和效能提升的緣由。

內聯的限制

在這篇文章中,討論了所謂的葉子內聯:將呼叫棧底部的一個函式內聯到其直接呼叫者中的行為。

內聯是一個遞迴的過程,一旦一個函式被內聯到它的呼叫者中,編譯器就可能將產生的程式碼內聯到它的呼叫者中,依此類推。

例如如下程式碼:

func BenchmarkMaxMaxMax(b *testing.B) {
    var r int
    for i := 0; i < b.N; i++ {
        r = max(max(-1, i), max(0, i))
    }
    Result = r
}

該執行速度將會和前面的例子一樣快,因為編譯器能夠反覆應用上面的優化,將程式碼減少到相同的 r = i 表示式。

總結

這篇文章針對內聯進行了基本的概念介紹和分析,並且通過 Go 的例子進行了一步步的剖析,讓大家對真實案例有了一個更貼切的理解。

Go 編譯器的優化總是無處不在的。

文章持續更新,可以微信搜【腦子進煎魚了】閱讀,本文 GitHub github.com/eddycjy/blog 已收錄,學習 Go 語言可以看 Go 學習地圖和路線,歡迎 Star 催更。

Go 圖書系列

推薦閱讀

相關文章