Go語言 邊界檢查消除

2023-02-16 17:39 更新

Go是一個內(nèi)存安全的語言。在數(shù)組和切片的索引和子切片操作中,Go運行時將檢查操作中使用的下標是否越界。 如果下標越界,一個恐慌將產(chǎn)生,以防止這樣的操作破壞內(nèi)存安全。這樣的檢查稱為邊界檢查。 邊界檢查使得我們的代碼能夠安全地運行;但是另一方面,也使得我們的代碼運行效率略微降低。

從Go官方工具鏈1.7開始,官方標準編譯器使用了一個新的基于SSA(single-assignment form,靜態(tài)單賦值形式)的后端。 SSA使得Go編譯器可以有效利用諸如BCE(bounds check elimination,邊界檢查消除)和CSE(common subexpression elimination,公共子表達式消除)等優(yōu)化。 BCE可以避免很多不必要的邊界檢查,CSE可以避免很多重復表達式的計算,從而使得編譯器編譯出的程序執(zhí)行效率更高。有時候這些優(yōu)化的效果非常明顯。

本文將展示一些例子來解釋邊界檢查消除在官方標準編譯器1.7+中的表現(xiàn)。

對于Go官方工具鏈1.7+,我們可以使用編譯器選項-gcflags="-d=ssa/check_bce/debug=1"來列出哪些代碼行仍然需要邊界檢查。

例子1

// example1.go
package main

func f1(s []int) {
	_ = s[0] // 第5行: 需要邊界檢查
	_ = s[1] // 第6行: 需要邊界檢查
	_ = s[2] // 第7行: 需要邊界檢查
}

func f2(s []int) {
	_ = s[2] // 第11行: 需要邊界檢查
	_ = s[1] // 第12行: 邊界檢查消除了!
	_ = s[0] // 第13行: 邊界檢查消除了!
}

func f3(s []int, index int) {
	_ = s[index] // 第17行: 需要邊界檢查
	_ = s[index] // 第18行: 邊界檢查消除了!
}

func f4(a [5]int) {
	_ = a[4] // 第22行: 邊界檢查消除了!
}

func main() {}
$ go run -gcflags="-d=ssa/check_bce/debug=1" example1.go
./example1.go:5: Found IsInBounds
./example1.go:6: Found IsInBounds
./example1.go:7: Found IsInBounds
./example1.go:11: Found IsInBounds
./example1.go:17: Found IsInBounds

我們可以看到函數(shù)f2中的第12行和第13行不再需要邊界檢查了,因為第11行的檢查確保了第12行和第13行中使用的下標肯定不會越界。

但是,函數(shù)f1中的三行仍然都需要邊界檢查,因為第5行中的邊界檢查不能保證第6行和第7行中的下標沒有越界,第6行中的邊界檢查也不能保證第第7行中的下標沒有越界。

在函數(shù)f3中,編譯器知道如果第一個s[index]是安全的,則第二個s[index]是無需邊界檢查的。

編譯器也正確地認為函數(shù)f4中的唯一一行(第22行)是無需邊界檢查的。

例子2

// example2.go
package main

func f5(s []int) {
	for i := range s {
		_ = s[i]
		_ = s[i:len(s)]
		_ = s[:i+1]
	}
}

func f6(s []int) {
	for i := 0; i < len(s); i++ {
		_ = s[i]
		_ = s[i:len(s)]
		_ = s[:i+1]
	}
}

func f7(s []int) {
	for i := len(s) - 1; i >= 0; i-- {
		_ = s[i]
		_ = s[i:len(s)]
	}
}

func f8(s []int, index int) {
	if index >= 0 && index < len(s) {
		_ = s[index]
		_ = s[index:len(s)]
	}
}

func f9(s []int) {
	if len(s) > 2 {
	    _, _, _ = s[0], s[1], s[2]
	}
}

func main() {}
$ go run -gcflags="-d=ssa/check_bce/debug=1" example2.go

酷!官方標準編譯器消除了上例程序中的所有邊界檢查。

注意:在Go官方工具鏈1.11之前,官方標準編譯器沒有足夠聰明到認為第22行是不需要邊界檢查的。

例子3

// example3.go
package main

import "math/rand"

func fa() {
	s := []int{0, 1, 2, 3, 4, 5, 6}
	index := rand.Intn(7)
	_ = s[:index] // 第9行: 需要邊界檢查
	_ = s[index:] // 第10行: 邊界檢查消除了!
}

func fb(s []int, index int) {
	_ = s[:index] // 第14行: 需要邊界檢查
	_ = s[index:] // 第15行: 需要邊界檢查(不夠智能?)
}

func fc() {
	s := []int{0, 1, 2, 3, 4, 5, 6}
	s = s[:4]
	index := rand.Intn(7)
	_ = s[:index] // 第22行: 需要邊界檢查
	_ = s[index:] // 第23行: 需要邊界檢查(不夠智能?)
}

func main() {}
$ go run -gcflags="-d=ssa/check_bce/debug=1" example3.go
./example3.go:9: Found IsSliceInBounds
./example3.go:14: Found IsSliceInBounds
./example3.go:15: Found IsSliceInBounds
./example3.go:22: Found IsSliceInBounds
./example3.go:23: Found IsSliceInBounds

噢,仍然有這么多的邊界檢查!

但是等等,為什么官方標準編譯器認為第10行不需要邊界檢查,卻認為第15和第23行仍舊需要邊界檢查呢? 是標準編譯器不夠智能嗎?

事實上,這里標準編譯器做得對!為什么呢? 原因是一個子切片表達式中的起始下標可能會大于基礎(chǔ)切片的長度。 讓我們先看一個簡單的使用了子切片的例子:

package main

func main() {
	s0 := make([]int, 5, 10)
	// len(s0) == 5, cap(s0) == 10

	index := 8

	// 在Go中,對于一個子切片表達式s[a:b],a和b須滿足
	// 0 <= a <= b <= cap(s);否則,將產(chǎn)生一個恐慌。

	_ = s0[:index]
	// 上一行是安全的不能保證下一行是無需邊界檢查的。
	// 事實上,下一行將產(chǎn)生一個恐慌,因為起始下標
	// index大于終止下標(即切片s0的長度)。
	_ = s0[index:] // panic
}

所以如果s[:index]是安全的則s[index:]是無需邊界檢查的這條論述只有在len(s)cap(s)相等的情況下才正確。這就是函數(shù)fbfc中的代碼仍舊需要邊界檢查的原因。

標準編譯器成功地檢測到在函數(shù)falen(s)cap(s)是相等的。干得漂亮!Go語言開發(fā)團隊!

例子4

// example4.go
package main

import "math/rand"

func fb2(s []int, index int) {
	_ = s[index:] // 第7行: 需要邊界檢查
	_ = s[:index] // 第8行: 邊界檢查消除了!
}

func fc2() {
	s := []int{0, 1, 2, 3, 4, 5, 6}
	s = s[:4]
	index := rand.Intn(7)
	_ = s[index:] // 第15行: 需要邊界檢查
	_ = s[:index] // 第16行: 邊界檢查消除了!
}

func main() {}
$ go run -gcflags="-d=ssa/check_bce/debug=1" example4.go
./example4.go:7:7: Found IsSliceInBounds
./example4.go:15:7: Found IsSliceInBounds

在此例子中,標準編譯器成功推斷出:

  • 在函數(shù)?fb2?中,如果第7行是安全的,則第8行是無需邊界檢查的;
  • 在函數(shù)?fc2?中,如果第15行是安全的,則第16行是無需邊界檢查的。

注意:Go官方工具鏈1.9之前中的標準編譯器沒有出推斷出第8行不需要邊界檢查。

例子5

當前版本的標準編譯器并非足夠智能到可以消除到一切應(yīng)該消除的邊界檢查。 有時候,我們需要給標準編譯器一些暗示來幫助標準編譯器將這些不必要的邊界檢查消除掉。

// example5.go
package main

func fd(is []int, bs []byte) {
	if len(is) >= 256 {
		for _, n := range bs {
			_ = is[n] // 第7行: 需要邊界檢查
		}
	}
}

func fd2(is []int, bs []byte) {
	if len(is) >= 256 {
		is = is[:256] // 第14行: 一個暗示
		for _, n := range bs {
			_ = is[n] // 第16行: 邊界檢查消除了!
		}
	}
}

func main() {}
$ go run -gcflags="-d=ssa/check_bce/debug=1" example5.go
./example5.go:7: Found IsInBounds

總結(jié)

本文上面列出的例子并沒有涵蓋標準編譯器支持的所有邊界檢查消除的情形。本文列出的僅僅是一些常見的情形。

盡管標準編譯器中的邊界檢查消除特性依然不是100%完美,但是對很多常見的情形,它確實很有效。 自從標準編譯器支持此特性以來,在每個版本更新中,此特性都在不斷地改進增強。 無需質(zhì)疑,在以后的版本中,標準編譯器會更加得智能,以至于上面第5個例子中提供給編譯器的暗示有可能將變得不再必要。 謝謝Go語言開發(fā)團隊出色的工作!

參考:

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


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號