GO内联优化

2023-07-28 ⏳2.6分钟(1.0千字)

本文诣在让读者了解Go中内联(inlining)优化的一些基础知识以及一些常见的场景是否会与内联相关。

一直对于内联优化一知半解.仅仅知道Go编译器会进行内联优化,却并不知道它真正会做什么?意义是什么?又会带来哪些问题?而在实际的业务开发中也碰到过一些问题迟迟没有解惑:

带着这些问题,我尝试去查看一些资料+本地调试,补全这块拼图。

内联优化

什么是内联优化

内联(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++ {
		r = max(-1, 1)
	}
	Result = r
}

通过内联会转变成:

func BenchmarkMax(b *testing.B) {
    var r int
    for i := 0; i < b.N; i++ {
        if -1 > i {
            r = -1
        } else {
            r = i
        }
    }
    Result = r
}

再经过编译器优化:

func BenchmarkMax(b *testing.B) {
    var r int
    for i := 0; i < b.N; i++ {
        if false {
            r = -1
        } else {
            r = i
        }
    }
    Result = r
}

最后经过死码消除:

func BenchmarkMax(b *testing.B) {
    var r int
    for i := 0; i < b.N; i++ {
        r = i
    }
    Result = r
}

内联优化的一些用法

编译的时候可以增加标识:

函数签名上也可以增加注解:

//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++ {
                r = add1(a, c)
        }
        result = r
}
func BenchmarkAdd2(b *testing.B) {
        var a, c = 5, 6
        var r int
        for i := 0; i < b.N; i++ {
                r = add2(a, c)
        }
        result = r
}

结果如下:

  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() {
        fmt.Println(a())
}

func a() int {
        var a, b = 5, 6
        r := add1(a, b)
        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.a0-1标记了a函数add1函数的关系。

pprof中如何展现内联函数

我们通过一个简易的例子看下结论:

package main

import (
        "net/http"
        _ "net/http/pprof"
)

var res int

func main() {
        go func() {
                for {
                        res = add()
                }
        }()

        http.ListenAndServe(":8080", nil)
}

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的条件:

开启open coded后: 使用deferBits判断defer语句是否被执行。

panic问题:当open coded defer发生panic时,因为被插入的defer语句无法执行到,所以会使用栈扫描._defer也增加了多个字段用于找到未注册到链表中的defer函数。所以最终的结果就是defer快了,panic慢了。

总结分析

我们可以通过上述总结出:

Go1.22内联优化将迎来大修

大家可以查看cmd/compile: overhaul inliner,追踪这项工作。期待在Go1.22中的表现。

参考文档

Inlining optimisations in Go

通过实例理解Go内联优化