节选自正在写的书稿,还没有配图。这一部分是第三章硬件介绍的一部分,在之前的小节里已经介绍了显卡的组成和一般知识,以及GPU的架构简介。这一节专门介绍CUDA程序如何映射到硬件上,希望对大家有所帮助。
由nvcc生成的通用计算程序分为主机端程序和设备端程序两部分。那么,一个完整的CUDA程序是如何在CPU和GPU上执行的呢?在这一节,我们不仅将介绍CUDA的编程模型如何映射到硬件上,还会介绍GPU的硬件设计如何对CUDA程序效率产生影响。
通常,在计算开始前,需要将要计算的数据通过API从内存拷贝到显存中,再在计算结束后将数据从显存拷贝回内存。通过CUDA API的存储器管理功能进行数据传输,不需要SPA中的运算参与。CPU先通过存储器管理API在显存上开辟空间,将内存中的数据由北桥经过PCI-E总线传到显存中。在bandwidthTest例子中我们介绍过,主机端内存可以分为两种:pinned(page-locked)或者pageable memory。一般的操作系统使用了虚拟内存和内存分页管理,这种设计在带来种种好处的同时,也使得新开辟的空间可能会被分配在低速的磁盘上,或者频繁改变地址,对提高单个程序的速度不利。使用cudaMallocHost开辟的pinned memory的必定存在于物理内存中,而且地址固定,可以有效的提高主机端与设备端的通信效率。此外,只有pinned memory才能使用CUDA API提供的异步传输功能,允许在GPU进行计算时进行主机和设备间的通信,实现流式处理。虽然使用Pinned memory有许多好处,但是实际使用中不能分配太大的pinned memory,否则操作系统和其他应用程序就会因为没有足够的物理内存而使用虚拟内存,降低系统整体性能。现在,数据已经准备好了,指令又是如何传给GPU的呢?
在对CUDA的介绍中我们已经知道,前缀为__host__的程序运行在主机端,前缀为__global__或者__device__的函数运行在设备端。我们知道,计算机是执行二进制的机器代码的,CPU如是,GPU也如是。CUDA程序的二进制代码由两部分构成:主机端代码和设备端代码。在调用nvcc编译器编译cuda程序时,nvcc本身只负责编译运行在设备端的函数,主机端的函数是由其他编译器编译,而链接也是由开发环境中的链接器进行,最后生成的可执行程序或者库从外观上看和一般的程序或者库没有什么不同。在执行CUDA程序时,主机端执行的二进制代码和一般程序并没有什么不同,只是在调用核函数时可以将设备端代码通过CUDA API传给显卡。注意,GPU传给CUDA API的设备端代码不一定是二进制代码cubin,也可能是运行于JIT动态编译器上的PTX代码。最后传到显卡上的是适合具体GPU的二进制代码,其中的信息稍多于ptx或者cubin,这是因为cubin或者ptx只包含了block一级的信息,而不包括整个grid的信息。目前在GPU上可以运行的指令长度仍然有限制,不能超过两百万条ptx指令。
GPU端二进制代码主要包括线程网格的维度,线程块的维度,每个线程块使用的资源数量,要运行的指令,以及常数存储器中的数据。之前我们介绍过,SM是GPU中的完整的核心,而TPC则更多的是平衡单元硬件比例形成的单位。现在,可以向SPA中的各个SM分发任务了。任务分发是由计算分发( compute scheduler)单元完成的,分发的单位是协作线程阵列(CTA,Collaborative Tread Arrays)。如果觉得CTA的名字太绕口,它的另一个名字你一定已经很熟悉了:block。block是CTA在编程模型中的表述,由于一个block中的线程使用同一块shared memory,因此一个CTA里的所有线程也就必须被分配同一个SM中。计算分发单元采用了轮询算法,它的任务是尽可能平均的将CTA分发到各个SM上,同时在每个SM上分配尽可能多的CTA。SM以warp为单位执行CTA,在同一个SM上可以同时存在多个CTA的上下文,但一个时刻只有一个warp正在被执行。在第一章我们已经介绍过,GPU是通过在不同warp间进行切换来隐藏长延时操作,实现高吞吐量的。如果SM只分配了一个CTA,由于属于同一个CTA的若干个warp会有比较大的几率会同时进入长延时操作,就会使SM中的执行单元处于长时间等待中。而属于不同CTA的warp在同一个SM上执行时,当一个CTA的所有warp都进入长延时操作时,在其他CTA中会有较大几率存在处于就绪态,可以被马上执行的warp,可以更好的隐藏延时。这就是active block问题,即在一个SM上最多能够同时分配多少个CTA。那么,如何计算active block呢?首先,我们要知道限制active block数量的因素,它们是:
每个SM中最多能够同时存在的block数量:在所有计算能力的硬件上都是8个。
每个SM中最多能够同时存在的warp数量:在计算能力1.0和1.1的硬件上是24,在1.2和1.3硬件上是32。这也意味着在1.0和1.1硬件上最多能够同时存在768个线程,在1.2和1.3硬件上最多能同时存在1024线程。注意:同一warp中的线程必须全部来自同一block,并且同一block中的所有warp必须全部同时存在。每个SM上最多能够存在的线程数量和每个block中最多的线程数量是两个概念,一个是硬件限制,一个是编程模型的限制,要区分清楚。
每个SM中拥有的register数量,register的最小单位是大小为32bit(4Byte)的寄存器文件。计算能力1.0和1.1的硬件中的每个SM拥有8192个寄存器文件,而计算能力1.2和1.3硬件中的SM拥有16384个寄存器文件。翻倍的寄存器文件大小是GT200的重大改进之一,它使得GT200的可编程性和计算性能都上了一个台阶。
每个SM中的共享存储器大小,目前所有计算能力硬件中的每个SM都拥有16KB的共享存储器。
知道了每个SM的资源上限,计算分发单元就可以知道最多能在一个SM上分配多少个CTA。比如,假设我们有一个由256 thread(8 warp)构成的block,每个thread占用16个寄存器文件,同时整个block使用了3K的shared memory,那么我们可以这样计算只考虑单个因素影响时的active block数量:
warp限制的active blcok数量:
在计算能力1.0/1.1硬件上 24/8=3
在计算能力1.1/1.2硬件上 32/8=4
Register限制的active blcok数量:
在计算能力1.0/1.1硬件上8192/(16×256)=2
在计算能力1.1/1.2硬件上16384/(16×256)=4
Shared memory限制的 active block数量:
在所有计算能力硬件上16384/3072=5
单个SM上的最大block数量:
在所有计算能力硬件上都是8
最终的active block数量就是考率以上所有单个因素影响后计算出来的最小值。在刚才讨论的问题中,计算能力1.0/1.1硬件收到寄存器大小限制,使得active block数量只有2,而计算能力1.2/1.3硬件的active block数量达到了4,更有利于隐藏延时。
在实际的程序编写过程中,active block数量会对程序的性能造成相当大的影响,因此必须考虑。但是一定要记住最终的目的是要使程序的运行时间最短,而不是增加active block数量,有时使用大量的高速存储器反而会达到更高的效率。在算法确定的情况下,可以考虑平衡register和shared memory的使用,以及调整block中的线程数量来增加active block数量。Active block只是程序性能的间接衡量标准之一,较小的block可以比较容易的实现较多的active blcok,却可能因为其他因素造成性能上的损失。我们将在第四章优化的第 节介绍如何对active block进行优化,在第 节介绍如何减少register的使用。
在任务分发后,现在SM终于可以开始进行计算了。在前面已经介绍过,一个SM相当于一个完整的处理器,每个SM中含有8个SP。那么,为什么指令要以32线程组成的warp为单位发射执行呢?为什么又存在由16个线程构成的half-warp?要回答这些问题,我们必须了解一下SM的详细组成,请参考对GT200架构的介绍
GPU中的器件总的来说分别工作在三个不同的时钟域内,分别是计算单元工作的处理器频率,存储器控制单元工作在GDDR DRAM IO频率,而纹理流水线,ROP单元以及SM内除了计算单元外的其他单元都工作在GPU核心频率。实际上由于GDDR DRAM的核心频率和IO频率也不相同,我们经常会在Nvidia GPU规格说明中看到三到四个不同的频率。计算单元工作在处理器频率;而取指,发射,各种缓存,寄存器,共享存储器等单元都工作在GPU核心频率。
取指与发射
取指和发射单元在物理上是紧密相连的。取指单元从DRAM中取出指令,并装载于指令缓存中。由于GPU没有分支预测单元,因此在出现分支或者循环时有可能会发生指令缓存不命中。指令缓存的设计属于商业秘密,因此没有更多细节可供参考。
处理器频率大约是GPU核心频率的两倍多,因此SM中的8个SP每两个处理器周期才能访问一次存储器,或者接受一条新的指令。也就是说,很多操作是以每16个线程为一组完成的。16在CUDA中是一个神奇的数字,在考虑性能优化时half-warp比warp更加重要。
SM中的指令是以warp为单位发射的,在属于同一个warp线程间进行通信不需要进行栅栏同步。虽然SP可以设计成接受一条新的指令然后执行两遍,对应的warp大小是16;但实际上SP接受一条指令执行了四遍,warp大小就成了32。将Warp大小定为32而不是16是综合多方面因素的结果,其中一个原因是为了实现双发射超标量并行,我们将在稍后进行介绍。
那么,一个SM中的所有active warp又是按照什么顺序发射的呢?为了避免各种可能影响执行的问题,发射逻辑对指令设置了优先级。当一条指令需要用到的寄存器和shared memory资源都处于可用状态时,这条指令的状态将被设置为就绪态。在每个时钟周期,发射逻辑从缓冲中选区中优先级最高的就绪态指令。发射逻辑电路使用一个加权算法计算各个active warp指令的优先级。优先级要受warp类型,指令类型和其他一些因素的影响。
如果一个warp中有多条处于就绪状态的指令等待执行,这些指令将被连续发射,直到重新计算状态和优先级,或者发射逻辑选择了另外一个warp进行发射。这意味着优先级策略实现了简单的乱序执行功能。一个warp的指令中可能先存在一次延迟很长的访存,然后是一次计算;但在实际运行中,如果计算不依赖访存得到的数据,可能出现计算在访存完成之间就已经结束的情况。这种乱序执行与现代CPU使用的乱序执行技术相比非常简单,作用也相对有限,但却是能够极大的节省晶体管和能耗方面的开销。
寄存器(register)
寄存器拥有很高的带宽。计算能力1.2/1.3硬件的每个SM拥有64KB的寄存器(Register Files),而计算能力1.0/1.1硬件的每个SM只有32KB寄存器。寄存器的基本单位是宽度为32bit(4Byte)的寄存器单元,这样计算能力1.2/1.3的每个SM就有16K个寄存器单元,每个SP能够平分到2K个寄存器单元。每个寄存器单元的宽度为32bit,所以64bit数据类型(双精度浮点和64位整数型)将占用两个相邻的寄存器单元。JIT/驱动能够动态的为线程块分配寄存器,而每个线程占用的寄存器大小则是静态分配的,在线程块寿命期间都不会更改。这就是说,每个线程占用的寄存器资源是由这个线程使用最多寄存器的时刻决定的。每个独立线程能够拥有4到128个寄存器单元。
共享存储器(Shared Memory)
GT200的每个SM拥有16KB shared memory,用于同一个block内的thread间通信。为了使一个half-warp内的thread能够在一个周期内并行的访问shared memory, shared memory的4096个入口被组织成为16个bank,每个bank拥有32bit的宽度。如果half-warp内有若干个线程访问的数据处于同一个bank中,就有可能出现bank conflict。当不发生bank conflict时,访问shared的延迟与register相同,否则多个线程就要串行的对处于同一Bank中的数据进行访问。在不同的block之间,shared memory是动态分配的。在同一个block内,所有的线程都能够访问shared memory。CUDA编程模型中的Shared memory是进行线程间低延迟数据通信的唯一方法,因此其地位至关重要。
1.2版本以后的硬件的支持对shared memory的原子操作。这里的原子操作是指保证在每个线程能够独占的访问存储器,即只有当一个线程完成对存储器的某个位置的操作以后,其他线程才能访问这一位置。1.1版本的硬件只能支持对global memory的原子操作。访问global memory需要很长的访存延迟(长达数百个时钟周期),性能很低。在GT200及以后的GPU上,可以支持对shared memory中32bit操作数的原子操作指令。对一个寄存器的原子操作很容易实现,以后的GPU也许能够在shared memory里实现对横跨两个寄存器的64bit字进行原子操作。
在实际使用中,大多数从显存中读出的数据都会被写到register中,而不是写到shared memory中。因此,为了简化设计,GPU的结构里shared memory和global memory没有直接连接起来。要把global memroy中的数据写到shared memory中,必须先把数据写到寄存器里,然后才能转移到shared memory中。编译器可以透明的完成这一过程,使用cuda C编程时可以将global memory中的值直接赋给shared memory。
由于各个warp在CUDA中的执行顺序并没有什么规律,因此各warp执行的进度也不尽相同。因此使用shared memory进行线程间通信时,必须进行调用一次__syncthreads()函数进行同步。调用syncthreads()内联函数将进行过一次栅栏同步(barrier),它保证属于同一个block中的所有warp都完成栅栏同步前的操作后才能继续进行。SM可以完成对512个线程的栅栏同步,因此一个Block中最多只能有512个线程。
执行单元
当代GPU必须拥有丰富的执行资源和强大的计算能力。Nvidia公司通过SIMT-一种SIMD的变形实现了这一目标。如上文所述,SIMT在提供了与SIMD相同性能的同时,还可以通过改变线程数量适应不同的执行宽度。
图五中的计算单元的运行频率是取指单元、调度单元、寄存器或共享存储器的两倍。在每个时钟周期(对高速计算单元来说是两个快时钟周期)可以执行一条新的warp指令。
Tesla架构中最主要的执行资源是8个32bit ALU和MAD(multiply-add units,乘加器)。它们能够对符合IEEE标准的单精度浮点数(对应float型)和32-bit整数(对应long int型,或者unsigned long int型)进行运算。ALU和MAD运算至少需要4个处理器周期才能完成一次计算。在每个处理器周期,ALU或MAD可以取出一个warp 的32个线程中的8个的操作数,在随后的3个时钟周期内进行运算并写回结果。
控制流指令(CMP,比较指令)是由分支单元执行的。如前文所述,GPU没有分支预测,因此分支在得到执行机会之前将被挂起。一个分支warp指令也需要4个时钟周期来执行。
除了标准的功能单元以外,每个SM还拥有两个能够执行不那么常用的运算的执行单元。第一种是用来处理寄存器中的64位浮点和整型操作数的64bit乘加单元,每个SM中有一个这样的单元。这种双精度FMA单元能够支持标准的IEEE754R对双精度操作数的要求,可以进行异常处理,也能够进行64bit整型算术。它能够完成带有舍入的乘加运算,支持高精度的类型转换。由于这样的单元在每个SM中只有一个,因此GPU的双精度计算速度只有单精度速度的1/12-1/8也就不足为奇了。Nvidia已经充分注意到了双精度运算对通用计算的重要性,下一代产品的双精度将会得到很大提高。
第二种是特殊函数单元,用来执行一些特殊指令。SFU用来执行超越函数,插值,倒数,平方根倒数,正弦,余弦以及其他特殊运算。CUDA中提供了一些带有__前缀的函数,其中一部分就是由SFU执行的。SFU执行的指令大多数有16个时钟周期的延迟,而一些由多个指令构成的复杂运算,如平方根或者指数运算需要32甚至更多的时钟周期。SFU中用于插值的部分拥有若干个32-bit浮点乘法单元,可以用来进行独立于FPU的乘法运算。SFU实际上有两个执行单元,每个执行单元为SM中8条流水线中的4条服务。向SFU发射的乘法指令也只需要4个时钟周期。
Dual 'Issue' 并发执行
Nvidia的微架构设计中,吞吐量与延迟之间的关系十分微妙。SP执行一条指令再怎么也有至少4个周期的延迟,而SM每两个处理器周期就能发射一条指令。SM在发射一条warp指令后,SP需要一段时间才能执行完毕,那么就有可能在这段时间里再发射一条指令,这种能力被Nvidia称为‘dual issue’双发射超标量并行。‘dual issue’实际上是让不同种类的功能单元能够同时运行。SP单元在工作时,其他的执行单元也能执行其他的warp指令。
SM每两个时钟周期就能发射一条warp指令。在第一个周期,一条MAD指令被发射到FPU单元。两个时钟周期以后,一条MUL指令被发射到了SFU单元。又过了两个时钟周期以后,FPU单元开始执行另一条MAD指令。再过了两个时钟周期,SFU开始执行一条占用很长时间的超越函数指令。应用这项技术能够使渲染核心的计算吞吐量提高50%,同时每两个时钟周期只需要发射一条指令就能满足需要,大大降低了标志设置和优先级逻辑的复杂度。不是所有的指令组合都能够并发执行。例如,双精度浮点单元和单精度浮点单元共享了一部分逻辑,因此无法同时使用。
我们已经介绍了SM如何执行一条指令。既然有了这些执行单元,那么当然也需要一套机制来从显存读写数据。接下来我们将介绍GPU如何对显存进行访问。
纹理,渲染和存储器流水线
现代GPU使用纹理流水线和渲染流水线进行数据的输入输出。由于CPU需要保证存储器一致性,因此它的读取和存储单元是核心中密不可分的一个部分;而GPU中的纹理和渲染输出流水线则与GPU的计算核心相对独立,同过一个互连总线连接。在GT200中,每个TPC中拥有三个SM,一条纹理流水线和一个与渲染输出单元(render output units, ROP)通信的端口。由于本文将GPU作为计算设备来介绍,因此也就不去深究纹理流水线和ROP单元在图形学的作用,而把重点放在如何在通用计算中使用它们。GT200的读取和存储流水线的结构分为两个部分,A部分属于一个TPC,而B部分则属于一个存储器控制器一端。A部分和B部分通过GPU片上互连连接,两者不是一一对应关系,一个TPC可以对GPU中的任意一个存储器控制器提出访问请求。
用于访问显存的装载(Load)和存储(Store)指令是由工作在GPU核心频率的SM生成的,但这些指令会被发射到一个工作在存储器IO频率的硬件上执行,因此SM控制器需要协调纹理单元和SM的不同时钟。读取和存储指令首先从SM被发送到TPC中的SM控制器,再由SM控制器负责对存储器流水线访问的管理。在这里,指令被分为两路:直接对显存进行读操作的装载流水线,对显存进行写操作的存储(ROP)流水线,以及通过纹理机制访问现存的纹理流水线。纹理流水线和装载取流水线共享了一部分硬件,所以不能被同时使用。
通过装载流水线读取显存中的数据,首先要计算地址。地址可以由存储器访问指令中的寄存器中的值与地址偏移量相加得到,然后将计算得到的40bit虚拟地址(在G80中是32bit)转换为存储器控制器使用的物理地址。在地址计算完成后,读取指令将以一个warp为单位通过片内互连发射到存储器控制器。对储存命令的处理与装载类似:首先计算地址,然后向通过片内互连发送到ROP单元,再发送到存储器控制器执行。原子操作指令同样也需要经过存储流水线和ROP单元发送。每次存储器访问时指令是按照warp发射的,但存储器控制器是按照half warp执行这些指令的,也就是一次最多能够并行处理16个访问请求。
为提高访存效率,存储器控制器会尽量成批执行对同一显存bank的同方向的读或写操作。因此在CUDA程序中thread数量较多的block通常可以获得更好的存储器访问性能,不过较多的线程会使每个线程可以使用的寄存器数量较少。
存储器流水线拥有两级纹理缓存。纹理缓存与CPU使用的缓存有一些不同之处。首先,CPU的缓存往往是一维的,因为大多数的架构中的存储器地址是线性的。当访问一个只有4-8Byte的数据字时,会取出一个缓存单元中所有的64Byte数据。因为CPU处理的数据往往有很强的相关性,因此多取出的相邻的50-60Byte数据也有可能会被用到。CPU处理的数据是大多只有一个维度,因而其缓存也在一个维度上连续,只需要预取“前后”的数据,;GPU需要处理的纹理是连续的二维图像,因此纹理缓存也必须是在两个维度上连续分布的,需要预取“上下左右”的数据。典型的GPU纹理流水线会计算需要装载数据的地址,将二维的纹理存储器空间映射为一维。
其次,纹理缓存是只读的,并且不满足缓存数据一致性。当显存中的纹理被修改以后,纹理缓存中的数据并不会被修改。每次修改纹理必须更新整个纹理,而不是纹理中被修改的一小部分。
第三,纹理缓存的主要功能是为了节省带宽和功耗,而CPU的缓存则是为了实现较低的延迟。纹理缓存提高不了随机访问数据的性能,也无法减小访存延迟。但使用纹理缓存可以通过利用数据的局部性提高性能,节省存储器带宽。
GT200中,每个TPC拥有的一级纹理缓存是24KB,但是被分为三个8KB的块。L2纹理缓存位于存储器控制单元中,每个大小是32KB,所以整个器件已共有256KB。
使用装载流水线和存储流水线对global的存储器访问时没有缓存,因此显存的性能对GPU至关重要。影响显存访问效率的因素主要有三个:对齐,合并访问,以及分区。
为了高效的访问显存,读取和存储必须对齐到4Byte宽度,也就是说每个数据占用的存储器空间必须是4Byte的整数倍,否则读写将被编译器拆分为多次操作,极大的影响效率。
一个half-warp的读写操作如果能够满足合并访问(coalesced access)条件,那么多次访存操作会被合并成一次完成,从而提高访问效率。
在1.0和1.1器件的合并访存条件十分严苛。首先,访存的开始地址必须对齐:16x32bit的合并必须对齐到64Byte(即访存起始地址必须是64Byte的整数倍);16x64bit的合并访存起始必须对齐到128Byte;16x128bit合并访存的起始地址必须对齐到128Byte,但是必须横跨连续的两个128Byte区域。其次,只有当第K个线程访问的就是第K个数据字时,才能实现合并访问,否则half warp中的16个访存指令就会被发射成16次单独的访存。
在新的1.2版本以上的硬件的存储器控制器有很大改进,更加容易实现合并访问。这些改进并不会提高GT200的纸面性能,但在某些应用中却可以将性能提高一个数量级。
首先,一次合并访问中的线程编号和数据字位置不需要相等,顺序可以随意。
其次,新的存储器控制器可以支持对8bit和16bit数据字的合并访问(分别使用32Byte和64Byte传输)。
最后,存储器控制器可以首地址没有对齐的访问进行拆分。当访问128Byte数据时如果地址没有对齐到128Byte,在老版本硬件中会产生16次访存指令发射,而在新版本中只会产生两次合并访存。例如,一次128Byte访存中有32Byte在一个区域中,另外一个区域中有96Byte,那么只会产生一次32Byte合并访存(对有32Byte数据的区域)和一次128Byte(对有96Byte数据的区域),而不是两次128Byte合并访问。
分区冲突(partition camping)问题是由多个存储器控制器之间不均衡引起的。Tesla架构中的每个存储器控制器可以提供64bit位宽,每个存储器控制器负责对若干片显存颗粒的操作,称为一个分区(partition)。整个GPU的带宽就是所有存储器控制器带宽之和。在G80数据在显存上的分配方式是:相邻的分区存储相邻的数据块,每个数据块的大小为256Byte。
如果从SPA产生的读取和装载指令能够均匀的被发射到各个存储器控制器,那么所有的存储器控制器就能同时并行工作,可用的带宽就比较大;如果所有的访问请求都被集中发射到少数存储器控制器,那么有效带宽就只由这一部分存储器控制器提供,而其他存储器控制器则处于闲置状态。
可以看出,global memory分区冲突的产生机制与shared memory的bank conflict类似,都是由存储器bank间的存储器访问请求负载不均衡引起的。分区问题是在一个比较大的宏观范围内产生的,与数据并行划分方法关系比较紧密;而bank conflict和合并访问问题则是在一个half-warp内的微观层次上产生的。一般来说,合并访问对性能的影响最大,在访存延迟被充分隐藏后bank conflict的影响开始突出,分区冲突的影响通常只有在转置等少数应用中需要考虑。