一篇文章把 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
循環,還有一些情況不會被內聯,例如閉包,select
,for
,defer
,go
關鍵字所開啓的新 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 4
與cost 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