图形管线之旅 Part3
原文:《A trip through the Graphics Pipeline 2011》
翻译:往昔之剑
转载请注明出处
此时,我们一路上通过多个驱动层和命令处理器将draw call从应用程序发送过来。最后终于要做图形处理了。最后一部分,来看一下顶点管线。不过在开始之前…
一些名词
我们现在所在的3D管线依次由若干阶段构成,每个阶段都有特殊功能。来给这些将要谈到的阶段命下名——基本上是按照D3D10/11的命名结构——加上相应的缩写。我们将在旅程的最终部分看到他们,但是还需要一些时间才能全部看到——我写了一个大纲,用一句话总结了每个阶段都做了什么。
- IA——输入组合器(Input Assembler)。读索引和顶点数据。
- VS——顶点着色器(Vertex Shader)。获取输入的顶点数据,写入下一个阶段用到的顶点数据。
- PA——图元装配(Primitive Assimbly)。读取顶点数据,组装成图元继续传递。
- HS——外壳着色器(Hull Shader)。接收补丁图元,将变换过的(或者没变换的)修补控制点写入域着色器(Domain Shader),加上驱动细分的额外数据。
- TS——细分阶段(Tessellator Stage)。创造顶点并连通细分的直线或三角面。
- DS——域着色器(Domain Shader)。取出已着色着色的控制点,HS中的额外数据,以及TS中的细分位置,并把他们再次变换成顶点。
- GS——几何着色器(Geometry Shader)。输入图元,选择邻接信息,然后输出成不同的图元。
- SO——输出流(Stream-Out)。将GS输出的(如变换后的图元)写入内存缓冲。
- RS——光栅器(Rasterizer)。光栅化图形。
- PS——像素着色器(Pixel Shader)。将经过插值的顶点数据输出像素颜色。还能写入UAV(无序访问视图 Unordered Access View)。
- OM——输出混合器(Output Merger)。从PS得到着色后的像素,做半透混合处理并把它们这回后缓冲区。
- CS——计算着色器(Compute Shader)。自己有一套独立的管线。输入只有常量缓冲和线程ID。可以写入缓冲和UAV。
现在所有的都交待完了,这里列出了多种数据流程,我来按顺序说明一下(这里不讲IA、PA、RS、OM阶段,他们和主题无关,他们不对数据做任何处理,仅仅重组数据——就像是粘合剂)
- VS→PS:历史悠久的管线。在D3D9时代,这就是全部管线了。目前为止仍然是常规渲染的重要流程。我从头到位走一遍,然后再回头换一种更丰富的流程。
- VS→GS→PS:几何着色(D3D10中新增)
- VS→HS→TS→DS→PS,VS→HS→TS→DS→GS→PS:曲面细分(D3D11中新增)
- VS→SO,VS→GS→SO,VS→HS→TS→DS→GS→SO:输出流(有/无 曲面细分)
- CS:计算(D3D11中新增)
现在你知道接下来要发生什么了,让我们从Vertex Shader开始吧。
输入组合器阶段(Input Assembler Stage)
这里发生的第一件事是从Index Buffer中载入索引——如果它是个包含索引的渲染批次。如果不是的话,就当成是序号一致的Index Buffer(0 1 2 3 4……)作为索引来使用。如果有Index Buffer,它的内容可不是从内存读取的,IA阶段通常通过一个数据缓存来访问Index/Vertex Buffer。还要注意,读取到的Index Buffer(实际上,在D3D10以上的所有资源访问都是这样)是做了边界检查的;如果你引用了原始Index Buffer之外的元素(例如,在只有5个索引的Index Buffer中,执行DrawIndexed函数,IndexCount参数设为6)所有的越界读取都将返回0。这么做(这种特殊情况)虽然完全没用,但是包含了一定意义。同样,你可以用一个NULL Index Buffer集合调用DrawIndexed——如果你的Index Buffer长度设为0,这么做也是一样的,所有读取都算越界,所以也返回0。在D3D10以上,你需要对未定义的东东多做一些处理:)
一旦有了索引,我们就有了需要从顶点数据流中读取的预处理顶点(pre-vertex)和预处理实例(pre-instance)数据(当前这个阶段的实例ID仅仅是一个简单的计数器)。这很简单——我们已经声明了数据布局(data layout);从缓存/内存中读取它,并且解包成浮点格式作为shader内核的输入数据。然而,读取不是立即完成的;硬件使用了一个着色顶点的缓存,以至于顶点可以被多个三角形引用(在常规的闭包mesh中,每个顶点都被6个三角形引用),就不用每次都重复渲染同一个顶点了——我们仅引用已经着色后的数据。
顶点缓存和着色
注意:本段部分内容包含了猜测,都是依据专家给出的关于现代GPU的评论。但是仅告诉了我是什么,却没解释原因,所有这块有一些是推断的。还有,我仅是猜测了一些细节。就是说,我不知道的就不会在这里完全阐述了——我描述的东西都是我认为靠谱可信的,我还不能保证实际在硬件里确实这么实现的,没准会漏过一些技巧和细节。
长久以来(直到shader model 3.0的时代),vertex & pixel shader都使用不同的处理单元实现,它们有各自不同性能权衡和顶点缓存,是很简单的东西:一般只是 个包含少量顶点的FIFO,对于最糟糕的输出属性也保留了足够空间,各自标记了顶点和索引。很简单吧。
之后,Unified Shader出现了。如果在两种类型的Shader中统一处理不同事物,这种设计必然要做出妥协让步。换句话说,Vertex Shader通常一帧可能达到1百万个顶点,而Pixel Shader在1920x1200分辨率上填充全屏 一帧至少 需要二百三十万个像素——还会有更多的渲染内容。那么猜一下那个处理单元会拖后腿?
有一个解决办法:用大量的统一着色单元(unified shader unit)替换掉每次只渲染若干个顶点的旧vertex shader uint来最大化吞吐量,避免延迟,因而就能处理大量批次的渲染工作(有多大?目前这个数貌似是一个批次 处理16~64个着色顶点)。
如果不想降低渲染效率,在你执行一次顶点着色负载(vertex shading load)之前,会有16~64次顶点cache miss。但是整个FIFO实际上并不是按照这个想法批处理顶点cache miss,且一口气渲染完他们。因为问题在于:如果你一次性渲染整个批次的顶点,就只能在顶点着色之后才能开始组装三角形。而此时,你刚刚才添加了一整个顶点批次(比如这里是32个)到FIFO的队尾,就意味着现在有32个旧的顶点被挤出队列了——但是这32个顶点中的每个顶点,可能已经命中了,在当前批次里我们正在组装的三角形的顶点缓存。哦!那就行不通了。很明显,我们实际上不能在FIFO里统计32个旧的顶点作为顶点缓存命中(vertex cache hits),因为正在引用的顶点已经不在了!那我们该需要多大的FIFO?如果我们正在渲染的一个批次里有32个顶点,就至少 需要32个条目大的空间,但是因为我们不能使用32个旧的条目(因为我们要移出他们),意味着实际上每个批次都是用的一个空的FIFO。那就让它大一点,64个条目呢?相当大了吧。注意,每次顶点缓存查找要涉及到比较所有FIFO中的标记(顶点序号)——这完全是并行的,但也很耗电;我们在这里用一个完全关联缓存来高效率实现它。还有,在派发执行32个顶点着色负载和收到结果之间里做什么呢——只能是等待吗?着色要花费几百个cycle,等待可不是个好主意!或许应该同事有两个着色负载,并行执行?但是现在我们的FIFO需要至少64个条目长度,并且我们不能统计上次的64个条目作为顶点命中,因为当我们收到结果的时候,他们都将被移出队列。并且,一个FIFO对应大量的shader内核吗?Amdahl定律——将一系列完全串行化的分量 (不能并行化)加入到管线里,必然会产生性能瓶颈。
整个FIFO真的不适合这个环境,所以,好吧,我们只能抛弃他了。回到画板上来。我们实际想要做什么?拿到一个大小合适的顶点批次来渲染,并且不渲染不必要的顶点。
那么,好吧,简单点:为32个顶点(1个批次)保留足够大的缓存空间,并且同样留出32个条目的标记的缓存空间。从一个空的缓冲开始,例如所有条目都是非法的。对于index buffer中的每个图元,从所有的顶点中查找一次;如果他命中缓存,那最好了。如果没命中,在当前的批次里分配一个插槽并且添加一个新的索引到 缓存标记数组(the cache tag array)里。当我们没有足够剩余空间再添加新的图元时,派发全部的顶点着色批次,保存缓存标记数组(例如刚刚着色过得32个顶点索引),并且再次从一个空的缓存开始设置下一个批次——确保渲染批次都是完全独立的。
每个批次都将占用shader unit一段时间(可能至少几百cycles!)。但是这不会有问题,因为我们有足够的shader uint——仅需要选择一个不同的shader unit来执行每个批次!我们最终能够高效并行的得到返回结果。在这点上我们可以使用保存的缓存标记和袁术index buffer数据来组装图元,发送到管线里(这就是我后面部分要讲到的“图元组装”的概念)。
顺便说下,我刚才说的“得到返回结果”,是什么意思呢?他们在哪结束的? 主要有两个选项:1. 特定的缓存里 或 2. 一些通用的缓存/临时内存。过去一般都用选项1,在 顶点数据周围用一个固定组织结构设计的缓存(每个顶点有16个float4向量的属性空间,之类的等等),但是后来GPU开始朝着选项2发展,仅仅是内存。这很灵活,一个重要的好处是你 可以在别的shader阶段也使用这个内存,然而举例来说,特定的顶点缓存对于像素着色或者计算管理是没什么用的。
目前为止所描述的顶点着色数据流程
Shader Unit内部
简而言之:这就是你想要看到的HLSL编译器的 反汇编输出结果(fxc/dumpbin)。它只是个擅长执行这种代码的处理器,在硬件中负责将 某些shader代码编译 成近似的shader字节码。与我之前谈论的东西不同,这块内容有丰富的资料——如果你感兴趣的话,可以从AMD和NVidia找到一些会议演示文档,或者阅读一下CUDA/Stream SDK的文档。
归纳一下:高速ALU主要布置在FMAC(浮点乘法累加 Floating Multiply-Accumulate)单元周围,某些硬件支持倒数,倒数平方根,log2,exp2,sin,cos运算,高吞吐量和高密度无延迟的优化,运行大量的线程来降低上述延迟,每个线程有很少的寄存器(因为正在运行的线程太多了!),非常适合执行直接式代码(无循环的),不适合运行有分支的(特别是不连贯的代码)。
上述的通常就是所有的实现。还有一些区别;AMD的硬件通常直接用4-位宽的SIMD表示HLSL/GLSL以及shader自节码(尽管它们后来不用了),而NVidia不久之前打算将4-通路SIMD转变成标量指令(scalar instruction)。再次提醒,所有的这些在web上都有资料。
尾注
我再次免责声明“顶点缓存与着色”一节:其中有一部分是我的猜测,所以讲的有点不清楚。
我也不打算讲如何写缓存的细节,这部分是托管的;缓存大小取决于处理批次的大小和想要输出的顶点属性。缓存大小和管理对于性能是很重要的,但我不在这里详细解释,我也不想解释;虽然很有趣,但是这部分内容与谈论的硬件非常特殊,不用深入了解。