CUDA C编程(三十六)CUDA C的开发过程

在产品研发过程中的软件开发注意结构,旨在标准化代码并维护最好的范例。现在有许多软件开发模型,每种模型都描述了一些方法,这些方法都是针对特定情况下的特殊需求的。CUDA平台的开发过程是建立在现有模型和熟悉软件生命周期的概念之上的。

了解GPU内存和执行模型抽象有助于更好地控制大规模并行GPU环境。这样,创建映射到抽象二维或三维网格的应用子域就变得很正常了,并且可以是使核函数像串行一样表示。重点关注高级区域分解和内存层次结构存储管理的内容,就不会被创建和销毁线程的繁琐细节所妨碍了。在CUDA的开发过程中,需要关注的重点是以下几个方面:

  • 以性能为导向
  • 配置文件驱动
  • 通过GPU架构的抽象模型进行启发引导

了解应用程序如何使用GPU对确定性能提升的因素是至关重要的。NVIDIA提供了许多功能强大且易于使用的工具,它们能使开发过程引人入胜又轻松愉悦。以下部分包含了CUDA的开发过程和CUDA的性能优化策略。

APOD 开 发 周 期

APOD是由NVIDIA特别为CUDA开发定制的迭代开发过程。APOD有4个阶段:评估、并行化、优化、部署。如下图所示:

评估

第一阶段的任务是评估应用程序,确定限制性能的瓶颈或具有高计算强度的临界区。在这里,需要评估用GPU配合CPU的可能性,发展策略以加速这些临界区。

在这一阶段,数据并行循环结构包含很重要的计算,应该始终给予其较高的评估优先级。这种循环类型是GPU加速的理想化情况。为了帮助找出这些临界区,应该使用性能分配工具来发掘出应用程序的热点。有些代码可能已经被转化为使用主机的并行编程模型(如OpenMP或pthreads)。只要现有的并行部分能够充分并行化,那么它们也将为GPU加速提供很好的目标。

并行化

一旦应用程序的瓶颈被确定,下一阶段就是将代码并行化。这里有几种加速主机代码的方式,包括以下几个方面:

  • 使用CUDA并行库
  • 采用并行化及向量化编译器
  • 手动开发CUDA内核使之并行化

将应用程序并行化的最直接方法就是利用现有的GPU加速库。如果应用程序已经使用了其他的C数学库,如BLAS或FFTW,那么就可以很容易转换成使用CUDA库,如cuBLAS或cuFFT。另一种相对简单并行化主机代码的方法是利用并行化编译器。openACC使用开放的、标准的编译指令,它是为加速器环境显式设计的。OpenACC扩展提供了充分的控制以确保数据常驻于接近处理单元的位置,并提供了一系列的编译指令。这些使得GPU编程更加简单,可跨并行和多核处理器。

如果应用程序所需的功能或性能超出了现有的并行库或并行编译器所能提供的范围,那么在这种情况下,对并行化使用CUDA C编写内核是必不可少的。通过使用 CUDA C,可以最大限度地使用GPU的并行能力。

根据原代码的情况,可能需要重构程序来展现固有并行以提升应用程序的性能。并行数据分解在这一阶段是不可避免地。大规模并行线程间的数据划分主要有两种不同的方法:块划分和循环划分。在块划分中,要处理的数据元素被分成块并分配到线程中,内核的性能与块的大小密切相关。在循环划分中,每个线程在跳跃之前一次处理一个元素,线程数量和元素数量相同。数据划分要考虑的问题与架构特征和要实现的算法性质相关。

优化

当组织好代码且并行运行后,将进入下一阶段:优化实现以提升性能。大致来说,基于CUDA的优化可以体现在以下两个层次上:网格级(grid-level)、内核级(kernel-level)。在网格级优化过程中,重点是整体GPU的利用率和效率。优化网格级性能的方法包括同时运行多个内核以及使用CUDA流和事件重叠带有数据的内核执行。

限制内核性能的主要原因有3个:内存带宽、计算资源、指令和内存延迟。在内核级优化过程中,要关注GPU的内存带宽和计算资源的高效使用,并减少或隐藏指令和内存延迟。

CUDA提供了以下强大且有用的工具,从而可以在网格级和内核级确定影响性能的因素:Nsight Eclipse Edition(nsight)、NVIDIA可视化性能分析工具(nvvp)、NVIDIA命令行性能分析工具(nvprof)。这些性能分析工具在优化处理中是十分有效的,并为提升性能提供了最好的意见。

部署

只要确定了GPU加速应用程序的结果是正确的,那么就进入了APOD的最后阶段,即如何利用GPU组件部署系统。例如,部署CUDA应用程序时,要确保在目标机器没有支持CUDA的GPU的情况下,程序仍能正常运行。CUDA运行时提供了一些函数,用于检测支持CUDA的GPU并检查硬件和软件的配置。但是,应用程序必须手动调整以适应检测到的硬件资源。

APOD是一个迭代过程,它的目的是将传统的应用程序转化为性能力良好且稳定的CUDA应用程序。那些包含许多GPU加速申请的应用程序,可能多次经过了APOD的流水线周期:确定优化因素、应用和测试优化、验证加速实现,并再次重复这个过程。

优 化 因 素

一旦正确的CUDA程序已作为APOD并行化阶段的一部分实现,那么在优化阶段就能开始寻找优化因素了。如前面所述,优化可以应用在各个层面,从重叠数据传输和树计算这个层面来看,所有的优化方法都在于底层的微调浮点运算。为了取得更好的性能,应该专注于程序的以下几个方面,按照重要性排列为:

  • 展现足够的并行性
  • 优化内存访问
  • 优化指令执行

展现足够的并行性

为了展现足够的并行性,应该在GPU上安排并发任务,以使指令带宽和内存带宽都达到饱和。有两种方法可以增强并行性:在一个SM中保证有更多活跃的并发线程束、为每个线程/线程束分配更多独立的工作。当在一个SM中活跃线程束的数量为最佳时,必须检查SM的资源占用率的限制因素(如共享内存、寄存器以及计算周期),以找到达到最佳性能的平衡点。活跃线程束的数量代表了在SM中展示的并行性的数量。但是,高占用率不对应高性能。根据内核算法的性质,一旦达到了一定程序的占用率,那么再进一步增加占用率就不会提高性能了。但是仍有机会从其他方面来提高性能。

能从两个不同的层面调整并行性:内核级、网格级。在内核级,CUDA采用划分方法分配计算资源:寄存器在线程间被划分,共享内存在线程块间划分。因此,内核中的资源消耗可能会限制活跃线程束的数量。在网格级,CUDA使用由线程块组成的网络来组织线程的执行,通过指定如下内容,可以自由选择最佳的内核启动配置参数:每个线程块中线程的数量、每个网格中线程块的数量。通过网格配置,能够控制线程块中安排线程的方式,以向SM展示足够的并行性,并在SM之间平衡任务。

优化内存访问

许多算法都是受内存限制的。对于这些应用程序和其他的一些程序,内存访问延迟和内存访问模式对内核性能有显著的影响。因此,内存优化是提高性能需要关注的重要方向之一。内存访问优化的目标是最大限度地提高内存带宽的利用率,重点应放在以下两个方面:内存访问模式(最大限度地使用总线上的字节)、充足的并发内存访问(隐藏内存延迟)。

来自每一个内核的内存请求(加载或存储)都是由单个线程束发出的。线程束中的每个线程都提供了一个内存地址,基于提供的内存地址,32个线程一起访问一个设备内存块。设备硬件将线程束提供的地址转换为内存事务。设备上的内存访问粒度为32字节。因此,在分析程序的数据传输时需要注意两个指标:程序需要的字节数和硬件传输的字节数。这两者之间的差值表示了浪费的内存带宽。

对全局内存来说,最好的访问模式是对齐和合并访问。对齐内存访问要求所需的设备内存的第一个地址是32字节的倍数。合并内存访问指的是,通过线程束的32个线程来访问一个连续的内存块。

加载内存和存储内存这两个操作的特性和行为是不同的。加载操作可以分为3种不同的类型:缓存(默认,一级缓存可用)、未缓存(一级缓存禁用)、只读。缓存加载的加载粒度是一个128字节的缓存行。对于未缓存和只读的加载来说,粒度是一个32字节的段。通常,在Fermi GPU上全局内存的加载,会首先尝试命中一级缓存,然后是二级缓存,最后是设备全局内存。在Kepler GPU上,全局内存的加载会跳过一级缓存。对于只读内存的加载来说,CUDA首先尝试命中一个独立的只读缓存,然后是二级缓存,最后是设备全局内存。对于不规则的访问模式,如未对齐/或未合并的访问模式,短加载粒度有助于提高带宽的利用率。在Fermi GPU上,一级缓存可以启用或禁用编译器选项。在默认情况下,全局存储操作跳过一级缓存并且回收正在匹配的缓存行。

由于共享内存是片上内存,所以比本地和设备的全局内存具有更高的带宽和更低的延迟。在很多方面,共享内存是一个可编程管理的缓存。使用共享内存有两个主要原因:通过显式缓存数据来减少全局内存的访问、通过重新安排数据布局避免未合并的全局内存的访问。在物理角度上,共享内存以一种线性方式排列,通过32个存储体(bank)进行访问。Fermi和Kepler各有不同的默认存储体模式:分别是4字节存储体模式和8字节存储体模式。共享内存地址到存储体的映射关系随着访问模式的不同而不同。当线程束的多个线程在同一存储体中访问不同字时,会发生存储体冲突。由于共享内存重复请求,所以多路存储体冲突可能要付出很大代价。当使用共享内存时,解决或减少存储体冲突的一个非常简单有效的方法是填充数组。在合适的位置添加填充字,可以使其跨不同存储体进行访问,从而减少了延迟并提高了吞吐量。

共享内存被划分在所有常驻线程块中,因此,他是一个关键资源,可能会限制内核占用率。

优化指令执行

有以下几种方法可以优化内核执行,包括:

  • 通过保证有足够多的活跃线程束来隐藏延迟;
  • 通过给线程分配更多独立的工作来隐藏延迟;
  • 避免线程束内出现分化执行路径。

尽管CUDA内核是以标量方式表示的,就像它在单一CUDA核心上运行一样,但是代码总是在线程束单元中以SIMT(单指令多线程)方式执行的。当对线程束发出一条指令时,每个线程用自己的数据执行相同的操作。可以通过修改内核执行配置来组织线程。线程块的大小会影响在SM上活跃线程束的数量。GPU通过异步处理运行中的工作来隐藏延迟(如全局加载和存储),以使得线程束进度、流水线、内存总线都处于饱和状态。我们可以调整内核执行配置获得更多的活跃线程束,或使每个线程做更多独立的工作,这些工作是可以以流水线方式执行和重叠执行。拥有不同计算能力的GPU设备有不同地硬件限制条件,因此,在不同的平台上网格/线程块启发式算法对于优化内核性能有非常重要的作用。

因为线程束内的所有线程在每一步都执行相同的指令,如果由于数据依赖的条件分支造成线程束有不同的控制流路径,那么线程运行可能会出现分化。当线程束内的线程发生分化时,线程束必须顺序执行每个分支路径,并禁用不在此执行路径上的线程。如果应用程序的运行时间大部分花在分化代码上,那么就会显著影响内核的性能。

线程间的通信和同步是并行编程中非常重要的特性,但是它会对取得良好的性能造成障碍。CUDA提供了一些机制,可以在不同层面上管理同步。通常,有两种方法来显式同步内核:在网格级进行同步、在线程块内进行同步。同步线程中有潜在的分化代码是很危险的,可能会导致未预料的错误。必须小心以确保所有线程都收敛于线程块内的显式障碍点。总之,同步增加了内核开销,并且在决定线程块中哪个线程束符合执行条件时,制约了CUDA调度器的灵活性。

CUDA 代 码 编 译

一个CUDA应用程序的源程序代码通常包含两种类型的源文件:常规的C源文件和CUDA C源文件。在设备代码文件中,通常有两种函数:设备函数一级调用设备函数或管理设备资源的主机函数。CUDA编译器将编译过程分成了以下两个部分:使用nvcc的设备函数编译、使用通用型C/C++编译器的主机函数编译。编译的设备对象作为加载对象被嵌入到主机的目标文件中。通过链接阶段,添加CUDA运行时库来支持设备的函数性。

List item

CUDA提供了以下两种方式编译CUDA函数:整体程序编译、独立编译。在CUDA 5.0之前,核函数的完整定义与它调用的所有设备函数必须在同一个文件范围内,不能跨文件调用设备函数或是访问设备变量,这种编译被称为整体程序编译。从CUDA 5.0开始,引入了设备代码的独立编译(虽然整体程序编译仍然是默认的编译模式)。在独立编译下,一个文件中定义的设备代码可以引用另一个文件中定义的设备代码。独立编译CUDA项目管理有以下优点:

  • 使传统的C代码到CUDA的移植更容易;
  • 通过增加库的重新编译减少了构建时间;
  • 有利于代码重用,减少了编译时间;
  • 可将目标文件合并为静态库;
  • 允许链接和调用外部设备代码;
  • 允许创建和使用第三方库。

独立编译

CUDA编译是将设备代码嵌入到主机对象中。在整体程序编译中,可执行的设备代码被嵌入到主机对象中。而独立编译的过程则不那么简单,主要包含以下3个步骤:

  1. 设备编译器将可重新定位的设备代码嵌入到主机目标文件中;
  2. 设备链接器结合设备对象;
  3. 主机链接器将设备和主机对象组合成一个最终可执行的程序。

考虑一个简单例子,其中有a.cu,b.cu,c.cpp3个文件。假设a.cu文件中的一些核函数引用b.cu文件中的一些函数或变量,因为是跨文件引用的,所以就必须使用独立编译来生成可执行文件。如果是一个Fermi设备(计算能力为2.x),则可以使用以下命令产生可重新定位的对象:$ nvcc -arch=sm_20 -dc a.cu b.cu,传到nvcc中的选项-dc,命令编译器编译每一个输入文件(a.cu和b.cu),生成一个包含可重新定位设备代码的目标文件。下一步,使用以下命令将所有设备对象链接在一起:$ nvcc -arch=sm_20 -dlink a.o b.o -o link.o,传到nvcc中的选项-dlink,使所有具有重新定位设备代码(a.o和b.o)的设备目标文件被链接到一个可以传递到主机链接器的目标文件(link.o)中,最后,主机链接器生成可以执行的程序,如下:

$ g++ -c c.cpp -o c.o
$ g++ c.o link.o -o test -L<path> -lcudart

独立编译过程如下所示:

Makefile示例文件

下面的代码清单是一个使用独立编译的Makefile示例文件。需要更换Makefile示例文件中完整的路径名称并更新可执行的文件名,以与工作环境相匹配。我们可以扩展示例来编译一个包含以下内容的项目:

  • C和CUDA C文件;
  • 跨CUDA C文件引用的设备函数或设备变量。

将CUDA文件整合到C项目中

CUDA提供了两套运行时API接口:C++接口、C规范接口。当把C代码移植到CUDA中时,需要通过调用CUDA运行时函数来从C函数中准备设备内存和数据。例如,从a.c文件中调用cudaMalloc函数是必须的。从C代码中调用CUDA运行时函数,需要在主机代码中包含C运行时头文件,如下所示:#include <cuda_runtime_api.h>,组织CUDA核函数时,可以像基于C的项目一样,使用它独立的文件。然后必须在设备源文件中创建内核封装函数,使之可以像正常的C函数那样被调用,但却执行CUDA内核启动。因为设备源文件中声明的主机函数默认C++规范,所以也需要用以下的声明来解决C++引用混乱的问题:

extern "C" void wrapper_kernel_launch(...){
   ...
}

关键字extern "C"指示编译器该主机函数名应该是正确的,以便它可以与C代码链接。下图展示了在独立的文件中如何使用C规范组织内核封装函数:
在这里插入图片描述
CUDA 错 误 处 理

错误处理可以说是程序开发中最不迷人又最重要的一个环节。构建一个程序,在把应用程序部署到具体生产环境前,确保它经得起各种未设定的错误考验是很有必要的。幸运的是,CUDA有一个很方便的检错机制。每一个CUDA API和库调用都会返回一个错误代码来指示成功或失败的具体细节。这些错误代码有利于从错误中恢复执行,或向用户显示有用的信息,正如在人恶化系统级软件开发项目中一样,为了稳定性,需要检查每一个函数调用的错误代码。

CUDA检错机制的一个特性是异步。CUDA函数调用返回的错误代码可能是也可能不是该特定函数调用执行操作的结果。函数可能返回一个错误信息,这个错误可能是由之前的任何异步函数调用而引起的,而且该调用仍在执行。这使得用户提供有用的错误信息或是从错误中恢复这一过程变得更加复杂。通过定义哪些操作可以并行运行,并准备处理任何函数中的错误,可以在一定程序上减少这些问题。

CUDA提供了3个用于错误检查的函数调用。cudaGetLastError为报告的所有错误都检查CUDA当前的状态。如果没有错误记录,返回cudaSuccess。如果一旦一个错误被记录,那么它将返回该错误,并将CUDA的内部状态清理为cudaSuccess。因此,如果多次调用cudaGetLastError返回一个错误代码,那么调用cudaGetLastError的应用程序就能区别这些不同的错误(虽然它们出错的原因是相关的)。

cudaPeekLastError和cudaGetLastError有同样的检测功能,但是它不会将内部的错误状态编程cudaSuccess。cudaGetErrorString会对CUDA错误返回一个可读的字符串,这对于面向用户的错误处理是很有用的。

我们最常用的就是用CHECK或是CALL_CUDA宏来退出错误:

#define CHECK(call){\
   cudaError_t err; \
   if((err = (call)) != cudaSuccess){\
      fprintf(stderr, "Got error %s at %s:%d\n",cudaGetErrorString(err),\
              __FILE, __LINE__);\
      exit(1);\
   }\
}

在许多应用程序中,从CUDA错误中恢复是有可能的,所以在这种情况下立即退出是没有必要的。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值