到现在为止,我们已经介绍了Draw Call指令从应用层发出后经过的多个driver层以及command processor处理逻辑,现在终于要开始介绍Draw Call的图形处理部分了,本文开始介绍顶点处理管线,不过在那之前还要先来介绍一些其他内容。
Have some Alphabet Soup!
图形管线包含多个stage,每个stage负责一项专项事务。下面先给出后面要介绍的各个stage的名称以及首字母的缩写,大部分都会跟D3D10/11保持一致:
IA
— Input Assembler. 读取顶点/索引数据。VS
— Vertex shader. 对输入的顶点数据进行处理,并输出处理后的顶点数据PA
— Primitive Assembly. 将上个阶段输出的顶点数据组合成primitive向下传递。HS
— Hull shader; 接收patch primitives, 输出经过变换(或者不经过任何变换)后的patch control points,domain shader的输入数据以及其他用于tessellation的数据。TS
— Tessellator stage.构建tessellated后的顶点与连接数据。DS
— Domain shader; 以shaded control points,HS输出的额外数据,TS输出的tessellated位置作为输入并再次将之转换为顶点数据。GS
— Geometry shader;以primitive作为输入(可能还会有一些邻接信息,可选),输出不同的primitives数据(Also the primary hub for…)SO
— Stream-out. 将GS的输出(比如说变换后的primitives数据)输出到内存中的一个buffer中。RS
— Rasterizer. 对primitives进行光栅化。PS
— Pixel shader. 以插值后的顶点数据为输入,以像素颜色数据作为输出,可能还会将数据写入到UAVs (unordered access views).OM
— Output merger. 以PS输出的着色完成的像素作为输入,进行alpha blending处理,并将最终的结果写入到backbuffer中。CS
— Compute shader.这个Stage处于另外一条单独的管线中,以constant buffer + 线程ID作为输入,其输出结果将写入到buffer或者UAVs中。
下面给出不同的管线的stage执行路线: (IA, PA, RS 以及 OM 等stages这里省略了没有列出来,因为这些stage并没有对数据做实际上的处理,只是对数据进行了重排,起到的是一个胶水的作用。)
VS→PS: 早期的编程管线,比如D3D9中管线的所有内容就是这两个stage了。同时这也是常规渲染图形管线中最重要的两个stage。
VS→GS→PS: Geometry Shading (D3D10新增管线路径).
VS→HS→TS→DS→PS, VS→HS→TS→DS→GS→PS: Tessellation (D3D11新增).
VS→SO, VS→GS→SO, VS→HS→TS→DS→GS→SO: Stream-out 管线(针对是否需要tessellation,有多种配置).
CS: Compute. D3D11新增.
上面对管线的路径进行了整理介绍,下面先从vertex shaders开始进行介绍。
Input Assembler stage
如果当前渲染的API调用的是一个带索引的batch,那么IA Stage要做的第一件事就是从IB中加载索引数据,否则就会按照一个标准索引buffer来进行绘制(0 1 2 3 4 …) 。带索引的batch的索引数据是从内存中获取得到的(当然并不是直接读取),IA Stage上会有一个数据缓存区用于保持顶点/索引数据的cache locality以提高访问执行效率。另外,这里还需要注意IB数据的读取操作(实际上D3D10+上的所有资源的获取)会需要经过bounds check(越界检测)。如果需要引用的元素数据超出了当前访问的IB的范围 (比如对一个只有5个索引的IB调用DrawIndexed
,IndexCount == 6
) ,那么这些越界访问的结果返回的就是0,而这个数值是无意义的。相似的,我们可以用NULL IB来调用 DrawIndexed
,其表现跟使用空IB调用这个接口的表现一致。在D3D10+中,想要触发undefined逻辑,大家还要加倍努力才行。
索引数据有了之后,那么就可以从输入的vertex streams中获取需要的per-vertex或者per-instance数据(当前instance的ID对应的是另外一个计数器,索引对应的只是顶点数据,这里暂时不需要考虑instance ID的使用)。vertex stream中的数据都是按照预先定义好的数据layout排布的,这里有了索引就可以按照这个layout进行定位访问,并将返回的数据unpack成shader core所需要的float格式。不过,这个访问并不是立即完成的,硬件会将渲染完成的顶点数据塞入到一个缓存中,从而当某个顶点被多个面片共用的时候(这种情况显然是非常常见的),这个顶点不需要进行多次渲染,只需要从缓存中将数据取出来就可以了。
Vertex Caching and Shading
注意,这个section的内容有一部分是原文作者的一些猜测,这些猜测是基于当代GPU上的一些已知知识进行的,这些知识只是给了结果,但是没有给出原因跟过程,因此这里会向外做一些扩展性的猜测,当然这些猜测都是具有一定的合理性,且在通常情况下是行得通的,虽然跟实际情况可能会有些出入。
不管怎么样,在很长一段时间里(至少可以追溯到GPU的SM 3.0),VS & PS是使用不同的单元来执行的,因此对于性能trade-off(平衡)有着不同的考虑,顶点cache的概念其实比较简单:通常是用一个FIFO来存储少量的顶点数据(比如一二十个),这块空间能够存储下最差情况下的顶点属性数据,并使用一个顶点index作为tag。
之后就出现了unified shader架构。如果想要将原本不同的两类shader统一到一起,那么在设计上就必须要做折中。一方面,VS每帧处理的顶点数目大概是一百万,而另一方面,PS处理的像素数目则是二百三十万(1920x1200),如果算上一些其他的渲染(比如后处理)的话,那么这个数目还会更更多。想一想,在这场拉锯战中,到底谁占上风呢?
结果就是:老派的VS每次只处理一个顶点,而现在的unified shader架构的目标是最大化吞吐量(throughput)而非缩小时延,那么这里就需要按照大批次来进行处理(多大呢,目前这个数值大概是每批处理16~24个顶点)。
如果不考虑shading的效率,那么按照上述方法,在发出新的vertex shading load(即在cache中没有找到结果)之前,可能会遭遇16~64次cache miss。而FIFO结构也不支持将所有cache miss的vertex shading load打包到一起,之后一次性load。问题在于:如果希望一次性处理一整个批次的顶点,就意味着必须要等到所有顶点shading完成之后才能开始进入到triangle assembling阶段,在那之后,就会将一个批次的顶点数据(这里以32为例,下同)添加到FIFO的末尾,也就意味着需要将FIFO中老的顶点数据移除出去(FIFO尺寸限制导致)——而这些移除出去的顶点数据可能会是当前shading完成的顶点所从属的面片中的一个组成顶点,完蛋了~~显然我们这里就无法再依赖于FIFO中之前shading完成的顶点数据而需要再次进行渲染。此外,在上述这种情况下,FIFO到底需要多大才足够呢?如果一个批次包含32个顶点,那么FIFO至少要能装下这些数据,也就是需要32个entries,而实际上在这种情况下,老的shading完成的数据如果希望保留的话,那么这里就需要更大一点的,比如64个entries,这个真的太大了。另外,由于每个顶点cache的查询都需要对tag进行一次比对,这个过程是并行的,但是电量消耗却是实实在在的。还有,在发起一个shading vertex load到收到数据之间的这段时间在做什么呢?等待?这个过程可能会需要消耗数百个cycle,如果只是等待的话,就太浪费了。那么如果同时开始两个shading load呢?那么在这种情况下FIFO就必须要要包含至少64个entries,且我们无法依赖最后的64个entries来实现cache hit。此外,一个FIFO对应着多个shader cores这种机制也会有问题, Amdahl’s law 依然有效 – 将一个严格串行执行的组件塞入到一个并行管线中,那么这个新加入的组件大概率会成为整个管线的瓶颈。
总的来说,这套FIFO方案并不是非常的合适,好吧,将之抛开,再继续思考。我们需要的到底是什么呢?对一个合适的顶点数目的batch进行渲染,且尽量避免对同一个顶点进行多次渲染。
简单一点:为一个batch32个顶点数据预留足够的空间,同时预留出32个entries的cache tag空间。对于每个batch而言,从empty “cache”开始,这个时候所有的entries都是无效的。之后对于IB中的每个primitive数据,先在所有(这里的所有是指这个batch中的所有index?)的索引数据中进行查询,判断是否已经被渲染过了(怎么判断的?cache空间能存下所有顶点的渲染结果?),如果cache命中了,没关系,就不需要再次渲染了;而如果cache miss了,那么就在当前的batch中分配一个slot,并将这个新的索引添加到cache tag array中。一旦当前batch已经不能再继续塞下一个primitive了,那么就可以开始将整个patch发送到vertex shading阶段了,之后将cache tag array保存下来,之后从一个全新的empty cache开始继续下一个batch。各个batch之间的处理是相互独立的,也就是说,是局部cache,没有相邻batch之间的cache。
每个batch的执行过程,可能会占用一定的shader unit的时间(大概是数百个cycle),不过没关系,shader unit数目足够,因此完全可以使用不同的shader unit来处理不同的batch,非常的并行。最终所有的shader unit会返回执行完成后的结果,然后我们就可以用存储下来的cache tag以及原始的IB数据来对primitive进行组装,并将结果传输到管线的下一个stage(这个就是primitive assembly的工作内容)。
顺带,当这里提到的执行完成的结果,通常有两种选项:
专属的buffer数据
通用化的内存结构
返回的结果通常使用的是第一种方式,对应的是为特定顶点数据所设计的组织结构,不过后面GPU会将数据转换为第二种方式,这种方式对于后续的处理而言会更方便一点,因为其他的shader stages可能其实不是很关心顶点属性的组织结构之类的数据。
Update: 上面给出的是一张关于VS的数据流走向的手绘图
Shader Unit internals
简而言之:看起来跟反汇编(disassembled)HLSL的输出差不多 (fxc /dumpbin
is your friend!)。这类代码适合计算机处理器直接运行,且具有非常丰富的文档,具体可以参考AMD/NVidia的一些会议陈述或者CUDA/Stream SDKs的一些官方文档。
这里是一些总结性内容:大多数ALU是围绕着FMAC (Floating Multiply-ACcumulate) 框架构建的,部分硬件支持为一些指令如reciprocal, reciprocal square root, log2, exp2, sin, cos, optimized 实现高吞吐量而非低时延的优化,低时延通常是通过大量的线程来遮掩,每个线程之只能占用少数几个寄存器,因此比较适合执行一些比较简单(straight-line)的代码,在一些具有分支的代码上的表现可能就不太好。
大多数的实现方式在整体框架上是非常一致的,不过还依然有着少量的区别。比如AMD硬件还依然坚持于HLSL/GLSL以及shader字节码中(即使现在逐渐移除了这些限定)所标明的4路SIMD框架,而NVidia则开始从4路SIMD框架专项标量指令(这些内容在网上都可以查到)。
更有意思的是不同的shader stage之间的区别。简单来说,少数的指令会存在区别,大多数的算术逻辑指令在各个stage上是完全一致的。部分结构(比如微分指令以及PS中的插值后的属性数据)只存在于部分stage上。大多数stage的区别在于输入跟输出数据的不同。
这里还有一点跟shader有关的大课题,那就是贴图采样,不过这个会放在后面介绍。