本章包含了CUDA C项目开发的几个方面;
CUDA C的开发过程;
配置文件驱动优化;
CUDA开发工具;
10.1 CUDA C的开发过程
CUDA平台的开发过程是建立在现有模型和熟悉软件生命周期的概念之上的。
了解GPU内存和执行模型抽象有助于更好地控制大规模并行GPU环境。
重点关注高级区域分解和内存层次结构存储管理的内容,就不会被创建和销毁线程的繁琐细节所妨碍了。
在CUDA开发中,需要关注的重点是一下几个方面:
以性能为导向;
配置文件驱动;
通过GPU架构的抽象模型进行启发引导;
10.1.1 APOD开发周期
APOD是NVIDIA特别为CUDA开发定制的迭代开发过程。
评估(assessment)
并行化(parallelization)
优化(optimization)
部署(deployment)
10.1.1.1 评估
在第一阶段的任务是评估应用程序,确定限制性能的瓶颈或具有高计算强度的临界区。在这里,需要评估用GPU配合CPU的可能性,发展策略以加速这些临界区。
在这一阶段,数据并行循环结构包含很重要的计算,应该始终给予其较高的评估优先级。可以使用性能分析工具来发掘应用程序的热点。
10.1.1.2 并行化
一旦应用程序的瓶颈被确定,有几种方式加速主机代码的方式:
使用CUDA并行库;
采用并行化及向量化编译器;
手动开发CUDA内核使之并行化;
将应用程序并行化最直接方法就是利用现有的GPU加速库。如果应用程序已经使用了其他C数学库,如BLAS或FFTW,就可以很容易转换为CUDA库。
另一种相对简单并行化主机代码就是利用并行化编译器。OpenACC使用开放的,标准的编译指令,它为加速器环境显式设计的。
如果应用程序所需的功能或性能超过了现有的并行库或并行编译器所能提供的范围,对并行化使用CUDA C 编写内核是必不可少的。
根据源代码的情况,可能需要重构程序来展现固有并行以提升应用程序的性能。并行数据分解在这一阶段是必不可少的。大规模并行线程间的数据划分主要有两种方式:块划分和循环划分。在块划分中,要处理的数据元素被分成块并分配到线程中,内核的性能与块的大小密切相关。在循环划分中,每个线程在跳跃之前一次处理一个元素,线程数量和元素数量相同。数据划分要考虑的问题与架构特征和要实现的算法性质相关。
10.1.1.3 优化
当组织好代码并且并行运行后,将进入下一个阶段:优化实现以提升性能。大致来说,基于CUDA的优化可以体现在以下两个层次上:
网格级(grid-level);
内核级(kernel-level);
在网格级优化过程中,重点是整体GPU的利用率和效率。优化网格级性能的方法包括同时运行多个内核已经使用CUDA流和事件重叠带有数据的内核执行。
限制内核性能的主要原因有3个:
内存带宽;
计算资源;
指令和内存的延迟;
在内核级优化的过程中,要关注GPU的内存带宽和计算资源的高效使用,并减少或隐藏指令和内存的延迟。
CUDA提供了强有力的工具,从而在网格级和内核级确定影响性能的因素:
Nsight, NVVP(可视化), NVprof(命令行);
10.1.1.4 部署
进入APOD的最后阶段,即如何利用GPU组件部署系统。例如,部署CUDA应用程序时,要确保在目标机器没有支持CUDA的GPU的情况下,程序仍然能正常进行。CUDA提供了一些函数检测CUAD的GPU并检查硬件和软件的配置。
APOD是一个迭代过程,它的目的是将传统的应用程序转换为性能良好且稳定的CUDA应用程序。
螺旋模型:
螺旋模型是一种软件开发方法,它是基于关键要素的连续细化概念,使用的迭代周期为:
分析;
设计;
实现;
它允许每一次围绕螺旋生命周期时,增加产品发布,或者增加细化。
10.1.2 优化因素
为了取得更好的性能,应该专注于程序的以下的几个方面,按照重要性排列为:
展现足够的并行性;
优化内存访问;
优化指令执行;
10.1.2.1 展现足够的并行性
为了展现足够的并行性,应该在GPU上安排并发任务,以使指令带宽和内存带宽都达到饱和。有两种方法可以增强并行性:
在一个SM中保证有更多活跃的并发线程束;
为每个线程/线程束分配更多独立的工作;
当一个SM中活跃的线程束的数量为最佳时,必须检查SM的资源占用率的限制因素(如共享内存、寄存器以及计算周期),以达到最佳性能的平衡点。
活跃线程束的数量代表了在SM中展示的并行性的数量。但是,高占用率不对应高性能。根据内核算法性质,一旦达到一定程度的占用率,那么在进一步增加占用率就不会增加性能,但是仍然有机会从其他地方来提高性能。
从两个不同的层面来调整并行性:
内核级;
网格级;
在内核级,CUDA采用划分方法分配计算资源:寄存器在线程间被划分,共享内存在线程块间划分。因此,内核中的资源消耗可能会限制活跃线程束的数量。
在网格级,CUDA使用线程块组成的网格来组织线程执行,通过指定如下内容,可以只有选择最佳的内核启动配置参数gridDim和blockDim。
通过网格配置,能够控制线程块中安排线程的方式,以向SM展示足够的并行性,并在SM之间平衡任务。
10.1.2.2 优化内存访问
许多算法都是受内存限制的。内存访问延迟和内存访问模式对内核性能有显著的影响。因此,内存优化时提高性能需要关注的重要方面之一。内存访问优化的目标是最大限度地提高内存带宽的利用率,重点放在以下两个方面:
内存访问模式(最大限度地使用总线上的字节);
充足的并发内存访问(隐藏内存延迟);
来自每一个内核的内存请求(加载或存储)都是单个线程束发出的。线程束中每个线程都提供了一个内存地址,基于提供的内存地址,32个线程一起访问一个设备的内存块。
设备硬件将线程束提供的地址转换为内存事务。设备上的内存访问粒度为32个字节。因此,在分析程序的数据传输时需要注意两个指标:程序需要的字节数合硬件传输的字节数。这两者之间的差值表示浪费的内存带宽。
对于全局内存来说,最好的访问模式是对齐和合并访问。对齐内存访问所需的设备内存的第一个地址时32字节的倍数。合并内存访问指的是,通过线程束中的32个线程来访问一个连续的内存块。
加载内存和读写内存这两个操作的特性和行为是不同的。加载可以分为3中不同的类型:
缓存(默认,一级缓存可用);
未缓存(一级缓存禁用);
只读;
缓存加载的粒度是一个128字节的缓存行。对于未缓存和只读的加载来说,粒度是32字节。
对于Fermi架构的全局内存加载而言,会首先尝试命中一级缓存,然后是二级缓存,最后是设备全局内存。
对于Kepler架构的全局内存加载而言,全局内存的加载会跳过一级缓存。对于只读内存的加载而言,CUDA会首先尝试命中一个独立的只读缓存,然后是二级缓存,最后才是全局内存。
对于不规则的访问模式,如未对齐/未合并的访问模式,短加载粒度有助于提高带宽的利用率。
由于共享内存是片上内存,所以比本地和设备的全局内存具有更高的带宽和更低的延迟。在很多方面,共享内存是一个可编程管理的缓存。使用共享内存有两个主要原因:
通过显示缓存数据来减少全局内存的访问;
通过重新安排数据布局来避免未合并的全局内存的访问;
从物理角度来说,共享内存以一种线性方式排列,通过32个存储体进行访问。Fermi和Kepler各有不同的默认存储体模式,分别是4字节和8字节存储体模式。
共享内存地址到存储体的映射关系随着访问模式的不同而不同。当线程束中的多个线程在同一个存储体访问不同字时,会发生存储体冲突。
解决或减少存储体冲突的一个非常简单有效的方法是填充数组。在合适的位置添加填充字,可以使其跨不同存储体进行访问,从而减少了延迟并提高了吞吐量。
共享内存被划分在所有常驻线程块中,因此,它是一个关键资源,可能会限制内核占用率。
10.1.2.3 优化指令执行
有以下几种方式可以优化内核执行,包括:
通过保证有足够多的活跃线程束来隐藏延迟;
通过给线程分配更多独立的工作来隐藏延迟;
避免线程束内出现分化执行路径;
由于CUDA是SIMT方式执行代码的,当对线程束发出一条指令时,每个线程用自己的数据执行相同的操作。
线程块的大小会影响在SM上活跃线程束的数量。GPU通过一部处理运行中的工作延迟来隐藏延迟(如全局内存的加载和存储),以使得线程束进度、流水、内存总是都处于饱和状态。
我们可以调整内核执行配置获得更多的活跃线程束,或使得每个线程做更多的独立工作。这些工作是可以以流水线方式执行和重叠执行。
在不同的平台和上网格/线程块启发式算法对于优化内核性能有着非常重要的作用。
因为线程束内的所有线程在每一步都执行相同的指令,如果由于数据依赖的条件分支造成线程束内有不同的控制逻辑流,那么线程运行可能会出现分化。当线程束内发生分化时,线程束必须顺序执行每个分支路径,并禁用不在此不路径上的线程。如果应用程序的运行时间大部分花在分化代码中,那么就会显著影响内核的性能。
线程间的通信和同步是并行编程中非常重要的特性。CUDA提供了不同层次管理同步:
在网格级进行同步;
在线程块内进行同步;
同步线程中有潜在的分化代码的危险,可能导致未知的错误。必须小心以确保所有线程都收敛于线程块内的显式障碍点。总之,同步增加了内核开销,并且在决定线程块中那个线程束负荷执行条件时,制约了CUDA调度器的灵活性。
10.1.3
10.2.1.2 性能分析范围
10.2.1.3 内存带宽
内核可以在各种存储类型上运作,主要包括以下几种类型:
共享内存;
一级/二级缓存;
纹理内存;
设备内存;
系统内存;(通过PCIe)
使用nvprof可以收集到许多与内存操作相关的事件/指标。使用这些指标/事件,可以在不同类型的内存上评估内核的效率。
10.2.1.4 全局内存效率
在最佳的情况下,全局内存的访问应该是对齐的并且是合并的。除了对齐和合并之外的任何访问模式都会导致重新进行内存请求。指标为:
gld_efficiency:请求的全局内存加载量和需要的全局内存加载吞吐量的比值。
gst_efficiency:
gld_transaction_per_request: 被每个全局内存加载请求执行的全局内存加载事务的平均数。
gst_transaction_per_request:
如果单一的全局加载或存储请求了很多事务,那么设备内存带宽可能就会被浪费掉。可以用gld/st_trannsactions查看内存操作的总数。
可以通过gld/st_throughtout查看内存操作的吞吐量。可以将这些值与理论峰值比较,以确定内核是否接近理想性能,或者还有提升空间。
10.2.1.5 共享内存存储体冲突
可以用shared_load/store_transactions_per_request指标检查应用程序是否出现了存储体冲突。当发生冲突时,任何一个加载或存储的相应值都将大于1。也可以用l1_shared_bank_conflict事件直接检查存储体冲突。此事件报告了当两个或多个共享内存请求访问同一个内存存储体时共享存储体冲突的数量。
用以下事件手机共享内存加载/存储指令的数量,但不包括重新执行的次数:
shared_load/store;
然后通过以下方式计算每一条指令重新执行的次数;
l1_shared_bank_conflict / (shared_load + shared_store);
也可以用以下指标来查看共享内存的效率:
shared_efficiency:请求的共享内存吞吐量和需要的共享内存吞吐量的比值。shared_efficiency值越小,意味着存储体冲突越多。
10.2.1.6 寄存器溢出
当内核使用的寄存器变量多余单个线程允许的最大值(Fermi 63个, Kepler 255个)时,编译器会把多余的值溢出到本地内存中。溢出到本地内存可能会大大降低内核的性能。为了评估寄存器溢出的严重程度,首先要收集以下事件:
l1_local_load_hit;
l1_local_load_miss;
l1_local_store_hit;
l1_local_store_miss;
然后计算下列比值:
local_load_hit_ratio = l1_local_load_hit / ( l1_local_load_hit + l1_local_load_miss);
local_store_hit_ratio = l1_local_store_hit / ( l1_local_store_hit + l1_local_store_miss);
较低的值表示这严重的寄存器溢出。
也可以查看l1_cache_local_hit_rate,它显示了本地加载和存储时一级缓存的命中率。如果执行更多的本地加载和存储,则意味着产生更多的溢出。
10.2.1.7 指令吞吐量
指令的吞吐量主要受到指令串行化和线程束分化的影响。可以用以下指标查看指令的串行化,可以通过下列两个指标来确定重新执行的百分比(串行化):
inst_executed:没有包含指令的重新执行。
inst_issued: 包含了指令的重新执行。
线程束分化也通过减少每个线程束活跃线程的数量来影响指令吞吐量,用下列指标查看:
branch_efficiency: 非分化分支与总分支的比值;其值越大,则表示较低的线程束分化;
也可以用以下事件来查看线程束分化:
branch:
divergent_branch:
通过比较这两个指标,可以确定分支分化的百分比;