CPU分支预测

cpu branch prediction

Posted by alovn on February 16, 2023

CPU为了加速程序的运行,它可以预取、缓存指令和数据。对于静态函数的调用,CPU会预知即将执行的分支,然后预取对应的指令。当使用动态调度的时候,CPU就无法预知程序的执行分支了。不过CPU会运用各种算法来猜测程序的下一步执行分支,这就是分支预测。如果CPU猜对了,就可以快速的执行,因为即将执行的指令已经被预取到了处理器的缓存中了。

下面的两个函数有什么区别吗?可以看出它们执行的总数都是10*100*1000次,但是它们的执行时间却大不相同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func A() {
    for i := 0; i < 10; i++ {
        for j := 0; j < 100; j++ {
            for k := 0; k < 1000; k++ {
            }
        }
    }
}

func B() {
    for i := 0; i < 1000; i++ {
        for j := 0; j < 100; j++ {
            for k := 0; k < 10; k++ {
            }
        }
    }
}

对以上函数做一个简单的性能测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
func BenchmarkA(b *testing.B) {
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        A()
    }
}

func BenchmarkB(b *testing.B) {
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        B()
    }
}

执行((这里使用的是go1.19.1 darwin/amd64)):

1
2
3
4
5
6
7
8
9
go test cpu_test.go -bench=.

goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i7-4870HQ CPU @ 2.50GHz
BenchmarkA-8          3684            313370 ns/op
BenchmarkB-8          1657            716028 ns/op
PASS
ok      command-line-arguments  2.650s

从上面的输出结果中可以看出, 函数A执行了3684次,平均每次执行耗时313370ns,函数B执行了1657次,平均每次执行耗时716028ns。函数A执行比函数B执行快了2倍多。

为什么出现这么大的差别呢?

原因在于函数B的分支预测的错误次数比较多。CPU依次从内存中读取指令,当遇到循环或if else时,会判断条件产生两个分支,其中某个分支成立时就对应着一个跳转指令。这时要执行的下一条指令就不是从内存中顺序加载了,而是从对应的分支中读取内存中的指令。

分支预测策略最简单的一种方式是假定跳转不发生。如果假定CPU执行了这种策略,那么对应到上面的循环代码,就是循环始终会进行下去。

因此在上面的函数A中,内层k循环每隔1000次才会发生1次预测错误。而同样的错误,在外层i、j循环每次都会发生。j循环发生100次,最外层的i循环发生10次,所以一共会发生10×100=1000次预测错误。

而对于函数B,内部k每循环10次,就会发生一次预测错误。而同样的错误,外层i、j每次循环都会发生。第二层j循环发生100次,最外层i循环发生1000次,所以一共会发生100×1000=10万次预测错误。

对于这个问题,在现实中很少成为问题。在现实的在线业务中大概率不会有如此密集分支预测错误导致性能下降的情况。而且现代的CPU都有缓存,若某个接口经常被调用,那么CPU可以从缓存中快速的获取对应函数的指针。不过我们还是要知道怎么样更高效的利用CPU。