一篇文章把 Go 內聯優化講明白了

爲了保證程序的執行高效與安全,現代編譯器並不會將程序員的代碼直接翻譯成相應地機器碼,它需要做一系列的檢查與優化。Go 編譯器默認做了很多相關工作,例如未使用的引用包檢查、未使用的聲明變量檢查、有效的括號檢查、逃逸分析、內聯優化、刪除無用代碼等。本文重點討論內聯優化相關內容。

內聯

《詳解逃逸分析》一文中,我們分析了棧分配內存會比堆分配高效地多,那麼,我們就會希望對象能儘可能被分配在棧上。在 Go 中,一個 goroutine 會有一個單獨的棧,棧又會包含多個棧幀,棧幀是函數調用時在棧上爲函數所分配的區域。但其實,函數調用是存在一些固定開銷的,例如維護幀指針寄存器 BP、棧溢出檢測等。因此,對於一些代碼行比較少的函數,編譯器傾向於將它們在編譯期展開從而消除函數調用,這種行爲就是內聯。

性能對比

首先,看一下函數內聯與非內聯的性能差異。

 1 1//go:noinline
 2 2func maxNoinline(a, b int) int {
 3 3    if a < b {
 4 4        return b
 5 5    }
 6 6    return a
 7 7}
 8 8
 9 9func maxInline(a, b int) int {
1010    if a < b {
1111        return b
1212    }
1313    return a
1414}
1515
1616func BenchmarkNoInline(b *testing.B) {
1717    x, y := 1, 2
1818    b.ResetTimer()
1919    for i := 0; i < b.N; i++ {
2020        maxNoinline(x, y)
2121    }
2222}
2323
2424func BenchmarkInline(b *testing.B) {
2525    x, y := 1, 2
2626    b.ResetTimer()
2727    for i := 0; i < b.N; i++ {
2828        maxInline(x, y)
2929    }
3030}
31
32

在程序代碼中,想要禁止編譯器內聯優化很簡單,在函數定義前一行添加//go:noinline即可。以下是性能對比結果

11BenchmarkNoInline-8     824031799                1.47 ns/op
22BenchmarkInline-8       1000000000               0.255 ns/op
3
4

因爲函數體內部的執行邏輯非常簡單,此時內聯與否的性能差異主要體現在函數調用的固定開銷上。顯而易見,該差異是非常大的。

內聯場景

此時,愛思考的讀者可能就會產生疑問:既然內聯優化效果這麼顯著,是不是所有的函數調用都可以內聯呢?答案是不可以。因爲內聯,其實就是將一個函數調用原地展開,替換成這個函數的實現。當該函數被多次調用,就會被多次展開,這會增加編譯後二進制文件的大小。而非內聯函數,只需要保存一份函數體的代碼,然後進行調用。所以,在空間上,一般來說使用內聯函數會導致生成的可執行文件變大(但需要考慮內聯的代碼量、調用次數、維護內聯關係的開銷)。

問題來了,編譯器內聯優化的選擇策略是什麼?

 1 1package main
 2 2
 3 3func add(a, b int) int {
 4 4    return a + b
 5 5}
 6 6
 7 7func iter(num int) int {
 8 8    res := 1
 9 9    for i := 1; i <= num; i++ {
1010        res = add(res, i)
1111    }
1212    return res
1313}
1414
1515func main() {
1616    n := 100
1717    _ = iter(n)
1818}
19
20

假設源碼文件爲main.go,可通過執行go build -gcflags="-m -m" main.go命令查看編譯器的優化策略。

11$ go build -gcflags="-m -m" main.go
22# command-line-arguments
33./main.go:3:6: can inline add with cost 4 as: func(int, int) int { return a + b }
44./main.go:7:6: cannot inline iter: unhandled op FOR
55./main.go:10:12: inlining call to add func(int, int) int { return a + b }
66./main.go:15:6: can inline main with cost 67 as: func() { n := 100; _ = iter(n) }
7

通過以上信息,可知編譯器判斷add函數與main函數都可以被內聯優化,並將add函數內聯。同時可以注意到的是,iter函數由於存在循環語句並不能被內聯:cannot inline iter: unhandled op FOR。實際上,除了for循環,還有一些情況不會被內聯,例如閉包,selectfordefergo關鍵字所開啓的新 goroutine 等,詳細可見src/cmd/compile/internal/gc/inl.go相關內容。

 1 1    case OCLOSURE,
 2 2        OCALLPART,
 3 3        ORANGE,
 4 4        OFOR,
 5 5        OFORUNTIL,
 6 6        OSELECT,
 7 7        OTYPESW,
 8 8        OGO,
 9 9        ODEFER,
1010        ODCLTYPE, // can't print yet
1111        OBREAK,
1212        ORETJMP:
1313        v.reason = "unhandled op " + n.Op.String()
1414        return true
15
16

在上文提到過,內聯只針對小代碼量的函數而言,那麼到底是小於多少纔算是小代碼量呢?

此時,我將上面的add函數,更改爲如下內容

11func add(a, b int) int {
22    a = a + 1
33    return a + b
44}
5
6

執行go build -gcflags="-m -m" main.go命令,得到信息

11./main.go:3:6: can inline add with cost 9 as: func(int, int) int { a = a + 1; return a + b }
2
3

對比之前的信息

11./main.go:3:6: can inline add with cost 4 as: func(int, int) int { return a + b }
2
3

可以發現,存在cost 4cost 9的區別。這裏的數值代表的是抽象語法樹 AST 的節點,a = a + 1包含的是 5 個節點。Go 函數中超過 80 個節點的代碼量就不再內聯。例如,如果在add中寫入 16 個a = a + 1,則不再內聯。

11./main.go:3:6: cannot inline add: function too complex: cost 84 exceeds budget 80
2
3
內聯表

內聯會將函數調用的過程抹掉,這會引入一個新的問題:代碼的堆棧信息還能否保證。舉個例子,如果程序發生 panic,內聯之後的程序,還能否準確的打印出堆棧信息?看以下例子。

 1 1package main
 2 2
 3 3func sub(a, b int) {
 4 4    a = a - b
 5 5    panic("i am a panic information")
 6 6}
 7 7
 8 8func max(a, b int) int {
 9 9    if a < b {
1010        sub(a, b)
1111    }
1212    return a
1313}
1414
1515func main() {
1616    x, y := 1, 2
1717    _ = max(x, y)
1818}
19
20

在該代碼樣例中,max函數將被內聯。執行程序,輸出結果如下

 11panic: i am a panic information
 22
 33goroutine 1 [running]:
 44main.sub(...)
 55        /Users/slp/go/src/workspace/example/main.go:5
 66main.max(...)
 77        /Users/slp/go/src/workspace/example/main.go:10
 88main.main()
 99        /Users/slp/go/src/workspace/example/main.go:17 +0x3a
10
11

可以發現,panic 依然輸出了正確的程序堆棧信息,包括源文件位置和行號信息。那,Go 是如何做到的呢?

這是由於 Go 內部會爲每個存在內聯優化的 goroutine 維持一個內聯樹(inlining tree),該樹可通過 go build -gcflags="-d pctab=pctoinline" main.go 命令查看

 1 1funcpctab "".sub [valfunc=pctoinline]
 2 2...
 3 3wrote 3 bytes to 0xc000082668
 4 4 00 42 00
 5 5funcpctab "".max [valfunc=pctoinline]
 6 6...
 7 7wrote 7 bytes to 0xc000082f68
 8 8 00 3c 02 1d 01 09 00
 9 9-- inlining tree for "".max:
10100 | -1 | "".sub (/Users/slp/go/src/workspace/example/main.go:10:6) pc=59
1111--
1212funcpctab "".main [valfunc=pctoinline]
1313...
1414wrote 11 bytes to 0xc0004807e8
1515 00 1d 02 01 01 07 04 16 03 0c 00
1616-- inlining tree for "".main:
17170 | -1 | "".max (/Users/slp/go/src/workspace/example/main.go:17:9) pc=30
18181 | 0 | "".sub (/Users/slp/go/src/workspace/example/main.go:10:6) pc=29
1919--
20
21
內聯控制

Go 程序編譯時,默認將進行內聯優化。我們可通過-gcflags="-l"選項全局禁用內聯,與一個-l禁用內聯相反,如果傳遞兩個或兩個以上的-l則會打開內聯,並啓用更激進的內聯策略。如果不想全局範圍內禁止優化,則可以在函數定義時添加 //go:noinline 編譯指令來阻止編譯器內聯函數。

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