Go语言编译优化 | 青训营笔记

243 阅读6分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 14 天

编译优化总结

  • 逃逸分析
  • 内联

逃逸分析

在一些语言中,如CC++,在栈还是堆上分配内存由程序员手动决定——堆分配使用mallocfree,而栈分配通过alloca。错误地使用这种机制会是导致内存错误的常见原因。

在 Go 中,如果一个值超过了函数调用的生命周期,编译器会自动将之移动到堆中。我们管这种现象叫:该值逃逸到了堆。

 type Foo struct {
     a, b, c, d int
 }

 func NewFoo() *Foo {
     return &Foo{a: 3, b: 1, c: 4, d: 7}
 }

在这个例子中,NewFoo 函数中分配的 Foo 将被移动到堆中,因此在 NewFoo 返回后 Foo 仍然有效。

这是从早期的 Go 就开始有的。与其说它是一种优化,不如说它是一种自动正确性特性。无法在 Go 中返回栈上分配的变量的地址。

同时编译器也可以做相反的事情;它可以找到堆上要分配的东西,并将它们移动到栈上。

逃逸分析例1

 // Sum 函数返回 0-100 的整数之和
 func Sum() int {
         const count = 100
         numbers := make([]int, count)
         for i := range numbers {
                 numbers[i] = i + 1
         }

         var sum int
         for _, i := range numbers {
                 sum += i
         }
         return sum
 }

Sum 将 0-100 的 ints型数字相加并返回结果。

因为 numbers 切片仅在 Sum函数内部使用,编译器将在栈上存储这100个整数而不是堆。也没有必要对 numbers进行垃圾回收,因为它会在 Sum 返回时自动释放。

调查逃逸分析

打印编译器关于逃逸分析的决策,使用-m标志。

% go build -gcflags=-m examples/esc/sum.go
# command-line-arguments
examples/esc/sum.go:8:17: Sum make([]int, count) does not escape
examples/esc/sum.go:22:13: answer escapes to heap
examples/esc/sum.go:22:13: main ... argument does not escape

第8行显示编译器已正确推断 make([]int, 100)的结果不会逃逸到堆。 第22行显示answer逃逸到堆的原因是fmt.Println是一个可变函数。 可变参数函数的参数被装入一个切片,在本例中为[]interface{},所以会将answer赋值为接口值,因为它是通过调用fmt.Println引用的。 从 Go 1.6(可能是)开始,垃圾收集器需要通过接口传递的所有值都是指针,编译器看到的是这样的:

 var answer = Sum()
 fmt.Println([]interface{&answer}...)

我们可以使用标识 -gcflags="-m -m" 来确定这一点。会返回:

examples/esc/sum.go:22:13: answer escapes to heap
examples/esc/sum.go:22:13:      from ... argument (arg to ...) at examples/esc/sum.go:22:13
examples/esc/sum.go:22:13:      from *(... argument) (indirection) at examples/esc/sum.go:22:13
examples/esc/sum.go:22:13:      from ... argument (passed to call[argument content escapes]) at examples/esc/sum.go:22:13
examples/esc/sum.go:22:13: main ... argument does not escape

总之,不要担心第22行,这对我们的讨论并不重要。

逃逸分析例2

package main
 
import "fmt"
 
type Point struct{ X, Y int }
 
const Width = 640
const Height = 480
 
func Center(p *Point) {
        p.X = Width / 2
        p.Y = Height / 2
}
 
func NewPoint() {
        p := new(Point)
        Center(p)
        fmt.Println(p.X, p.Y)
}

NewPoint 创建了一个 *Point 指针值 p。 我们将p传递给Center函数,该函数将点移动到屏幕中心的位置。最后我们打印出 p.Xp.Y的值。

 % go build -gcflags=-m examples/esc/center.go
# command-line-arguments
examples/esc/center.go:10:6: can inline Center
examples/esc/center.go:17:8: inlining call to Center
examples/esc/center.go:10:13: Center p does not escape
examples/esc/center.go:18:15: p.X escapes to heap
examples/esc/center.go:18:20: p.Y escapes to heap
examples/esc/center.go:16:10: NewPoint new(Point) does not escape
examples/esc/center.go:18:13: NewPoint ... argument does not escape
# command-line-arguments

尽管p是使用new分配的,但它不会存储在堆上,因为Center被内联了,所以没有p的引用会逃逸到Center函数。

内联

在 Go 中,函数调用有固定的开销;栈和抢占检查。

硬件分支预测器改善了其中的一些功能,但就功能大小和时钟周期而言,这仍然是一个成本。

内联是避免这些成本的经典优化方法。

内联只对叶子函数有效,叶子函数是不调用其他函数的。这样做的理由是:

  • 如果你的函数做了很多工作,那么前序开销可以忽略不计。
  • 另一方面,小函数为相对较少的有用工作付出固定的开销。这些是内联目标的功能,因为它们最受益。

还有一个原因就是严重的内联会使得堆栈信息更加难以跟踪。

内联例1

func Max(a, b int) int {
        if a > b {
                return a
        }
        return b
}
 
func F() {
        const a, b = 100, 20
        if Max(a, b) == b {
                panic(b)
        }
}

我们再次使用 -gcflags = -m 标识来查看编译器优化决策。

% go build -gcflags=-m examples/max/max.go
# command-line-arguments
examples/max/max.go:3:6: can inline Max
examples/max/max.go:12:8: inlining call to Max

编译器打印了两行信息:

  • 首先第3行,Max的声明告诉我们它可以内联
  • 其次告诉我们,Max的主体已经内联到第12行调用者中。

内联是什么样的?

编译 max.go 然后我们看看优化版本的 F() 变成什么样了。

% go build -gcflags=-S examples/max/max.go 2>&1 | grep -A5 '"".F STEXT'
"".F STEXT nosplit size=1 args=0x0 locals=0x0
        0x0000 00000 (/Users/dfc/devel/gophercon2018-performance-tuning-workshop/4-compiler-optimisations/examples/max/max.go:10)       TEXT    "".F(SB), NOSPLIT, $0-0
        0x0000 00000 (/Users/dfc/devel/gophercon2018-performance-tuning-workshop/4-compiler-optimisations/examples/max/max.go:10)       FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (/Users/dfc/devel/gophercon2018-performance-tuning-workshop/4-compiler-optimisations/examples/max/max.go:10)       FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (<unknown line number>)    RET
        0x0000 c3

一旦Max被内联到这里,这就是F的主体 - 这个函数什么都没干。我知道屏幕上有很多没用的文字,但是相信我的话,唯一发生的就是RET。实际上F变成了:

func F() {
        return
}

注意 : 利用 -S 的输出并不是进入二进制文件的最终机器码。链接器在最后的链接阶段进行一些处理。像FUNCDATAPCDATA这样的行是垃圾收集器的元数据,它们在链接时移动到其他位置。 如果你正在读取-S的输出,请忽略FUNCDATAPCDATA行;它们不是最终二进制的一部分。

调整内联级别

使用-gcflags=-l标识调整内联级别。有些令人困惑的是,传递一个-l将禁用内联,两个或两个以上将在更激进的设置中启用内联。

  • -gcflags=-l,禁用内联。
  • 什么都不做,常规的内联
  • -gcflags='-l -l' 内联级别2,更积极,可能更快,可能会制作更大的二进制文件。
  • -gcflags='-l -l -l' 内联级别3,再次更加激进,二进制文件肯定更大,也许更快,但也许会有 bug。
  • -gcflags=-l=4 (4个 -l) 在 Go 1.11 中将支持实验性的中间栈内联优化。