zoukankan      html  css  js  c++  java
  • 【并行计算-CUDA开发】CUDA shared memory bank 冲突

    CUDA SHARED MEMORY

    shared memory在之前的博文有些介绍,这部分会专门讲解其内容。在global Memory部分,数据对齐和连续是很重要的话题,当使用L1的时候,对齐问题可以忽略,但是非连续的获取内存依然会降低性能。依赖于算法本质,某些情况下,非连续访问是不可避免的。使用shared memory是另一种提高性能的方式。

    GPU上的memory有两种:

    · On-board memory

    · On-chip memory

    global memory就是一块很大的on-board memory,并且有很高的latency。而shared memory正好相反,是一块很小,低延迟的on-chip memory,比global memory拥有高得多的带宽。我们可以把他当做可编程的cache,其主要作用有:

    · An intra-block thread communication channel 线程间交流通道

    · A program-managed cache for global memory data可编程cache

    · Scratch pad memory for transforming data to improve global memory access patterns

    本文主要涉及两个例子作解释:reduction kernel,matrix transpose kernel。

    shared memory(SMEM)是GPU的重要组成之一。物理上,每个SM包含一个当前正在执行的block中所有thread共享的低延迟的内存池。SMEM使得同一个block中的thread能够相互合作,重用on-chip数据,并且能够显著减少kernel需要的global memory带宽。由于APP可以直接显式的操作SMEM的内容,所以又被称为可编程缓存。

    由于shared memory和L1要比L2和global memory更接近SM,shared memory的延迟比global memory低20到30倍,带宽大约高10倍。

    image

    当一个block开始执行时,GPU会分配其一定数量的shared memory,这个shared memory的地址空间会由block中的所有thread 共享。shared memory是划分给SM中驻留的所有block的,也是GPU的稀缺资源。所以,使用越多的shared memory,能够并行的active就越少。

    关于Program-Managed Cache:在C语言编程里,循环(loop transformation)一般都使用cache来优化。在循环遍历的时候使用重新排列的迭代顺序可以很好利用cache局部性。在算法层面上,我们需要手动调节循环来达到令人满意的空间局部性,同时还要考虑cache size。cache对于程序员来说是透明的,编译器会处理所有的数据移动,我们没有能力控制cache的行为。shared memory则是一个可编程可操作的cache,程序员可以完全控制其行为。

    Shared Memory Allocation

    我们可以动态或者静态的分配shared Memory,其声明即可以在kernel内部也可以作为全局变量。

    其标识符为:__shared__

    下面这句话静态的声明了一个2D的浮点型数组:

    __shared__ float tile[size_y][size_x];

    如果在kernel中声明的话,其作用域就是kernel内,否则是对所有kernel有效。如果shared Memory的大小在编译器未知的话,可以使用extern关键字修饰,例如下面声明一个未知大小的1D数组:

    extern __shared__ int tile[];

    由于其大小在编译器未知,我们需要在每个kernel调用时,动态的分配其shared memory,也就是最开始提及的第三个参数:

    kernel<<<grid, block, isize * sizeof(int)>>>(...)

    应该注意到,只有1D数组才能这样动态使用。

    Shared Memory Banks and Access Mode

    之前博文对latency和bandwidth有了充足的研究,而shared memory能够用来隐藏由于latency和bandwidth对性能的影响。下面将解释shared memory的组织方式,以便研究其对性能的影响。

    Memory Banks

    为了获得高带宽,shared Memory被分成32(对应warp中的thread)个相等大小的内存块,他们可以被同时访问。不同的CC版本,shared memory以不同的模式映射到不同的块(稍后详解)。如果warp访问shared Memory,对于每个bank只访问不多于一个内存地址,那么只需要一次内存传输就可以了,否则需要多次传输,因此会降低内存带宽的使用。

    Bank Conflict

    当多个地址请求落在同一个bank中就会发生bank conflict,从而导致请求多次执行。硬件会把这类请求分散到尽可能多的没有conflict的那些传输操作 里面,降低有效带宽的因素是被分散到的传输操作个数。

    warp有三种典型的获取shared memory的模式:

    · Parallel access:多个地址分散在多个bank。

    · Serial access:多个地址落在同一个bank。

    · Broadcast access:一个地址读操作落在一个bank。

    Parallel access是最通常的模式,这个模式一般暗示,一些(也可能是全部)地址请求能够被一次传输解决。理想情况是,获取无conflict的shared memory的时,每个地址都在落在不同的bank中。

    Serial access是最坏的模式,如果warp中的32个thread都访问了同一个bank中的不同位置,那就是32次单独的请求,而不是同时访问了。

    Broadcast access也是只执行一次传输,然后传输结果会广播给所有发出请求的thread。这样的话就会导致带宽利用率低。

    下图是最优情况的访问图示:

    image

    下图一种随机访问,同样没有conflict:

    image

    下图则是某些thread访问到同一个bank的情况,这种情况有两种行为:

    · Conflict-free broadcast access if threads access the same address within a bank

    · Bank conflict access if threads access different addresses within a bank

    image

    Access Mode

    根据不同的CC版本,bank的配置也不同,具体为:

    · 4 bytes for devices of CC 2.x

    · 8 bytes for devices of CC3.x

    对于Fermi,一个bank是4bytes。每个bank的带宽是32bits每两个cycle。连续的32位字映射到连续的bank中,也就是说,bank的索引和shared memory地址的映射关系如下:

    bank index = (byte address ÷ 4 bytes/bank) % 32 banks

    下图是Fermi的地址映射关系,注意到,bank中每个地址相差32,相邻的word分到不同的bank中以便使warp能够获得更多的并行获取内存操作(获取连续内存时,连续地址分配到了不同bank中)。

    image

    当同一个warp的两个thread要获取同一个地址(注意是同一个地址还是同一个bank)的时候并不发生bank conflict。对于读操作,会用一次transaction获得结果后广播给所有请求,当写操作时,只有一个thread会真正去写,但是哪个thread执行了写是无法知道的(undefined)。

    在8bytes模式中,同理4bytes,连续的64-bits字会映射到连续的bank。每个bank带宽是64bite/1个clock。其映射关系公式:

    bank index = (byte address ÷ 8 bytes/bank) % 32 banks

    这里,如果两个thread访问同一个64-bit中的任意一个两个相邻word(1byte)也不会导致bank conflict,因为一次64-bit(bank带宽64bit/cycle)的读就可以满足请求了。也就是说,同等情况下,64-bit模式一般比32-bit模式更少碰到bank conflict。

    下图是64-bit的关系图。尽管word0和word32都在bank0中,同时读这两个word也不会导致bank conflict(64-bit/cycle):

    image

    下图是64-bit模式下,conflict-free的情况,每个thread获取不同的bank:

    image

    下图是另一种conflict-free情况,两个thread或获取同一个bank中的word:

    image

    下图红色箭头是bank conflict发生的情况:

    image

    Memory Padding

    memory padding是一种避免bank conflict的方法,如下图所示,所有的thread分别访问了bank0的五个不同的word,这时就会导致bank conflict,我们采取的方法就是在每N(bank数目)个word后面加一个word,这样就如下面右图那样,原本bank0的每个word转移到了不同的bank中,从而避免了bank conflict。

    image

    增加的这写word不会用来存储数据,其唯一的作用就是移动原始bank中的word,使用memory padding会导致block可获得shared memory中有用的数量减少。还有就是,要重新计算数组索引来获取正确的数据元素。

    Access Mode Configuration

    对Kepler来说,默认情况是4-byte模式,可以用下面的API来查看:

    cudaError_t cudaDeviceGetSharedMemConfig(cudaSharedMemConfig *pConfig);

    返回结果放在pConfig中,其结果可以是下面两种:

    cudaSharedMemBankSizeFourByte

    cudaSharedMemBankSizeEightByte

    可以使用下面的API来设置bank的大小:

    cudaError_t cudaDeviceSetSharedMemConfig(cudaSharedMemConfig config);

    bank的配置参数如下三种:

    cudaSharedMemBankSizeDefault

    cudaSharedMemBankSizeFourByte

    cudaSharedMemBankSizeEightByte

    在其启动不同的kernel之间修改bank配置会有一个隐式的device同步。修改shared memory的bank大小不会增加shared memory的利用或者影响kernel的Occupancy,但是对性能是一个主要的影响因素。一个大的bank会产生较高的带宽,但是鉴于不同的access pattern,可能导致更多的bank conflict。

    Synchronization

    因为shared Memory可以被同一个block中的不同的thread同时访问,当同一个地址的值被多个thread修改就导致了inter-thread conflict,所以我们需要同步操作。CUDA提供了两类block内部的同步操作,即:

    · Barriers

    · Memory fences

    对于barrier,所有thread会等待其他thread到达barrier point;对于Memory fence,所有thread会阻塞到所有修改Memory的操作对其他thread可见,下面解释下CUDA需要同步的主要原因:weakly-ordered。

    Weakly-Ordered Memory Model

    现代内存架构有非常宽松的内存模式,也就是意味着,Memory的获取不必按照程序中的顺序来执行。CUDA采用了一种叫做weakly-ordered Memory model来获取更激进的编译器优化。

    GPU thread写数据到不同的Memory的顺序(比如shared Memory,global Memory,page-locked host memory或者另一个device上的Memory)同样没必要跟程序里面顺序呢相同。一个thread的读操作的顺序对其他thread可见时也可能与实际上执行写操作的thread顺序不一致。

    为了显式的强制程序以一个确切的顺序运行,就需要用到fence和barrier。他们也是唯一能保证kernel对Memory有正确的行为的操作。

    Explicit Barrier

    同步操作在我们之前的文章中也提到过不少,比如下面这个:

    void __syncthreads();

    __syncthreads就是作为一个barrier point起作用,block中的thread必须等待所有thread都到达这个point后才能继续下一步。这也保证了所有在这个point之前获取global Memory和shared Memory的操作对同一个block中所有thread可见。__syncthreads被用来协作同一个block中的thread。当一些thread获取Memory相同的地址时,就会导致潜在的问题(读后写,写后读,写后写)从而引起未定义行为状态,此时就可以使用__syncthreads来避免这种情况。

    使用__syncthreads要相当小心,只有在所有thread都会到达这个point时才可以调用这个同步,显而易见,如果同一个block中的某些thread永远都到达该点,那么程序将一直等下去,下面代码就是一种错误的使用方式:

    if (threadID % 2 == 0) {
        __syncthreads();
        } else {
            __syncthreads();
    }        

    Memory Fence

    这种方式保证了任何在fence之前的Memory写操作对fence之后thread都可见,也就是,fence之前写完了,fence之后其它thread就都知道这块Memory写后的值了。fence的设置范围比较广,分为:block,grid和system。

    可以通过下面的API来设置fence:

    void __threadfence_block();

    看名字就知道,这个函数是对应的block范围,也就是保证同一个block中thread在fence之前写完的值对block中其它的thread可见,不同于barrier,该function不需要所有的thread都执行。

    下面是grid范围的API,作用同理block范围,把上面的block换成grid就是了:

    void __threadfence();

    下面是system的,其范围针对整个系统,包括device和host:

    void __threadfence_system();

    Volatile Oualifier

    声明一个使用global Memory或者shared Memory的变量,用volatile修饰符来修饰该变量的话,会组织编译器做一个该变量的cache的优化,使用该修饰符后,编译器就会认为该变量可能在某一时刻被别的thread改变,如果使用cache优化的话,得到的值就缺乏时效,因此使用volatile强制每次都到global 或者shared Memory中去读取其绝对有效值。

    CHECKING THE DATA LAYOUT OF SHARED MEMORY

    该部分会试验一些使用shared Memory的例子,包括以下几个方面:

    · 方阵vs矩阵数组

    · Row-major vs column-major access

    · 静态vs动态shared Memory声明

    · 全局vs局部shared Memory

    · Memory padding vs no Memory padding

    我们在设计使用shared Memory的时候应该关注下面的信息:

    · Mapping data elements across Memory banks

    · Mapping from thread index to shared Memory offset

    搞明白这两点,就可以掌握shared Memory的使用了,从而构建出牛逼的代码。

    Square Shared Memory

    下图展示了一个每一维度有32个元素并以row-major存储在shared Memory,图的最上方是该矩阵实际的一维存储图示,下方的逻辑的二维shared Memory:

    image

    我们可以使用下面的语句静态声明一个2D的shared Memory变量:

    __shared__ int tile[N][N];

    可以使用下面的方式来数据,相邻的thread获取相邻的word:

    tile[threadIdx.y][threadIdx.x]

    tile[threadIdx.x][threadIdx.y]

    上面两种方式哪个更好呢?这就需要注意thread和bank的映射关系了,我们最希望看到的是,同一个warp中的thread获取的是不同的bank。同一个warp中的thread可以使用连续的threadIdx.x来确定。不同bank中的元素同样是连续存储的,以word大小作为偏移。因此次,最好是让连续的thread(由连续的threadIdx.x确定)获取shared Memory中连续的地址,由此得知,

    tile[threadIdx.y][threadIdx.x]应该展现出更好的性能以及更少的bank conflict。

    Accessing Row-Major versus Column-Major

    假设我们的grid有2D的block(32,32),定义如下:

    #define BDIMX 32
    #define BDIMY 32
    dim3 block(BDIMX,BDIMY);
    dim3 grid(1,1);

    我们对这个kernel有如下两个操作:

    · 将thread索引以row-major写到2D的shared Memory数组中。

    · 从shared Memory中读取这些值并写入到global Memory中。

    kernel代码:

    复制代码
    __global__ void setRowReadRow(int *out) {
        // static shared memory
        __shared__ int tile[BDIMY][BDIMX];
        // 因为block只有一个
        unsigned int idx = threadIdx.y * blockDim.x + threadIdx.x;
        // shared memory store operation
        tile[threadIdx.y][threadIdx.x] = idx;
        // 这里同步是为了使下面shared Memory的获取以row-major执行
        //若有的线程未完成,而其他线程已经在读shared Memory。。。
        __syncthreads();
        // shared memory load operation
        out[idx] = tile[threadIdx.y][threadIdx.x] ;
    }                            
    复制代码

    观察代码可知,我们有三个内存操作:

    · 向shared Memory存数据

    · 从shared Memor取数据

    · 向global Memory存数据

    因为在同一个warp中的thread使用连续的threadIdx.x来检索title,该kernel是没有bank conflict的。如果交换上述代码threadIdx.y和threadIdx.x的位置,就变成了column-major的顺序。每个shared Memory的读写都会导致Fermi上32-way的bank conflict或者在Kepler上16-way的bank conflict。

    复制代码
    __global__ void setColReadCol(int *out) {
        // static shared memor
        __shared__ int tile[BDIMX][BDIMY];
        // mapping from thread index to global memory index
        unsigned int idx = threadIdx.y * blockDim.x + threadIdx.x;
        // shared memory store operation
        tile[threadIdx.x][threadIdx.y] = idx;
        // wait for all threads to complete
        __syncthreads();
        // shared memory load operation
        out[idx] = tile[threadIdx.x][threadIdx.y];
    }            
    复制代码

    编译运行:

    $ nvcc checkSmemSquare.cu –o smemSquare
    $ nvprof ./smemSquare

    在Tesla K40c(4-byte模式)上的结果如下,正如我们所想的,row-major表现要出色:

    ./smemSquare at device 0 of Tesla K40c with Bank Mode:4-byte
    <<< grid (1,1) block (32,32)>>
    Time(%) Time Calls Avg Min Max Name
    13.25% 2.6880us 1 2.6880us 2.6880us 2.6880us setColReadCol(int*)
    11.36% 2.3040us 1 2.3040us 2.3040us 2.3040us setRowReadRow(int*)

    然后使用nvprof的下面的两个参数来衡量相应的bank-conflict:

    shared_load_transactions_per_request

    shared_store_transactions_per_request

    结果如下(8 bytes模式,4 bytes应该是32),row-major只有一次transaction:

    复制代码
    Kernel:setColReadCol (int*)
    1 shared_load_transactions_per_request 16.000000
    1 shared_store_transactions_per_request 16.000000
    Kernel:setRowReadRow(int*)
    1 shared_load_transactions_per_request 1.000000
    1 shared_store_transactions_per_request 1.000000
    Writing Row-Major and Reading Column-Major
    复制代码

    本节的kernel实现以row-major写shared Memory,以Column-major读shared Memory,下图指明了这两种操作的实现:

    image

    kernel代码:

    复制代码
    __global__ void setRowReadCol(int *out) {
        // static shared memory
        __shared__ int tile[BDIMY][BDIMX];
        // mapping from thread index to global memory index
        unsigned int idx = threadIdx.y * blockDim.x + threadIdx.x;
        // shared memory store operation
        tile[threadIdx.y][threadIdx.x] = idx;
        // wait for all threads to complete
        __syncthreads();
        // shared memory load operation
        out[idx] = tile[threadIdx.x][threadIdx.y];
    }                        
    复制代码

    查看nvprof结果:

    Kernel:setRowReadCol (int*)
    1 shared_load_transactions_per_request 16.000000
    1 shared_store_transactions_per_request 1.000000

    写操作是没有conflict的,读操作则引起了一个16次的transaction。

    Dynamic Shared Memory

    正如前文所说,我们可以全局范围的动态声明shared Memory,也可以在kernel内部动态声明一个局部范围的shared Memory。注意,动态声明必须是未确定大小一维数组,因此,我们就需要重新计算索引。因为我们将要以row-major写,以colu-major读,所以就需要保持下面两个索引值:

    · row_idx:1D row-major 内存的偏移

    · col_idx:1D column-major内存偏移

    kernel代码:

    复制代码
    __global__ void setRowReadColDyn(int *out) {
        // dynamic shared memory
        extern __shared__ int tile[];
        // mapping from thread index to global memory index
        unsigned int row_idx = threadIdx.y * blockDim.x + threadIdx.x;
        unsigned int col_idx = threadIdx.x * blockDim.y + threadIdx.y;
        // shared memory store operation
        tile[row_idx] = row_idx;
        // wait for all threads to complete
        __syncthreads();
        // shared memory load operation
        out[row_idx] = tile[col_idx];
    }            
    复制代码

    kernel调用时配置的shared Memory:

    setRowReadColDyn<<<grid, block, BDIMX * BDIMY * sizeof(int)>>>(d_C);

    查看transaction:

    Kernel: setRowReadColDyn(int*)
    1 shared_load_transactions_per_request 16.000000
    1 shared_store_transactions_per_request 1.000000

    该结果和之前的例子相同,不过这里使用的是动态声明。

    Padding Statically Declared Shared Memory

    直接看kernel代码:

    复制代码
    __global__ void setRowReadColPad(int *out) {
        // static shared memory
        __shared__ int tile[BDIMY][BDIMX+IPAD];
        // mapping from thread index to global memory offset
        unsigned int idx = threadIdx.y * blockDim.x + threadIdx.x;
        // shared memory store operation
        tile[threadIdx.y][threadIdx.x] = idx;
        // wait for all threads to complete
        __syncthreads();
        // shared memory load operation
        out[idx] = tile[threadIdx.x][threadIdx.y];
    }                            
    复制代码

    改代码是setRowReadCol的翻版,查看结果:

    Kernel: setRowReadColPad(int*)
    1 shared_load_transactions_per_request 1.000000
    1 shared_store_transactions_per_request 1.000000

    正如期望的那样,load的bank_conflict已经消失。在Fermi上,只需要加上一列就可以解决bank-conflict,但是在Kepler上却不一定,这取决于2D shared Memory的大小,因此对于8-byte模式,可能需要多次试验才能得到正确结果。

     

    参考书《professional cuda c programming》

  • 相关阅读:
    HDU 2116 Has the sum exceeded
    HDU 1233 还是畅通工程
    HDU 1234 开门人和关门人
    HDU 1283 最简单的计算机
    HDU 2552 三足鼎立
    HDU 1202 The calculation of GPA
    HDU 1248 寒冰王座
    HDU 1863 畅通工程
    HDU 1879 继续畅通工程
    颜色对话框CColorDialog,字体对话框CFontDialog使用实例
  • 原文地址:https://www.cnblogs.com/huty/p/8517832.html
Copyright © 2011-2022 走看看