详解内联优化

为了保证程序的执行高效与安全,现代编译器并不会将程序员的代码直接翻译成相应地机器码,它需要做一系列的检查与优化。Go编译器默认做了很多相关工作,例如未使用的引用包检查、未使用的声明变量检查、有效的括号检查、逃逸分析、内联优化、删除无用代码等。本文重点讨论内联优化相关内容。

内联

《详解逃逸分析》一文中,我们分析了栈分配内存会比堆分配高效地多,那么,我们就会希望对象能尽可能被分配在栈上。在Go中,一个goroutine会有一个单独的栈,栈又会包含多个栈帧,栈帧是函数调用时在栈上为函数所分配的区域。但其实,函数调用是存在一些固定开销的,例如维护帧指针寄存器BP、栈溢出检测等。因此,对于一些代码行比较少的函数,编译器倾向于将它们在编译期展开从而消除函数调用,这种行为就是内联。


性能对比

首先,看一下函数内联与非内联的性能差异。

 1//go:noinline
 2func maxNoinline(a, b int) int {
 3    if a < b {
 4        return b
 5    }
 6    return a
 7}
 8
 9func maxInline(a, b int) int {
10    if a < b {
11        return b
12    }
13    return a
14}
15
16func BenchmarkNoInline(b *testing.B) {
17    x, y := 1, 2
18    b.ResetTimer()
19    for i := 0; i < b.N; i++ {
20        maxNoinline(x, y)
21    }
22}
23
24func BenchmarkInline(b *testing.B) {
25    x, y := 1, 2
26    b.ResetTimer()
27    for i := 0; i < b.N; i++ {
28        maxInline(x, y)
29    }
30}

在程序代码中,想要禁止编译器内联优化很简单,在函数定义前一行添加//go:noinline即可。以下是性能对比结果

1BenchmarkNoInline-8     824031799                1.47 ns/op
2BenchmarkInline-8       1000000000               0.255 ns/op

因为函数体内部的执行逻辑非常简单,此时内联与否的性能差异主要体现在函数调用的固定开销上。显而易见,该差异是非常大的。


内联场景

此时,爱思考的读者可能就会产生疑问:既然内联优化效果这么显著,是不是所有的函数调用都可以内联呢?答案是不可以。因为内联,其实就是将一个函数调用原地展开,替换成这个函数的实现。当该函数被多次调用,就会被多次展开,这会增加编译后二进制文件的大小。而非内联函数,只需要保存一份函数体的代码,然后进行调用。所以,在空间上,一般来说使用内联函数会导致生成的可执行文件变大(但需要考虑内联的代码量、调用次数、维护内联关系的开销)。

问题来了,编译器内联优化的选择策略是什么?

 1package main
 2
 3func add(a, b int) int {
 4    return a + b
 5}
 6
 7func iter(num int) int {
 8    res := 1
 9    for i := 1; i <= num; i++ {
10        res = add(res, i)
11    }
12    return res
13}
14
15func main() {
16    n := 100
17    _ = iter(n)
18}

假设源码文件为main.go,可通过执行go build -gcflags="-m -m" main.go命令查看编译器的优化策略。

1$ go build -gcflags="-m -m" main.go
2# command-line-arguments
3./main.go:3:6: can inline add with cost 4 as: func(int, int) int { return a + b }
4./main.go:7:6: cannot inline iter: unhandled op FOR
5./main.go:10:12: inlining call to add func(int, int) int { return a + b }
6./main.go:15:6: can inline main with cost 67 as: func() { n := 100; _ = iter(n) }

通过以上信息,可知编译器判断add函数与main函数都可以被内联优化,并将add函数内联。同时可以注意到的是,iter函数由于存在循环语句并不能被内联:cannot inline iter: unhandled op FOR。实际上,除了for循环,还有一些情况不会被内联,例如闭包,selectfordefergo关键字所开启的新goroutine等,详细可见src/cmd/compile/internal/gc/inl.go相关内容。

 1    case OCLOSURE,
 2        OCALLPART,
 3        ORANGE,
 4        OFOR,
 5        OFORUNTIL,
 6        OSELECT,
 7        OTYPESW,
 8        OGO,
 9        ODEFER,
10        ODCLTYPE, // can't print yet
11        OBREAK,
12        ORETJMP:
13        v.reason = "unhandled op " + n.Op.String()
14        return true

在上文提到过,内联只针对小代码量的函数而言,那么到底是小于多少才算是小代码量呢?

此时,我将上面的add函数,更改为如下内容

1func add(a, b int) int {
2    a = a + 1
3    return a + b
4}

执行go build -gcflags="-m -m" main.go命令,得到信息

1./main.go:3:6: can inline add with cost 9 as: func(int, int) int { a = a + 1; return a + b }

对比之前的信息

1./main.go:3:6: can inline add with cost 4 as: func(int, int) int { return a + b }

可以发现,存在cost 4cost 9的区别。这里的数值代表的是抽象语法树AST的节点,a = a + 1包含的是5个节点。Go函数中超过80个节点的代码量就不再内联。例如,如果在add中写入16个a = a + 1,则不再内联。

1./main.go:3:6: cannot inline add: function too complex: cost 84 exceeds budget 80

内联表

内联会将函数调用的过程抹掉,这会引入一个新的问题:代码的堆栈信息还能否保证。举个例子,如果程序发生panic,内联之后的程序,还能否准确的打印出堆栈信息?看以下例子。

 1package main
 2
 3func sub(a, b int) {
 4    a = a - b
 5    panic("i am a panic information")
 6}
 7
 8func max(a, b int) int {
 9    if a < b {
10        sub(a, b)
11    }
12    return a
13}
14
15func main() {
16    x, y := 1, 2
17    _ = max(x, y)
18}

在该代码样例中,max函数将被内联。执行程序,输出结果如下

1panic: i am a panic information
2
3goroutine 1 [running]:
4main.sub(...)
5        /Users/slp/go/src/workspace/example/main.go:5
6main.max(...)
7        /Users/slp/go/src/workspace/example/main.go:10
8main.main()
9        /Users/slp/go/src/workspace/example/main.go:17 +0x3a

可以发现,panic依然输出了正确的程序堆栈信息,包括源文件位置和行号信息。那,Go是如何做到的呢?

这是由于Go内部会为每个存在内联优化的goroutine维持一个内联树(inlining tree),该树可通过 go build -gcflags="-d pctab=pctoinline" main.go 命令查看

 1funcpctab "".sub [valfunc=pctoinline]
 2...
 3wrote 3 bytes to 0xc000082668
 4 00 42 00
 5funcpctab "".max [valfunc=pctoinline]
 6...
 7wrote 7 bytes to 0xc000082f68
 8 00 3c 02 1d 01 09 00
 9-- inlining tree for "".max:
100 | -1 | "".sub (/Users/slp/go/src/workspace/example/main.go:10:6) pc=59
11--
12funcpctab "".main [valfunc=pctoinline]
13...
14wrote 11 bytes to 0xc0004807e8
15 00 1d 02 01 01 07 04 16 03 0c 00
16-- inlining tree for "".main:
170 | -1 | "".max (/Users/slp/go/src/workspace/example/main.go:17:9) pc=30
181 | 0 | "".sub (/Users/slp/go/src/workspace/example/main.go:10:6) pc=29
19--

内联控制

Go程序编译时,默认将进行内联优化。我们可通过-gcflags="-l"选项全局禁用内联,与一个-l禁用内联相反,如果传递两个或两个以上的-l则会打开内联,并启用更激进的内联策略。如果不想全局范围内禁止优化,则可以在函数定义时添加 //go:noinline 编译指令来阻止编译器内联函数。

往期推荐

P.S.  欢迎大家添加小菜刀私人微信,一起进Golang群学习交流~

机器铃砍菜刀

请备注

Golang 技术分享

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值