Golang函数内联原理剖析

1,498 阅读8分钟

前言

当程序调用一个函数的时候,是需要一定的开销来保存当前函数的上下文信息的,比如保存原来的栈指针,保存某些寄存器的值,保存返回地址等等。如果在编译期能做一些优化,减少这些开销,那么带来的优化效果是很显著的。这个优化方法就是本篇文章的主题——函数内联(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]为例来看一下编译器做内联的详细步骤:

  1. 遍历 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
  2. 调用inlcalls(C)对 C 进行内联操作,遍历 C 的函数体,并尝试着对每个语句进行内联:第一个语句println("C")不用内联,第二个语句D()不可以内联,原因如上面所说,函数 D 还没有经过caninl()的内联检查,内联属性 Inline 为空,不满足内联条件。到此对 C 的内联操作结束,并没有任何实际的内联操作发生。

  3. 遍历 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
  4. 调用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

参考: gocompiler.shizhz.me/