标签 内联 下的文章

上一篇文章中我论述了 叶子内联 leaf inlining 是怎样让 Go 编译器减少函数调用的开销的,以及延伸出了跨函数边界的优化的机会。本文中,我要论述内联的限制以及叶子内联与 栈中内联 mid-stack inlining 的对比。

内联的限制

把函数内联到它的调用处消除了调用的开销,为编译器进行其他的优化提供了更好的机会,那么问题来了,既然内联这么好,内联得越多开销就越少,为什么不尽可能多地内联呢?

内联可能会以增加程序大小换来更快的执行时间。限制内联的最主要原因是,创建许多函数的内联副本会增加编译时间,并导致生成更大的二进制文件的边际效应。即使把内联带来的进一步的优化机会考虑在内,太激进的内联也可能会增加生成的二进制文件的大小和编译时间。

内联收益最大的是小函数,相对于调用它们的开销来说,这些函数做很少的工作。随着函数大小的增长,函数内部做的工作与函数调用的开销相比省下的时间越来越少。函数越大通常越复杂,因此优化其内联形式相对于原地优化的好处会减少。

内联预算

在编译过程中,每个函数的内联能力是用内联预算计算的 1 。开销的计算过程可以巧妙地内化,像一元和二元等简单操作,在 抽象语法数 Abstract Syntax Tree (AST)中通常是每个节点一个单位,更复杂的操作如 make 可能单位更多。考虑下面的例子:

package main

func small() string {
    s := "hello, " + "world!"
    return s
}

func large() string {
    s := "a"
    s += "b"
    s += "c"
    s += "d"
    s += "e"
    s += "f"
    s += "g"
    s += "h"
    s += "i"
    s += "j"
    s += "k"
    s += "l"
    s += "m"
    s += "n"
    s += "o"
    s += "p"
    s += "q"
    s += "r"
    s += "s"
    s += "t"
    s += "u"
    s += "v"
    s += "w"
    s += "x"
    s += "y"
    s += "z"
    return s
}

func main() {
    small()
    large()
}

使用 -gcflags=-m=2 参数编译这个函数能让我们看到编译器分配给每个函数的开销:

% go build -gcflags=-m=2 inl.go
# command-line-arguments
./inl.go:3:6: can inline small with cost 7 as: func() string { s := "hello, world!"; return s }
./inl.go:8:6: cannot inline large: function too complex: cost 82 exceeds budget 80
./inl.go:38:6: can inline main with cost 68 as: func() { small(); large() }
./inl.go:39:7: inlining call to small func() string { s := "hello, world!"; return s }

编译器根据函数 func small() 的开销(7)决定可以对它内联,而 func large() 的开销太大,编译器决定不进行内联。func main() 被标记为适合内联的,分配了 68 的开销;其中 small 占用 7,调用 small 函数占用 57,剩余的(4)是它自己的开销。

可以用 -gcflag=-l 参数控制内联预算的等级。下面是可使用的值:

  • -gcflags=-l=0 默认的内联等级。
  • -gcflags=-l(或 -gcflags=-l=1)取消内联。
  • -gcflags=-l=2-gcflags=-l=3 现在已经不使用了。和 -gcflags=-l=0 相比没有区别。
  • -gcflags=-l=4 减少非叶子函数和通过接口调用的函数的开销。 2

不确定语句的优化

一些函数虽然内联的开销很小,但由于太复杂它们仍不适合进行内联。这就是函数的不确定性,因为一些操作的语义在内联后很难去推导,如 recoverbreak。其他的操作,如 selectgo 涉及运行时的协调,因此内联后引入的额外的开销不能抵消内联带来的收益。

不确定的语句也包括 forrange,这些语句不一定开销很大,但目前为止还没有对它们进行优化。

栈中函数优化

在过去,Go 编译器只对叶子函数进行内联 —— 只有那些不调用其他函数的函数才有资格。在上一段不确定的语句的探讨内容中,一次函数调用就会让这个函数失去内联的资格。

进入栈中进行内联,就像它的名字一样,能内联在函数调用栈中间的函数,不需要先让它下面的所有的函数都被标记为有资格内联的。栈中内联是 David Lazar 在 Go 1.9 中引入的,并在随后的版本中做了改进。这篇文稿深入探究了保留栈追踪行为和被深度内联后的代码路径里的 runtime.Callers 的难点。

在前面的例子中我们看到了栈中函数内联。内联后,func main() 包含了 func small() 的函数体和对 func large() 的一次调用,因此它被判定为非叶子函数。在过去,这会阻止它被继续内联,虽然它的联合开销小于内联预算。

栈中内联的最主要的应用案例就是减少贯穿函数调用栈的开销。考虑下面的例子:

package main

import (
    "fmt"
    "strconv"
)

type Rectangle struct {}

//go:noinline
func (r *Rectangle) Height() int {
    h, _ := strconv.ParseInt("7", 10, 0)
    return int(h)
}

func (r *Rectangle) Width() int {
    return 6
}

func (r *Rectangle) Area() int { return r.Height() * r.Width() }

func main() {
    var r Rectangle
    fmt.Println(r.Area())
}

在这个例子中, r.Area() 是个简单的函数,调用了两个函数。r.Width() 可以被内联,r.Height() 这里用 //go:noinline 指令标注了,不能被内联。 3

% go build -gcflags='-m=2' square.go                                                                                                          
# command-line-arguments
./square.go:12:6: cannot inline (*Rectangle).Height: marked go:noinline                                                                               
./square.go:17:6: can inline (*Rectangle).Width with cost 2 as: method(*Rectangle) func() int { return 6 }
./square.go:21:6: can inline (*Rectangle).Area with cost 67 as: method(*Rectangle) func() int { return r.Height() * r.Width() }                       
./square.go:21:61: inlining call to (*Rectangle).Width method(*Rectangle) func() int { return 6 }                                                     
./square.go:23:6: cannot inline main: function too complex: cost 150 exceeds budget 80                        
./square.go:25:20: inlining call to (*Rectangle).Area method(*Rectangle) func() int { return r.Height() * r.Width() }
./square.go:25:20: inlining call to (*Rectangle).Width method(*Rectangle) func() int { return 6 }

由于 r.Area() 中的乘法与调用它的开销相比并不大,因此内联它的表达式是纯收益,即使它的调用的下游 r.Height() 仍是没有内联资格的。

快速路径内联

关于栈中内联的效果最令人吃惊的例子是 2019 年 Carlo Alberto Ferraris 通过允许把 sync.Mutex.Lock() 的快速路径(非竞争的情况)内联到它的调用方来提升它的性能。在这个修改之前,sync.Mutex.Lock() 是个很大的函数,包含很多难以理解的条件,使得它没有资格被内联。即使锁可用时,调用者也要付出调用 sync.Mutex.Lock() 的代价。

Carlo 把 sync.Mutex.Lock() 分成了两个函数(他自己称为 外联 outlining )。外部的 sync.Mutex.Lock() 方法现在调用 sync/atomic.CompareAndSwapInt32() 且如果 CAS( 比较并交换 Compare and Swap )成功了之后立即返回给调用者。如果 CAS 失败,函数会走到 sync.Mutex.lockSlow() 慢速路径,需要对锁进行注册,暂停 goroutine。 4

% go build -gcflags='-m=2 -l=0' sync 2>&1 | grep '(*Mutex).Lock'
../go/src/sync/mutex.go:72:6: can inline (*Mutex).Lock with cost 69 as: method(*Mutex) func() { if "sync/atomic".CompareAndSwapInt32(&m.state, 0, mutexLocked) { if race.Enabled {  }; return  }; m.lockSlow() }

通过把函数分割成一个简单的不能再被分割的外部函数,和(如果没走到外部函数就走到的)一个处理慢速路径的复杂的内部函数,Carlo 组合了栈中函数内联和编译器对基础操作的支持,减少了非竞争锁 14% 的开销。之后他在 sync.RWMutex.Unlock() 重复这个技巧,节省了另外 9% 的开销。

相关文章:

  1. Go 中的内联优化
  2. goroutine 的栈为什么会无限增长?
  3. 栈追踪和 errors 包
  4. 零值是什么,为什么它很有用?

  1. 不同发布版本中,在考虑该函数是否适合内联时,Go 编译器对同一函数的预算是不同的。
  2. 时刻记着编译器的作者警告过“更高的内联等级(比 -l 更高)可能导致错误或不被支持”。 Caveat emptor。
  3. 编译器有足够的能力来内联像 strconv.ParseInt 的复杂函数。作为一个实验,你可以尝试去掉 //go:noinline 注释,使用 -gcflags=-m=2 编译后观察。
  4. race.Enable 表达式是通过传递给 go 工具的 -race 参数控制的一个常量。对于普通编译,它的值是 false,此时编译器可以完全省略代码路径。

via: https://dave.cheney.net/2020/05/02/mid-stack-inlining-in-go

作者:Dave Cheney 选题:lujun9972 译者:lxbwolf 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

本文讨论 Go 编译器是如何实现内联的,以及这种优化方法如何影响你的 Go 代码。

请注意:本文重点讨论 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 初始值为 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

内联的限制

本文中我论述的内联称作 叶子内联 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 中隐藏的编译指令

  1. 在 Go 中,一个方法就是一个有预先定义的形参和接受者的函数。假设这个方法不是通过接口调用的,调用一个无消耗的函数所消耗的代价与引入一个方法是相同的。
  2. 在 Go 1.14 以前,栈检查的前置处理也被垃圾回收器用于 STW,通过把所有活跃的 goroutine 栈空间设为 0,来强制它们切换为下一次函数调用时的运行时状态。这个机制最近被替换为一种新机制,新机制下运行时可以不用等 goroutine 进行函数调用就可以暂停 goroutine。
  3. 我用 //go:noinline 编译指令来阻止编译器内联 max。这是因为我想把内联 max 的影响与其他影响隔离开,而不是用 -gcflags='-l -N' 选项在全局范围内禁止优化。关于 //go: 注释在这篇文章中详细论述。
  4. 你可以自己通过比较 go test -bench=. -gcflags=-S 有无 //go:noinline 注释时的不同结果来验证一下。
  5. 你可以用 -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中国 荣誉推出