2. Go 性能調優之 —— 編譯優化

原文連接: https://github.com/sxs2473/go...
本文使用 Creative Commons Attribution-ShareAlike 4.0 International 協議進行受權許可。

編譯優化

本節介紹Go編譯器執行的三個重要優化。git

  • 逃逸分析
  • 內聯
  • 死碼消除

Go 編譯器的歷史

Go 編譯器在2007年左右開始做爲 Plan9 編譯器工具鏈的一個分支。當時的編譯器與 Aho 和 Ullman 的 Dragon Book 很是類似。程序員

2015年,當時的 Go 1.5 編譯器 從 C 機械地翻譯成 Gogithub

一年後,Go 1.7 引入了一個基於 SSA 技術的 新編譯器後端 ,取代了以前的 Plan 9風格的代碼。這個新的後端爲泛型和體系結構特定的優化提供了許多可能。golang

逃逸分析

咱們要討論的第一個優化是逃逸分析。後端

爲了說明逃逸分析,首先讓咱們來回憶一下在 Go spec 中沒有提到堆和棧,它只提到 Go 語言是有垃圾回收的,但也沒有說明如何是如何實現的。安全

一個遵循 Go spec 的 Go 實現能夠將每一個分配操做都在堆上執行。這會給垃圾回收器帶來很大壓力,但這樣作是絕對錯誤的 -- 多年來,gccgo對逃逸分析的支持很是有限,因此才致使這樣作被認爲是有效的。函數

然而,goroutine 的棧是做爲存儲局部變量的廉價場所而存在;沒有必要在棧上執行垃圾回收。所以,在棧上分配內存也是更加安全和有效的。工具

在一些語言中,如CC++,在棧仍是堆上分配內存由程序員手動決定——堆分配使用mallocfree,而棧分配經過alloca。錯誤地使用這種機制會是致使內存錯誤的常見緣由。優化

在 Go 中,若是一個值超過了函數調用的生命週期,編譯器會自動將之移動到堆中。咱們管這種現象叫:該值逃逸到了堆。ui

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 中返回棧上分配的變量的地址。

同時編譯器也能夠作相反的事情;它能夠找到堆上要分配的東西,並將它們移動到棧上。

逃逸分析 - 例1

讓咱們來看下面的例子:

// Sum 函數返回 0-100 的整數之和
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
}

Sum 將 0-100 的 ints型數字相加並返回結果。

由於 numbers 切片僅在 Sum函數內部使用,編譯器將在棧上存儲這100個整數而不是堆。也沒有必要對 numbers進行垃圾回收,由於它會在 Sum 返回時自動釋放。

調查逃逸分析

證實它!

要打印編譯器關於逃逸分析的決策,請使用-m標誌。

% go build -gcflags=-m examples/esc/sum.go
# command-line-arguments
examples/esc/sum.go:8:17: Sum make([]int, count) does not escape
examples/esc/sum.go:22:13: answer escapes to heap
examples/esc/sum.go:22:13: main ... argument does not escape

第8行顯示編譯器已正確推斷 make([]int, 100)的結果不會逃逸到堆。

第22行顯示answer逃逸到堆的緣由是fmt.Println是一個可變函數。 可變參數函數的參數被裝入一個切片,在本例中爲[]interface{},因此會將answer賦值爲接口值,由於它是經過調用fmt.Println引用的。 從 Go 1.6(多是)開始,垃圾收集器須要經過接口傳遞的全部值都是指針,編譯器看到的是這樣的:

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

咱們可使用標識 -gcflags="-m -m" 來肯定這一點。會返回:

examples/esc/sum.go:22:13: answer escapes to heap
examples/esc/sum.go:22:13:      from ... argument (arg to ...) at examples/esc/sum.go:22:13
examples/esc/sum.go:22:13:      from *(... argument) (indirection) at examples/esc/sum.go:22:13
examples/esc/sum.go:22:13:      from ... argument (passed to call[argument content escapes]) at examples/esc/sum.go:22:13
examples/esc/sum.go:22:13: main ... argument does not escape

總之,不要擔憂第22行,這對咱們的討論並不重要。

逃逸分析 - 例2

這個例子是咱們模擬的。 它不是真正的代碼,只是一個例子。

package main

import "fmt"

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.Xp.Y 的值。

% go build -gcflags=-m examples/esc/center.go
# command-line-arguments
examples/esc/center.go:10:6: can inline Center
examples/esc/center.go:17:8: inlining call to Center
examples/esc/center.go:10:13: Center p does not escape
examples/esc/center.go:18:15: p.X escapes to heap
examples/esc/center.go:18:20: p.Y escapes to heap
examples/esc/center.go:16:10: NewPoint new(Point) does not escape
examples/esc/center.go:18:13: NewPoint ... argument does not escape
# command-line-arguments

儘管p是使用new分配的,但它不會存儲在堆上,由於Center被內聯了,因此沒有p的引用會逃逸到Center函數。

內聯

在 Go 中,函數調用有固定的開銷;棧和搶佔檢查。

硬件分支預測器改善了其中的一些功能,但就功能大小和時鐘週期而言,這仍然是一個成本。

內聯是避免這些成本的經典優化方法。

內聯只對葉子函數有效,葉子函數是不調用其餘函數的。這樣作的理由是:

  • 若是你的函數作了不少工做,那麼前序開銷能夠忽略不計。
  • 另外一方面,小函數爲相對較少的有用工做付出固定的開銷。這些是內聯目標的功能,由於它們最受益。

還有一個緣由就是嚴重的內聯會使得堆棧信息更加難以跟蹤。

內聯 - 例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 標識來查看編譯器優化決策。

% go build -gcflags=-m examples/max/max.go
# command-line-arguments
examples/max/max.go:3:6: can inline Max
examples/max/max.go:12:8: inlining call to Max

編譯器打印了兩行信息:

  • 首先第3行,Max的聲明告訴咱們它能夠內聯
  • 其次告訴咱們,Max的主體已經內聯到第12行調用者中。

內聯是什麼樣的?

編譯 max.go 而後咱們看看優化版本的 F() 變成什麼樣了。

% go build -gcflags=-S examples/max/max.go 2>&1 | grep -A5 '"".F STEXT'
"".F STEXT nosplit size=1 args=0x0 locals=0x0
        0x0000 00000 (/Users/dfc/devel/gophercon2018-performance-tuning-workshop/4-compiler-optimisations/examples/max/max.go:10)       TEXT    "".F(SB), NOSPLIT, $0-0
        0x0000 00000 (/Users/dfc/devel/gophercon2018-performance-tuning-workshop/4-compiler-optimisations/examples/max/max.go:10)       FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (/Users/dfc/devel/gophercon2018-performance-tuning-workshop/4-compiler-optimisations/examples/max/max.go:10)       FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (<unknown line number>)    RET
        0x0000 c3

一旦Max被內聯到這裏,這就是F的主體 - 這個函數什麼都沒幹。我知道屏幕上有不少沒用的文字,可是相信個人話,惟一發生的就是RET。實際上F變成了:

func F() {
        return
}

注意 : 利用 -S 的輸出並非進入二進制文件的最終機器碼。連接器在最後的連接階段進行一些處理。像FUNCDATAPCDATA這樣的行是垃圾收集器的元數據,它們在連接時移動到其餘位置。 若是你正在讀取-S的輸出,請忽略FUNCDATAPCDATA行;它們不是最終二進制的一部分。

調整內聯級別

使用-gcflags=-l標識調整內聯級別。有些使人困惑的是,傳遞一個-l將禁用內聯,兩個或兩個以上將在更激進的設置中啓用內聯。

  • -gcflags=-l,禁用內聯。
  • 什麼都不作,常規的內聯
  • -gcflags='-l -l' 內聯級別2,更積極,可能更快,可能會製做更大的二進制文件。
  • -gcflags='-l -l -l' 內聯級別3,再次更加激進,二進制文件確定更大,也許更快,但也許會有 bug。
  • -gcflags=-l=4 (4個 -l) 在 Go 1.11 中將支持實驗性的 中間棧內聯優化

死碼消除

爲何ab是常數很重要?

爲了理解發生了什麼,讓咱們看一下編譯器在把Max內聯到F中的時候看到了什麼。咱們不能輕易地從編譯器中得到這個,可是直接手動完成它。

Before:

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

After:

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

由於ab是常量,因此編譯器能夠在編譯時證實分支永遠不會是假的;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() {
}

死碼消除(續)

分支消除是一種被稱爲死碼消除的優化。實際上,使用靜態證實來代表一段代碼永遠不可達,一般稱爲死代碼,所以它不須要在最終的二進制文件中編譯、優化或發出。

咱們發現死碼消除與內聯一塊兒工做,以減小循環和分支產生的代碼數量,這些循環和分支被證實是不可到達的。

你能夠利用這一點來實現昂貴的調試,並將其隱藏起來

const debug = false

結合構建標記,這可能很是有用。

進一步閱讀

編譯器標識練習

編譯器標識提供以下:

go build -gcflags=$FLAGS

研究如下編譯器功能的操做:

  • -S 打印正在編譯的包的彙編代碼
  • -l 控制內聯行爲; -l 禁止內聯, -l -l 增長-l(更多-l會增長編譯器對代碼內聯的強度)。試驗編譯時間,程序大小和運行時間的差別。
  • -m 控制優化決策的打印,如內聯,逃逸分析。-m打印關於編譯器的想法的更多細節。
  • -l -N 禁用全部優化。

注意 : If you find that subsequent runs of go build ... produce no output, delete the ./max binary in your working directory.

相關文章
相關標籤/搜索