深入理解 CPU 的分支预测 (Branch Prediction) 模型

1,825 阅读7分钟
原文链接: segmentfault.com

说明: 本文以stackoverflow上Why is it faster to process a sorted array than an unsorted array?为原型,翻译了问题和高票回答并加入了大量补充说明,方便读者理解。

背景

先来看段c++代码,我们用256的模数随机填充一个固定大小的大数组,然后对数组的一半元素求和:

#include 
#include 
#include 

int main()
{
    // 随机产生整数,用分区函数填充,以避免出现分桶不均
    const unsigned arraySize = 32768;
    int data[arraySize];

    for (unsigned c = 0; c < arraySize; ++c)
        data[c] = std::rand() % 256;

    // !!! 排序后下面的Loop运行将更快
    std::sort(data, data + arraySize);

    // 测试部分
    clock_t start = clock();
    long long sum = 0;

    for (unsigned i = 0; i < 100000; ++i)
    {
        // 主要计算部分,选一半元素参与计算
        for (unsigned c = 0; c < arraySize; ++c)
        {
            if (data[c] >= 128)
                sum += data[c];
        }
    }

    double elapsedTime = static_cast(clock() - start) / CLOCKS_PER_SEC;

    std::cout << elapsedTime << std::endl;
    std::cout << "sum = " << sum << std::endl;
}

编译并运行:

g++ branch_prediction.cpp
./a.out

在我的macbook air上运行结果:

# 1. 取消std::sort(data, data + arraySize);的注释,即先排序后计算
10.218
sum = 312426300000

# 2. 注释掉std::sort(data, data + arraySize);即不排序,直接计算
29.6809
sum = 312426300000

由此可见,先排序后计算,运行效率有进3倍的提高。

为保证结论的可靠性, 我们再用java来测一遍:

import java.util.Arrays;
import java.util.Random;

public class Main
{
    public static void main(String[] args)
    {
        // Generate data
        int arraySize = 32768;
        int data[] = new int[arraySize];

        Random rnd = new Random(0);
        for (int c = 0; c < arraySize; ++c)
            data[c] = rnd.nextInt() % 256;

        // !!! With this, the next loop runs faster
        Arrays.sort(data);

        // Test
        long start = System.nanoTime();
        long sum = 0;

        for (int i = 0; i < 100000; ++i)
        {
            // Primary loop
            for (int c = 0; c < arraySize; ++c)
            {
                if (data[c] >= 128)
                    sum += data[c];
            }
        }

        System.out.println((System.nanoTime() - start) / 1000000000.0);
        System.out.println("sum = " + sum);
    }
}

在intellij idea中运行结果:

# 1. 先排序后计算
5.549553
sum = 155184200000
# 2. 不排序直接结算
15.527867
sum = 155184200000

也有三倍左右的差距。且java版要比c++版整体快近乎1倍?这应该是编译时用了默认选项,gcc优化不够的原因,后续再调查这个问题。

问题的提出

以上代码在数组填充时已经加入了分区函数,充分保证填充值的随机性,计算时也是按一半的元素来求和,所以不存在特例情况。而且,计算也完全不涉及到数据的有序性,即数组是否有序理论上对计算不会产生任何作用。在这样的前提下,为什么排序后的数组要比未排序数组运行快3倍以上?

分析

想象一个铁路分叉道口。

为了论证此问题,让我们回到19世纪,那个远距离无线通信还未普及的年代。你是铁路交叉口的扳道工。当听到火车快来了的时候,你无法猜测它应该朝哪个方向走。于是你叫停了火车,上前去问火车司机该朝哪个方向走,以便你能正确地切换铁轨。

要知道,火车是非常庞大的,切急速行驶时有巨大的惯性。为了完成上述停车-问询-切轨的一系列动作,火车需耗费大量时间减速,停车,重新开启。

既然上述过车非常耗时,那是否有更好的方法?当然有!当火车即将行驶过来前,你可以猜测火车该朝哪个方向走。

  • 如果猜对了,它直接通过,继续前行。

  • 如果猜错了,车头将停止,倒回去,你将铁轨扳至反方向,火车重新启动,驶过道口。

如果你不幸每次都猜错了,那么火车将耗费大量时间停车-倒回-重启。
如果你很幸运,每次都猜对了呢?火车将从不停车,持续前行!

上述比喻可应用于处理器级别的分支跳转指令里:

if (data[c] >= 128)
    sum += data[c];   
cmp edx, 128
jl SHORT $LN3@main
add rbx, rdx
$LN3@main:

让我们回到文章开头的问题。现在假设你是处理器,当看到上述分支时,当你并不能决定该如何往下走,该如何做?只能暂停运行,等待之前的指令运行结束。然后才能继续沿着正确地路径往下走。

要知道,现代编译器是非常复杂的,运行时有着非常长的pipelines, 减速和热启动将耗费巨量的时间。

那么,有没有好的办法可以节省这些状态切换的时间呢?你可以猜测分支的下一步走向!

  • 如果猜错了,处理器要flush掉pipelines, 回滚到之前的分支,然后重新热启动,选择另一条路径。

  • 如果猜对了,处理器不需要暂停,继续往下执行。

如果每次都猜错了,处理器将耗费大量时间在停止-回滚-热启动这一周期性过程里。
如果侥幸每次都猜对了,那么处理器将从不暂停,一直运行至结束。

上述过程就是分支预测(branch prediction)。虽然在现实的道口铁轨切换中,可以通过一个小旗子作为信号来判断火车的走向,但是处理器却无法像火车那样去预知分支的走向--除非最后一次指令运行完毕。

那么处理器该采用怎样的策略来用最小的次数来尽量猜对指令分支的下一步走向呢?答案就是分析历史运行记录: 如果火车过去90%的时间都是走左边的铁轨,本次轨道切换,你就可以猜测方向为左,反之,则为右。如果在某个方向上走过了3次,接下来你也可以猜测火车将继续在这个方向上运行...

换句话说,你试图通过历史记录,识别出一种隐含的模式并尝试在后续铁道切换的抉择中继续应用它。这和处理器的分支预测原理或多或少有点相似。

大多数应用都具有状态良好的(well-behaved)分支,所以现代化的分支预测器一般具有超过90%的命中率。但是面对无法预测的分支,且没有识别出可应用的的模式时,分支预测器就无用武之地了。

关于分支预测期,可参考维基百科相关词条"Branch predictor" article on Wikipedia..

文首导致非排序数组相加耗时显著增加的罪魁祸首便是if逻辑:

if (data[c] >= 128)
    sum += data[c];

注意到data数组里的元素是按照0-255的值被均匀存储的(类似均匀的分桶)。数组data有序时,前面一半元素的迭代将不会进入if-statement, 超过一半时,元素迭代将全部进入if-statement.

这样的持续朝同一个方向切换的迭代对分支预测器来说是非常友好的,前半部分元素迭代完之后,后续迭代分支预测器对分支方向的切换预测将全部正确。

简单地分析一下:
有序数组的分支预测流程:

T = 分支命中
N = 分支没有命中

data[] = 0, 1, 2, 3, 4, ... 126, 127, 128, 129, 130, ... 250, 251, 252, ...
branch = N  N  N  N  N  ...   N    N    T    T    T  ...   T    T    T  ...

       = NNNNNNNNNNNN ... NNNNNNNTTTTTTTTT ... TTTTTTTTTT  (非常容易预测)

无序数组的分支预测流程:

data[] = 226, 185, 125, 158, 198, 144, 217, 79, 202, 118,  14, 150, 177, 182, 133, ...
branch =   T,   T,   N,   T,   T,   T,   T,  N,   T,   N,   N,   T,   T,   T,   N  ...

       = TTNTTTTNTNNTTTN ...   (完全随机--无法预测)

在本例中,由于data数组元素填充的特殊性,决定了分支预测器在未排序数组迭代过程中将有50%的错误命中率,因而执行完整个sum操作将会耗时更多。

优化

利用位运算取消分支跳转。
基本知识:

|x| >> 31 = 0 # 非负数右移31为一定为0
~(|x| >> 31) = -1 # 0取反为-1

-|x| >> 31 = -1 # 负数右移31为一定为0xffff = -1
~(-|x| >> 31) = 0 # -1取反为0

-1 = 0xffff
-1 & x = x # 以-1为mask和任何数求与,值不变

故分支判断可优化为:

int t = (data[c] - 128) >> 31; # statement 1
sum += ~t & data[c]; # statement 2

分析:

  1. data[c] < 128, 则statement 1值为: 0xffff = -1, statement 2等号右侧值为: 0 & data[c] == 0;

  2. data[c] >= 128, 则statement 1值为: 0, statement 2等号右侧值为: ~0 & data[c] == -1 & data[c] == 0xffff & data[c] == data[c];

故上述位运算实现的sum逻辑完全等价于if-statement, 更多的位运算hack操作请参见bithacks.