CUDA:Supercomputing for the Masses (用于大量数据的超级计算)-第四节
了解和使用共享内存(1)
Rob Farber 是西北太平洋国家实验室(Pacific Northwest National Laboratory)的高级科研人员。他在多个国家级的实验室进行大型并行运算的研究,并且是几个新创企业的合伙人。大家可以发邮件到rmfarber@gmail.com与他沟通和交流。
CUDA(Compute Unified DeviceArchitecture,即计算统一设备架构的简称)开发人员面临的一个最重要的性能挑战就是:最佳利用本地多处理器内存资源如共享内存,常量内存和寄存器。本系列文章里第三节中讨论的原因就是: 当全局内存传输超过60GB/s,这也就相当于数据的接触点使用仅为15GF/s。如要获得更高的性能,就需要重新使用本地数据。CUDA软件和硬件设计人员已经进行了一些工作,以隐藏全局内存延迟和全局内存带宽限制――但这都是以本地数据重用为前提的。
回忆下第二节,内核启动需要指定一个执行配置,以确定构成块的线程数和结合起来构成一个网格的块的数量。需要特别注意的是,块内的线程可通过本地多处理器资源彼此通信,因为CUDA执行模型指定,仅可在单一多处理器上处理块。换言之,在块内写入共享内存的数据在这个块内可被其它线程读取,但是不可被来自其它块的线程读取。具备这些特征的共享内存可在硬件内被有效执行,这对CUDA开发人员来说也就意味着快速内存读取(简单探讨一些警戒事项)。
现在,我们有一个方法可以让使用CUDA硬件的设计人员平衡产品价格和CUDA软件开发人员的需求。作为开发人员,我们想要大量的本地多处理器资源如寄存器和共享内存。因为这样我们的工作就变得简单得多,我们的软件也更为快捷有效。硬件设计人员则需要以较低的价位推出硬件产品。不幸的是,本地多处理器内存比较昂贵。我们都认同廉价的CUDA硬件也很出色,因此CUDA启动的硬件被设计成具备不同能力的产品,以在市场上以不同的价位出售。市场然后根据能力平衡来决定适当的价格。这实际上是一个很好的解决方案,因为技术发展迅猛-每一代的CUDA启动设备都比上一代的设备要强大很多,包含了更多的更高性能的组件,但是价位却没有什么变化。
等待吧!这听上去像是一个令人头疼的软件问题,而不仅仅是一个折中方法,因为CUDA开发人员需要应对这些不同的硬件配置,我们还面临着设备资源的有限数量的挑战。为此,我们创建了一些设计助手以帮助为不同的构架选定“最佳”高性能执行配置。我强烈推荐下载和使用CUDA 占有率计算器(occupancy calculator),它其实就是个出色的电子数据表。(当传递ptxas-options=-voption,如寄存器数量和本地,共享和常量内存使用时,nvcc编译器将为每个内核报告电子数据表所需的信息)。在论坛和文章里常常会看到的一句忠告就是“尝试不同的配置,比较对性能的影响”。这做起来很简单,因为执行配置由变量确定。实际上,在被安装时,诸多应用程序可能会有效自动配置(如,确定最佳的执行配置)。同样,CUDA运行时调用cudaGetDeviceCount() 和cudaGetDeviceProperties()提供了一种在系统内列举CUDA设备和检索它们属性的方法。使用这类信息的一个可能的方法就是:为最佳性能的执行配置进行数据表查找,或启动自动优化。
CUDA执行模型
为了提高性能,每个硬件多处理器都能同时积极处理多个块。块的数量取决于每个线程的寄存器的数量,每个块的共享内存是由给定的内核提出要求的。在同一时间被同一多处理器处理的块被称为“活动的”。对资源要求最低的内核可以更好地利用(或占用)每个多处理器,因为多处理器的寄存器和共享内存在所有的活动的块的线程之间被分开。使用CUDA占有率计算器来寻求线程数量和活动的块与寄存器数量和共享内存量之间的平衡。找到正确的结合点可以极为有效地加强您的内核的性能。如果每个多处理器上没有足够的寄存器或共享内存可以处理至少一个块,内核可能就不能启动(参见第三节中关于cudaGetLastError() 的讨论,以确定如何发现和处理这类故障)。
每个活动块被分割成线程SIMD(单指令多数据)群,称为Warp:每个Warp包含同样数量的线程,称为Warpsize,被多处理器以SIMD方式执行。这意味着Warp中的每个线程传递的都是指令库中的相同指令,指导线程执行一些操作或操纵本地和/或全局内存。从硬件角度看,SIMD模型效率高而且经济有效,但从软件角度来看,很遗憾,它会使条件操作序列化(比如,使条件句的两个分支都必须被求值)。请注意条件操作对内核运行有深刻的影响。谨慎使用的话,一般是可以控制的,但也有可能引起某些问题。
活动Warp(比如所有活动块的所有Warp)是以时间片分配的:线程调度程序定期从一个Warp切换到另一个Warp以最大限度利用多处理器的计算资源。块之间或块中的Warp之间的执行次序是不确定的,也就是说可以是任何次序。但是,线程可以通过__syncthreads()进行同步。请注意只有执行__syncthreads()后,才能保证写入共享(和全局)内存是可见的。除非变量被声明为易失性(volatile)变量,否则编译器可以被用于优化(例如重新排序或消除)内存的读和写,以提高性能。
__syncthreads()允许在一个条件句范围内被调用,但只有当条件的求值方式在整个线程块中都相同时才可以。否则的话,代码执行过程可能会被挂起或产生意想不到的副作用。可喜的是,__syncthreads()的开销很低,因为在没有线程等待任何其他线程的情况下,它仅花费4个时钟周期为一个Warp发送指令。Half-Warp指的是Warp的前一半或者后一半,这是内存访问包括本期后面将要讨论的合并内存访问的一个重要概念。
上面的讨论有几条内容需要继续记住:
- 多处理器资源如共享内存是有限的,并且较有价值;
- 有效管理有限的多线程资源,如共享内存,因为诸多的CUDA启动设备配置实际上对CUDA开发人员来说都是至关重要的。
- 条件操作(如if 语句)会对你的内核运行时产生重要影响。
CUDA占有率计算器和nvcc编译器是非常重要的共图,我们要好好使用和学习-特别是当我们琢磨执行配置的时候。
CUDA内存模式
- 每个多处理器,如上面的块(0, 0) 和块(1, 0)所示,包含有以下四种内存类型;
- 每线程一套本地寄存器;
- 平行数据缓存或共享内存由所有线程共享,执行共享内存空间;
只读常量缓存由所有线程共享,从常量内存空间加速读取。这是作为设备内存的只读区执行的(常量内存将在下一专栏里讨论。届时请参阅CUDA Programming Guide的5.1.2.2章节)。
所有处理器共享的只读纹理缓存,加速从纹理存储器空间的读取。这是作为设备内存的只读区执行的(在接下来的文章里会探讨纹理存储器。届时请参阅CUDA Programming Guide的5.1.2.3章节)。
不要弄混淆的是,该图示包括一个在多处理器内的标识为“本地内存”的块。本地内存意味着“在每个线程范围之内”。它是内存抽象,而不是多处理器的一个实际的硬件组件。事实上,本地内存通过编译器在全局内存内进行分配,和其它任何全局内存区一样提供完全相同的性能。本地内存基本上由编译器使用,保存任何程序员认为相对线程来说为“本地”的内容,这些内容因为一些原因不适合更快的内存。在正常情况下,在内核里宣布的自动变量存于寄存器中,可快速读取。在有些情况下,编译器可能会选择将这些变量放置在本地内存中,譬如当有太多寄存器变量,数组包含有四个以上的元素,一些结构或数组可能会小号太多寄存器空间,或当编译器不能确定数组是否根据常量索引定址。
注意,因为本地内存可能会导致性能降低。对ptx汇编代码的检查(通过使用-ptx 或 -keepoption获得)可判断在第一编译阶段,变量是否已经存于本地内存中,因为变量是通过使用.local助忆符来声明的,使用ld.local和st.local助忆符读取。如果在最初阶段没有将它放置到本地内存,那么在随后的编译阶段如果发现它可能会消耗目标架构太多的寄存器空间也可能会决定将它放置在那里。
一直到下一个专栏之前,我都推荐使用占有率计算器,以扎扎实实地了解执行模型和内核启动执行配置是如何影响寄存器的数量和共享内存的数量的。