下面来介绍下本系列的最后一篇内容:CS。
Execution environment
这个系列关注的是架构层面的数据流,而非具体的shader执行过程。到目前为止介绍的各个stage,注意力都放在进入各个stage的输入以及各个stage的输出,内部工作情况通常可以由数据的情况来推导。而CS的情况跟这些stage有所不同,因为这是一个单一的而非嵌入到整条管线的stage,也正因为这个原因,所以用于CS计算的硬件单元在芯片上的表面积相比其他硬件而言也要小得多。
事实上,除了从API state中得来的数据比如说bound Constant Buffers以及资源之外,CS基本上不需要为输入数据进行buffering处理,唯一的一个就是线程索引(thread index)。这里可能会让人产生误解,因此大家需要记住:CS中的线程指的是dispatch的原子单元(atomic unit),与大家印象中操作系统中的线程有着不小的区别。CS中的线程有自己的ID以及寄存器,但是没有自己的指令计数器(Program Counter)或者堆栈(Stack),也不是独立scheduled的。
实际上,CS中的线程的地位跟VS中的顶点以及PS中的像素一样,它们的表现方式也基本一样:将一批像素、顶点、线程打包成一个组(数目在大概16~64之间),这个组被称为Warp(NVidia)或者Wavefront(AMD)(下面统一用Warp表示),各个元素在lockstep模式下执行代码。CS线程不会有单独的schedule,但是Warp整体会有:为了避免计算带来的延迟,我们这里虽然不会将一个线程切换到另一个线程,但是会将一个Warp切换到另一个Warp。每个Warp中的单个线程并不会进行单独的分支路径计算,而是跟其他线程同进同退,即如果某个线程需要走A分支,而其他线程需要走B分支,最终的结果就是所有线程AB分支都要走。简而言之,CS中的线程看起来更像是SIMD中的lanes,而非我们平时编程中的线程。
上面介绍了线程跟warp的概念,在这个之上,还有线程组(thread group)的概念。每个线程组的大小是在shader编译的时候确定的,在DX11中,线程组的尺寸是通过三元组来指定的XYZ表示的是三个维度的尺寸。这种机制是出于对2D或者3D资源的寻址方便的考虑,此外还有出于对遍历算法的性能优化的考虑。在宏观层面,CS的执行是分配到多个线程组,线程组的ID在D3D11中也是使用三元组来指定的,其基本原理跟前面介绍的线程的三元组原理一致。
线程ID根据shader的喜好不同,会按照不同的形式传递到CS。这个ID对所有的线程来说都是不相同的,是CS的唯一输入数据,这一点上面跟其他的shader类型有所不同,当然,这只是冰山一角。
Thread Groups
上面的描述会让人产生一种线程组是整个框架层次的一个比较普通中间部分的错觉。实际上,这里还有一点没有说到:Thread Group Shared Memory(TGSM)。而这个结构使得线程组变得特别起来。在DX11硬件中,CS能够访问的TGSM的大小为32k,这块空间主要用于同一线程组内的线程之间的沟通。这是不同CS线程之间通信的主要方法。
那么这个东西在硬件上是怎么实现的呢?比较简单:线程组之间的所有的warps都被同一个shader unit所处理。shader unit使用的本地存储空间的大小为至少32k(通常会稍大一点)。而由于线程组间的所有线程都共享同一个shader unit(因此也共享同一套ALU),因此对于共享内存而言,无需过于复杂的同步机制或者仲裁机制:只需要限定在任意给定的cycle中,只有一个Warp能够访问内存(因为在任意的cycle中,只有一个warp能够发起指令(issue instructions))。当然,这个过程会被管线化(可以用于并行处理),但是基本的不变性还是保留下来了的:每个shader unit,我们都有一个对应的TGSM,对TGSM的访问需要跨越多个管线stage但是真正对TGSM的读写只发生在其中的一个stage,而在那个cycle中的所有的内存访问都是来自于同一个warp中的。
对于实际的共享内存间的通信,还有一些没有描述清楚。上面的不变性只是保证了即使我们不添加任意的interlock来阻止同时访问,每个cycle只有一套warp能够访问TGSM。这种机制可以使得硬件的设计更简洁也更快速。但是由于warp的schedule会存在随机性,这里并没有保证从shader程序执行的角度来看,访问是按照某种特定的顺序进行的;访问顺序只决定于在某个时间点上谁是可执行的(不需要等待内存访问或者贴图读取结束)。如果继续深挖下去,实际上,由于整个过程是管线化的,因此对于TGSM的写操作需要花费一些cycle才能变成可见。这种情况在不同的管线stage对TGSM分别同时进行读写的时候会有影响。因此我们在这里依然会需要一些同步机制——barriers。barriers有多种类型,但是基本上都是由如下三种元素组成:
组同步Barrier(Group Synchronization)。 Group同步Barrier会强制要求当前组内的所有线程都抵达某个barrier之后才能进入下一步操作。一旦某个warp触发了这样一个barrier,就会将之标记成不可执行,看起来就像是在等待内存或者贴图访问的结果一样。而一旦最后的一个warp抵达了这个barrier,就会将不可执行标记移除,放开权限,重新激活前面处于等待过程中的warp。这里添加了一些约束条件,肯定会导致一些延迟,不过好处就是不需要原子内存交易(atomic memory transactions)或者之类的东西;除了在微观层面上执行率下降之外,整体还算是比较实惠。
组内存Barrier(Group Memory Barriers)。由于一个组内的所有线程都是在同一个shader unit中执行的,这种barrier的实现基本上就等同于一次管线flush,用于确保所有处于途中的共享内存操作都是被完成的。对于当前的shader unit而言,已经无需再进行与外部资源的同步处理,也就是说这种方法也是非常廉价的。
设备内存Barrier(Device Memory Barriers)。这个Barrier会在所有的内存访问结束之前阻止组内的所有线程的执行——不论是直接的还是间接的(比如贴图采样)。如前所述,GPU上的内存访问与贴图访问都有很高的延迟——量化一下,大概是高于600或者通常都是高于1000个cycle,因此这种Barrier的操作损伤比较高。
DX11提供了将上述三种基本Barrier塞入到一个原子单元中的不同种类的barriers。
Unordered Access Views
我们已经介绍了CS的输入以及执行过程,但是还没有介绍过CS的输出数据的存放位置,这些数据是存储在UAV(unordered access views)中的。UAV跟PS中的RT比较类似(实际上,在PS中UAV可以跟RT同时使用),但是存在一些重要的语法区别:
最重要的区别在名字上已经体现了,UAV的访问是无序的,即API无法保证对于UAV的访问会按照某种约定好的顺序排列。在前面我们说过,在渲染primitives的时候,quads会按照API的顺序进行深度测试,alpha-blending以及回写,或者至少从结果上来看是按照顺序执行的,而为了保证这个结果,其中需要花费不少的功夫。而UAV就没有做这些处理——在shader中需要的时候,就会立即触发对UAV的访问,最终结果看起来会跟API的调用顺序不太一样。这里的不一样并不是完全不一样,在一个API调用内部的顺序可能无法保证一致,但是API跟驱动会共同保证多个API调用之间的执行顺序是一致的。因此如果我们需要通过一个复杂的CS(或者PS)来将数据写入到UAV中,之后再使用第二个CS来从这个UAV中读取数据,那么这个时候得到的肯定是完整的数据而非只完成了部分更新的数据。
UAV支持随机访问。在PS中,每一个像素在执行过程中,各个RT的写入位置是相同的,但是却可以对UAV中的任意位置进行读写。
UAV支持原子操作。在传统的PS管线中(无UAV的管线),由于各个像素写入的位置都是相互独立的,因此不需要考虑这个功能。但是在添加了UAV的PS中,各个像素执行的时候可能会对同一个UAV位置进行访问,这就可能导致竞争,因此需要一套同步机制来避免竞争。
从CPU程序员的视角来看,UAV就跟多线程系统中的共享内存一样,不同的是UAV的原子操作,这是GPU跟CPU设计中不同的地方。
Atomics
在当前的CPU中,对于共享内存访问的策略设计大多是通过层级内存(memory hierarchy比如多级缓存)来实现。如果想要向共享内存的某个位置进行写操作,那么当前活动的core必须要保证对这条缓存行(cache line)拥有独占权(exclusive ownership),而这个独占权是通过所谓的缓存一致性协议(cache coherency protocol)来实现的,常用的协议为MESI 以及其衍生协议。具体详情各位自行了解,关键的一点是,由于内存的写操作需要拥有独占权,因此两个core不会同时拥有对同一个内存位置的写权限。在这种模型下,原子操作可以通过维持独占权直到写操作结束来实现。
在这类模型中,原子操作的实现是通过常规的Core ALU加上load/store units完成的,大多数关键的事件都是在缓存中发生的。这种实现方案的优点是原子操作(或多或少)是常规的内存访问,虽然其中还包含了一些额外的要求;缺点是存在一系列的问题:
最严重的问题是,缓存一致性的最标准的实现方式——snooping——要求处于协议中的所有agents都能够相互通信,这种约束会严重限制扩展性。当然,关于这个问题有许多解决方案(主要是使用所谓的Directory-based(基于目录的)一致性协议)),但是这些解决方案会导致内存访问方案的延迟性以及复杂性增加。
另一个问题是,内存transactions以及locks都是发生在缓存行级别的,如果两个不相关但是却需要频繁更新的变量共享同一个缓存行,就可能导致多个core之间的“乒乓”加锁,从而导致大量的一致性transactions(使得性能下降),这就是著名的false sharing(伪共享)。这个问题可以通过软件来规避,只要确保不相关的属性不会被放置到同一个缓存行中就可以。但是在GPU中,app既不能得知或控制缓存行的尺寸,也无法得知或控制运行时内存的layout,因此这个问题会更严重一点。
当前的GPU是通过对内存层级架构进行重新组织来解决伪共享问题的。硬件上增加了一个专属的原子unit(因为是专属的,因此如果有两个逻辑单元均需要处理某个缓存行,则统一需要通过这个专属单元进行,这就不会出现前面的false sharing了)来直接处理最底层(lowest-level)的共享缓存层级(shared cache hierarchy),而放弃在shader unit内部对原子操作进行处理(前面说过会导致竞争)。因为只有一个这种缓存,不论缓存行是否处于缓存中(存在就表示当前处理的就是这个缓存行,不存在就表示当前处理的是内存的拷贝,后面实际读写时会先加载到缓存中),一致性问题都不会存在。原子操作包含(在对应的内存位置不在缓存中时)将对应的内存位置添加到缓存中,之后使用atomic unit上的一个专属的整数ALU在缓存上直接进行对应的读-改-写操作。如果某个atomic unit在某个内存位置上处于繁忙状态,那么对于这个内存位置的其他操作都将处于阻碍状态。而由于GPU中存在多个atomic units,因此有必要确保这些unit不会同时访问同一个内存位置,一个简单的做法是为每个atomic unit分派一套独有的地址(静态而非动态)。而这套方案可以通过hash函数将每个atomic unit的索引转换到对应的内存地址来实现(注意,因为在官方文档中没有找到对应的信息,因此这里给出的方案只是推测)。
如果某个shader unit想要对一个给定的内存地址进行原子操作,首先就需要找到这个内存地址对应的atomic unit,之后等到这个atomic unit准备接收新的指令的时候,将操作提交上去(之后如果需要获取原子操作的结果的话,还需要继续等待,直到操作完成)。atomic unit可能每个时刻只能处理一条指令,或者拥有一条包含了重要(outstanding)请求的FIFO队列。当然,这里有多种方案来确保原子操作的处理过程是公平的,从而保证shader unit可以正常往前执行。
最后一点要注意的,不论是设备内存访问,还是内存或者贴图读取,还是UAV的写入,都会触发重要的原子操作,shader unit需要及时跟踪其对应的重要原子操作,并确保在触碰到设备内存访问的barrier之前这些操作都已经处于完成状态。
Structured buffers and append/consume buffers
Structured buffers可以看成是对驱动内部的shader编译器的一种提示数据,用于告诉shader编译器这个数据是怎么使用的——如名字所示,这个buffer包含了一系列具有固定stride的数据元素,这些数据元素在访问的时候会按照一个整体进行——不过这些数据依然会编译到常规的内存访问中(compile down to regular memory accesses,啥意思?)。这个buffer会对驱动对buffer访问的位置以及内存中的layout进行偏移,但是不会为这个模型增加其他的新功能。
Append/consume buffers也是差不多的,这个buffer可以使用现存的原子指令来实现。实际上,这个buffer的实施方案中也确实包含了这种方法,不过有一点不一样,append/consume指针并不是资源中的一个显式的位置,而是通过特殊的原子指令来访问的资源之外的一个边带(side-band)数据(跟structured buffers一样,append/consume buffer中数据的声明同样表明了数据在内存中的位置)。