為了保證程序的執(zhí)行高效與安全肿仑,現(xiàn)代編譯器并不會(huì)將程序員的代碼直接翻譯成相應(yīng)地機(jī)器碼匾乓,它需要做一系列的檢查與優(yōu)化迅皇。Go編譯器默認(rèn)做了很多相關(guān)工作贷笛,例如未使用的引用包檢查用狱、未使用的聲明變量檢查禽拔、有效的括號檢查埠帕、逃逸分析拔恰、內(nèi)聯(lián)優(yōu)化褪尝、刪除無用代碼等闹获。本文重點(diǎn)討論內(nèi)聯(lián)優(yōu)化相關(guān)內(nèi)容。
內(nèi)聯(lián)
在《詳解逃逸分析》一文中河哑,我們分析了棧分配內(nèi)存會(huì)比堆分配高效地多避诽,那么,我們就會(huì)希望對象能盡可能被分配在棧上璃谨。在Go中沙庐,一個(gè)goroutine會(huì)有一個(gè)單獨(dú)的棧鲤妥,棧又會(huì)包含多個(gè)棧幀,棧幀是函數(shù)調(diào)用時(shí)在棧上為函數(shù)所分配的區(qū)域拱雏。但其實(shí)棉安,函數(shù)調(diào)用是存在一些固定開銷的,例如維護(hù)幀指針寄存器BP铸抑、棧溢出檢測等贡耽。因此,對于一些代碼行比較少的函數(shù)鹊汛,編譯器傾向于將它們在編譯期展開從而消除函數(shù)調(diào)用蒲赂,這種行為就是內(nèi)聯(lián)。
性能對比
首先柒昏,看一下函數(shù)內(nèi)聯(lián)與非內(nèi)聯(lián)的性能差異凳宙。
//go:noinline
func maxNoinline(a, b int) int {
if a < b {
return b
}
return a
}
func maxInline(a, b int) int {
if a < b {
return b
}
return a
}
func BenchmarkNoInline(b *testing.B) {
x, y := 1, 2
b.ResetTimer()
for i := 0; i < b.N; i++ {
maxNoinline(x, y)
}
}
func BenchmarkInline(b *testing.B) {
x, y := 1, 2
b.ResetTimer()
for i := 0; i < b.N; i++ {
maxInline(x, y)
}
}
在程序代碼中,想要禁止編譯器內(nèi)聯(lián)優(yōu)化很簡單职祷,在函數(shù)定義前一行添加//go:noinline
即可氏涩。以下是性能對比結(jié)果
BenchmarkNoInline-8 824031799 1.47 ns/op
BenchmarkInline-8 1000000000 0.255 ns/op
因?yàn)楹瘮?shù)體內(nèi)部的執(zhí)行邏輯非常簡單,此時(shí)內(nèi)聯(lián)與否的性能差異主要體現(xiàn)在函數(shù)調(diào)用的固定開銷上有梆。顯而易見是尖,該差異是非常大的。
內(nèi)聯(lián)場景
此時(shí)泥耀,愛思考的讀者可能就會(huì)產(chǎn)生疑問:既然內(nèi)聯(lián)優(yōu)化效果這么顯著饺汹,是不是所有的函數(shù)調(diào)用都可以內(nèi)聯(lián)呢?答案是不可以痰催。因?yàn)閮?nèi)聯(lián)兜辞,其實(shí)就是將一個(gè)函數(shù)調(diào)用原地展開,替換成這個(gè)函數(shù)的實(shí)現(xiàn)夸溶。當(dāng)該函數(shù)被多次調(diào)用逸吵,就會(huì)被多次展開,這會(huì)增加編譯后二進(jìn)制文件的大小缝裁。而非內(nèi)聯(lián)函數(shù)扫皱,只需要保存一份函數(shù)體的代碼,然后進(jìn)行調(diào)用捷绑。所以韩脑,在空間上,一般來說使用內(nèi)聯(lián)函數(shù)會(huì)導(dǎo)致生成的可執(zhí)行文件變大(但需要考慮內(nèi)聯(lián)的代碼量粹污、調(diào)用次數(shù)段多、維護(hù)內(nèi)聯(lián)關(guān)系的開銷)。
問題來了壮吩,編譯器內(nèi)聯(lián)優(yōu)化的選擇策略是什么进苍?
package main
func add(a, b int) int {
return a + b
}
func iter(num int) int {
res := 1
for i := 1; i <= num; i++ {
res = add(res, i)
}
return res
}
func main() {
n := 100
_ = iter(n)
}
假設(shè)源碼文件為main.go
蕾总,可通過執(zhí)行go build -gcflags="-m -m" main.go
命令查看編譯器的優(yōu)化策略。
$ go build -gcflags="-m -m" main.go
# command-line-arguments
./main.go:3:6: can inline add with cost 4 as: func(int, int) int { return a + b }
./main.go:7:6: cannot inline iter: unhandled op FOR
./main.go:10:12: inlining call to add func(int, int) int { return a + b }
./main.go:15:6: can inline main with cost 67 as: func() { n := 100; _ = iter(n) }
通過以上信息琅捏,可知編譯器判斷add
函數(shù)與main
函數(shù)都可以被內(nèi)聯(lián)優(yōu)化生百,并將add
函數(shù)內(nèi)聯(lián)。同時(shí)可以注意到的是柄延,iter
函數(shù)由于存在循環(huán)語句并不能被內(nèi)聯(lián):cannot inline iter: unhandled op FOR
蚀浆。實(shí)際上,除了for
循環(huán)搜吧,還有一些情況不會(huì)被內(nèi)聯(lián)市俊,例如閉包,select
滤奈,for
摆昧,defer
,go
關(guān)鍵字所開啟的新goroutine等蜒程,詳細(xì)可見src/cmd/compile/internal/gc/inl.go
相關(guān)內(nèi)容绅你。
case OCLOSURE,
OCALLPART,
ORANGE,
OFOR,
OFORUNTIL,
OSELECT,
OTYPESW,
OGO,
ODEFER,
ODCLTYPE, // can't print yet
OBREAK,
ORETJMP:
v.reason = "unhandled op " + n.Op.String()
return true
在上文提到過,內(nèi)聯(lián)只針對小代碼量的函數(shù)而言昭躺,那么到底是小于多少才算是小代碼量呢忌锯?
此時(shí),我將上面的add
函數(shù)领炫,更改為如下內(nèi)容
func add(a, b int) int {
a = a + 1
return a + b
}
執(zhí)行go build -gcflags="-m -m" main.go
命令偶垮,得到信息
./main.go:3:6: can inline add with cost 9 as: func(int, int) int { a = a + 1; return a + b }
對比之前的信息
./main.go:3:6: can inline add with cost 4 as: func(int, int) int { return a + b }
可以發(fā)現(xiàn),存在cost 4
與cost 9
的區(qū)別帝洪。這里的數(shù)值代表的是抽象語法樹AST的節(jié)點(diǎn)似舵,a = a + 1
包含的是5個(gè)節(jié)點(diǎn)。Go函數(shù)中超過80個(gè)節(jié)點(diǎn)的代碼量就不再內(nèi)聯(lián)葱峡。例如砚哗,如果在add
中寫入16個(gè)a = a + 1
,則不再內(nèi)聯(lián)族沃。
./main.go:3:6: cannot inline add: function too complex: cost 84 exceeds budget 80
內(nèi)聯(lián)表
內(nèi)聯(lián)會(huì)將函數(shù)調(diào)用的過程抹掉频祝,這會(huì)引入一個(gè)新的問題:代碼的堆棧信息還能否保證泌参。舉個(gè)例子脆淹,如果程序發(fā)生panic,內(nèi)聯(lián)之后的程序沽一,還能否準(zhǔn)確的打印出堆棧信息盖溺?看以下例子。
package main
func sub(a, b int) {
a = a - b
panic("i am a panic information")
}
func max(a, b int) int {
if a < b {
sub(a, b)
}
return a
}
func main() {
x, y := 1, 2
_ = max(x, y)
}
在該代碼樣例中铣缠,max
函數(shù)將被內(nèi)聯(lián)烘嘱。執(zhí)行程序昆禽,輸出結(jié)果如下
panic: i am a panic information
goroutine 1 [running]:
main.sub(...)
/Users/slp/go/src/workspace/example/main.go:5
main.max(...)
/Users/slp/go/src/workspace/example/main.go:10
main.main()
/Users/slp/go/src/workspace/example/main.go:17 +0x3a
我們可以發(fā)現(xiàn),panic依然輸出了正確的程序堆棧信息蝇庭,包括源文件位置和行號信息醉鳖。那,Go是如何做到的呢哮内?這是由于Go內(nèi)部會(huì)為每個(gè)存在內(nèi)聯(lián)優(yōu)化的goroutine維持一個(gè)內(nèi)聯(lián)樹(inlining tree)盗棵,該樹可通過 go build -gcflags="-d pctab=pctoinline" main.go
命令查看
funcpctab "".sub [valfunc=pctoinline]
...
wrote 3 bytes to 0xc000082668
00 42 00
funcpctab "".max [valfunc=pctoinline]
...
wrote 7 bytes to 0xc000082f68
00 3c 02 1d 01 09 00
-- inlining tree for "".max:
0 | -1 | "".sub (/Users/slp/go/src/workspace/example/main.go:10:6) pc=59
--
funcpctab "".main [valfunc=pctoinline]
...
wrote 11 bytes to 0xc0004807e8
00 1d 02 01 01 07 04 16 03 0c 00
-- inlining tree for "".main:
0 | -1 | "".max (/Users/slp/go/src/workspace/example/main.go:17:9) pc=30
1 | 0 | "".sub (/Users/slp/go/src/workspace/example/main.go:10:6) pc=29
--
內(nèi)聯(lián)控制
Go程序編譯時(shí),默認(rèn)將進(jìn)行內(nèi)聯(lián)優(yōu)化北发。我們可通過-gcflags="-l"
選項(xiàng)全局禁用內(nèi)聯(lián)纹因,與一個(gè)-l
禁用內(nèi)聯(lián)相反,如果傳遞兩個(gè)或兩個(gè)以上的-l
則會(huì)打開內(nèi)聯(lián)琳拨,并啟用更激進(jìn)的內(nèi)聯(lián)策略瞭恰。如果不想全局范圍內(nèi)禁止優(yōu)化,則可以在函數(shù)定義時(shí)添加 //go:noinline
編譯指令來阻止編譯器內(nèi)聯(lián)函數(shù)狱庇。