分支预测相关

为什么要有pipeline

在这里插入图片描述
关于流水线(pipeline),这里举一个生活中的例子。
比如在洗车时,当前面一辆车清洗完成进入擦洗阶段后,下一辆车就可以进入喷水阶段了,这就是一个典型的流水线场景(如图所示),它不是说非要前面一辆车把清洗、擦洗全部完成后,下一辆车才能开始。一条洗车流水线可同时洗四辆车。洗车吞吐为4量车。
同理,为了提高cpu的执行指令的吞吐量,现在处理器将一个指令的执行,划分成多个阶段。
粗略地可以分成fetch(取指:读取指令)、decode(译码:执行译码)、execution(执行:执行指令)、retirement(写果:回写运行结果),细分开来可以分成十多甚至二十多个阶段。在处理器处理指令时,可以像流水线一样同时处理位于不同阶段的指令。

pipeline存在的问题

下图,假设一个pipeline分为四个阶段,每个阶段耗费一个时钟周期。
在这里插入图片描述
4条指令按照先后顺序进入pipeline,每间隔一个时钟周期,指令就能从pipeline的上一个阶段转移到下一个阶段,在第四个时钟周期时,4条指令全部进入pipeline内,各个阶段都含有一条指令。

按照这种策略,最佳的情况就是指令源源不断地进入pipeline,pipeline中就会一直都在同时处理四条指令,那么指令的处理效率就是原来的4倍。
其中存在一个必要的条件:A指令之后,一定是B指令,才有效果。如果A指令后,实际上是C指令,那么之前读取B指令的各个步骤全都废弃掉。
所以pipeline存在一个重大的问题:如何确定下一条需要执行的指令

分支预测

为了克服上述问题,pipeline中引入了Branch Prediction机制。

Branch Prediction就是通过预测,把接下来最有可能执行的分支(指令)获取进入pipeline,能显著提高pipelines的性能。
在这里插入图片描述

常见的分支预测技术

静态预测(傻傻的随机)

最简单的静态分支预测方法就是任选一条分支。
1)认为branch一定会token;
2)认为branch一定不会token;
这样平均命中率为50%。更精确的办法是根据原先运行的结果进行统计从而尝试预测分支是否会跳转。

动态预测(记录下概率)

相比静态预测,更精确的办法是根据原先运行的结果进行统计从而尝试预测分支是否会跳转。

BHT(Branch History Table)

顾名思义,这是记录分支历史信息的表格,用于判定一条分支指令是否token。

这儿记录的是跳转信息,简单点的,可以用1bit位记录,例如1表示跳转,0表示不跳转,而这个表格的索引是指令PC值;
考虑在32位系统中,如果要记录完整32位的branch history,则需要4Gbit的存储器,这超出了系统提供的硬件支持能力;
所以一般就用指令的后12位作为BHT表格的索引,这样用4Kbit的一个表格,就可以记录branch history了。

当然,通过大伙的不懈努力和分析,发现在BHT中用1bit位记录分支是否跳转还不够准确,用2bit位记录就非常好了,而用3bit或者更多位记录,效果与2bit类似。所以在BHT中,一般就用2bit位记录分支是否跳转:例如11和10表示这条分支会跳转;01和00表示分支不会跳转。这个2bit计数器大伙叫做饱和计数器。

常见的影响分支预测的情况

条件跳转(if else 语句)

CPU只有在跳转指令时才会做分支预测。

1、跳转指令不是大小比较产生的,而是像if 、 for这种逻辑分叉产生的底层就是跳转执行。
2、三目运算符底层就不是跳转指令。

来源于stackoverflow上的一个问题:为什么处理有序数组比处理无需数组快?此处先回答:因为有序数据,分组预测只失效了一次。

    public static void countUnsortedArr() {
    
    
        int cnt = 0;
        for (int i = 0; i < MAX_LENGTH; i++) {
    
    
            if (arr[i] < THRESHLOD) {
    
    
                cnt++;
            }
        }
    }

    public static void countSortedArr() {
    
    
        int cnt = 0;
        for (int i = 0; i < MAX_LENGTH; i++) {
    
    
            if (arrSotred[i] < THRESHLOD) {
    
    
                cnt++;
            }
        }
    }

测试环境

MacBook Pro (13-inch, 2017)
CPU: 2.5 GHz Intel Core i7
JMH version: 1.21
VM version: JDK 11, Java HotSpot™ 64-Bit Server VM, 11+28
测试方式:预热一轮,然后对每个函数做三轮的压测,每轮都是10s

结果如下,SCore表示执行一次这个函所需要的微秒数,数值越小性能越高。

Benchmark                              Mode  Cnt      Score      Error  Units
BranchPredictionTest.countSortedArr    avgt    3   5212.052 ± 7848.382  us/op
BranchPredictionTest.countUnsortedArr  avgt    3  31854.238 ± 5393.947  us/op

是不是很出乎意料,明显有序数组中统计快的多,性能差距足足有 6倍 。而且经过多次测试,这个性能差距非常稳定。

准备的数据是100w个从0-100w之间的数,然后统计小于50w的数的个数。无序的情况下相当于会有50%的可能性分支预测失败,有序情况下100w次预测只会有一次失败,分支预测失败就是产生性能差距的原因。

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

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 ...   (完全随机--无法预测)

虚函数调用

什么是虚函数

在 C++ 中,虚函数通过 virtual 关键字定义,实现在类的继承当中,编译器通过判断对象的类型,在调用函数时,执行对应的函数。

Java 中并没有显式去定义虚函数的概念,Java 中实际上每个函数都默认是一个虚函数(声明 final 关键字的函数除外),比如下面示例中 eat() 方法。

public class Animal {
    
    
	public void eat() {
    
     System.out.println("loongshawn eat like a generic Animal."); 
	}
}
 
public class Dog extends Animal {
    
    
   public void eat() {
    
     System.out.println("loongshawn eat like a dog!"); }
}
	 
public class Cat extends Animal {
    
    
   public void eat() {
    
     System.out.println("loongshawn eat like a cat!"); }
}
 
public static void main(String[] args) {
    
    
  List<Animal> animals = new LinkedList<Animal>();

   animals.add(new Animal());
   animals.add(new Dog());
   animals.add(new Cat());
   for (Animal currentAnimal : animals) {
    
    
      currentAnimal.eat();
   }
}

虚函数为啥性能开销很大
cpu执行虚函数时:
1、需要转查一次虚函数表(不是直接跳转)
2、和流水线相关是说得通的,究其原因还是因为存在动态跳转,这会导致分支预测失败,流水线排空
设想一下,如果说不是虚函数,那么在编译时期,其相对地址是确定的,编译器可以直接生成jmp/invoke指令;
如果是虚函数,多出来的一次查找vtable所带来的开销,倒是次要的,关键在于,这个函数地址是动态的。

为啥虚函数调用导致分支预测失败

对虚函数的调用是动态联编的,那么 动态联编是什么?

引自多态的概念:当不同的对象调用相同的名称的成员函数时,可能引起不同的行为(执行不同的代码),这种现象叫多态性。将 函数调用 链接 相应函数体的代码 的过程 称为函数联编。在C++中,分为静态联编和动态联编。

静态联编:不同的类可以有同名的成员或函数,编译器在编译时对它们进行函数联编,这种在编译时刻进行的联编称为静态联编,也称为编译时多态性。函数重载属于这种。

动态联编:在程序运行时才能确定调用哪个函数,也称为运行时多态性(也是java的多态)。在C++中,只有虚函数才可能是动态联编的,可以通过定义类的虚函数和创建派生类,在派生类中重新实现虚函数,实现具有运行时的多态性。

关于虚函数的调用,需要先查虚拟函数表,至于表索引是啥,只有真正在运行时确定是哪个类型,才知道。所以说函数地址是动态变化的,可能会有错误的分支预测,导致流水线清空

参考:
0、局部性原理
1、什么是分支预测
2、分支预测的类型
3、为什么if else 会让分支预测失效
4、一文告诉你CPU分支预测对性能影响有多大
5、虚函数执行慢的原因
6、什么是动态联编(虚函数就是动态联编,作为 4 的补充)
7、深入理解 CPU 的分支预测(Branch Prediction)模型

Guess you like

Origin blog.csdn.net/qq_43579103/article/details/121509990