CPU分支预测

  1. 1 问题引入
  2. 2 分析
  3. 3 优化

1 问题引入

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

#include <iostream>
#include <ctime>
#include <algorithm>

int main(int argc, char** argv)
{
    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<double>(clock() - start) / CLOCKS_PER_SEC;
    std::cout << "elapsedTime:" << elapsedTime << ", CLOCKS_PER_SEC:" << CLOCKS_PER_SEC << std::endl;
    std::cout << "sum=" << sum << std::endl;
    return 0;
}

结果如下:

elapsedTime:11.15, CLOCKS_PER_SEC:1000000
sum=314931600000

现在注释掉std::sort函数,结果如下:

elapsedTime:24.78, CLOCKS_PER_SEC:1000000
sum=314931600000

可以发现先排序后计算运行效率提高了2倍,计算完全不涉及数据的有序性,理论上数组是否有序不会对计算产生任何作用,为什么排序后的数组要比未排序数组运行快2倍?

2 分析

处理器为了提高执行效率采用流水线架构,对于四级流水线,任何一条指令在CPU执行都必须经过如下步骤:

  • 取指(Fetch):从内存读取指令字节,地址为程序计数器(PC)的值
  • 译码(Decode):对取回的指令的拆分和解释,识别和区分出不同的指令类别及各种获取操作数的方法
  • 执行(Execute):执行指令,如果是跳转指令,会检查条件码和分支条件,选择分支
  • 写回(Write Back):将指令结果保存到内存

第一条指令进入执行阶段时,第二条指令已经开始译码,第三条指令处于取指阶段,...,相对于第一条指令执行写回内存再开始第二条指令的取指,效率大幅度提高。现代处理器一般流水线深度高达10-31级,对程序执行速度提高显著。但是遇到跳转指令时执行效率会急剧下降,对于分支跳转指令,我们在执行完该指令前是不知道是否发生跳转,即无法确定分支指令的下一条指令的地址,所以无法将分支指令的下一条指令放入流水线,只能等待分支指令执行完成才能开始下一条命令的取指步骤,导致流水线出现气泡(Bubble),大大降低流水线的处理能力。

为解决上述问题,分支预测器(Branch predictor)应运而生,当指令执行到分支跳转指令时,CPU不再是空等待分支跳转指令执行完毕给出下一条指令的地址,而是根据模型预测分支是否发生跳转以及跳转到哪里,CPU将预测到的指令放入流水线,去执行指令的取指、译码等工作。当分支跳转指令执行完成后,会给出真正是否需要跳转的结果,CPU即可判断分支跳转预测是否正确,如果预测正确,则流水线继续执行,如果预测错误,则需要清空流水线,将前面不该进入流水线的指令清空,然后回退到分支地方,将正确的分支路径放入流水线重新执行。大多数应用都具有状态良好的(well-behaved)分支,所以现代化的分支预测器一般具有超过90%的命中率,但是面对无法预测的分支,且没有识别出可应用的模式时,分支预测器就无用武之地了。处理器如何预测?答案是分析历史运行记录,常见的分支预测器有静态分支预测器,动态分支预测器。

上述代码导致非排序数组性能差别的关键在于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操作将会耗时更多。

3 优化

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

|x| >> 31 = 0 #非负数右移31位一定为0
~(|x| >> 31) = -1 #0取反为-1
-|x| >> 31 = -1 #负数右移31位一定为0xffffffff = -1
~(-|x| >> 31) = 0 #-1取反为0
-1 = 0xffffffff
-1 & x = x #以-1为mask和任何数求与,值不变

故分支判断可优化为:

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

当data[c] < 128,t为-1,t为0;
当data[c] >= 128,t为0,
t为-1;

若想避免移位操作,可以使用如下方式:

int t = -(data[c] >= 128);
sum += t & data[c];

转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至yj.mapple@gmail.com

文章标题:CPU分支预测

文章字数:1.4k

本文作者:melonshell

发布时间:2019-10-10, 23:40:47

最后更新:2020-06-09, 08:52:43

原始链接:http://melonshell.github.io/2019/10/10/CPU-branch-prediction/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。

目录
×

喜欢就点赞,疼爱就打赏

相册