Chapter 3 – The Graphics Processing Unit

历史上图形学的发展起始于在每个像素的扫描线与三角形重叠处的颜色插值,之后显示这些值。还包括读取图像的能力,其允许我们将贴图运用于物体表面。加入了用于插值以及z-depth测试的硬件。由于这个部分的使用频率很高,这一部分处理由硬件直接负责,以增加处理性能。而渲染管线的更多部分以及每个部分更多的功能在之后的世代中被不断地加入。专业图形硬件相较于CPU的唯一优势就是速度,但是速度非常的重要。

在过去的20年中,图形硬件已经发生了极大的变化。第一个包含硬件顶点处理的消费级图形硬件在1999年面世(NVIDIA GeForce256)。NVIDIA创建了graphics processing unit(GPU)这一术语,用以区分GeForce256与之前只能进行光栅化的芯片,但之后行业就停滞不前了。几年之后,GPU从只能进行设置的复杂固定功能管线进化成高度可编程的模块,开发者能在其中实现自己的算法。各种类型的可编程着色器是我们控制GPU的主要方法。出于效率的考量,部分管线仍然只能设置,并不能编程,但是可编程以及灵活性是整体的行业趋势。

GPU专注于一组高度可并行的任务,所以其处理速度非常快。它拥有主要运行z-buffer,主要用于读取贴图图像以及其他buffer,以及主要负责检测哪一些像素与三角形相交的各种客制化芯片。我们在第二十三章中会学习这些芯片如何完成它们的任务。在这之前我们更需要了解GPU是如何并行处理它的可编程着色器。

在3.3小节中,我们将解释着色器的功能。现在,我们只需要知道着色器是一个小型处理器,其会处理一些相对独立的任务,例如将一个顶点由其在世界空间内的位置转换至屏幕坐标,或者计算一个被三角形覆盖的像素的颜色。随着每一帧数以千计或者数以百万计的三角形被传输至屏幕,每一秒可能会有十亿计的着色器调用(shader invocation)

首先,延迟(latency)是所有处理器都需要面对的问题。读取数据是需要时间的。一般我们可以认为,如果信息距离处理器越远,那么等待的时间就越长,这就是延迟。在第二十三章中,我们会学习更多关于延迟的细节。相较于位于本地寄存器中的信息,从内存芯片中读取信息会花费更长的时间。在第十八章中,我们将会讨论更多关于内存读取的内容。最关键的是,等待读取数据的时间意味着处理器是闲置的,其会降低效率。

3.1 并行数据结构

不同的处理器结构会使用不同的策略以减少闲置。CPU被优化为处理大量不同的数据结构以及庞大的代码。CPU拥有多个处理器,但是主要以连续地方式运行代码,受限的SIMD向量处理只是一个小小的例外。为了尽可能减少延迟的影响,大多数CPU芯片由速度较快的本地缓存所组成,这一部分内存将包含接下去会使用的数据。CPU也会使用不同的技术以避免闲置,例如,分支预测(branch prediction),指令重新排序(instruction reordering),寄存器重命名(register renaming)以及缓存预读取(cache prefetching)。

GPU使用完全不同的方法。GPU芯片的大部分区域主要是一大组处理器,我们称之为着色器核心(shader cores),数量以千计。GPU则是一个流处理器,其中有序的多组相类似的数据将被轮流处理。正因为其相似性——一组顶点或者像素——GPU可以以大量并行的方式去处理这些数据。另一个重要的原因是这些调用都是尽可能相对独立的,也就是说他们并不需要周围区域调用的数据,也不需要分析可写入的内存位置。但有时候为了支持其他新的有用的功能,我们会打破这些定律,例如一个处理器以等待另一个处理器完成其工作,但这些操作的代价都是潜在的延迟。

GPU都是为了总处理量吞吐量(throughput)进行优化的。但是,这样的高速处理也是有副作用的。也就是说,芯片中负责内存的缓存以及控制逻辑的区域会更少,这样也意味着每一个着色器核心的延迟会远远高于GPU处理器所遇到的延迟。

假设一个网格被光栅化,且有2000个像素块需要被处理;那么像素着色器程序将被调用2000次。假设,我们的GPU非常弱鸡,只有一个着色器处理器。它开始2000个像素块中的第1个着色器程序。着色器处理器首先处理了一些算数上的算法,其中的变量都位于寄存器中。寄存器是本地的,读取速度非常快,并不会发生闲置。之后着色器处理器将遇到另一个指令,例如读取贴图,也就是基于像素位于表面的位置求出图像中的像素。贴图是一个完全独立的资源,其并不是像素程序的本地内存,因此我们需要读取贴图中的数据。内存读取可能需要非常多的时间,而在此期间GPU处理器没有任何任务可以继续执行。因此,此刻着色器处理器会闲置并等待贴图的颜色值被返回。

为了让这个弱鸡GPU能稍微diao一些,我们将为每个像素块提供一些本地寄存器的存储空间。现在,着色器处理器并不会在读取贴图时闲置,其会切换并执行另一个像素块,也就是2000个像素块中的第2个。这个切换十分快速,也不会影响第一个或者第二个像素块。与第一个像素块相同,在处理第二个像素块时,我们也会进行算数运算,之后再一次需要读取贴图。现在着色器核心将会切换到第三个像素块。最终,所有2000个像素块都会以这种方法处理。此时着色器处理器会返回到第一个像素块。这一次,贴图的颜色已经被取出且可以被我们使用了,因此着色器程序可以继续执行其他指令。处理器将以相同的方式运行直到另一个指令会导致闲置,或者所有的处理都被完成。GPU用于处理单个像素块的时间可能会增加,但是处理所有像素块所花费的时间会明显降低。

在这一结构下,我们让GPU不断在fragment之间切换以减少延迟。GPU则进一步改进了这一设计,将逻辑处理指令与数据进行区分。其被成为SIMD(single instruction multiple data,单一指令多数据),这一安排使得同一个指令同步在固定数量的着色器程序上运行。相较于使用个体逻辑与分配模块来运行每个程序,SIMD能够花费非常少的芯片或者说运算能力在处理数据上以及程序切换上。将我们举例的2000个像素块转换为现代的GPU术语,每次为一个像素块调用像素着色器就被称为一个线程(thread)。这一类型的线程不同于CPU的线程。其由着色器的输入值所需要的内存连同着着色器运行所需要的寄存器空间所组成。使用同一个着色器程序的线程被划分为一个组,NVIDIA称其为warps而AMD称其为wavefronts。warps/wavefronts将由一定数量(8到64个)的GPU着色器核心运行,且使用SIMD处理模式。每一个线程都被映射到SIMD通道。

假设我们有2000个线程需要运行。NVIDIA GPUs的warps包含32个线程。也就是说2000/32=62.5,我们需要分配64个warp,其中一个warp一半是空的。一个warp的运行方式类似于我们之前举的单一GPU处理器的例子。着色器程序同步地运行于所有32个处理器中。当需要读取内存时,32个处理器中的所有线程都会遇到读取内存的指令,这是因为所有的处理器都运行同一个指令。这表示这个warp中所有的线程将会闲置并等待它们所读取的结果。但是GPU并不会闲置,而是将这个warp置换为另一个由32个线程所组成的warp,之后新的warp仍会被32个核心进行处理。由于每一个线程中的数据都未被改变,这个置换的操作非常之快。每一个线程都拥有自己的寄存器,同时每一个warp会一直记录哪一个指令正在被执行。置换入一个新的warp对核心来说只不过是指向一组不同的线程进行处理;并不会有其他的开销。执行warp并不断地置换,知道完成所有操作。如下图所示。

事实上,由于置换warp的开销非常低,置换所导致的延迟可能会更短。当然,还有其他技术用于优化GPU的运行效率,但warp置换仍然是所有GPU所采用主要的减少延迟的机制。还有其他一些因素会影响warp置换的效果。例如,如果线程的数量太少,那么warp的数量也会相应减少,这样仍然会产生延迟。着色器程序的结构会极大地影响效率。其中一个主要的因素就是每个线程所使用的寄存器的数量。在之前的例子中,我们假设2000个线程都能同时寄存在GPU上。每个线程的着色器程序所需要的寄存器越多,那么寄存在GPU上的线程也就越少,相应的warp也会越少。如果warp数量较少,那么意味着,我们不能通过置换warp来避免延迟。寄存在GPU中的warp被称为“in flight”,而寄存的数量被成为占有率(occupancy)。高占有率意味着有许多warp那个被用于处理,那么闲置的处理器就会更少。低占有率通常意味着捉急的性能。读取内存的频率也会影响我们需要多少warp来减少延迟。

另一个会影响整体效率的因素就是动态分支,也就是“if”判断与各种循环。假设在着色器程序中,运行到了“if”判断。如果所有的线程进入同一个分支,那么warp可以继续进行,其并不用考虑其他分支。但是,如果有一些线程,或者只有一个线程进入了另一个分支,那么warp必须执行两个分支,之后再将线程所不需要的分支丢弃。这个问题被称为线程分歧(thread divergence),也就是说一部分线程需要执行其他线程所不要执行的循环或者if判断,这个时候处理器就会闲置。

所有的GPU都实行这一架构理念,这带来了严格的限制但是GPU的运算能力相较于CPU更强。理解这个系统是如何运作的可以让作为程序员的我们更好的使用GPU的运算能力。在之后的小节中,我们将继续讨论GPU是如何实现渲染管线的,可编程的着色器是如何运作的,以及每一代GPU的进化与功能。

留下评论

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据