鲲鹏内存屏障

 

 

单核 vs 多核

从多核的视角上来说,是存在着乱序的可能的。比如,假设存在变量x = 0,cpu0上执行写入W0(x, 1),对x写入1。接着在cpu1上,执行读取R1(x, 0),得到x = 0,这在x86和arm/power的cpu上都是可能出现的。原因是x86上cpu核和cache以及内存之间,存在着store buffer,当W0(x, 1)执行成功后,修改只存在于store buffer中,并未写到cache以及内存上,因此cpu1读取不到最新的x值。对于arm/power来说,同样也有store buffer,而且还可能会有invalid queue,导致cpu1读不到最新的x值

对于没有invalid queue的x86系列cpu来说,当修改从store buffer刷入cache时,就能够保证在其他核上能够读到最新的修改。但是,对于存在invalid queue的cpu来说,则不一定。

为了能够保证多核之间的修改的可见性,我们在写程序的时候需要加上内存屏障,例如x86上的mfence指令。

乱序执行 vs 顺序提交

我们知道,在cpu中为了能够让指令的执行尽可能地并行起来,从而发明了流水线技术。但是如果两条指令的前后存在依赖关系,比如数据依赖,控制依赖等,此时后一条语句就必需等到前一条指令完成后,才能开始。

cpu为了提高流水线的运行效率,会做出比如:1)对无依赖的前后指令做适当的乱序和调度;2)对控制依赖的指令做分支预测;3)对读取内存等的耗时操作,做提前预读;等等。以上总总,都会导致指令乱序的可能。

但是对于x86的cpu来说,在单核视角上,其实它做出了Sequential consistency[1]的一致性保障。Sequential consistency的在wiki上的定义如下:

"... the result of any execution is the same as if the operations of all the processors were executed in some sequential order, and the operations of each individual processor appear in this sequence in the order specified by its program."

也就是说,要满足Sequential consistency,必需保障每个处理器的指令执行顺序必需和程序给出的顺序一致。奇怪吧?这不就和我刚才说的指令乱序优化矛盾了嘛?其实并不矛盾,指令在cpu核内部确实是乱序执行和调度的,但是它们对外表现却是顺序提交的。如果把ISA寄存器(如EAX,EBX等)和store buffer,作为cpu对外的接口的话,cpu只需要把内部真实的物理寄存器按照指令的执行顺序,顺序映射到ISA寄存器上,也就是cpu只要将结果顺序地提交到ISA寄存器,就可以保证Sequential consistency。

当然,以上是对x86架构的cpu来说的,ARM/Power架构的cpu在单核上的一致性保证要弱一些,无需保证Sequential consistency,因此也不需要顺序提交,只需保证控制依赖,数据依赖,地址依赖等指令的顺序即可。要想在这些弱一致性模型cpu下保证无关指令间的提交顺序,需要使用barrier指令。

Store Buffer & Invalid Queue

store buffer存在于cpu核与cache之间,对于x86架构来说,store buffer是FIFO,因此不会存在乱序,写入顺序就是刷入cache的顺序。但是对于ARM/Power架构来说,store buffer并未保证FIFO,因此先写入store buffer的数据,是有可能比后写入store buffer的数据晚刷入cache的。从这点上来说,store buffer的存在会让ARM/Power架构出现乱序的可能。store barrier存在的意义就是将store buffer中的数据,刷入cache。

在某些cpu中,存在invalid queue。invalid queue用于缓存cache line的失效消息,也就是说,当cpu0写入W0(x, 1),并从store buffer将修改刷入cache,此时cpu1读取R1(x, 0)仍是允许的。因为使cache line失效的消息被缓冲在了invalid queue中,还未被应用到cache line上。这也是一种会使得指令乱序的可能。load barrier存在的意义就是将invalid queue缓冲刷新。

X86 vs ARM/Power

对于x86架构的cpu来说,在单核上来看,其保证了Sequential consistency,因此对于开发者,我们可以完全不用担心单核上的乱序优化会给我们的程序带来正确性问题。在多核上来看,其保证了x86-tso模型,使用mfence就可以将store buffer中的数据,写入到cache中。而且,由于x86架构下,store buffer是FIFO的和不存在invalid queue,mfence能够保证多核间的数据可见性,以及顺序性。[2]

对于arm和power架构的cpu来说,编程就变得危险多了。除了存在数据依赖,控制依赖以及地址依赖等的前后指令不能被乱序之外,其余指令间都有可能存在乱序。而且,它们的store buffer并不是FIFO的,而且还可能存在invalid queue,这些也同样让并发编程变得困难重重。因此需要引入不同类型的barrier来完成不同的需求。[3]

 

 

什么是内存屏障

由于现代CPU的运行速度往往要比内存要快得多——一般在从内存获取一个变量的同时,CPU可以执行数百条指令,因此现代计算机架构往往在CPU和内存之间增加一级缓存,以允许在缓存中快速访问较为频繁使用的数据。与此同时,CPU被设计成在从内存中获取数据的同时,可以执行其他指令和内存引用,这就导致了指令和内存引用的乱序执行。为了解决这一内存乱序问题,引入了各种同步原语,这些原语通过使用内存屏障来实现多处理器之间内存访问的顺序一致性。

 

什么时候需要使用内存屏障

仅仅在两个CPU之间存在需要通过共享内存来实现交互的可能时,才需要使用内存屏障。

说明:

使用原始的内存屏障原语不是最好的选择,建议使用包含内存屏障语义的高级原语。例如:可以使用c++原子操作的内存屏障接口,详情可参考https://en.cppreference.com/w/cpp/atomic/memory_order

 

什么时候不需要显式使用内存屏障

各种架构下内存序模型定义有所差异,下面介绍在Arm架构下,不需要显式使用内存屏障的几个典型场景。

  • 存在地址依赖时,不需要显式使用内存屏障,也可保证内存一致性。
    例如:
    LDR X1,[X2] 
    AND X1, X1, XZR 
    LDR X4,[X3, X1]
     

    第三行的汇编语句从内存地址[X3, X1]读数据到寄存器X4时,需要依赖于寄存器X1从地址[X2]中读到的值,所以在这种情况下,LDR X1, [X2]将先于LDR X4, [X3, X1]执行。

  • 存在控制依赖时,不需要显式使用内存屏障,也可保证内存一致性。

    例如:

    r1 = x;
    if(r1 ==0) nop(); 
    y =1;
     

    如果一个条件分支依赖于一个加载操作,那么在条件分支后面的存储操作都在加载操作后执行。所以r1 = x将先于y = 1执行。

  • 存在寄存器数据依赖时,不需要显式使用内存屏障,也可保证内存一致性。

    例如:

    LDR X1,[X2] 
    ADD X3, X3, X1 
    SUB X3, X3, X1 
    STR X4,[X3]
     

    以上的语句执行过程中,在执行最后的STR指令时依赖于X3寄存器中存放的内存地址,而X3寄存器的值又依赖于从[X2]内存地址中获取到的数据X1寄存器的值;这些寄存器之间存在数据依赖,所以LDR X1, [X2]将先于STR X4, [X3]执行。

 

架构差异

简要总结x86和Arm之间的内存序差异,如表1所示:

表1 x86和鲲鹏架构之间的内存序差异

内存乱序行为

x86

arm

读-读乱序

不允许

允许

读-写乱序

不允许

允许

写-读乱序

允许

允许

写-写乱序

不允许

允许

原子操作-读写乱序

不允许

允许

 

简单说明一下表1中内存乱序行为的含义。

以写-读乱序为例,假设在程序中连续访问两个全局变量,执行的操作序列是先写一个值到全局变量,再读取另一个全局变量的值,如下所示:

CPU 0

CPU 1

x = 1

r1 = y

y = 1

r2 = x

初始值: x = 0 且 y = 0

表1可知,在x86和arm架构中都存在写-读乱序行为。故其实际执行序列可能如下所示:

CPU 0

CPU 1

r1 = y

x = 1

r2 = x

y = 1;

初始值: x = 0 且 y = 0

所以最终的执行结果有可能是r1 = 0 且 r2 = 0。

总的来说,x86架构下的内存序要比arm下严格得多,大部分情况下的内存访问都是定序的,如果不加修改的将多线程程序移植到arm架构下,就可能会出现功能问题。

CPU内存屏障指令移植

在x86-64架构中,内存屏障指令主要分为sfence、lfence和mfence三类。

  • sfence指令前后的写入(store/release)指令,按照在sfence前后的指令序进行执行。从硬件上来说,保证store buffer数据全部被清空的时候才继续往后面执行。
  • lfence指令前后的读取(load/acquire)指令,按照在lfence前后的指令序进行执行。
  • mfence指令之前的写入(store/release)指令,都在该mfence指令之后的写入(store/release)指令之前(指令序,Program Order)执行。既确保写者能够按照指令序完成数据写入,也确保读者能够按照指令序完成数据读取。
例如,在x86上的代码段:
__asm__ __volatile__("sfence" : : : "memory"); 
__asm__ __volatile__("lfence" : : : "memory"); 
__asm__ __volatile__("mfence" : : : "memory");

在鲲鹏上分别替换为:

_asm__ __volatile__("dmb ishst" : : : "memory"); 
__asm__ __volatile__("dmb ishld" : : : "memory"); 
__asm__ __volatile__("dmb ish" : : : "memory");

在程序编译的时候,特别是加入了优化选项-O2或-O3之后,编译器可能会将代码打乱顺序执行,即编译生成后的汇编代码的执行顺序可能与原始的高级语言代码中的执行顺序不一致。所以编译器提供了编译阶段的内存屏障,用于指导编译器及时刷新寄存器的值到内存中,保证该编译器屏障前后的内存访问指令在编译后是定序排布的。

常见的编译型屏障定义如下所示:

#define barrier() __asm__ __volatile__("": : :"memory") 
 

x86属于强内存序架构,大部分情况使用编译型屏障就可以保证多线程内存访问的一致性。但是,在arm架构下就无法保证这一点。举例说明如下:

【例1】

#define barrier() __asm__ __volatile__("": : :"memory")
// init: flag = data = 0;
thread0(void){     
    data = 1;
    barrier();     
    flag =1;
}
thread1(void){
    if(flag != 1) return;
    barrier();
    assert(data == 1);
}
 

在以上代码中,thread0和thread1分别运行在不同的CPU上,在x86架构下不会触发thread1中的断言,但是在arm架构下就可能出现(flag == 1 && data == 0)的情况,如表1中所示,arm架构下可允许写-写乱序,所以存在flag被置为1但是实际上data仍未被赋值的情况,从而导致了thread0中断言被触发。

参考Linux内核代码中的平台相关的宏定义,如下所示:

// x86
#define barrier() __asm__ __volatile__("": : :"memory")
#define smp_rmb() barrier()
#define smp_wmb() barrier()
#define smp_mb() asm volatile("lock; addl $0,-132(%%rsp)" ::: "memory", "cc")
// arm
#define smp_mb()  asm volatile("dmb ish" ::: "memory")
#define smp_wmb() asm volatile("dmb ishst" ::: "memory")
#define smp_rmb() asm volatile("dmb ishld" ::: "memory")
 

在x86架构下,读屏障和写屏障的宏smp_rmb()和smp_wmb()都设置成了编译器内存屏障而在arm架构下则都设置成了CPU指令级内存屏障,从这里也可看出两个架构之间的差异。所以为了确保thread1中的断言不被触发,我们需要将原代码中的编译型屏障改写成CPU级内存屏障,如下所示:

#define smp_wmb() asm volatile("dmb ishst" ::: "memory")
#define smp_rmb() asm volatile("dmb ishld" ::: "memory")
// init: flag = data = 0;
thread0(void) {
    data = 1; 
    smp_wmb();
    flag = 1; 
}
thread1(void) { 
    if (flag != 1) 
       return;
    smp_rmb();
    assert(data == 1);
}

尽可能使用acquire和release语义进行同步

相比ARMv7,ARMv8增加了load-acquire(LDLARB,LDLARH和LDLAR)和store-release(STLLRB,STLLRH和STLLR)指令,这可以直接支持c++原子库中的相关语义。这些指令可以理解成半屏障。这些半屏障指令的执行效率要比全屏障更高,所以在能够使用这种类型的屏障时,我们尽可能acquire和release语义来做线程间的同步。

Read-Acquire用于修饰内存读取指令,一条 read-acquire 的读指令会禁止它后面的内存操作指令被提前执行即后续内存操作指令重排时无法向上越过屏障。

Write-Release用于修饰内存写指令,一条 write-release 的写指令会禁止它上面的内存操作指令被乱序到写指令完成后才执行,即写指令之前的内存操作指令重排时不会向下越过屏障。

所以,为进一步提高性能,我们可以将CPU内存屏障指令移植中【例1】的代码改写成如下所示:

thread0(void) {
    data = 1; 
    barrier();
    __atomic_store_n (&flag, 1, __ATOMIC_RELEASE);
}
thread1(void) { 
    if (__atomic_load_n (&flag, __ATOMIC_ACQUIRE) != 1) 
       return;
    assert(data == 1);
}

 

 

Reference

[1] Lamport, Leslie. "How to make a multiprocessor computer that correctly executes multiprocess progranm." IEEE transactions on computers 9 (1979): 690-691.

[2] Sewell, Peter, et al. "x86-TSO: a rigorous and usable programmer's model for x86 multiprocessors." Communications of the ACM 53.7 (2010): 89-97.

[3] Maranget, Luc, Susmit Sarkar, and Peter Sewell. "A tutorial introduction to the ARM and POWER relaxed memory models." Draft available from . cl. cam. ac. uk/~ pes20/ppc-supplemental/test7. pdf (2012).

[4]Memory Barrierhttps://lostjeffle.bitcron.com/post/blog/15937432540773

[5]https://support.huaweicloud.com/codeprtr-kunpenggrf/kunpengtaishanporting_12_0044.html

posted on 2021-03-17 17:42  tycoon3  阅读(1337)  评论(0编辑  收藏  举报

导航