GO内联优化
礼物说本文诣在让读者了解Go中内联(inlining)优化的一些基础知识以及一些常见的场景是否会与内联相关。
一直对于内联优化一知半解.仅仅知道Go编译器会进行内联优化,却并不知道它真正会做什么?意义是什么?又会带来哪些问题?而在实际的业务开发中也碰到过一些问题迟迟没有解惑:
- 内联优化是否会丢失堆栈信息?
- pprof中的火焰图,经常会向下缺失函数,是否是因为内联?
- Go1.14版本之后
defer
是如何内联的?如何解决panic后的调用问题?
带着这些问题,我尝试去查看一些资料+本地调试,补全这块拼图。
内联优化
什么是内联优化
内联(inlining),其优化的对象为函数,也称为函数内联。内联优化就是把简短的函数在调用它的地方展开。早期都是由程序员手动实现的,而现在内联则是编译过程中基础的优化实现。
为什么要内联优化
内联优化一是可以消除函数调用本身的开销.二是让编译器可以看到更多的上下文从而执行更高效的其他优化策略。本质上,内联优化可以让编译器看得更广更远,这样就能执行类似常量传播,死码消除这样的编译器基础优化方式。
内联优化做了什么
大家可以通过一个例子可以看到内联优化
做了什么。
func max(a, b int) int {
if a > b {
return a
}
return b
}
var Result int
func BenchmarkMax(b *testing.B) {
var r int
for i := 0; i < b.N; i++ {
= max(-1, 1)
r }
= r
Result }
通过内联会转变成:
func BenchmarkMax(b *testing.B) {
var r int
for i := 0; i < b.N; i++ {
if -1 > i {
= -1
r } else {
= i
r }
}
= r
Result }
再经过编译器优化:
func BenchmarkMax(b *testing.B) {
var r int
for i := 0; i < b.N; i++ {
if false {
= -1
r } else {
= i
r }
}
= r
Result }
最后经过死码消除:
func BenchmarkMax(b *testing.B) {
var r int
for i := 0; i < b.N; i++ {
= i
r }
= r
Result }
内联优化的一些用法
编译的时候可以增加标识:
- -S 打印正在编译的包的汇编代码
- -l 控制内联行为; -l 禁止内联, -l -l 增加-l(更多-l会增加编译器对代码内联的强度)。试验编译时间,程序大小和运行时间的差异。
- -m 控制优化决策的打印,如内联,逃逸分析。-m打印关于编译器的想法的更多细节。
- -l -N 禁用所有优化。
函数签名上也可以增加注解:
//go:noinline
内联优化带来的开销
使用了业务项目尝试:
➜ xxx git:(master) ✗ time go build -o xxx-noinline -gcflags=all="-l" -a .
go build -o xxx-noinline -gcflags=all="-l" -a .
56.09s user 9.11s system 581% cpu 11.213 total
➜ xxx git:(master) ✗ time go build -o xxx-withinline -a .
go build -o xxx-withinline -a .
70.50s user 9.60s system 589% cpu 13.598 total
-rwxr-xr-x 1 xxx staff 38M Jul 28 17:18 xxx-noinline
-rwxr-xr-x 1 xxx staff 43M Jul 28 17:18 xxx-withinline
发现使用了内联,编译速度上涨了20%,包大小上涨了10%。
benchmark测试内联优化性能
func add1(a, b int) int {
return a + b
}
//go:noinline
func add2(a, b int) int {
return a + b
}
func BenchmarkAdd1(b *testing.B) {
var a, c = 5, 6
var r int
for i := 0; i < b.N; i++ {
= add1(a, c)
r }
= r
result }
func BenchmarkAdd2(b *testing.B) {
var a, c = 5, 6
var r int
for i := 0; i < b.N; i++ {
= add2(a, c)
r }
= r
result }
结果如下:
➜ nl go test -bench=.
goos: darwin
goarch: arm64
pkg: example.com/m/v2
BenchmarkAdd1-8 1000000000 0.3225 ns/op
BenchmarkAdd2-8 1000000000 0.9416 ns/op
PASS
ok example.com/m/v2 2.192s
可以看到内联效果比禁止内联快三倍。通过汇编代码我们可以发现内联优化直接将add2(a,c)
的函数调用优化成了MOVD $11, R0
直接把11赋值给了r。具体如何查看汇编,我们可以看下一节。
如何查看汇编代码
编译器提供-S
标识查看编译包的汇编代码
go build -gcflags=-S main.go
我们可以看到 如下的Go代码的编译结果:
func a() int {
var a, b = 5, 6
return add1(a, b)
}
func b() int {
var a, b = 5, 6
return add2(a, b)
}
func add1(a, b int) int {
return a + b
}
//go:noinline
func add2(a, b int) int {
return a + b
}
编译结果为:
main.a STEXT size=16 args=0x0 locals=0x0 funcid=0x0 align=0x0 leaf
0x0000 00000 (main.go:9) TEXT main.a(SB), LEAF|NOFRAME|ABIInternal, $0-0
0x0000 00000 (main.go:9) FUNCDATA ZR, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
0x0000 00000 (main.go:9) FUNCDATA $1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
0x0000 00000 (<unknown line number>) NOP
0x0000 00000 (main.go:11) MOVD $11, R0
0x0004 00004 (main.go:11) RET (R30)
0x0000 60 01 80 d2 c0 03 5f d6 00 00 00 00 00 00 00 00 `....._.........
main.b STEXT size=64 args=0x0 locals=0x18 funcid=0x0 align=0x0
0x0000 00000 (main.go:14) TEXT main.b(SB), ABIInternal, $32-0
0x0000 00000 (main.go:14) MOVD 16(g), R16
0x0004 00004 (main.go:14) PCDATA $0, $-2
0x0004 00004 (main.go:14) CMP R16, RSP
0x0008 00008 (main.go:14) BLS 48
0x000c 00012 (main.go:14) PCDATA $0, $-1
0x000c 00012 (main.go:14) MOVD.W R30, -32(RSP)
0x0010 00016 (main.go:14) MOVD R29, -8(RSP)
0x0014 00020 (main.go:14) SUB $8, RSP, R29
0x0018 00024 (main.go:14) FUNCDATA ZR, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
0x0018 00024 (main.go:14) FUNCDATA $1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
0x0018 00024 (main.go:16) MOVD $5, R0
0x001c 00028 (main.go:16) MOVD $6, R1
0x0020 00032 (main.go:16) PCDATA $1, ZR
0x0020 00032 (main.go:16) CALL main.add2(SB)
0x0024 00036 (main.go:16) LDP -8(RSP), (R29, R30)
0x0028 00040 (main.go:16) ADD $32, RSP
0x002c 00044 (main.go:16) RET (R30)
0x0030 00048 (main.go:16) NOP
0x0030 00048 (main.go:14) PCDATA $1, $-1
0x0030 00048 (main.go:14) PCDATA $0, $-2
0x0030 00048 (main.go:14) MOVD R30, R3
0x0034 00052 (main.go:14) CALL runtime.morestack_noctxt(SB)
0x0038 00056 (main.go:14) PCDATA $0, $-1
0x0038 00056 (main.go:14) JMP 0
0x0000 90 0b 40 f9 ff 63 30 eb 49 01 00 54 fe 0f 1e f8 ..@..c0.I..T....
0x0010 fd 83 1f f8 fd 23 00 d1 a0 00 80 d2 e1 07 7f b2 .....#..........
0x0020 00 00 00 94 fd fb 7f a9 ff 83 00 91 c0 03 5f d6 .............._.
0x0030 e3 03 1e aa 00 00 00 94 f2 ff ff 17 00 00 00 00 ................
rel 32+4 t=9 main.add2+0
rel 52+4 t=9 runtime.morestack_noctxt+0
内联函数是否会丢失堆栈信息?
我们通过例子看下:
package main
import "fmt"
func main() {
.Println(a())
fmt}
func a() int {
var a, b = 5, 6
:= add1(a, b)
r return r
}
func add1(a, b int) int {
if b == 6 {
panic("xixi")
}
return a + b
}
编译的时候确认是否内联:
➜ nl go build -gcflags=-m main.go
# command-line-arguments
./main.go:15:6: can inline add1
./main.go:9:6: can inline a
./main.go:11:11: inlining call to add1
./main.go:6:15: inlining call to a
./main.go:6:15: inlining call to add1
./main.go:6:13: inlining call to fmt.Println
./main.go:6:13: ... argument does not escape
./main.go:6:15: ~R0 escapes to heap
./main.go:6:15: "xixi" escapes to heap
./main.go:11:11: "xixi" escapes to heap
./main.go:17:8: "xixi" escapes to heap
再执行:
➜ nl ./main
panic: xixi
goroutine 1 [running]:
main.add1(...)
main.go:17
main.a(...)
main.go:11
main.main()
main.go:6 +0x30
发现结果:堆栈并没有丢失. 因为Go在内部会维持一个内联树,我们可以通过-gcflags="-d pctab=pctoinline"
查看:
funcpctab main.a [valfunc=pctoinline]
0 -1 00000 (main.go:9) TEXT main.a(SB), ABIInternal, $32-0
0 00000 (main.go:9) TEXT main.a(SB), ABIInternal, $32-0
0 -1 00000 (main.go:9) MOVD 16(g), R16
4 00004 (main.go:9) PCDATA $0, $-2
4 00004 (main.go:9) CMP R16, RSP
8 00008 (main.go:9) BLS 48
c 00012 (main.go:9) PCDATA $0, $-1
c 00012 (main.go:9) MOVD.W R30, -32(RSP)
10 00016 (main.go:9) MOVD R29, -8(RSP)
14 00020 (main.go:9) SUB $8, RSP, R29
18 00024 (main.go:9) FUNCDATA ZR, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
18 00024 (main.go:9) FUNCDATA $1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
18 00024 (<unknown line number>) NOP
18 00024 (main.go:11) MOVD $type.string(SB), R0
20 0 00032 (main.go:11) MOVD $main..stmp_1(SB), R1
28 00040 (main.go:11) PCDATA $1, ZR
28 00040 (main.go:11) CALL runtime.gopanic(SB)
2c 00044 (main.go:11) HINT ZR
30 00048 (main.go:11) NOP
30 00048 (main.go:9) PCDATA $1, $-1
30 00048 (main.go:9) PCDATA $0, $-2
30 -1 00048 (main.go:9) MOVD R30, R3
34 00052 (main.go:9) CALL runtime.morestack_noctxt(SB)
38 00056 (main.go:9) PCDATA $0, $-1
38 00056 (main.go:9) JMP 0
40 done
wrote 7 bytes to 0x14000029998
00 08 02 04 01 04 00
-- inlining tree for main.a:
0 | -1 | main.add1 (main.go:11:11) pc=24
--
funcpctab main.add1 [valfunc=pctoinline]
其中 inlining tree for main.a
的0
和-1
标记了a函数
和add1函数
的关系。
pprof中如何展现内联函数
我们通过一个简易的例子看下结论:
package main
import (
"net/http"
"net/http/pprof"
_ )
var res int
func main() {
go func() {
for {
= add()
res }
}()
.ListenAndServe(":8080", nil)
http}
func add() int {
var a, b = 5, 6
return a + b
}
我们分别编译出带内联以及禁止内联的包:
➜ nl go build -gcflags=-l -o maininline main.go
➜ nl go build -o mainnoinline main.go
-rwxr-xr-x 1 liyingfan staff 6.8M Jul 28 17:59 maininline
-rwxr-xr-x 1 liyingfan staff 6.8M Jul 28 17:59 mainnoinline
最后通过pprof火焰图查看结果:
可以发现,与堆栈信息不同,内联的函数不会显示在pprof的火焰图上。
defer
是如何内联的?
defer
在Go1.14之前都是在堆/栈上分配,有个runtime._defer
的结构体,是链表形式。这种方式的defer操作耗时大概35ns。在Go1.14引入了opencoded1方案。使用代码内联优化了defer的额外开销并使用funcdata管理panic调用,defer耗时可以降至6ns.
开启open coded的条件:
- 函数的 defer 数量少于或者等于 8 个;
- 函数的 defer 关键字不能在循环中执行;
- 函数的 return 语句与 defer 语句的乘积小于或者等于 15 个;
开启open coded后: 使用deferBits
判断defer语句是否被执行。
- 编译期间 如果可以确定的defer语句会被执行,则会直接插入
deferBits
代码. - 如果依赖运行时才能判断的条件语句,则需要使
runtime.deferreturn
决定是否执行defer语句。
panic问题:当open coded defer发生panic时,因为被插入的defer语句无法执行到,所以会使用栈扫描._defer
也增加了多个字段用于找到未注册到链表中的defer函数。所以最终的结果就是defer快了,panic慢了。
总结分析
我们可以通过上述总结出:
- 内联可以提升代码性能,带来的开销则是编译编慢,包变大。
- 内联函数不会丢失堆栈信息。
- 内联函数不再显示在pprof的火焰图中
- Go1.14在某些特定情况下会使用内联的方式优化defer效率。并通过栈扫描的方式解决panic问题。
Go1.22内联优化将迎来大修
大家可以查看cmd/compile: overhaul inliner,追踪这项工作。期待在Go1.22中的表现。