CPU为什么会乱序?
原因是为了提高CPU的效率,挖掘程序并行度,将不相关的操作并行执行,排在后面的指令不等前面的指令执行结束就开始执行,并且在前面的指令执行结束前结束,例如在CPU0上执行下面两条指令:
在乱序情况下,b=2可能会早于a=1执行完。这种乱序,在其他流水线上是可见的,因为毕竟内存是共享的,但是CPU无法感知多核之间的数据依赖关系,假如另一条流水线运行的业务依赖这个赋值的先后顺序,如果不加处理,则有可能会导致业务逻辑出现错误。在单个流水线上乱序执行有没有这种问题呢?我认为是没有的,原因如下:
1.首先,如果a先于b完成自闭不言,本身就符合逻辑顺序。
2.b先于a完成,但是都早于assert完成,这种执行上虽然是乱序,但是从结果上和逻辑一致,也不会有问题。
3.b先于a完成,assert执行时,a还没有退役,这种情况下,a虽然没有完成,但是a最新状态要么在保留站中,要么在ROB中,要么由于数据相关指向另一个ROB或者保留站,无论哪种情况,最新状态都会从流水线内部的状态获得,而不会直接取内存(具体分析可以看量化这本书),所以仍然能够获取新值,不会出错。
a的最新值要么在流水线中,要么在内存中,即便执行顺序被打乱,但是仍然能够获取到正确的数据,所以,单条流水线不会有问题。
多流水线多发射就不行了,多流水线下,一条流水线不能从另一条流水线取数据,只能从内存中获取旧值,造成错误。
流水线的乱序和多发射
CPU流水线:
多条流水线不是乱序执行或者多发射的必要条件,乱序或者多发射也可以在单流水线上实现。单流水线实现乱序好理解,但是单个流水线实现多发射怎么理解呢?在VLIW架构中,每个周期也可以执行多于一条的指令,但是它和超标量处理器在本质上是有差别的,VLIW是依赖编译器和程序员自身来决定哪些指令可以并行执行,而超标量处理器则是靠硬件自身来决定哪些执行可以并行执行的,是运行时发现并行度,而非像VLIW架构那样在运行前静态决定。VLIW在硬件上实现是相对简单的,在功能专一的专用处理器领域可以有一番作为,比如DSP处理器。而当前的通用处理器则必须是超标量结构的。
suprscaler是超标量,表明系统中存在多个流水线,而超流水线特指流水线的深度。而单流水线也可以多发射。
5级双发射流水线
两路PC,红色和黑色两条执行路径。
SMT流水线(参考本等HT超线程技术)
PC和GPR寄存器多出几套。
Cortex-A53/Cortex-A57的多发射:
加上与上面两个正交的概念预测执行,构成计算机体系结构中三个两两正交的概念
以上的体系结构没有概念上的冲突,都可以设计出来。
即便对于支持乱序的处理器,其取指过程还是按照程序顺序取指令的,只是在发射阶段,只要执行需要的资源条件满足,不管前面的指令是否完成,都会发射出去执行。这样的话,最野蛮的动态屏障可以通过在取得屏障指令,完成ID阶段后刷新流水线实现。
如何定义读内存和写内存?
int x, y, r1, r2;
#define barrier() __asm__ __volatile__("": : :"memory")
//thread 1
void run1(void)
{
x = 1;
barrier();
r1 = y;
}
//thread 2
void run2(void)
{
y = 1;
barrier();
r2 = x;
}
编译为汇编指令
gcc -O3 -S main.c
.file "main.c"
.text
.p2align 4,,15
.globl run1
.type run1, @function
run1:
.LFB0:
.cfi_startproc
movl $1, x(%rip)
movl y(%rip), %eax
movl %eax, r1(%rip)
ret
.cfi_endproc
.LFE0:
.size run1, .-run1
.p2align 4,,15
.globl run2
.type run2, @function
run2:
.LFB1:
.cfi_startproc
movl $1, y(%rip)
movl x(%rip), %eax
movl %eax, r2(%rip)
ret
.cfi_endproc
.LFE1:
.size run2, .-run2
.comm r2,4,4
.comm r1,4,4
.comm y,4,4
.comm x,4,4
.ident "GCC: (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0"
.section .note.GNU-stack,"",@progbits
核心指令只有下面几行:
movl $1, x(%rip)
movl y(%rip), %eax
movl %eax, r1(%rip)
同样是movl指令,却代表这读和写两个意思,这一点和RISC处理器定义不太一样,无论是ARM,MIPS还是RISCV,读和写分别是两条不同的指令,ARM是ldr/str,MIPS是ld/sw,RISCV则和MIPS是同样的定义。
写中一定包含着读,但是读不一定会写。
所以,对于X86架构来说,定义如何区分读和写指令不能根据指令标记符,而要看使用场景。
所以,对于movl来讲,判断读写的依据是数据的移动方向,如果移动方向是从内存中到寄存器中,就是读,如果移动方向是寄存器到内存,则是写操作,还以以上面的举例:
movl $1, x(%rip) //立即数到内存,写操作
movl y(%rip), %eax //内存到寄存器,读操作
movl %eax, r1(%rip) //寄存器到内存,写操作
了解上面的例子就明白内存屏障的应用比之前理解的还要detail的多,通常你认为的写操作,首先蕴含的是隐藏的读操作,同理,读操作中可能包含着写操作,区分清楚读写操作,是正确有效的用好barrier的基础。
above example ,if we remove the barrier as below
int x, y, r1, r2;
#define barrier() __asm__ __volatile__("": : :"memory")
//thread 1
void run1(void)
{
x = 1;
r1 = y;
}
//thread 2
void run2(void)
{
y = 1;
r2 = x;
}
compiler command
gcc -S -O3 barrier.c
you can see ,the instruction x=1, y=2 are seperated by instruction r1=y and r2=x, the result you can get r1=0 and r2=0, whichi is very strange and inrational.
所以,你会得到r1=r2=0这种case,而这个逻辑,如果按照C代码的流程顺序执行,是不可能会出现的。
当按照C语言表达的顺序顺序执行的时候,以如下两个CPU的执行流举例。
我们可以证明一下,不会出现r1=r2=0这种case。反证法
证明:
假设r1或r2为1,则一定不会出现r1=r2=0,证明成立。
假设r1或者r2某一个为0,就假设r1=0吧,r1=0,可以推断出两个结果,x=1成立和y=1比 CPU0读y后执行。也就是说,CPU1执行movl x(%rip), %eax和movl %eax, r2(%rip)执行的时候,x已经是1了,所以,必然推断出r2=1.所以 这种情况下不可能r1=r2=0。
同理,可以证明r2为0时的情况。
所以,可以得出结论,如果不增加barrier选项,编译器的静态指令调度,可能会修改程序原来的语义。
this is abviously different compare to O0 optimilze option
gcc -S -O0 barrier.c
but why cause this difference? why instructon
"movl x(%rip), %eax"
place ahead before instrutions
"movl $1, y(%rip)"
to break the sequential flow?
that is because during O3 optimaliztion, the compiler apply a static optimilize method, when can reduce the pipeline data RAW harzard, read after write is a really dependent,对于存储器访问地址有相关性的指令,比如前一条指令写某个内存地址,后一条指令读该地址,那么他们的执行顺序一定不能被颠倒,否则会造成结果错误。而对于存储器访问地址没有相关性的指令,比如前后指令分别读写不一样的地址,那么他们的执行顺序可以被颠倒,不会影响程序最终的执行结果,不会造成结果错误。
基于上述的原理,编译器可以对程序产生的汇编指令流中的指令顺序进行适当改变,从而在某些情况下优化性能,比如将某些 有数据相关性的指令中间插入后续没有数据相关性的指令,上面的例子就属于这种情况。另一方面,处理器核的硬件在执行程序时也可以动态地调整指令的执行顺序,从而提高处理器的执行效率。
22 movl x(%rip), %eax
23 movl $1, y(%rip)
24 movl %eax, r2(%rip)
compare the instrucition flow
17 movl $1, x(%rip)
18 movl y(%rip), %eax
19 movl %eax, r1(%rip)
the O3 method can can avoid the pipeline stall and make improve the performance.
即便通过安插编译器静态barrier可以防止静态乱序情况的出现,但是当前的绝大部分高性能处理器都支持多发射,乱序执行,静态barrier仍然无法阻止指令乱序执行情况的出现。
dynamic instruction scheduling case:
here is an example of dynmic instruction memory barrier use scenario in linux kernel ttwu(try to wake up) work flow.
the classic