
本文讨論 Go 編譯器是如何實作内聯的,以及這種優化方法如何影響你的 Go 代碼。https://linux.cn/article-12176-1.html 作者:Dave Cheney 譯者:Xiaobin.Liu
請注意:本文重點讨論 gc,這是來自 golang.org 的事實标準的 Go 編譯器。讨論到的概念可以廣泛适用于其它 Go 編譯器,如 gccgo 和 llgo,但它們在實作方式和功效上可能有所差異。
内聯是什麼?
内聯(inlining)就是把簡短的函數在調用它的地方展開。在計算機發展曆程的早期,這個優化是由程式員手動實作的。現在,内聯已經成為編譯過程中自動實作的基本優化過程的其中一步。
為什麼内聯很重要?
有兩個原因。第一個是它消除了函數調用本身的開銷。第二個是它使得編譯器能更高效地執行其他的優化政策。
函數調用的開銷
在任何語言中,調用一個函數 1 都會有消耗。把參數編組進寄存器或放入棧中(取決于 ABI),在傳回結果時的逆反過程都會有開銷。引入一次函數調用會導緻程式計數器從指令流的一點跳到另一點,這可能導緻管道滞後。函數内部通常有前置處理(preamble),需要為函數執行準備新的棧幀,還有與前置相似的後續處理(epilogue),需要在傳回給調用方之前釋放棧幀空間。
在 Go 中函數調用會消耗額外的資源來支援棧的動态增長。在進入函數時,goroutine 可用的棧空間與函數需要的空間大小進行比較。如果可用空間不同,前置處理就會跳到運作時(runtime)的邏輯中,通過把資料複制到一塊新的、更大的空間的來增長棧空間。當這個複制完成後,運作時就會跳回到原來的函數入口,再執行棧空間檢查,現在通過了檢查,函數調用繼續執行。這種方式下,goroutine 開始時可以申請很小的棧空間,在有需要時再申請更大的空間。2
這個檢查消耗很小,隻有幾個指令,而且由于 goroutine 的棧是成幾何級數增長的,是以這個檢查很少失敗。這樣,現代處理器的分支預測單元可以通過假定檢查肯定會成功來隐藏棧空間檢查的消耗。當處理器預測錯了棧空間檢查,不得不放棄它在推測性執行所做的操作時,與為了增加 goroutine 的棧空間運作時所需的操作消耗的資源相比,管道滞後的代價更小。
雖然現代處理器可以用預測性執行技術優化每次函數調用中的泛型和 Go 特定的元素的開銷,但那些開銷不能被完全消除,是以在每次函數調用執行必要的工作過程中都會有性能消耗。一次函數調用本身的開銷是固定的,與更大的函數相比,調用小函數的代價更大,因為在每次調用過程中它們做的有用的工作更少。
是以,消除這些開銷的方法必須是要消除函數調用本身,Go 的編譯器就是這麼做的,在某些條件下通過用函數的内容來替換函數調用來實作。這個過程被稱為内聯,因為它在函數調用處把函數體展開了。
改進的優化機會
Cliff Click 博士把内聯描述為現代編譯器做的優化措施,像常量傳播(LCTT 譯注:此處作者筆誤,原文為 constant proportion,修正為 constant propagation)和死代碼消除一樣,都是編譯器的基本優化方法。實際上,内聯可以讓編譯器看得更深,使編譯器可以觀察調用的特定函數的上下文内容,可以看到能繼續簡化或徹底消除的邏輯。由于可以遞歸地執行内聯,是以不僅可以在每個獨立的函數上下文處進行這種優化決策,也可以在整個函數調用鍊中進行。
實踐中的内聯
下面這個例子可以示範内聯的影響:
package main
import "testing"
//go:noinline
func max(a, b int) int {
if a > b {
return a
}
return b
}
var Result int
func BenchmarkMax(b *testing.B) {
var r int
for i := 0; i < b.N; i++ {
r = max(-1, i)
}
Result = r
}
運作這個基準,會得到如下結果:3
% go test -bench=.
BenchmarkMax-4 530687617 2.24 ns/op
在我的 2015 MacBook Air 上
max(-1, i)
的耗時約為 2.24 納秒。現在去掉
//go:noinline
編譯指令,再看下結果:
% go test -bench=.
BenchmarkMax-4 1000000000 0.514 ns/op
從 2.24 納秒降到了 0.51 納秒,或者從
benchstat
的結果可以看出,有 78% 的提升。
% benchstat {old,new}.txt
name old time/op new time/op delta
Max-4 2.21ns ± 1% 0.49ns ± 6% -77.96% (p=0.000 n=18+19)
這個提升是從哪兒來的呢?
首先,移除掉函數調用以及與之關聯的前置處理 4 是主要因素。把
max
函數的函數體在調用處展開,減少了處理器執行的指令數量并且消除了一些分支。
現在由于編譯器優化了
BenchmarkMax
,是以它可以看到
max
函數的内容,進而可以做更多的提升。當
max
被内聯後,
BenchmarkMax
呈現給編譯器的樣子,看起來是這樣的:
func BenchmarkMax(b *testing.B) {
var r int
for i := 0; i < b.N; i++ {
if -1 > i {
r = -1
} else {
r = i
}
}
Result = r
}
再運作一次基準,我們看一下手動内聯的版本和編譯器内聯的版本的表現:
% benchstat {old,new}.txt
name old time/op new time/op delta
Max-4 2.21ns ± 1% 0.48ns ± 3% -78.14% (p=0.000 n=18+18)
現在編譯器能看到在
BenchmarkMax
裡内聯
max
的結果,可以執行以前不能執行的優化措施。例如,編譯器注意到
i
初始值為
,僅做自增操作,是以所有與
i
的比較都可以假定
i
不是負值。這樣條件表達式
-1 > i
永遠不是
true
。5
證明了
-1 > i
永遠不為 true 後,編譯器可以把代碼簡化為:
func BenchmarkMax(b *testing.B) {
var r int
for i := 0; i < b.N; i++ {
if false {
r = -1
} else {
r = i
}
}
Result = r
}
并且因為分支裡是個常量,編譯器可以通過下面的方式移除不會走到的分支:
func BenchmarkMax(b *testing.B) {
var r int
for i := 0; i < b.N; i++ {
r = i
}
Result = r
}
這樣,通過内聯和由内聯解鎖的優化過程,編譯器把表達式
r = max(-1, i))
簡化為
r = i
。
内聯的限制
本文中我論述的内聯稱作葉子内聯(leaf inlining):把函數調用棧中最底層的函數在調用它的函數處展開的行為。内聯是個遞歸的過程,當把函數内聯到調用它的函數 A 處後,編譯器會把内聯後的結果代碼再内聯到 A 的調用方,這樣持續内聯下去。例如,下面的代碼:
func BenchmarkMaxMaxMax(b *testing.B) {
var r int
for i := 0; i < b.N; i++ {
r = max(max(-1, i), max(0, i))
}
Result = r
}
與之前的例子中的代碼運作速度一樣快,因為編譯器可以對上面的代碼重複地進行内聯,也把代碼簡化到
r = i
表達式。
下一篇文章中,我會論述當 Go 編譯器想要内聯函數調用棧中間的某個函數時選用的另一種内聯政策。最後我會論述編譯器為了内聯代碼準備好要達到的極限,這個極限 Go 現在的能力還達不到。
相關文章:
1. 使 Go 變快的 5 件事2. 為什麼 Goroutine 的棧空間會無限增長?3. Go 中怎麼寫基準測試4. Go 中隐藏的編譯指令
- 在 Go 中,一個方法就是一個有預先定義的形參和接受者的函數。假設這個方法不是通過接口調用的,調用一個無消耗的函數所消耗的代價與引入一個方法是相同的。 ↩
- 在 Go 1.14 以前,棧檢查的前置處理也被垃圾回收器用于 STW,通過把所有活躍的 goroutine 棧空間設為 0,來強制它們切換為下一次函數調用時的運作時狀态。這個機制最近被替換為一種新機制,新機制下運作時可以不用等 goroutine 進行函數調用就可以暫停 goroutine。 ↩
- 我用
編譯指令來阻止編譯器内聯//go:noinline
。這是因為我想把内聯max
的影響與其他影響隔離開,而不是用max
選項在全局範圍内禁止優化。關于-gcflags='-l -N'
注釋在這篇文章中詳細論述。 ↩//go:
- 你可以自己通過比較
有無go test -bench=. -gcflags=-S
注釋時的不同結果來驗證一下。 ↩//go:noinline
- 你可以用
選項來自己驗證一下。 ↩-gcflags=-d=ssa/prove/debug=on
via: https://dave.cheney.net/2020/04/25/inlining-optimisations-in-go
作者:Dave Cheney 選題:lujun9972 譯者:lxbwolf 校對:wxy
本文由 LCTT 原創編譯,Linux中國 榮譽推出