Go 编译器优化

2022-11-22 14:59:45 浏览数 (1)

《从.go 文本文件到可执行文件》一文中,我们简单描述了 Go 编译器的工作流程。本文将继续深入其中的一些代码优化的工作。

前情回顾

死代码消除

死代码消除( dead code elimination, 缩写 DCE )是用来移除对程序执行结果没有任何影响的代码,以此 减少程序的体积大小 ,并且还可以避免程序在执行过程中进行一些不必要的运算行为,从而 减少执行时间

需要注意的是,除了不会执行到的代码( unreachable code ),一些只会影响到无关程序执行结果的变量( dead variables ),也属于死码( dead code )的范畴。

简单示例:

代码语言:javascript复制
package main

func main() {
 const a, b = 200, 100
 var max int
 if a > b {
  max = a
 } else {
  max = b
 }
 if max == b {
  panic(b)
 }
}

对于常量 ab ,编译器在编译时可以判断出 a 永远是大于 b 的,即 a > b 永远为 true ,也就是说 else {} 分支属于 unreachable code 将永远不会被执行,所以编译器会进行第一次优化:分支消除

代码语言:javascript复制
package main

func main() {
 const a, b = 200, 100
 const max = a
 if max == b {
  panic(b)
 }
}

由于 max 变量后续没有再被引用,所以 max 实际也是一个常量。相同道理,max == b 永远为 false ,编译器会进行第二次分支消除优化:

代码语言:javascript复制
package main

func main() {
 const a, b = 200, 100
 const max = a
}

对于剩下的常量则明显属于 dead variables ,再次优化:

代码语言:javascript复制
package main

func main() {
}

我们可以查看最初程序的 SSA 生成过程来验证:

代码语言:javascript复制
$ GOSSAFUNC=main go build main.go

查看生成的 ssa.html

死代码消除过程

最终生成的 SSA

可以看到,main 函数内的所有逻辑确实都被编译器优化掉了。

函数内联

如果程序中存在大量的小函数的调用,函数内联(function call inlining)就会直接用函数体替换掉函数调用来 减少因为函数调用而造成的额外上下文切换开销

简单示例:

代码语言:javascript复制
package main

func main() {
 n := 1
 for i := 0; i < 10; i   {
  n = double(n)
 }
 println(n)
}

func double(n int) int {
 return 2 * n
}

对于上面的代码,编译器内联优化后会变成:

代码语言:javascript复制
package main

func main() {
 n := 1
 for i := 0; i < 10; i   {
  n = 2 * n
 }
 println(n)
}

Go 编译器会计算函数内联所花费的成本,只有满足相关策略时才会进行内联优化,最简单的当函数内有 godeferselect 等关键字时就不会发生内联,具体的策略可以直接查看源码:

内联优化相关源码

使用 go tool compile -m=2 main.gogo build -gcflags="-m -m" main.go 可以输出内联优化的相关信息( -m 的数量越多输出结果越详细)

代码语言:javascript复制
$ go tool compile -m=2 main.go
main.go:11:6: can inline double with cost 4 as: func(int) int { return 2 * n }
main.go:3:6: can inline main with cost 28 as: func() { n := 1; for loop; println(n) }
main.go:6:13: inlining call to double

或者也可以输出汇编代码查看是否有进行 double 函数的调用,这里显然是没有的:

代码语言:javascript复制
$ go tool compile -S main.go | grep CALL.*double

如果我们不想一个函数被内联,可以直接在其函数定义时加一个 //go:noinline 注释:

代码语言:javascript复制
//go:noinline
func double(n int) int {
 return 2 * n
}

同样可以进行验证:

代码语言:javascript复制
$ go tool compile -S main.go | grep CALL.*double
        0x0025 00037 (main.go:6)        CALL    "".double(SB)
$ go tool compile -m=2 main.go
main.go:12:6: cannot inline double: marked go:noinline
main.go:3:6: cannot inline main: function too complex: cost 81 exceeds budget 80

可以看到此时还输出了函数无法内联的原因。

如果希望所有函数都不执行内联操作,可以直接为编译器选项加上 -l 参数,即 go build -gcflags="-l" main.go (如果 -l 数量大于等于 2 ,编译器将会采用更激进的内联策略,但也可能会生成更大的二进制文件)。

正常情况,我们直接使用编译器默认选项即可。

逃逸分析

不同于 C 语言的手动内存管理方式(通过 malloc 分配堆内存对象, free 手动释放),带有 GC 机制的 Go 语言在编译阶段会进行逃逸分析,自动决定将变量分配到 goroutine 的栈(stack)内存区或者全局的堆(heap)内存区上

其中的逃逸规则有很多,最简单的一种是:如果变量超出了函数调用的生命周期,编译器就会将其逃逸到堆上。

简单示例:

代码语言:javascript复制
package main

func main() {
 A()
 B()
}

func A() int {
 a := 1024
 return a
}

func B() *int {
 b := 1024
 return &b
}

重点关注返回指针类型的 B 函数,通过 go tool compile -l -m=2 main.go 来查看逃逸结果( -l 是全局禁止函数内联,避免影响逃逸分析):

代码语言:javascript复制
$ go tool compile -l -m=2 main.go
main.go:14:2: b escapes to heap:
main.go:14:2:   flow: ~r0 = &b:
main.go:14:2:     from &b (address-of) at main.go:15:9
main.go:14:2:     from return &b (return) at main.go:15:2
main.go:14:2: moved to heap: b

根据结果可以看出 B 函数中分配的变量 b 逃逸到了堆上(moved to heap: b),而对于全程在 A 函数生命周期内的 a 变量则没有发生逃逸(直接在栈上分配了)。

代码语言:javascript复制
$ go tool compile -S main.go | grep runtime.newobject
        0x0020 00032 (main.go:14)       CALL    runtime.newobject(SB)
        rel 33 4 t=7 runtime.newobject 0

从汇编来看,也只有在 main.go:14 (对应源码:b := 1024 )位置处才调用了 runtime.newobject 函数。

runtime.newobject 源码

runtime.newobject 函数的作用正是执行 malloc 动作 在堆上分配内存

在栈上分配内存,将直接由 CPU 提供 push(入栈)和 pop(出栈) 指令支持,但在堆上分配,就需要额外等待 Go GC 负责回收,虽然 Go GC 十分高效,但也不可避免会造成一定的性能损耗。

所以如果想要追求极致性能,我们就要尽量避免一些不必要的堆内存分配。

0 人点赞