并行计算基础(3)

一、重新分析矩阵相乘

// Md,Nd,Pd都是Width*Width的方阵,使用的Block中线程数也是W*W
__global__ void MatMulKernel(float * Md, float * Nd, float * Pd, int Width) {
    // 横坐标为tx的列索引
    int tx = threadIdx.x;
    // 纵坐标为ty的行索引
    int ty = threadIdx.y;
    
    float Pvalue = 0;
    for (int k = 0;k < Width;++k) {
        // 处于tx的一行
        float Mdelement = Md[ty * Width + k];
        // 处于ty的一列
        float Ndelement = Nd[k * Width + tx];
        // Width元素做累加,得到坐标ty,tx的值
        Pvalue += Mdelement * Ndelement;
    }
    // 将计算得到的ty,tx的值写入相应的位置
    Pd[ty * Width + tx] = Pvalue;
}

上述代码实现了矩阵相乘,但是只使用了一个Block来完成,那么能够计算的矩阵大小就被Block的最大容量所限制了。我们要完成大型矩阵相乘的计算,则需要将矩阵分块来处理,也就是使用多个Block来并行完成:

// Md,Nd,Pd都是Width*Width的方阵,使用多个Block来分块计算
__global__ void MatMulKernel(float * Md, float * Nd, float * Pd, int Width) {
    // 索引要利用Block编号、大小以及线程编号共同决定
    int tx = blockIdx.x * blockDim.x + threadIdx.x;
    int ty = blockIdx.y * blockDim.y + threadIdx.y;
    
    float Pvalue = 0;
    for (int k = 0;k < Width;++k) {
        // 处于tx的一行
        float Mdelement = Md[ty * Width + k];
        // 处于ty的一列
        float Ndelement = Nd[k * Width + tx];
        // Width元素做累加,得到坐标ty,tx的值
        Pvalue += Mdelement * Ndelement;
    }
    // 将计算得到的ty,tx的值写入相应的位置
    Pd[ty * Width + tx] = Pvalue;
}
dim3 dimGrid(Width / TILE_WIDTH, Width / TILE_WIDTH);
dim3 dimBlock(TILE_WIDTH, TILE_WIDTH);
MatMulKernel <<<dimGrid, dimBlock>>> (Md, Nd, Pd, Width);

在调用时,我们需要计算需要多少个Blocks,由于例子中使用的是方阵,所以没有区分Width和Height

这样我们就可以使用dimGrid.x * dimGrid.y个Block来一起计算一个比较大的矩阵。

 

二、访存带宽受限

在上述矩阵相乘的代码中,除了我们已经解决的矩阵大小的问题,还有一个很重要的问题是访存受限的问题。

代码中的for循环中,每计算一个坐标的结果,则需要读取2 * Width次,极大的受限于访存带宽。

 

以G80为例子:

  1.G80的峰值GFLOPS为346.5,每个浮点数为4Byte,则G80每秒能够处理的Byte数为346.5*4 = 1386GByte。

  2.但G80的实际访存带宽只有86.4GB/s,那么就限制了计算速度为21.6GFLOPS,而实际代码的运行速度还更低,只有15GFLOPS

 

为了使GPU得运算速度尽量少的被访存带宽限制,我们要尽量减少对Global Memory的访问。如下图所示:

这里的Width是矩阵的总大小,TILE_WIDTH是瓦片大小。所以代码如下(代码比较绕,仔细思考):

__global__ void MatMulKernel(float * Md, float * Nd, float * Pd, int Width) {
    // Warp大小
    const int TILE_WIDTH = 32;
    // 在共享存储中分配空间
    __shared__ float Mds[TILE_WIDTH][TILE_WIDTH];
    __shared__ float Nds[TILE_WIDTH][TILE_WIDTH];

    // Block编号
    int bx = blockIdx.x;
    int by = blockIdx.y;
    // Block中线程编号
    int tx = threadIdx.x;
    int ty = threadIdx.y;
    
    // 在原数据中的某行某列
    int Row = by * TILE_WIDTH + ty;
    int Col = bx * TILE_WIDTH + tx;

    float Pvalue = 0;
    // 为什么要循环m次,因为每一个位置的值都要计算整个列和整个行(包含m个Warp)
    for (int m = 0;m < Width / TILE_WIDTH;++m) {
        // m中的每一个Warp都要等数据拷贝完毕后才能进行计算
        Mds[ty][tx] = Md[Row * Width + (m * TILE_WIDTH + tx)];
        Nds[ty][tx] = Nd[(m * TILE_WIDTH + ty) * Width + Col];
        // 等大家都拷贝完
        __syncthreads();
        
        // Pvalue累加了m次独立Warp互相运算的结果,得到最终一行Warp和一列Warp的运算结果
        for(int k = 0;k<TILE_WIDTH;++k)
            Pvalue += Mds[ty][k] * Nds[k][tx];
        // 等到大家都计算完,在执行下一对Warp
        __syncthreads();
    }
    // 得到最终结果后,写入Pd
    Pd[Row*Width + Col] = Pvalue;
}

上述代码在个Block计算之前,将对应的数据由各个线程自己拷贝到对应的共享存储中,相当于1:1访问。然后在计算时,直接从共享存储中访问。这里我们使用Warp的大小,例如是32,则我们对Global的访问次数就减少了32倍,所以我们需要确定Warp选用多大的数最好。

 

三、Warp分支发散和Warp分割

假设一个Warp是32个线程,那么我们的Block尽量设置为32的倍数。

我们可以将Warp看成一个部门的一个小组,这个小组里的人同吃同睡(执行统一指令),但存在分支发散,例如其中一半人去吃饭,另一半人在看。过一会,吃完饭后,另一半人去踢足球,这一组人在看。如下图所示:

 

所以,在我们做分支的时候,尽量以32的倍数来分割,这样就不会出现分支发散的情况,从而提高性能。如下代码:

// 存在分支发散(假设warpSize = 32)
if (threadIdx.x > 15) {
    // todo
}
// 不存在分支发散(warpSize > 1)
if (threadIdx.x > warpSize - 1) {
    // todo
}

 

例子,对8个数进行累加,使用8个线程,假设每两个线程是一个warp。

第一种方法,相邻两个数相加,最后得到结果:

 

第二种方法,每隔4个位置的数相加:

 

 明显后者更好,可以更快的使后面的warp完成工作,从而供其他任务调度。

 

posted @ 2019-09-02 17:23  风间悠香  阅读(568)  评论(0编辑  收藏  举报