原文:GopherCon 2019 - Optimizing Go Code without a blindfold (opens new window)
译者采用的 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
}
2
3
4
5
6
7
8
性能测试代码如下
func BenchmarkCopyList(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
copyList(input)
}
}
2
3
4
5
6
启动测试得到如下输出,由输出可知:这个简单函数每次调用大约耗时 244ns,附带不少内存分配。
go test -bench=.
BenchmarkCopyList-4 5000000 244 ns/op 240 B/op 4 allocs/op
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:}
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
}
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%
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
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%
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%
2
3
4
5
6
7
8
然后,CPU 的问题还不至于此。一次性运行多次性能测试,会发现性能因不明原因明显下降。
译者注:上述现象在本人电脑上并不明显
这种情况是因为上述性能测试 100% 占用 CPU 太久触发 CPU 降频了。好在有另一个工具--perflock (opens new window) -- 用于防止性能测试一下子占用太多 CPU:
上述例子中,我们限定性能测试的 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)
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
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
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
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
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)
}
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))
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!")
}
2
3
4
5
生成HelloWorld
程序的 SSA 报表。
GOSSAFUNC=HelloWorld go build
# 用浏览器打开生成的ssa.html即可看到SSA的形式
# code
dumped SSA to ./ssa.html
2
3
4
5
如有兴趣,可以通过Go 的介绍文档 (opens new window)了解其编译器的更多内部机理。
演讲的幻灯片参见这里 (opens new window)。