Go performance improvement tips -- boundary check

1. What is boundary inspection?

Boundary check, English Name: boundaries check elimination, abbreviated as BCE. It is a check method in Go language to prevent array and slice from crossing the boundary and causing memory insecurity. If the check subscript is out of bounds, Panic will be generated.

Boundary checking makes our code run safely, but on the other hand, it also makes our code run a little less efficient.

For example, the following code will perform three boundary checks

package main

func f(s []int) {
    _ = s[0]  // Check for the first time
    _ = s[1]  // Check the second time
    _ = s[2]  // Check the third time
}

func main() {}

You might be curious, three times? How did I know it had to be checked three times.

In fact, you just need to add parameters when compiling. The command is as follows

go build -gcflags="-d=ssa/check_bce" demo.go
# command-line-arguments
./demo.go:4:7: Found IsInBounds
./demo.go:5:7: Found IsInBounds
./demo.go:6:7: Found IsInBounds

2. Boundary inspection conditions?

Not all indexing operations on arrays and slices require boundary checking.

For example, the following example does not need boundary checking, because the compiler knows the length of the slice s and your termination index according to the context, and can immediately judge whether it is out of bounds. Therefore, it does not need boundary checking, because it already knows whether there will be panic in this place at the time of compilation.

package main

func f1() {
    s := []int{1, 2, 3, 4}
    _ = s[:9] // No boundary check is required

}
func main() {}

Therefore, it can be concluded that boundary checking is required for index operations that cannot be judged whether they will cross the boundary at the compilation stage
Like this

package main


func f(s []int) {
    _ = s[:9]  // Boundary check required
}
func main()  {}

3. Special cases of boundary inspection

3.1 case 1

In the following example code, since index 2 has been checked at the front to see if it will cross the boundary, the smart compiler can infer that the following indexes 0 and 1 do not need to be checked again

 package main

func f(s []int) {
    _ = s[2] // Check once
    _ = s[1]  // Will not check
    _ = s[0]  // Will not check
}

func main() {}

3.2 case 2

In the following example, you can logically ensure that code that does not cross the boundary will not be checked.

package main

func f(s []int) {
    for index, _ := range s {
        _ = s[index]
        _ = s[:index+1]
        _ = s[index:len(s)]
    }
}

func main()  {}

3.3 case III

In the following example code, although the length and capacity of the array can be determined, the index is a random number obtained through the rand.Intn() function. In the compiler's view, this index value is uncertain. It may be greater than or less than the length of the array.

Therefore, the first check is required. After the first check, the second index can be inferred logically, so the boundary check will not be carried out again.

package main

import (
    "math/rand"
)

func f()  {
    s := make([]int, 3, 3)
    index := rand.Intn(3)
     _ = s[:index]  // First inspection
    _ = s[index:]  // Will not check
}

func main()  {}

However, if the above code is changed slightly, the length and capacity of the slice will become different, and the result will become different again.

package main

import (
    "math/rand"
)

func f()  {
    s := make([]int, 3, 5)
    index := rand.Intn(3)
     _ = s[:index]  // First inspection
    _ = s[index:]  // Second inspection
}

func main()  {}

Only when the length and capacity of the array are equal, can index: be deduced. In this case, just check once

Once the length and capacity of the array are not equal, the index may be greater than the length of the array or even the capacity of the array in the view of the compiler.

Let's assume that the random number obtained by index is 4, then it is greater than the array length. At this time, s[:index] can succeed, but s[index:] will fail. Therefore, it is necessary to check the second boundary.

You might say, isn't the maximum value of index 3? How could it be 4?

You should know that the compiler does not know that the maximum value of index is 3 when compiling.

To summarize

  1. When the length and capacity of the array are equal, s[:index] can ensure that s[index:] is also true, because it only needs to be checked once
  2. When the length and capacity of the array are different, s[:index] cannot be guaranteed, because it needs to be checked twice

3.4 case 4

With the above foreshadowing, let's look at the following example. Since the array is a parameter passed in by the caller, the compiler cannot know whether the length and capacity of the array are equal when compiling. Therefore, it can only be safe to check both.

package main

import (
    "math/rand"
)

func f(s []int, index int) {
    _ = s[:index] // First inspection
    _ = s[index:] // Second inspection
}

func main()  {}

If the order of the two expressions is reversed, just do a check

package main

import (
    "math/rand"
)

func f(s []int, index int) {
    _ = s[index:] // First inspection
    _ = s[:index] // Don't check
}

func main()  {}

3.5. Actively eliminate boundary inspection

Although the compiler has made great efforts to eliminate some boundary checks that should be eliminated, there will inevitably be some omissions.

This requires "cooperation between the police and the people". For those scenarios that have not been considered by the compiler, but developers are trying to pursue the operation efficiency of the program, you can use some tips to give some hints to tell the compiler where boundary checking can not be done.

For example, in the following example, from the logic of the code, it is completely unnecessary to check the boundary, but the compiler is not so intelligent. In fact, every for loop needs to check the boundary, which is a waste of performance.

package main


func f(is []int, bs []byte) {
    if len(is) >= 256 {
        for _, n := range bs {
            _ = is[n] // Boundary check is required for each cycle
        }
    }
}
func main()  {}

Try adding the sentence is = is[:256] before the for loop to tell the compiler that the length of the new is is is 256 and the maximum index value is 255, which will not exceed the maximum value of byte, because is[n] will not exceed the limit logically.

package main


func f(is []int, bs []byte) {
    if len(is) >= 256 {
        is = is[:256]
        for _, n := range bs {
            _ = is[n] // No boundary check is required
        }
    }
}
func main()  {}

3.6 effect of boundary inspection on Performance

The impact of boundary checking on performance has been discussed, but how much is the impact? Take the example above as a benchmark

package main

import "testing"

func f4(is []int, bs []byte) {
	if len(is) >= 256 {
		for _, n := range bs {
			_ = is[n] // Boundary check is required for each cycle
		}
	}
}

func f5(is []int, bs []byte) {
	if len(is) >= 256 {
		for _, n := range bs {
			is = is[:256]
			_ = is[n] // Boundary check is required for each cycle
		}
	}
}

func BenchmarkFunc_f4_test(b *testing.B) {
	s := make([]int, 1000, 10000000)
	bs := []byte{'b', 'a', 'c', 'd', 'e', 'g', 'h', 'j'}
	for i := 0; i < b.N; i++ {
		f4(s, bs)
	}
}

func BenchmarkFunc_f5_test(b *testing.B) {
	s := make([]int, 1000, 10000000)
	bs := []byte{'b', 'a', 'c', 'd', 'e', 'g', 'h', 'j'}
	for i := 0; i < b.N; i++ {
		f5(s, bs)
	}
}

The benchmark test results are as follows:

go test -bench=. -benchmem
goos: linux
goarch: amd64
pkg: Go_base/daily_test/bce_demo
BenchmarkFunc_f4_test-8         179074254                6.33 ns/op            0 B/op          0 allocs/op
BenchmarkFunc_f5_test-8         208692784                5.82 ns/op            0 B/op          0 allocs/op
PASS
ok      Go_base/daily_test/bce_demo     3.253s

As a result, with the increase of the number of for loops, there is a significant difference in its performance. For small slices, the effect of array operation may not be very obvious, but if the data is large or the performance is harsh, it is necessary to avoid boundary checking.

4, Reference

  1. https://iswbm.com/362.html
  2. https://gfw.go101.org/article/bounds-check-elimination.html

Posted on Sun, 05 Dec 2021 21:10:30 -0500 by Marqis