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)
 }
}

對於常量 ab ,編譯器在編譯時可以判斷出 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 編譯器會計算函數內聯所花費的成本,只有滿足相關策略時纔會進行內聯優化,最簡單的當函數內有 godeferselect 等關鍵字時就不會發生內聯,具體的策略可以直接查看源碼:

內聯優化相關源碼

使用 go tool compile -m=2 main.gogo 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 &(address-of) at main.go:15:9
main.go:14:2:     from return &(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