聊一聊分支预测,思考为什么使用 if/else 语句会降低程序效率

写在前面

如果觉得写得好,有所收获,记得点个关注和点个赞哦,感谢支持。
在Stack Overflow上看到了这样的一个帖子,觉得挺值得学习的,这个帖子是关于讨论为什么处理排序数组比处理未排序数组快?看完后面的回答,然后得到了一个概念,就是“分支预测”,然后针对分支预测查看了许多资料和论文,觉得收获挺多的,所以写一篇博文记录一下。

引出问题

可能有很多人没有接触过“分支预测”,不要着急,我们在正式讲解分支预测之前,我们先来探讨一下上面的问题,为什么处理排序数组比处理未排序数组快?我们先来看一下下面这样一段代码

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

public class Main{
    public static void main(String[] args){
        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;

        // !!! 第一次运行的时候不进行排序,即注释掉下面的这个,第二次运行的时候排序
        Arrays.sort(data);

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

        for (int i = 0; i < 100000; ++i){
            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);
    }
}

上面的这段代码我们运行两次,第一次把排序的那行注释掉,第二次进行排序,可以观察一下运行的结果,我这里运行的
在这里插入图片描述在这里插入图片描述
左边的是未排序的,右边的是排序的,发现两种同样的遍历,排序和未排序之间的结果相差十秒之多,这是为什么?要知道,因为我们上面的代码是直接new出来的数组,所以排除数据被带入缓存的因素,当然如果你觉得会不会是编译器的因素,你大可以换编译器尝试一下,结果都是相似的,更甚者你可以使用C++或者其他语言进行尝试,其结果都是相似的结果,那么这到底是是为什么呢?其实这就涉及到计算机科学中一个非常重要的概念,就是分支预测。下面我们就来了解这个概念。

分析问题的原因

首先我们仔细看一下代码,在for循环中,我们是使用if分支进行判断操作,现在我们来这样考虑一个if语句,在处理器级别,它是一条分支指令。这样想,如果你是处理器,并且看到一个分支。你不知道它将走哪条路。你会怎么做?你应该停止执行当前的操作并等待之前的指令完成,然后再沿着正确的路径继续进行下一步操作。现代处理器很复杂,而且流程很长。因此,他们需要一直进行“热身”和“放慢脚步”的操作。那有没有更好的办法来解决这个问题呢?其中一个办法就是,通过猜测分支将会朝哪个方向前进,也就是进行结果预测。

  • 如果猜对了,则继续执行。
  • 如果猜错了,则需要刷新当前的操作缓存并回滚到分支。然后可以沿着正确的路径重新启动。

这就是分支预测,要知道,在计算机中,处理器直到最后一刻才知道分支的方向。所以进行分支预测式非常有效的一种方式,我们只要通过过去大量的分支行为来进行预测, 就可以提高预测的效率。换句话说,我们可以尝试识别一个模式并遵循这个模式,这或多或少是分支预测变量的工作方式。在现实开发中,大多数应用程序都具有行为良好的分支(这里的行为良好指的是分支的行为结果存在规律性)。因此,现代分支预测器通常将达到90%以上的命中率。但是,当面对没有可识别模式的不可预测分支时,分支预测变量实际上是没有用的。如果感兴趣想要深入的话,可以看这篇文章。到这里,我们就可以知道一件事情,上面排序和未排序之所以会有的差异的原因,是在于if分支,也就是下面这一段代码

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

我们可以知道,数据在0到255之间均匀分布,如果对数据进行排序时,大约前半部分的迭代将不会进入if语句。而后半部分都会进入if语句。这种情况就是上面所说的“规律性”,这对分支预测器非常友好,因为分支连续多次朝同一方向前进。即使是简单的饱和计数器也可以正确预测分支。我们来举个例子,假设排序之后的数据是如下这样的,我们将它可视化一下
在这里插入图片描述
当数据完全随机时,分支预测器将变得没有什么用,因为它无法预测随机数据。因此,可能会有大约50%的错误预测(没有比随机猜测好)同样我们可视化一下数据。
在这里插入图片描述

如何提高程序效率

通过上面的分析我们知道了因为分支的预测性,导致程序的效率下降的问题,我们怎么来解决这个问题呢?最直接的就是将数据进行规律性整理,保证分支预测的准确率,从而提高程序的效率。当然,如果编译器将数据进行规划性整理比较困难,我们还有一种方法,就是可以稍微牺牲一点程序的可读性来提高效率,即通过二进制位运算来解决。比如我们可以进行这样的替换

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

将上面的这段代码换成下面这样的

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

这样做直接消除了分支,并用一些按位运算将其替换(请注意,这种解决办法只是举个例子,在本例中并不完全等同于原始的if语句。但是在这种情况下,它对于的所有输入值均有效data[])。简而言之,就是分支可能会影响程序的效率,我们可以通过一些方式,避免使用分支,或者提高分支的效率(如排序),下面我把数据排序与未排序,分支和未分支的时间贴出来

//  分支+乱序
seconds = 16.93293813
//  分支+排序
seconds = 6.643797077
//  分支+乱序
seconds = 3.113581453
//  未分支+排序
seconds = 3.186068823

进一步讲讲分支预测

条件分支指令通常具有两路后续执行分支。即不采取(not taken)跳转,顺序执行后面紧挨JMP的指令,以及采取(taken)跳转到另一块程序内存去执行那里的指令。是否需要跳转,只有到真正执行时才能确定。如果没有分支预测器,处理器将会等待分支指令通过了指令流水线的执行阶段,才把下一条指令送入流水线的第一个阶段—取指令阶段(fetch stage),这种技术叫做 流水线停顿。分支预测器就是猜测条件判断会走哪一路,如果猜对,就避免流水线停顿造成的时间浪费。如果猜错,那么流水线中推测执行的那些中间结果全部放弃,重新获取正确的分支路线上的指令开始执行,这导致了程序执行的延迟。

什么是指令流水线?

开发计算机程序,本质上是编写一组期望计算机顺序执行的命令。早期的计算机一次仅执行一条命令。这意味着每个命令都会加载到内存中,执行完成后再加载下一个命令。指令流水线是一种改进。处理器会将工作分解成多个部分,对不同的部分并行执行。这样,处理器能够在加载下一条的同时执行一条命令。处理器内部的指令流水线越长,不仅可以简化还能并行执行更多的部分。这样能够提高系统的整体性能。例如下面这个简单的程序:

int a = 0;
a += 1;
a += 2;
a += 3;

程序会按照下面的流水线处理:Fetch(提取)、Decode(解码)、Execute(执行)、Store(存储):
在这里插入图片描述
这里可以看到四个命令如何并行处理,整体执行速度更快。处理器执行某些命令时会导致流水线问题。流水线中部分指令执行时依赖于之前的指令,但是前面的指令可能还没有执行。分支是一种危险。分支会挑选两个执行方向之一执行,但只有在解析后才能确定是哪个方向。这意味着通过分支加载命令都是不安全的,因为无法知道从哪里加载命令。修改上面的程序加入分支:

int a = 0;
a += 1;
if (a < 10) {
  a += 2;
}
a += 3;

运行结果与之前相同,但其中加入了 if语句。在解析前,虽然计算机能看到这些指令,但不能加载 if 后面的指令。因此,执行的顺序看起来像下面这样:
在这里插入图片描述
现在可以立刻看到加入分支对程序执行造成的影响,得到相同结果所需的时钟周期。分支预测是对上面的一种改进,计算机会尝试预测分支的执行路径,然后采取相应的动作。在上面的示例中,处理器会预测if(a <10)为 true,因此把 a += 2 作为下一条待执行指令。这将导致执行的顺序变成这样:
在这里插入图片描述
可以看到程序的性能立即得到了提升:现在只要9个时钟周期而不是之前的11个,速度提升了19%。但是,这样做也并非没有风险。如果分支预测出错,那么将对不应该执行的指令排队。发生这种情况时,计算机要丢弃这些指令重新开始。修改判断条件改为false:

int a = 0;
a += 1;
if (a > 10) {
  a += 2;
}
a += 3;

可能会像下面这样执行:
在这里插入图片描述
现在,即使处理的指令更少,执行却比之前慢!处理器错误地预测分支等于 true,把 a += 2 指令排队。接着发现分支等于 false,必须丢弃已排队的指令,然后重新执行。到此我们就饿差不多了解了分支预测的概念,这里多提一句,如果我们不能去掉分支,我们可以保证分支的顺序,if/else 语句的分支顺序很重要。也就是说,下面这样的分支安排性能会更好:

if (mostLikely) {
  // Do something
} else if (lessLikely) {
  // Do something
} else if (leastLikely) {
  // Do something
}
发布了144 篇原创文章 · 获赞 1098 · 访问量 128万+

猜你喜欢

转载自blog.csdn.net/DBC_121/article/details/105360658
今日推荐