高性能 Go 代码工坊(Part4)

Go Official Blog

共 11221字,需浏览 23分钟

 · 2021-04-11

4. 编译器优化


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

 逃逸分析 内联 消除死码


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


4.1. Go 编译器的历史

Go 编译器大约在 2007 年开始作为 Plan9 编译器工具链的一个分支。当时的编译器与  Aho 和 Ullman  的《Dragon Book》(龙书)非常相似。

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

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


4.2. 逃逸分析

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

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

Go 规范的兼容 Go spec 可以将每个分配存储在堆中。这将给垃圾收集器带来很大压力,但它绝不是不正确的 —— 几年来,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 在返回后,它的返回值仍然保持有效。

这在 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-argumentsexamples/esc/sum.go:22:13: inlining call to fmt.Printlnexamples/esc/sum.go:8:17: Sum make([]int, count) does not escapeexamples/esc/sum.go:22:13: answer escapes to heapexamples/esc/sum.go:22:13: io.Writer(os.Stdout) escapes to heapexamples/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:22examples/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 heapexamples/esc/sum.go:22:13:      from ~arg0 (assign-pair) at examples/esc/sum.go:22:13examples/esc/sum.go:22:13: io.Writer(os.Stdout) escapes to heapexamples/esc/sum.go:22:13:      from io.Writer(os.Stdout) (passed to call[argument escapes]) at examples/esc/sum.go:22:13examples/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 = 640const Height = 480func 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  函数,该函数将 point 移动到屏幕中心的位置。最后,我们打印 p.X 和 p.Y 的值。

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


即使 p 是用 new 函数分配的,它也不会被存储在堆中,因为没有引用  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-argumentsexamples/inl/max.go:4:6: can inline Maxexamples/inl/max.go:11:6: can inline Fexamples/inl/max.go:13:8: inlining call to Maxexamples/inl/max.go:20:6: can inline mainexamples/inl/max.go:21:3: inlining call to Fexamples/inl/max.go:21:3: inlining call to Max


编译器打印了两行。

 第一行在第 3 行,Max 的声明,告诉我们它可以内联。 第二个是在第 12 行, Max  函数内联到调用函数中。


在不使用 //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}

What are FUNCDATA and PCDATA? The output from -S is not the final machine code that goes into your binary. The linker does some processing during the final link stage. Lines like FUNCDATA and PCDATA are metadata for the garbage collector which are moved elsewhere when linking. If you’re reading the output of -S, just ignore FUNCDATA and PCDATA lines; they’re not part of the final binary.

什么是 FUNCDATA 和 PCDATA? > _ 从 -S 输出的并不是最终进入你的二进制的机器代码。在最后的链接阶段,链接器会做一些处理。像 FUNCDATA 和 PCDATA 这样的行是垃圾收集器的元数据,在链接时被转移到其他地方。如果你正在读取 -S 的输出,只需忽略 FUNCDATA 和 PCDATA 行,它们不是最终二进制的一部分。



4.3.3. 讨论


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

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

-gcflags=-S doesn’t prevent the final binary being build in your working directory. If you find that subsequent runs of go build … produce no output, delete the ./max binary in your working directory.

-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 (四个 -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 是一个简单的函数 —— 内联和消除死码已经消除了它的大部分复杂性预算 —— 它适合于中间堆栈内联,而不管调用 max 是什么。

Mid stack inlining can be used to inline the fast path of a function, eliminating the function call overhead in the fast path. This recent CL which landed in for Go 1.13 shows this technique applied to sync.RWMutex.Unlock().
中间堆栈内联可用于内联函数的快速路径,从而消除了快速路径中的函数调用开销。最近在 Go 1.13 中引入的  CL 展示了将此技术应用于 sync.RWMutex.Unlock ()。


进一步阅读

Mid-stack inlining in the Go compiler presentation by David LazarProposal: Mid-stack inlining in the Go compiler


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. 进一步阅读


Using // +build to switch between debug and release buildsHow to use conditional compilation with the go build tool



4.5. 编译器命令练习


提供了编译器命令:

go build -gcflags=$FLAGS


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

-S 打印正在编译的包装的(Go flavoured)组件。-l 控制内联器的行为;-l 禁用内联,-l -l 增加它(更多 -l 增加了编译器对内联代码的胃口)。试验编译时间、程序大小和运行时间的差异。-m 控制优化决策的打印,如内联、逃逸分析。-m-m`  打印了关于编译器思考的细节。-l -N 禁用所有优化。


4.5.1. 进一步阅读

Codegen Inspection by Jaana Burcu Dogan


4.6. 边界检查消除


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

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

var v = make([]int, 9)var A, B, C, D, E, F, G, H, I intfunc 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,会发生什么?


浏览 11
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报