Go 中的內聯優化
文討論 Go 編譯器是如何實現內聯的以及這種優化方法如何影響你的 Go 程式碼。
請注意:本文重點討論 gc,實際上是 golang.org 的 Go 編譯器。討論到的概念可以廣泛用於其他 Go 編譯器,如 gccgo 和 llgo,但它們在實現方式和功能上可能有所差異。
內聯是什麼?
內聯就是把簡短的函式在呼叫它的地方展開。在計算機發展歷程的早期,這個優化是由程式設計師手動實現的。現在,內聯已經成為編譯過程中自動實現的基本優化過程的其中一步。
為什麼內聯很重要?
有兩個原因。第一個是它消除了函式呼叫本身的開銷。第二個是它使得編譯器能更高效地執行其他的優化策略。
函式呼叫的開銷
在任何語言中,呼叫一個函式
在 Go 中函式呼叫會消耗額外的資源來支援棧的動態增長。在進入函式時,goroutine 可用的棧空間與函式需要的空間大小相等。如果可用空間不同,前置處理就會跳到把資料複製到一塊新的、更大的空間的執行時邏輯,而這會導致棧空間變大。當這個複製完成後,執行時跳回到原來的函式入口,再執行棧空間檢查,函式呼叫繼續執行。這種方式下,goroutine 開始時可以申請很小的棧空間,在有需要時再申請更大的空間。
這個檢查消耗很小 — 只有幾個指令 — 而且由於 Goroutine 是成幾何級數增長的,因此這個檢查很少失敗。這樣,現代處理器的分支預測單元會通過假定檢查肯定會成功來隱藏棧空間檢查的消耗。當處理器預測錯了棧空間檢查,必須要拋棄它推測性執行的操作時,與為了增加 Goroutine 的棧空間執行時所需的操作消耗的資源相比,管道阻塞的代價更小。
雖然現代處理器可以用預測性執行技術優化每次函式呼叫中的泛型和 Go 特定的元素的開銷,但那些開銷不能被完全消除,因此在每次函式呼叫執行必要的工作過程中都會有效能消耗。一次函式呼叫本身的開銷是固定的,與更大的函式相比,呼叫小函式的代價更大,因為在每次呼叫過程中它們做的有用的工作更少。
消除這些開銷的方法必須是要消除函式呼叫本身,Go 的編譯器就是這麼做的,在某些條件下通過用函式的內容來替換函式呼叫來實現。這個過程被稱為內聯,因為它在函式呼叫處把函式體展開了。
改進的優化機會
Cliff Click 博士把內聯描述為現代編譯器做的優化措施,像常量傳播(譯註:此處作者筆誤,原文為 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
初始值為 0
,僅做自增操作,因此所有與 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
。
內聯的限制
本文中我論述的內聯稱作葉子內聯;把函式呼叫棧中最底層的函式在呼叫它的函式處展開的行為。內聯是個遞迴的過程,當把函式內聯到呼叫它的函式 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 現在的能力還達不到。
文中的引用說明:
- 在 Go 中,一個方法就是一個有預先定義的形參和接受者的函式。假設這個方法不是通過介面呼叫的,呼叫一個無消耗的函式所消耗的代價與引入一個方法是相同的。
- 在 Go 1.14 以前,棧檢查的前置處理也被 gc 用於 STW,通過把所有活躍的 Goroutine 棧空間設為 0,來強制它們切換為下一次函式呼叫時的執行時狀態。這個機制[最近被替換][https://github.com/golang/proposal/blob/master/design/24543-non-cooperative-preemption.md]為一種新機制,新機制下執行時可以不用等 Goroutine 進行函式呼叫就可以暫停 goroutine。[][9]
- 我用
//go:noinline
編譯指令來阻止編譯器內聯max
。這是因為我想把內聯max
的影響與其他影響隔離開,而不是用-gcflags='-l -N'
選項在全域性範圍內禁止優化。關於//go:
註釋在[這篇文章][https://dave.cheney.net/2018/01/08/gos-hidden-pragmas]中詳細論述。 - 你可以自己通過比較
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 譯者:lxbwolf 校對:polaris1119