非盲目式的Go代码优化

8/4/2019 golang

原文:GopherCon 2019 - Optimizing Go Code without a blindfold (opens new window)

Illustration by Sketch Post

译者采用的 go 版本为 1.12.5

# 概览

人人都想程序跑得更快,而这个目标借助 Go 的性能测试是很容易实现的。优化程序可能会相当复杂,需要耗费精力仔细斟酌正确的姿势。本文将会展示业余的性能优化者所必需的技巧和工具。

# Go 性能测试

你是否想知道怎么确定你的代码是否慢?它能否跑得更快?如果是的话,你上手的第一件工具应该是性能测试。

Go 的标准库testing有用于测量代码的 CPU 和内存消耗的工具。看个简单例子

func copyList(in []string) []string {
	var out []string
	for _, s := range in {
		out = append(out, s)
	}

	return out
}
1
2
3
4
5
6
7
8

性能测试代码如下

func BenchmarkCopyList(b *testing.B) {
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		copyList(input)
	}
}
1
2
3
4
5
6

启动测试得到如下输出,由输出可知:这个简单函数每次调用大约耗时 244ns,附带不少内存分配。

go test -bench=.
BenchmarkCopyList-4   	 5000000	       244 ns/op	     240 B/op	       4 allocs/op
1
2

能优化吗?可以借助 pprof (opens new window) 工具查看哪行代码比较耗时:

go test -cpuprofile=cpu.out -bench=.

go tool pprof cpu.out

# 输出
Type: cpu
Time: Aug 3, 2019 at 3:50pm (CST)
Duration: 1.63s, Total samples = 1.52s (93.09%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) list copyList
Total: 1.52s
ROUTINE ======================== code.copyList in /xxxx/code/demo.go
      60ms      560ms (flat, cum) 36.84% of Total
         .          .      1:package code
         .          .      2:
         .          .      3:func copyList(in []string) []string {
         .          .      4:	var out []string
      10ms       10ms      5:	for _, s := range in {
      50ms      550ms      6:		out = append(out, s)
         .          .      7:	}
         .          .      8:
         .          .      9:	return out
         .          .     10:}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

由上可知,大部分时间都用在append操作上面了!好在可通过预分配切片然后给每个元素赋值的方式来简单修正。

func copyList(in []string) []string {
	out := make([]string, len(in))
	for i, s := range in {
		out[i] = s
	}

	return out
}
1
2
3
4
5
6
7
8

看一下性能提升程度,这时需要 benchcmp (opens new window) 工具--展示同一个性能测试两次执行结果的性能变化:

go get -u -v golang.org/x/tools/cmd/benchcmp

# with the copyList from code/demo.go
go test -bench=. > old.txt
# with the copyList from code/demo_optimized.go
go test -bench=. > new.txt

benchcmp old.txt new.txt

# 对比结果如下
benchmark               old ns/op     new ns/op     delta
BenchmarkCopyList-4     237           67.8          -71.39%

benchmark               old allocs     new allocs     delta
BenchmarkCopyList-4     4              1              -75.00%

benchmark               old bytes     new bytes     delta
BenchmarkCopyList-4     240           96            -60.00%
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

🎉 哇!!🎉 性能飙升不少呐。

# 但是,性能测试信得过吗?

上一节的例子简单了点,现实需要性能测试的生产级代码往往要复杂得多。让我们转到标准库的 encoding/json (opens new window) 包看个资源密集型的例子。范围确定一下,就是 BenchmarkCodeDecoder (opens new window) 测试。

稍微跑多几次的话,你会发现性能测度每次都有所不同:

cd $GOROOT/src/encoding/json

go test -bench=CodeDecoder
go test -bench=CodeDecoder
go test -bench=CodeDecoder
go test -bench=CodeDecoder
go test -bench=CodeDecoder

# 结果对比如下
BenchmarkCodeDecoder-4   	     100	  19056070 ns/op	 101.83 MB/s
BenchmarkCodeDecoder-4   	     100	  19120626 ns/op	 101.49 MB/s
BenchmarkCodeDecoder-4   	     100	  18875387 ns/op	 102.80 MB/s
BenchmarkCodeDecoder-4   	     100	  20058788 ns/op	  96.74 MB/s
BenchmarkCodeDecoder-4   	     100	  20389545 ns/op	  95.17 MB/s
1
2
3
4
5
6
7
8
9
10
11
12
13
14

+/-3% 的变动看起来不值得让我们失眠,像encoding/json这样的包通常会稍有加速--事实上,encoding/json的最新4次性能提升都小于 4%。干扰因素纷繁复杂,我们该如何确定一次变动是否真正地影响了一个包的性能呢?

这时就得数据说话了。

# 性能测试 ❤️ 的数据

为了理解某个包的真正性能特征,我们通常会看性能测试多次运行的结果,然后计算中位数和方差。这时可以用到 benchstat (opens new window) 工具:

go get -u -v golang.org/x/perf/cmd/benchstat

go test -bench=CodeDecoder -count=8 > ~/old.txt

#结果如下
name           time/op
CodeDecoder-4    21.7ms ±18%

name           speed
CodeDecoder-4  90.5MB/s ±16%
1
2
3
4
5
6
7
8
9
10

16% 的方差仍然是相当高的。要降低它的话,我们得看看 CPU 还干了啥。理想情况下,性能测试会愉快地拼命占用 CPU,因此 CPU 会尽可能地被它压榨到 0%。但事实是,你可能手头打开了 Slack、编辑器还有 20 多个 Chrome 标签页。(有趣事实:Slack 中的动态表情通常消耗大量 CPU (opens new window)

关掉这些资源贪心型应用足够把我们的方差降到 +/-5%。

go test -bench=CodeDecoder -count=8 > ~/new.txt

# 新结果如下
name           time/op
CodeDecoder-4   18.7ms ± 5%

name           speed
CodeDecoder-4  104MB/s ± 5%
1
2
3
4
5
6
7
8

然后,CPU 的问题还不至于此。一次性运行多次性能测试,会发现性能因不明原因明显下降。

json-benchstat-throttling

译者注:上述现象在本人电脑上并不明显

这种情况是因为上述性能测试 100% 占用 CPU 太久触发 CPU 降频了。好在有另一个工具--perflock (opens new window) -- 用于防止性能测试一下子占用太多 CPU:

json-perflock

上述例子中,我们限定性能测试的 CPU 利用率为不超过 70%,足以防止电脑降低 CPU 的频率了。

benchstat可分析多次性能测试然后计算中位数和方差,还可以用于帮助理解某次代码变更对性能的影响。看个例子:

go test -bench=CodeDecoder -count=8 > ~/old.txt  
go test -bench=CodeDecoder -count=8 > ~/new.txt  

benchstat ~/old.txt ~/new.txt

# 输出
name           old time/op    new time/op    delta
CodeDecoder-4    21.0ms ±16%    21.0ms ± 9%   ~     (p=0.798 n=8+8)

name           old speed      new speed      delta
CodeDecoder-4  92.9MB/s ±14%  92.7MB/s ± 9%   ~     (p=0.776 n=8+8)
1
2
3
4
5
6
7
8
9
10
11

可见,新代码平均情况下运行需要 21.0ms vs 21.0ms... 嗯? 好在benchstat在底部提供上下文数据帮助我们理解优化是否有明显的数据提升。对比两次性能测试,我们通常看到的是+/-X.XX%而不是~形式的变化。然后,当前情形的高p值(0.776)让benchstat裁定本次优化效果不明显。而什么是p值呢?假设变更对代码性能没影响,把p值看做benchstat报告至少如刚才所见般性能提升的概率。越低的p值意味着benchstat发现的性能提升在没有显著优化时的发生概率是越低的,因此p值越低越好。

译者注:这个测试在本人电脑上也不准

再来个惊喜--其实上面是同一份代码的两次执行!p=0.776是相当糟糕的。通常p<=0.05才意味着一次优化是显著的。

# 性能测试小结

理解上述内容后,我们现在就可以编写性能测试,用benchstat来检测代码的性能,然后用pprof发掘潜在优化点,且基于perlock+benchstat测试它们。

# 再看看:编译器优化

我们可能都听说过摩尔定律 (opens new window)--计算能力(即集体管数目)每 18 个月会翻一番。但Proebsting定律 (opens new window)呢?它推测编译器的进步每 18 年会使得计算能力翻一番。听起来稀松平常,但是这暗示着编译器也是可以带来显著性能提升的哟!了解性能测试不少后,现在让我们也聊一聊 Go 编译器优化性能的一些方式。

学习编译器的骚操作前,建议读一下 cmd/compile (opens new window) 的文档以对 Go 的编译器有个宏观的了解。

# 函数内联

go build时,可通过-gcflags标识给编译器传参。例如,如果你传了两个-m标识,编译器会汇报它能够却没有内联的函数:

go build -gcflags="-m -m" io 2> io.txt

cat io.txt | grep 'function too complex'

# 部分输出
io.go:289:6: cannot inline WriteString: function too complex: cost 136 exceeds budget 80
1
2
3
4
5
6

这项技巧有助于发现能够优化的函数,促使编译器内联它们。

# 堆的内存分配

-m -m标识也能用于发现表达式逃逸到堆而触发内存分配的情形。看到热函数请求大量内存分配时,这个技巧可以帮我们查原因:

go build -gcflags="-m -m" io 2> io.txt

cat io.txt | grep 'escapes to heaear'

# 部分输出
io.go:293:23: ([]byte)(s) escapes to heap
1
2
3
4
5
6

# 边界检查

每次索引切片时,go 编译器会对切片生成边界检查。go 编译会执行一轮优化(称为 balance check elimination,简称bce),将认定为静态安全的索引操作的检查移除掉。通过设置不同级别的调试查看这些检查是否能够从你的代码移除:

go build -gcflags=-d=ssa/prove/debug=1 io 2> io.txt
cat io.txt

io.go:446:8: Proved IsSliceInBounds
1
2
3
4

另一个调试级别的结果如下

go build -gcflags=-d=ssa/prove/debug=2 io 2> io.txt
~ cat io.txt

# 部分输出
multi.go:59:14: x+d >= w; x:v24 b6 delta:1 w:0 d:signed
1
2
3
4
5

# 清空Maps

Go 1.11 之前,清空 map 最高效的方式是用新分配的 map 覆写它。但是,这其实不是非常高效的。从 Go 1.11 起,我们可以遍历 map 的键,逐个删除它们。聪明的编译器会优化这项操作,免得你新分配一下map!

// replace a map
m = make(map[string]string)

// clear a map; faster since Go 1.11!
for k := range m {
	delete(m, k)
}
1
2
3
4
5
6
7

# 检查字符串长度

类似地,之前计算字符串长度的有效方式是遍历计算其中 rune 的个数。现在可以简化为检查 rune 切片的长度了:

// count manually
n := 0
for range str {
	n++
}

// simple, and fast since Go 1.11!
n := len([]rune(str))
1
2
3
4
5
6
7
8

总而言之,可以考虑编写一些能够让编译器优化的代码。

# 多说一句:SSA分析

如果读过上面的 Go 编译器文档 (opens new window),你应该了解到 Go 编译器的优化基于源代码的 SSA(Static Single Assignment)形式。用他们的话讲:“这个阶段,AST 会被转换为 Static Single Assignment(SSA)形式,一种具有特定属性的、更低层次的中间表示法,这种表示法使得优化更加容易实现,并用于最终机器码的生成。”

go build时设定一个特殊的环境变量--GOSSAFUNC,我们就可以查看相应包函数的 SSA 输出了:

给定代码如下

package code

func HelloWorld() {
        println("hello, world!")
}
1
2
3
4
5

生成HelloWorld程序的 SSA 报表。

GOSSAFUNC=HelloWorld go build

# 用浏览器打开生成的ssa.html即可看到SSA的形式
# code
dumped SSA to ./ssa.html
1
2
3
4
5

如有兴趣,可以通过Go 的介绍文档 (opens new window)了解其编译器的更多内部机理。

演讲的幻灯片参见这里 (opens new window)