图形管线之旅 Part2
原文:《A trip through the Graphics Pipeline 2011》
翻译:往昔之剑
转载请注明出处
还没那么快
在上一篇,讲述了渲染命令在被GPU处理前,经历的各种阶段。简而言之,比你想像的要复杂。接下来,我将讲述提过的命令处理器(command processor),最终都对command buffer做了哪些事情。啥?哪提过这个了——骗你的- -。这篇文章确实是第一次提到命令处理器,但是记住,所有command buffer都经过内存或系统内存来访问PCIE和本地显存。我们将按顺序经过管线,因此在我们到达命令处理器之前,我们先来聊聊内存。
内存系统
GPU没有规则的内存系统,这不同于你常见的通用CPU或其它硬件,因为它被设计成多种用途。在常见的机器上 你能发现有两个本质区别:第一,GPU内存系统带宽很快,相当快。Core i7 2600K勉强能达到19GB/s的带宽。GeForce GTX480 的带宽接近180GB/s——差了一个数量级啊!
第二点,GPU内存系统频率很慢,相当慢。Nehalem(第一代Core i7)主内存的cache miss大约140个时钟周期,这是按照时钟频率除以内存延迟得来的数据(AnandTech给出的数据)。我刚提到的GeForce GTX 480的内存延迟大约400~800时钟周期,比Core i7有4倍多的内存延迟。除此之外,Core i7的时钟频率是2.93GHz,而GTX 480 shader时钟频率表是1.4GHz——就是说,这里还有两倍的差距。哇塞,差出一个数量级了!靠,搞笑呢吧,我有点激动。这一定是一种权衡,继续听下去。
没错——GPU在带宽上大量增加,但是它们要付出大量增加内存延迟的代价(事实证明,这相当耗电,不过已经超出本文讨论范围了)。这种模式上—— GPU的吞吐量受限于延迟。不要一味的等待结果,干点什么事情吧!
以上是你需要了解的关于GPU内存的知识,除了DRAM的趣闻外,后面讲的也都很重要:DRAM芯片被组织成2D网格——无论逻辑上还是物理上。有(水平)行线和(垂直)列线,这些线的每个交叉点有一个晶体管和一个电容器。如果你想知道如何用这些材料制作内存,可以访问(https://tokyo.zxproxy.com/browse.php?u=V%2FmbvKGmGdz9QE4KTynGv27LALUJtfhzT4wC%2FQA%3D&b=14#Operation_principle)。总之,重点是DRAM的地址是被行地址和列地址分开的,DRAM的一次内部读/写总是访问给出行的所有列。访问内存一行上的所有列比访问相同数量的多行内存的开销要小得多。这仅是DRAM的一个小知识,但对后续来说非常重要。注意:以后再看一下这里。把这里,包括之前的内容联系起来,只读几个内存字节,并不能达到内存带宽的最大值,如果想要内存带宽饱和,应该一次读满一整行DRAM。
PCIe 主机接口
按照图形程序员的观点,这部分硬件没什么意思。实际上,这也是GPU的硬件架构。你也得关心它,因为它太慢的话也是瓶颈。所以得找靠谱的人把它弄好,确保没问题。除此之外,它还能让GPU读/写显存和大量的寄存器,让GPU访问(一部分)主内存。让人烦恼的是,这些传输的延迟比内存延迟更糟糕,因为得从芯片发出信号,到插槽里,经过主板,然后到CPU上要很长时间。带宽虽然合适——在16-lane PCIe 2.0接口上 可达8GB/s峰值,大部分是GPU使用的, 而CPU只占1/3~1/2的带宽。这个比例是可以的。不像早期的AGP,是对称的点对点链接——带宽是双向的。AGP有一个快速通道,从CPU到GPU上,但是反向是不行的。
最后一部分的内存小知识
老实说,我们现在已经非常非常接近实际看到的3D指令了!你都快闻到它了。但还有一件事情我们需要解决。因为现在我们有两种内存——(本地的)显存和映射的系统内存。一个是往北走一天的路程,另一个是往南走沿着PCIe高速公路一周的旅程,选哪个?简单的解决方案:只增加一条额外的地址线,来告诉你走哪条路。这就简单了,很有效并已经使用很长时间了。或者你可能是在统一的内存架构上,比如某些游戏主机(不包括PC)。那种情况的话,就不用选了,只有内存是你要去的地方。如果你想更好一点,你就添加一个MMU(memory management unit内存管理单元),它提供给你完全虚拟化的地址空间,并允许你搞一些很好的tricks,比如频繁地访问显存中的纹理(这很快),其他部分在系统内存里,以及大部分完全没映射的——就跟凭空变出来一样,通常,读一个磁盘花50年的话, 这毫不夸张,访问内存就好比是一天,这就是一个硬件的读取花费时间,这相当的快。操蛋的磁盘!我跑题了……
当你显存不够用的时候,MMU还能整理显存地址空间上的碎片,而不用实际拷贝。好东西啊,它让多个进程共享同一GPU更简单了。使用一个MMU肯定是可以的,但我不确定是否需要它,尽管它相当好用(谁来帮帮我? 如果我搞懂了,我会更新这篇文章,但是现在我压根不懂)。总之,MMU/虚拟内存并不是你实际添加上去的(不像在架构里的缓存和一致性存储器),并不针对于某个特别的阶段——我会在别的地方提到它,先把它放着。
还有个DMA引擎,可以拷贝内存而不牵扯到我们重要的3D硬件/shader内核。通常,它至少可以在系统内存和显存之间拷贝(双向的)。它常常进行显存复制(如果你需要整理显存IPIan的话,这就很有用)。它通常不能进行系统内存的拷贝,因为这是GPU,而不是内存拷贝单元——在CPU上执行系统内存拷贝,不用双向的经过PCIe。
我画了个图,展示更多的细节——现在,你的GPU有多个内存控制器,每个控制多个内存条,它们都得争取到带宽:)
好,来列个清单。CPU端有一个预置的command buffer,有了PCIE主机接口,CPU可以通知我们和写寄存器。我们得把逻辑转变成地址载入,然后返回数据——如果它是从系统内存经过PCIe的,假如我们想要获取显存中的command buffer。KMD会设置一个DMA传输,不论是CPU还是GPU上的shader内核都不用管它。然后通过内存系统可以拿到显存中的拷贝数据,这就是我们设置的所有过程,最后来看一下command buffer。
终于到了命令处理器
在开始讨论命令处理器之前,已经做了很多准备工作,用一个词概括它,那就是“缓冲”。
如上所述,内存通道是高带宽并且高延迟的。对于大多数GPU管线后续位而言,解决办法是运行多个独立的线程。但如果这样做的话,我们只有一个命令处理器,得考虑一下command buffer的顺序(因为command buffer包含了状态改变和执行渲染需要的正确队列)。所以我们接下来应该做的事情是:添加一个足够大的缓冲区向前预取来避免间断。
在该缓冲区中,命令处理器能到达实际的命令处理前端——基本上就是个知道如何解析指令的状态机(按照硬件规范格式)。一些指令处理2D渲染操作——除非把命令处理器单独分为2D的,3D前端才不用管它。不管怎样,现在的GPU仍藏有检测2D的硬件功能,就像是淘汰掉的VGA芯片的某个地方依然支持文本模式,4-bit/像素的位平面模式,平滑滚动之类的一样。没用显微镜就能发现这些淘汰掉的东西说明运气不错。总之,这些东西还存在,但是以后我就不再讲它们了:)然后是实际处理3D/shader管线里一些图元(primitive)的指令了。我会在接下来的部分讲它们。还有一些指令在3D/shader管线里由于各种原因(和各种管线设置)不参与渲染,后面都会讲。
接下来是改变状态的指令。作为一个程序员,你可以认为它们只是改变了一些变量。但是GPU是一个巨大的并行计算器,在并行系统里你不能只改变一个全局变量并想让它正确工作——如果你不能保证所有东西都是一成不变的,最后就会出bug。有几种常见的办法,基本上所有的芯片都针对不同类型的状态使用不同的方法:
- 当改变一个状态的时候,你得让所有涉及到的工作都结束掉(即flush部分管线)。在过去,显卡芯片都是这么处理状态改变的——这很简单,并且批次少,三角面少,管线简短的时候开销不大。随着批次和三角面数的增加,这种开销也增加了。这种办法限用于改变频率不高的(只刷新整个管线的一部分影响不大)或者只实现开销大/难度大的特殊需求。
- 你可以让硬件单元完全的无状态。只传递状态改变指令给指定的阶段,然后周期性的把这个阶段追加到当前状态。这些状态不会保存在哪里——但总是存在,如果管线的其它阶段想要知道这些状态位是可以的,因为已经当参数传进来了(然后传递给下一阶段)。如果你的状态只改变少数位,那就不划算了。要是改变全部的纹理采样状态设置,那还行。
- 有时只存一份状态的拷贝,每次阶段上都要改变一大堆的东西,都得刷新它。但要是存两份拷贝(或四份)那就好多了,这样前端的状态设置就可以提前了。要是你有足够的寄存器(插槽)来位每个状态存储两个副本,一些激活的工作用插槽0,你可以安全的修改插槽1而不用停止或干扰到工作的运行。现在你不要发送整个状态到管线了——只有一个指令,选择使用插槽0还是1。当然,如果插槽0和1正在使用,又遇上一个状态要改变的时候,你还是得等,但是你可以提前操作一步。这个技术不止用两个插槽。
- 对于采样器或者纹理资源视图(Shader Resource View)的状态,你可以在同一时间大量的设置,不过你也不会这么做。你不会仅仅因为你要跟踪两条凭空的状态集,就为2*128的纹理保留状态空间。对于这种情况,你可以使用一种寄存器重命名方案——拥有128个实际纹理描述的内存池。如果在一个shader里真的需要128个纹理,那改变状态将非常的慢。但大多情况下,一个应用程序用不到20个纹理,你有相当多的空间来保障多个版本。
同步
指令的最后一部分是处理CPU/GPU和GPU/GPU同步。通常,这些形式都是“如果事件X发生,则执行Y”。我将先讲“执行Y”的部分,它可能是GPU告诉CPU现在该做什么的推送通知(“CPU啊,我正要进入显示设备0的垂直空白间隙VBI,所以你要是不想无效的翻缓冲,现在就赶紧干活吧!”),或者也可能是GPU只记录发生了什么,CPU可以以后来询问它(“说吧,GPU,你最近处理了哪个command buffer片段?”—“等我查一下啊,序列号是303。”)前者通过中断来实现,只用在频率不高的,优先级高的事件,因为中断开销很大。在这之后,需要每次触发事件时,从command buffer将值写入到CPU可见的GPU寄存器。
比如你有16个寄存器,将寄存器0赋值为currentCommandBufferSeqId。给每次要提交到GPU的command buffer分配一个序列号(这步在KMD中完成),然后在每个command buffer的开始部分,添加标记“如果到达这里,就写入register 0”。瞧,现在GPU知道正在处理哪个command buffer了,我们知道命令处理器会按序列严格执行完所有的指令,所以如果第一个指令command303被执行,那就是直到序列号为302的command buffer都已经完成了,它们现在可以被KMD重新利用、释放、更改,或者想怎么处理都可以。
关于“如果事件X发生”中的“事件X”是什么,我们来举一个例子,比如“如果你到达这里了”——大概就是这个意思。再比如,“如果在command buffer里,shader读取完所有渲染批次的贴图之前”(这时候就表明回收再利用texture/render target的内存是安全的),“如果所有激活的render target/UAV已经处理完了”(这表示实际上可以将它们作为纹理安全使用了),“如果到目前为止所有的操作都已经完成”,之类的等等。
这些操作通常被叫做“fences”,顺表说一下,有很多种方法从状态寄存器中取出写入的值,但我觉得最靠谱的方法是使用一个顺序计数器(可能借用了其它知识)。没错,这里有些概念我没讲,因为我觉得你应该都懂。我以后可能会详细说明:)
已经讲了一半了——现在可以从GPU返回状态到CPU了,允许我们在驱动程序里做适当的内存管理(现在可以知道,在什么时候可以实际安全的复用vertex buffer,command buffer, texture和其它资源了)。但这还没完——还漏了一个难点。要是我们需要纯粹的在GPU端同步呢?我们回到刚才render target的例子上,在实际的渲染完成之前,不能使用它作为纹理(并且在其他步骤发生的时候——曾经用过的纹理单元也有很多细节)。解决办法是“等待”指令:“一直等到寄存器M有值N”。这可以是等于,小于,或者更复杂的比较操作——简单起见,只讨论等于的情况。在提交渲染批次之前,“等待”可以允许我们同步render target。还可以允许我们构建一个flush GPU的操作:“如果挂起的工作完成了,设置寄存器0为++seqId”/“一直等到寄存器0有值seqId”。GPU/GPU同步就全部搞定了——在DX11的compute shader指令里,有一种更细粒度的同步,这是GPU端唯一的同步机制。关于 规则渲染,你不需要了解太多。
顺便说一下,如果你可以写CPU端的寄存器,你还可以用另一种方法——提交一个局部comand buffer,包含上一个的特殊值,然后让 CPU端替代GPU端改变寄存器。这种方法可以用来实现D3D11风格的多线程渲染,你可以提交一个包含vertex/index buffer引用的渲染批次,CPU仍然要加锁(有可能会正被另一个线程写入)。你仅需要在实际渲染调用之前发送一个等待指令,随后一旦vertex/index buffer解锁,CPU就可以改变寄存器内容了。如果GPU没收到这个指令,那么等待指令就是一个空操作;如果收到了,就花费一些(命令处理器)时间处理。干的漂亮吧?实际上, 如果你在提交指令之后更改command buffer,即使没有CPU可写的状态寄存器,也可以实现这种方法,只要有一个command buffer“跳转”指令。细节留给读者思考:)
当然,你不必需要这种设置寄存器/等待寄存器模型;对于GPU/GPU同步,你仅需要一个“render target barrier”指令,来确保render target可以安全使用,还需要一个“flush所有东东”的指令。但是我更喜欢这种设置寄存器风格的模型,因为可以一石二鸟(反馈给CPU正在使用的资源,和GPU自同步)。
这里,我画了一张图。有点复杂,我说一下细节。基本想法是这样:命令处理器开始部分有一个先入先出队列(FIFO),跟着是指令解码逻辑,由2D单元、3D前端(常规3D渲染)或者shader单元(compute shader)等多种块来直接执行操作,还有一个块处理同步/等待指令(包含我说过得公开可见寄存器),和一个处理command buffer跳转/调用指令的单元(改变了当前的预取地址,转向FIFO)。所有分派工作的单元都需要发送给我们完成事件,所以我们知道何时纹理不再被使用了,以及可以再利用它们的内存。
结束语
下一步,才正真接触渲染工作。最后还剩3个部分关于GPU,我们开始看一下顶点数据!(三角形还没被光栅化呢。还需要一些时间)
事实上,在这个阶段,管线已经出现了分支;如果我们运行compute shader,下一步将是compute shader阶段。但是我们先不讲它,因为compute shader是后面的部分!先讲常规渲染。