GPU实际上是一个SM的阵列,每个SM包含若干个SP。一个SP可以执行一个thread,但是实际上并不是所有的thread能够在同一时刻执行。Nvidia把32个threads组成一个warp,warp是调度和运行的基本单元。warp中所有threads并行的执行相同的指令。一个warp需要占用一个SM运行,多个warps需要轮流进入SM。由SM的硬件warp scheduler负责调度。目前每个warp包含32个threads(Nvida保留修改数量的权利)。所以,一个GPU上resident thread最多只有 SM*warp个。
在每个SM中有与其工作速度相同的寄存器文件(register file) ,专有的内存共享内存,进行特殊运算的SPU单元。
当执行多线程程序时,CPU遵从的是MIMD模型,支持多线程处理多个不同的指令流。而GPU的并行是相对更加细粒度的,SPMD模式(单程序多数据),将同一条指令送到n个逻辑执行的单元,也就是每个线程执行相同的代码,但是数据不同。
在以下这段cuda代码中,每个线程都执行了两次读取内存操作,一次乘法操作,一次存储操作,代码相同,数据不同。
__global__ void addKernel(int *c, const int *a, const int *b)
{
int i = threadIdx.x;
c[i] = a[i] + b[i];
}
这是一段GPU内核函数,以__global__来声明这段并不属于cpu。拥有这段函数后遍可以调用内核。记住内核仅仅是一个运行在GPU上的函数。调用时需按照以下语法:
Kernel_function<<>>(param1, param2, ...);
参数num_threads表示执行内核函数的线程数量,每个线程块中最多支持的线程数量受到机器限制,这点需要注意。
param即传参数传递,可以通过寄存器或者常量内存来进行。
而参数num_blocks指线程块,那么什么是线程块呢?
线程块(block):
顾名思义即一块线程,在启动内核程序时会先启动对应的块数,每块再启动对应数量的线程
kernel_func<<<2,128>>>(a,b,c)
在以上这段代码中,该程序将被调用共2 x 128次。
要注意,块内线程数的选择有一个warp的概念,一个block内的线程同时也被打包成多个warp(线程束),一个warp由32各线程组成,所以为了让资源不浪费,即warp内的线程数被充分利用,线程数经常设置为32的整数倍。
在程序中要尽量避免使用小的线程块,这样无法充分利用硬件。(注:应该是因为线程块时进行调度的单位
线程网格(grid):
为了让c语言中的数组能够方便进行映射,线程块也可以看成一个二维结构,即线程网格。
线程网格包含若干线程块,每个线程块在网格中具有x,y坐标。则一次能开启x*y*t个线程。
既然我们现在已经有了线程,线程块,线程网格,那么无论是一维,二维或是三维的内存数据索引都能够与程序的并行结构进行一一对应,让数据与处理器保持紧密联系。
线程索引:
假设有一个32 x 16维的数组,让数组与线程块产生一一对应的关系,我们至少有两种划分模式:
一般来说我们选择长方形布局,主要有两点原因:
一是同一个线程块中的线程可以通过共享内存来进行通信,二是正方形布局中一块(一行)内存区域需要访存两次才能得到,长方型布局只需一次。
布局的代码如下:
dim3 threads_rect(32,4);
dim3 blocks_rect(1,4);
some_kernel_func<<>>(a,b,c);
或
dim3 threads_rect(32,4);
dim3 blocks_rect(1,4);
some_kernel_func<<>>(a,b,c);
访问时通过以下变量获取索引
gridDim.x:线程网格x维度上线程块的数量
gridDim.y:线程网格y维度上线程块的数量
blockDim.x:一个线程块x维度上的线程数量
blockDlm.y:一个线程块y维度上的线程数量
theadIdx.x:线程块x维度上的线程索引
theadIdx.y:线程块y维度上的线程索引
线程束是GPU的基本执行单元。在SM中,线程块不是线程的最小集合。SM会把线程块划分为若干个线程束,每个线程束由32个(可以通过固有变量wrapsize进行获取)连续的线程组成。在一个线程束中 ,所有的线程按照SIMT的方式执行。也就是说,在线程束里的所有线程都会执行相同的指令,每个线程都在该线程束上的私有数据进行操作。
分支:
GPU在执行完分支结构的一个分支后会接着执行另一个分支。对不满足分支条件的线程,GPU在执行这块代码的时候会将它们设置成未激活状态。当这块代码执行完毕之后,GPU继续执行另一个分支,这时,刚刚不满足分支条件的线程如果满足当前的分支条件,那么它们将被激活,然后执行这一段代码。最后,所有的线程聚合,继续向下执行。
__global__ some_func(){
if(some_condition){
action_a();
}else{
action_b();
}
}
代码中,假设索引为偶数的线程满足条件为真的情况,执行函数action_a(),索引为奇数的线程满足条件为假的情况,执行函数action_b()。由于硬件每次只能为一个线程束获取一条指令,线程束中一半的线程要执行条件为真的代码段,一半线程执行条件为假的代码段,因此,这时有一半的线程会被阻塞,而另一半线程会执行满足条件的那个分支。如此,硬件的利用率只达到了50%。
事实上,在指令执行层,硬件的调度是基于半个线程束,而不是整个线程束。这意味着,只要我们能将半个线程束中连续的16个线程划分到同一个分支中(条件苛刻),那么硬件就能同时执行分支结构的两个不同条件的分支块,例如,示例程序中if-else的分支结构。这时硬件的利用率就可以达到100%。
__global__ some_func(){
if((thread_idx % 32) < 16){
action_a();
}else{
action_b();
}
}
但这种现象发生的条件很苛刻,只有当缓存大小与线程束需要获取的内存数据大小相同而且数据在内存分布上是连续的才会发生。
GPU利用率:
CUDA模式用成千上万的线程来隐藏内存操作的延迟,为了最大限度地利用设备,使程序性能得到提升,我们应尽量将每个线程块开启的线程数设为192或256。
在线程块调度者为每个SM初始化分配了线程块之后,就会处于闲置状态,直到有线程块执行完毕。当线程块执行完毕之后就会从SM中撤出,并释放其占用的资源。由于线程块都是相同的大小,因此一个线程块从SM中撤出后另一个在等待队列中的线程块就会被调度执行。为了提高设备的利用率,尽量保证在每个GPU中,线程块的数目都是SM数目的整数倍(目的是负载平衡,也可以将任务划分得更细有更多的动态轮换空间)。
本文以原理的在讲解、重点提取、重构为主,只是挑选我觉得重要晦涩的原理性问题进行逻辑上的再阐释