关于处理器静态&动态内存屏障的原理和应用

本文深入探讨了处理器为何需要乱序执行,详细介绍了CPU流水线、多发射技术以及静态与动态乱序的概念。讨论了内存屏障在X86、ARM64和RISCV架构中的不同实现,强调了它们在多核环境下的重要性。通过实例分析,阐述了内存屏障如何确保数据一致性,并展示了Linux内核中内存屏障的应用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

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

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

papaofdoudou

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值