一文搞懂 Go 內聯優化

移動互聯網時代,直面 C 端用戶的業務系統規模一般都很龐大,系統消耗的機器資源也很可觀,系統使用的 CPU 核數、內存都是在消耗公司的真金白銀。在服務水平不下降的前提下儘量降低單服務實例的資源消耗,即我們俗稱的 “少喫草多產奶”,一直是各個公司經營人員的目標,有些公司每降低 1% 的 CPU 核數使用,每年都能節省幾十萬的開銷。

在編程語言選擇不變的情況下,要想持續降低服務資源消耗,一方面要靠開發人員對代碼性能持續地打磨,另一方面依靠編程語言編譯器在編譯優化方面提升帶來的效果則更爲自然和直接。不過,這兩方面也是相輔相成的,開發人員如果能對編譯器的優化場景和手段理解更爲透徹的話,就能寫出對編譯優化更爲友好的代碼,從而獲得更好的性能優化效果。

Go 核心團隊在 Go 編譯器優化方面一直在持續投入並取得了不俗的效果,雖然和老牌的 GCC[1] 和 llvm[2] 的代碼優化功力相比還有不小的空間。近期看到的一篇文章 “字節大規模微服務語言發展之路” 中也有提到:字節內部通過修改 Go 編譯器的內聯優化 (收益最大的改動),從而讓字節內部服務的 Go 代碼獲得了更多的優化機會,實現了線上服務 10-20% 的性能提升以及內存資源使用的下降,節約了大概了十幾萬個核。

看到這麼明顯的效果,想必各位讀者都很想了解一下 Go 編譯器的內聯優化了。別急,在這一篇文章中,我就和大家一起來學習和理解一下 Go 編譯器的內聯優化。希望通過本文的學習,能讓大家掌握如下內容:

下面我們就先來了解一下什麼是內聯優化。


1. 什麼是編譯器的內聯優化

內聯 (inlining)[3] 是編程語言編譯器常用的優化手段,其優化的對象爲函數,也稱爲函數內聯。如果某函數 F 支持內聯,則意味着編譯器可以用 F 的函數體 / 函數定義替換掉對函數 F 進行調用的代碼,以消除函數調用帶來的額外開銷,這個過程如下圖所示:

我們知道 Go 從 1.17 版本 [4] 才改爲基於寄存器的調用規約 [5],之前的版本一直是基於棧傳遞參數與返回值,函數調用的開銷更大,在這樣的情況下,內聯優化的效果也就更爲顯著。

除此之外,內聯優化之後,編譯器的優化決策可以不侷限在每個單獨的函數 (比如上圖中的函數 g) 上下文中做出,而是可以在函數調用鏈上做出了 (內聯替換後,代碼變得更平(flat) 了)。比如上圖中對 g 後續執行的優化將不侷限在 g 上下文,由於 f 的內聯,讓編譯器可以在 g->f 這個調用鏈的上下文上決策後續要執行的優化手段,即內聯讓編譯器可以看得更廣更遠了

我們來看一個簡單的例子:

// github.com/bigwhite/experiments/tree/master/inlining-optimisations/add/add.go

//go:noinline
func add(a, b int) int {
    return a + b
}

func main() {
    var a, b = 5, 6
    c := add(a, b)
    println(c)
}

這個例子中,我們的關注點是 add 函數,在 add 函數定義上方,我們用 //go:noinline 告知編譯器對 add 函數關閉 inline,我們構建該程序,得到可執行文件:add-without-inline;然後去掉 //go:noinline 這一行,再進行一次程序構建,得到可執行文件 add,我們用 lensm 工具 [6] 以圖形化的方式查看一下這兩個可執行文件的彙編代碼,並做以下對比:

我們看到:非內聯優化的版本 add-without-inline 如我們預期那樣,在 main 函數中通過 CALL 指令調用了 add 函數;但在內聯優化版本中,add 函數的函數體並沒有替換掉 main 函數中調用 add 函數位置上的代碼,main 函數調用 add 函數的位置上對應的是一個 NOPL 的彙編指令,這是一條不執行任何操作的空指令。那麼 add 函數實現的彙編代碼哪去了呢?

// add函數實現的彙編代碼
ADDQ BX, AX
RET

結論是:被優化掉了!這就是前面說的內聯爲後續的優化提供更多的機會。add 函數調用被替換爲 add 函數的實現後,Go 編譯器直接可以確定調用結果爲 11,於是連加法運算都省略了,直接將 add 函數的結果換成了一個常數 11(0xb),然後直接將常量 11 傳給了 println 內置函數 (MOVL 0xb, AX)。

通過一個簡單的 benchmark,也可以看出內聯與非內聯 add 的性能差異:

// 開啓內聯優化
$go test -bench .
goos: darwin
goarch: amd64
pkg: github.com/bigwhite/experiments/inlining-optimisations/add
BenchmarkAdd-8    1000000000          0.2720 ns/op
PASS
ok   github.com/bigwhite/experiments/inlining-optimisations/add 0.307s

// 關閉內聯優化
$go test -bench .
goos: darwin
goarch: amd64
pkg: github.com/bigwhite/experiments/inlining-optimisations/add
BenchmarkAdd-8    818820634          1.357 ns/op
PASS
ok   github.com/bigwhite/experiments/inlining-optimisations/add 1.268s

我們看到:內聯版本是非內聯版本性能的 5 倍左右

到這裏,很多朋友可能會問:既然內聯優化的效果這麼好,爲什麼不將 Go 程序內部的所有函數都內聯了,這樣整個 Go 程序就變成了一個大函數,中間再沒有任何函數調用了,這樣性能是不是可以變得更高呢?雖然理論上可能是這種情況,但內聯優化不是沒有開銷的,並且針對不同複雜性的函數,內聯的效果也是不同的。下面我就和大家一起先來看看內聯優化的開銷!

2. 內聯優化的 “開銷”

在真正理解內聯優化的開銷之前,我們先來看看內聯優化在 Go 編譯過程中的位置,即處於哪個環節。

Go 編譯過程

和所有靜態語言編譯器一樣,Go 編譯過程大致分爲如下幾個階段:

Go 團隊並沒有刻意將 Go 編譯過程分爲我們常識中的前後端,如果非要這麼分,源碼分析 (包括詞法和語法分析)、類型檢查和中間表示(Intermediate Representation) 構建可以歸爲邏輯上的編譯前端,後面的其餘環節都劃歸爲後端。

源碼分析形成抽象語法樹,然後是基於抽象語法樹的類型檢查,待類型檢查通過後,Go 編譯器將 AST 轉換爲一個與目標平臺無關的中間代碼表示。

目前 Go 有兩種 IR 實現方式,一種是 irgen(又名 "-G=3" 或是 "noder2"),irgen 是從 Go 1.18 版本 [7] 開始使用的實現 (這也是一種類似 AST 的結構);另外一種是 unified IR,在 Go 1.19 版本 [8] 中,我們可以使用 GOEXPERIMENT=unified 啓用它,根據最新消息,unified IR 將在 Go 1.20 版本落地。

注:現代編程語言編譯過程多數會多次生成中間代碼 (IR),比如下面要提到的靜態單賦值形式(SSA) 也是一種 IR 形式。針對每種 IR,編譯器都會有一些優化動作:

圖:編譯優化過程 (圖來自 https://www.slideserve.com/heidi-farmer/ssa-static-single-assignment-form)

編譯後端的第一步是一個被 Go 團隊稱爲中端 (middle end) 的環節,在這個環節中,Go 編譯器將基於上面的中間代碼進行多輪 (pass) 的優化,包括死代碼消除、內聯優化、方法調用實體化 (devirtualization) 和逃逸分析等。

注:devirtualization 是指將通過接口變量調用的方法轉換爲接口的動態類型變量直接調用該方法,消除通過接口進行方法表查找的過程。

接下來是中間代碼遍歷 (walk),這個環節是基於上述 IR 表示的最後一輪優化,它主要是將複雜的語句分解成單獨的、更簡單的語句,引入臨時變量並重新評估執行順序,同時在這個環節,它還會將一些高層次的 Go 結構轉換爲更底層、更基礎的操作結構,比如將 switch 語句轉換爲二分查找或跳錶,將對 map 和 channel 的操作替換爲運行時的調用(如 mapaccess) 等。

接下來是編譯後端的最後兩個環節,首先是將 IR 轉換爲 SSA(靜態單一賦值) 形式,並再次基於 SSA 做多輪優化,最後針對目標架構,基於 SSA 的最終形式生成機器相關的彙編指令,然後交給彙編器生成可重定位的目標機器碼。

注: 編譯器 (go compiler) 產生的可重定位的目標機器碼最終提供給鏈接器 (linker) 生成可執行文件。

我們看到 Go 內聯發生在中端環節,是基於 IR 中間代碼的一種優化手段,在 IR 層面上實現函數是否可內聯的決策,以及對可內聯函數在其調用處的函數體替換

一旦瞭解了 Go 內聯所處環節,我們就能大致判斷出 Go 內聯優化帶來的開銷了。

Go 內聯優化的開銷

我們用一個實例來看一下 Go 內聯優化的開銷。reviewdog[9] 是一個純 Go 實現的支持 github、gitlab 等主流代碼託管平臺的代碼評審工具,它的規模大約有 12k 行 (使用 loccount[10] 統計):

// reviewdog代碼行數統計結果:

$loccount .
all          SLOC=14903   (100.00%) LLOC=4613    in 141 files
Go           SLOC=12456   (83.58%) LLOC=4584    in 106 files
... ...

我們在開啓內聯優化和關閉內聯優化的情況下分別對 reviewdog 進行構建,採集其構建時間與構建出的二進制文件的 size,結果如下:

// 開啓內聯優化(默認)
$time go build -o reviewdog-inline -a github.com/reviewdog/reviewdog/cmd/reviewdog
go build -o reviewdog-inline -a github.com/reviewdog/reviewdog/cmd/reviewdog  53.87s user 9.55s system 567% cpu 11.181 total

// 關閉內聯優化
$time go build -o reviewdog-noinline -gcflags=all="-l" -a github.com/reviewdog/reviewdog/cmd/reviewdog
go build -o reviewdog-noinline -gcflags=all="-l" -a   43.25s user 8.09s system 566% cpu 9.069 total

$ ls -l
-rwxrwxr-x  1 tonybai tonybai 23080429 Oct 13 12:05 reviewdog-inline*
-rwxrwxr-x  1 tonybai tonybai 20745006 Oct 13 12:04 reviewdog-noinline*
... ...

我們看到開啓內聯優化的版本,其編譯消耗時間比關閉內聯優化版本的編譯時間多出 24% 左右,並且生成的二進制文件 size 要大出 11% 左右 - 這就是內聯優化帶來的開銷!即會拖慢編譯器並導致生成的二進制文件 size 變大。

注:hello world 級別的程序是否開啓內聯優化大多數情況是看不出來太多差異的,無論是編譯時間,還是二進制文件的 size。

由於我們知道了內聯優化所處的環節,因此這種開銷就可以很好地給予解釋:根據內聯優化的定義,一旦某個函數被決策爲可內聯,那麼程序中所有調用該函數的位置的代碼就會被替換爲該函數的實現,從而消除掉函數調用帶來的運行時開銷,同時這也導致了在 IR(中間代碼)層面出現一定的代碼 “膨脹”。前面也說過,代碼膨脹後的“副作用” 是編譯器可以以更廣更遠的視角看待代碼,從而可能實施的優化手段會更多。可實施的優化輪次越多,編譯器執行的就越慢,這進一步增加了編譯器的耗時;同時膨脹的代碼讓編譯器需要在後面環節處理和生成更多代碼,不僅增加耗時,還增加了最終二進制文件的 size。

Go 向來對編譯速度和 binary size 較爲敏感,所以 Go 採用了相對保守的內聯優化策略。那麼到底 Go 編譯器是如何決策一個函數是否可以內聯呢?下面我們就來簡單看看 Go 編譯器是如何決策哪些函數可以實施內聯優化的。

3. 函數內聯的決策原理

前面說過,內聯優化是編譯中端多輪 (pass) 優化中的一輪,因此它的邏輯相對獨立,它基於 IR 代碼進行,改變的也是 IR 代碼。我們可以在 Go 源碼的 $GOROOT/src/cmd/compile/internal/inline/inl.go 中找到 Go 編譯器進行內聯優化的主要代碼。

注:Go 編譯器內聯優化部分的代碼的位置和邏輯在以前的版本以及在未來的版本中可能有變化,目前本文提到的是代碼是 Go 1.19.1 中的源碼。

內聯優化 IR 優化環節會做兩件事:第一遍歷 IR 中所有函數,通過 CanInline 判斷某個函數是否可以內聯,對於可內聯的函數,保存相應信息,比如函數 body 等,供後續做內聯函數替換使用;第二呢,則是對函數中調用的所有內聯函數進行替換。 我們重點關注 CanInline,即 Go 編譯器究竟是如何決策一個函數是否可以內聯的

內聯優化過程的 “驅動邏輯” 在 $GOROOT/src/cmd/compile/internal/gc/main.go 的 Main 函數中:

// $GOROOT/src/cmd/compile/internal/gc/main.go
func Main(archInit func(*ssagen.ArchInfo)) {
    base.Timer.Start("fe""init")

    defer handlePanic()

    archInit(&ssagen.Arch)
    ... ...

    // Enable inlining (after RecordFlags, to avoid recording the rewritten -l).  For now:
    //  default: inlining on.  (Flag.LowerL == 1)
    //  -l: inlining off  (Flag.LowerL == 0)
    //  -l=2, -l=3: inlining on again, with extra debugging (Flag.LowerL > 1)
    if base.Flag.LowerL <= 1 {
        base.Flag.LowerL = 1 - base.Flag.LowerL
    }
    ... ...

    // Inlining
    base.Timer.Start("fe""inlining")
    if base.Flag.LowerL != 0 {
        inline.InlinePackage()
    }
    noder.MakeWrappers(typecheck.Target) // must happen after inlining
    ... ...
}

從代碼中我們看到:如果沒有全局關閉內聯優化 (base.Flag.LowerL != 0),那麼 Main 就會調用 inline 包的 InlinePackage 函數執行內聯優化。InlinePackage 的代碼如下:

// $GOROOT/src/cmd/compile/internal/inline/inl.go
func InlinePackage() {
    ir.VisitFuncsBottomUp(typecheck.Target.Decls, func(list []*ir.Func, recursive bool) {
        numfns := numNonClosures(list)
        for _, n := range list {
            if !recursive || numfns > 1 {
                // We allow inlining if there is no
                // recursion, or the recursion cycle is
                // across more than one function.
                CanInline(n)
            } else {
                if base.Flag.LowerM > 1 {
                    fmt.Printf("%v: cannot inline %v: recursive\n", ir.Line(n), n.Nname)
                }
            }
            InlineCalls(n)
        }
    })
}

InlinePackage 遍歷每個頂層聲明的函數,對於非遞歸函數或遞歸前跨越一個以上函數的遞歸函數,通過調用 CanInline 函數判斷其是否可以內聯,無論是否可以內聯,接下來都會調用 InlineCalls 函數對其函數定義中調用的內聯函數進行替換。

VisitFuncsBottomUp 是根據函數調用圖從底向上遍歷的,這樣可以保證每次在調用 analyze 時,列表中的每個函數都只調用列表中的其他函數,或者是在之前的調用中已經 analyze 過 (在這裏就是被內聯函數體替換過) 的函數。

什麼是遞歸前跨越一個以上函數的遞歸函數,看下面這個例子就懂了:

// github.com/bigwhite/experiments/tree/master/inlining-optimisations/recursion/recursion1.go
func main() {
    f(100)
}

func f(x int) {
    if x < 0 {
        return
    }
    g(x - 1)
}
func g(x int) {
    h(x - 1)
}
func h(x int) {
    f(x - 1)
}

f 是一個遞歸函數,但並非自己調用自己,而是通過 g -> h 這個函數鏈最終又調回自己,而這個函數鏈長度 > 1,所以 f 是可以內聯的:

$go build -gcflags '-m=2'  recursion1.go
./recursion1.go:7:6: can inline f with cost 67 as: func(int) { if x < 0 { return  }; g(x - 1) }

我們繼續看 CanInline 函數。CanInline 函數有 100 多行代碼,其主要邏輯分爲三個部分。

首先是對一些 //go:xxx 指示符 (directive) 的判定,當該函數包含下面指示符時,則該函數不能內聯:

其次會對該函數的狀態做判定,比如如果函數體爲空,則不能內聯;如果未做類型檢查 (typecheck),則不能內聯等。

最後調用 visitor.tooHairy 對函數的複雜性做判定。判定方法就是先爲此次遍歷 (visitor) 設置一個初始最大預算(budget),這個初始最大預算值爲一個常量(inlineMaxBudget),目前其值爲 80:

// $GOROOT/src/cmd/compile/internal/inline/inl.go
const (
    inlineMaxBudget       = 80
)

然後在 visitor.tooHairy 函數中遍歷該函數實現中的各個語法元素:

// $GOROOT/src/cmd/compile/internal/inline/inl.go
func CanInline(fn *ir.Func) {
    ... ...
    visitor := hairyVisitor{
        budget:        inlineMaxBudget,
        extraCallCost: cc,
    }
    if visitor.tooHairy(fn) {
        reason = visitor.reason
        return
    }
    ... ...
}

不同元素對預算的消耗都有不同,比如調用一次 append,visitor 預算值就要減去 inlineExtraAppendCost,再比如如果該函數是中間函數 (而非葉子函數),那麼 visitor 預算值也要減去 v.extraCallCost,即 57。就這樣一路下來,如果預算被用光,即 v.budget < 0,則說明這個函數過於複雜,不能被內聯;相反,如果一路下來,預算依然有,那麼說明這個函數相對簡單,可以被內聯優化。

注:爲什麼 inlineExtraCallCost 的值是 57?這是一個經驗值,是通過一個 benchmark 得出來的 [11]。

一旦確定可以被內聯,那麼 Go 編譯器就會將一些信息保存下來,保存到 IR 中該函數節點的 Inl 字段中:

// $GOROOT/src/cmd/compile/internal/inline/inl.go
func CanInline(fn *ir.Func) {
    ... ...
    n.Func.Inl = &ir.Inline{
        Cost: inlineMaxBudget - visitor.budget,
        Dcl:  pruneUnusedAutos(n.Defn.(*ir.Func).Dcl, &visitor),
        Body: inlcopylist(fn.Body),

        CanDelayResults: canDelayResults(fn),
    }
    ... ...
}

Go 編譯器設置 budget 值爲 80,顯然是不想讓過於複雜的函數被內聯優化,這是爲什麼呢?主要是權衡內聯優化帶來的收益與其開銷。讓更復雜的函數內聯,開銷會增大,但收益卻可能不會有明顯增加,即所謂的 “投入產出比” 不足。

從上面的原理描述可知,對那些 size 不大 (複雜性較低)、被反覆調用的函數施以內聯的效果可能更好。而對於那些過於複雜的函數,函數調用的開銷佔其執行開銷的比重已經十分小了,甚至可忽略不計,這樣內聯效果就會較差。

很多人會說:內聯後不是還有更多編譯器優化機會麼?問題在於究竟是否有優化機會以及會實施哪些更多的優化,這是無法預測的事情。

4. 對 Go 編譯器的內聯優化進行干預

最後我們再來看看如何對 Go 編譯器的內聯優化進行干預。Go 編譯器默認是開啓全局內聯優化的,並按照上面 inl.go 中 CanInline 的決策流程來確定一個函數是否可以內聯。

不過 Go 也給了我們控制內聯的一些手段,比如我們可以在某個函數上顯式告知編譯器不要對該函數進行內聯,我們以上面示例中的 add.go 爲例:

//go:noinline
func add(a, b int) int {
    return a + b
}

通過 //go:noinline 指示符,我們可以禁止對 add 的內聯:

$go build -gcflags '-m=2' add.go
./add.go:4:6: cannot inline add: marked go:noinline

注:禁止某個函數內聯不會影響 InlineCalls 函數對該函數內部調用的內聯函數的函數體替換。

我們也可以在更大範圍關閉內聯優化,藉助 - gcflags '-l'選項,我們可以在全局範圍關閉優化,即 Flag.LowerL == 0,Go 編譯器的 InlinePackage 將不會執行。

我們以前面提到過的 reviewdog 來驗證一下:

// 默認開啓內聯
$go build -o reviewdog-inline github.com/reviewdog/reviewdog/cmd/reviewdog

// 關閉內聯
$go build -o reviewdog-noinline -gcflags '-l' github.com/reviewdog/reviewdog/cmd/reviewdog

之後我們查看一下生成的 binary 文件 size:

$ls -l |grep reviewdog
-rwxrwxr-x  1 tonybai tonybai 23080346 Oct 13 20:28 reviewdog-inline*
-rwxrwxr-x  1 tonybai tonybai 23087867 Oct 13 20:28 reviewdog-noinline*

我們發現 noinline 版本居然比 inline 版本的 size 還要略大!這是爲什麼呢?這與 - gcflags 參數的傳遞方式有關,如果只是像上面命令行那樣傳入 - gcflags '-l',關閉內聯僅適用於當前 package,即 cmd/reviewdog,而該 package 的依賴等都不會受到影響。-gcflags 支持 pattern 匹配:

-gcflags '[pattern=]arg list'
 arguments to pass on each go tool compile invocation.

我們可以通過設置不同 pattern 來匹配更多包,比如 all 這個模式就可以包括當前包的所有依賴,我們再來試試:

$go build -o reviewdog-noinline-all -gcflags='all=-l' github.com/reviewdog/reviewdog/cmd/reviewdog
$ls -l |grep reviewdog
-rw-rw-r--  1 tonybai tonybai     3154 Sep  2 10:56 reviewdog.go
-rwxrwxr-x  1 tonybai tonybai 23080346 Oct 13 20:28 reviewdog-inline*
-rwxrwxr-x  1 tonybai tonybai 23087867 Oct 13 20:28 reviewdog-noinline*
-rwxrwxr-x  1 tonybai tonybai 20745006 Oct 13 20:30 reviewdog-noinline-all*

這回我們看到 reviewdog-noinline-all 要比 reviewdog-inline 的 size 小了不少,這是因爲 all 將 reviewdog 依賴的各個包的內聯也都關閉了。

5. 小結

在這篇文章中,我帶大家一起了解了 Go 內聯相關的知識,包括內聯的概念、內聯的作用、內聯優化的 “開銷” 以及 Go 編譯器進行函數內聯決策的原理,最後我還給出控制 Go 編譯器內聯優化的手段。

內聯優化是一種重要的優化手段,使用得當將會給你的系統帶來不小的性能改善。Go 編譯器組也在對 Go 內聯優化做持續改善,從之前僅支持葉子函數的內聯,到現在支持非葉子節點函數的內聯,相信 Go 開發者在未來還會繼續得到這方面帶來的性能紅利。

本文涉及的源碼可以在這裏 [12] 下載。

6. 參考資料


我的聯繫方式:

參考資料

[1] 

GCC: http://gcc.gnu.org

[2] 

llvm: https://llvm.org

[3] 

內聯 (inlining): http://en.wikipedia.org/wiki/Inline_expansion

[4] 

1.17 版本: https://tonybai.com/2021/08/17/some-changes-in-go-1-17

[5] 

基於寄存器的調用規約: https://tonybai.com/2021/08/20/using-register-based-calling-convention-in-go-1-17/

[6] 

lensm 工具: https://github.com/loov/lensm

[7] 

Go 1.18 版本: https://tonybai.com/2022/04/20/some-changes-in-go-1-18

[8] 

Go 1.19 版本: https://tonybai.com/2022/08/22/some-changes-in-go-1-19

[9] 

reviewdog: https://tonybai.com/2022/09/08/make-reviewdog-support-gitlab-push-commit-to-preserve-the-code-quality-floor

[10] 

loccount: https://gitlab.com/esr/loccount

[11] 

通過一個 benchmark 得出來的: https://github.com/golang/go/issues/19348#issuecomment-439370742

[12] 

這裏: https://github.com/bigwhite/experiments/tree/master/inlining-optimisations

[13] 

“Gopher 部落” 知識星球: https://wx.zsxq.com/dweb2/index/group/51284458844544

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/WfjnwzZvBsymk_DyhAV_6g