第 5 章 CUDA
CUDA (Compute Unified Device Architecture) 是Nvidia推出的用于自家显卡的并行计算技术。这里将介绍如何使用CUDA来进行并行运算。
5.1 开始之前
工欲善其事,必先利其器。——《论语》
CUDA需要gcc版本高于5,推荐的版本是9.x。服务器上已经安装了gcc9.3.0,可以直接load
之后,要装载cuda的module
这样,nvcc编译器和各种库就添加到环境变量里面了。
5.2 我的第一个cuda程序
信じる心があなたの魔法!——『リトルウィッチアカデミア』
还是之前的先乘后加。我们把计算过程放到GPU上去做。
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <cuda_runtime.h>
__global__ void muladd(double* a, double* b, double* c, double* d,
unsigned int N){
unsigned int id = blockIdx.x * blockDim.x + threadIdx.x;
unsigned int j;
if(id < N){
for(j = 0; j < 1000000; j++){
d[id] = a[id] * b[id] + c[id];
}
}
}
int main(){
double* a;
double* b;
double* c;
double* d;
double* cua;
double* cub;
double* cuc;
double* cud;
a = (double*)(malloc(8192*sizeof(double)));
b = (double*)(malloc(8192*sizeof(double)));
c = (double*)(malloc(8192*sizeof(double)));
d = (double*)(malloc(8192*sizeof(double)));
cudaMalloc(&cua, 8192*sizeof(double));
cudaMalloc(&cub, 8192*sizeof(double));
cudaMalloc(&cuc, 8192*sizeof(double));
cudaMalloc(&cud, 8192*sizeof(double));
//Prepare data
unsigned long long i;
for(i = 0; i < 8192; i++){
a[i] = (double)(rand()%2000) / 200.0;
b[i] = (double)(rand()%2000) / 200.0;
c[i] = ((double)i)/10000.0;
}
clock_t start, stop;
double elapsed;
start = clock();
cudaMemcpy(cua, a, 8192*sizeof(double), cudaMemcpyHostToDevice);
cudaMemcpy(cub, b, 8192*sizeof(double), cudaMemcpyHostToDevice);
cudaMemcpy(cuc, c, 8192*sizeof(double), cudaMemcpyHostToDevice);
muladd<<<32, 256>>>(cua, cub, cuc, cud, 8192);
cudaMemcpy(d, cud, 8192*sizeof(double), cudaMemcpyDeviceToHost);
stop = clock();
elapsed = (double)(stop-start) / CLOCKS_PER_SEC;
printf("Elapsed time = %8.6f s\n", elapsed);
for(i = 0; i < 8192; i++){
if(i % 1001 == 0){
printf("%5llu: %16.8f * %16.8f + %16.8f = %16.8f (%d)\n",
i, a[i], b[i], c[i], d[i], d[i]==a[i]*b[i]+c[i]);
}
}
free(a);
free(b);
free(c);
free(d);
cudaFree(cua);
cudaFree(cub);
cudaFree(cuc);
cudaFree(cud);
}
编译cuda程序要使用nvcc
编译器。
注意这里的-fmad=false
。nvcc默认是会把相邻的乘法和加法优化成FMA的(关于FMA,参见上一部分最后一章(3))。这里关闭FMA来跟CPU同台竞技,也为了后面比较结果时不会出差错。
运行一下,哇!比1s还短!
(其实nvcc只负责GPU部分,剩下的会交给普通的c++编译器。不好意思,CUDA runtime API是c++的,但是众所周知,c++(基本上)和c是兼容的。所以本质上上面是一个看起来像c的c++程序😋)
我们来解释一下这里都干了些啥
5.2.1 #include <cuda_runtime.h>
大多数我们需要的功能都在 cuda_runtime.h 头文件里提供。要include进来哦!
5.2.2 __global__ void muladd
注意__global__
,这是CUDA对c++的扩展。常见的标签:
__global__
,__device__
,和__host__
- 还有别的不常用的,可以参考cuda runtime文档
__global__
的函数可以被CPU或GPU调用,在GPU上执行。(在GPU上调用__global__
函数似乎是在并行地运行并行程序(就是套娃🤣)。一般还是从CPU调用比较常见。)这种函数被称为“kernel”。
__device__
的函数只可以被GPU调用,在GPU上执行。这种函数一般会被编译器展开到调用处。
__host__
的函数只可以被CPU调用,在CPU上执行。和不加标签一样。
__global__
函数必须是返回值为void
的函数,__device__
和__host__
则无要求。
5.2.3 blockIdx
,blockDim
和threadIdx
一个“kernel”事实上要同时被许多核心执行,那么每个核心就需要知道自己处理的是哪个数据,要是大家抢同一个数据的话就没有意义了。CUDA设计了两级的网格——grid和block。每个grid可以包含若干个block,每个block又包含若干个thread。一个thread将会在一个核心上执行。但是要注意,每个block包含的thread数目不能超过一个最大值(应该是1024)。此外,根据程序使用的寄存器数目,一个block可以包含的thread数目可能会再小一点。(GPU的寄存器还是很多的,一般来说不用担心)此外,每个block里的thread数目最好设为32的倍数,其中原理可以参考后续章节。
这里的blockIdx,blockDim和threadIdx就相当于线程的编号,用于知道自己应该取处理哪些数据。blockIdx就是block在grid中的编号,blockDim是block中thread的数目,threadIdx是thread在block中的编号。
注意到后面的.x
, CUDA允许使用最大三维的block和三维的grid。上面的程序只使用了一个维度,就只需要.x
即可。
下图展示了grid,block和thread的关系。
图 5.1: grid, block和thread
事实上也有gridDim,用来表示grid在各个维度上包含多少block。
5.2.4 cudaMalloc
类似于malloc
,但是是在显存中申请空间。第一个参数要传入一个指针的指针,函数执行完后第一个参数所指向的指针就是显存空间的指针了。第二个参数就是以byte计的空间大小。
5.2.5 cudaMemcpy
用于系统内存和显存之间进行数据拷贝。第一个参数是目标指针,第二个参数是源头指针,第三个参数是要拷贝的内容的大小(byte单位),第四个参数设置拷贝方向。
为什么要有第四个参数呢?其实指针类型相当于是整数,并没有标识这是系统内存的指针还是显存的指针,所以需要第四个参数说明拷贝方向。
第四个参数有一下几种可选值:
cudaMemcpyHostToHost
cudaMemcpyHostToDevice
cudaMemcpyDeviceToHost
cudaMemcpyDeviceToDevice
具体作用从名字上看就很明显了。
5.2.6 <<<32, 256>>>
三重尖括号也是CUDA对C++的扩展,用来表示以何种方式组织grid和block。原则上<<<>>>
里面应该接受两个dim3类型的量。
dim3是基于uint3的类型,uint3是CUDA定义的一种矢量类型,顾名思义,就是三个unsigned int放在一起。(事实上,就是一块12字节的连续空间而已)。定义dim3变量时,未指定的维度上的数会被设置为1.
所以上面的<<<>>>
里面直接写两个数字其实代表了x维度,剩下两个维度自动设置成1了。
里面第一个参数是对grid的描述,第二个是对block的描述。比如要使用如图6.1的结构运行一个叫做myKernel的__global__
函数:(假设该函数不需要参数)
5.2.7 cudaFree
cudaFree
是用来释放显存的,和c的free
类似。
及时释放使用完的内存是好习惯哦😀
5.3 CUDA新增类型
Be not afeard. The isle is full of noises, sounds and sweet airs, that give delight and hurt not.——The Tempest
除了上面提到的dim3
之外,CUDA还提供了其他一些矢量类型,如double2,double3,double4,float2,float3,float4还有char/short/int/long/long long及其无符号系列。具体可以参考cuda vector types。但是,这些矢量类型并不支持SIMD,甚至没有实现矢量加减法,可以把它们看成只是为了方便表示矢量而定义的类型,实际使用的时候还需自己拿每个分量去做计算。
另外,CUDA还支持一种半精度浮点数,每个16bit(也有相应的矢量类型)。(关于计算机里的浮点数表示可以参考附录)
这种半精度类型可能在机器学习领域比较有用。根据CUDA的文档,使用半精度的话建议用half2
这种矢量类型(一个half2
是一个两维的half
矢量),因为half2
可以用一些特别的函数(如__hadd2
(矢量加法)之类的,还有乘法、除法等等)在一个指令中做两个加法。这可能是CUDA唯一的SIMD操作吧。
5.4 使用__device__
函数
不在其位,不谋其政——《论语》
一般我们写一个程序都包含许多函数,它们调用来调用去,组合成我们想要的样子。使用函数的好处在于重复的代码可以只写一次。而且当这部分功能需要改变的时候只要修改函数内容就可以了。
在CUDA里我们也可以这么做。下面我们分别实现一个乘法函数和一个加法函数,让kernel调用它们完成计算。
__device__ double myAdd(double a, double b){
return a+b;
}
__device__ void myMul(double* a, double* b, double* c){
*c = *a + *b;
}
__global__ void muladd(double* a, double* b, double* c, double* d,
unsigned long long N){
unsigned long long id = blockIdx.x * blockDim.x + threadIdx.x;
unsigned long long j;
if(id < N){
for(j = 0; j < 1000000; j++){
myMul(a+i, b+i, d+i);
d[i] = myAdd(c[i], d[i]);
}
}
}
这里的myAdd
和myMul
采用了不同的写法。myAdd
接受两个double参数,返回一个double(如前文所述,__device__
函数并不要求void返回值)。而myMul
则是另一种风格,直接接受三个指向double的指针,把第三个指针指向的double设为前两个指针指向的double的和。
myAdd
和一般的函数设计思路是一致的。但是当你想要从一组参数获得多种返回值的时候,myMul
的设计思路可能会变得非常有用。当然,也可以使用引用传递来返回值:
__device__
函数也支持递归,但我想不到什么需要并行且递归的运算。
5.6 本章小结
感觉怎么样?有点似懂非懂?要不尝试翻回来再看看代码,自己改一改试一试,有没有觉得稍微明白一点了?可以试着改一改thread和block的结构,看看运行效率有怎样的变化。
还有要注意一点,这里程序比较简单,所以完全没有安排错误处理。cudaMalloc
,cudaMemcpy
等函数都是有返回值的,会返回一个cudaError_t
的类型。大家完全可以用auto
类型来接收返回值。cudaError_t
是一个枚举类型,其实跟int没太大区别。当返回值是cudaSuccess
(这个等于0,可以直接把返回值和0作比较判断有没有error)的时候说明成功了,没有错误。其他各种错误可以参考cudaRuntimeAPI
此外,kernel函数也有可能运行不成功,比如因为占用寄存器数量过多且每个block里的线程数比较多,可能会导致kernel没有成功执行。要处理这种错误,可以这样做:
some_kernel<<<10,1024>>();
auto err = cudaGetLastError();
if(err != cudaSuccess){
//handle the error
}
喜闻乐见的自问自答环节!
问:为什么thread的数量是32的倍数比较好?
事实上,CUDA搞的是一种SIMT模型(single instruction multiple threads),也就是说,一些线程是共享一条指令的。目前的所有支持CUDA的显卡都是以32个线程为一组(称作一个线程束(warp)),它们共同接受同一个指令并进行计算。如果一个block里的thread数目不是32的倍数,假设是48,那么显卡会把这48个线程分成两组,一组32线程,一组16线程。在执行16线程的一组的时候,只有16个线程在干活,另外16个线程只能站在干岸上看着,形成一方有难八方围观之势。为什么这16个线程不能干点别的呢?因为硬件上就是把32个线程作为一组的来调度的。
基于同样的原因,kernel里面要尽量避免不必要的条件判断。因为每次发送指令,接受指令的线程都要干一样的事情,所以在条件语句中,是一个分支的线程执行完,再另一个分支的线程执行。如果两个分支的跳转概率一样,可能就会使得性能减半。
问:三维grid和三维block意义何在?
事实上,grid和block的维度对于计算机来说并没有太大的意义,但是对于人来说还是有意义的。使用什么样的grid和block结构取决于数据的“样子”。
就像一维数组和三维数组一样,原则上,用多维数组处理的问题都可以转化到用一维数组来处理。
比如10*10的数组,访问起来是
array2[i][j]
换成一维数组呢,就可以这样访问:array[i*10+j]
问:二维和三维的block里面thread数量是怎么限制的?
不考虑寄存器数量限制的话:
首先,一个block里面不能有超过1024个线程。其次,在每个维度上都有一个最大线程数。只有同时满足以上限制才可以。(第一个维度,也就是x的最大值肯定是1024,这保证了一维的block可以用满线程数限制)。
具体的最大值,还有好多其他参数,包括寄存器数量等等都有相应的函数可以查询。具体可以参考cuda的sample里面的1_Utilities/deviceQuery
里面的程序以及运行结果。
问:我是使用printf法来debug的那种选手怎么办?
没关系,kernel函数里可以使用printf。但是要注意,在kernel里用printf的时候不要把grid和block设置得太大,否则你会得到铺天盖地的打印信息。(也可以在kernel里面判断,只有在特定block的特定thread打印信息也是可行的)
问:很好,那么这到底有什么用呢?
答:正常小朋友一般问不出来这种问题。
再补充一个信息(可能管理员会发现比较有用)。可以使用nvidia-smi
查看GPU的工作情况。这个运行一下只会显示一次信息。如果相要持续显示信息,可以使用-l
或者-lms
选项。具体见nvidia-smi -h
输出的帮助信息。