【Java深刻学习系列】之CPU的分支预测(Branch Prediction)模型

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

背景

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

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

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<double>(clock() - start) / CLOCKS_PER_SEC;

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

编译并运行:ios

g++ branch_prediction.cpp
./a.out

在个人macbook air上运行结果:c++

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

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

因而可知,先排序后计算,运行效率有进3倍的提升。数组

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

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中运行结果:dom

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

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

问题的提出

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

分析

想象一个铁路分叉道口。oop

clipboard.png

为了论证此问题,让咱们回到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.

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

int t=-((data[c]>=128)); # generate the mask
sum += ~t & data[c]; # bitwise AND

结论

  • 使用分支预测: 是否排序严重影响performance
  • 使用bithack: 是否排序对performance无显著影响

这个例子告诉给咱们启示: 在大规模循环逻辑中要尽可能避免数据强依赖的分支(data-dependent branching).

补充知识

Pipeline

先简单说明一下CPU的instruction pipeline(指令流水线),如下简称pipeline。 Pipieline假设程序运行时有一连串指令要被运行,将程序运行划分红几个阶段,按照必定的顺序并行处理之,这样便可以加速指令的经过速度。

绝大多数pipeline都由时钟频率(clock)控制,在数字电路中,clock控制逻辑门电路(logical cicuit)和触发器(trigger), 当受到时钟频率触发时,触发器获得新的数值,而且逻辑门须要一段时间来解析出新的数值,而当受到下一个时钟频率触发时触发器又获得新的数值,以此类推。

而借由逻辑门分散成不少小区块,再让触发器连接这些小区块组,使逻辑门输出正确数值的时间延迟得以减小,这样一来就能够减小指令运行所须要的周期。 这对应Pipeline中的各个stages。

通常的pipeline有四个执行阶段(execuate stage): 读取指令(Fetch) -> 指令解码(Decode) -> 运行指令(Execute) -> 写回运行结果(Write-back).

分支预测器

分支预测器是一种数字电路,在分支指令执行前,猜想哪个分支会被执行,能显著提升pipelines的性能。

条件分支一般有两路后续执行分支,not token时,跳过接下来的JMP指令,继续执行, token时,执行JMP指令,跳转到另外一块程序内存去执行。

为了说明这个问题,咱们先考虑以下问题。

没有分支预测器会怎样?

加入没有分支预测器,处理器会等待分支指令经过了pipeline的执行阶段(execuate stage)才能把下一条指令送入pipeline的fetch stage。

这会形成流水线停顿(stalled)或流水线冒泡(bubbling)或流水线打嗝(hiccup),即在流水线中生成一个没有实效的气泡, 以下图所示:

本图中一个气泡在编号为3的始终频率中产生,指令运行被延迟

图中一个气泡在编号为3的始终频率中产生,指令运行被延迟。

Stream hiccup现象在早期的RISC体系结构处理器中常见。

有分支预测期的pipeline

咱们来看分支预测器在条件分支跳转中的应用。
条件分支一般有两路后续执行分支,not token时,跳过接下来的JMP指令,继续执行, token时,执行JMP指令,跳转到另外一块程序内存去执行。

加入分支预测器后,为避免pipeline停顿(stream stalled),其会猜想两路分支哪一路最有可能执行,而后投机执行,若是猜错,则流水线中投机执行中间结果所有抛弃,从新获取正确分支路线上的指令执行。可见,错误的预测会致使程序执行的延迟。

由前面可知,Pipeline执行主要涉及Fetch, Decode, Execute, Write-back几个stages, 分支预测失败会浪费Write-back以前的流水线级数。现代CPU流水线级数很是长,分支预测失败可能会损失20个左右的时钟周期,所以对于复杂的流水线,好的分支预测器很是重要。

常见的分支预测器

  • 静态分支预测器

静态分支预测器有两个解码周期,分别评价分支,解码。即在分支指令执行前共经历三个时钟周期。
详情见图:

静态分支预测器

  • 双模态预测器(bimodal predictor)

也叫饱和计数器,是一个四状态状态机. 四个状态对应两个选择: token, not token, 每一个选择有两个状态区分强弱: strongly,weakly。分别是Strongly not takenWeakly not taken, Weakly taken, Strongly taken

状态机工做原理图以下:

饱和计数器

图左边两个状态为不采纳(not token),右边两个为采纳(token)。由not token到token中间有两个渐变状态。由红色到绿色翻转须要连续两次分支选择。

技术实现上可用两个二进制位来表示,00, 01, 10, 11分别对应strongly not token, weakly not token, weakly token, strongly token。 一个判断两个分支预测规则是否改变的简单方法即是判断这个二级制状态高位是否跳变。高位从0变为1, 强状态发生翻转,则下一个分支指令预测从not token变为token,反之亦然。

据评测,双模态预测器的正确率可达到93.5%。预测期通常在分支指令解码前起做用。

其它常见分支预测器如两级自适应预测器,局部/全局分支预测器,融合分支预测器,Agree预测期,神经分支预测器等。

相关文章
相关标签/搜索