首页 > 代码库 > CUDA学习日志:线程协作与例程
CUDA学习日志:线程协作与例程
接触CUDA的时间并不长,最开始是在cuda-convnet的代码中接触CUDA代码,当时确实看的比较痛苦。最近得空,在图书馆借了本《GPU高性能编程 CUDA实战》来看看,同时也整理一些博客来加强学习效果。
Jeremy Lin
在上篇博文中,我们已经用CUDA C编写了一个程序,知道了如何编写在GPU上并行执行的代码。但是对于并行编程来说,最重要的一个方面就是,并行执行的各个部分如何通过相互协作来解决问题。只有在极少数情况下,各个处理器才不需要了解其他处理器的执行状态而彼此独立地计算出结果。即使对于一些成熟的算法,也仍然需要在代码的各个并行副本之间进行通信和协作。因此,下面我们来讲讲不同线程之间的通信机制和并行执行线程的同步机制。
首先,我们来看一个线程块的网格示意图:
我们将并行线程块的集合称为线程格(Grid),在上图的Grid中总共有6个线程块(block),每个线程块有12个线程(thread)。
硬件限制:
- 线程块的数量限制为不超过65 535;
- 每个线程块的线程数量限制为不超过512。
解决线程块数量的硬件限制的方法就是将线程块分解为线程。
共享内存
线程协作主要是通过共享内存实现的。CUDA C支持共享内存,我们可以将CUDA C的关键字__share__添加到变量声明中,这将使这个变量驻留在共享内存中。
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
附加知识:
变量类型限定符
__device__
该限定符声明位于设备上的变量。在接下来介绍的其他类型限定符中,最多只能有一种可与__device__限定符一起使用,以更具体地指定变量属于哪个存储器空间。如果未出现其他限定符,则变量具有以下特征:
- 位于全局储存器空间中;
- 与应用程序具有相同的生命周期;
- 可通过网格内的所有线程访问,也可通过运行时库从主机访问。
__constant__
该限定符可选择与__device__限定符一起使用,所声明的变量具有以下特征:
- 位于固定存储器空间中;
- 与应用程序具有相同的生命周期;
- 可通过网格内的所有线程访问,也可通过运行时库从主机访问。
__shared__
该限定符可选择与__device__限定符一起使用,所声明的变量具有以下特征:
- 位于线程块的共享存储器空间中;
- 与块具有相同的生命周期;
- 仅可通过块内的所有线程访问。
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
CUDA C编译器对共享内存中的变量与普通变量将分别采取不同的处理方式。对于GPU上启动的每个线程块,CUDA C编译器都将创建该变量的一个副本。线程块中的每个线程都共享这块内存,但线程却无法看到也不能修改其他线程块的变量副本。这就实现了一种非常好的方式,使得一个线程块中的多个线程能够在计算上进行通信和协作。而且,共享内存缓冲区驻留在物理GPU上,而不是驻留在GPU之外的系统内存中。因此,在访问共享内存时的延迟要远远低于访问普通缓冲区的延迟,使得共享内存像每个线程块的高速缓存或者中间结果暂存器那样高效。
不过,如果想要真正实现线程之间的通信,还需要一种机制来实现线程之间的同步。例如,如果线程A将一个值写入到共享内存中,并且我们希望线程B对这个值进行一些操作,那么只有当线程A的写入操作完成之后,线程B才能执行它的操作。如果没有同步,那么将会发生竞态条件(Race Condition),在这种情况下,代码执行结果的正确性将取决于硬件的不确定性。这种同步方法就是:
__syncthreads()
这个函数调用将确保线程块中的每个线程都执行完__syncthreads()前面的语句后,才会执行下一条语句。
下面,我们通过一个内积运算来加深理解。
Code:
#include "cuda_runtime.h" #include<stdlib.h> #include<stdio.h> #define imin(a,b) (a<b?a:b) #define sum_square(x) (x*(x+1)*(2*x+1)/6) const int N = 33*1024; const int threadsPerBlock = 256; const int blocksPerGrid = imin(32, (N+threadsPerBlock-1)/threadsPerBlock); __global__ void dot_Jere(float *a, float *b, float *c) { __shared__ float cache[threadsPerBlock]; int tid = threadIdx.x + blockIdx.x * blockDim.x; int cacheIndex = threadIdx.x; float temp = 0; while (tid < N) { temp += a[tid] * b[tid]; tid += blockDim.x * gridDim.x; } // 设置cache中相应位置上的值 cache[cacheIndex] = temp; // 对线程块中的线程进行同步 __syncthreads(); // 对于归约运算来说,以下代码要求threadPerBlock必须是2的指数 int i = blockDim.x / 2; while (i != 0) { if (cacheIndex < i) { cache[cacheIndex] += cache[cacheIndex + i]; } __syncthreads(); i /= 2; } if (cacheIndex == 0) { c[blockIdx.x] = cache[0]; } } int main() { float *a, *b, c, *partial_c; float *dev_a, *dev_b, *dev_partial_c; a = (float*)malloc(N*sizeof(float)); b = (float*)malloc(N*sizeof(float)); partial_c = (float*)malloc(blocksPerGrid*sizeof(float)); cudaMalloc((void**)&dev_a, N*sizeof(float)); cudaMalloc((void**)&dev_b, N*sizeof(float)); cudaMalloc((void**)&dev_partial_c, blocksPerGrid*sizeof(float)); for (int i = 0; i < N; i++) { a[i] = i; b[i] = 2*i; } cudaMemcpy(dev_a, a, N*sizeof(float), cudaMemcpyHostToDevice); cudaMemcpy(dev_b, b, N*sizeof(float), cudaMemcpyHostToDevice); dot_Jere<<<blocksPerGrid, threadsPerBlock>>>(dev_a, dev_b, dev_partial_c); cudaMemcpy(partial_c, dev_partial_c, blocksPerGrid*sizeof(float), cudaMemcpyDeviceToHost); c = 0; for (int i = 0; i < blocksPerGrid; i++) { c += partial_c[i]; } printf("Does GPU value %.6g = %.6g?\n", c, 2*sum_square((float)(N-1))); cudaFree(dev_a); cudaFree(dev_b); cudaFree(dev_partial_c); free(a); free(b); free(partial_c); return 0; }结果:
__shared__ float cache[threadsPerBlock];定义一个共享内存 cache[ ],这个共享内存用来保存每个线程计算的乘积值。因为对于共享变量,编译器都将为每个线程块生成共享变量的一个副本,因此我们只需根据线程块中线程的数量来分配内存,即将它的大小设置为threadsPerBlock,这样就可以使线程块中的每个线程都能将它计算的临时结果保存在某个位置上。
在分配了共享内存后,开始计算数据索引:
int tid = threadIdx.x + blockIdx.x * blockDim.x; int cacheIndex = threadIdx.x;这个tid每个线程都不一样,在GPU中线程并行处理,tid表示着相应线程的ID。由上篇博文可知,blockIdx.x表示的是当前线程所在线程块在grid的x方向的索引,而blockDim.x表示线程块的大小。在上面的这个例子中,blockDim.x=256,blockIdx.x和threadIdx.x是变动的。
然后,在while循环中对tid有一个递增:
float temp = 0; while (tid < N) { temp += a[tid] * b[tid]; tid += blockDim.x * gridDim.x; }一开始,我其实对这个tid的递增值有点不太了解,在这个例子中gridDim.x=32,即tid每次递增值为256*32=8192,后来才知道,其实这个递增值和多CPU的并行程序的递增值是一个道理,在多CPU中递增值是CPU的个数。而在这里,这个递增值表示的是当前全部运行的线程数。因为内积的向量长度是33*1024=33792,大于当前运行的线程数,为了能够计算全部的内积,我们就引入while循环,多次运行,直到计算完全部向量对应位置的乘积。
当算法执行到现在后,我们需要对cache内的临时乘积值进行求和,但是这是一种危险的操作,因为我们需要确定所有对共享数组cache[ ]的写入操作在读取cache[]之前完成了。而这正是
// 对线程块中的线程进行同步 __syncthreads();完成的功能。这个函数调用将确保线程块中的每个线程都执行完__syncthreads()前面的语句后,才会执行下一条语句。因此,在__syncthreads()函数下面的归约运算是在所有线程块内的线程都执行完cache写入操作后进行的。
归约运算如下:
// 对于归约运算来说,以下代码要求threadPerBlock必须是2的指数 int i = blockDim.x / 2; while (i != 0) { if (cacheIndex < i) { cache[cacheIndex] += cache[cacheIndex + i]; } __syncthreads(); i /= 2; } if (cacheIndex == 0) { c[blockIdx.x] = cache[0]; }这个归约运算的逻辑比较简单,就是每个线程将cache[]中的两个值相加起来,然后将结果保存回cache[]。由于每个线程都将两个值合并为一个值,那么在完成这个步骤后,得到的结果就是计算开始时数值数量的一半。在下一个步骤中,我们对这一半数值执行相同的操作。
当然,这里面也涉及到了同步问题。在对cache求和的迭代中,下一轮计算的启动必须确保上一轮cache的计算已经完结。因此,我们需要在
if (cacheIndex < i)
{
cache[cacheIndex] += cache[cacheIndex + i];
}
__syncthreads();
i /= 2;
中加入__syncthreads()。
现在,我们来考虑,如果将__syncthreads()放入if{ }内会有什么结果?在上面的代码中,我们只有当cacheIndex小于 i 时才需要更新共享内存cache[ ]。由于cacheIndex实际上就等于threadIdx.x,因而这意味着只有一部分的线程会更新共享内存。那么如果将__syncthreadx()放入if{ }内,即意味着只等待那些需要写入共享内存的线程,那是不是就能获得性能提升?
No,这只会让GPU停止响应。
Why!我们知道,线程块中的每个线程依次通过代码,每次一行。每个线程执行相同的指令,但对不同的数据进行计算。然而,当每个线程执行的指令放在一个条件语句中,这将意味着并不是每个线程都会执行这个指令,这种情况称为线程发散(Thread Divergence),在正常的环境下,发散的分支只会使得某些线程处于空闲状态,而其他线程将执行分支中的代码。但在__syncthread()情况中,线程发散的后果有点糟糕。CUDA架构将确保,除非线程块中的每个线程都执行了__syncthread(),否则没有任何线程能执行__syncthread()之后的指令。而当__syncthread()位于发散分支中,那么一些线程将永远都无法执行__syncthread()。因此,由于要确保在每个线程执行完__syncthread()后才能执行后面的语句,所以硬件将使这些线程保持等待。
最后,main()函数这一块的cuda语法上一篇博文已经讲了,它的逻辑也比较简单,我就不再多说了。
更多资源请 关注博客:LinJM-机器视觉 微博:林建民-机器视觉
CUDA学习日志:线程协作与例程