上一篇文章介绍的fork phase将一些输入stream数据转换成多个独立的任务,并将之分配给shader units;而本篇文章介绍的join phase则将这些shader units的输出合并成一个由多个内存操作组成的stream。跟之前一样,我们会先介绍需要完成的工作,之后再介绍这些工作与硬件的映射。
Merging pixels again: blend and late Z
在这条管线的末尾(D3D称之为Output Merger阶段),我们需要完成的工作有late Z/Stencil处理以及Blending计算。这两个操作相对来说都很简单,而且都需要对RT以及深度buffer进行更新。由于这个更新过程贯穿了整条管线,且是作用于所有的quad上面的,因此这两个操作都是高带宽依赖的。此外,由于blending以及Z处理都需要按照API顺序执行,因此这个阶段对于执行顺序也是有要求的,这里首先需要做的事情就是对需要处理的quad进行排序。
Z处理以及blending操作是一个固定管线的block,会首先需要对每个RT执行一个乘法指令,一个MAD指令以及几个减法指令(干啥的?)。这个block会被设计得足够精简,且由于这个block跟shader units是分离的,因此需要属于自己的ALU,不过这部分ALU数目通常很少:因为我们希望将芯片面积分配给shader units中的ALU,因为这部分是惠及所有在GPU上运行的指令的,而管线末尾的block所对应的ALU是专属的,不能被其他单元所共享的,因此性价比会更低一点。此外,因为这个部分需要对数据按照顺序进行处理以保证正确性,所以我们还需要一个简短的可以预测的延迟。如果要考虑到吞吐量与延迟之间的平衡,那么我们这里可选的方案数就进一步收缩。虽然我们可以对那些没有重叠区域的quad进行并行处理,但是在一些情况下,比如需要绘制大量的细小面片的时候,在屏幕的所有位置都有多个输入的quads,这个时候我们需要保证这些quads被处理的速度不低于他们输入时的速度,否则我们这里的大量的并行像素处理工作就进行不下去了(原因呢?。
Meet the ROPs
ROPs是一些硬件单元,这个术语在不同的人嘴里有不同的含义: “Render OutPut unit”, “Raster Operations Pipeline”, 以及“Raster Operations Processor”。而其真正的含义可能需要追溯到很早以前纯2D硬件加速的时代了。在这个时代,硬件的主要目标是实现快速的位块传输。这套经典的2D ROP设计包含了三个输入:frame buffer中的当前像素数值,源数据以及掩码输入(mask input),ROP会计算一个由着三个参数组成的函数,并将结果写回到frame buffer中。当时的图像并不是我们现在看到的真彩色,而是用bit plane格式表示的数据,这里的函数实际上是一些二进制的逻辑函数。之后随着时间推移,bit plane从舞台上退下来,真彩色逐渐成为主流,之前的开关掩码输入被后面的alpha通道所替代,而按位计算额操作被blend操作替代,但是这个名字却一直保留了下来,即使到了今天,OpenGL中的这些“逻辑操作”依然还是被称为ROPs。
那么对于blend/late Z操作而言,硬件要做哪些工作呢,其实比较简单:
从内存中读取原始的RT/深度buffer数据——内存访问,存在较高的延迟。其中可能还会涉及到深度buffer跟RT的解压缩处理
将输入的待着色的quads按照正确的API顺序进行排序。这个操作会有一套buffering策略,因此并不会在发现quads并未按照正确顺序完成的瞬间立马触发挂起(stall)逻辑(考虑一下循环、if,discard,多个贴图fetch的延迟等)。注意,这里只需要基于Primitive ID进行排序,从一个相同的primitive出来的两个quads是不可能会重叠的,因此不需要进行排序。
进行实际的blend/z-stencil操作,这个是纯数学的计算了。
将计算结果写回到内存中,做一些压缩处理。这个地方依然会有高延迟产生,不过这一次我们并不需要等待返回的结果,因此并不会对实际的管线产生影响。
那么,到这里为止,工作就算完成了吗?理论上来说,是的。
不过,我们这里还需要考虑一下这个执行过程中的高延迟问题。这个延迟是对每个像素或者说quad都会有的,因此还需要考虑较高的内存带宽问题,这部分内容可以参考前面第二篇文章 的介绍。
Memory bandwidth redux: DRAM pages
在第二篇文章中,我们介绍了DRAM的2D布局,以及为啥将内存访问局限在一行之中会有较快的访问速度,因此想要做到完美的带宽节省,就需要保证访问都是以行为单位进行的。不过实际上由于DRAM的每个行尺寸还挺大的,比如说任意的DRAM芯片,其尺寸都到达了Gb范围,且这些DRAM的尺寸也并不需要必须是方形的(实际上2:1的aspect ratios是比较流行的),从这里可以粗糙推导出一个芯片上行数跟列数。对于512Mb的芯片而言,这个数值大概是16384×32768,即每行大概是4k字节,这个数值作为内存处理的基本单元来说实际上并不是很方便。
因此,人们提出了一个折中方案:page。每个DRAM page实际上是一行中的一个尺寸更为灵活方便的slice(大概是256或者512bits)。这里以512bits/page为例,我们选取深度buffer或者RT常用的数据,比如每个像素消耗32bits,那么每个page就对应于16个像素。非常的巧合,像素着色通常是以16到64为一组(NVidia偏向于16,而AMD则倾向于64,前面介绍光栅化的时候使用的8x8像素组成的tile是来自于AMD的数据,如果换到NVidia中,那么使用4x4个像素组成的tile来进行粗糙光栅化应该是很容易让人理解的,不过在网络上并没有找到相关资料)。那么是否有一种像素访问策略可以保证得到较好的DRAM page一致性(即保证访问效率足够高)呢?当然是有的,注意实际上这一点在内置的RT数据排布上就可以得到暗示:通常我们会按照一种较高访问效率的方式来对像素数据进行存储,从渲染着色的角度来考虑,通常4x4或者8x2的DRAM page排布方式会比16x1的排布方式要有用得多,这就是为什么RT通常不会按照线性排布方式存储的原因。
这又给了我们另外一个按照组来对像素进行着色的理由,以及另一个实施双重光栅化处理的理由。此外,这种做法对于降低内存访问的延迟也有一定作用。跟往常一样,这里关于GPU的做法都是出于原作者的合理推测,并不代表实际的GPU实现架构。通常在光栅化完成一个tile的处理之后,我们就知道了哪些像素是需要参与到后面的着色处理中的,在这个时候,我们就会选择一个ROP来处理这个tile对应的quads,并同时将一个读取对应frame buffer上相应位置的数据的指令添加到指令队列中。当我们一旦拿到了某个quad的着色计算的结果,这个时候从frame buffer中读取的数据应该已经就位了,那么此时就可以开始进行blending操作而没有一点延迟(当然,如果关闭了blending处理,那么这个过程就可以直接跳过)。对于深度数据的处理也是类似的,如果我们是在PS处理之前,先运行一遍Early-Z,我们可能需要分配一个ROP并更早的进行Depth/Stencil数据的读取过程,这个过程通常会放在粗糙的Z Test之后;而如果我们运行的是Late Z处理,这个处理过程就跟前面blending的处理过程接近一致了。
为了保证低延迟,这个过程是对所有的PS而非那些执行时间最短的PS都进行的(会导致带宽问题)。此外还有一个问题需要考虑,那就是PS会有多个RT输出的实现方法,这个可能会取决于需要实施的特征。通常来说,可以对一个PS执行多次,每次输出一个RT来实现(简单,低效),也可以通过同一个ROP完成所有RT的输出(最多支持8个RT),也可以为每个RT的输出单独分配一个ROP。
如果ROPs中有这些buffer,我们还可以将这些buffer当成小的缓存来使用,这种做法在绘制大量的小面片的时候会比较有帮助。再强调一次,这里的一切其实都是原作者基于事实的合理推测,并不确定GPU是否真是如此做的,不过如果真的是这样做的话,那么最好每个batch进行一次flush操作,以避免由于全量的回写操作的cache所带来的的同步或者一致性等问题
内存相关的介绍完了,下面介绍一下压缩相关的内容。
Depth buffer and color buffer compression
深度相关的基本内容在第七篇 文章中已经介绍过了,这里已经没啥可说的了,除了带宽问题,而这个问题后面的颜色数据同样会遇到,其实对于普通的渲染而言,带宽并不会是一个很大的问题(除非PS输出速度超级快),但是如果开启MSAA的话,带宽立马就会成为一个很大的隐患。跟前面一样,我们这里需要一种无损的压缩方式来降低带宽的消耗,不同的是每个tile一个的平面方程压缩方法并不适合像素贴图数据。
但是实际上问题也不大,因为MSAA是每个像素进行一次着色计算(除非使用的是sample-frequency着色方式),因此对于被单个primitive完全覆盖的所有像素而言,其中的各个采样点存储的数据都是相同的,根据这个线索,有人给出了颜色buffer的压缩方案:设定一个标记位(可以是逐像素,逐quad或者一个更大的粒度),用于表示是否所有的像素都处于同一个压缩block中,是否所有的采样sample都存储的是相同的颜色。在这种情况下,每个像素只需要存储一个数据,而这个在数据回写的时候,很容易就能判断出来是否满足条件。此外(有点类似深度压缩),我们还需要在芯片上的SRAM中存储一些tag bits。primitive的edge穿过的像素,会需要存储所有采样点的颜色值,不过如果面片尺寸不是特别小的话,这种情况出现的概率通常比较小,因此在这种压缩机制下可以节省大笔的带宽消耗,同样的,我们这里还可以仿照前面深度的处理方式实现快速的数据清理。
说到数据清理跟压缩,还有一点需要提一下:部分GPU会使用类似于HiZ之类的机制来实现对一个刚刚清理过的非常大的像素block(可以是一个光栅化的tile或者更大尺寸的数据块)的村粗。因此每个block只需要存储一个颜色数值。这种做法可以实现快速的clear(需要在SRAM中添加一些tag bits来标记)。不过一旦在这个block上写入了有效的数值之后,这种压缩机制就会失效(需要清理tag bits)。不过这种做法依然是值得推荐的,因为在clear或者初次read的时候,可以节省大量的带宽。
到这里位置,我们就完成了第一条渲染路径:普通的VS->PS渲染。后面会开始介绍GS管线相关的内容。不过在结束之前,还要再插入一点题外的内容。
Aside: Why no fully programmable blend?
相信很多人都有过这种疑问,常规的blend实现有时候会非常的束手束脚。关于这个问题,有两种提议的实现方案:
在PS中完成Blend处理,即PS读取frame buffer数据,进行blend计算之后将新的结果写入frame buffer
增加一个新的blend单元,比方说blend shaders,将之放在PS之后执行。
下面一起来探讨下这两种方案的可行性。
1. Blend in Pixel Shader
这种方案显得很无脑:毕竟我们已经在shader中做了贴图的读取实现了,为什么不可以对当前的RT进行读取呢?但是实际上,无限制的数据读取其实是一个非常蠢的想法,因为这就意味着当前着色输出的每个像素都有可能对其他的像素的结果产生影响。举个例子,如果我们需要访问当前待着色的quad的左边的像素,而左边的像素正在进行着色当中还没完成,或者如果我们需要对当前quad跟其他另外的quad各采取一半结果,这种情况要怎么处理呢?还有很多奇奇怪怪的情况没有列举,总之这种做法会使得渲染结果一团乱麻不受控制。那么如果我们这里给定一个限制条件,只允许读取当前待渲染的quad的数据呢?这种情况就好得多。
然而,实际上,依然会导致一些顺序上的限制问题,我们需要对光栅化过程产生的quads跟当前正被PS处理的quads进行检查。如果光栅化过程输出的某个quad想要写入到某个将会被PS所覆盖的sample上,就会产生竞争,这个时候就需要等到PS计算完成后,才能进行光栅化的写入过程。听起来好像不是很惨,但是我们要如何处理这个问题呢?如果使用tag bit来进行标记,那么这里需要的标记数就太多了,虽然有很多trick方案,但同时会造成其他问题,总之是没有一个较为完美的解决方案。
那么我们是否可以强制限定着色处理按照提交的顺序进行呢?即保证整个管线中的shader按照lockstep的方式执行,虽然可以解决标记的问题,但是又有新的问题。那就是我们需要保证在一个batch中的shader都是在同时执行的,但实际上我们无法保证这一点:因为贴图采样的时间是不固定的,if指令的执行情况也是不确定的等等。
2. “Blend Shaders”
这种方案虽然可以行得通,但是会有一些问题。
首先,在ROPs中会需要一套全量的ALU+指令解码器,这不是一个小的改动;其次,前面说过,常规的“并行处理”策略并不适用于blend,因为我们可能会碰到多个quads按照顺序写入到同一个像素的情况,这种时候我们必须要按照顺序进行处理,因此我们希望有一个较低的延迟(才能保证这种情况下,处理过程不会占用太长时间?)。从这个角度来看,blend是一个跟常规的unified shader units不同的设计点,因此也不能使用常规的并行处理方式来加速。第三,纯粹的线性执行方案也不可行,因为吞吐量太低了。因此我们需要将之管线化,不过要想实现管线化,首先需要知道管线有多长。。对于一个常规的blend单元而言,这个长度是固定的,实现起来很简单,如果使用blend shader的话应该也是差不多的,但是实际上,由于设计上的限制,我们很难得到一个blend shader,更可能的是得到一个blend寄存器combiner(干啥的,为什么这么说?),并且根据所需要执行的指令数(跟管线的长度有关)的不同,这个combiner的上限也有所不同。
总的来说,blend的串行限制使得我们必须要从一些low level来考虑这个问题,而非我们喜欢的通过代码的方式来实现。我们或许可以得到一个带有多个blend mode的较好的blend unit,这个unit可能会使用一种更为开放的寄存器combiner的设计风格,而这种风格引擎程序同学跟硬件同学可能都不是很喜欢。所以总的来说,这种方案实现起来也并不像我们想象的那么美好。