第35章:边界检查消除



边界检查消除

Go是一个内存安全的语言。在数组和切片的索引和子切片操作中,Go运行时将检查操作中使用的下标是否越界。 如果下标越界,一个恐慌将产生,以防止这样的操作破坏内存安全。这样的检查称为边界检查。 边界检查使得我们的代码能够安全地运行;但是另一方面,也使得我们的代码运行效率略微降低。

从Go官方工具链1.7开始,官方标准编译器使用了一个新的基于SSA(single-assignment form,静态单赋值形式)的后端。 SSA使得Go编译器可以有效利用诸如BCE(bounds check elimination,边界检查消除)和CSE(common subexpression elimination,公共子表达式消除)等优化。 BCE可以避免很多不必要的边界检查,CSE可以避免很多重复表达式的计算,从而使得编译器编译出的程序执行效率更高。有时候这些优化的效果非常明显。

本文将展示一些例子来解释边界检查消除在官方标准编译器1.7+中的表现。

对于Go官方工具链1.7+,我们可以使用编译器选项-gcflags="-d=ssa/check_bce"来列出哪些代码行仍然需要边界检查。

例子1

  1. // example1.go
  2. package main
  3. func f1(s []int) {
  4. _ = s[0] // 第5行: 需要边界检查
  5. _ = s[1] // 第6行: 需要边界检查
  6. _ = s[2] // 第7行: 需要边界检查
  7. }
  8. func f2(s []int) {
  9. _ = s[2] // 第11行: 需要边界检查
  10. _ = s[1] // 第12行: 边界检查消除了!
  11. _ = s[0] // 第13行: 边界检查消除了!
  12. }
  13. func f3(s []int, index int) {
  14. _ = s[index] // 第17行: 需要边界检查
  15. _ = s[index] // 第18行: 边界检查消除了!
  16. }
  17. func f4(a [5]int) {
  18. _ = a[4] // 第22行: 边界检查消除了!
  19. }
  20. func main() {}
  1. $ go run -gcflags="-d=ssa/check_bce" example1.go
  2. ./example1.go:5: Found IsInBounds
  3. ./example1.go:6: Found IsInBounds
  4. ./example1.go:7: Found IsInBounds
  5. ./example1.go:11: Found IsInBounds
  6. ./example1.go:17: Found IsInBounds

我们可以看到函数f2中的第12行和第13行不再需要边界检查了,因为第11行的检查确保了第12行和第13行中使用的下标肯定不会越界。

但是,函数f1中的三行仍然都需要边界检查,因为第5行中的边界检查不能保证第6行和第7行中的下标没有越界,第6行中的边界检查也不能保证第第7行中的下标没有越界。

在函数f3中,编译器知道如果第一个s[index]是安全的,则第二个s[index]是无需边界检查的。

编译器也正确地认为函数f4中的唯一一行(第22行)是无需边界检查的。

注意:目前(Go官方工具链1.22版本),如果一个泛型函数中的操作涉及到类型参数,则标准编译器不会检查此操作是否需要边界检查。 下面是一个示例来证明这一点:

  1. // example1b.go
  2. package main
  3. func foo[E any](s []E) {
  4. _ = s[0] // 第5行
  5. _ = s[1] // 第6行
  6. _ = s[2] // 第7行
  7. }
  8. // var _ = foo[bool]
  9. func main() {
  10. }

如果其中的变量声明行被注释掉,则标准编译器什么也不输出:

  1. $ go run -gcflags="-d=ssa/check_bce" example1b.go

如果其中的变量声明行被启用,则标准编译器输出:

  1. ./aaa.go:5:7: Found IsInBounds
  2. ./example1b.go:6:7: Found IsInBounds
  3. ./example1b.go:7:7: Found IsInBounds
  4. ./example1b.go:4:6: Found IsInBounds

例子2

  1. // example2.go
  2. package main
  3. func f5(s []int) {
  4. for i := range s {
  5. _ = s[i]
  6. _ = s[i:len(s)]
  7. _ = s[:i+1]
  8. }
  9. }
  10. func f6(s []int) {
  11. for i := 0; i < len(s); i++ {
  12. _ = s[i]
  13. _ = s[i:len(s)]
  14. _ = s[:i+1]
  15. }
  16. }
  17. func f7(s []int) {
  18. for i := len(s) - 1; i >= 0; i-- {
  19. _ = s[i]
  20. _ = s[i:len(s)]
  21. }
  22. }
  23. func f8(s []int, index int) {
  24. if index >= 0 && index < len(s) {
  25. _ = s[index]
  26. _ = s[index:len(s)]
  27. }
  28. }
  29. func f9(s []int) {
  30. if len(s) > 2 {
  31. _, _, _ = s[0], s[1], s[2]
  32. }
  33. }
  34. func main() {}
  1. $ go run -gcflags="-d=ssa/check_bce" example2.go

酷!官方标准编译器消除了上例程序中的所有边界检查。

注意:在Go官方工具链1.11之前,官方标准编译器没有足够聪明到认为第22行是不需要边界检查的。

例子3

  1. // example3.go
  2. package main
  3. import "math/rand"
  4. func fa() {
  5. s := []int{0, 1, 2, 3, 4, 5, 6}
  6. index := rand.Intn(7)
  7. _ = s[:index] // 第9行: 需要边界检查
  8. _ = s[index:] // 第10行: 边界检查消除了!
  9. }
  10. func fb(s []int, index int) {
  11. _ = s[:index] // 第14行: 需要边界检查
  12. _ = s[index:] // 第15行: 需要边界检查(不够智能?)
  13. }
  14. func fc() {
  15. s := []int{0, 1, 2, 3, 4, 5, 6}
  16. s = s[:4]
  17. index := rand.Intn(7)
  18. _ = s[:index] // 第22行: 需要边界检查
  19. _ = s[index:] // 第23行: 需要边界检查(不够智能?)
  20. }
  21. func main() {}
  1. $ go run -gcflags="-d=ssa/check_bce" example3.go
  2. ./example3.go:9: Found IsSliceInBounds
  3. ./example3.go:14: Found IsSliceInBounds
  4. ./example3.go:15: Found IsSliceInBounds
  5. ./example3.go:22: Found IsSliceInBounds
  6. ./example3.go:23: Found IsSliceInBounds

噢,仍然有这么多的边界检查!

但是等等,为什么官方标准编译器认为第10行不需要边界检查,却认为第15和第23行仍旧需要边界检查呢? 是标准编译器不够智能吗?

事实上,这里标准编译器做得对!为什么呢? 原因是一个子切片表达式中的起始下标可能会大于基础切片的长度。 让我们先看一个简单的使用了子切片的例子:

  1. package main
  2. func main() {
  3. s0 := make([]int, 5, 10)
  4. // len(s0) == 5, cap(s0) == 10
  5. index := 8
  6. // 在Go中,对于一个子切片表达式s[a:b],a和b须满足
  7. // 0 <= a <= b <= cap(s);否则,将产生一个恐慌。
  8. _ = s0[:index]
  9. // 上一行是安全的不能保证下一行是无需边界检查的。
  10. // 事实上,下一行将产生一个恐慌,因为起始下标
  11. // index大于终止下标(即切片s0的长度)。
  12. _ = s0[index:] // panic
  13. }

所以如果s[:index]是安全的则s[index:]是无需边界检查的这条论述只有在len(s)cap(s)相等的情况下才正确。这就是函数fbfc中的代码仍旧需要边界检查的原因。

标准编译器成功地检测到在函数falen(s)cap(s)是相等的。干得漂亮!Go语言开发团队!

例子4

  1. // example4.go
  2. package main
  3. import "math/rand"
  4. func fb2(s []int, index int) {
  5. _ = s[index:] // 第7行: 需要边界检查
  6. _ = s[:index] // 第8行: 边界检查消除了!
  7. }
  8. func fc2() {
  9. s := []int{0, 1, 2, 3, 4, 5, 6}
  10. s = s[:4]
  11. index := rand.Intn(7)
  12. _ = s[index:] // 第15行: 需要边界检查
  13. _ = s[:index] // 第16行: 边界检查消除了!
  14. }
  15. func main() {}
  1. $ go run -gcflags="-d=ssa/check_bce" example4.go
  2. ./example4.go:7:7: Found IsSliceInBounds
  3. ./example4.go:15:7: Found IsSliceInBounds

在此例子中,标准编译器成功推断出:

  • 在函数fb2中,如果第7行是安全的,则第8行是无需边界检查的;
  • 在函数fc2中,如果第15行是安全的,则第16行是无需边界检查的。

注意:Go官方工具链1.9之前中的标准编译器没有出推断出第8行不需要边界检查。

例子5

当前版本的标准编译器并非足够智能到可以消除到一切应该消除的边界检查。 有时候,我们需要给标准编译器一些暗示来帮助标准编译器将这些不必要的边界检查消除掉。

  1. // example5.go
  2. package main
  3. func fd(is []int, bs []byte) {
  4. if len(is) >= 256 {
  5. for _, n := range bs {
  6. _ = is[n] // 第7行: 需要边界检查
  7. }
  8. }
  9. }
  10. func fd2(is []int, bs []byte) {
  11. if len(is) >= 256 {
  12. is = is[:256] // 第14行: 一个暗示
  13. for _, n := range bs {
  14. _ = is[n] // 第16行: 边界检查消除了!
  15. }
  16. }
  17. }
  18. func main() {}
  1. $ go run -gcflags="-d=ssa/check_bce" example5.go
  2. ./example5.go:7: Found IsInBounds

总结

本文上面列出的例子并没有涵盖标准编译器支持的所有边界检查消除的情形。本文列出的仅仅是一些常见的情形。

尽管标准编译器中的边界检查消除特性依然不是100%完美,但是对很多常见的情形,它确实很有效。 自从标准编译器支持此特性以来,在每个版本更新中,此特性都在不断地改进增强。 无需质疑,在以后的版本中,标准编译器会更加得智能,以至于上面第5个例子中提供给编译器的暗示有可能将变得不再必要。 谢谢Go语言开发团队出色的工作!

参考:

  1. Bounds Check Elimination
  2. Utilizing the Go 1.7 SSA Compiler第二部分

本书由老貘历时三年写成。目前本书仍在不断改进和增容中。你的赞赏是本书和Go101.org网站不断增容和维护的动力。

(请搜索关注微信公众号“Go 101”或者访问github.com/golang101/golang101获取本书最新版)