第八章 CUDA共享内存的合理使用

        前一章讨论了全局内存的合理使用,本章接着讨论共享内存的合理使用。共享内存是一种可直接被程序员直接操控的缓存,主要作用有两个:一个是一个是减少核函数中对全局内存的访问次数,实现高效的线程块内部的通信;另一个是提高全局内存访问的合并度。本章将通过具体的例子阐明共享内存的合理使用,包括一个数组规约的例子和矩阵转置的例子。其中在CUDA中数组规约是一个非常适合学习CUDA编程的例子,通过他可以了解到CUDA编程的很多方面,如后两章的原子函数和后三章的线程束内的函数和协作组。

8.1 例子:数组规约的计算

        考虑一个有N个元素的数组x,假如我们需要计算该数组中所有元素的和即sum=x[0]+x[1]+...+x[N-1],下列代码给出一个C++函数。
 

real reduce(const real *x,const int N)
{
    real sum=0.0;
    for(int n=0;n

在这个例子中考虑一个长度为1e8的一维数组,在主函数中,将数组元素初始化为1.23调用reduce()使用双精度浮点数时,该程序输出
        sum = 123000000.110771.
结果表示前9位有效数字都正确,从第十位开始出现错误,在使用单精度浮点型时
        sum = 33554432.000000.该结果完全错误,这是因为在累加的过程中出现了所谓的“大数吃小数”的现象。单精度浮点数只有7位有效数字,在上面的函数中,将变量累加到3000多万之后,在将其与1.23相加,其值就不会增加了。现在已经发展出更为安全的求和算法,如Kahan等。在后续学习过程中CUDA算法要比C++实现要稳健的多,使用单精度浮点数时结果也相当准确。

8.1.1 仅使用全局内存

        数组规约的并行计算显然比数组相加的问题更为复杂一些。对于数组相加的并行计算问题,只需要定义和数组元素一样多的线程,让一个线程去对两个数进行求和即可。对于数组规约的并行计算问题,需要从一个数组出发,最终得到一个数。所以必须使用某种迭代方案。假设数组元素个数是2的整数次方(稍后会去掉这个假定),可以把后半部分的各个元素与前半部分对应的数组元素相加。如此重复这个过程,最后得到的第一个数组元素就是最初的数组中各个元素的和。这就是所谓的折半规约(binary reduction)法。假设使用一维网格和线程块,并将核函数的网格大小与线程块大小的乘积取为N,可能会写出如下的程序

void __global__ reduce(real *d_x,int N)
{
    int n=threadIdx.x+blockDim.x*blockIdx.x;
    for(int offset=N/2;offset>0;offset/=2)
    {
        if(n


 

并认为核函数执行完之后d_x的和就保存在d_x[0]中了。然而,用该核函数并不能得到正确的结果。这是因为这是对于多线程的程序,两个不同线程中指令的执行次序可能和代码中所展现的次序不同。为了方便分析,将上述核函数中循环的前两次迭代明显的写出来

if(n

考察对数组元素d_x[N/4]的操作。第一个迭代中,会向数组元素d_x[N/4]写入数据(由线程n=N/4执行);在第二次迭代中,会从d_x[N/4]取出数据的操作,由线程0执行。有一种可能的情况:在线程n=0开始执行第二行语句时,线程n=N/4还没执行完第一行的语句,如果这种情况发生了,就有可能得到意外的结果。
        要保证核函数中语句的执行顺序与出现顺序一致,就必须使用某种同步机制,在CUDA中,提供了一个同步函数__syncthreads()。该函数只能用在核函数中,其最贱的用法就是不带任何参数。__syncthreads();该函数可以保证一个线程块中的所有线程(或者说是线程束)在执行该语句后面的语句的时候都完全执行了该语句前面的语句。然而,该函数只是针对同一个线程块中的线程,不同线程块中线程的执行次序依然是不确定的。
        既然函数__syncthreads()能够同步单个线程块中的线程,那么我们就利用该功能让每个线程块对其中的数组元素进行规约,以下代码给出了规约核函数。
 

__global__ void reduce_global(real *d_x,real *d_y)
{
    const int tid=threadIdx.x;
    real *x=d_x+blockDim.x*blockIdx.x;
    
for(int offset=blockDim.x>>1;offset>0;offset>>=1)
{
    if(tid

下面是该核函数值得注意的地方:
        (1)核函数的第四行定义了一个指针x。赋值符号的右边(动态)数组d_x中的第blockDim.x*blockIdx.x个元素的地址。所以第四行也可以写成
        real *x=&d_x[blockDim.x*blockIdx.x];
这样定义的x在不同的线程块中指向全局内存中不同的地址,使得我们可以在不同的线程块中对数组d_x中的不同部分进行规约。具体得说,每一个线程块处理blockDim.x个数据,这里不再假设N/2的整数次方,但假设N能被blockDim.x整除,并且假设bclokDim.x是2的整数次方(作者采用最常用的线程块大小128)。
        (2)第6-13行就是在各个线程块内对其中的数据独立地进行规约,第12行的同步语句确保了同一个线程块中的线程按照代码出现的顺序执行指令。至于两个不同线程块中的线程,则不一定按照代码出现的顺序执行指令,但这不影响程序的正确性,这是因为,在核函数中,每个线程块都处理不同的数据,相互之间没有依赖。总结来说就是说,一个线程块内的线程需要合作,所以需要同步;两个线程块之间不需要合作,所以不需要同步。
        (3)核函数的第6行也值得注意。这里将blockDim.x/2写成了blockDim.x>>1,并将offset/2写成了offset>>=1。这是利用了位操作。以上不同写法在结果上的等价要求blockDim.x和offset都是2的整数次方。在核函数中,位操作比对应的整数操作高效。当所涉及的变量在编译期间就知道其可能的取值时,编译器会自动用位操作取代响应的整数操作,但明显的使用位操作也是不错的做法。
        (4)该核函数仅仅将一个长度为1e8的数组规约成一个长度为1e8/128的数组d_y。为了计算整个数组的长度,将把数组d_y从设备复制到主机,并继续在主机中对d_y继续进行规约,得到最终的计算结果,这样做不是很高效,但目前先这样做。
        用如下命令编译(其中的-O3是针对主机代码的)
        nvcc -O3 -arch=sm_75 reduce2gpu.cu
        全部计算包括核函数的执行、将数组d_y从设备中复制到主机及在主机中对数组d_y进行规约,所花时间为6ms,计算速度大致为CPU版本的20倍。 

8.1.2 使用共享内存

         在前一个版本的核函数中,对全局内存地访问是很频繁的。前面介绍过全局内存的访问速度是所有内存中最低的,应该减少对他的使用。所有设备内存中,寄存器是最高效的,但在需要线程合作的问题中,用仅对单个线程可见的寄存器是不够的的,需要定义对整个线程块可见的共享内存。
        在核函数中,要将一个变量定义为共享内存变量,就要在定义语句中加上一个限定符__shared__。一般情况下,需要一个长度等于线程块大小的数组,在当前问题中,可以定义以下共享内存数组:
        __shared__ real s_y[128];
如果没有限定符__shared__,极有可能定义一个长度为128的局部数组,常用s_给共享内存变量进行赋值,而用d_给全局变量进行赋值。需要强调的是,在一个核函数中定义一个共享内存变量,相当于在每一个线程块中有了一个该变量的一个副本。每个副本都不一样,虽然他们共用一个变量名。呵呵那函数中对共享内存的操作都是同时作用在所有副本上的,这种并行的特征在使用共享内存时需要牢记在心。
        以下函数给出了静态个in小GIANG内存的规约核函数:
 

void __global__ reduce_shared(real *d_x,real *d_y)
{
    const int tid=threadIdx.x;
    const int bid=blockIdx.x;
    const int n=bid*blockDim.x+tid;
    __shared__ real s_y[128];
    s_[tid]=(n>1;offset>0;offset>>=1)
{

    if(tid

        (1)第6行定义了共享内存数组s_y[128].
        (2)第7行将全局内存中的数据复制到共享内存中去,这里用到了前面所说的共享内存的特征:每个线程都有一个共享内存变量的副本。第七行的语句所实现的功能可以展开如下:
                1)当bid等于0的时候,将内存中第0到blockDim.x-1个数组元素复制给第0个线程块的共享内存变量副本。
                2) 当bid等于1的时候,将全局变量中的第blockDim.x到第2*blockDim.x-1个数组元素复制给第一个线程块的共享内存变量副本。
                3)因为这里有n=N对应的共享内存数组元素将被赋值为0,不对规约结果产生影响。
         (3)在第八行调用函数__syncthreads()进行线程块内的同步。在使用共享内存进行线程块之间的合作和通信的时候,都要进行同步,以确保共享内存中的数据对线程块内的所有线程都准备就绪。
        (4)第10-18行的规约计算用共享内存变量替换了原来的全局内存变量。这里也要记住:每个线程块都对其中的共享内存副本进行了操作。在规约过程结束后,每一个线程块中的s_y[0]副本保存了若干数组元素的和。
        (5)因为共享内存的生命周期仅仅在核函数中,多以必须将之前的共享内存中的某些结果保存到全局变量中,如20-23行所示,这里的判断if(tid==0)可保证在一个线程块中只执行一次,该语句的作用可以展开如下:
        1)当bid等于0时,将第0个线程块中的s_y[0]的副本复制给d_y[0];
        2)当bid等于1时,将第1个线程块中的s_y[0]赋值给d_y[1];
               用装有GeForce MX450的计算机进行测试,使用单精度浮点数时,全部计算(包括核函数的执行、将数组d_y从设备复制到主机以及在主机中对数组d_y进行归纳)所花时间大致为6ms,和不用共享内存的版本的所用时间相当。一般来说使用共享内存减少全局内存的访问一般会带来性能的提升。但也不是绝对,一般来说,共享内存地访问次数越多,使用共享内存带来的加速效果越明显。在我们的数组规约的问题中,使用共享内存相对于仅使用全局内存有两个好处:一个是不再要求全局内存数组的长度N是线程块大小的整数倍,另一个是在规约的过程中不会改变全局内存中的数据。

8.1.3 使用动态共享内存

        在前面的核函数中,定义了共享内存数组时指定了一个固定的长度(128).程序假定了长度与核函数的执行配置block_size(与核函数中的blockDim.x)是一样的。如果在定义共享内存变量时不小心把数组长度写错了,可能引起错误或降低核函数性能。
        有一种方法可以减少这种错误的发生,那就是使用动态的共享内存,将上一个版本中的静态共享内存改成动态共享内存,只需要做以下两处修改。
        (1)调用核函数的执行配置中写下第三个参数:
        <<>>
前两个参数分别为网格大小和线程块大小,第三个参数就是核函数中的每个线程块需要定义的动态共享内存的字节数。在我们的以前所有的配置过程中,这个参数没有出现,实质上是被定义为了0.
        (2)要使用动态共享内存,需要改变共享内存变量的声明方式,例如:
        extern __shared__ real s_y[];
它与之前静态共享内存变量声明方式:
        __shared__ real s_y[];
有两点的不同,第一必须加上限定词__shared__;第二不能指定数组的大小,但不能变成指针,
        __shared__ real *s_y;但这是错的,因为数组并不代表为指针。
        无论用什么GPU 使用动态共享内存__shared real s_y[]或者__shared__ real s_y[128]执行并无太大区别。

8.2 使用共享数据类型进行矩阵转置

        在前一章中,讨论了矩阵转置的计算,重点考察了全局内存的访问模式对于核函数的影响,在矩阵转置问题中,对全局内存的读或写操作总有一个是非合并的。本节可以看到共享内存可以改善全局内存的访问模式,使得对于全局内存的读和写都是合并的。以下代码为使用共享内存进行矩阵转置的函数:
 

const int MatrixDim=124;
const int block_size=32;
void __global__ transpose1(const real *A,real*B)
{
    __shared__ real S[block_size][block_size];
    int bx=blockIdx.x*block_size;
    int by=blockIdx.y*block_size;
    
    int nx1=bx+threadIdx.x;
    int ny1=by+threadIdx.y;
    if(nx1

下面对该函数详细的解释:
        (1)在矩阵转置的核函数中,其中心思想是用一个线程块处理一片1片(tile)矩阵。这里一片矩阵的行数和列数都是32.为了利用共享内存全局内存的访问方式,在第三列定义了一个两维的静态共享内存数组S,其行数和列数与一片矩阵的行数和列数相同。
        (2)第11行,将一片矩阵从全局内存中数组A中读取出来,存放在共享内存数组中。这里对全局内存的访问是合并的,因为相邻的threadIdx.x与全局内存中相邻的数据对应。
        (3)第13行,将共享内存中的数据写入全局内存数组B之前,进行一次线程块内的同步操作,一般来说,利用共享内存中的数据之前,都要进行线程块内的同步操作,以确保共享内存数组中的所有元素都已经更新完毕。
        (4)接下来几行尤为关键,为了更好的理解代码,将第15-20行改写成以下形式:
        int nx2=bx+threadIdx.x;
        int ny2=by+threadIdx.y;
        if(nx2 {
        B[nx2*N+ny2]=S[threadIdx.y][threadIdx.x];
}
这样改写后的核函数与第七章的核函数相比,唯一的区别就是将全局内存转移到了共享内存,然后由原封不动的转移到了全局内存,并没有改变对全局内存的访问方式。要改变对全局内存的访问方式很简单:只要调换这几行代码的threadIdx.x和threadIdx.y即可,其中对于内存数组B的访问也是合并的,因为相邻threadIdx.x与内存数组B中相邻的数据对应。

8.3 避免共享内存的bank冲突

        关于共享内存,有一个内存bank的概念值得注意。为了获得高的内存带宽,共享内存在物理上被分为了32个(刚好等于一个线程束中的线程数目)。可以将32个bank从0-31编号。在每一个bank中,又可以对其中的内存地址从0开始编号。为方便起见,我们将所有bank中编号为0的内存称为第一层内存,将所有bank中编号为1的内存称为第二层内存。在开普勒架构中,每个bank的宽度为8字节,在所有其他架构中,每个bank的宽度为4字节。这里不管住开普勒架构。
        对于bank宽度为四字节的架构,共享内存数组是按照如下方式线性地映射到内存bank中的:共享内存数组中连续的128个字节的内容分摊到32个bank的某一层中,每个bank负责4字节的内容。例如:对一个长度为128单精度浮点数变量的共享内存数组而言,第0-31个数组元素依次对应到32个bank的第一层;第32-63个数组元素依次对应到32个bank的第二层;第64-95个数组元素依次对应到32个bank的第三层;第96-127个数组元素依次对应到32个bank的第四层,也就是说每个bank分摊四个在低智商相差128字节的数据
第八章 CUDA共享内存的合理使用_第1张图片

        只要同一线程束内的多个线程不同时访问同一个bank不同层的数据,该线程束对共享内存的访问就只需要依次内存事务。当同一线程束的多个线程试图访问同一个bank不同层的数据时,就会发生bank冲突。在一个线程束内对同一个bank的n层数据同时访问将导致n次内存事务,称为发生了n路bank冲突。最坏的情况是线程束内32个线程同时访问同一个bank中32个不同层的地址,这将导致32路bank冲突。这种n很大的bank冲突要尽量避免。
        在8.2节中的核函数transpose1()中,定义了一个长度为32*32=1024的单精度浮点型变量的共享内存数组。其中每个共享内存bank(非开普勒架构而言)对应32个连续的数组元素;每个bank有32层数据,8.2节中transpose1()函数可以看出,同一个线程束中的32个线程(连续的32个threadIdx.x的值) 将对应共享内存数组S中跨度为32的线程。也就是说,这32个线程将刚好访问同一个bank中的32个数据,这将导致32路bank冲突,但第11行没有bank冲突。
        通常可以改变共享内存数组大小的方式来消除或减轻共享内存的bank冲突。例如见上述函数中的共享内存定义修改如下:
        __shared__ real S[32][32+1]
这样就可以完全消除19行中读取共享内存时的bank冲突。这是因为,这样改变共享内存的数组大小之后,同一个线程束中的32个线程(连续的32个threadIdx.x的值)将对应共享内存数组S中跨度为33的数据,如果第一个线程访问第一个bank的第一层;第二个线程会访问第二个bank的第二层,于是这32个线程将分别访问32个不同的bank中的数据,所以没有bank冲突。

你可能感兴趣的:(CUDA从入门到实践,算法,数据结构,windows,c++,人工智能)