一
首先看一下CPU和GPU的微架构和计算能力对比。例如我的笔记本lenovo Y480,4核CPU,NVIDIA GT650M显卡384个CUDA核。
计算能力对比:
CPU: 4 * 2.5=10GFLOPS
GPU: 384 * 0.88= 337.92GFLOPS
显卡计算性能是4核i5 CPU的33.792倍,因此我们可以充分利用这一资源来对一些耗时的应用进行加速。
二
GPU的设计初衷是为了加速应用程序中的图形绘制运算,用于在显示器上渲染计算机图形。因此开发人员需要通过OpenGL或者DirectX等API来访问GPU,这不仅要求开发人员掌握一定的图形编程知识,而且要想方设法将通用计算问题转换为图形计算问题。其次,GPU与多核CPU在计算架构上有着很大不同,GPU更注重于数据并行计算,即在不同的数据上并行执行相同的计算,而对并行计算中的互斥性、同步性以及原子性等方面支持不足。这些因素都限制了GPU在通用并行计算中的应用范围。
CUDA架构的出现解决了上述问题。CUDA架构专门为GPU计算设计了一种全新的结构,目的正是为了减轻GPU计算模型中的这些限制。在CUDA架构下,开发人员可以通过CUDA C对GPU编程。CUDA C/C++是对标准C/C++的一种简单扩展,学习和使用起来都非常容易,并且其最大的优势在于不需要开发人员具备计算机图形学知识。CUDA架构用于实现GPU设备的通用计算应用,具有高并行性、高带宽、性价比高、易于编程等优势。CUDA具有如上所述的诸多优势,但是,其仍然有许多局限性,例如,CUDA对多分支和并行化低的应用程序执行效率不高,不适合整型数据运算,对处理的任务的细分程度及方式直接影响到CUDA应用程序的性能,。
CUDA是一种专门为提高并行程序开发效率而设计的计算架构与编程模型,是一个完整的GPGPU解决方案,提供了硬件的直接访问接口,而不必像传统方式一样必须依赖图形API接口来实现GPU的访问。在构建高性能应用程序时,CUDA架构能充分发挥GPU的强大计算功能,在NVIDIA GPU上编写程序来完成通用计算任务。
CUDA包含了CUDA指令集架构(ISA)以及GPU内部的并行计算引擎。CUDA 的核心有三个重要抽象概念: 线程组层次结构、共享存储器、屏蔽同步,可轻松将其作为C 语言的最小扩展级公开给程序员。CUDA 软件堆栈由几层组成,一个硬件驱动程序,一个应用程序编程接口(API)和它的Runtime, 还有二个高级的通用数学库,CUFFT 和CUBLAS。硬件被设计成支持轻量级的驱动和Runtime 层面,因而提高性能。从CUDA体系结构的组成来说,包含了三个部分:开发库、运行期环境和驱动。
开发库是基于CUDA技术所提供的应用开发库。目前CUDA提供了两个标准的数学运算库——CUFFT(离散快速傅立叶变换)和CUBLAS(离散基本线性计算)的实现。这两个数学运算库所解决的是典型的大规模的并行计算问题,也是在密集数据计算中非常常见的计算类型。开发人员在开发库的基础上可以快速、方便的建立起自己的计算应用。
运行期环境提供了应用开发接口和运行期组件,包括基本数据类型的定义和各类计算、类型转换、内存管理、设备访问和执行调度等函数。基于CUDA开发的程序代码在实际执行中分为两种,一种是运行在CPU上的宿主代码(Host Code),一种是运行在GPU上的设备代码(Device Code)。不同类型的代码由于其运行的物理位置不同,能够访问到的资源不同,因此对应的运行期组件也分为公共组件、宿主组件和设备组件三个部分,基本上囊括了所有在GPGPU开发中所需要的功能和能够使用到的资源接口,开发人员可以通过运行期环境的编程接口实现各种类型的计算。
由于目前存在着多种GPU版本的NVidia显卡,不同版本的GPU之间都有不同的差异,因此驱动部分基本上可以理解为是CUDA-enable的GPU的设备抽象层,提供硬件设备的抽象访问接口。CUDA提供运行期环境也是通过这一层来实现各种功能的。目前基于CUDA开发的应用必须有NVIDIA CUDA-enable的硬件支持。
三
主要概念与名称
主机
将CPU及系统的内存(内存条)称为主机。
设备
将GPU及GPU本身的显示内存称为设备。
线程(Thread)
一般通过GPU的一个核进行处理。(可以表示成一维,二维,三维)。
线程块(Block)
1. 由多个线程组成(可以表示成一维,二维,三维)。
2. 各block是并行执行的,block间无法通信,也没有执行顺序。
3. 注意线程块的数量限制为不超过65535(硬件限制)。
线程格(Grid)
由多个线程块组成(可以表示成一维,二维,三维)。
线程束
在CUDA架构中,线程束是指一个包含32个线程的集合,这个线程集合被“编织在一起”并且“步调一致”的形式执行。在程序中的每一行,线程束中的每个线程都将在不同数据上执行相同的命令。
核函数(Kernel)
1. 在GPU上执行的函数通常称为核函数。
2. 一般通过标识符__global__修饰,调用通过<<<参数1,参数2>>>,用于说明内核函数中的线程数量,以及线程是如何组织的。
3. 以线程格(Grid)的形式组织,每个线程格由若干个线程块(block)组成,而每个线程块又由若干个线程(thread)组成。
4. 是以block为单位执行的。
5. 只能在主机端代码中调用。
6. 调用时必须声明内核函数的执行参数。
7. 在编程时,必须先为kernel函数中用到的数组或变量分配好足够的空间,再调用kernel函数,否则在GPU计算时会发生错误,例如越界或报错,甚至导致蓝屏和死机。
dim3结构类型
1. dim3是基亍uint3定义的矢量类型,相当亍由3个unsigned int型组成的结构体。uint3类型有三个数据成员unsigned int x; unsigned int y; unsigned int z;
2. 可使用亍一维、二维或三维的索引来标识线程,构成一维、二维或三维线程块。
3. dim3结构类型变量用在核函数调用的<<<,>>>中。
4. 相关的几个内置变量
4.1. threadIdx,顾名思义获取线程thread的ID索引;如果线程是一维的那么就取threadIdx.x,二维的还可以多取到一个值threadIdx.y,以此类推到三维threadIdx.z。
4.2. blockIdx,线程块的ID索引;同样有blockIdx.x,blockIdx.y,blockIdx.z。
4.3. blockDim,线程块的维度,同样有blockDim.x,blockDim.y,blockDim.z。
4.4. gridDim,线程格的维度,同样有gridDim.x,gridDim.y,gridDim.z。
5. 对于一维的block,线程的threadID=threadIdx.x。
6. 对于大小为(blockDim.x, blockDim.y)的 二维 block,线程的threadID=threadIdx.x+threadIdx.y*blockDim.x。
7. 对于大小为(blockDim.x, blockDim.y, blockDim.z)的 三维 block,线程的threadID=threadIdx.x+threadIdx.y*blockDim.x+threadIdx.z*blockDim.x*blockDim.y。
8. 对于计算线程索引偏移增量为已启动线程的总数。如stride = blockDim.x * gridDim.x; threadId += stride。
函数修饰符
1. __global__,表明被修饰的函数在设备上执行,但在主机上调用。
2. __device__,表明被修饰的函数在设备上执行,但只能在其他__device__函数或者__global__函数中调用。
常用的GPU内存函数
cudaMalloc()
1. 函数原型: cudaError_t cudaMalloc (void **devPtr, size_t size)。
2. 函数用处:与C语言中的malloc函数一样,只是此函数在GPU的内存你分配内存。
3. 注意事项:
3.1. 可以将cudaMalloc()分配的指针传递给在设备上执行的函数;
3.2. 可以在设备代码中使用cudaMalloc()分配的指针进行设备内存读写操作;
3.3. 可以将cudaMalloc()分配的指针传递给在主机上执行的函数;
3.4. 不可以在主机代码中使用cudaMalloc()分配的指针进行主机内存读写操作(即不能进行解引用)。
cudaMemcpy()
1. 函数原型:cudaError_t cudaMemcpy (void *dst, const void *src, size_t count, cudaMemcpyKind kind)。
2. 函数作用:与c语言中的memcpy函数一样,只是此函数可以在主机内存和GPU内存之间互相拷贝数据。
3. 函数参数:cudaMemcpyKind kind表示数据拷贝方向,如果kind赋值为cudaMemcpyDeviceToHost表示数据从设备内存拷贝到主机内存。
4. 与C中的memcpy()一样,以同步方式执行,即当函数返回时,复制操作就已经完成了,并且在输出缓冲区中包含了复制进去的内容。
5. 相应的有个异步方式执行的函数cudaMemcpyAsync(),这个函数详解请看下面的流一节有关内容。
cudaFree()
1. 函数原型:cudaError_t cudaFree ( void* devPtr )。
2. 函数作用:与c语言中的free()函数一样,只是此函数释放的是cudaMalloc()分配的内存。
GPU内存分类
全局内存
通俗意义上的设备内存。
共享内存
1. 位置:设备内存。
2. 形式:关键字__shared__添加到变量声明中。如__shared__ float cache[10]。
3. 目的:对于GPU上启动的每个线程块,CUDA C编译器都将创建该共享变量的一个副本。线程块中的每个线程都共享这块内存,但线程却无法看到也不能修改其他线程块的变量副本。这样使得一个线程块中的多个线程能够在计算上通信和协作。
常量内存
1. 位置:设备内存
2. 形式:关键字__constant__添加到变量声明中。如__constant__ float s[10];。
3. 目的:为了提升性能。常量内存采取了不同于标准全局内存的处理方式。在某些情况下,用常量内存替换全局内存能有效地减少内存带宽。
4. 特点:常量内存用于保存在核函数执行期间不会发生变化的数据。变量的访问限制为只读。NVIDIA硬件提供了64KB的常量内存。不再需要cudaMalloc()或者cudaFree(),而是在编译时,静态地分配空间。
5. 要求:当我们需要拷贝数据到常量内存中应该使用cudaMemcpyToSymbol(),而cudaMemcpy()会复制到全局内存。
6. 性能提升的原因:
6.1. 对常量内存的单次读操作可以广播到其他的“邻近”线程。这将节约15次读取操作。(为什么是15,因为“邻近”指半个线程束,一个线程束包含32个线程的集合。)
6.2. 常量内存的数据将缓存起来,因此对相同地址的连续读操作将不会产生额外的内存通信量。
纹理内存
1. 位置:设备内存
2. 目的:能够减少对内存的请求并提供高效的内存带宽。是专门为那些在内存访问模式中存在大量空间局部性的图形应用程序设计,意味着一个线程读取的位置可能与邻近线程读取的位置“非常接近”。如下图:
3. 纹理变量(引用)必须声明为文件作用域内的全局变量。
4. 形式:分为一维纹理内存 和 二维纹理内存。
4.1. 一维纹理内存
4.1.1. 用texture<类型>类型声明,如texture<float> texIn。
4.1.2. 通过cudaBindTexture()绑定到纹理内存中。
4.1.3. 通过tex1Dfetch()来读取纹理内存中的数据。
4.1.4. 通过cudaUnbindTexture()取消绑定纹理内存。
4.2. 二维纹理内存
4.2.1. 用texture<类型,数字>类型声明,如texture<float,2> texIn。
4.2.2. 通过cudaBindTexture2D()绑定到纹理内存中。
4.2.3. 通过tex2D()来读取纹理内存中的数据。
4.2.4. 通过cudaUnbindTexture()取消绑定纹理内存。
固定内存
1. 位置:主机内存。
2. 概念:也称为页锁定内存或者不可分页内存,操作系统将不会对这块内存分页并交换到磁盘上,从而确保了该内存始终驻留在物理内存中。因此操作系统能够安全地使某个应用程序访问该内存的物理地址,因为这块内存将不会破坏或者重新定位。
3. 目的:提高访问速度。由于GPU知道主机内存的物理地址,因此可以通过“直接内存访问DMA(Direct Memory Access)技术来在GPU和主机之间复制数据。由于DMA在执行复制时无需CPU介入。因此DMA复制过程中使用固定内存是非常重要的。
4. 缺点:使用固定内存,将失去虚拟内存的所有功能;系统将更快的耗尽内存。
5. 建议:对cudaMemcpy()函数调用中的源内存或者目标内存,才使用固定内存,并且在不再需要使用它们时立即释放。
6. 形式:通过cudaHostAlloc()函数来分配;通过cudaFreeHost()释放。
7. 只能以异步方式对固定内存进行复制操作。
原子性
1. 概念:如果操作的执行过程不能分解为更小的部分,我们将满足这种条件限制的操作称为原子操作。
2. 形式:函数调用,如atomicAdd(addr,y)将生成一个原子的操作序列,这个操作序列包括读取地址addr处的值,将y增加到这个值,以及将结果保存回地址addr。
常用线程操作函数
1. 同步方法__syncthreads(),这个函数的调用,将确保线程块中的每个线程都执行完__syscthreads()前面的语句后,才会执行下一条语句。
使用事件来测量性能
1. 用途:为了测量GPU在某个任务上花费的时间。CUDA中的事件本质上是一个GPU时间戳。由于事件是直接在GPU上实现的。因此不适用于对同时包含设备代码和主机代码的混合代码设计。
2. 形式:首先创建一个事件,然后记录事件,再计算两个事件之差,最后销毁事件。
流
1. 并发重点在于一个极短时间段内运行多个不同的任务;并行重点在于同时运行一个任务。
2. 任务并行性:是指并行执行两个或多个不同的任务,而不是在大量数据上执行同一个任务。
3. 概念:CUDA流表示一个GPU操作队列,并且该队列中的操作将以指定的顺序执行。我们可以在流中添加一些操作,如核函数启动,内存复制以及事件的启动和结束等。这些操作的添加到流的顺序也是它们的执行顺序。可以将每个流视为GPU上的一个任务,并且这些任务可以并行执行。
4. 硬件前提:必须是支持设备重叠功能的GPU。支持设备重叠功能,即在执行一个核函数的同时,还能在设备与主机之间执行复制操作。
5. 声明与创建:声明cudaStream_t stream;,创建cudaSteamCreate(&stream);。
6. cudaMemcpyAsync():前面在cudaMemcpy()中提到过,这是一个以异步方式执行的函数。在调用cudaMemcpyAsync()时,只是放置一个请求,表示在流中执行一次内存复制操作,这个流是通过参数stream来指定的。当函数返回时,我们无法确保复制操作是否已经启动,更无法保证它是否已经结束。我们能够得到的保证是,复制操作肯定会当下一个被放入流中的操作之前执行。传递给此函数的主机内存指针必须是通过cudaHostAlloc()分配好的内存。(流中要求固定内存)
7. 流同步:通过cudaStreamSynchronize()来协调。
8. 流销毁:在退出应用程序之前,需要销毁对GPU操作进行排队的流,调用cudaStreamDestroy()。
9. 针对多个流:
9.1. 记得对流进行同步操作。
9.2. 将操作放入流的队列时,应采用宽度优先方式,而非深度优先的方式,换句话说,不是首先添加第0个流的所有操作,再依次添加后面的第1,2,…个流。而是交替进行添加,比如将a的复制操作添加到第0个流中,接着把a的复制操作添加到第1个流中,再继续其他的类似交替添加的行为。
9.3. 要牢牢记住操作放入流中的队列中的顺序影响到CUDA驱动程序调度这些操作和流以及执行的方式。
技巧
1. 当线程块的数量为GPU中处理数量的2倍时,将达到最优性能。
2. 核函数执行的第一个计算就是计算输入数据的偏移。每个线程的起始偏移都是0到线程数量减1之间的某个值。然后,对偏移的增量为已启动线程的总数。
四
线程并行将线程的概念引申到CUDA程序设计中,我们可以认为线程就是执行CUDA程序的最小单元,在GPU上每个线程都会运行一次该核函数。但GPU上的线程调度方式与CPU有很大不同。CPU上会有优先级分配,从高到低,同样优先级的可以采用时间片轮转法实现线程调度。GPU上线程没有优先级概念,所有线程机会均等,线程状态只有等待资源和执行两种状态,如果资源未就绪,那么就等待;一旦就绪,立即执行。当GPU资源很充裕时,所有线程都是并发执行的,这样加速效果很接近理论加速比;而GPU资源少于总线程个数时,有一部分线程就会等待前面执行的线程释放资源,从而变为串行化执行。
块并行 块并行相当于操作系统中多进程的情况,CUDA有线程组(线程块)的概念,将一组线程组织到一起,共同分配一部分资源,然后内部调度执行。线程块与线程块之间,毫无瓜葛。这有利于做更粗粒度的并行。我们的任务有时可以采用分治法,将一个大问题分解为几个小规模问题,将这些小规模问题分别用一个线程块实现,线程块内可以采用细粒度的线程并行,而块之间为粗粒度并行,这样可以充分利用硬件资源,降低线程并行的计算复杂度。
多个线程块组织成了一个Grid,称为线程格(经历了从一位线程,二维线程块到三维线程格的过程)
流并行流可以实现在一个设备上运行多个核函数。前面的块并行也好,线程并行也好,运行的核函数都是相同的(代码一样,传递参数也一样)。而流并行,可以执行不同的核函数,也可以实现对同一个核函数传递不同的参数,实现任务级别的并行。
CUDA中的流用cudaStream_t类型实现,用到的API有以下几个:cudaStreamCreate(cudaStream_t * s)用于创建流,cudaStreamDestroy(cudaStream_t s)用于销毁流,cudaStreamSynchronize()用于单个流同步,cudaDeviceSynchronize()用于整个设备上的所有流同步,cudaStreamQuery()用于查询一个流的任务是否已经完成。
前面介绍了三种利用GPU实现并行处理的方式:线程并行,块并行和流并行。在这些方法中,各个线程所进行的处理是互不相关的,即两个线程不回产生交集,每个线程都只关注自己的一亩三分地,对其他线程毫无兴趣,就当不存在。。。。
当然,实际应用中,这样的例子太少了,也就是遇到向量相加、向量对应点乘这类才会有如此高的并行度,而其他一些应用,如一组数求和,求最大(小)值,各个线程不再是相互独立的,而是产生一定关联,线程2可能会用到线程1的结果,这时就需要利用线程通信技术了。
线程通信在CUDA中有三种实现方式:
1. 共享存储器;
2. 线程 同步;
3. 原子操作;
最常用的是前两种方式,共享存储器,术语Shared Memory,是位于SM中的特殊存储器。还记得SM吗,就是流多处理器,相当于大核。一个SM中不仅包含若干个SP(流处理器,小核),还包括一部分高速Cache,寄存器组,共享内存等,结构如图所示:
从图中可看出,一个SM内有M个SP,Shared Memory由这M个SP共同占有。另外指令单元也被这M个SP共享,即SIMT架构(单指令多线程架构),一个SM中所有SP在同一时间执行同一代码。
为了实现线程通信,仅仅靠共享内存还不够,需要有同步机制才能使线程之间实现有序处理。通常情况是这样:当线程A需要线程B计算的结果作为输入时,需要确保线程B已经将结果写入共享内存中,然后线程A再从共享内存中读出。同步必不可少,否则,线程A可能读到的是无效的结果,造成计算错误。同步机制可以用CUDA内置函数:__syncthreads();当某个线程执行到该函数时,进入等待状态,直到同一线程块(Block)中所有线程都执行到这个函数为止,即一个__syncthreads()相当于一个线程同步点,确保一个Block中所有线程都达到同步,然后线程进入运行状态。
注意的是,位于同一个Block中的线程才能实现通信,不同Block中的线程不能通过共享内存、同步进行通信,而应采用原子操作或主机介入。
五
sp: 最基本的处理单元,streaming processor 最后具体的指令和任务都是在sp上处理。GPU进行并行计算,也就是很多个sp同时做处理。
sm:多个sp加上其他的一些资源组成一个sm, streaming multiprocessor. 其他资源也就是存储资源,共享内存,寄储器等。
warp:GPU执行程序时的调度单位,目前cuda的warp的大小为32,同在一个warp的线程,以不同数据资源执行相同的指令。
grid、block、thread:在利用cuda进行编程时,一个grid分为多个block,而一个block分为多个thread.其中任务划分到是否影响最后的执行效果。划分的依据是任务特性和GPU本身的硬件特性。
在CUDA架构下,线程的最小单元是thread,多个thread组成一个block,多个block再组成一个grid,不同block之间的thread不能读写同一shared memory共享内存,因此,block里面的thread之间的通信和同步所带来的开销是比较大的。SM以 32 个 Thread 为一组的 Warp 来执行 Thread。Warp内的线程是静态的,即在属于同一个warp内的thread之间进行通信,不需要进行栅栏同步(barrier)。Fermi的设计根据G80和GT200的架构作出的很多缺陷来改变。在Fermi中,每个SM中的数量不再是GT200的8个SP,而是变成了32个SP,NVIDIA现在又称之为CUDA Core,总共具有16个SM,所以总共有512个SP。而在GT200中,是30个SM,240个SP。
此图反应了warp作为调度单位的作用,每次GPU调度一个warp里的32个线程执行同一条指令,其中各个线程对应的数据资源不同。
一个sm只会执行一个block里的warp,当该block里warp执行完才会执行其他block里的warp。
进行划分时,最好保证每个block里的warp比较合理,那样可以一个sm可以交替执行里面的warp,从而提高效率,此外,在分配block时,要根据GPU的sm个数,分配出合理的block数,让GPU的sm都利用起来,提利用率。分配时,也要考虑到同一个线程block的资源问题,不要出现对应的资源不够。
六
随着多核CPU和众核GPU时代的到来,并行编程已经得到了业界越来越多的重视,CPU+GPU异构程序能够极大提高现有计算机系统的运算性能,对于科学计算等运算密集型程序有着非常重要的意义。
对CUDA C的简单理解:如果粗暴的认为C语言工作的对象是CPU和内存条(称为主机内存),那么CUDA C工作的的对象就是GPU及GPU上的内存(称为设备内存),且充分利用了GPU多核的优势及降低了并行编程的难度。一般通过C语言把数据从外界读入,再分配数据,给CUDA C,以便在GPU上计算,然后再把计算结果返回给C语言,以便进一步工作,如进一步处理及显示,或重复此过程。
并行编程的中心思想是分而治之:将大问题划分为一些小问题,再把这些小问题交给相应的处理单元并行地进行处理。在CUDA中,这一思想便体现在它的具有两个层次的问题划分模型。一个问题可以首先被粗粒度地划分为若干较小的子问题,CUDA使用被称为块(Block)的单元来处理它们,每个块都由一些CUDA线程组成,线程是CUDA中最小的处理单元,将这些较小的子问题进一步划分为若干更小的细粒度的问题,我们便可以使用线程来解决这些问题了。对于一个普通的NVIDIA GPU,其CUDA线程数目通常能达到数千个甚至更多,因此,这样的问题划分模型便可以成倍地提升计算机的运算性能。
异构程序设计跟传统的串行程序设计差别是很大的,学习起来也是非常不容易的。NVIDIA非常够意思,为了简化CUDA的学习曲线,它采用了绝大多数程序员都熟悉的C语言作为其根基,CUDA C是NVIDIA为程序员提供的一类编程接口,它实际上是一个C语言的扩展,在C的基础上增加了一些新的语法和变量,并且提供了功能丰富的库函数,方便程序员使用GPU进行异构计算。
除了前面章节提到的CUDA最基本、最核心的概念以外,CUDA C呈现给程序员的接口主要由两大类API构成,它们分别是CUDA Runtime API和CUDA Driver API,Runtime API实际上是对于Driver API的封装,其目的自然是方便程序员的代码编写工作。Driver API为用户提供了更细一层的控制手段,通过它可以控制诸如CUDA Contexts(一种类似主机进程的概念)以及CUDA Modules(类似主机动态加载库的概念)等更加底层的CUDA模块。
CUDA程序组成
一个.cu文件内既包含CPU程序(称为主机程序),也包含GPU程序(称为设备程序)。如何区分主机程序和设备程序?根据声明,凡是挂有“__global__”或者“__device__”前缀的函数,都是在GPU上运行的设备程序,不同的是__global__设备程序可被主机程序调用,而__device__设备程序则只能被设备程序调用。
没有挂任何前缀的函数,都是主机程序。主机程序显示声明可以用__host__前缀。设备程序需要由NVCC进行编译,而主机程序只需要由主机编译器(如Linux上的GCC)。主机程序主要完成设备环境初始化,数据传输等必备过程,设备程序只负责计算。
主机程序中,有一些“cuda”打头的函数,这些都是CUDA Runtime API,即运行时函数,主要负责完成设备的初始化、内存分配、内存拷贝等任务。函数cudaGetDeviceCount(),cudaGetDeviceProperties(),cudaSetDevice()等都是运行时API。
任何一种程序设计语言都需要相应的编译器将其编译为二进制代码,进而在目标机器上得到执行。对于异构计算而言,这一过程与传统程序设计语言是有一些区别的。为什么?因为CUDA它本质上不是一种语言,而是一种异构计算的编程模型,使用CUDA C写出的代码需要在两种体系结构完全不同的设备上执行:1、CPU;2、GPU。因此,CUDA C的编译器所做的工作就有点略多了。一方面,它需要将源代码中运行在GPU端的代码编译得到能在CUDA设备上运行的二进制程序。另一方面,它也需要将源代码中运行在CPU端的程序编译得到能在主机CPU上运行的二进制程序。最后,它需要把这两部分有机地结合起来,使得两部分代码能够协调运行。
CUDA C为我们提供了这样的编译器,它便是NVCC。严格意义上来讲,NVCC并不能称作编译器,NVIDIA称其为编译器驱动(Compiler Driver),本节我们暂且使用编译器来描述NVCC。使用nvcc命令行工具我们可以简化CUDA程序的编译过程,NVCC编译器的工作过程主要可以划分为两个阶段:离线编译(Offline Compilation)和即时编译(Just-in-Time Compilation)。
离线编译(Offline Compilation)
下面这幅图简单说明了离线编译的过程:
在CUDA源代码中,既包含在GPU设备上执行的代码,也包括在主机CPU上执行的代码。因此,NVCC的第一步工作便是将二者分离开来,这一过程结束之后:
1. 运行于设备端的代码将被NVCC工具编译为PTX代码(GPU的汇编代码)或者cubin对象(二进制GPU代码);
2. 运行于主机端的代码将被NVCC工具改写,将其中的内核启动语法(如<<<...>>>)改写为一系列的CUDA Runtime函数,并利用外部编译工具(gcc for linux,或者vc compiler for windows)来编译这部分代码,以得到运行于CPU上的可执行程序。
完事之后,NVCC将自动把输出的两个二进制文件链接起来,得到异构程序的二进制代码。
即时编译(Just-in-time Compile)
任何在运行时被CUDA程序加载的PTX代码都会被显卡的驱动程序进一步编译成设备相关的二进制可执行代码。这一过程被称作即时编译(just-in-time compilation)。即时编译增加了程序的装载时间,但是也使得编译好的程序可以从新的显卡驱动中获得性能提升。同时到目前为止,这一方法是保证编译好的程序在还未问世的GPU上运行的唯一解决方案。
在即时编译的过程中,显卡驱动将会自动缓存PTX代码的编译结果,以避免多次调用同一程序带来的重复编译开销。NVIDIA把这部分缓存称作计算缓存(compute cache),当显卡驱动升级时,这部分缓存将会自动清空,以使得程序能够自动获得新驱动为即时编译过程带来的性能提升。
七
至于CUDA编程,在深入理解了GPU并行工作方式,以及CUDA架构的线程组织方式之后,再来学习.cu并行异构的CUDA代码应该不会太难。CUDA程序和C/C++程序并无太大区别,只是多了一些以"cuda"开头的一些库函数和一个特殊声明的函数。
对于CPU和GPU异构问题,简单来说就是通过cuda_runtime.h头文件调用CUDA提供的运行时API来实现CPU与GPU的互操作:
cudaSetDevice():选择设备(GPU)。
cudaMalloc():动态分配显存。
cudaMemcpy():设备与主机之内的数据拷贝。
cudaThreadSynchronize():同步所有设备上的线程,等待所有线程结束。
cudaFree():释放由cudaMalloc分配的显存。
cudaThreadExit():结束CUDA上下文环境,释放其中的资源。
对于GPU并行运算加速来说,就是通过定义在GPU上运行的函数,也称作核函数,并调用device_launch_parameters.h头文件包含的内核函数的并行计算参数 threadIdx、blockDim、blockIdx、gridDim和wrapSize,来实现线程并行、块并行、流并行。
如定义一个简单的向量加内核函数定义内核函数
__global__ void addKernel(int *c, const int *a, const int *b)
{
int i = threadIdx.x;
c[i] = a[i] + b[i];
}
1. 一个简单的线程并行完整CUDA程序#include "cuda_runtime.h" //CUDA运行时API
2. #include "device_launch_parameters.h" //CUDAgpu启动参数
3. #include <stdio.h>
4. cudaError_t addWithCuda(int *c, const int *a, const int *b, size_t size);
5. __global__ void addKernel(int *c, const int *a, const int *b) //核函数定义
6. {
7. int i = threadIdx.x;
8. c[i] = a[i] + b[i];
9. }
10. int main()
11. {
12. const int arraySize = 5;
13. const int a[arraySize] = { 1, 2, 3, 4, 5 };
14. const int b[arraySize] = { 10, 20, 30, 40, 50 };
15. int c[arraySize] = { 0 };
16. // Add vectors in parallel.
17. cudaError_t cudaStatus;
18. int num = 0;
19. cudaDeviceProp prop;
20. cudaStatus = cudaGetDeviceCount(&num);
21. for(int i = 0;i<num;i++)
22. {
23. cudaGetDeviceProperties(&prop,i);
24. }
25. cudaStatus = addWithCuda(c, a, b, arraySize);
26. if (cudaStatus != cudaSuccess)
27. {
28. fprintf(stderr, "addWithCuda failed!");
29. return 1;
30. }
31. printf("{1,2,3,4,5} + {10,20,30,40,50} = {%d,%d,%d,%d,%d}\n",c[0],c[1],c[2],c[3],c[4]);
32. // cudaThreadExit must be called before exiting in order for profiling and
33. // tracing tools such as Nsight and Visual Profiler to show complete traces.
34. cudaStatus = cudaThreadExit();
35. if (cudaStatus != cudaSuccess)
36. {
37. fprintf(stderr, "cudaThreadExit failed!");
38. return 1;
39. }
40. return 0;
41. }
42. // 重点理解这个函数
43. cudaError_t addWithCuda(int *c, const int *a, const int *b, size_t size)
44. {
45. int *dev_a = 0; //GPU设备端数据指针
46. int *dev_b = 0;
47. int *dev_c = 0;
48. cudaError_t cudaStatus; //状态指示
49.
50. // Choose which GPU to run on, change this on a multi-GPU system.
51. cudaStatus = cudaSetDevice(0); //选择运行平台
52. if (cudaStatus != cudaSuccess)
53. {
54. fprintf(stderr, "cudaSetDevice failed! Do you have a CUDA-capable GPU installed?");
55. goto Error;
56. }
57. // 分配GPU设备端内存
58. cudaStatus = cudaMalloc((void**)&dev_c, size * sizeof(int));
59. if (cudaStatus != cudaSuccess)
60. {
61. fprintf(stderr, "cudaMalloc failed!");
62. goto Error;
63. }
64. cudaStatus = cudaMalloc((void**)&dev_a, size * sizeof(int));
65. if (cudaStatus != cudaSuccess)
66. {
67. fprintf(stderr, "cudaMalloc failed!");
68. goto Error;
69. }
70. cudaStatus = cudaMalloc((void**)&dev_b, size * sizeof(int));
71. if (cudaStatus != cudaSuccess)
72. {
73. fprintf(stderr, "cudaMalloc failed!");
74. goto Error;
75. }
76. // 拷贝数据到GPU
77. cudaStatus = cudaMemcpy(dev_a, a, size * sizeof(int), cudaMemcpyHostToDevice);
78. if (cudaStatus != cudaSuccess)
79. {
80. fprintf(stderr, "cudaMemcpy failed!");
81. goto Error;
82. }
83. cudaStatus = cudaMemcpy(dev_b, b, size * sizeof(int), cudaMemcpyHostToDevice);
84. if (cudaStatus != cudaSuccess)
85. {
86. fprintf(stderr, "cudaMemcpy failed!");
87. goto Error;
88. }
89. // 运行核函数
90. <span style="BACKGROUND-COLOR: #ff6666"><strong> addKernel<<<1, size>>>(dev_c, dev_a, dev_b);</strong> 第一个1,代表线程格里只有一个线程块;第二个size,代表一个线程块里只有size个线程。
91. </span> // cudaThreadSynchronize waits for the kernel to finish, and returns
92. // any errors encountered during the launch.
93. cudaStatus = cudaThreadSynchronize(); //同步线程
94. if (cudaStatus != cudaSuccess)
95. {
96. fprintf(stderr, "cudaThreadSynchronize returned error code %d after launching addKernel!\n", cudaStatus);
97. goto Error;
98. }
99. // Copy output vector from GPU buffer to host memory.
100. cudaStatus = cudaMemcpy(c, dev_c, size * sizeof(int), cudaMemcpyDeviceToHost); //拷贝结果回主机
101. if (cudaStatus != cudaSuccess)
102. {
103. fprintf(stderr, "cudaMemcpy failed!");
104. goto Error;
105. }
106. Error:
107. cudaFree(dev_c); //释放GPU设备端内存
108. cudaFree(dev_a);
109. cudaFree(dev_b);
110. return cudaStatus;
111. }
八
CUDA体系架构
体系架构由两部分组成,分别是流处理器阵列(SPA)和存储器系统。 (GT200)
GPU的巨大计算能力来自SPA中的大量计算单元,SPA的结构又分为两层:TPC(线程处理器群)和SM(流多处理器);
存储器系统由几个部分组成:存储器控制器(MMC),固定功能的光栅操作单元(ROP),以及二级纹理缓存。
CUDA 执行模型
将CPU作为主机(Host),而GPU作为协处理器(Coprocessor) 或者设备(Device),从而让GPU来运行一些能够被高度线程化的程序。
在这个模型中,CPU与GPU协同工作,CPU负责进行逻辑性强的事务处理和串行计算,GPU则专注于执行高度线程化的并行处理任务。
一个完整的CUDA程序是由一系列的设备端kernel函数并行步骤和主机端的串行处理步骤共同组成的。
grid运行在SPA上
block运行在SM上
thread运行在SP上
grid block thread
Kernel不是一个完整的程序,而只是其中的一个关键并行计算步骤。
Kernel以一个网格(Grid)的形式执行,每个网格由若干个线程块(block)组成,每一个线程块又由若干个线程(thread)组成。
一个grid最多可以有65535 * 65535个block
一个block总共最多可以有512个thread,在三个维度上的最大值分别为512, 512和64
存储器模型
Register
Local
shared
Global
Constant
Texture
Host memory
Pinned host memory
CUDA C语言
由Nvidia的CUDA编译器(nvcc)编译
CUDA C不是C语言,而是对C语言进行扩展形成的变种
引入了函数类型限定符:__device__,__host__和__global__。
引入了变量限定符:__device__,__shared__和__constant__。
引入了内置矢量类型:char1,dim3,double2等
引入了内建变量:blockIdx,threadIdx,gridDim,blockDim和warpSize
引入了<<<>>>运算符
引入了一些函数:同步函数,原子函数,纹理函数等
主机端代码主要完成的功能
启动CUDA
为输入数据分配内存空间
初始化输入数据
为GPU分配显存,存放输入数据
将内存输入数据拷贝到显存
为GPU分配显存,存放输出数据
调用device端的kernel计算
为CPU分配内存,存放输出数据
将显存结果读到内存
使用CPU进行其他处理
释放内存和显存空间
退出CUDA
设备端代码主要完成的功能
从显存读数据到GPU中
对数据处理
将处理后的数据写回显存
九
最后推荐一下CUDA编程学习资料
1.CUDA编程指南5.0中文版http://wenku.baidu.com/link?url=Bav8tFkg0ucd04cuTlFnXiVYv131Gmxi6vdfrMX-QJ45R6leMHLWqTLj9ZiGJHwMDGeFlbMiGrNdVPBjM3U1j8T80KtgdeqKu8CN111X2Zi
2.NVIDIA免费文档《NVIDIA CUDA Programming Guide》《NVIDIA CUDA Best Practices Guide》。
3.《gpu高性能编程cuda实战》
4.《大规模并行处理器编程实战》
5.《GPGPU编程技术:从GLSL、CUDA到OpenCL》