CUDA优化reduce

一、baseline

名称 耗时 带宽 加速比
baseline 826 150.47
__global__ void reduce_base(float *d_in, float *d_out){
    __shared__ float sdata[256];

    unsigned int tid = threadIdx.x;
    unsigned int idx = blockIdx.x * blockDim.x + threadIdx.x;
    sdata[tid] = d_in[idx];
    __syncthreads();

    for (int i = 1; i < blockDim.x; i *= 2){
        if ((tid % (2 * i)) == 0)
            sdata[tid] += sdata[tid + i];
        __syncthreads();
    }

    if(tid==0)
        d_out[blockIdx.x]=sdata[tid];
}

在本次实验中,采用的GPU是V100。
耗时结果是使用nsight测出来的。在V100 中global的带宽是900GB/s。可以看出,其带宽利用率较差,baseline版本存在着较大的改进空间。

至于为什么需要关注全局内存带宽:
大多数设备端的访问都是从全局内存开始的,而且多数GPU应用程序容易受到内存带宽的限制,因此,最大限度利用全局内存带宽对于调控核函数的性能十分重要

二、优化技巧1:避免线程束分化

2.1 问题分析

这里就需要提到线程束分化的问题了。

if (con)
{
    //do something
}
else
{
    //do something
}

假设这段代码是核函数的一部分,那么当一个线程束的32个线程执行这段代码的时候,如果其中16个执行if中的代码段,而另外16个执行else中的代码块,同一个线程束中的线程,执行不同的指令,这叫做线程束的分化
我们知道在每个指令周期,线程束中的所有线程执行相同的指令,但是线程束又是分化的,所以这似乎是相悖的,但是事实上这两个可以不矛盾。

解决矛盾的办法就是每个线程都执行所有的if和else部分,当一部分con成立的时候,执行if块内的代码,有一部分线程con不成立,那么他们怎么办?继续执行else?不可能的,因为分配命令的调度器就一个,所以这些con不成立的线程等待,就像分水果,你不爱吃,那你就只能看着别人吃,等大家都吃完了,再进行下一轮(也就是下一个指令)线程束分化会产生严重的性能下降。
条件分支越多,并行性削弱越严重。

因为线程束分化导致的性能下降就应该用线程束的方法解决,根本思路是避免同一个线程束内的线程分化,而让我们能控制线程束内线程行为的原因是线程块中线程分配到线程束是有规律的而不是随机的。这就使得我们根据线程编号来设计分支是可以的。

此处就有一个问题:

if (tid % (2*i) == 0)

每次都是由双数线程进行的计算。
CUDA优化reduce_第1张图片

有很大的线程束分化问题,这严重影响了代码执行的效率。
解决方法:尽可能让线程在一个分支内。

名称 耗时 带宽 加速比
baseline 826 150.47
Divergence 527 238.8 1.57

2.2 代码实现

__global__ void reduce_Divergence(float *d_in, float *d_out){
    __shared__ float sdata[256];

    unsigned int tid = threadIdx.x;
    unsigned int idx = blockIdx.x * blockDim.x + threadIdx.x;
    sdata[tid] = d_in[idx];
    __syncthreads();

    for (int i = 1; i < blockDim.x; i *= 2){
        int index = 2 * i * tid;
        if (index < blockDim.x)
            sdata[index] += sdata[index + i];
        __syncthreads();
    }

    if(tid==0)
        d_out[blockIdx.x]=sdata[tid];
}

虽然代码依旧存在着if语句,但是却与baseline代码有所不同。

我们继续假定block中存在256个thread,即拥有256/32=8个warp。
当进行第1次迭代时,0-3号warp的index=blockDim.x(什么也不做)。对于每个warp而言,都只是进入到一个分支内,所以并不会存在warp divergence的情况。

当进行第2次迭代时,0、1号两个warp进入计算分支。当进行第3次迭代时,只有0号warp进入计算分支。

当进行第4次迭代时,只有0号warp的前16个线程进入分支。此时开始产生warp divergence。通过这种方式,我们消除了前3次迭代的warp divergence

三、优化技巧2:解决bank冲突

3.1 什么是bank冲突

为了获得较高的内存带宽,共享存储器被划分为多个大小相等的存储器模块,称为bank,可以被同时访问。
因此任何跨越b个不同的内存bank的对n个地址进行读取和写入的操作可以被同时进行,这样就大大提高了整体带宽 ——可达到单独一个bank带宽的b倍。

但是很多情况下,我们无法充分发挥bank的功能,以致于shared memory的带宽非常的小,这可能是因为我们遇到了bank冲突

当一个warp中的不同线程访问一个bank中的不同的字地址时,就会发生bank冲突。

3.2 共享内存的映射方式

要解决bank冲突,首先我们要了解一下共享内存的地址映射方式。
在共享内存中,连续的32-bits字被分配到连续的32个bank中,这就像电影院的座位一样:一列的座位就相当于一个bank,所以每行有32个座位,在每个座位上可以“坐”一个32-bits的数据(或者多个小于32-bits的数据,如4个char型的数据,2个short型的数据);而正常情况下,我们是按照先坐完一行再坐下一行的顺序来坐座位的,在shared memory中地址映射的方式也是这样的。下图中内存地址是按照箭头的方向依次映射的:
CUDA优化reduce_第2张图片

3.3 reduce中的bank冲突

reduce的数据每一个数据都是float型,也就是一个数据占据一个座位

把目光聚焦在这个for循环中。并且只聚焦在0号warp。

在第一次迭代中,0号线程需要去load shared memory的0号地址以及1号地址的数,然后写回到0号地址。
而此时,这个warp中的16号线程,需要去load shared memory中的32号地址和33号地址。可以发现,0号地址跟32号地址产生了2路的bank冲突。

在第2次迭代中,0号线程需要去load shared memory中的0号地址和2号地址。
这个warp中的8号线程需要load shared memory中的32号地址以及34号地址,16号线程需要load shared memory中的64号地址和68号地址,24号线程需要load shared memory中的96号地址和100号地址。
又因为0、32、64、96号地址对应着同一个bank,所以此时产生了4路的bank冲突。现在,可以继续算下去,8路bank冲突,16路bank冲突。由于bank冲突,所以reduce1性能受限。下图说明了在load第一个数据时所产生的bank冲突。

CUDA优化reduce_第3张图片

3.4 解决bank冲突

采用交错配对的方式进行归约即可。

初始跨度是线程块大小的一半,然后每次循环减少一半。

把目光继续看到这个for循环中,并且只分析0号warp。
0号线程需要load shared memory的0号元素以及128号元素。1号线程需要load shared memory中的1号元素和129号元素。这一轮迭代中,在读取第一个数时,warp中的32个线程刚好load 一行shared memory数据。

再分析第2轮迭代,0号线程load 0号元素和64号元素,1号线程load 1号元素和65号元素。咦,也是这样,每次load shared memory的一行。

再来分析第3轮迭代,0号线程load 0号元素和32号元素,接下来不写了,总之,一个warp load shared memory的一行。没有bank冲突。到了4轮迭代,0号线程load 0号元素和16号元素。那16号线程呢,16号线程啥也不干,因为s=16,16-31号线程啥也不干,跳过去了。示意图如下:
CUDA优化reduce_第4张图片

名称 耗时 带宽 加速比
baseline 826 150.47
Divergence 527 238.8 1.57
Bank 394 314.64 1.34

3.5 代码实现:

__global__ void reduce_Bank(float *d_in, float *d_out){
    __shared__ float sdata[256];

    unsigned int tid = threadIdx.x;
    unsigned int idx = blockIdx.x * blockDim.x + threadIdx.x;
    sdata[tid] = d_in[idx];
    __syncthreads();

    for (unsigned int i = blockDim.x / 2; i > 0; i >>= 1){
        if (tid < i)
            sdata[tid] += sdata[tid + i];
        __syncthreads();
    }

    if(tid == 0)
        d_out[blockIdx.x]=sdata[tid];
}

四、优化技巧3:利用空闲线程

4.1 解决空线程问题

细想一下,我们虽然每个block有256个线程,但是嘞,咱们没用满!
取数的时候会用到一下,但是计算时每次都是只用一半128-64-32-16-8-4-2-1

我们想的是,256个都给算个加法,这样带宽一下上来了。

所以要怎么做呢?
每个block还是256个线程,不同的地方在于每个block里边会处理512个数据,这样一来grid中block的数量也减少了一半。

具体操作也就是在取数据的时候再进行一次加法,利用线程。

名称 耗时 带宽 加速比
baseline 826 150.47
Divergence 527 238.8 1.57
Bank 394 314.64 1.34
Idle 213 572.6 1.85

与Bank相比性能提升还是很大的,1.85倍的提升

4.2 代码实现:

#include 
#include 
#include "cuda_runtime.h"
#include "device_launch_parameters.h"
#include 
#include 
__global__ void reduce_Idle(float *d_in, float *d_out){
    __shared__ float sdata[512];

    unsigned int tid = threadIdx.x;
    unsigned int idx = blockIdx.x * (blockDim.x * 2) + threadIdx.x;
    sdata[tid] = d_in[idx] + d_in[idx + blockDim.x];
    __syncthreads();

    for (unsigned int i = blockDim.x / 2; i > 0; i >>= 1){
        if (tid < i)
            sdata[tid] += sdata[tid + i];
        __syncthreads();
    }

    if(tid == 0)
        d_out[blockIdx.x]=sdata[tid];
}
bool check(float *out,float *res,int n){
    for(int i = 0; i < n; i++){
        if(out[i] != res[i])
            return false;
    }
    return true;
}
int main(){
    //set GPU number
    int dev = 0;
    cudaDeviceProp deviceProp;
    cudaGetDeviceProperties(&deviceProp, dev);
    printf("device is : %d , %s \n",dev, deviceProp.name);
    cudaSetDevice(dev);

    const int size = 32*1024*1024;
    printf("     array size is : %d \n", size);

    //execution configuration
    int block_Size = 512;//threads number in a block
    int block_Num = size / block_Size;
    dim3 block (256, 1);
    dim3 grid (block_Num, 1);
    printf("grid %d block %d\n", grid.x, block.x);

    //allocate memory
    float *h_a = (float *)malloc(size * sizeof(float));
    float *d_a;
    float *h_out = (float *)malloc(block_Num * sizeof(float));
    float *d_out;

    cudaMalloc((void **)&d_a, size * sizeof(float));
    cudaMalloc((void **)&d_out,block_Num * sizeof(float));
    float *res = (float *)malloc(block_Num * sizeof(float));

    //initialize the array
    for (int i = 0; i < size; i++){
        h_a[i] = 1;
    }

    //compute on CPU
    for (int i = 0; i < block_Num; i++){
        float cur = 0;
        for (int j = 0; j < block_Size; j++){
            cur += h_a[i * block_Size + j];
        }
        res[i] = cur;
    }

    cudaMemcpy(d_a, h_a, size * sizeof(float), cudaMemcpyHostToDevice);
    reduce_Idle<<<grid, block>>>(d_a, d_out);
    cudaMemcpy(h_out, d_out, block_Num * sizeof(float), cudaMemcpyDeviceToHost);

    if (check(h_out, res, block_Num))
        printf("the result is right\n");
    else{
        printf("the result is wrong\n");
        for (int i = 0; i < block_Num; i++){
            printf("%lf ", h_out[i]);
        }
        printf("\n");
    }
    cudaFree(d_a);
    cudaFree(d_out);

}

五、优化技巧4:展开最后一维减少同步

5.1 分析问题

对于解决空线程后的效果来说,性能已经算是比较好了。但是依旧没有达到我们想要的效果。

可以发现,当进行到最后几轮迭代时(使用的线程数量<=32时),此时的block中只有warp0在干活时,线程还在进行同步操作
这一条语句造成了极大的浪费。

由于一个warp中的32个线程每次都是执行同一条指令,这天然地保持了同步状态
因而当i=32时,即只有一个SIMD单元在工作时,完全可以将__syncthreads()这条同步代码去掉。

所以我们将最后一维进行展开以减少同步。

名称 耗时 带宽 加速比
baseline 826 150.47
Divergence 527 238.8 1.57
Bank 394 314.64 1.34
Idle 213 572.6 1.85
Unroll 183 679.71 1.16

5.2 代码实现:

需要注意的是这个地方的cache变量需要使用volatile来进行声明,它告诉编译器每次赋值时必须将cache[tid]的值返回到全局内存中,而不是简单的读写缓存或寄存器。

__global__ void reduce_Unroll(float *d_in, float *d_out){
    __shared__ float sdata[512];

    unsigned int tid = threadIdx.x;
    unsigned int idx = blockIdx.x * (blockDim.x * 2) + threadIdx.x;
    sdata[tid] = d_in[idx] + d_in[idx + blockDim.x];
    __syncthreads();

    for (unsigned int i = blockDim.x / 2; i > 32; i >>= 1){
        if (tid < i)
     sdata[tid] += sdata[tid + i];
        __syncthreads();
    }

    if (tid < 32)
        warpReduce(sdata, tid);
    if(tid == 0)
        d_out[blockIdx.x]=sdata[tid];
}

六、优化技巧5:完全循环展开

6.1 分析问题

循环展开将循环的主体多次编写,使得迭代次数减少为: 原始次数/循环展开因子
通过这种方式减少了条件判断的次数,因而可以实现速度的提升。

因为我们知道每个块的最大线程数为1024个,并且归约中的循环迭代次数是基于一个线程块维度的,所以可以完全循环展开

名称 耗时 带宽 加速比
baseline 826 150.47
Divergence 527 238.8 1.57
Bank 394 314.64 1.34
Idle 213 572.6 1.85
Unroll 183 679.71 1.16
Comroll 196 633.61 -

但是最终的结果并没用提升,反而似乎是有点下降,我觉得是编译器的锅,可能已经帮我们优化这一步了。

回头有了想法再填坑吧。

6.2 代码实现:

__device__ void warpReduce(volatile float* cache, int tid){
    cache[tid] += cache[tid + 32];
    cache[tid] += cache[tid + 16];
    cache[tid] += cache[tid + 8];
    cache[tid] += cache[tid + 4];
    cache[tid] += cache[tid + 2];
    cache[tid] += cache[tid + 1];


}
__global__ void reduce_ComUnroll(float *d_in, float *d_out){
    __shared__ float sdata[512];

    unsigned int tid = threadIdx.x;
    unsigned int idx = blockIdx.x * (blockDim.x * 2) + threadIdx.x;
    sdata[tid] = d_in[idx] + d_in[idx + blockDim.x];
    __syncthreads();


    if (blockDim.x >= 1024 && tid < 512){
 		sdata[tid] += sdata[tid + 512];
       __syncthreads();
    }
    if (blockDim.x >= 512 && tid < 256){
 		sdata[tid] += sdata[tid + 256];
       __syncthreads();
    }
    if (blockDim.x >= 256 && tid < 128){
		sdata[tid] += sdata[tid + 128];
       __syncthreads();
    }
    if (blockDim.x >= 128 && tid < 64){
 		sdata[tid] += sdata[tid + 64];
       __syncthreads();
    }

    if (tid < 32)
 		warpReduce(sdata, tid);
    if(tid == 0)
 		d_out[blockIdx.x]=sdata[tid];
}

七、优化技巧6:调整block大小

7.1 问题分析

当走到这一步的时候,能调的东西已经基本上调完了。再把眼光放在block和thread的设置上。之前默认了每个block中的线程数量等于block处理的数据量

也就是说,一个block开启256个线程时,这个block负责256个元素的reduce操作。那可不可以让一个block多管点数。这样的话,开启的block数量少一些。以此对block设置进行调整,获得最优block取值,这样或许能够带来一些性能收益?

这样需要再思考一下block的取值。如果一个线程被分配更多的work时,可能会更好地覆盖延时。这一点比较好理解。如果线程有更多的work时,对于编译器而言,就可能有更多的机会对相关指令进行重排,从而去覆盖访存时的巨大延时。

但是也不是越多越好,还是要有个合理的数据。

7.2 时间测试

对于block处理的数据量设置为256、512、1024、2048分别进行测试。

名称 耗时 带宽 加速比
512 233.28 526.95
1024 198.43 621.72 1.18
2048 185.76 653.44 1.28
4096 187.49 650.39 1.27

可以看到的是速度从512开始依次递增,但是4096相比2048并没有出现明显的加快,选择2048最为合适。
理论上来说,这个值取SM数量的倍数会比较合理。但是V100的SM是80,取一个完美的倍数还是比较困难。

名称 耗时 带宽 加速比
baseline 826 150.47
Divergence 527 238.8 1.57
Bank 394 314.64 1.34
Idle 213 572.6 1.85
Unroll 183 679.71 1.16
Comroll 196 633.61 -
Block 186 653.44 -

7.2 代码实现

#include 
#include 
#include "cuda_runtime.h"
#include "device_launch_parameters.h"
#include 
#include 
#define THREAD_PER_BLOCK 256

__device__ void warpReduce(volatile float* cache, unsigned int tid){
    cache[tid] += cache[tid + 32];
    cache[tid] += cache[tid + 16];
    cache[tid] += cache[tid + 8];
    cache[tid] += cache[tid + 4];
    cache[tid] += cache[tid + 2];
    cache[tid] += cache[tid + 1];
}
template <unsigned int blockSize, int NUM_PER_THREAD>
__global__ void reduce_Block(float *d_in, float *d_out){
    __shared__ float sdata[blockSize];

    unsigned int tid = threadIdx.x;
    unsigned int idx = blockIdx.x * (blockSize * NUM_PER_THREAD) + threadIdx.x;

    // each thread loads NUM_PER_THREAD element from global to shared mem
    sdata[tid] = 0;

    for (int i = 0; i < NUM_PER_THREAD; i++){
        sdata[tid] += d_in[idx + i * blockSize];
        __syncthreads();
    }

    if (blockDim.x >= 1024 && tid < 512){
        sdata[tid] += sdata[tid + 512];
        __syncthreads();
    }
    if (blockDim.x >= 512 && tid < 256){
        sdata[tid] += sdata[tid + 256];
        __syncthreads();
    }
    if (blockDim.x >= 256 && tid < 128){
        sdata[tid] += sdata[tid + 128];
        __syncthreads();
    }
    if (blockDim.x >= 128 && tid < 64){
        sdata[tid] += sdata[tid + 64];
        __syncthreads();
    }


    if (tid < 32)
        warpReduce(sdata, tid);
    if (tid == 0)
        d_out[blockIdx.x] = sdata[0];
}
bool check(float *out,float *res,int n){
    for(int i = 0; i < n; i++){
        if(out[i] != res[i])
            return false;
    }
    return true;
}
int main(){
    //set GPU number
    int dev = 0;
    cudaDeviceProp deviceProp;
    cudaGetDeviceProperties(&deviceProp, dev);
    printf("device is : %d , %s \n",dev, deviceProp.name);
    cudaSetDevice(dev);

    const int size = 32*1024*1024;
    printf("     array size is : %d \n", size);

    //execution configuration
    const int block_Size = 1024;//per block need manage data num
    const int block_Num = size / block_Size; //block num
    const int NUM_PER_THREAD = block_Size / THREAD_PER_BLOCK;//per thread need manage data num

    dim3 block (THREAD_PER_BLOCK, 1);
    dim3 grid (block_Num, 1);
    printf("grid %d block %d\n", grid.x, block.x);

    //allocate memory
    float *h_a = (float *)malloc(size * sizeof(float));
    float *d_a;
    float *h_out = (float *)malloc(block_Num * sizeof(float));
    float *d_out;

    cudaMalloc((void **)&d_a, size * sizeof(float));
    cudaMalloc((void **)&d_out,block_Num * sizeof(float));
    float *res = (float *)malloc(block_Num * sizeof(float));

    //initialize the array
    for (int i = 0; i < size; i++){
        h_a[i] = 1;
    }

    //compute on CPU
    for (int i = 0; i < block_Num; i++){
        float cur = 0;
        for (int j = 0; j < block_Size; j++){
            if (i * block_Size + j < size)
                cur += h_a[i *block_Size + j];
        }
        res[i] = cur;
    }

    cudaMemcpy(d_a, h_a, size * sizeof(float), cudaMemcpyHostToDevice);
    reduce_Block<THREAD_PER_BLOCK, NUM_PER_THREAD><<<grid, block>>>(d_a, d_out);
    cudaMemcpy(h_out, d_out, block_Size * sizeof(float), cudaMemcpyDeviceToHost);

    if (check(h_out, res, block_Size))
        printf("the result is right\n");
    else{
        printf("the result is wrong\n");
        for (int i = 0; i < block_Num; i++){
            printf("%lf ", h_out[i]);
        }
        printf("\n");
    }
    cudaFree(d_a);
    cudaFree(d_out);

}

八、优化技巧7:shuffle指令

8.1 问题分析

NV提出了Shuffle指令,对于reduce优化有着非常好的效果。目前绝大多数访存类算子,像是softmax,batch_norm,reduce等,都是用Shuffle实现。所以,在这里谈一下这么把shuffle指令用在reduce优化上。

Shuffle指令是一组针对warp的指令。Shuffle指令最重要的特性就是warp内的寄存器可以相互访问。在没有shuffle指令的时候,各个线程在进行通信时只能通过shared memory来访问彼此的寄存器。而采用了shuffle指令之后,warp内的线程可以直接对其他线程的寄存器进行访存。通过这种方式可以减少访存的延时。除此之外,带来的最大好处就是可编程性提高了,在某些场景下,就不用shared memory了。毕竟,开发者要自己去控制 shared memory还是挺麻烦的一个事。

关于shuffle指令见:shuffle

名称 耗时 带宽 加速比
baseline 826 150.47
Divergence 527 238.8 1.57
Bank 394 314.64 1.34
Idle 213 572.6 1.85
Unroll 183 679.71 1.16
Comroll 196 633.61 -
Block 186 653.44 -
Shuffle 163 745.33 1.12

8.2 代码实现

template <unsigned int blockSize>
__device__ __forceinline__ float warpReduceSum(float sum){
    if(blockSize >= 32)sum += __shfl_down_sync(0xffffffff,sum,16);
    if(blockSize >= 16)sum += __shfl_down_sync(0xffffffff,sum,8);
    if(blockSize >= 8)sum += __shfl_down_sync(0xffffffff,sum,4);
    if(blockSize >= 4)sum += __shfl_down_sync(0xffffffff,sum,2);
    if(blockSize >= 2)sum += __shfl_down_sync(0xffffffff,sum,1);
    return sum;
}

template <unsigned int blockSize, int NUM_PER_THREAD>
__global__ void reduce_Shuffle(float *d_in,float *d_out){
    float sum = 0;

    unsigned int tid = threadIdx.x;
    unsigned int idx = blockIdx.x * (blockSize * NUM_PER_THREAD) + threadIdx.x;

    // each thread loads NUM_PER_THREAD element from global to shared mem

    for (int i = 0; i < NUM_PER_THREAD; i++){
        sum += d_in[idx + i * blockSize];
    }



    // shared mem for partial sums(one per warp in the block
    static __shared__ float warpLevelSums[WARP_SIZE];
    const int laneId = threadIdx.x % WARP_SIZE;
    const int warpId = threadIdx.x / WARP_SIZE;

    sum = warpReduceSum<blockSize>(sum);

    if(laneId == 0)
    {
        warpLevelSums[warpId]=sum;
        __syncthreads();
    }
    sum = (threadIdx.x < blockDim.x / WARP_SIZE)? warpLevelSums[laneId]:0;
    // Final reduce using first warp
    if(warpId == 0)sum = warpReduceSum<blockSize/WARP_SIZE>(sum);
    // write result for this block to global mem
    if(tid==0)d_out[blockIdx.x]=sum;
}

九、总结

名称 耗时 带宽 加速比
baseline 826 150.47
Divergence 527 238.8 1.57
Bank 394 314.64 1.34
Idle 213 572.6 1.85
Unroll 183 679.71 1.16
Comroll 196 633.61 -
Block 186 653.44 -
Shuffle 163 745.33 1.12

在进行了展开最后一维减少同步后速度已经很快了,达到了183,后续进行的完全展开效果并不好,甚至拖慢了速度。

在完全展开的基础上,设置block处理数据个数为2048可以取得最好的收益,但是效果仍然差于Unroll

但是使用shuffle指令后,提升很大,带宽也达到了745.33,利用率超过了80%

CUDA优化reduce_第5张图片

你可能感兴趣的:(高性能计算,CUDA学习,cuda,加速,高性能计算)