如果我们仔细观察,可以发现,全称为图形处理单元的GPU,其最基本的计算部门——SM,已经跟图形没有半毛钱关系了。这一切都是NVIDIA有意为之,把各种图形相关的操作剥离成独立的硬件单元(Geometry Controller、Raster等),把计算单元SM解耦出来,以开启和拥抱更加广阔的并行计算市场。
通用计算管线与图形管线相比,还有一个最大的不同:我们分配的这些线程是协作式的,我们可以根据它们的线程ID分配它们干不同的活,它们之间还需要数据传递。也不是说图形管线发出来的线程完全不需要协作和数据传递,只是所有的协作模式已经被定死了,不需要我们操心,比如:执行片元任务的时候是以每四个线程为单位的,以方便计算它们之间数据的差值(ddx、ddy)。
正因为通用管线释放了这么多的自由度,使得其性能的上限被提高了,我们可以根据具体任务,分配线程,并设计它们的协作模型以及数据依赖关系。不过,性能的上限是需要我们自己去探索达到的,如果缺乏对硬件的基本理解与优化技巧,深不可测的性能下限也在那等着我们。
CUDA线程的三级组织结构为:Grid-Block-Thread,每个Block包含多少线程在核函数中写死,Block是协作发生的组织单位(因此也被称为CTA,cooperative thread array),里面的线程可以通过共享内存传递数据。每个Grid包含多少Block,由应用程序在每次调用时指定,同一个Grid的所有Block之间则是完全独立的,没有数据依赖。
因为线程与任务之间的映射是由我们决定的,那么我们自然需要知道每一个线程的ID,才能通过它获取到对应的数据,执行对应的计算任务,将计算结果写到对应的内存中。不过因为其三级组织结构,而且每一级结构可以支持三维的索引(上图只显示了2维),光有一个ID可能不能满足我们的需求。当然我们都可以自己算出来,但鉴于其需要被高频使用,因此不同API全都提供了一堆内置变量,来描述ID的不同表示,以避免我们自己计算浪费性能。
真正的并行单元是Warp又是三级线程组织结构,又是三维的线程ID映射,初学起来让人头晕脑胀,但是我们别忘了,无论上层的概念如何复杂,底层的硬件执行单元都是SM,真正的并行单位始终是Warp。因此优化的基础大多是以Warp为主角的,比如:
最好为每个Block分配Warp线程数(32)的整数倍线程数。因为不管多少线程都要拆分成Warp单位去执行,33个线程与64个线程同样需要执行两个Warp。
同一分支要尽可能挤到同一Warp里。如果设计的算法中,不同的线程不得不执行不同的分支,比如Warp1要执行分支A和B,Warp2也要执行分支A和B,如果能让Warp1只执行分支A,而Warp2只执行分支B,就能获得性能的提升。(因为每个线程要做什么都是我们说了算,因此给了我们这样的优化空间)
如果内存读写指令都只由一个Warp执行,那么无需同步。因为一个Warp内的线程本身就是锁步运行的,因此肯定不需要同步。但如果是不同Warp之间存在数据依赖,则不得不同步。比如一个先写后读的常见例子:Warp1的线程要读取由Warp1其他线程写入到共享内存的数据,则无需同步,因为该数据必定已经被写入;而Warp1的线程要读取由Warp2线程写入到共享内存的数据,则必须同步,因为Warp1开始执行读取指令的时候,Warp2执行到哪里了则无法预料,因为Warp的调度是硬件决定的,对程序员是不透明的
Global Memory与L2 cache
每一块显存都会与GPU内的对应的一个L2 cache相连。L2 cache是在SM之外的,因此可以供所有SM共同使用。不同Grid的执行是串行的,如果他们存在对同一块显存的数据依赖,则由硬件负责同步。一个Warp中的连续线程访问连续的内存,可以被合并为一条内存读取指令,能显著提高效率。
Shared Memory与L1 cache
共享内存和L1 cache位于SM中,Tesla架构的L1 cache只用于缓存纹理数据,之后的架构则和共享内存占据相同的硬件单元,并可以由用户配置两者的大小。
通用管线的协作线程模型能够高效运行的秘密全在共享内存上,因为其位于SM中,读写速度肯定远高于主存与L2 cache
而共享内存是对一个block内的所有线程可见的,这意味着一个block内所有线程必定位于同一个SM中。而同一个grid的不同block则不一定,因此不同block之间是老死不相往来无法通信的(如果想实现更高自由度更多粒度的同步,可以使用合作组)。这也是为什么一个block的最大线程数有所限制,因为一个SM能容纳的Warp是有限的。
一个warp32线程可能需要同时对共享内存进行读写,因此需要考虑bank冲突的问题。(只需要考虑同时写的问题,同时读因为有广播机制的原因所以不考虑)。
共享内存的带宽是有限制的,支持同时32个bank的读写,但是每个bank只能读写32位数据。多个地址会被映射到同一个bank,如果有多个线程想要同时读写同一个bank的数据,则不得不变成串行执行。读内存现在的硬件都有广播的功能,不会出现bank冲突,但是写内存则需要小心的安排每个线程要写入的内存地址,尽量保持每个线程都映射到不同的bank中,以保持warp指令的并行性。
Local Memory与Register Files
每一个线程都有自己运行所需的局部变量,存放在寄存器文件中,如果存不下会溢出到L1 cache中,如果还存不下会被一路驱逐出去,贬谪到L2甚至到主存中。(性能会受到毁灭性打击)
最初的局部变量其他线程是无法访问的,但是较新的硬件支持了Shuffle操作,可以在一个Warp的线程间直接传递数据,比通过共享内存来回读写数据还快。