为对某一数据集进行操作,你需要将数据从主机传输到设备上、在数据集是哪个进行操作,然后将结果传输回主机。由于是在完全串行的方式下执行的,这将导致主机和GPU在一段时间内实现制的,白白浪费了传输能力与计算能力。
在本章,我们详细介绍了多GPU的使用,包括如何使用流以确保GPU总是有工作可做。使用简单的双缓冲技术,尽管GPU正在将结果传输回主机并且请求一个新的工作包,但另一个缓冲仍然能被计算引擎来处理下一个数据块。
主机处理器支持虚拟内存系统,其中物理内存页可以被标记为换出状态。然后将它更换到磁盘上。一旦主机处理器访问到该页,处理器将会从磁盘将该页加载回来。它允许程序员使用比硬件上实际空间更大的虚拟地址空间。
你应该总是使用较大数量的主机内存的系统上的锁页内存。锁页内存允许GPU上的DMA控制器请求主机内存传输而不需CPU主机处理器的参与。因此,在管理传输或者从磁盘换出的页面调回时,没有加载操作需要劳烦主机处理器处理。
PCI-E传输实际上是基于DMA的传输执行。因此,驱动程序必须分配一块锁页内存,执行一个从常规内存到锁页内存的主机端复制操作,初始化传输,等待传输完成,然后释放锁页内存。所有这些操作都花费一定时间且会消耗CPU周期。
在GPU分配的内存默认情况下是锁页内存,这只是因为GPU不支持将内存交换到磁盘上。
可以用cudaMallocHost()函数在主机处理器上注册为锁页内存。注册内存只是设置一些内部标记以确保内存不会被换出,并且告诉CUDA驱动程序,该内存为锁页内存,能直接使用它,而不需要使用一个临时的缓冲区。
cudaMallocHost()的第三个参数为flag,有以下标记;
cudaHostAllocDefault--多数情况下使用,简单地指定为默认行为。
cudaHostAllocWriteCombined--用于只被传输到设备的内存区域。当主机要从这个内存区域读取时不要使用这个标记。这在特定的硬件设备上能够加速到设备的传输。
cudaHostAllocPortable--锁页内存在所有CUDA上下文中变成锁页和可见的。如果打算在CUDA上下文之间或主机处理器的线程之间传递指针,则必须使用这个标志。
cudaHostAllocMapped--它将主机内存分配到设备内存空间,这允许GPU内核直接读取和写入,所有的传输将隐式地处理。
cudaMallocHost()详情参考cudaMallocHost()函数
零复制内存是一种特殊形式的内存映射,它允许你将主机内存直接映射到GPU内存空间上。
零复制内存能够避免在系统中执行这些复制并且不会影响PCI-E总线传输。
零复制内存也有一个非常有用的使用场合。就是将CPU应用程序移植到GPU的初始阶段。在这个开发阶段,经常会有主机上的若干段代码没有移植到GPU。将这样的数据声明为零复制内存区域就能允许代码整段地移植并且仍然能够工作。在所有代码都真正移植到在GPU之前,程序的性能通常是很差的。它允许更小的移植操作,所以这不是一个要么全做,要么全不做的问题。
使用零复制内存或主机映射内存做三件重要的事情。第一是启用它,第二是使用它分配内存,最后将常规的主机指针转换成指向设备内存空间的指针。
我们需要在CUDA上下文创建之前进行下面的调用:
cudaSetDeviceFlags(cudaDeviceMapHost);
当CUDA上下文被创建时,驱动程序会知道它需要支持主机映射内存。没有驱动程序支持,主机映射(零复制)内存将无法工作。如果该支持在CUDA上下文创建之后完成,内存也无法工作。请注意对cudaHostAlloc这样的函数的调用,尽管在主机内存上执行,也仍然要创建一个GPU上下文。
下一阶段是分配主机上的内存,这样它就能映射到设备内存。
cudaHostAlloc((void**)&host_data_to_device, size_in_bytes, cudaHostAllocWriteCombined | cudaHostAllocMapped);
最后,我们需要通过cudaHostGetDevicePointer()函数将主机指针转换成指向设备的指针。
cudaHostGetDevicePointer(&dev_host_data_to_device, host_data_to_device, 0);
在这个调用中,我们将之前在主机内存空间分配的host_data_to_device转换成GPU内存空间的指针。不要将这两个指针混淆。在GPU内核中,只使用转换后的指针;原始的指针只出现在主机上执行的代码。因此,为了释放内存,需要在主机上执行cudaFreeHost()。
使用锁页内存复制,我们可以显著的减少数据传输的时间,但是这个传输时间依旧是增加的,因为它是串行操作。
而使用零复制内存,我们将传输和内核执行操作分解成更小的块,然后以流水线的方式执行它们。整体时间得以减少的非常显著。
请注意,由于消费级GPU只有一个启用了复制引擎,故无法对来自设备的复制操作执行同样优化。它们只支持一个内存流。当你执行读取操作、内核操作、写入操作时,如果写操作在随后的读操作之前放入流中,该写操作会阻塞读操作,直到挂起的写操作完成。而tesla设备却不如此,因为他们两个复制引擎都启动了,因此tesla显卡能够支持相互独立的流。
当然,你也可以选择流和异步内存复制实现。零复制只是提供了一种选择,一个更简单的使用接口。
对于绝大多数程序而言,最终的带宽限制来源于设备获取输入数据和写回输出数据的IO速度。
如果你使用的是网络连接存储,那么该限制还包含网络链路传输速度的限制。
遇到的另一个带宽限制是主机内存速度的限制。
CUDA4.0之后引入了点对点通信。CUDA4,1同样在非NVIDIA硬件上也引入了点对点通信。因此,在适当的硬件上,GPU可以与任何支持的设备通信。而这也受到InfiniBnad以及其他告诉网卡数量的限制。然而,原则上任何PCI-E设备都支持与GPU通信。因此,一个RAID控制器能够直接向GPU发送数据或从GPU接收数据。由于不存在主机端参与,这类设备潜力巨大。由于数据不必传入CPU然后在传回,因此延迟降低了很多。
尽可能确保从线程读取数据时合并访问,并且保证程序100%利用了从内部传输到GPU上的数据。
有两种策略可以试图产生重叠的传输。第一种,用计算时间重叠传输时间。
流在GPU计算中是一项非常有用的功能,通过建立独立工作队列我们能够以异步方式驱动GPU设备。也就是说,CPU可以将一系列的工作元素压入队列,然后离开,在再次服务GPU之前做别的事情。
通过为GPU创建一个工作流,取代了GPU与CPU同步工作的模式,取代了CPU不得不一直询问GPU来确认是否完成的模式,我们只是给它大量的工作去处理。只需定期去检查工作是否完成,如果完成,则可以将更多的工作压入流或工作队列中。
对于异步操作,从GPU传入或传出需要固定的或者锁页的内存。
你会受到PCI-E带宽容量的限制。
如可能,尽量使用锁页内存。
使用至少2MB的传输大小。
理解零复制内存的使用,它是流API的一种替代方法。
把应用程序分解成大小合适的网格、线程块和线程,是保证CUDA内核性能的关键环节之一。包括GPU在内的几乎所有计算机设计,内存都是瓶颈。线程布局的一个糟糕的选择通常也会导致一个明显影响性能的内存模式。
高速缓存模型可能导致一个问题,使人们认为硬件可以将他们从糟糕的编程中拯救出来。
尽管有数以千计的线程是闲置的,但是它们并不是免费的。非活动线程的问题有两方面。首先,只要线程束中的一个线程是活跃的,那么对应线程束就保持活跃,可供调度,并且占用资源。然而只有有限数目的线程束可以在调度期间被调度。以下两种方式都是无意义的:在多个CUDA核上调度只含有一个线程的线程束或者在一个CUDA核上调度而剩下15个闲置。然而,对于一个有分支的执行流,当线程束内活动线程只剩下一个时,此时很无意义。
非活动的线程束本身也不是免费的。虽然SM内部关心的是线程束,而不是线程块,然而外部调度器只能向SM而不是线程束调度线程块。因此,如果每个块包含一个活动的线程束,那么仅有6~8个线程束以供SM从中选择调度。通常根据计算能力的版本和资源使用的情况,在一个SM中容纳多大64个活跃的线程束。现在存在一个明显的问题,因为线程级的并行模型(TLP)依赖于大量的线程来隐藏内存和指令延迟。随着活跃线程束数量的减少,SM通过TLP隐藏延迟的能力也明显下降。一旦超过某个程度,就会降低性能,尤其当线程束仍在访问全局内存的时候。
因此,诸如规约这类操作的最后一层或者其他线程束数量逐渐减少的操作中,我们需要引入一些指令级并行操作。我们要尽可能地终止最后的线程束以使整个线程块都闲置出来,并替换为另一个包含一组更活跃束的线程块。
算术运算密度这个术语用来衡量每次内存读取相应的算术运算的数目。
由于处理器必须将数据从共享内存中完全移到局存起,因此我们必须将共享内存读存考虑为内存操作。
如何才能提高此类指令流的算术运算密度?首先,我们要了解底层的指令集。指令的最大操作数是128字节,即一个4元素矢量的加载/存储操作。
1. 超越函数操作
GPU具有以下加速器:
除法;
平方根;
平方根的倒数;
正弦;
余弦;
以2为底的对数;
以2为底的指数;
2. 近似
在一定的搜索空间求解问题时,近似是一种有用的技术。
相比于双精度计算,单精度计算占用更少的寄存器,从而使得更多的线程块加载到硬件中。内存读取也减少了。
一旦我们开启近似,内核就可以测试结果已查看它是否在一定容许范围内或符合一些准则,来保证进一步的分析是有理有据的。
3. 查找表
查找表是一个用于复杂算法的常见优化技术。对于CPU端昂贵的算法,查找表一般能表现得相当好。其原理是,在数据空间中计算出数据中的代表点,然后应用插值方法根据与任意边缘点之间的相应距离生成中间点。这通常用于现实世界的建模中,因为线性插值的方法在拥有足够多数量的关键样本点时,可以提供一个实际信号的很好近似。
平均算术指令的响应延时将会在18~24周期内,而平均的存储器读取在400~600周期的级别。
在许多情况下,查找方式可能战胜计算方式,尤其是你实现了GPU的高占用率。而在低占用率处,计算方式往往胜出,当然取决于实际计算的复杂程度。
1. 复杂运算简化
当访问数组元素的索引时,通常未优化的编译器代码将使用
array_element_address = index * element_size;
可以简单地对基址寄存器增加一个固定偏移大小。
由于某些指令(乘、除)比其他指令(加减)计算花费更高的代价。而优化试图以更高效的的操作取代高代价的操作。
另外,简单地增加#pragma unroll指令,会指示编译器展开全部的循环。
2. 循环不变式分析
循环不变式分析查找在循环体内不变的表达式,并将其移到循环体外。
任何内存事务,如读或写,如果该事务涉及访问当前不可用的数据,则可能会导致切换到另一个线程束。全局内存的该区域可以为任何SM的任何活动块的任何线程束上的任何线程所访问。
许多程序员并不了解编译器的优化步骤,一旦因为优化过于激进,做了违背代码原意的事情,他们就会指责编译器。因此,在优化代码上,编译器往往相当保守。
作为程序员,理解这一点,可以让你做出源码级的优化。记住把全局内存看作一个慢速IO涉笔,从中读取一次数据,并重复使用这些数据。
3. 循环展开
循环展开是一种技术,旨在确保你在运行一个循环的开校内完成一个合理数量的数据操作。查看以下代码:
for (int i = 0; i < 100; i++)
q[i] = i;
就汇编代码而言,这将产生;
在寄存器上加载0,赋给参数i;
在寄存器上测试100;
一个分支,要么退出,要么执行循环;
对保存有循环计算器的寄存器加1;
对下标为i的数组q计算地址;
将i存储到计算出的地址。
这些指令只在最后做了一些实在的工作。指令的其余部分都是开销。
for (int i = 0; i < 25; i += 4)
{
q[i] = i;
q[i + 1] = i + 1;
q[i + 2] = i + 2;
q[i + 3] = i + 3;
}
展开循环后,因此有用的工作与采取循环带来的开销之间的比例大大增加。然而,C源代码量有所增加,而且现在所做的工作相对于第一个循环变得不太明显。
在CPU领域,寄存器往往是有限的。因此在每个步骤中相同的寄存器会被重复使用。这样可以减少寄存器开销,但这意味着知道q[i]完成后,q[i+1]才能开始处理。GPU把每个地址的计算分配到一个单独的寄存器。因此,我们有4个一组的并行指令,而不是4个顺序执行的指令。每组压入流水线,因此对应输出结果几乎一个接着一个的。
使用这种方法限制的是寄存器的数目,由于GPU最多有64个寄存器,有相当大的余地可以展开小的循环体,同时实现良好的加速。
NVCC编译器支持#pragma unroll指令,它会自动展开全部的常量的次循环。当循环次数不是常数时,它将不会展开。
通常情况下,unroll4或者unroll8会工作得很好,但超出太多将使用过多寄存器,这会导致寄存器溢出。先溢出到一级缓存,然后到全局内存,则会导致性能巨大下降。
4. 循环剥离
循环剥离常用在循环次数不是循环展开大小的整数倍时。在这里,最后的数次循环分离出来,单独执行,然后展开循环的主体。
循环剥离也能用在循环的开始。在这种情况下,它允许把一个未对齐的结构作为一个对齐的结构作为一个对准结构的访问。
当使用#pragma loop unroll N指令时,编译器将展开循环,使得迭代次数不超过循环的边界,并在循环末端自动插入循环剥离代码。
5. 窥孔优化
这种优化寻找那些可以被同功能的、更复杂的指令代替的指令组。典型的是乘法之后紧跟加法,这种方式的构造可以替换为madd(乘法和加法)指令,从而将指令的数目从两个减少到一个。
其他窥孔优化包括控制流简化,代数运算简化和删除不会执行的代码。
6. 公共子表达式和折叠
a[idx + i] = b[idx + i];
数组a和数组b都是有参数idx和i来索引的。如果这些参数是在局部范围内起作用,则编译器可以统一计算索引,并将该值增加到数组a和b的起始地址,同时增加到每个参数的工作地址。但是,如果任一个索引参数是全局变量,计算就必须重复,因为任何一个参数都可能已经被其他同时运行的线程所改变。
注意到,在函数中使用常量参数,或在全局内存中包含这样的参数,你可能会限制编译器对代码进行优化的能力。
GPU执行代码以线程块或线程束为单位。
如果代码中有一条分支并且只有几条指令在分支上,则这几条指令将会进入分支,而其他的指令在分支点等待。实际上,那些不在分支上的线程将清除标志位。相反,那么在此分支上的线程会设置标志位。
这种处理方式称为谓词法。当线程束中对应某个线程的标志位因为在分支上而被设置,就创造 一个谓词。大多数PTX运算码支持一个可选的谓词以便允许选中的线程执行指令。分支的准则实际是以半个线程束为单位的。
在线程束中防止分支最早的办法是简单地将线程束中你不希望参与到结果中区域用掩码标示出来。
GPU将代码编译到一个叫做PTX(并行线程执行指令集架构, Parallel Thread eXecution Instruction Set Architecture)的虚拟汇编系统中。
查看和理解底层的汇编函数最简单的方式之一是在Parallel Nsight中通过"View Disassembly"(查看反汇编)选项来查看源代码和汇编码的混合体。
要在一个SM上启动一个块,CUDA运行时将会观察块对寄存器和共享内存情况的使用。如果有足够的资源,该块将启动。实际上,块并不是主要的考虑因素。关键的因素是整体的线程数相对于最大支持数量的百分比。
CUDA中,‘’local‘’是指一个给定的线程中一个变量的范围。因此,CUDA文档还用local memory来表示线程的私有数据。
在使用原子操作时候,栈帧需要被看到。栈帧在一级缓存也会存在,除非它太大了。在这些地方CUDA编译器可能简单地内联调用设备函数,从而移除了为被调用的函数传递形参的需求。
常量内存通常用于参数传递。
寄存器可以在编译器中使用-maxrregcount n来强行或控制。使用它来指示编译器使用比现在更多或更少的寄存器。或许也希望使用更少的寄存器来允许SM额外调度一个块。另一种情况是可能已经被其他一些因素,如共享内存的用量,限制,因此不放允许编译器使用寄存器。通过使用更多的寄存器,编译器可以重复使用更多的寄存器中的值,而不是反复存储/读取它们。相反,使用更少的寄存器通常会导致更多的全局内存访问。
要去更少的寄存器以额外运行一个块是一种折衷的行为。寄存器数量越少,附加快将带来越高的占用率,但是这不一定会使性能提升。
只有在调度器实际运行的某个时刻,线程束不够用,因此SM阻塞的时候,增加更多的线程束才会有实际帮助。
每个内核的寄存器从26减少到25个,作用不大。然而,在寄存器临界数量(16, 10, 24和32)上过渡通常会允许调度更多的块。这将带来更多可选择的线程束,并且通常会提升性能。但有时因为更多的块意味着对共享资源更多的竞争。
理解线程布局如歌影响内存和缓存的存取模式;
内核启动时声明的线程数量值使用32的倍数;
思考如何增加实际每次内存读取时的工作量;
在优化代码和修改源代码以协助编译器时,至少应了解一些编译器的工作原理;
考虑如何避免线程束的分支;
查看PTX和最终的目标代码来确认编译器没有生成低效的代码。若存在低效代码,则分析原因并且修改源代码来解决;
了解和掌握数据被放在哪里以及编译器在表明什么;
以下是三种常见瓶颈,按重要性排序如下;
PCI-E传输瓶颈;
内存带宽瓶颈;
计算瓶颈:
1. PCI-E传输瓶颈
在一个计算机节点增加更多的GPU通常会降低总带宽,但却实现了整体GPU数量的提升。如果使用单个GPU或使用多个GPU将所有数据都存储在GPU内存空间内,传输开销就会消除。通常增加GPU卡所造成的带宽缩减的范围很大程度上取决于主机端硬件。
压缩技术是一种明显增加PCI-E传输速率硬限制的技术。但是数据压缩之后存在一个问题,即通过压缩数据恢复出源数据。
使用流使计算与传输重叠进行或使用零复制内存。当PCI-E的传输时间超过内核执行时间时,使用零复制内存就可以完全隐藏计算时间。
传输瓶颈也包括主机端对内存带宽也存在一定限制。
每个计算机节点加载已经保存数据到诸如本地存储设备或网络存储设备的速度也是一个限制因素。
2. 内存瓶颈
假设数据从GPU端传输的问题已解决,接下来需要考虑的问题是全局内存的内存带宽。从时间和功率消耗的角度来看,移动数据的开销是非常大的。因此,考虑高效的数据存取已经数据重用是在选择一个合适算法时的基本标准。GPU拥有大量的计算资源,因此一个低效且GPU访存模式友好(合并,分片、高度本地化)的算法优于一个计算密集但GPU访存模式不友好的算法。
当考虑内存时,也需要考虑线程间的合作问题,而且线程合作最好限制在单一的线程块内。假设线程通信局限于小范围的通用算法比假设每个线程可以与所有其他线程对话的通用算法更有用。一般地,为旧式向量机设计的算法比为当前集群式计算机中N个独立处理节点的设计的分布式算法高效的多。
现代GPU中,一级缓存和二级缓存有时能出乎意料地对内核执行时间产生巨大影响。
通过确保计算的局部性来实现数据重用。我们可以通过将较大数据集划分为若干个小块,重复多次传输来替代之前一整块的传输方式。
尽管需要大量的内存事务,内存合并还是实现高内存吞吐量的关键。通过使用各种向量类型变量增加事务处理规模,从而优化指令集并行以及内存带宽。
3. 计算瓶颈
(1)复杂性
对于需要处理边界条件类函数,其控制逻辑复杂。多个if else。最好的解决方法是为每种边界情况单独写一个内核,或者让CPU来处理这些判断。
对于模板型问题,每个单元从周围N层的单元获取数据并以某种方式计算得到结果。由于每个单元的值需要通过周围单元的值计算得到,因此,会多次读取每个单元的值。解决方法是,使用多线程将数据分块读入到共享内存。无论是在读取数据还是写回数据,允许对全局内存的合并访问,从而达到性能提升。然而,共享内存在线程块之间是不可见,即共享内存只能在同一线程块中的线程之间共享,并且线程块与线程块之间也没有直接传输共享内存数据的机制。这主要是有CUDA的设计造成的,每次执行时,所有线程块中只有一部分线程块能够执行,因此,共享内存会在旧线程块撤出,新线程块调度之后重复利用。
按列加载每个单元会造成一系列独立的内存事务,效率很低。
通常,书写多个内核可以很好地消除控制流复杂性的问题。如果合适,这些内核还可以调用一个通用的程序来处理一系列的数据值。这样,数据获取的复杂性就移除了。
(2)指令吞吐量
与大多数处理器一样,不是所有指令在每个设备上运行的执行时间都是相同的。对于一个给定的处理器,选择正确的指令进行混合是编译器需要认真执行的工作。
现在,吞吐量和指令延迟并不等价。在计算出当前结果以供之后操作使用之前,可能需要花费20个时钟周期甚至更多。指令流水线中一系列的浮点操作可能在20个周期之后才开始执行,每条指令执行一个周期。因此,吞吐量是每个线程每个周期执行一条指令,但指令延迟则是20个时钟周期。
(3)同步和原子操作
许多算法都需要同步点。一个线程块执行同步的开销并不大,但却会潜在地影响性能。除非每个线程块包含的线程数特别多,否则CUDA调度程序会试图使每个SM最大限度地调度更多线程块,即每个SM调度器处理16个线程块。随着每个线程块线程数量的增加,SM能调度的线程块数量也相应的减少。这不会对程序造成很糟的影响。但如果结合同步,则可能导致SM堵塞。
当线程块执行同步时,大量可供调度的线程束变得不再可供调度,直到除最后一个线程束外的其他线程束到达同步点之后才能再次调度。解决同步问题的方案就是不使用包含大量线程的线程块。我们需要做的只是尽可能地完全填充SM,使其不闲置。
对于线程块间的同步,则可以通过全局内存实现。
使用性能分析工具深入挖掘实际结果与预期不同的原因;
通过生成普通情况和特殊情况的内核来避免复杂逻辑的内核,或通过缓存的特性完全消除了复杂的内核;
了解流控制中预测的工作机制;
不要假设编译器将会提供与其他更成熟的编译器相同级别的优化措施。
考虑如下一些主要因素:
主机到GPU的数据传入/传出;
内存合并;
启动配置;
理论和实际的占有率;
缓存的利用率;
共享内存使用率以及冲突;
分支;
指令级并行;
设备计算能力;
根据启动配置,我们尽可能优化以下方面:
每个块的线程数:
全部的块的数目;
每个线程执行的任务(指令级并行);
使用向量类型将增加寄存器的使用,反过来又可能会降低每个SM中线程块的驻留数目,也会进一步提高缓存的利用率。内存吞吐量也将可能随着内存事务的总数下降而增加。然而,有同步点的内核可能会受到影响,因为随着驻留块数的下降,SM中可供调度的线程束变少了。
正如许多优化方法一样,其结果是难以预料的,因为有些因素会符合你的想法而另一些没有。最好的解决方法就是尝试和检查。然后回退,以理解那个或者那些因素是主要的,那些是次要的。不要浪费时间在次要因素上,除非主要因素已经解决。
如果没有尝试过,有太多因素让你无法断定改变会带来什么效果;
在开发时,需要进行一些实验以获取最好的解决方法;
不同的硬件平台上的最佳解决方法是不同的;
编写应用程序时,要意识到会碰到各种硬件,并要知道每个平台哪些能工作的最好。
影响性能的因素主要是传输、内存/数据模式、SM利用率。
应该知道,优化是一个耗费时间和反复的过程,它会增加对代码已经硬件是如何起作用的理解。反过来,因为更熟悉什么可以或者不能很好地工作在GPU上,从而从一开始就能设计和编写出更好的代码。