并行计算基础(2)

一、CPU和GPU交互

1.各自有自己的物理内存空间,CPU的是内存,GPU的是显存

2.通过PCI-E总线互连(8GB/S~16GB/S)

3.交互开销较大

 

 

GPU各存储访存速度:

Register寄存器,最快

Shared Memory,共享存储,很快

Local Memory,本地存储,在显存中,有缓存,相对较慢

Global Memory,全局存储,在显存中,有缓存,相对较慢

Constant Memory,在显存中,多级缓存,1-100时钟周期,比较快

Texture Memory,在显存中,多级缓存,1-100时钟周期,比较快

Instruction Memory,不可见的,在显存中,有缓存

 

二、GPU线程组织模型

 

 

 线程组成Block,Block组成Grid。

 

 

 Warp是几个线程的组合,有一定特殊的规律,用于内部管理。

 

线程组织架构说明:

1.一个Kernel就是一个要运行的程序,里面有大量的线程。Kernel启动一个Grid,里面有若干个Blocks,由用户设定。Grid可以理解为一个公司。

2.一个Block中包含多个线程,一个Block内部的线程共享Shared Memory,可以同步“_syncthreads()”。Block可以理解为一个部门。

3.线程和线程块具有唯一的标识。

 

程序对于GPU也有一定的映射关系:

 

其中,一个线程对应一个CUDA core或ALU,一个Block对应一个SM或SMX,一个Grid对应多个SM,最大为整个设备。

 

 

GPU内存和线程的关系:

 

1.一个线程有自己的存储器,叫做Local Memory,是私有的,只能自己访问。例如私人的办工作,电脑等资源。

2.每个Block,有内部线程可共享的Shared Memory,相当于部门中的打印机等共享资源。

3.每个Grid(Kernal)之间有共享的Global Memory,也就是GPU设备的全局存储。相当于多个公司都可以访问的大楼。

4.主机端的存储器(内存)可以和不同的GPU设备的内存(显存)相互拷贝数据。

如下图所示:

 

 

1.线程运算时与寄存器交互最快。

2.线程读取Local Memory时,由于该存储位于外部显存,所以速度相当较慢。

3.一个Block中共享Shared Memory。

4.各个Block中的线程都可以访问Global Memory。

5.Constant和Texture对于线程都是只读的存储。

6.Constant和Texture可以由主机端来读写。

 

三、CUDA编程模式

CUDA编程语言实际上是扩展的C语言(Extended C)

CUDA提供了许多特定的关键词。例如__device__,__global__,__host__等。

 

CUDA函数声明:

__device__ float DeviceFunc();
__global__ void KernelFunc();
__host__ float HostFunc();

1.由__device__修饰的函数声明表示该函数的执行位置是在GPU设备上,需要由其他GPU上的函数来调用。

2.由__global__修饰的函数是kernel函数,也是入口函数,在CPU上调用,在GPU上执行,必须返回void。

3.__host__修饰的函数是在主机端调用,也在主机端运行。

4.__device__和__host__可以同时作用于一个函数,说明该函数的操作在CPU和GPU上是一样的。

 

Kernel:

  数据并行处理函数。

  通过调用Kernel函数在设备端创建轻量级线程,线程由硬件负责创建并调度。

  Kernel函数是在CPU上调用,然后再GPU上执行,是一个入口函数。

// 定义一个Kernel函数用__global__修饰
__global__ void VecAdd(float * A, float *B, float *C) {
    int i = threadIdx.x;
    C[i] = A[i] + B[i];
}

int main() {
    //.....需要将A B都拷贝到显存
    //.....在显存中分配C的空间
    // 使用N个线程来计算
    VecAdd<<<1, N>>> (A, B, C);

    return 0;
}

 

线程层次Thread Hierarchies:

使用一个Block来处理:

__global__ void MatAdd(float A[N][N], float B[N][N], float C[N][N]) {
    // 线程有N*N个,xy代表线程索引
    int i = threadIdx.x;
    int j = threadIdx.y;
    C[i][j] = A[i][j] + B[i][j];
}

int main() {
    // 使用一个Block
    int numBlocks = 1;
    // 每个Block有N*N个线程
    dim3 threadPerBlock(N, N);
    // 这里使用一个Block,每个Block有N*N个线程
    MatAdd <<<numBlocks, threadPerBlock>>> (A, B, C);

    return 0;
}

上述代码中,只使用一个Block(一个部门),该Block中有N*N个线程(人员)。这个Block是一个2D的Block。

 

Block中的线程:

在G80和GT200显卡中,每个Block最多512个线程,而Fermi架构的GPU每个Block可以有1024个线程,可以查阅相关GPU手册。

每个Block相当于一个SM,即核心。所以该Block中的线程都是工作在相同的处理器核心中的。他们共享所在核心的Shared Memory。

 

使用多个Block处理:

__global__ void MatAdd(float A[N][N], float B[N][N], float C[N][N]) {
    // 遍历每个Block的所有元素,并分别执行加法
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    int j = blockIdx.y * blockDim.y + threadIdx.y;
    if (i < N && j < N) {
        C[i][j] = A[i][j] + B[i][j];
    }
}

int main() {
    // 每个Block有16*16个线程
    dim3 threadPerBlock(16, 16);
    // 使用需要计算矩阵的尺寸来计算需要多少个Block
    dim3 numBlocks(N / threadPerBlock.x, N / threadPerBlock.y);
    // 这里使用一个Block,每个Block有N*N个线程
    MatAdd <<<numBlocks, threadPerBlock>>> (A, B, C);

    return 0;
}

 

Block与GPU核心(SM)数量关系:

 

当GPU只有2个SM(核心)时,程序有需要8个Block,则需要通过2个核心4次运算才能完成。

如果是4个核心,则需要2次运算才能完成。

 

 

四、数据传输

使用cudaMalloc在device上申请内存空间:

// 该指针用于存放device上分配空间的首地址
float * Md = 0;
// 申请设备内存大小为size
int size = 16 * 16 * sizeof(float);
// 这里必须传入&Md,即Md指针的地址。
// 因为cudaMalloc会将分配好的设备内存首地址赋值给Md,这个Md只能在Device上使用,不能直接在CPU程序中赋值等
cudaMalloc((void **)&Md, size);
// 释放Md指向的设备内存空间
cudaFree(Md);

 

内存传输:

  Host to Host

  Host to Device

  Device to Host

  Device to Device

对应一下四种操作:

// 申请设备内存大小为size
int size = 16 * 16 * sizeof(float);

// M指向CPU上的空间
float * M = (float *)malloc(size);
float * M2 = (float *)malloc(size);
// Md指向GPU上的空间
float * Md = 0;
float * Md2 = 0;
cudaMalloc((void **)&Md, size);
cudaMalloc((void **)&Md2, size);

// 从主机端内存中拷贝数据到Device的Global Memory中
cudaMemcpy(Md, M, size, cudaMemcpyHostToDevice);
// 从设备端拷贝数据到主机端
cudaMemcpy(M, Md, size, cudaMemcpyDeviceToHost);
// 从主机端数据拷贝到主机端另一个空间,相当于memcpy
cudaMemcpy(M, M2, size, cudaMemcpyHostToHost);
// 从设备端拷贝数据到设备端另一个空间
cudaMemcpy(Md2, Md, size, cudaMemcpyDeviceToDevice);

 

五、矩阵乘法示例

// 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;
}

 

六、GPU上函数需要注意的问题

由于GPU特殊的工作情况和结构,在__Global__和__device__函数中,注意以下几点:

1.尽量少用递归(不鼓励)

2.不要使用静态变量

3.少用malloc(允许但不鼓励,因为并行的使用malloc,空间很快耗光)

4.小心通过指针实现的函数调用(注意指针时CPU端的还是GPU端的)

 

七、CUDA数据类型

矢量数据类型(同时适用于host和device代码):

 

 通过函数make_<type name>构造:

int2 i2 = make_int2(1, 2);
float4 f4 = make_float4(1.0f, 2.0f, 3.0f, 4.0f);
cout << i2.x << i2.y << endl;
cout <<  f4.x << f4.y << f4.z << f4.w << endl;

 

八、CUDA支持的部分函数

部分函数列表:

 

面向Device端,更快,精度降低:

 

 九、线程同步

块内线程可以同步(Block内):

  调用__syncthreads 创建一个barrier栅栏

  每个线程在调用点等待块内所有线程执行到这个地方,然后再继续执行后续指令

Md[i] = Hd[j];
__syncthreads();
func(Md[i], Md[i + 1]);

如上述代码所示,func中同时需要Md[i]和Md[i+1],当Md[i]准备好时,Md[i+1]不一定准备好了,所以需要在前面等待Md[i+1]准备好后,再继续执行func函数。

 

__syncthreads会导致线程的暂停,破坏了线程执行的独立性,并可能由于线程同步的位置不同(条件分支中使用同步)导致同步死锁。所以在使用同步时一定要小心。

 

 十、线程调度

以G80显卡为例:

 

G80包含以下:

1.有16个核,也就是SM(8个绿色方框为一个SM)

2.每个SM有8个SP,也就是CUDA core或ALU(1个绿色方框)

3.每个SM最多可驻扎768个线程,128 X 6 = 768,每个SM可以保存6个上下文(蓝色部分)

4.总共可以同时驻扎12288个线程

5.但是由于只有128个CUDA core,同时也就只能执行128个线程

 

对于一个GPU设备来说,最大处理的线程量主要和CUDA core总量以及每个SM的上下文数量有关。但同时执行的线程数只与CUDA core数一致。

 

Warp:

针对Block中的线程,例如有64个线程(CUDA core),编号是连续的0-63。

假设一个Warp是32个线程组成(Warp的线程数和Block的线程数一般呈倍数关系,warpSize),则该Block中就有2个Warp,都运行在同一个SM上。第一个Warp线程编号为0-31,第二个Warp的线程编号为32-63。

Warp是线程调度的最小单位。

Warp的线程是天生同步的,也就是说他们必须是执行相同的指令流,当遇到分支可能导致执行的程序不同时(例如if else)则会出现串行的可能:

 

可能出现最差性能,就是1/N的性能。

例子:

1.如果一个SM分配了3个Block,其中每个Block含256个线程,那么总共有24个Warp(每个Warp 32个线程)。

2.GT200的一个SM最多可以驻扎1024个线程,那相当于1024/32=32个Warp。

3.假设每个Warp有32个线程,但每个SM只有8个SPs,如何分配?需要将一个Warp分成4份,然后在一个SM上轮换执行4次。流程如下:

  指令已经预备

  第一个周期8个线程进入SPs

  在第二、三、四周期各进入8个线程

  因此,分发一个Warp需要4个周期

4.对于目前的GPU来说,SM中所含的SP数一般都大于Warp含线程数量,所以以上分发流程一般不会再出现。

 

十一、内存模型

寄存器:

假设每个SM有8K个寄存器,有768个线程。则每个线程可以分到10个寄存器。

当超出限制时,则将因为Block的减少而减少。

例如,当一个线程需要用到11个寄存器,一个Block含256个线程。

本来如果每个线程使用寄存器不超出限制的时候,这个SM可以容纳3个Block(一个Block内的线程只能在同一个SM上执行),也就是刚好768个线程。

但由于寄存器超出限制,这个SM就只能容纳2个Block,即512个线程。所以就造成了资源的浪费。剩下未分配的SP也就只能闲着。

 

共享存储:

和寄存器类似原理类似。

假设每个SM最多8个Block,一共有16KB共享存储器。如果一个Block需要大于2K的共享存储器,则这个SM就不能容纳8个Block,同样造成资源浪费。

 

全局存储(显存):

访存延时(100个周期),访存较慢,片外存储

Host主机可读写

GT200 GPU访存带宽150GB/s,容量4GB,新的显卡的访存带宽已达到300-500GB/s,容量达到8-32GB

 

位于不同存储的变量定义:

 

 其中register和local存储我们不能操作。

__shared__定义存放在共享存储中的变量,这个变量只能是Block内部线程共享。

使用__device__关键字来定义全局存储(显存)中的变量。

__constant__用来定义常量(例如PI),存放在constant Memory中的。

 

posted @ 2019-08-29 17:41  风间悠香  阅读(1036)  评论(0编辑  收藏  举报