编译优化
本节介绍Go编译器执行的三个重要优化。
- 逃逸分析
- 内联
- 死码消除
Go 编译器的历史
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 的栈是作为存储局部变量的廉价场所而存在;没有必要在栈上执行垃圾回收。因此,在栈上分配内存也是更加安全和有效的。
CC++mallocfreealloca
在 Go 中,如果一个值超过了函数调用的生命周期,编译器会自动将之移动到堆中。我们管这种现象叫:该值逃逸到了堆。
type Foo struct {
a, b, c, d int
}
func NewFoo() *Foo {
return &Foo{a: 3, b: 1, c: 4, d: 7}
}
NewFooFooNewFooFoo
这是从早期的 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
}
Sumints
numbersSumnumbersSum
调查逃逸分析
证明它!
-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
make([]int, 100)
answerfmt.Println[]interface{}answerfmt.Println
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*PointppCenterp.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
pnewCenterpCenter
内联
在 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
编译器打印了两行信息:
MaxMax
内联是什么样的?
max.goF()
% 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
MaxRETF
func F() {
return
}
-SFUNCDATAPCDATA-SFUNCDATAPCDATA
调整内联级别
-gcflags=-l-l
-gcflags=-l-gcflags='-l -l'-gcflags='-l -l -l'-gcflags=-l=4-l
死码消除
ab
MaxF
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)
}
}
ab10020F
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)
}
}
aa
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
结合构建标记,这可能非常有用。
进一步阅读
编译器标识练习
编译器标识提供如下:
go build -gcflags=$FLAGS
研究以下编译器功能的操作:
-S-l-l-l -l-l-l-m-m-l -N
go build ..../max