图形流水线之旅 part2 GPU存储架构和指令处理器

        

        在前面的part1中,我解释了3D渲染指令在PC上实际被GPU处理之前所经过的各种阶段,然后以指令处理器这儿挖了个坑。OK,在这部分,我们确实会先遇到指令处理器,但你要知道,所有指令缓冲区的东西都会经过存储器——不管是系统内存还是显存。我们按顺序通过流水线,所以在进入指令处理器之前,先花点时间谈谈存储器(内存、显存)。

存储器子系统

        GPUs没有标准规则的存储子系统——它不像你平时看到的通用CPU或是其他硬件,因为它是为各种各样不同使用模式设计的。有两个根本性方式可以看出GPUs存储子系统与一般机器的不同。
        第一个就是GPU的存储子系统非常快。一个酷睿i7 2600K在极限下可能达到19GB/s的内存带宽。而一块GeForce GTX 480的总内存带宽接近180GB/s,这差不多超了一个数量级。
        第二个就是GPU的存储子系统非常慢。在Nehalem(第一代i7)上,一次高速缓存与主存的缺失大约需要每时钟频率140个周期。而前面提到的GTX 480拥有400-800个时钟的存储访问延迟。以周期来测算的话,GTX 480差不多有i7的4倍存储延迟。前面提到的酷睿i7时钟频率为2.93GHz,而GTX 480着色时钟频率为1.4GHz——这又有2倍的差距了。这快接近一个数量级的差距了。
        看出来了吧,GPUs可以在带宽上可以巨量增长,但它也要承担随之增长的延迟。这是通用模式的一部分——GPU的吞吐量受限于延迟。别在这儿蛋疼了,咱们还是做点其他的吧。
        上述差不多就是你所需要了解的GPU存储器的一切。当然,除了接下来讲的重要的DRAM。DRAM芯片无论是逻辑上还是物理上都是以2D网格的形式存在的。它上面拥有水平线与垂直线。每个线之间的交叉点是一个晶体管和一个电容器,如果你想了解的更多,去查WIKI(http://en.wikipedia.org/wiki/DRAM#Operation_principle)吧。总之,关键点在于,DRAM上一个位置的地址是横、纵地址分开的,DRAM内部读/写通常是同时遍历所给那一行的所有列。这意味着,访问正确映射到DRAM上一行的存储列比访问相同数量的内存而要遍历多行要廉价的多。这些看起来有点像DRAM的冷知识,但接下来就会变得很重要。把这个和前一段联系起来看,你不可能只是在内存中读一点字节就达到存储带宽的极限。如果你想让存储带宽饱和,一次性读DRAM上一整行吧。

PCIe主机接口

        从图形程序员角度看,这个硬件有点没意思。实际上,GPU硬件架构上有同样的东西。就是它一慢就会成瓶颈,而你不得不关心的东西。所以你要做的就是让优秀的人去看好它,确保不出事。除此之外,它让CPU可以读/写访问显存和GPU的寄存器,让GPU可以读/写访问部分主存。其实这些都很让人很头疼,因为这些处理过程的延迟甚至比存储延迟更糟——信号离开芯片,进入插槽,经过主板,然后又回到CPU的某个地方。带宽虽然合适——总峰值可达8GB/s的总带宽通过16线 PCIe 2.0相连,GPU使用了大部分,所以只剩大约3到5成的总CPU内存带宽可用。不像早期的标准如AGP,PCI是点对点串行链接——双向带宽。AGP从CPU到GPU有一个快速通道,但没有反方向的。

最后一点关于存储器的零碎

        我们现在已经非常非常接近3D指令了。不过还得先解决一件小事。现在摆在我们面前的有两种存储器——(局部)显存和被映射的系统内存(主存)。一个花费一天向北旅行,另一个踏上PCIe大道向南旅行一星期。我们选哪条路?
        早期解决方案:额外添加一条地址线来告诉你走哪条路。简单又实用,而且已经使用多次了。或许你处于一个统一的存储器架构上,比如一些游戏主机(不是PC)。那样的话,就不用纠结选择的问题的了,存储器就是你要去的终点。如果你想做的更漂亮点,可以添加一个MMU(存储器管理单元),它为你提供完全虚拟化的地址空间并允许你展示各种技巧,比如将一张纹理上要频繁被访问的部分存在显存上(速度快),其他部分放在系统内存里,而且大部分根本不被映射——就像一团空气。
        当程序运行而又发现显存不足时,MMU可让你对显存地址空间进行碎片整理而不需要真正地拷贝东西。而且MMU也让多进程共享一个GPU变得很容易。使用一个MMU肯定是可以的,但我不确定是否必须得有。总之,一个MMU/虚拟内存不是你可以实实在在摸到的(不像一个硬件架构里的高速缓存和存储器),但在一些特殊阶段里也不是很特别。
        还有一个DMA引擎的东西,它可以复制内存而不牵扯到任何3D硬件/着色器核心。一般来说,它至少能在系统内存和显存之间进行复制(两个方向均可)。它经常做的是显存到显存的复制操作(你想做显存碎片整理的话,这个东西很有用),但不能做系统内存到系统内存的复制。因为这是GPU而不是内存复制单元——在CPU上做系统内存的复制吧,在那儿不需要双向通过PCIe。
        下面这幅图可以说的更详细点。如今的GPU有多个内存控制器(controller),每一个控制器又可以控制多个存储体。但它们都得想方设法去获取带宽。



        OK,总结一下,我们已在CPU上准备了一个指令缓冲区,拥有了PCIe主机接口。因此,CPU可以获取这些并将它们的地址写入寄存器中。我们的逻辑是由地址返回数据——如果它经由PCIe来源于系统内存,而我们却想在显存中获取指令缓冲区,那么KMD可设置一个DMA转换,这样不管是CPU还是GPU上的着色器核心都不必担心了。然后我们可以通过存储子系统来从显存上的副本获取数据。现在一切准备就绪,终于可以一窥指令的究竟了。

最后,指令处理器

        关于指令处理器的讨论现在就开始了,前面讲的诸多东西其实只为了一个东西:
         缓冲作用
        前面提过,两条存储路径都会引起高带宽而高延迟。对于GPU流水线中的接下来的大部分“位”,要做的只是运行大量独立线程。但问题在于,我们只有一个指令处理器,它又需要按顺序接收提供的指令缓冲区(因为这个缓冲区里包含了诸如很多状态改变与渲染的指令,这些都需要按顺序正确执行)。因此,我们接下来要做的就是:添加一个足够大的缓冲区并向前预读足够远,以此来避免间断。
        在这个缓冲区里,处理器会到达真正的指令处理前端,从根本上说,它只是一个知道如何解析指令的状态机。一些指令处理2D渲染操作——除非有一个单独用于处理2D事务的指令处理器而且3D端从来没看见它。不管怎样,现代GPU里仍然隐藏着一个专用2D硬件,就像某个地方存在一个VGA芯片依然支持文本模式、4-bit/pixel位面模式、平滑卷动等等。运气不错,没用显微镜就找到了这些快要淘汰的东西。这些东西确实存在,但此后我可不会再提起。然后就是将基元(primitives)传递给3D/着色器管道的各种指令了。当然也有去了3    D/着色器管道却不做任何渲染的指令。这些我会在下一章详细介绍。
        接着是一些改变状态的指令。作为一个程序员,你可以想成是改变变量。GPU是一个做海量并行的计算器,你不能在一个并行系统里改变一个全局变量并期望其他一切工作正常——如果你没法保证在你强制改变后一切OK的话,那最后肯定会有BUG。其实有许多流行的解决方法,基本上所有的芯片都会根据不同的状态类型使用不同的方法。
无论何时改变一个状态,你得强制命令所有与该状态有关的未完成工作立马结束/完成(比如基本地局部流水线Flush操作)。曾经,图形芯片处理大部分状态更改都是采用此法——在批处理少、三角形少、流水线短的情况下简单而廉价。当然啦,随着批处理和三角形数量增加,流水线变长,这种方法的消耗会迅速增长。现在这种方法还在用,但仅限于状态更改频率不高或者是一些昂贵/困难的特殊方案。
  • 你可以让硬件单元完全无状态。只需要将状态更改指令传递给需要的状态,然后每个周期将该状态附加给所有下游的当前状态。已更改的状态只循环不存储。它们穿过一个流水线阶段,然后奔向下一个。因此,如果你的状态里只有很少的位数据(状态更改的不多),而一些流水线阶段要查看该状态内的位数据,那这种操作相当昂贵而且不实用。当然,如果是设置整个活跃纹理的纹理采样状态,到还行。
  • 有时,只存储了状态的一个副本,而阶段更改串行化事务很频繁,每次更改你都得将副本Flush一次,但如果你有两个副本,事情就会好很多。这样你的状态设置前端就可以提前获取信息。假定你有足够的寄存器(插槽)存储每个状态的两种模式,有效模式设置为引用插槽0。你可以安全地修改成插槽1而不需要停止或干扰作业。现在你不必发送全部状态循环流水线——只需一个选择使用插槽0还是1的单比特指令。当然,如果状态更改指令进来时,插槽0和1都不空,那你还是等等会,但你可以提前一步获取到这个情况。使用更多插槽用的是相同的技术。
  • 对于一些像采样器或者着色器资源视图的状态,你需要同时设置大量状态,问题在于你做不了。你不会希望只因为可能会使用跟踪到的2条飞行状态集而预留2*128活跃纹理的状态空间。对于这些情况,你可以使用一种寄存器重命名方案——一个含有128个物理纹理描述符的内存池。除非一个着色程序中确实同时需要128个纹理,那状态更改将会非常慢。但一般来说,一个APP用不到20个纹理,你就有足够的动态余量来维持多个状态版本的运行。
        这个清单不是很全面——但关键点在于一些看起来像更改变量一样简单的事情可能需要不一般的海量硬件支持。

同步

        最后,剩下的指令集处理CPU/GPU和GPU/CPU之间的同步性问题。
        通常,讲解这些的形式都是“如果X发生了,执行Y”。我会先解决“执行Y”部分——关于Y是什么,这儿有两种不错的观点:它可能是GPU大喊CPU立刻做一些事的push-model通知(“喂,CPU!我现在要在垂直空白间隙进入0显示模式,所以如果你要光滑地翻转缓存的话,就是现在!”),或者,它可能是GPU先记下某事,CPU可以延后询问的pull-model事务(“那个,GPU啊,你处理的最近的指令缓冲片段是啥?“—“我查一下啊……是序列ID 303”)。前者一般由中断操作来实施,而且只用于很少的、高优先级的情况,因为中断操作太昂贵了。所有后者的实施,都是在必须发生时才从指令缓冲区向CPU可见的GPU寄存器内写入数据。
        假定你有16个寄存器。然后给寄存器0分配currentCommandBufferSeqId。先给要提交给GPU的每一个指令缓冲区分配一个序列号,然后在每个指令缓冲区开始位置添加提示“如果你要在指令缓冲区内获取该指针,请写入寄存器0”.好的,现在我们就知道GPU当前正在处理哪一个指令缓冲区了。而且我们还知道,指令处理器是严格按顺序实施/结束指令的。所以如果指令缓冲区303中的第一条指令被执行了,那么序列ID 302之前(包括302)的所有指令缓冲区都已经实施/结束了,可以被KMD回收了,要么释放,要么修改。
        我们现在有了一个关于X的例子:“如果你到了这儿”——这可能是最简单的例子,但已经够用了。还有其他例子:“如果所有着色器在指针进入指令缓冲区之前都已经完成从(该指针指向的)批处理中读入所有纹理的工作”(这标志回收指向纹理/渲染目标内存的指针很安全),“如果已完成渲染所有活跃渲染目标/UAV”,还有“如果所有操作到这点全部完成”等等等等。
        有多种方法可以取出写入到状态寄存器中的值,但我觉得唯一正常的方法是使用一个顺序计数器。我这儿没讲那些与原理无关的随机信息(什么是顺序计数器?),因为我觉得你应该了解。
        到这儿我们已经说了一半——可以从GPU返回状态给CPU,允许在驱动程序上做正确的存储管理(尤其是我们可以查询那些曾用于顶点缓存、指令缓存、纹理和其他资源的已被安全回收了的内存)。但这不是全部——我们漏掉了一点东西。例如,如果我们要纯粹在GPU端做同步怎么办?先回到渲染目标的例子上。我们在渲染结束前是不能将它作为纹理的。解决办法就是——“等”:等到寄存器M有了值N。相较而言,花费可能相等,可能便宜,可能昂贵——我一般简单地认为是相等的。该方法允许我们在提交一个批处理之前做渲染目标同步。它也允许我们做一次全局GPU flush操作。一切搞定。GPU/GPU的同步问题终于解决了——在介绍DX11中计算着色器的时候,有一种更好的——粒度同步(grained synchronization),通常这是你在GPU端唯一的同步机制。对于定期的渲染情况,你需要关心更多。
        如果你要从CPU端写入寄存器,也可以使用另一种方法——提交一个包含“等”特定值操作的局部指令缓存,然后由CPU而不是GPU更改寄存器。这种事可以由D3D11风格的多线程渲染来实施,你可以提交一个引用锁定在CPU端的顶点/索引缓存的批处理。你只需在实际渲染调用的前方等着就行。一旦顶点/索引缓存解锁,CPU就能更改寄存器中的内容。如果GPU从未在得到这样的指令缓存,“等”就成了一个空操作。如果得到了,就会花费一些(指令处理器)时间来解析,直到确认数据存在。事实上,即使没有CPU可写状态寄存器,如果你可以在提交之后修改指令缓存的话,也可做相同的事,只要有一个指令缓存“跳转”操作。
        当然,其实你不需要这种注册寄存器/等待寄存器模型。对于GPU/GPU同步,你只需简单地拥有一个“rendertarget barrier”结构来确保一个渲染目标(render target)安全可用,再加上一条“flush everything”指令即可。但我自己更喜欢这种注册寄存器风格的模型,因为它可以一石二鸟(报告CPU正使用的资源和GPU自动同步)。
        下面这个图表有点复杂,我简单的说下。基本思想是:指令处理器在前面有一个FIFO,接着是指令解码逻辑,然后通过与2D单元、3D前端(标准3D渲染)、着色器单元(计算着色器)直接交流的各种块上面执行,接着是一个处理同步/等待指令的块,还有一个处理指令缓存跳转/调用的单元。所有这些分配了工作的单元都需要向我们发送返回完成事件,这样我们才知道比如什么时候纹理没被使用,它们的内存可被回收。



结束语

        下一节我们会开始接触一些实际的渲染工作。事实上,现今流水线已经开始出现分支,如果我们运行计算着色器,那下一步将是运行计算着色程序。但我们不这么做,因为计算着色器是很后面的部分的主题。还是先从标准(固定)渲染流水线开始吧。
 提一句:我在这儿说的只是大致框架,我为了便于理解少讲了很多细节,有必要(兴趣)的可以自己去研究。





  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值