Go 編譯器優化
死代碼消除
死代碼消除( dead code elimination, 縮寫 DCE )是用來移除對程序執行結果沒有任何影響的代碼,以此 減少程序的體積大小 ,並且還可以避免程序在執行過程中進行一些不必要的運算行爲,從而 減少執行時間 。
需要注意的是,除了不會執行到的代碼( unreachable code ),一些只會影響到無關程序執行結果的變量( dead variables ),也屬於死碼( dead code )的範疇。
簡單示例:
package main
func main() {
const a, b = 200, 100
var max int
if a > b {
max = a
} else {
max = b
}
if max == b {
panic(b)
}
}
對於常量 a
和 b
,編譯器在編譯時可以判斷出 a
永遠是大於 b
的,即 a > b
永遠爲 true
,也就是說 else {}
分支屬於 unreachable code 將永遠不會被執行,所以編譯器會進行第一次優化:分支消除
package main
func main() {
const a, b = 200, 100
const max = a
if max == b {
panic(b)
}
}
由於 max
變量後續沒有再被引用,所以 max
實際也是一個常量。相同道理,max == b
永遠爲 false
,編譯器會進行第二次分支消除優化:
package main
func main() {
const a, b = 200, 100
const max = a
}
對於剩下的常量則明顯屬於 dead variables ,再次優化:
package main
func main() {
}
我們可以查看最初程序的 SSA 生成過程來驗證:
$ GOSSAFUNC=main go build main.go
查看生成的 ssa.html
:
死代碼消除過程
最終生成的 SSA
可以看到,main 函數內的所有邏輯確實都被編譯器優化掉了。
函數內聯
如果程序中存在大量的小函數的調用,函數內聯(function call inlining)就會直接用函數體替換掉函數調用來 減少因爲函數調用而造成的額外上下文切換開銷 。
簡單示例:
package main
func main() {
n := 1
for i := 0; i < 10; i++ {
n = double(n)
}
println(n)
}
func double(n int) int {
return 2 * n
}
對於上面的代碼,編譯器內聯優化後會變成:
package main
func main() {
n := 1
for i := 0; i < 10; i++ {
n = 2 * n
}
println(n)
}
Go 編譯器會計算函數內聯所花費的成本,只有滿足相關策略時纔會進行內聯優化,最簡單的當函數內有 go
、defer
、select
等關鍵字時就不會發生內聯,具體的策略可以直接查看源碼:
內聯優化相關源碼
使用 go tool compile -m=2 main.go
或 go build -gcflags="-m -m" main.go
可以輸出內聯優化的相關信息( -m 的數量越多輸出結果越詳細)
$ go tool compile -m=2 main.go
main.go:11:6: can inline double with cost 4 as: func(int) int { return 2 * n }
main.go:3:6: can inline main with cost 28 as: func() { n := 1; for loop; println(n) }
main.go:6:13: inlining call to double
或者也可以輸出彙編代碼查看是否有進行 double
函數的調用,這裏顯然是沒有的:
$ go tool compile -S main.go | grep CALL.*double
如果我們不想一個函數被內聯,可以直接在其函數定義時加一個 //go:noinline
註釋:
//go:noinline
func double(n int) int {
return 2 * n
}
同樣可以進行驗證:
$ go tool compile -S main.go | grep CALL.*double
0x0025 00037 (main.go:6) CALL "".double(SB)
$ go tool compile -m=2 main.go
main.go:12:6: cannot inline double: marked go:noinline
main.go:3:6: cannot inline main: function too complex: cost 81 exceeds budget 80
可以看到此時還輸出了函數無法內聯的原因。
如果希望所有函數都不執行內聯操作,可以直接爲編譯器選項加上 -l
參數,即 go build -gcflags="-l" main.go
(如果 -l
數量大於等於 2 ,編譯器將會採用更激進的內聯策略,但也可能會生成更大的二進制文件)。
正常情況,我們直接使用編譯器默認選項即可。
逃逸分析
不同於 C 語言的手動內存管理方式(通過 malloc
分配堆內存對象, free
手動釋放),帶有 GC 機制的 Go 語言在編譯階段會進行逃逸分析,自動決定將變量分配到 goroutine 的棧(stack)內存區或者全局的堆(heap)內存區上 。
其中的逃逸規則有很多,最簡單的一種是:如果變量超出了函數調用的生命週期,編譯器就會將其逃逸到堆上。
簡單示例:
package main
func main() {
A()
B()
}
func A() int {
a := 1024
return a
}
func B() *int {
b := 1024
return &b
}
重點關注返回指針類型的 B
函數,通過 go tool compile -l -m=2 main.go
來查看逃逸結果( -l 是全局禁止函數內聯,避免影響逃逸分析):
$ go tool compile -l -m=2 main.go
main.go:14:2: b escapes to heap:
main.go:14:2: flow: ~r0 = &b:
main.go:14:2: from &b (address-of) at main.go:15:9
main.go:14:2: from return &b (return) at main.go:15:2
main.go:14:2: moved to heap: b
根據結果可以看出 B
函數中分配的變量 b
逃逸到了堆上(moved to heap: b),而對於全程在 A
函數生命週期內的 a
變量則沒有發生逃逸(直接在棧上分配了)。
$ go tool compile -S main.go | grep runtime.newobject
0x0020 00032 (main.go:14) CALL runtime.newobject(SB)
rel 33+4 t=7 runtime.newobject+0
從彙編來看,也只有在 main.go:14
(對應源碼:b := 1024
)位置處才調用了 runtime.newobject
函數。
runtime.newobject 源碼
而 runtime.newobject
函數的作用正是執行 malloc
動作 在堆上分配內存 。
在棧上分配內存,將直接由 CPU 提供 push(入棧)和 pop(出棧) 指令支持,但在堆上分配,就需要額外等待 Go GC 負責回收,雖然 Go GC 十分高效,但也不可避免會造成一定的性能損耗。
所以如果想要追求極致性能,我們就要儘量避免一些不必要的堆內存分配。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/eWM9AvG1qXnMWF4qIwhnVQ