note.wcoder.com
wcoder GitHub

Table of Contents

[TOC]
High Performance Go Workshop(Part4)

4. 编译器优化

本节介绍了Go编译器执行的一些优化。

  • 逃逸分析

  • 内联

  • 去掉无效代码

这些都是全部在编译器的前端处理,而代码仍以 AST 形式处理;然后代码传递给 SSA 编译器以进行进一步优化。

4.1. Go编译器的历史

Go 编译器开始于 2007 年左右的 Plan9 编译器工具链的分叉。当时,编译器与 Aho and Ullman 的《Dragon Book》非常相似。

2015 年,当时的 Go 1.5 编译器是用 C 写的。

一年后,Go 1.7 引入了新的基于 SSA 技术的编译器后端,取代了以前的 Plan 9 样式代码生成。新的编译器后端为通用和体系结构特定的优化带来了许多机会。

4.2. 逃逸分析

我们讨论的第一个优化是逃逸分析。

为了说明逃逸分析的作用,我们回想起 Go 的规范没有提到堆或堆栈。它仅提及该语言在介绍中是被垃圾收集的,并且没有给出关于如何实现这一点的提示。

Go 规范的兼容 Go 实现可以存储堆上的每个分配。这将给垃圾收集器带来很大压力,但它绝不是不正确的 —— 数年来,gccgo 对逃逸分析的支持非常有限,因此可以有效地被视为在这种模式下运行。

但是,goroutine 的堆栈是存储局部变量的位置;没有必要在堆栈上进行垃圾回收。因此,如果这样做是安全的,则放置在堆栈上的分配将更有效。

在某些语言中,例如 C 和 C++,选择在堆栈上或堆上分配是程序员的手动操作的 —— 堆分配使用 malloc  和 free ,堆栈分配是通过 alloca 进行的。错误使用这些方法是内存 gg 的常见原因。

在 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 在返回后它的返回值仍然保持有效。

这在 Go 的最开始的时候就一直存在。与其说是优化,不如说是自动纠正功能。在 Go 中不可能意外返回堆栈分配变量的地址。

但编译器也可以做相反的事情;它可以找到假定在堆上分配的东西,并将它们移动到堆栈中。

让我们看一个例子

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 
}  

func main() {  
    answer := Sum() 
    fmt.Println(answer) 
}

Sum将累加 1 到 100,并返回结果。

由于数字切片仅在 Sum 内部引用,因此编译器将安排将该切片的 100 个整数存储在堆栈上,而不是堆上。不需要垃圾收集 numbers,当 Sum 返回时,它会自动释放。

4.2.1. 证明它!

要打印编译器的逃逸分析决定,请使用 -m 命令。

% go build -gcflags=-m examples/esc/sum.go 
# command-line-arguments 
examples/esc/sum.go:22:13: inlining call to fmt.Println 
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: io.Writer(os.Stdout) escapes to heap 
examples/esc/sum.go:22:13: main []interface {} literal does not escape 
<autogenerated>:1: os.(*File).close .this 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" 命令来确认。返回

% go build -gcflags='-m -m' examples/esc/sum.go 2>&1 | grep sum.go:22 
examples/esc/sum.go:22:13: inlining call to fmt.Println func(...interface {}) (int, error) { return fmt.Fprintln(io.Writer(os.Stdout), fmt.a...) } 
examples/esc/sum.go:22:13: answer escapes to heap examples/esc/sum.go:22:13:      from ~arg0 (assign-pair) at examples/esc/sum.go:22:13 
examples/esc/sum.go:22:13: io.Writer(os.Stdout) escapes to heap 
examples/esc/sum.go:22:13:      from io.Writer(os.Stdout) (passed to call[argument escapes]) at examples/esc/sum.go:22:13 
examples/esc/sum.go:22:13: main []interface {} literal does not escape

简而言之,不必担心第22行,这对本次讨论并不重要。

4.2.2. 练习

  • 此优化是否对所有 count 值都成立?

  • 如果 count是变量而不是常量,此优化是否成立?

  • 如果 count是 Sum 的参数,此优化是否成立?

4.2.3. 逃逸分析(续)

此示例是故意这样的。它不是真正的代码,只是一个例子。

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.X 和 p.Y 的值。

% go build -gcflags=-m examples/esc/center.go 
# command-line-arguments 
examples/esc/center.go:11:6: can inline Center 
examples/esc/center.go:18:8: inlining call to Center 
examples/esc/center.go:19:13: inlining call to fmt.Println 
examples/esc/center.go:11:13: Center p does not escape 
examples/esc/center.go:19:15: p.X escapes to heap 
examples/esc/center.go:19:20: p.Y escapes to heap 
examples/esc/center.go:19:13: io.Writer(os.Stdout) escapes to heap 
examples/esc/center.go:17:10: NewPoint new(Point) does not escape 
examples/esc/center.go:19:13: NewPoint []interface {} literal does not escape 
<autogenerated>:1: os.(*File).close .this does not escape

即使 p 是使用新函数分配的,也不会将其存储在堆上,因为p的引用没有逃逸到 Center函数。

问题:第19行,如果 p 不逃逸,那什么是逃逸到堆?

4.3. 内联

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

这在一定程度上可以通过硬件分支预测器得到改善,但在函数大小和时钟周期方面仍然存在开销。

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

直到 Go 1.11 内联仅适用于 _leaf  _函数,该函数不调用另一个函数。这样做的理由是:

  • 如果函数执行大量工作,则前导开销可以忽略不计。这就是为什么函数超过一定大小(目前一些指令计数,加上一些操作,防止所有内联(例如,在 Go 1.7 之前的 switch)

  • 另一方面,小型函数会为执行的相对较小的有用的工作进行固定的开销。这些是内联目标的功能,因为它们受益最大。

另一个原因是沉重的内联使堆栈跟踪更难跟踪。

4.3.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/inl/max.go 
# command-line-arguments examples/inl/max.go:4:6: can inline Max 
examples/inl/max.go:11:6: can inline F 
examples/inl/max.go:13:8: inlining call to Max 
examples/inl/max.go:20:6: can inline main 
examples/inl/max.go:21:3: inlining call to F 
examples/inl/max.go:21:3: inlining call to Max

编译器打印了两行。

  • 第一行在第3行,Max的声明,告诉我们它可以内联。

  • 第二个是 Max 函数已在第 12 行内联到调用函数中。

在不使用 //go:noinline 注释的情况下,重写 Max,使其仍返回正确的答案,但编译器不再认为它可内联。

4.3.2. 内联是什么样的?

编译 max.go 并查看优化版本的 F() 变成什么样了。

% go build -gcflags=-S examples/inl/max.go 2>&1 | grep -A5 '"".F STEXT' 
"".F STEXT nosplit size=2 args=0x0 locals=0x0  
0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:11)     TEXT    "".F(SB), NOSPLIT|ABIInternal, $0-0 
0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:11)     FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 
0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:11)     FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 
0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:11)     FUNCDATA        $3, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 
0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:13)     PCDATA  $2, $0

一旦 Max被内联到 F中,它就是 F 的主体,这个函数中什么也没有发生。我知道屏幕上有很多文字,但请相信我,唯一发生的事情是 RET。实际上 F 变成了:

func F() {  
    return 
}

4.3.3. 讨论

为什么我在 F()中将 a 和 b 声明为常量?

实验 a 和 b 作为变量声明的输出会发生什么情况?如果 a 和 b 作为参数传入 F(),会发生什么情况?

-gcflags=-S 不会阻止工作目录中构建最终的二进制文件。如果发现随后的 go build …运行没有输出,请删除工作目录中的 ./max 二进制文件。

4.3.4. 调整内联级别

使用 -gcflags=-l 标志执行调整内联级别。传递一个 -l 将禁用内联,两个或更多将在更激进设置下启用内联。

  • -gcflags=-l,内联已禁用。

  • 没有参数的话,定期内联。

  • -gcflags='-l -l' 内联级别2,更具侵略性,可能更快,可能会使更大的二进制文件。

  • -gcflags='-l -l -l' 内联级别3,再次更具攻击性,二进制文件肯定更大,也许再次更快,但也可能是错误。

  • -gcflags=-l=4(four -ls) 在 Go 1.11 将启用实验中间堆栈内联优化。

4.3.5. 中间栈内联

自Go1.12以来,所谓的中间堆栈内联已经启用(它以前在 Go 1.11 的预览版中可用,带有 -gcflags='-l -l -l -l' 标志)。

我们可以在前面的例子中看到一个中间栈内联的例子。在 Go 1.11 和更早的版本中,F 不应该是一个 leaf 函数-它调用了 max。但是由于内联改进,F现在内联到它的调用方中。这有两个原因。当 max被内嵌到 F 中时,F 不包含其他函数调用,因此它成为一个潜在的 leaf 函数,假设其复杂度预算没有被超过。因为 F 是一个简单的函数,所以无效代码消除消除了很多复杂的预算。

中间堆栈内联可用于内联函数的快速路径,从而消除了快速路径中的函数调用开销。最近在 Go 1.13 中引入的 CL 展示了将此技术应用于 sync.RWMutex.Unlock()。

进一步阅读

4.4. 消除无效代码

为什么 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 是常数,编译器可以在编译时证明分支永远不会是 false;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) 
    } 
}

现在,分支的结果是知道的,然后 result的内容也已知。这是调用分支消除。

func F() {  
    const a, b = 100, 20 
    const result = a 
    if result == b { 
        panic(b) 
    } 
}

现在,分支被消除,我们知道 result总是等于 a,并且因为 a 是常量,我们知道 result是常量。编译器将此证明应用于第二个分支

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

4.4.1. 消除无效代码(续)

分支消除是称为无效代码消除的优化类别之一。实际上,使用静态证明来显示一段代码永远无法到达,俗称 "dead",因此,无需在最终二进制文件中对其进行编译,优化或发布。

我们看到了无效代码消除如何与内联协同工作,以减少通过删除被证明无法访问的循环和分支生成的代码量。

你可以利用这一点来实现开销很大的调试,并将其隐藏在背后

const debug = false

与构建标签结合使用,这可能非常有用。

4.4.2. 进一步阅读

4.5. 编译器命令练习

提供了编译器命令:

go build -gcflags=$FLAGS

调查以下编译器函数的操作:

  • -S 打印正在编译的包装的(Go flavoured)组件。

  • -l 控制内衬人的行为;-l 禁用内联,-l -l 增加它(更多 -l 增加了编译器对内联代码的胃口)。试验编译时间、程序大小和运行时间的差异。

  • -m 控制优化决策的打印,如内联、逃逸分析。-m-m` 打印了有关编译器想法的更多详细信息。

  • -l -N 禁用所有优化。

如果发现随后的 go build … 运行没有输出,请删除工作目录中的 ./max 二进制文件。

4.5.1. 进一步阅读

4.6. 边界检查消除

Go  是一种有边界检查语言。这意味着将检查数组和切片下标操作,以确保它们在各自类型的范围内。

对于数组,可以在编译时完成此操作。对于切片,必须在运行时完成此操作。

var v = make([]int, 9)   
var A, B, C, D, E, F, G, H, I int   
func BenchmarkBoundsCheckInOrder(b *testing.B) {  
    for n := 0; n < b.N; n++ { 
        A = v[0] 
        B = v[1] 
        C = v[2] 
        D = v[3] 
        E = v[4] 
        F = v[5] 
        G = v[6] 
        H = v[7] 
        I = v[8] 
    } 
}

使用 -gcflags=-S反汇编 BenchmarkBoundsCheckInOrder。每个循环执行多少个边界检查操作?

func BenchmarkBoundsCheckOutOfOrder(b *testing.B) {  
    for n := 0; n < b.N; n++ { 
        I = v[8] 
        A = v[0] 
        B = v[1] 
        C = v[2]
        D = v[3] 
        E = v[4] 
        F = v[5] 
        G = v[6] 
        H = v[7] 
    } 
}

重新排列我们分配 A 到 I 的顺序会影响程序。

拆开 BenchmarkBoundsCheckOutOfOrder并找出答案。

4.6.1. 练习

  • 重新排列下标操作的顺序是否会影响函数的大小?它会影响功能的速度吗?

  • 如果将 v 移入Benchmark函数内会怎样?

  • 如果将 v 声明为数组 var v [9]int,会发生什么?

← Previous Next →
Less
More