编译优化

逃逸分析
内联
死码消除
Go 编译器在2007年左右开始作为 Plan9 编译器工具链的一个分支。当时的编译器与 Aho 和 Ullman 的 Dragon Book 非常相似。



2015年,当时的 Go 1.5 编译器 从 C 机械地翻译成 Go。



一年后,Go 1.7 引入了一个基于 SSA 技术的 新编译器后端 ,取代了之前的 Plan 9风格的代码。这个新的后端为泛型和体系结构特定的优化提供了许多可能。

在 Go spec 中没有提到堆和栈,它只提到 Go 语言是有垃圾回收的,但也没有说明如何是如何实现的。



一个遵循 Go spec 的 Go 实现可以将每个分配操作都在堆上执行。这会给垃圾回收器带来很大压力,但这样做是绝对错误的 – 多年来,gccgo对逃逸分析的支持非常有限,所以才导致这样做被认为是有效的。



然而,goroutine 的栈是作为存储局部变量的廉价场所而存在;没有必要在栈上执行垃圾回收。因此,在栈上分配内存也是更加安全和有效的。



在一些语言中,如C和C++,在栈还是堆上分配内存由程序员手动决定——堆分配使用malloc 和free,而栈分配通过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 中返回栈上分配的变量的地址。



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



要打印编译器关于逃逸分析的决策,请使用-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



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



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)
}



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 中,函数调用有固定的开销;栈和抢占检查。



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



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



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



如果你的函数做了很多工作,那么前序开销可以忽略不计。
另一方面,小函数为相对较少的有用工作付出固定的开销。这些是内联目标的功能,因为它们最受益。
还有一个原因就是严重的内联会使得堆栈信息更加难以跟踪。



我们再次使用 -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行调用者中。



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



-gcflags=-l,禁用内联。
什么都不做,常规的内联
-gcflags=’-l -l’ 内联级别2,更积极,可能更快,可能会制作更大的二进制文件。
-gcflags=’-l -l -l’ 内联级别3,再次更加激进,二进制文件肯定更大,也许更快,但也许会有 bug。
-gcflags=-l=4 (4个 -l) 在 Go 1.11 中将支持实验性的 中间栈内联优化。
死码消除
为什么a和b是常数很重要?



为了理解发生了什么,让我们看一下编译器在把Max内联到F中的时候看到了什么。我们不能轻易地从编译器中获得这个,但是直接手动完成它。



Before:



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)
}
}
After:



func F() {
const a, b = 100, 20
var result int
if a > b {
result = a
} else {
result = b
}
if result == b {
panic(b)
}
}
因为a和b是常量,所以编译器可以在编译时证明分支永远不会是假的;100总是大于20。因此它可以进一步优化 F 为



func F() {
const a, b = 100, 20
var result int
if true {
result = a
} else {
result = b
}
if result == b {
panic(b)
}
}
既然分支的结果已经知道了,那么结果的内容也就知道了。这叫做分支消除。



func F() {
const a, b = 100, 20
const result = a
if result == b {
panic(b)
}
}
现在分支被消除了,我们知道结果总是等于a,并且因为a是常数,我们知道结果是常数。 编译器将此证明应用于第二个分支



func F() {
const a, b = 100, 20
const result = a
if false {
panic(b)
}
}
并且再次使用分支消除,F的最终形式减少成这样。



func F() {
const a, b = 100, 20
const result = a
}
最后就变成



func F() {
}
分支消除是一种被称为死码消除的优化。实际上,使用静态证明来表明一段代码永远不可达,通常称为死代码,因此它不需要在最终的二进制文件中编译、优化或发出。



我们发现死码消除与内联一起工作,以减少循环和分支产生的代码数量,这些循环和分支被证明是不可到达的。



你可以利用这一点来实现昂贵的调试,并将其隐藏起来



const debug = false
结合构建标记,这可能非常有用。


Category golang