图形管道2011 GPU内存架构和命令处理器

原始链接

前面提到,在PC上,3D渲染指令在传输到GPU之前要经过各个阶段,其中命令处理器的内容因为太长只占了一个比特。在本文中,我们将开始稍微详细地介绍这一块的内容。当然,由于篇幅有限,没有办法完全覆盖它的所有细节。

命令缓冲区的整个过程都与内存有关,无论是通过PCI总线访问的系统内存还是本地显存,所以如果要按照流水线的顺序陈述内容,就必须在介绍命令处理器之前先介绍一些内存相关的内容。

因为GPU的内存子系统是为特殊用途而设计的,所以它不同于CPU或其他硬件的常规内存子系统,主要有两个区别:

其实GPU的带宽大大提升,代价就是延迟的增加。而这也是实际需要造成的。GPU更注重吞吐量而不是延迟。有时候耽误不要紧,我们可以干点别的。

GPU没有你的常规内存子系统——它不同于你在通用CPU或其他硬件中看到的,因为它是为非常不同的使用模式而设计的。GPU的内存子系统在两个基本方面不同于你在普通机器中看到的:

这些都是我们需要了解的关于GPU内存的内容,还有一个关于DRAM(动态随机存取存储器)的重要内容:DRAM芯片是以2D网格组织的,无论是物理上还是逻辑上。网格意味着有行和列的线,这些线相交的地方是一个晶体管和一个电容器。这里的重点是,DRAM中某个位置的地址实际上是分为行地址和列地址的,DRAM在读写的时候,实际上会读出某一行(缓存行)上所有列的内容数据。这意味着,如果你要读取的数据恰好在DRAM上的同一行,那么它的访问速度要比不在同一行时高得多。目前这个结论似乎没有作用,但后面要介绍的内容会逐渐凸显这一点的重要性。如果使用前面介绍的GPU/CPU的数据,只读取内存中有限的字节数,很难达到上述峰值。例如,如果你想以全带宽的方式访问存储器,一次访问的内容最好对应DRAM中的一整行。

从图形程序员的角度来看,PCIe硬件的内容看似毫无意义,但实际上,GPU硬件架构也是毫无意义的。但是当图形程序运行缓慢的时候,我们就不得不硬着头皮去了解底层的实现方式,以便定位瓶颈,然后找专业的同学帮忙解决这个问题。否则可能会导致以下情况:CPU直接访问显存和GPU上的寄存器,而GPU直接访问CPU的主存。后来由于两者之间极高的访问延迟(因为是跨芯片的数据访问),大概要在程序运行一周后(笑)。8 GB/s的内存带宽峰值其实是一个理论值,对应的是PCIe 2.0连接16通道的总带宽,而实际运行值大概是这个值的一半或者三分之一,都是可用的数值比例。与AGP等早期标准不同,今天的GPU是点对点对称连接——即带宽值是指双向值,而AGP标准是非对称的,从CPU到GPU的传输带宽高于反过来。

关于内存还有一点需要明确。现在我们有两种类型的内存,即本地视频内存和映射系统内存。其中一个需要一天时间到达北极,另一个需要一周时间通过PCI总线到达南极。你会选择哪条路?

最简单的解决方案:建立一个额外的地址行来告诉我们该走哪条路。这个方案很简单,但是经过多次验证,非常有效。如果我们的硬件(比如一些游戏机或者手机)采用统一的内存架构,那么我们别无选择,只能拥有一个内存,那么只有一条路可走。如果想把事情做得更精致,可以考虑增加一个MMU(内存管理单元)来分配一个虚拟地址空间,这样可以让你玩点花样,比如把一些经常访问的映射资源放在显存里(因为这样更快),而其他资源放在系统内存里,剩下的大部分资源不会直接映射。躺在硬盘里(必要的时候从硬盘里读取,当然这是极慢的。如果把内存访问时间延长到一天,从高清硬盘读取差不多要50年)。

当内存不足时,MMU允许对内存进行碎片整理,而无需实际复制。此外,它可以让多个进程更容易享受GPU。MMU是必须的,但是我不确定是不是所有的GPU都有这样的东西。

此外,还有一个DMA(直接内存访问)引擎,可以在不占用3D硬件/着色器核心的情况下复制内存。一般来说,这是用于系统内存和显存之间的复制(双向的),但也可以用于显存之间的复制(如果需要VRAM磁盘碎片整理的话,这个功能非常有用),但不能用于系统内存之间的复制(因为这是GPU中的一个功能单元,如果需要复制系统内存,可以直接在CPU中完成,不要通过PCIe传输到GPU,很蠢)。

更新:这里有一张图给出进一步的细节——GPU有多个内存控制器,每个控制器控制多个内存库,这是通过前面一个粗hub来完成的。

为了整理这里的所有内容,我们在CPU上有一个命令缓冲区和一个PCIe主机接口。CPU通过主机接口与GPU通信,并将其地址写入寄存器。之后GPU中有相应的逻辑通过加载指令读取这个地址——如果是系统内存,那么数据会通过PCIe传输,如果我们把命令缓冲区放在显存中,那么KMD会直接建立一个DMA传输进行数据传输。无论哪种情况,都不需要消耗GPU上的CPU资源或着色器核心资源。然后,我们可以在视频存储器上获得传输数据的副本。整个路径基本打开。让我们开始介绍命令的内容。

前面说过,GPU的现状是高带宽,高延迟。这种情况的一个解决方案是执行大量的独立线程。但是由于我们只有一个命令缓冲区,每个线程都需要按顺序从缓冲区中读取指令(因为命令缓冲区中包含的状态切换和渲染指令需要按正确的顺序执行),所以这里给出的一个比较好的解决方案是设置一个足够大的缓冲区,让指令按照大跨度提前执行,避免打嗝(性能消耗的尖峰)。

从这个缓冲区开始,正式进入命令处理阶段,本质上是一个状态机,会根据硬件指定的格式解析命令。一些命令用于处理与2D渲染相关的操作(除非专门为2D事务设置了单独的命令处理器,在这种情况下,3D命令处理器将不会与这组命令有任何交集)。在这两种情况下,现代GPU上仍然隐藏着专有的2D命令硬件。它就像这套模具上的VGA芯片,用于处理文本模式、4位/像素位平面模式、平滑滚动和其他类似的事情。该命令的一部分将把补丁数据传输到3D着色器管线,这将在后面讨论。有些命令会不经过任何绘制处理就进入3D着色器管道(有很多情况,后面会介绍)。一些命令用于实现状态切换。从程序员的角度来说,状态切换可以直接看作是变量修正,其实现逻辑也差不多。但是,由于GPU是大规模并行处理计算器,所以不可能简单地在并行系统中修改全局变量就指望不会出现问题。状态切换常用的实现方案有很多,基本上所有芯片都会根据不同的状态选择不同的实现方案:

从上面可以看出,在应用层面,看似修改一个变量参数那么简单,但实际上已经做了大量的处理来避免性能消耗的降低。

最后要介绍的一些指令是专门用来处理CPU和GPU同步的。

一般来说,这类指令的格式是“如果发生X事件,则执行Y逻辑”。先介绍Y逻辑执行部分。对于Y来说,有两种执行方式:第一种是推送模式,GPU会主动发送指令告诉CPU做什么(“Oi!CPU!我现在正在显示器0上输入垂直消隐间隔,所以如果您想在不撕裂的情况下翻转缓冲区,现在正是时候!”);第二种是拉模式,GPU记录一些关键信息,然后CPU在适当的时候发送消息获取相关数据状态(“比如说,GPU,你最近开始处理的命令缓冲区碎片是什么?”–“让我检查一下…序列id 303。”).前者通常以中断的形式执行,因为中断的高消耗,所以主要用于处理一些不常见的高优先级事件;后者的实现需要一些CPU可见的GPU寄存器,以及在事件发生时将数据从命令缓冲区写入寄存器的方法。

比如我们这里有16这样的寄存器,然后把currentCommandBufferSeqIdd写到寄存器0。之后,每个提交给GPU(KMD)的命令缓冲区都被分配了一个序列号,并且在每个命令缓冲区的开头都增加了一个处理逻辑:当到达这个命令缓冲区的某个位置时,开始向寄存器0写入数据。所以现在我们知道了GPU当前正在处理哪个命令缓冲区,命令处理器按照严格的顺序处理命令,也就是说,如果命令缓冲区中第一条命令的序列号是303,那么之前包括302在内的所有指令都已经执行完了,所以这里可以回收这些指令对应的空间做其他用途。

我们来看看触发事件X,上面提到的“到达这个命令缓冲区的某个位置”其实就是一个X事件,是最简单但也是非常有用的事件。其他事件包括“所有着色器在到达命令缓冲区中的某个位置之前,已经从批处理批处理中读取了所有贴图”(用于释放某个点的所有贴图和RT内存)、“所有活动RT/UAV的渲染过程已经结束”(用于确保当前需要的贴图有效)等等。

这些操作就是我们通常所说的“围栏”。选择写入状态寄存器的值的方法有很多种,但作者认为唯一健壮的是使用顺序计数器,但他没有提到具体的原因(可能写在其他不在本系列的博客中)。。)

到目前为止,已经介绍了GPU到CPU的同步机制,但这不是全部内容。目前GPU内部还缺乏一种数据同步机制(并行计算)。我们用前面的RT例子来说明,RT只有在所有渲染操作完成后(除了一些其他的处理步骤)才能作为贴图使用。这实际上对应的是一条等待型指令:一直等到寄存器M中的值等于n(也可以是比较指令等其他指令。).在这种情况下,在提交新的批处理之前,可以保证RT的同步,并且还可以构建仅GPU的刷新操作。现在GPU之间的数据同步完成了。在DX11 Compute Shader中另一个优秀的同步机制推出之前,这个同步机制是GPU上唯一的同步机制,对于普通渲染来说已经足够了。

另外,如果可以在CPU上写GPU寄存器,也可以用同样的方式实现CPU和GPU的同步——CPU提交一个部分命令缓冲区,其中包含对某个值的等待操作,当达到等待条件时,CPU将该特定值写入GPU寄存器。这个逻辑可以用来实现D3D11风格的多线程渲染过程,其中向GPU提交一个批量引用VB/IB(可能是在另一个线程的写操作中)。在这种情况下,你只需要在正式渲染开始之前等待锁操作被释放。如果GPU根本不去命令缓冲区中预设的指令位置,那么这里的这个等待操作就相当于无效,否则要等一段时间才能释放数据锁状态。事实上,我们可以在没有CPU允许对寄存器进行写操作的情况下完成这个方案,只要我们能够纠正之前已经提交到命令缓冲区的数据(只要命令缓冲区中有一个“跳转”指令)。

当然不一定要用上面说的寄存器设置和等待模型;对于GPU之间的同步,可以直接使用一个“RT barrier”指令来保证RT使用的安全性。但原作者认为寄存器设置模式效果会更好(将仍在使用的资源上报给CPU,完成GPU之间的同步),一举两得。

更新:此处添加了一个流程图。情况看起来比较复杂,后面会注意精简相关细节。基本思路是命令处理器前面有一个FIFO,然后进入命令解码逻辑。指令的执行是通过与2D单元、3D渲染单元和着色器单元交互的多个块来完成的。之后有一个处理同步/等待等指令的块(这些指令有上面提到的可见寄存器),还有一个处理命令缓冲区跳转/调用指令的单元(改变当前需要获取的FIFO地址)。所有分配任务的单位都会发回一个指令完成的事件,让我们知道不再使用地图之类的信息。