CPU流水线模式对程序性能提升的影响

202 阅读11分钟

最近在看一本书 <深入理解计算机系统>,里面有一个章节是关于处理器体系结构,其中详细介绍了流水线的概念以及原理,非常有趣。作为研发,大家可能会关注一些提升程序性能的小技巧,编译器本身编译过程中也进行了大量的性能优化,最常见的比如指令重排、循环消除等,你可能不知道的是,这些性能优化小技巧本质就是提高CPU流水线的吞吐量,进而提升CPU的指令执行效率。

指令执行过程

指令

通俗来讲,就是CPU可以执行的最小单元。与CPU支持的指令集密切相关,目前常见的指令集为CISC(代表intel系列芯片)和RISC(代表ARM,比如高通、苹果系列芯片)。CISC支持的指令更为复杂(数量很多),相对来说控制难度上升。而RISC 较为简单(数量少),导致完成某个复杂操作时需要多条指令,但整个过程比较透明,容易控制。

关于CISC、RISC孰优孰劣的问题,历史上有个很多争论,没有标准答案。目前更多倾向于互相取长补短。

ARM v8 指令官网:developer.arm.com/documentati… 图片

指令执行过程

执行执行过程主要分为以下六个基本阶段:取指 -> 译码 -> 执行 -> 访存 -> 写回 -> 更新PC

  • 取指:CPU结构中有个非常重要的部件是PC(程序计数器),取值指的是从程序计数器中取当前需要执行的指令的开始地址(注意,指令不是存在PC中的),并将PC+1(指向下一条指令的开始地址),最后从内存中读取对应指令
  • 译码:从寄存器中读取操作数(现在指令结构限制最多只有两个操作数,比如 addq 指令就需要两个操作数)
  • 执行:CPU结构中另一个非常重要的部件ALU(算数逻辑运算单元),执行阶段简单理解为执行指令意图,比如累加
  • 访存:该阶段可以访问内存读数或写数
  • 写回:ALU计算结果写回到寄存器文件
  • 更新PC:将PC设置成下一条指令地址(这里指的是发生了指令跳转,比如if条件满足,我们会跳转到其他代码块,并不是顺序执行了)

这里简单描述了指令的执行过程,想要详细了解内部细节,推荐去读一下原书。

流水线原理

通过上面指令执行过程的分析,可以发现一条指令的执行分为若干个阶段,每个阶段占用了CPU的部分电路单元资源,其他电路单元处于空闲状态。流水线化的本质就是解决这个问题,将处于不同阶段的指令并行执行,使得同一时钟周期内关键电路被充分利用,从而提升整个CPU指令执行的吞吐量。

非流水线化执行

非流水线化执行一条指令从取指到更新PC独占CPU资源,其他指令需要等待其执行完成后才可被载入执行。我们假设每条指令的组合逻辑耗时30ms(实际是皮秒,这里只是为了简单计算)+ 寄存器操作10ms = 40ms,时钟周期为 40ms,则每秒吞吐量为:1000ms / 40ms = 25 条指令。图片

每一条指令完整走完每个阶段后下一条指令开始执行

流水线化执行

假设将指令执行分为3个阶段(注意,CPU执行设计过程中并不是严格按照上述的执行过程六个阶段去做划分的),每个阶段耗时10ms(注意:时钟周期取的是最慢的阶段作为周期间隔 ---- 木桶效应,主频高的CPU一定是通过硬件优化或阶段更加细化降低了最慢的那个阶段的耗时),附加10ms的寄存器操作事件,则每条指令实际耗时  10 x 3 + 10 x 3 = 60ms (增大了延迟),但时钟周期降低到了 10 + 10 = 20ms,每秒吞吐量为:1000ms / 20ms  = 50 条指令,对比非流水线提升2倍。
图片

增加了并行度,三条指令执行完成整体时间降低

流水线的局限性

不一致的划分

每个阶段的耗时是不一样的,将整个计算过程划分成具有相同延迟的各个阶段对硬件设计者是巨大的挑战。因此,阶段划分是一个平衡的过程,拿汽车生产举例:如果组装阶段很快1天组装100辆,喷漆阶段很慢一天只能喷10辆,巨大的时间差就会导致流水线运行效率降低,也是不合理的划分(你可能会想到增加更多的喷漆车间来解决这个问题,但本质还是提升某一阶段的效率来达到平衡)。

过多的阶段划分不一定等于收益增加

不同阶段的流转需要额外的成本(上述的10ms寄存器操作),如果额外成本超过了流水线本身的收益,那就会得不偿失。

流水线常见的优化手段

分支预测

实际编写的程序中,存在大量的分支逻辑(if、switch等),当流水线载入一条分支指令时,如何选择下一条载入的指令?这里就用到了分支预测的策略,预测失败可能会降低流水线吞吐量(因为相当于流水线载入了一部分并不会被执行到的指令,造成浪费)。不同的平台策略并不相同,但有意思的是有研究表明总是选择分支的预测策略成功率大约为60%(相当于命中if逻辑),基于这个分析,我们某些编程习惯可能会对性能产生影响,比如:

// caseA
if (!check(params)) {
  throw XxException("params error");
}
// do something

// caseB
if (check(params)) {
  // do something
  return result;
}
throw XxException("params error");

假设现在分支预测策略为总是选择,那么上述两个代码写法,显然 caseB 是要优于 caseA 的。为什么呢?因为我们绝大部分的请求是正常场景,也就是 check(params) = true,只有少数异常流量或边界场景会导致 check(params) = false, 因此 caseB 会有更多的有效代码被载入流水线中执行。

返回地址预测

绝大多数过程/函数调用结束时,都会返回到调用后的那条指令:

int a() {
  int num = b();
  return num+1;
}

int b() {
  // do something
  return xx;
}

方法 b 执行结束后,会执行方法 a 中的 return 语句,现代处理器会在取指单元中放入一个硬件栈,会保存过程调用的返回地址。当取出一个返回指令时,就从栈中弹出顶部值作为预测的返回值。同样,预测失败时也需要提供恢复机制。

指令重排

指令重排的本质仍然是提升流水线的吞吐量。通过重排来尽可能的避免分支预测、流水线冒险(两条指令前后依赖,先后进入流水线时会产生冒险)产生等。

程序性能优化

选择合适的算法和数据结构
比较直观,不过多赘述。

消除连续的函数调用

避免在循环中进行函数/过程调用(函数/过程调用会带来额外的开销,并且会影响大多数的程序优化),同时有选择的妥协程序模块性可以获得更大的效率(意思是方法别拆的太细从而导致大量的函数调用,这个也是寻找一个平衡,比如极致追求性能的程序模块可以选择妥协模块性)。

// 低效写法
for (i = 0; i < funA(); i++) { // do something }

// 高效写法
int j = funA();
for (i = 0; i < j; i++) { // do something }

消除不必要的内存引用

CPU寄存器的存取速度比内存快得多,如果寄存器中存的是操作数而不是内存地址,那么整个指令的执行效率要高很多(可以理解为省去了指令执行六个阶段中的访存阶段)。从Java角度来看,基本数据类型存储在栈中,引用类型(如对象)存储在堆中,栈中保存的是对象的引用地址,那么在操作对象时就会存在上述问题。

public class Test {

    public void a(int a, Integer sum) {
        for (int i = 0; i < a; i++) {
            // 每次循环,都需要从堆中取数、存数
            sum += getRandom();
        }
    }

    public void b(int a, Integer sum) {
        // 使用基本类型作为中间值
        int acc = 0;
        for (int i = 0; i < a; i++) {
            // 栈内完成
            acc += getRandom();
        }
        // 最后将结果保存到堆中的对象中
        sum += acc;
    }

    private int getRandom() {
        return (new Random()).nextInt(101);
    }
}

使用javap对字节码反编译后得到的文件中(不是汇编)可以看到,使用Integer类型循环中多了两次方法调用(拆装包),而基本类型则不存在这种情况。

图片

方法a

图片

方法b

循环展开

循环展开是一种程序变换,通过每次增加迭代计算的元素数量,减少循环的迭代次数。

int sum1(int[] nums) {
  int sum = 0;
  int j = nums.length;
  for (int i = 0; i < j; i++) {
      sum += nums[i];
  }
  return sum;
}


// sum2 通过每次循环中多计算一次,减少了整体循环次数,但整体直观性比 sum1 差很多,所以本质还是平衡的过程
int sum2(int[] nums) {
  int sum = 0;
  int j = nums.length - 1;
  for (int i = 0; i < j; i+=2) {
    sum += nums[i];
    sum += nums[i+1];
  }
  
  if (i < nums.length) {
    sum += nums[i];    
  }
  return sum;
}

多个累积变量&重新结合

多个累计变量本质是减少指令依赖(也就是降低流水线冒险),提升CPU指令执行并发度。比如循环展开中的第二个例子, 循环中的两行代码其实是有依赖关系,会在流水线中产生气泡。

int sum2(int[] nums) {
  int sum = 0;
  int acc0 = 0;
  int acc1 = 0;
  int j = nums.length - 1;
  for (int i = 0; i < j; i+=2) {
    // 引入 acc0、acc1 来消除下面两条指令之间的依赖关系,避免产生流水线冒险
    acc0 += nums[i];
    acc1 += nums[i+1];
  }
  
  if (i < nums.length) {
    sum += nums[i];    
  }
  return sum + acc0 + acc1;
}

重新结合

有一个有趣的现象是,利用计算机体系结构的特性通过对乘法、加法进行重新结合,有助于减少指令数量,提升性能。例如:

sum = a + b + c;sum = a + (b + c);

字节码反编译结果也有所差别:

图片

a + b + c

图片

a + (b+c)

条件数据传送

前面讲到过通过分支预测来进行流水线优化,但分支预测的命中概率是比较低的,而且实际业务代码中,分支条件存在任意特性,这些都会导致分支预测逻辑处理的会比较糟糕。对于这些本质无法预测的情况,使用条件数据传送比条件控制转移更能提升程序性能。是否能够使用条件数据传送,依赖编译器的优化,人为不能直接控制,但某些条件表达行为方法更容易直接被翻译成条件传送。看下面这段代码:


// 假设两个数组长度一样,这里只是做演示,不会处理那么严谨

// 条件控制转移
void funcA(int[] a, int[] b) {
  int length = a.length;
  for (int i = 0; i < length; i++) {
    if (a[i] > b[i]) {
      int tmp = a[i];
      a[i] = b[i];
      b[i] = tmp;        
    }    
  }
}

// 条件数据传送
void funcB(int[] a, int[] b) {
  int length = a.length
  for (int i = 0; i < length; i++) {
    int min = a[i] > b[i] ? b[i] : a[i];
    int max = a[i] > b[i] ? a[i] : b[i];
    a[i] = min;
    b[i] = max;    
  } 
}

总结

通过了解CPU底层指令执行的原理,有助于我们理解高级语言最终转换为机器语言的过程,也更容易让我们理解某些性能优化方法与人类逻辑思维的相悖。这其实是晶体管堆砌起来的硬件的局限性,但不妨碍我们看到人类无限的创造力。

欢迎专注微信公众号,原文链接:mp.weixin.qq.com/s/DCxcMijPP…