Go 是一种内存安全的语言,在针对数组 (array) 或 Slice 做索引和切片操作时,Go 的运行时(runtime)会检查所涉及的索引是否超出范围。如果索引超出范围,将产生一个 Panic,以防止无效索引造成的伤害。这就是边界检查(BCE)。边界检查使我们的代码能够安全地运行,但也会影响一定的性能。
原文链接:
Bounds Check Elimination
自从 Go Toolchain 1.7 以后,标准的 Go 编译器采用了一个基于 SSA (静态单赋值形式)的新的编译器后端。SSA 帮助 Go 编译器有效地进行代码优化,比如 BCE (边界检查消除) 和 CSE (公共子表达式消除)。BCE 可以避免一些不必要的边界检查,CSE 可以避免一些重复的计算,如此使得标准的 Go 编译器可以生成更高效的程序。有时这些优化的改进效果是显而易见的。
本文将列出一些示例,说明 BCE 如何与标准的 Go 编译器1.7 + 协同工作。
对于 Go Toolchain 1.7 + ,我们可以使用 -gcflags = “-d=ssa/check _ bce/debug=1”
编译器标志来显示哪些代码行仍然需要进行边界检查。
例 1
1 | // example1.go |
1 | $ go run -gcflags="-d=ssa/check_bce/debug=1" example1.go |
我们可以看到,没有必要为函数 f2
中的第 12 行和第 13 行进行边界检查,因为第 11 行的边界检查确保了第 12 行和第 13 行的索引不会超出范围。
但在函数 f1
中,必须对这三行都进行边界检查。因为第 5 行的边界检查不能保证第六行和第七行的安全,同样第六行的检查也不能保证第七行的安全。
而对于函数 f3
,编译器知道如果第一个 s [ index ]
是安全的,那么第二个 s [ index ]
就也是绝对安全的。
编译器还能正确地判断出 f4
中的唯一一行(22行)是安全的。
例 2
1 | // example2.go |
1 | $ go run -gcflags="-d=ssa/check_bce/debug=1" example2.go |
酷! 标准编译器删除程序中的所有绑定检查。
注意: 在 Go Toolchain 1.11 版本之前,标准编译器不够智能,无法检测到第22行是安全的。
例3
1 | // example3.go |
1 | $ go run -gcflags="-d=ssa/check_bce/debug=1" example3.go |
哦,这么多地方还需要做边界检查!
但是,为什么标准的 Go 编译器认为第 10 行是安全的,而第 15 行和第 23 行却不是呢?编译器还不够聪明吗?
事实上,编译器设计如此!为什么?原因是子切片表达式中的起始索引可能大于原始切片的长度。让我们看一个简单的例子:
1 | package main |
因此,只有满足 len(s) == cap(s)
时,才能根据 s[:index]
是安全的得出 s[index:]
也是安全地的结论,这就是为什么函数 fb
和 fc
中的代码行仍然需要进行边界检查的原因。
标准 Go 编译器成功地检测到函数 fa
中的 len (s)
等于 cap (s)
干得好! Go团队加油!
例4
1 | // example4.go |
1 | $ go run -gcflags="-d=ssa/check_bce/debug=1" example4.go |
在这个例子中,go 编译器成功推断出:
- 如果第 7 行是安全的,那么第 8 行也是安全地
- 如果第 15 行是安全的,那么第 16 行也是安全地
注意:在1.9版本之前的 Go Toolchain 中,标准的 Go 编译器无法检测到第 8 行不需要边界检查。
例 5
当前版本的标准 Go 编译器不够聪明,无法消除所有不必要的边界检查。有时,我们可以做一些提示来帮助编译器消除一些不必要的边界检查.
1 | // example5.go |
1 | $ go run -gcflags="-d=ssa/check_bce/debug=1" example5.go |
核心的思想就是尽量消除在循环中的边界检查,这个例子有点奇怪,可以看下面这个:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23 // example4.go
package main
func bad(a, b []int64, n int) {
if len(a) >= n && len(b) >= n {
for i, v := range b {
a[i] = v
}
}
}
func good(a, b []int64, n int) {
if len(a) >= n && len(b) >= n {
a = a[:n]
b = b[:n]
for i, v := range b {
a[i] = v
}
}
}
func main() {}
1
2
3
4 $ go run -gcflags="-d=ssa/check_bce/debug=1" .\example2.go
# command-line-arguments
.\example2.go:7:5: Found IsInBounds
.\example2.go:14:8: Found IsSliceInBounds通过 14 15 行的子切片操作,我们可以把边界检查放到循环之外,简单跑一下 Benchmark 差距还是挺明显的
1
2
3
4
5
6
7 cpu: Intel(R) Core(TM) i7-7500U CPU @ 2.70GHz
BenchmarkBCE
BenchmarkBCE/good
BenchmarkBCE/good-4 296671 4912 ns/op
BenchmarkBCE/bad
BenchmarkBCE/bad-4 182302 6136 ns/op
PASS
摘要
标准的 Go 编译器进行了更多的 BCE 优化。它们可能不像上面列出的那么明显,所以本文不会全部展示。
尽管标准 Go 编译器中的 BCE 特性仍然不够完美,但对于许多常见情况来说,它确实做得很好。毫无疑问,标准的 Go 编译器在以后的版本中会做得更好,这样上面第5个例子中的提示可能就没有必要了。谢谢团队增加了这个美妙的功能!