首頁 > 軟體

Go編譯原理之函數內聯

2022-08-05 18:01:50

前言

在前一篇文章中分享了編譯器優化的變數捕獲部分,本文分享編譯器優化的另一個內容—函數內聯。函數內聯是指將將較小的函數內容,直接放入到呼叫者函數中,從而減少函數呼叫的開銷

函數內聯概述

我們知道每一個高階程式語言的函數呼叫,成本都是在與需要為它分配棧記憶體來儲存引數、返回值、區域性變數等等,Go的函數呼叫的成本在於引數與返回值棧複製、較小的棧暫存器開銷以及函數序言部分的檢查棧擴容(Go語言中的棧是可以動態擴容的,因為Go在分配棧記憶體不是逐漸增加的,而是一次性分配,這樣是為了避免存取越界,它會一次性分配,當檢查到分配的棧記憶體不夠用時,它會擴容一個足夠大的棧空間,並將原來棧中的內容拷貝過來)

下邊寫一段程式碼,通過Go的基準測試來測一下函數內聯帶來的效率提升

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
}

在編譯的過程中,Go的編譯器其實會計算函數內聯花費的成本,所以只有簡單的函數,才會觸發函數內聯。在後邊函數內聯的原始碼實現中,我們可以看到下邊這些情況不會被內聯:

  • 遞迴函數
  • 函數前有如下注釋的:go:noinlinego:noracego:nocheckptrgo:uintptrescapes
  • 沒有函數體
  • 函數宣告的抽象語法樹中節點數大於5000(我的Go版本是1.16.6)(也就是函數內部語句太多的情況,也不會被內聯)
  • 函數中包含閉包(OCLOSURE)、range(ORANGE)、select(OSELECT)、go(OGO)、defer(ODEFER)、type(ODCLTYPE)、返回值是函數(ORETJMP)的,都不會內聯

我們也可以構建或編譯的時候,通過引數去控制它是否可以內聯。如果希望程式中所有的函數都不執行內聯操作

go build -gcflags="-l" xxx.go
go tool compile -l xxx.go

同樣我們在編譯時,也可以檢視哪些函數內聯了,哪些函數沒內聯,以及原因是什麼

go tool compile -m=2 xxx.go

看一個例子

package main
func test1(a, b int) int {
	return a+b
}
func step(n int) int {
	if n &lt; 2 {
		return n
	}
	return step(n-1) + step(n-2)
}
func main()  {
	test1(1, 2)
	step(5)
}

可以看到test1這個函數是可以內聯的,因為它的函數體很簡單。step這個函數因為是遞迴函數,所以它不會進行內聯

函數內聯底層實現

這裡邊其實每一個函數呼叫鏈都很深,我這裡不會一行一行的解釋程式碼的含義,僅僅會將一些核心的方法拿出來介紹一下,感興趣的小夥伴可以自己去偵錯一下(前邊有發相關文章)(Go原始碼偵錯方法

還是前邊提到多次的Go編譯入口檔案,你可以在入口檔案中找到這段程式碼

Go編譯入口檔案:src/cmd/compile/main.go -> gc.Main(archInit)
// Phase 5: Inlining
if Debug.l != 0 {
		// 查詢可以內聯的函數
		visitBottomUp(xtop, func(list []*Node, recursive bool) {
			numfns := numNonClosures(list)
			for _, n := range list {
				if !recursive || numfns > 1 {
					caninl(n)
				} else {
					......
				}
				inlcalls(n)
			}
		})
	}
	for _, n := range xtop {
		if n.Op == ODCLFUNC {
			devirtualize(n)
		}
	}

下邊就看一下每個方法都在做哪些事情

visitBottomUp

該方法有兩個引數:

  • xtop:前邊已經見過它了,它存放的是每個宣告語句的抽象語法樹的根節點陣列
  • 第二個引數是一個函數(該函數也有兩個引數,一個是滿足是函數型別宣告的抽象語法樹根節點陣列,一個是bool值,true表示是遞迴函數,false表示不是遞迴函數)

進入到visitBottomUp方法中,你會發現它主要是遍歷xtop,並對每個抽象語法樹的根節點呼叫了visit這個方法(僅針對是函數型別宣告的抽象語法樹)

func visitBottomUp(list []*Node, analyze func(list []*Node, recursive bool)) {
	var v bottomUpVisitor
	v.analyze = analyze
	v.nodeID = make(map[*Node]uint32)
	for _, n := range list {
		if n.Op == ODCLFUNC && !n.Func.IsHiddenClosure() { //是函數,並且不是閉包函數
			v.visit(n)
		}
	}
}

visit方法的核心是呼叫了inspectList方法,通過inspectList對抽象語法樹按照深度優先搜尋進行遍歷,並將每一個節點作為inspectList方法的第二個引數(是一個函數)的引數,比如驗證這個函數裡邊是否有遞迴呼叫等(具體就是下邊的switch case)

func (v *bottomUpVisitor) visit(n *Node) uint32 {
	if id := v.nodeID[n]; id > 0 {
		// already visited
		return id
	}
	......
	v.stack = append(v.stack, n)
	inspectList(n.Nbody, func(n *Node) bool {
		switch n.Op {
		case ONAME:
			if n.Class() == PFUNC {
				......
			}
		case ODOTMETH:
			fn := asNode(n.Type.Nname())
			......
			}
		case OCALLPART:
			fn := asNode(callpartMethod(n).Type.Nname())
			......
		case OCLOSURE:
			if m := v.visit(n.Func.Closure); m < min {
				min = m
			}
		}
		return true
	})
		v.analyze(block, recursive)
	}
	return min
}

後邊通過呼叫visitBottomUp的第二個引數傳遞的方法,對抽象語法樹進行內聯的判斷及內聯操作,具體就是caninlinlcalls這兩個方法

caninl

該方法的作用就是驗證是函數型別宣告的抽象語法樹是否可以內聯

這個方法的實現很簡單,首先是通過很多的if語句驗證函數前邊是否有像go:noinline等這種標記

func caninl(fn *Node) {
	if fn.Op != ODCLFUNC {
		Fatalf("caninl %v", fn)
	}
	if fn.Func.Nname == nil {
		Fatalf("caninl no nname %+v", fn)
	}
	var reason string // reason, if any, that the function was not inlined
	......
	// If marked "go:noinline", don't inline
	if fn.Func.Pragma&Noinline != 0 {
		reason = "marked go:noinline"
		return
	}
	// If marked "go:norace" and -race compilation, don't inline.
	if flag_race && fn.Func.Pragma&Norace != 0 {
		reason = "marked go:norace with -race compilation"
		return
	}
	......
	// If fn has no body (is defined outside of Go), cannot inline it.
	if fn.Nbody.Len() == 0 {
		reason = "no function body"
		return
	}
	visitor := hairyVisitor{
		budget:        inlineMaxBudget,
		extraCallCost: cc,
		usedLocals:    make(map[*Node]bool),
	}
	if visitor.visitList(fn.Nbody) {
		reason = visitor.reason
		return
	}
	if visitor.budget < 0 {
		reason = fmt.Sprintf("function too complex: cost %d exceeds budget %d", inlineMaxBudget-visitor.budget, inlineMaxBudget)
		return
	}
	n.Func.Inl = &Inline{
		Cost: inlineMaxBudget - visitor.budget,
		Dcl:  inlcopylist(pruneUnusedAutos(n.Name.Defn.Func.Dcl, &visitor)),
		Body: inlcopylist(fn.Nbody.Slice()),
	}
	......
}

這裡邊還有一個主要的方法就是visitList,它是用來驗證函數裡邊是否有我們上邊提到的go、select、range等等這些語句。對於滿足內聯條件的,它會將改寫該函數宣告抽閒語法樹的內聯欄位(Inl)

inlcalls

該方法中就是具體的內聯操作,比如將函數的引數和返回值轉換為呼叫者中的宣告語句等。裡邊的呼叫和實現都比較複雜,這裡不粘程式碼了,大家可自行去看。函數內聯的核心方法都在如下檔案中

src/cmd/compile/internal/gc/inl.go

以上就是Go編譯原理之函數內聯的詳細內容,更多關於Go編譯原理函數內聯的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com