前言
当程序调用一个函数的时候,是需要一定的开销来保存当前函数的上下文信息的,比如保存原来的栈指针,保存某些寄存器的值,保存返回地址等等。如果在编译期能做一些优化,减少这些开销,那么带来的优化效果是很显著的。这个优化方法就是本篇文章的主题——函数内联(inline)。
内联的两面性
内联的优点
实例代码如下:
func sum(base, i int) int {
return 100/base + i
}
func doSum(base int, ints []int) int {
var s int
for _, val := range ints {
s += sum(base, val)
}
return s
}
内联就是用函数体替换掉函数调用,节省函数调用开销,达到优化的效果。内联后代码如下:
func doSum(base int, ints []int) int {
var s int
for _, val := range ints {
s += 100/base + val
}
return s
}
这样我们就去掉了所有对函数 sum 的调用。
内联不仅可以减少函数调用开销,内联之后的代码还可以在编译期更好的继续优化,比如逃逸分析。具体可以看我上一篇文章:juejin.cn/post/705517…
内联的缺点
Inline 不会改变函数的行为,但会缩短程序中的函数调用链,这对于错误信息的展示是不友好的。以上述代码为例,当参数 base=0 时, 理想状况下程序应该汇报的错误代码位置在函数 sum() 内,而非 Inline 之后的 doSum() 中。因此编译器在 Inline 时必须维持原先代码的结构,并将其与 Inline 之后的代码关联起来,这会引入额外的复杂度。
而且不是所有函数都可以无脑内联。假设有一个函数,这个函数的代码非常多,如果直接对该函数内联的话,那编译器就会将所有调用该函数的地方进行替换,这会导致程序体积变大,最终的可执行文件也会变大。由此可知内联可能不适合比较大的函数。
上下文切换的开销是基本固定的,相对于函数的总体执行开销,其实小函数上下文切换所占的比重更大。因此减少程序中的小函数调用,才是优化程序性能的重要方向。
那函数到底多小才适合内联呢?这个大小计算的标准又是怎样的呢?后面会基于 Go1.17 代码讲解。
原理解析
入口
入口代码为:
src\cmd\compile\internal\gc\main.go
func Main(archInit func(*Arch)) {
//...
if Debug.l != 0 {
// Find functions that can be inlined and clone them before walk expands them.
visitBottomUp(xtop, func(list []*Node, recursive bool) {
// 判断递归调用场景
numfns := numNonClosures(list)
for _, n := range list {
if !recursive || numfns > 1 {
caninl(n)
} else {
if Debug.m > 1 {
fmt.Printf("%v: cannot inline %v: recursive\n", n.Line(), n.Func.Nname)
}
}
inlcalls(n)
}
})
}
//...
}
代码中有如下关键点:
1.这个 Debug 是个结构体,存储着编译调试时可以带上的参数:
// gc debug flags
type DebugFlags struct {
P, B, C, E,
K, L, N, S,
W, e, h, j,
l, m, r, w int
}
var Debug DebugFlags
其中 l 指的就是 -gcflags="-l",禁止函数内联。如果编译时带上 -gcflags="-l",这时 Debug.l = 0,否则 Debug.l = 1。
2.visitBottomUp 的函数签名为:
func visitBottomUp(list []*Node, analyze func(list []*Node, recursive bool))
其中第一个 list 入参就是 xtop,第二个 list 入参代表一个 SCC,analyze 函数对 SCC 进行内联分析。
3.caninl() 是对函数体进行分析,判断该函数是否可以被内联
4.inlcalls() 重新扫描函数,看看是否存在可以被内联的函数调用,有的话就对 AST 树进行内联展开,即用 inlineable 函数替换调用处。
内联判断
前文中提到,函数调用对小函数影响更大,因此如何衡量函数的大小及复杂度,是编译器需要量化的事情。
该逻辑由结构体 hairyVisitor 驱动:
// hairyVisitor visits a function body to determine its inlining
// hairiness and whether or not it can be inlined.
type hairyVisitor struct {
budget int32
reason string
extraCallCost int32
usedLocals map[*Node]bool
}
- budget:内联“预算”,起始值为 80, 由常量 inlineMaxBudget 定义,函数体内各种语句操作都会有对应的“代价(cost)”,总体“代价”超过“预算”的话,那么该函数就会由于太复杂而无法内联。
- reason:保存不能内联的理由
- extraCallCost:方法调用的开销,默认为 57, 由常量 inlineExtraCallCost 定义
- usedLocals:用来记录当前函数的局部变量,函数内联时需要使用
编译器定义了一个“内联代价(Inline Cost)”来表达函数复杂度,内联代价与函数 AST 的节点数目正相关,每个子节点的 cost 为 1, 如果该节点还需要额外的操作,则还需要减掉对应的 Cost, 例如函数调用的 Cost 是 57, 由常量 inlineExtraCallCost 定义;内联总代价小于 80 的函数可以内联,该数字被称为函数的“内联预算(Budget)”,由常量 inlineMaxBudget 定义。
计算内联代价的方法是 visitor.visitList(fn.Nbody), 该方法对函数体的语句进行遍历,并从“内联预算(budget 字段)”中减掉语句的各种“代价(cost)”,如果最终预算还有盈余,则函数可被内联。例如下列函数:
func B() {
println("B")
println("B")
}
// go:noinline
func C() {
println("C")
}
func A() {
println("A")
C()
}
对于函数 B,语句 println("B") 总体的代价是 2, 因此整个函数的代价便是 4, 该函数没有用完内联预算,因此是可以内联的。而对于函数 A,其内联代价的计算如下:
- Cost(A) = 2 + 2 + 57 = 61
- 2: 语句
println("A")的代价 - 2: 语句
C()本身的代价 - 57: 由于函数 C 无法内联,所以需要减去一个 extraCallCost, 默认为 57
A 仍然可以内联,但同时可以发现,如果 A 调用了两个以上不可内联的函数的话,那么他就会耗尽“预算”而无法内联了。
内联的主要核心逻辑就是这样,主要由好几个核心函数来共同完成,下面逐一讲解。
caninl
判断函数能否内联的逻辑封装在函数 caninl 中。
精简后代码如下:
func caninl(fn *Node) {
//...
// 如果标记了 "go:noinline", 则不能 inline
if fn.Func.Pragma&Noinline != 0 {
reason = "marked go:noinline"
return
}
cc := int32(inlineExtraCallCost)
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()),
}
//...
}
主要逻辑如下: 1.如果函数标记了 //go:noinline,表示该函数不能内联,直接 return
2.创建一个 visitor 结构体
visitor := hairyVisitor{
budget: 80,
extraCallCost: 57,
usedLocals: make(map[*Node]bool),
}
3.visitor.visitList(fn.Nbody) 分析函数体的语句,算出函数体的总开销。如果 budget < 0,打印函数过于复杂,返回
4.否则,也就是 budget > 0 的话,表示函数可以被内联,则创建一个 Inline 结构体指针,赋给 n.Func.Inl。说明对于可以内联的函数, caninl 会为该函数创建一个 Inline 对象
visitList
// Look for anything we want to punt on.
func (v *hairyVisitor) visitList(ll Nodes) bool {
for _, n := range ll.Slice() {
if v.visit(n) {
return true
}
}
return false
}
遍历每条语句,调用 visit 对语句进行分析
visit
内联分析最重要的函数就是 visit,精简后代码如下:
func (v *hairyVisitor) visit(n *Node) bool {
switch n.Op {
case OCALLFUNC:
if fn := inlCallee(n.Left); fn != nil && fn.Func.Inl != nil {
v.budget -= fn.Func.Inl.Cost
break
}
v.budget -= v.extraCallCost
// Call is okay if inlinable and we have the budget for the body.
case OCALLMETH:
t := n.Left.Type
if inlfn := asNode(t.FuncType().Nname).Func; inlfn.Inl != nil {
v.budget -= inlfn.Inl.Cost
break
}
// Call cost for non-leaf inlining.
v.budget -= v.extraCallCost
// Things that are too hairy, irrespective of the budget
case OCALL, OCALLINTER:
// Call cost for non-leaf inlining.
v.budget -= v.extraCallCost
case OPANIC:
v.budget -= inlineExtraPanicCost
case ORECOVER:
// recover matches the argument frame pointer to find
// the right panic value, so it needs an argument frame.
v.reason = "call to recover"
return true
case OAPPEND:
v.budget -= inlineExtraAppendCost
case ODCLCONST, OEMPTY, OFALL:
// These nodes don't produce code; omit from inlining budget.
return false
case OIF:
if Isconst(n.Left, CTBOOL) {
// This if and the condition cost nothing.
return v.visitList(n.Ninit) || v.visitList(n.Nbody) ||
v.visitList(n.Rlist)
}
case ONAME:
if n.Class() == PAUTO {
v.usedLocals[n] = true
}
}
v.budget--
// When debugging, don't stop early, to get full cost of inlining this function
if v.budget < 0 && Debug.m < 2 && !logopt.Enabled() {
return true
}
return v.visit(n.Left) || v.visit(n.Right) ||
v.visitList(n.List) || v.visitList(n.Rlist) ||
v.visitList(n.Ninit) || v.visitList(n.Nbody)
}
可以看到主要是用一个大的 switch-case 来处理所有的语句类型。我们就只分析语句中有函数调用(OCALLFUNC)这种情况。
简单的实例代码如下:
func A() {
//...
B()
//...
}
分析到 B() 语句时,对应 switch 中的 case OCALLFUNC。如果 B() 是可以内联的话(inlCallee),就用预算减去 B() 的花销,即 v.budget -= fn.Func.Inl.Cost;否则就用预算减去 57,即 v.budget -= v.extraCallCost。
inlcalls
从函数头上的注释可知,inlcalls 就是遍历函数体的语句和表达式来用可内联的函数来替换调用。
// Inlcalls/nodelist/node walks fn's statements and expressions and substitutes any
// calls made to inlineable functions. This is the external entry point.
func inlcalls(fn *Node) {
savefn := Curfn
Curfn = fn
maxCost := int32(inlineMaxBudget)
if countNodes(fn) >= inlineBigFunctionNodes {
maxCost = inlineBigFunctionMaxCost
}
inlMap := make(map[*Node]bool)
fn = inlnode(fn, maxCost, inlMap)
if fn != Curfn {
Fatalf("inlnode replaced curfn")
}
Curfn = savefn
}
递归调用
对于递归调用,只有函数调用自己这种场景是不允许内联的,如果多个函数形成一个调用环,那么编译器依然会尝试着对其进行内联。
实例代码如下:
func C() {
println("C")
D()
}
func D() {
println("D")
C()
}
func main() {
C()
}
我们以scc = [C, D]为例来看一下编译器做内联的详细步骤:
-
遍历 scc,处理节点 C,遍历 C 的函数体
- Cost(C) = 2 + 2 + 57 = 61
- 2: 语句
println("C")的代价 - 2: 语句
D()本身的代价 - 57: 由于函数 D() 还没有经过
caninl()的内联检查,节点 D 中还没有 Inline 指针对象,判断出 D() 无法内联,所以需要减去一个 extraCallCost, 默认为 57 - 节点 C 的开销没有超过 80,判断出该函数可以内联,并为其创建内联属性 Inline
-
调用
inlcalls(C)对 C 进行内联操作,遍历 C 的函数体,并尝试着对每个语句进行内联:第一个语句println("C")不用内联,第二个语句D()不可以内联,原因如上面所说,函数 D 还没有经过caninl()的内联检查,内联属性Inline为空,不满足内联条件。到此对 C 的内联操作结束,并没有任何实际的内联操作发生。 -
遍历 scc,处理节点 D,遍历 D 的函数体
- Cost(D) = 2 + 2 + 61 = 65
- 2: 语句
println("D")的代价 - 2: 语句
C()本身的代价 - 61: 节点 C 中有 Inline 指针对象,判断出 C() 可以内联,所以需要减去 C 的 Cost,上面已算出 C 的 Cost 是 61
- 节点 D 的开销没有超过 80,判断出该函数可以内联,并为其创建内联属性 Inline
-
调用
inlcalls(D)对 D 进行内联操作,逻辑同第2步,不同的是编译器发现语句C()可以进行内联,于是完成内联后函数 D 变为:
func D() {
println("D")
// Inlined statements
println("C")
D()
}
可以执行命令go build -gcflags="-m -m" main.go来查看函数的 cost,打印结果如下:
.\main.go:3:6: can inline C with cost 61 as: func() { println("C"); D() }
.\main.go:8:6: can inline D with cost 65 as: func() { println("D"); C() }
.\main.go:10:3: inlining call to C func() { println("C"); D() }
.\main.go:13:6: can inline main with cost 63 as: func() { C() }
.\main.go:14:3: inlining call to C func() { println("C"); D() }
.\main.go:14:3: inlining call to D func() { println("D"); C() }
.\main.go:14:3: cannot inline C into main: repeated recursive cycle