在基于CPU的串行应用程序上,程序员一般对存储器访问次序不会太过敏感。因为在CPU上,应用程序一般都是用普通的读写操作(尽管现在有些CPU也支持弱次序访存操作,但对于串行应用程序来说,存储器一致性会得到保存),这些读写操作的效果都会严格按照程序串行次序,比如像以下代码:
/*加volatile关键字确保当前线程对此数组的修改操作对其他线程可见,
即显式使用加载/存储操作,而不是直接在寄存器中操作*/
volatile int a[] = { 1, 2 };
int x = a[0];
int y = a[1];
a[0] = x + 1;
a[1] = y + 2;
上述代码如果是在一块双核的CPU上执行,那么在其中一个核心上执行完上述代码,而在另一个核心上去观察,肯定观察到a[0]
先被写完,然后再是a[1]
被写完。在GPGPU上不会去保证这种存储器次序一致性。也就是说,如果我们在某个工作项上执行上述代码,在另一个工作项上可能会观察到a[1]
先完成存储,然后再是a[0]
。换句话说,在另一个工作项上,读取a[0]
时可能仍然是1,而读取a[1]
时值已经是4了。由于对于大规模数据并行访存来说,要保证存储次序一致性需要非常昂贵的开销,而且在很多情况下,每个工作项只需关心自己读写的那块数据而不去管其他工作项读写的数据,因此GPGPU一般都会使用弱存储器次序。而如果一些工作项需要使用其他工作项产出的数据,那么可以使用我们下面讲述的更强的存储器次序。
OpenCL 2.0提供了若干种存储器次序,可利用它们对不同执行单元所共享的原子对象进行原子操作。这里的执行单元是指硬件上的线程,比如CPU上的一个核心或是GPU上的一个处理元素。这些存储器次序一般同时包含了两种不同的语义:一种是在当前执行单元上下文中,相继几个存储器操作之间的执行次序(按照程序次序严格执行,还是可以进行相互交换前后次序执行);另一种是在某一执行单元中,对另一个执行单元进行存储器操作所产生的副作用是否可见。而这些存储器次序也完全基于ISO/IEC 9899:2011(即C11)标准中的存储器模型。以下介绍每种存储器次序的特性及用法,并列出相关示例代码。
memory_order_relaxed
memory_order_relaxed(松弛的存储器次序),该存储器次序意味着对存储器次序不做任何限制。如果用这个存储器次序进行原子操作(如使用atomic_fetch_add),原子对象也能安全地递增。但是该原子操作无法保证其他访存操作与该操作之间的次序。memory_order_relaxed也是次序最弱的。
比如,像以下代码:
global volatile atomic_int atom_obj = ATOMIC_VAR_INIT(10);
global volatile int a = 100;
atomic_fetch_add_explicit(&atom_obj, 1, memory_order_relaxed);
a += 10;
对于这段代码,atom_obj肯定能发生递增操作,但是在当前执行单元中可能观察到a变量先被修改,然后atom_obj再被修改,即这个修改次序没有严格按照程序次序进行。另外,对于其他执行单元而言,对atom_obj原子对象以及变量a的修改可能不可见。下面再举一个看上去更极端但很有可能发生的例子:
global volatile atomic_int atomA = ATOMIC_VAR_INIT(10);
global volatile atomic_int atomB = ATOMIC_VAR_INIT(100);
//执行单元0
int b = atomic_load_explicit(&atomB, memory_order_relaxed);
atomic_store_explicit(&atomA, b, memory_order_relaxed);
//执行单元1
int a = atomic_load_explicit(&atomA, memory_order_relaxed);
atomic_store_explicit(&atomB, 50, memory_order_relaxed);
对于上述代码,我们假定执行单元1在执行单元0执行完后立即执行。我们看到一开始atomA原子对象的值被初始化为10,atomB原子对象被初始化为100。而在执行单元0中先用memory_order_relaxed存储器次序读atomB的值,然后再把该值写到atomA原子对象中。对于这两个一读一写,在执行单元0中肯定会按照程序次序进行。因为就目前而言,通常不会有处理器实现这么一种存储器次序:在同一个执行单元的上下文中,先读取某个存储单元的值A,然后把读到的值A写到另一个存储单元中,在此过程中读写次序不能保证原始的程序次[插图]
。除非执行类似上述执行单元1中所示的代码,先读取对象A,然后写的是对象B的值,那么这两者之间的操作次序不需要严格按照程序次序执行。
执行单元1中,从atomA原子对象读到的值没有在原子对象B的存储操作中使用,也就是说,这两个操作是完全相互独立的,没有任何依赖。因此,这两个操作如果都使用memory_order_relaxed存储器次序,那么即便在当前执行单元的上下文中也是不会保证程序次序的。这就意味着如果处理器支持无序执行(即Out-of-Order execution,简称OoO执行),那么处理器实现完全可以先做下面的存储操作,再做上面的加载操作(这个同第一个例子中的情况一样)。另外,由于执行单元0的加载与存储操作使用的是memory_order_relaxed存储器次序,因此这两个操作在执行单元1中可能不会立刻被观察到。然后,结合执行单元1中本身存在的先执行存储、后执行加载的操作,那么最终执行的流程可能是:先做执行单元1中的atomic_store_explicit(&atomB,50,memory_order_relaxed),然后是执行单元0的所有操作,最后是执行单元1的int a=atomic_load_explicit(&atomA,memory_order_relaxed)。当然,这仅仅是可能的一种排列方式。也不排除整个执行恰好按照程序次序完成,这都取决于当前硬件的实现以及当前处理器的执行状态。如果按照上面那种流程执行完,那么执行单元0中的局部变量b与执行单元1中的局部变量a全都是50。而且原子对象atomA的值也是50。
memory_order_acquire
memory_order_acquire(具有获得语义的存储器次序),可用于如栅栏或原子操作。其作用是从和它进行同步的释放操作那里“获得”副作用(所谓副作用就是对相关数据的修改):如果一次获得操作跟一次释放操作进行同步,那么执行单元的获得操作将会看到在那个释放操作之前的所有副作用(并且也有可能看到后续的副作用)。而在使用memory_order_acquire存储器次序的当前执行单元中,其后面的访存操作不会被重新编排到它之前执行。我们在编写OpenCL程序时可以使用一个“获得”语义以安全地观察到另一个执行单元对某些共享存储变量的修改情况。例如:
global volatile atomic_int atomA = ATOMIC_VAR_INIT(10);
global volatile atomic_int atomB = ATOMIC_VAR_INIT(20);
//在执行单元0中
atomic_fetch_add_explicit(&atomA, 1, memory_order_relaxed);
atomic_store_explicit(&atomB, 0, memory_order_release);
//在执行单元1中
int b = atomic_load_explicit(&atomB, memory_order_acquire);
int a = atomic_load_explicit(&atomA, memory_order_relaxed);
上述代码中,我们假定执行单元0完全把两条原子操作执行完后,执行单元1才执行。那么在执行单元1中,用获得语义的存储器次序对atomB原子对象做原子加载操作之后,它已经观察到了在执行单元0中最后用释放语义的存储器次序对atomB原子对象做存储操作的副作用。
在执行单元0中,atomic_fetch_add_explicit的操作不会被重新安排到atomic_store_explicit之后去做。但在执行单元1中,执行单元0中对atomA的修改操作可能仍然不可见,所以观察到的执行单元0中的atomA与atomB的访存完成次序也未必跟执行单元0中完成的次序一样。
另外,在执行单元1中,第二条语句的执行不会被安排到第一条之前,所以存储器次序是先完成对变量b的加载操作,然后再去做对变量a的加载操作。当然,变量a取到的值可能是10,也有可能是11。
memory_order_release
memory_order_release(具有释放语义的存储器次序),可用于栅栏或原子操作。其作用是“释放”与它进行同步的获得操作的副作用。在释放之前的所有副作用都被包含在这个释放操作中。在执行释放语义的当前执行单元中,执行释放语义的访存操作之前所有访存操作不会被重新安排到此释放语义访存操作之后执行。我们在编写OpenCL程序时可以使用一个“释放”语义以将当前执行单元对某些共享存储变量的存储操作暴露给其他执行单元,使得其他执行单元能安全地观察到这些共享存储变量已被修改。
这里需要注意的是,获得语义无须一定要跟一个指定的释放语义进行同步。也就是说,在某一执行代码上下文中,使用了一次获得语义之后,后面不是一定得出现一次释放语义。像上述代码例子中获得语义与释放语义也不是成双成对出现的。
如上面的那个例子,在执行单元0中,第一条使用松弛存储器次序的存储操作不会在第二条使用释放语义的存储操作之后执行。所以,释放语义是在其之前所有访存操作完成后再完成执行的。
memory_order_acq_rel
memory_order_acq_rel(同时具备获得语义与释放语义的存储器次序),它具有memory_order_acquire与memory_order_release存储器次序的特性。该存储器次序一般用于读-修改-写操作。在使用memory_order_acq_rel存储器次序的当前执行单元上下文中,对某个原子对象使用memory_order_acq_rel存储器次序进行读-修改-写操作时,在加载该原子对象之后的所有访存操作都不能重新安排到该加载操作之前;而在修改完该原子对象进行存储操作时,所有在此存储操作之前的访存操作都不能重新安排到此存储操作之后。例如:
global volatile atomic_int atomA = ATOMIC_VAR_INIT(10);
global volatile atomic_int atomB = ATOMIC_VAR_INIT(20);
global volatile int c = 0;
//在执行单元0中
atomic_fetch_add_explicit(&atomA, 1, memory_order_relaxed);
atomic_store_explicit(&atomB, 0, memory_order_release);
//在执行单元1中
atomic_fetch_add_explicit(&atomA, 1, memory_order_relaxed);
atomic_fetch_add_explicit(&atomB, 1, memory_order_acq_rel);
c++;
//在执行单元2中
int b = atomic_load_explicit(&atomB, memory_order_acquire);
int a = atomic_load_explicit(&atomA, memory_order_relaxed);
假设,执行单元0执行结束后紧接着做执行单元1,执行单元1完成后紧接着做执行单元2。这里,我们在执行单元1中看到第二条原子加法操作使用了memory_order_acq_rel。说明在对atomB做原子加法时先采用了获得语义,这样做就是为了在修改atomB的值之前先确保atomB在执行单元0中的修改在执行单元1中可见。而在执行单元0中,正好对atomB采用了memory_order_release存储器次序,因此这里用获得语义可以确保在做此原子加法之前,atomB已经是被执行单元0更新过了(即确保atomB当前值为0)。然后,在原子加法对atomB修改之后写回存储位置时则立即采用了释放语义,使得在执行单元2中完全可以通过获得语义来观察到atomB被执行单元1修改了。所以,memory_order_acq_rel这个存储器次序作用在一个原子的读-修改-写操作上时,其实是如下流程(当然,整个流程是原子的,即不可被打断、不可被分割的):先用获得语义加载该原子对象,然后对该原子对象进行更新,最后用释放语义来存储更新后的原子对象。
在执行单元1中,原子对象atomA的修改不会被重新安排到对原子对象atomB的修改之后,而对普通变量c的递增操作也不会被重新安排到atomB原子对象修改之前。因此,执行单元1中3条语句的执行都按照整个程序次序进行。而由于在执行单元0和执行单元1中对atomA原子对象使用的都是松弛存储器次序进行修改,对其他执行单元都不可见。因此,在执行单元2中所获取的atomA的值可能都没经过前两个执行单元的修改,故在执行单元2完成后,变量a的值可能是10,当然也有可能是11,或12。但最终程序全部完成后,atomA的值肯定是12。原子操作对于该操作所作用的原子对象而言必定奏效。这里仅仅对于存储器加载次序上是松弛的,也就是说在执行单元2中读得比较快,还没等该原子对象修改好就把值读进来了。而且对于很多存储器的实现而言,加载操作往往要快于存储操作。尤其对于含有Cache层级的存储器系统而言更是如此。如果是采用存储器次序对外不可见的操作,在其他执行单元所读到的值很可能是该执行单元中L1 Cache中的脏数据,而Cache进行刷新也需要耗费一些时间。对于弱存储器次序的访存操作而言,在这里有很多种可能性。
memory_order_seq_cst
emory_order_seq_cst(程序次序一致的存储器次序)。它的作用是每个执行单元的加载和存储都能以程序次序被观察到,并且来自不同执行单元的加载和存储操作会以简单的交错形式被观察到。该存储器次序与memory_order_acq_rel语义上差不多,不过比memory_order_acq_rel存储器次序又多了一个单一总和次序,即在所有对同一个共享的原子对象使用memory_order_seq_cst存储器次序操作的执行单元中所观察到的修改都是以相同的次序完成的。memory_order_seq_cst被称为次序一致性的(sequentially consistent)存储器次序。这也是最强的存储器次序。
例如以下程序:
global volatile atomic_int atomA = ATOMIC_VAR_INIT(10);
global volatile atomic_int atomB = ATOMIC_VAR_INIT(20);
//在执行单元0中
atomic_fetch_add_explicit(&atomA, 1, memory_order_seq_cst);
atomic_fetch_add_explicit(&atomB, 1, memory_order_seq_cst);
//在执行单元1中
atomic_fetch_add_explicit(&atomA, 2, memory_order_seq_cst);
atomic_fetch_add_explicit(&atomB, 2, memory_order_seq_cst);
//在执行单元2中
int a = atomic_load_explicit(&atomA, memory_order_seq_cst);
int b = atomic_load_explicit(&atomB, memory_order_seq_cst);
我们仍然假设先执行完执行单元0,然后紧接着做执行单元1,然后再做执行单元2。这里,执行单元0和执行单元1中都使用了memory_order_seq_cst存储器次序,并分别对atomA与atomB做原子加法操作。因此,不管是在当前执行单元上下文还是在其他两个执行单元上下文中,对atomA的修改次序都是按照执行单元0中的加1操作,然后做执行单元1中的加2操作。而atomB也是同样。而且,atomB的修改操作都发生在atomA之后。因此,在执行单元2中,所观察到的整个对atomA与atomB的修改过程是:atomA++;atomB++;atomA+=2;atomB+=2;因此,执行单元2中变量a的值一定是13,而变量b的值也一定是23。
综上所述,除了memory_order_seq_cst之外的所有存储器次序,在对某一个执行单元的访存操作使用该次序时,都无法保证在其他执行单元中能以相同的访存次序被观察到。而对于memory_order_acquire、memory_order_release和memory_order_acq_rel只能观察到其副作用。只有memory_order_seq_cst才能保证其他执行单元能同时观察到副作用与严格的程序次序。
无论指定哪种存储器次序,要跨整个异构平台对存储器操作添加约束,都会给程序执行增加相当大的负荷。因此,OpenCL 2.0同时也提供了存储器次序强化的作用域,指定在哪一类存储区域上使用指定的存储器次序。因此,几乎所有的原子操作API中都带有memory_scope类型的参数,它是一个枚举类型,具体枚举值如下:
1)memory_scope_work_item:指定的存储器次序约束只作用于当前工作项内。请注意,这个存储作用域只能在调用atomic_work_item_fence函数并且标志参数为CLK_IMAGE_MEM_FENCE时才能使用。因此,在某个工作项内需要做存储器次序限制的场合十分稀少。
2)memory_scope_work_group:指定的存储器次序约束只作用于单个工作组内的所有工作项。这个存储作用域一般是用得最多的。
3)memory_scope_device:指定的存储器次序作用于单个计算设备中的所有工作项。一次访存操作的副作用要对一个设备中的所有工作项可见,这个开销比一个工作组内可见的开销显然就要大很多。
4)memory_scope_all_svm_devices:指定的存储器次序作用于跨多个计算设备的所有工作项以及主机端(当使用SVM时)。使用memory_scope_all_svm_devices存储区域,对某个缓存(不具有CL_MEM_SVM_ATOMICS标志)缓存所执行的一次释放语义次序访存操作,应至少实现memory_scope_device级别的存储器次序可见性,使得在一个队列同步点(如一个OpenCL事件)能对该缓存完成完整的同步。
当分析存储器操作的次序约束时,这些存储区域(作用域)定义了一个可见性的层级。例如,如果一个程序员知道某个存储器操作次序将与一组来自单个工作组内的工作项关联,那么实现在跨多个计算设备的场景下,需要在各自计算设备的同一个上下文(即一个工作组内的工作项)内来管理存储器次序,以节省开销。当对全局存储器进行使用时,所有存储器区域都能有效使用。
大量OpenCL程序都可以使用一个简化的存储器模型(即仅使用松弛的存储器次序)进行编写。不过要实现这个目标应该参考以下准则(OpenCL 2.0之前没有引入存储器次序这个概念,因此适用于以下准则)。
准则1:要安全地管理全局共享全局存储器对象,应该经由主机端命令队列所定义的同步点。
准则2:在工作组内做底层同步应该要用诸如barrier这样的工作组函数。
准则3:如果系统分配的或具有原子支持的细粒度SVM缓存,想使用顺序一致性行为,那么只能对memory_scope_all_svm_devices存储区域使用memory_order_seq_cst存储器次序。
准则4:如果想要对非系统分配的或带有原子支持的细粒度的SVM缓存使用顺序一致性行为,那么只要对memory_scope_device存储区域使用memory_order_seq_cst存储器次序的访存操作即可。
准则5:确保OpenCL程序没有数据竞争。
如果在一个OpenCL 2.0程序中遵循了以上5条准则,那么我们可以不用太过关心隐藏在松弛存储器次序背后的细节,可以直接使用默认的松弛存储器次序。