Go 高效能系列教程之三:編譯器優化

yudotyang發表於2021-06-01

這部分主要集中在 Go 編譯器優化方面。其中逃逸分析內聯優化是在編譯器的前端處理的,這時程式碼仍然處於抽象語法樹的形式。然後程式碼被傳給 SSA(static single assignment form)編譯器繼續優化,會經過死程式碼消除邊界檢查消除Nil 檢查消除

3.1 Go 編譯器的歷史

大約在 2007 年,Go 編譯器開始是作為 Plan9 編譯器工具鏈的一個分支。當時的編譯器和 Aho 和 UIIman 的 Dragon Book 極為相似。

在 2015 年,Go 1.5 的編譯器在技術上從 C 轉換到了 Go 語言。

一年以後,Go 1.7 介紹了基於 SSA 技術的新的編譯器後端實現,替換了之前的 Plan9 風格的程式碼生成器。這個新的後端實現介紹了為通用以及特定架構的優化提供了可能。

3.2 逃逸分析

我們首先要介紹的優化技術是逃逸分析

為了演示逃逸分析所做的事情,我們回想下 Go 規範沒有提到的堆或棧。在引言中只涉及到了 Go 語言是基於垃圾回收的,但並沒有提及是怎麼實現的。

Go 規範的相容實現版本可以儲存堆上每次分配的記憶體。這會給垃圾收集器帶來很大的壓力,但這不是錯誤的實現方式。這幾年來,gccgo對逃逸分析的支援非常有限,因此可以認為在這種模式下執行是有效的 (for several years, gccgo had very limited support for escape analysis so could effectively be considered to be operating in this mode)。

然而,goroutine 的用來儲存區域性變數是一個非常簡單有效的地方;因為在函式返回時會自動對棧的資訊進行收集,所以無需在棧上進行垃圾回收。因此,在安全的情況下,更有效的記憶體分配方式是在棧上進行分配。

在一些像 C、C++ 的語言中,在棧上分配記憶體還是堆上分配記憶體是由程式設計師自己決定的 --- 堆記憶體分配通過 malloc 和 free 函式進行管理,棧記憶體的分配是通過 alloca 函式進行的。在程式中錯誤的使用這兩種記憶體分配方式是導致記憶體損壞的常見原因。

在 Go 中,如果變數的生命週期超出了函式的生命週期,編譯器會自動的把變數值從棧記憶體移動到堆記憶體上。我們稱這種機制為變數逃逸到堆上

type Foo struct {
    a, b, c, d int
}

func NewFoo() *Foo {
    return &Foo{a: 3, b: 1, c: 4, d:7}
}

在該示例中,在函式 NewFoo 中分配的 Foo 變數將會被移動到堆上,所以當 NewFoo 返回之後,Foo 的值依然是有效的。

這種情況自 Go 成立以來是一直存在的。與其說是一種自動糾正功能,不如說是一種優化機制。在 Go 中,意外返回棧的記憶體地址是不可能的。

但是,編譯器可以做這樣的事情(譯者注:編譯器可以直接獲取堆疊的記憶體地址)。編譯器可以知道哪些是在堆上分配的,並將它們移動到棧記憶體。

讓我們一起看下下面的例子:

func Sum() int {
    const count = 100
    numbers := make([]int, count)
    for i := range numbers {
        numbers[i] = i + 1
    }

    var sum int
    for _, i := range numbers {
        sum += i
    }
    return sum
}

func main() {
    answer := Sum()
    fmt.Println(answer)
}

Sum 函式的功能是對 int 型別的值求從 1 到 100 的和並返回結果。

由於numbers切片只在函式 Sum 內引用,所以,編譯器將儲存這 100 個 integer 型別的數字在棧記憶體上,而不是堆記憶體上。對於 numbers 變數來說,並不需要進行記憶體回收(GC),噹噹 Sum 函式返回時會自動被回收。

3.2.1 證明

通過在 go build 命令上增加 -m flag 可以列印出編譯器的逃逸分析的資訊。

逃逸分析在 Go 1.13 中已被重寫。這解決了一些長期存在的限制,更不用說在以前的實現中發現的一些有爭議的邊緣問題。

Go 1.13 的直接結果是類似於 1.12 的逃逸分析功能,但是其除錯輸出(不久之後也會看到)已經有所變化

% go build -gcflags=-m examples/esc/sum.go
# command-line-arguments
examples/esc/sum.go:21:13: inlining call to fmt.Println
examples/esc/sum.go:7:17: Sum make([]int, 100) does not escape
examples/esc/sum.go:21:13: answer escapes to heap
examples/esc/sum.go:21:13: main []interface {} literal does not escape
examples/esc/sum.go:21:13: io.Writer(os.Stdout) escapes to heap
<autogenerated>:1: (*File).close .this does not escape

由上面內容可知, 第 7 行中展示了編譯器對 make([] int, 100) 的分配並沒有逃逸到堆記憶體上。

第 21 行中中,answer變數逃逸到了堆記憶體上,原因是 fmt.Println() 是一個可變引數函式。可變參函式的引數被封裝在切片中,在本例中為 [] interface{},因此 answer變數被封裝到了一個 interface 型別的值中,所以這裡是通過 fmt.Println 的引用。由於從 Go 1.6 版本開始的垃圾回收器要求通過介面傳遞的所有值都是指標,因此編譯器看到的大致是這樣的:

var answer = Sum()
fmt.Println([]interface{&answer}...)

我們可以通過使用-gcflags="-m -m" flag 來檢視輸出:

% go build -gcflags='-m -m' examples/esc/sum.go 2>&1 | grep sum.go:21
examples/esc/sum.go:21:13: inlining call to fmt.Println func(...interface {}) (int, error) { var fmt..autotmp_3 int; fmt..autotmp_3 = <N>; var fmt..autotmp_4 error; fmt..autotmp_4 = <N>; fmt..autotmp_3, fmt..autotmp_4 = fmt.Fprintln(io.Writer(os.Stdout), fmt.a...); return fmt..autotmp_3, fmt..autotmp_4 }
examples/esc/sum.go:21:13: answer escapes to heap
examples/esc/sum.go:21:13: main []interface {} literal does not escape
examples/esc/sum.go:21:13: io.Writer(os.Stdout) escapes to heap

總之,不要過於擔心第 21 行的事情,這裡不是那麼重要。

3.2.2 練習與思考

  • 此優化技術是否適用於 count 的所有型別的值?

    如果 count 是 var 變數的時候,是會逃逸到堆記憶體上去的。但如果 count 是常量,則不會逃逸到堆記憶體上去。

  • 如果 count 是變數,而非常量,此優化是否適用?

    如果 count 是變數,是會逃逸到堆記憶體上去的。

  • 如果 count 是 Sum 函式的引數,此優化是否適用? 如果 count 作為 Sum 函式的引數,也會逃逸到堆記憶體上。

3.2.3 逃逸分析(續)

下面是個人為的例子,僅是示例。

type Point struct{ X, Y int }

const Width = 640
const Height = 480

func Center(p *Point) {
    p.X = Width / 2
    p.Y = Height / 2
}

func NewPoint() {
    p := new(Point)
    Center(p)
    fmt.Println(p.X, p.Y)
}

NewPoint 函式建立了一個 *Point 指標型別的變數 p。我們傳遞 p 到 Center 函式。最後我們列印出 p.X 和 p.Y 的值。

% go build -gcflags=-m examples/esc/center.go
# command-line-arguments
examples/esc/center.go:11:6: can inline Center
examples/esc/center.go:18:8: inlining call to Center
examples/esc/center.go:19:13: inlining call to fmt.Println
examples/esc/center.go:11:13: Center p does not escape
examples/esc/center.go:17:10: NewPoint new(Point) does not escape
examples/esc/center.go:19:15: p.X escapes to heap
examples/esc/center.go:19:20: p.Y escapes to heap
examples/esc/center.go:19:13: NewPoint []interface {} literal does not escape
examples/esc/center.go:19:13: io.Writer(os.Stdout) escapes to heap
<autogenerated>:1: (*File).close .this does not escape

即使 p 是通過 new 函式分配的,但它不會被儲存到堆上,因為沒有任何對 p 的引用逃逸到 Center 函式。

3.2.4 逃逸場景

  • 指標逃逸 指標逃逸是指在函式中建立了一個物件,返回了這個物件的指標。這種情況下,函式雖然退出了,但是指標指向物件的記憶體依然會被使用,所以物件的記憶體不能隨著函式結束而回收(這時指標變數本身已經回收),因此只能分配在堆上。
package main

import "fmt"

type Point struct{x, y int}

func NewPoint(x, y int) *Point {
    p := new(Point)
    p.x = x
    p.y = y
    return p
}

func main() {
    p := NewPoint(10, 20)
    fmt.Println(p)
}

在這個例子中,函式 NewPoint 的區域性變數 p 發生了逃逸。p 作為返回值,在 main 函式中繼續使用,因此 p 指向的記憶體不能夠分配在棧上,只能分配在堆上。

通過 -gcflags=-m 檢視變數逃逸情況:

% go build -gcflags='-m' main.go
# command-line-arguments
./main.go:7:6: can inline NewPoint
./main.go:15:15: inlining call to NewPoint
./main.go:16:13: inlining call to fmt.Println
./main.go:8:10: new(Point) escapes to heap
./main.go:15:15: new(Point) escapes to heap
./main.go:16:13: []interface {}{...} does not escape
<autogenerated>:1: .this does not escape

但如果將 NewPoint 的返回值換成是 Point 而非指標,我們來看看是什麼結果。

func NewPoint(x, y int) Point {
    p := new(Point)
    p.x = x
    p.y = y
    return *p
}
% go build -gcflags='-m' main.go
# command-line-arguments
./main.go:7:6: can inline NewPoint
./main.go:15:15: inlining call to NewPoint
./main.go:16:13: inlining call to fmt.Println
./main.go:8:10: new(Point) does not escape
./main.go:15:15: new(Point) does not escape
./main.go:16:13: p escapes to heap
./main.go:16:13: []interface {}{...} does not escape
<autogenerated>:1: .this does not escape

在第 8 行和 15 行顯示 new(Point) does not escape,說明沒有發生記憶體逃逸。

  • interface{}動態型別逃逸 在 Go 語言中,空介面即 interface{} 可以表示任意的型別,如果函式引數為 interface{},編譯期間很難確定其引數的具體的型別,也會發生逃逸。 ```golang package main

import "fmt"

func PrintDemo(d interface{}) { fmt.Println(d) }

func main() { const count = 10 PrintDemo(count) }

在命令列執行 go build -gcflags='-m' main.go
```bash
% go build -gcflags='-m' main.go
./main.go:5:6: can inline PrintDemo
./main.go:6:13: inlining call to fmt.Println
./main.go:9:6: can inline main
./main.go:11:11: inlining call to PrintDemo
./main.go:11:11: inlining call to fmt.Println
./main.go:5:16: leaking param: d
./main.go:6:13: []interface {}{...} does not escape
./main.go:11:11: count escapes to heap
./main.go:11:11: []interface {}{...} does not escape
<autogenerated>:1: .this does not escape

由以上輸出可知,第 11 行的 count 引數逃逸到了 heap 上。

  • 不確定長度大小 我們看上面 Sum 函式的例子。當 count 分別為 var 變數、常量時的情況。 情況一:當 count 為 var 變數時:

    func Sum() int {
    var count = 100
    numbers := make([]int, count)
    for i := range numbers {
        numbers[i] = i + 1
    }
    
    var sum int
    for _, i := range numbers {
        sum += i
    }
    return sum
    }
    

func main() { answer := Sum() fmt.Println(answer) }

在終端輸出結果:
```bash
% go build -gcflags='-m' main.go
# command-line-arguments
./main.go:21:13: inlining call to fmt.Println
./main.go:7:17: make([]int, count) escapes to heap
./main.go:21:13: answer escapes to heap
./main.go:21:13: []interface {}{...} does not escape
<autogenerated>:1: .this does not escape

由輸出結果可知,make([] int, count) escapes to heap 產生了逃逸分析。因為,在編譯期間,make 函式不知道 count 的具體值,所以也會在堆上分配記憶體。

如果我們把 count 換成常量看看如何?

const count = 100

在終端輸出結果:

% go build -gcflags='-m' main.go
# command-line-arguments
./main.go:21:13: inlining call to fmt.Println
./main.go:7:17: make([]int, count) does not escape
./main.go:21:13: answer escapes to heap
./main.go:21:13: []interface {}{...} does not escape
<autogenerated>:1: .this does not escape

由以上結果可知,make([] int, count) does not escape,沒有發生逃逸,因為在編譯器期間,常量的值是確定的。

  • 棧空間不足 作業系統對核心執行緒使用的棧空間是有大小限制的,64 位系統一般為 8MB。在終端下,通過 ulimit -a 命令可以檢視當前機器上棧允許佔用的記憶體的大小。如下: bash sh-3.2# ulimit -a ... stack size (kbytes, -s) 8192 cpu time (seconds, -t) unlimited ... 其中,stack size 之處機器的棧空間最大是 8MB。對於 Go 編譯器來說,超過一定大小的區域性變數將逃逸到堆上 或無法判斷當前切片長度時會將物件分配到堆中。我們看下下面的例子:
func generate8191() {
    numSlice := make([]int, 8191) // < 64KB
    for i := 0; i < 8191; i++ {
        numSlice[i] = i
    }
}

func generate8192() {
        numSlice := make([]int, 8192) // =64KB
    for i := 0; i < 8192; i++ {
        numSlice[i] = i
    }   
}

    func generateN(n int) {
        numSlice := make([]int, n) // 不確定
        for i := 0; i < n; i++ {
            numSlice[i] = i
        }   
}

func main() {
    generate8191()
    generate8192()
    generateN(1)
}
  • generate8191() 建立了大小為 8191 的 int 型切片,恰好小於 64 KB(64 位機器上,int 佔 8 位元組),不包含切片內部欄位佔用的記憶體大小。
  • generate8192() 建立了大小為 8192 的 int 型切片,恰好佔用 64 KB。
  • generate(n),切片大小不確定,呼叫時傳入。

編譯結果如下:

% go build -gcflags='-m' main.go
./main.go:4:6: can inline generate8191
./main.go:11:6: can inline generate8192
./main.go:18:7: can inline generateN
./main.go:25:6: can inline main
./main.go:26:14: inlining call to generate8191
./main.go:27:14: inlining call to generate8192
./main.go:28:11: inlining call to generateN
./main.go:5:18: make([]int, 8191) does not escape
./main.go:12:19: make([]int, 8192) escapes to heap
./main.go:19:19: make([]int, n) escapes to heap
./main.go:26:14: make([]int, 8191) does not escape
./main.go:27:14: make([]int, 8192) escapes to heap
./main.go:28:11: make([]int, n) escapes to heap

make([] int, 8191) 沒有發生逃逸,make([] int, 8192) 和 make([] int, n) 逃逸到堆上,也就是說,當切片佔用記憶體超過一定大小,或無法確定當前切片長度時,物件佔用記憶體將在堆上分配

場景示例參考了極客兔兔的示例,參考來源:https://geektutu.com/post/hpg-escape-analysis.html

  • 閉包函式
package main

import "fmt"

func Fibonacci() func() int {
    a, b := 0, 1
    return func() int {
        a, b = b, a+b
        return a
    }
}

func main() {
    f := Fibonacci()

    for i := 0; i < 10; i++ {
        fmt.Printf("Fibonacci: %d\n", f())
    }
}

Fibonacci() 函式中原本屬於區域性變數的 a 和 b 由於閉包的引用,不得不將二者放到堆上,以致產生逃逸。

$ go build -gcflags=-m
#gitHub/test/pool
./main.go:7:9: can inline Fibonacci.func1
./main.go:7:9: func literal escapes to heap
./main.go:7:9: func literal escapes to heap
./main.go:8:10: &b escapes to heap
./main.go:6:5: moved to heap: b
./main.go:8:13: &a escapes to heap
./main.go:6:2: moved to heap: a
./main.go:17:34: f() escapes to heap
./main.go:17:13: main ... argument does not escape

小結

編譯器決定記憶體分配位置的方式,就稱為逃逸分析。逃逸分析由編譯器完成,作用於編譯階段。

傳值會拷貝整個物件,而傳指標只會拷貝指標地址,指向的物件是同一個。傳指標可以減少值的拷貝,但是會導致記憶體分配逃逸到堆中,增加垃圾回收 (GC) 的負擔。在物件頻繁建立和刪除的場景下,傳遞指標導致的 GC 開銷可能會嚴重影響效能

一般情況下,對於需要修改原物件值,或佔用記憶體比較大的結構體,選擇傳指標。對於只讀的佔用記憶體較小的結構體,直接傳值能夠獲得更好的效能

3.3 內聯

在 Go 中,函式呼叫具有固定的開銷:棧分配和搶佔檢查。

通過硬體預測期可以改善其中的某些功能,但對於功能大小和時鐘週期而言,這仍具有一定的開銷。

內聯是避免這種開銷較經典的優化技術。

直到 Go 1.11 版本,內聯也僅僅是在葉子函式中起作用,即一個函式沒有再呼叫其他函式則成為葉子函式。這樣做的理由是:

  • 如果你的函式做了很多事情,那麼那些固定的開銷可以忽略不計。這就是為什麼函式要達到一定的大小(目前有一些指令,加上一些啊哦做無法阻止所有內容的內聯,例如 Go 1.7 之前的 switch 語句)
  • 另一方面,小函式會為執行了少量功能而付出一定的固定開銷。這就是內聯機制的作用,因為他們會最大程度的減少函式固定開銷。

另外一個原因是非常大的內聯會使棧跟蹤非常困難。

3.3.1 內聯(示例)

func Max(a, b int) int {
    if a > b {
        return a    
    }

    return b
}

func F() {
    const a, b = 100, 20
    if Max(a, b) == b {
        panic(b)    
    }
}

我們使用 -gcflags=-m flag 以便檢視編譯器的優化機制:

% go build -gcflags=-m examples/inl/max.go
examples/inl/max.go:4:6: can inline Max
examples/inl/max.go:11:6: can inline F
examples/inl/max.go:13:8: inlining call to Max
examples/inl/max.go:20:6: can inline main
examples/inl/max.go:21:3: inlining call to F
examples/inl/max.go:21:3: inlining call to Max

編譯器列印出兩個資訊:

  • 首先,在第三行中,Max 函式的定義,告訴我們該函式可以被內聯
  • 其次,告訴我們 Max 函式的內容可以被內聯到函式呼叫中。

3.3.2 內聯看起來是什麼樣的?

編譯 max.go 並且檢視 F 函式被優化的版本變成什麼了。

% go build -gcflags=-S examples/inl/max.go 2>&1 | grep -A5 '"".F STEXT'
"".F STEXT nosplit size=2 args=0x0 locals=0x0
        0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:11)     TEXT    "".F(SB), NOSPLIT|ABIInternal, $0-0
        0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:11)     FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:11)     FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:11)     FUNCDATA        $3, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:13)     PCDATA  $2, $0

當 Max 函式被內聯以後,F 函式的主體部分 -- 在這個函式中沒有發生任何東西。我知道螢幕上有很多文字,但有用的不多。依我之見,唯一發生的是 RET。 實際上,F 變為:

func F() {
    return
}

什麼是 FUNCDATA 和 PCDATA? 從-S flag 中輸出的內容並非是寫入二進位制檔案的最終的機器碼。在最後的連結階段,聯結器做了一些處理。FUNCDATA 和 PCDATA 之類的行是垃圾收集器的後設資料,在連結時會移至其他位置。如果你在閱讀-S flag 輸出的時候,請忽略 FUNDATA 和 PCDATA 行,因為他們不是最終二進位制檔案的一部分。 在其餘的演示中,我將使用一個小的 Shell 指令碼來減少程式集輸出中的混亂情況。

asm() {
        go build -gcflags=-S 2>&1 $@ | grep -v PCDATA | grep -v FUNCDATA >| less
}

3.4 消除無效程式碼 (Dead code elimination)

死碼消除 (dead code elimination, DCE)是一種編譯器優化技術,用處是在編譯階段去掉對程式執行結果沒有任何影響的程式碼。

死程式碼消除有很多好處:減少程式體積,程式執行過程中避免執行無用的指令,縮短執行時間。

在下面的程式碼中,為什麼說 a 和 b 是常量會這麼重要?

為了理解發生了什麼,讓我們看看當函式 Max 被內聯到 F 以後,編譯器看到的內容是什麼。我們不能很容易的從編譯器中得到這個結果,但可以直接手動實現

之前是這樣的:

func Max(a, b int) int {
    if a > b {
        return a
    }
    return b
}

func F() {
    const a, b = 100, 20
    if Max(a, b) == b {
        panic(b)
    }
}

內聯優化之後:

func F() {
    const a, b = 100, 20
    var result int
    if a > b {
        result = a
    } else {
        result = b
    }
    if result == b {
        panic(b)
    }
}

因為 a 和 b 是常量,所以編譯器可以在編譯時證明該分支永遠不會為假。100 永遠大於 20。所以編譯器才可以進一步優化 F 函式成這樣:

func F() {
    const a, b = 100, 20
    var result int
    if true {
        result = a
    } else {
        result = b
    }
    if result == b {
        panic(b)
    }
}

既然知道了分支的結果,那麼結果的內容也就知道了。 這就是消除分支。

func F() {
    const a, b = 100, 20
    const result = a
    if result == b {
        panic(b)
    }
}

現在消除了分支,我們知道結果總是等於 a,並且因為 a 是一個常數,所以我們知道結果是一個常數。 編譯器將此證明應用於第二個分支

func F() {
    const a, b = 100, 20
    const result = a
    if false {
        panic(b)
    }
}

並再次使用分支消除,將 F 的最終形式簡化為。

func F() {
    const a, b = 100, 20
    const result = a
}

最後就成了這樣:

func F() {
}

3.4.1 消除無效程式碼(續)

分支消除是稱為消除無效程式碼的一種優化類別之一。 實際上,使用靜態證明來顯示一段程式碼是永遠無法到達的,俗稱死程式碼,因此無需在最終二進位制檔案中進行編譯,優化或提交程式碼。

我們看到了無效程式碼消除如何與內聯一起工作,通過刪除被證明無法訪問的迴圈和分支而減少程式碼量。

您可以利用此優勢實施昂貴的除錯,並將其隱藏在後面。

const debug = false

和編譯的 tag 一起使用將會非常有用。

3.4.2 調整內聯級別

使用-gcflags=-l 標誌來執行調整內聯級別。 令人困惑的傳遞單個-l 將禁用內聯,而兩個或多個將啟用更激進的設定中的內聯。

  • -gcflags=-l, 禁用內聯.
  • 什麼都不傳, 常規內聯.
  • -gcflags='-l -l' 內聯級別 2, 更具攻擊性,可能更快,可能會生成更大的二進位制檔案.
  • -gcflags='-l -l -l' 內聯級別 3, 再次變得更具攻擊性,二進位制檔案肯定更大,也許再次更快,但也可能有問題.
  • -gcflags=-l=4 (4 個 -ls) Go 1.11 中的版本將使實驗性中間堆疊內聯優化成為可能。 我相信從 Go 1.12 開始它沒有任何作用.

3.5 證明通過(Prove pass)

prove pass 的功能是對全域性中 SSA 值的取值範圍做一個推斷,這樣就可以消除掉許多不必要的分支判斷。

看下下面的程式碼:

package main

func foo(x int32) bool {
    if x > 5 {
        if x > 3 {
            return true
        }
        panic("x less than 3")
    }
    return false
}

func main() {
    foo(-1)
}

解釋說明:

  • **if x > 5 ** 這個分支中,我們已經知道了 x 是大於 5 的
  • 因此,在 *** if x > 3*** 這個分支中,那麼 x 一定是大於 3 的。

3.5.1 論證

類似於初始化和逃逸分析,我們可以要求編譯器向我們展示 Prove pass 的工作原理。通過在 go tool compile 的-gcflags 中增加 -d flag 即可。如下:

% go build -gcflags=-d=ssa/prove/debug=on examples/prove/foo.go
#command-line-arguments
examples/prove/foo.go:5:10: Proved Greater64

第 5 行是 if x > 3。編譯器已經告訴我們它已經證明了這個分支永遠都是 true。

3.6 編譯器的 intrinsic 函式

Go 允許用匯編的方式編寫函式。這項技術實現首先包含一個函式定義和一個對應的彙編函式的實現。例如:

//decl.go
package asm

// Add returns the sum of a and b.
func Add(a int64, b int64) int64

這裡定義了一個 Add 函式,該函式有兩個 int64 型別的引數,返回兩數字和。注意這裡的 Add 函式沒有函式體,只有定義。如果我們直接編譯,會看到下面的提示:

% go build
#high-performance-go-workshop/examples/asm [high-performance-go-workshop/examples/asm.test]
./decl.go:4:6: missing function body

為了滿足編譯器能夠順利編譯通過,我們必須為該函式提供給一個彙編的實現,我們可以在相同的包中建立一個 .s 字尾的檔案來實現:

//add.s
TEXT ·Add(SB),$0
    MOVQ a+0(FP), AX
    ADDQ b+8(FP), AX
    MOVQ AX, ret+16(FP)
    RET

現在我們就可以像 Go 的正常程式碼一樣來 build,test 以及使用 asm.Add 函式了。

但是,這樣存在一個問題,彙編函式是不能被內聯優化的。這一直是 Go 開發人員長期所抱怨的,他們需要使用匯編來提高效能,或不想在語言中公開的操作。向量指令,原子原語等等,當需要使用匯編編寫這些函式會付出高昂的成本,就因為他們無法被內聯。

對於彙編內聯的語法已經有了很多的提案,像 GCC's 的 asm( ... ) 指令,但它們都沒有被 Go 開發者接受。相反,Go 增加了 intrinsic 函式。

一個 intrinsic 函式就是用 Go 語言編寫的 Go 程式碼,然而,編譯器對這些函式有專門的替換處理。

以下兩個包使用了之中技術:

  • math/bits
  • sync/atomic

這種替換的實現是在編譯器內部實現的;如果你的計算機架構支援更快的的方式,那麼它將被用同等的指令來無縫替換掉。

同樣也會生成的更高效的程式碼,因為 intrinsic 函式就是普通的 Go 程式碼,內聯規則以及棧內聯規則對它們都適用。

3.6.1 Popcnt 示例

讓我們以前面的 Popcnt 為例。Population count 是一個重要的加密操作,所以現代的 CPU 有一個本地指令來執行實現它。

在 math/bits 的包中提供了一組函式,OnesCount...等可被編譯器識別並替換為它們的原生等效項。

func BenchmarkMathBitsPopcnt(b *testing.B) {
    var r int
    for i := 0; i < b.N; i++ {
        r = bits.OnesCount64(uint64(i))
    }
    Result = uint64(r)
}

執行基準測試並和手動移位的實現進行效能比較:

Run the benchmark and compare the performance of the hand rolled shift implementation and math/bits.OnesCount64.

% go test -bench=.  ./examples/popcnt-intrinsic/

3.6.2 Atomic counter 示例

下面是一個原子計數的示例。我們已經有一些特定型別上的方法,每個方法呼叫了一些更深層次的方法,更多的包呼叫等等。你可能會誤認為這會產生很多的開銷。

package main

import (
    "sync/atomic"
)

type counter uint64

func (c *counter) get() uint64 {
    return atomic.LoadUint64((*uint64)(c))
}
func (c *counter) inc() uint64 {
    return atomic.AddUint64((*uint64)(c), 1)
}
func (c *counter) reset() uint64 {
    return atomic.SwapUint64((*uint64)(c), 0)
}

var c counter

func f() uint64 {
    c.inc()
    c.get()
    return c.reset()
}

func main() {
    f()
}

但是,因為在內聯和編譯器 intrinsics 之間的互動轉換,這部分程式碼在大多數平臺上會轉換為高效的原生程式碼。

"".f STEXT nosplit size=36 args=0x8 locals=0x0
        0x0000 00000 (/tmp/counter.go:21)       TEXT    "".f(SB), NOSPLIT|ABIInternal, $0-8
        0x0000 00000 (<unknown line number>)    NOP
        0x0000 00000 (/tmp/counter.go:22)       MOVL    $1, AX
        0x0005 00005 (/tmp/counter.go:13)       LEAQ    "".c(SB), CX
        0x000c 00012 (/tmp/counter.go:13)       LOCK
        0x000d 00013 (/tmp/counter.go:13)       XADDQ   AX, (CX) //標註1
        0x0011 00017 (/tmp/counter.go:23)       XCHGL   AX, AX
        0x0012 00018 (/tmp/counter.go:10)       MOVQ    "".c(SB), AX //標註2
        0x0019 00025 (<unknown line number>)    NOP
        0x0019 00025 (/tmp/counter.go:16)       XORL    AX, AX
        0x001b 00027 (/tmp/counter.go:16)       XCHGQ   AX, (CX)  //標註3
        0x001e 00030 (/tmp/counter.go:24)       MOVQ    AX, "".~r0+8(SP)
        0x0023 00035 (/tmp/counter.go:24)       RET
        0x0000 b8 01 00 00 00 48 8d 0d 00 00 00 00 f0 48 0f c1  .....H.......H..
        0x0010 01 90 48 8b 05 00 00 00 00 31 c0 48 87 01 48 89  ..H......1.H..H.
        0x0020 44 24 08 c3                                      D$..
        rel 8+4 t=15 "".c+0
        rel 21+4 t=15 "".c+0
  • 標註 1:c.inc()
  • 標註 2:c.get()
  • 標註 3:c.reset()

深入閱讀

3.7 邊界檢查預估

Go 是一種邊界檢查語言。這就意味著,陣列、切片(slice)相關的操作程式碼會被做邊界檢查以確保它們都在各自型別的邊界之內。

對於陣列來說,邊界檢查會在編譯階段完成,因為陣列的大小是固定的。但對於切片來說,邊界檢查的工作必須在執行時才能完成。

var v = make([]int, 9)

var A, B, C, D, E, F, G, H, I int

func BenchmarkBoundsCheckInOrder(b *testing.B) {
    var a, _b, c, d, e, f, g, h, i int
    for n := 0; n < b.N; n++ {
        a = v[0]
        _b = v[1]
        c = v[2]
        d = v[3]
        e = v[4]
        f = v[5]
        g = v[6]
        h = v[7]
        i = v[8]
    }
    A, B, C, D, E, F, G, H, I = a, _b, c, d, e, f, g, h, i
}

使用 -gcflags=-S 來反編譯 BenchmarkBoundsCheckInOrder。檢視下再每個迴圈中有多少次的邊界檢查。

3.8 編譯器 flags

編譯器的 flags 以以下方式提供: go build -gcflags=$FLAGS

考察以下編譯器函式的執行情況:

  • -S 列印正在編譯的包的彙編(Go 風格)
  • -l 控制編譯器的內聯行為; -l 禁用內聯, -l -l 增加內聯級別。(更多 -l,就增加編譯器對內聯程式碼的要求)
  • -m 控制編譯器優化詳細輸出,像內聯,逃逸分析。-m -m 會列印更詳細的優化輸出資訊。
  • -l -N 禁用所有的優化機制
  • -d=ssa/prove/debug=0n,類似於-l 和-S
  • -d flag 還會有其他引數,使用 go tool compile -d help 檢視更多。

深入閱讀

小結

編譯器優化會從內聯、逃逸分析、邊界檢查、intrinsic 函式、死程式碼消除等方面進行優化,以提高效率。其中內聯可以通過編譯 flag 時的引數控制內聯的程度。死程式碼消除、邊界檢查、intrinsic 函式在編譯過程或執行時自動執行。

那麼對程式設計師來說,最有用處的應該是逃逸分析。通過逃逸分析可以優化程式碼以降低在堆上分配物件的個數以減少 GC 的壓力。從而提高程式效能

原文連結 https://dave.cheney.net/high-performance-go-workshop/gophercon-2019.html#benchmarking

更多原創文章乾貨分享,請關注公眾號
  • Go 高效能系列教程之三:編譯器優化
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章