【Graphics Pipeline 2011】GPU内存架构以及Command Processor

原文链接

Not so fast.

前面介绍了,在PC上,3D渲染指令在被传递到GPU之前所经历的各个阶段,其中Command Processor一块内容由于太长,只是占了个位,在本文中要开始对这一块的内容做一个稍微详细一点的介绍,当然,由于篇幅有限,也没有办法完全覆盖其所有细节。

Command Buffer的整个处理过程都是跟内存相关的,不管是通过PCI总线访问的系统内存还是local显存等,因此如果我们要按照管线上的顺序来对内容进行陈述,就必须在介绍Command Processor之前先介绍一部分内存相关的内容。

The memory subsystem

由于GPU的内存子系统是为特殊的目的而设计,因此跟CPU或者其他硬件的常规内存子系统有所不同,主要有如下两个区别:

  1. 一方面,GPU内存子系统速度更快。Core i7 2600K的内存带宽好的时候大约为19GB/s,GeForce GTX 480的内存带宽则接近于180 GB/s,差不多是CPU的10倍。

  2. 另一方面,GPU内存子系统的速度又更慢。Nehalem(第一代Core i7)内存上如果发生cache miss,如果将 AnandTech给出的内存延迟与时钟速率相乘的话,大概会需要140个时钟周期;而GeForce GTX 480的内存访问延迟大概是400~800个时钟周期。因此,如果以cycle作为衡量单位的话,那么GeForce GTX 480的时延大概是Core i7的四倍还多,不过这里Core i7的时钟频率是3.93GHz,而GTX 480则是1.4 GHz,也就是说,这里又慢了两倍。总的算起来就相当于GPU的时延差不多是CPU时延的十倍左右了。(2020年6月数据,Intel Core i9-10980XE,带宽8 GT/s,主频最高4.8 GHz; Titan RTX,带宽672GB/s(感觉还不如CPU的带宽了?),频率最高1770 MHz(差距并没有缩小),此处结论依然有效)。这个结论非常有意思,从常识上来说,不太可能会出现这样的强烈反差。

事实上是,GPU在带宽上的大幅提升,其代价就是时延的增加。而这也是基于实际需要而导致的,GPU对于吞吐量throughput的重视程度要高于时延,有时延没关系,我们可以干点别的。

GPUs don’t have your regular memory subsystem – it’s different from what you see in general-purpose CPUs or other hardware, because it’s designed for very different usage patterns. There’s two fundamental ways in which a GPU’s memory subsystem differs from what you see in a regular machine:

这些就是我们需要知道的所有有关GPU内存的内容,另外还有一个关于DRAM(Dynamic Random Access Memory)的重要内容:DRAM芯片是按照2D网格形式组织的,不论是从物理上还是逻辑上来看,都是如此。网格就意味着存在着横竖的行列线条,在这些线条相交的位置就是一个晶体管与电容。这里的重点是,DRAM中某个位置的地址实际上实惠被分割成行地址跟列地址的,而DRAM在进行读写的时候,实际上是会将某一行上的所有列的内容数据都读取出来(缓存行,cache line)。这也就意味着如果想要读取的一票数据正好处于DRAM上的同一行的话,其访问速度要远远高于不在同一行的情况。在目前看来,这个结论好像没什么作用,不过后面要介绍的内容就会逐渐凸显这一点的重要性,如果用前面介绍的GPU/CPU的数据来说明的话,那就是,如果我们只是读取内存上有限的几个字节的话,是很难达到上述的巅峰数值的,比如说,如果你希望以满带宽的方式进行内存访问,那么一次性访问的内容最好对应于DRAM中的一个整行。

The PCIe host interface

从图形程序员的角度来看,PCIe硬件的内容好像没什么意思,而实际上GPU硬件架构同样没啥意思。不过当图形程序运行缓慢的时候,我们就不得不硬着头皮去了解底层的实现方式以期定位到瓶颈,之后呢就找专业的同学帮忙来解决这个问题。否则可能会导致如下的局面,CPU直接访问显存以及GPU上的寄存器,而GPU则直接访问CPU主存,之后由于两者之间超高的访问延迟(因为是跨芯片数据访问),等程序运行完一帧大概是一周以后了(笑)。内存带宽巅峰数值8 GB/s实际上是理论值,对应的是16路的PCIe 2.0连接时的总带宽,而实际上运行时的数值大概是这个数值的一半或者三分之一,这都是可用的数值比例。不像早期的标准比如AGP,现在的GPU是点对点的对称连接——即带宽数值指的是双向数值,AGP标准则是不对称的,从CPU到GPU的传输带宽要比反过来要高一些。

Some final memory bits and pieces

关于内存,这里还有一点需要介绍清楚。现在我们有两类内存,即local显存与映射后的系统内存。其中一个需要花费一天的时间抵达北极,而另一个则需要花费一周的时间通过PCI总线抵达南极,你会选择哪条路?

最简单的解决方案:额外搭建一条地址线路告诉我们该走哪条路。这个方案很简单,但是已经经过很多次的验证,十分有效。如果我们的硬件(比如部分游戏主机或者手机),采用的是统一的内存架构(unified memory architecture),那么我们没得选,只有一个内存,因此只有一条路可以走。如果你想把事情做得精致一点,那么可以考虑添加一个MMU(内存管理单元,memory manage unit),用于分配一块虚拟地址空间,可以让你玩出一些花样,比如像是将一些频繁访问的贴图资源放置在显存中(因为更快),而其他的资源则放置在系统内存中,之后剩下的大部分资源则直接不做映射,躺在硬盘里(在需要的时候从硬盘读取,当然这个超慢,如果将内存访问时间拉长到一天,那么从HD硬盘读取差不多就相当于需要50年)。

一个MMU允许在显存不足的时候,以不进行实际的拷贝的前提对显存做磁盘整理。此外还可以使得多个进程对GPU的共享变得更为容易。MMU很有必要,不过不确定是否所有GPU都有这么个东西。

此外,还有一个DMA(Direct Memory Access)引擎可以在不占用3D硬件/Shader Cores的前提下进行内存拷贝。通常来说,这个是用于在系统内存与显存之间的拷贝(双向),不过实际上也可以用于显存之间的拷贝(如果要进行VRAM磁盘整理的话,这个功能很有用),但是不能用于系统内存之间的拷贝(因为这个是GPU中的功能单元,如果需要系统内存拷贝,直接在CPU做就完了,不要专门通过PCIe传输到GPU上来进行,这太蠢了)。

Update: 这里画了一张图来给出更进一步的细节 – GPU有多个内存控制器,每个控制器控制着多个内存banks,这个是通过前方的一个较粗的hub来完成的

这里对所有的内容进行梳理,我们在CPU上有一个command buffer,有一个PCIe host interface(PCIe主机接口),CPU通过主机接口来与GPU通信,并将其地址写入到寄存器中。之后在GPU中有相应的逻辑将这个地址通过一个load指令来读取数-如果是系统内存的话,那么数据会通过PCIe传送过来,而如果我们是将command buffer放置在显存中的话,那么KMD会直接架起一个DMA传输进行数据输送。不论是上面的哪种情况,都不需要消耗CPU资源,也不需要消耗GPU上的shader core资源。之后我们就能够在显存上拿到传送过来的数据的拷贝。整条路径基本上就打通了,下面开始进入commands内容介绍。

At long last, the command processor!

前面提到,GPU目前的情况是带宽高,延迟高。而应对这个情况的一个方案是执行大量的独立线程。不过由于我们只有一个command buffer,因此需要各个线程按顺序从buffer中将指令读取出来(因为command buffer中包含的状态切换以及渲染指令需要按照正确的顺序执行),因此这里给出的一个较好的解决方案是,设定一个足够大的buffer,并按照一个较大的跨度提前获取需要执行的指令来避免hiccups(性能消耗上的一个尖刺)。

从这个buffer开始,就正式进入了command处理阶段,其本质上是一个状态机,会按照硬件指定的格式来对command进行解析。部分command用于处理跟2D渲染相关的操作(除非专门为2D事务专门设立一个单独的command processor,在这种情况下,3D command processor就不会与这套command产生任何交集),不论是哪种情况,在现代GPU上都仍然存在在隐藏的专属2D command硬件,就像是在这套模具上的某个用于处理text mode(字体模式),4-bit/pixel bit-plane modes,平滑滚动以及其他类似事务的VGA芯片一样。部分command会将面片数据传输到3D shader管线,这个细节后面再讲;部分command会进入到3D shader管线,但是不进行任何绘制处理(情况有很多种,后面再介绍)。部分command用于实现状态切换,从程序员的角度来说,可以直接将状态切换看成是变量修正,其实现逻辑是相似的。不过由于GPU是一个大型的并行处理计算器,因此不能简单的在一套并行系统中对全局变量进行修正并奢望不发生任何问题,关于状态切换有很多套常用的实现方案,而基本上所有的芯片都会根据状态的不同选择不同的实现方案:

  • 发生状态切换时,则要求所有与此状态相关的后续工作都已经是已完成的状态(比如用一个部分管线(partial pipeline)flush操作)。从统计上来看,这是大多数状态切换常用的策略——简单,且在面片数或者batch数较少管线较短的时候消耗较低,而如果面片数,batch数增加,管线加长,消耗可能就会飞起。这种策略目前依然有效,主要用于处理一些不常发生的状态切换(从全帧的角度来考虑,寥寥数个部分管线flush操作并不算什么)以及一些使用专用处理策略代价过于高昂的状态切换。
  • 另一种策略是将硬件单元做成状态无关(stateless)的,状态切换指令直接传输到那些与之相关的stage,之后每个cycle这个stage都会将当前的state附加到其下游的所有东西上。这些状态并没有存储下来,但是却无处不在,因此如果部分管线stage想要查看状态中的部分bit位,这是可以做到的,因为数据会被传进来(并沿着管线继续往下传)。如果一个状态只包含非常少的几个bit,这种策略是非常便宜与实用的;而如果是贴图采样state加上全套active贴图,代价就比较高了。
  • 每次状态切换的时候都执行flush会导致频繁的序列化操作,消耗太高,因此这里的一种处理策略是直接存储一份(或者多份)状态的拷贝,这样的话,状态设置就可以提前一点进行了。比如说,如果GPU寄存器有足够的空间存储两套状态,分别对应slot 0跟slot 1,那么在slot 0处于繁忙状态的时候,我们还可以对slot 1进行修改,此时根本不需要担心会影响到slot 0;在进行状态切换的时候,也不需要发送整套状态数据,只需要一个bit位用于表明是slot 0 还是slot 1就可以了。当然,如果两套状态都处于繁忙状态,那么就还是要等待,不过总的来说还是比只使用一套状态要提前一点。这里如果将状态备份数目继续增加,那么优势还可以继续提升。
  • 对于一些sampler或者Shader Resource View状态切换,可能需要同时进行大量同类设置,不过也并不是没有办法绕过。不要因为前面说到需要存储一个状态的备份,而认为这里需要为两套128张active textures分配状态空间,实际上这里可以使用一套寄存器残重命名策略(register renaming scheme),用一个包含了128张物理贴图描述结构的数据池来对数据进行处理。如果某个渲染真的需要128张贴图的话,那么这种策略依然会变得缓慢,不过按照通常的情况,一个应用同时使用的贴图数目不会超过20张,因此这样的数据池可以同时保存多套贴图状态数据。

从上面可以看到,在应用层面看起来就像是修正了一个变量参数一样简单的操作,在实际上是进行了大量的处理以避免性能消耗的降低。

Synchronization

最后要介绍的一部分指令,是专门用于处理CPU/GPU之间同步的指令。

通常来说,这类指令的格式是“如果发生了X事件,那么执行Y逻辑”。这里先来介绍一下Y逻辑执行部分。对于Y而言,这里有两种执行选项:第一种是推送模型,这种模型中GPU会主动发指令告诉CPU可以做什么事情了 (“Oi! CPU! I’m entering the vertical blanking interval on display 0 right now, so if you want to flip buffers without tearing, this would be the time to do it!”);第二种是拉取模型,GPU将一些关键信息记录下来,之后CPU在合适的时机发消息获取相关数据状态 (“Say, GPU, what was the most recent command buffer fragment you started processing?” – “Let me check… sequence id 303.”).前者通常是采用中断的方式执行, 因为中断的高消耗,因此主要用于处理一些不常用的高优先级事件;后者的实现需要用到一些CPU可见的GPU寄存器,以及当某个事件发生时,将数据从command buffer中写入到寄存器的方法。

比如我们这里有16个这样的寄存器,之后将currentCommandBufferSeqIdd写入到寄存器0。之后为每一个提交到GPU(KMD)的command buffer分配一个序号,并在每个command buffer的开头,添加一个处理逻辑:当抵达这个command buffer的某个位置之后,开始将数据写入到寄存器0。那么现在我们就知道了GPU当前正在处理哪个command buffer,且command processor处理command是严格按照顺序进行的,即如果command buffer中的第一个command的序号是303的话,那么前面的所有指令包括302就都已经被执行了,因此这里可以将这些指令对应的空间重新收回用作他用了。

下面再来看下触发事件X,上面说到的“抵达这个command buffer的某个位置”实际上就是一个X事件,且是其中最简单但是也非常有用的事件;其他的事件还包括“在抵达command buffer的某个位置之前,所有shader完成了从批处理batch中而来的所有贴图读取操作”(用于在某点释放所有的贴图、RT内存),“所有active RT/UAV的渲染过程已经结束”(用于确保当前需要取用的贴图是否有效)等等。

这些操作就是我们平常所说的“fences”。挑选写入到状态寄存器的数值的方法有很多种,但是原文作者认为唯一稳健的一种就是使用一个顺序计数器来完成,不过没有提及具体的原因(说可能会写在其他非本系列的blog中。。)

到目前为止,就已经介绍完了从GPU向CPU的同步机制,但是这并不是全部的内容,目前还欠缺了一块GPU内部的数据同步机制(并行计算)。还是用前面的RT的例子来说明,只有当所有的渲染操作都完成(除此之外还有一些其他的处理步骤)以后才能将RT用作贴图。这个实际上对应的是一个等待类型的指令:等到寄存器M中的数值等于N为止(也可以是其他的指令比如比较指令等)。在这种情况下,就可以保证在提交一个新的batch之前完成RT的同步,同时还可以构建一个纯GPU的flush操作。现在GPU之间的数据同步也完成了。在DX11的Compute Shader中的另一种更优秀的同步机制引入之前,这套同步机制就是GPU上唯一的一套同步机制了,对于普通的渲染来说,已经足够使用。

另外,如果在CPU上能实现对GPU寄存器的写操作,那么按照同样的方法,也可以实现CPU到GPU的同步——CPU提交一个partial command buffer,这个buffer包含了对某个特定数值的等待操作,当等待条件达成时,CPU将特定数值写入到GPU的寄存器中。这套逻辑可以用于实现D3D11风格的多线程渲染流程,在这个流程中可以提交一个引用着正被CPU锁定状态的VB/IB(可能正处于另一个线程的写操作中)的batch到GPU。在这种情况下,只需要在正式渲染开始之前等待锁操作解除即可。如果GPU在command buffer中根本就没有走到预设的指令位置,那么这里的这个等待操作就相当于无效的,否则就是花点时间等待数据锁定状态解除了。实际上我们可以在不需要CPU对寄存器的写权限的前提下完成这套方案,只需要能对之前已经提交到command buffer的数据进行修正就行了(只要在command buffer中有一个“jump”指令)。

当然,这里不必一定需要使用上面提到的寄存器设置、等待模型;对于GPU之间的同步,可直接使用一个“RT barrier”指令来确保RT的使用是安全的。不过原文作者认为寄存器设置模型效果会更好一点(将尚在使用中的资源情况报告给CPU,同时完成GPU之间的同步),一石二鸟。

Update: 这里增加了一个流程图,情况看起来比较复杂,后面会注意精简相关细节。基本思想是,command processor在前面哟一个FIFO,之后进入command解码逻辑,指令的执行是通过多个与2D单元,3D渲染单元以及shader单元存在交互的block完成的。之后有一个block用于处理同步/等待等指令(这些指令前面说过,有着可见的寄存器),这里还有一个用于处理command buffer的jump/call指令(改变当前需要获取的FIFO的地址)的单元。所有派发任务的单元都会发回一个指令完成的事件从而让我们知道比如贴图已经不再使用了等信息。

你可能感兴趣的:(【Graphics Pipeline 2011】GPU内存架构以及Command Processor)