实操体验 CPU 的流水线/多发射

Posted doujiang24

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了实操体验 CPU 的流水线/多发射相关的知识,希望对你有一定的参考价值。

前言

前文 <一行机器指令感受下内存操作到底有多慢> 中,我们体验到了 CPU 流水线阻塞带来的数量级性能差异。当时只是根据机器码,分析推断出来的,这次我们做一些更小的实验来分析验证。

动手之前,我们先了解一些背景。在 \\<CPU 提供了什么> 一文中介绍过,CPU 对外提供了运行机器指令的能力。那 CPU 又是如何执行机器指令的呢?

CPU 是如何执行机器指令的

一条机器指令,在 CPU 内部也分为好多个细分步骤,逻辑上来说可以划分为这么五个阶段:

  1. 获取指令
  2. 解析指令
  3. 执行执行
  4. 访问内存
  5. 结果写回

流水线作业

例如连续的 ABCD 四条指令,CPU 并不是先完整的执行完 A,才会开始执行 B;而是 A 取指令完成,则开始解析指令 A,同时继续取指令 B,依次类推,形成了流水线作业。

理想情况下,充分利用 CPU 硬件资源,也就是让流水线上的每个器件,一直保持工作。然而实际上,因为各种原因,CPU 没法完整的跑满流水线。

比如:

  1. 跳转指令,可能跳转执行另外的指令,并不是固定的顺序执行。
    例如这样的跳转指令,可能接下来就需要跳转到 400553 的指令。
je    400553

对于这种分支指令,CPU 有分支预测技术,基于之前的结果预测本次分支的走向,尽量减少流水线阻塞。

  1. 数据依赖,后面的指令,依赖前面的指令。
    例如下面的两条指令,第二条指令的操作数 r8 依赖于第一条指令的结果。
mov    r8,QWORD PTR [rdi]
add    r8,0x1

这种时候,CPU 会利用操作数前推技术,尽量减少阻塞等待。

多发射

现代复杂的 CPU 硬件,其实也不只有一条 CPU 流水线。简单从逻辑上来理解,可以假设是有多条流水线,可以同时执行指令,但是也并不是简单的重复整个流水线上的所有硬件。

多发射可以理解为 CPU 硬件层面的并发,如果两条指令没有前后的顺序依赖,那么是完全可以并发执行的。CPU 只需要保证执行的最终结果是符合期望的就可以,其实很多的性能优化,都是这一个原则,通过优化执行过程,但是保持最终结果一致。

实践体验

理论需要结合实践,有实际的体验,才能更清晰的理解原理。

这次我们用 C 内联汇编来构建了几个用例来体会这其中的差异。

基准用例

#include <stdio.h>

void test(long *a, long *b, long *c, long *d) {
    __asm__ (
        "mov r8, 0x0;"
        "mov r9, 0x0;"
        "mov r10, 0x0;"
        "mov r11, 0x0;"
    );

    for (long i = 0; i <= 0xffffffff; i++) {
    }

    __asm__ (
        "mov [rdi], r8;"
        "mov [rsi], r9;"
        "mov [rdx], r10;"
        "mov [rcx], r11;"
    );
}

int main(void) {
    long a = 0;
    long b = 0;
    long c = 0;
    long d = 0;

    test(&a, &b, &c, &d);

    printf("a = %ldn", a);
    printf("b = %ldn", b);
    printf("c = %ldn", c);
    printf("d = %ldn", d);

    return 0;
}

我们用如下命令才执行,只需要 1.38 秒。
注意,需要使用 -O1 编译,因为 -O0 下,基准代码本身的开销也会很大。

$ gcc -masm=intel -std=c99 -o asm -O1 -g3 asm.c
$ time ./asm
a = 0
b = 0
c = 0
d = 0

real    0m1.380s
user    0m1.379s
sys     0m0.001s

以上的代码,我们主要是构建了一个空的 for 循环,可以看下汇编代码来确认下。
一下是 test 函数对应的汇编,确认空的 for 循环代码没有被编译器优化掉。

000000000040052d <test>:
  40052d:       49 c7 c0 00 00 00 00    mov    r8,0x0
  400534:       49 c7 c1 00 00 00 00    mov    r9,0x0
  40053b:       49 c7 c2 00 00 00 00    mov    r10,0x0
  400542:       49 c7 c3 00 00 00 00    mov    r11,0x0
  400549:       48 b8 00 00 00 00 01    movabs rax,0x100000000
  400550:       00 00 00
  400553:       48 83 e8 01             sub    rax,0x1  // 在 -O1 的优化下,变成了 -1 操作
  400557:       75 fa                   jne    400553 <test+0x26>
  400559:       4c 89 07                mov    QWORD PTR [rdi],r8
  40055c:       4c 89 0e                mov    QWORD PTR [rsi],r9
  40055f:       4c 89 12                mov    QWORD PTR [rdx],r10
  400562:       4c 89 19                mov    QWORD PTR [rcx],r11
  400565:       c3                      ret

加入两条简单指令

这次我们在 for 循环中,加入了 “加一” 和 “写内存” 的两条指令。

    for (long i = 0; i <= 0xffffffff; i++) {
        __asm__ (
            "add r8, 0x1;"
            "mov [rdi], r8;"
        );
    }

本次执行时间,跟基础测试基本无差别。
说明新加入的两条指令,和基准测试用的空 for 循环,被“并发” 执行了,所以并没有增加执行时间。

$ gcc -masm=intel -std=c99 -o asm -O1 -g3 asm.c
$ time ./asm
a = 4294967296
b = 0
c = 0
d = 0

real    0m1.381s
user    0m1.381s
sys     0m0.000s

再加入内存读

这个例子,也就是上一篇中优化 LuaJIT 时碰到的情况。
新加入的内存读,跟原有的内存写,构成了数据依赖。

    for (long i = 0; i <= 0xffffffff; i++) {
        __asm__ (
            "mov r8, [rdi];"
            "add r8, 0x1;"
            "mov [rdi], r8;"
        );
    }

再来看执行时间,这次明显慢了非常多,是的,流水线阻塞的效果就是这么感人

以上是关于实操体验 CPU 的流水线/多发射的主要内容,如果未能解决你的问题,请参考以下文章

容器平台自动化CI/CD流水线实操

多线程

查询处理器未能为执行并行查询启动必要的线程资源啥意思

CPU的流水线指令设计

多线程

多线程之指令重排序