渲染

Real-time Rendering 第十八章 管线优化

Spread the love

在本卷中,算法是在质量、内存和性能权衡的上下文中提出的。在本章中,我们将讨论与特定算法无关的性能问题和机会。瓶颈检测和优化是重点,首先进行小的局部更改,最后使用技术将应用程序作为一个整体进行结构化,以利用并行处理能力。

正如我们在第2章中看到的,渲染图像的过程是基于流水线架构的,包含四个概念阶段:应用程序、几何处理、栅格化和像素处理。总有一个阶段是瓶颈——管道中最慢的过程。这意味着这个瓶颈阶段设置了吞吐量的限制,即,因此是优化的主要候选项。

优化渲染管线的性能类似于优化一个流水线处理器(CPU)[715]的过程,所以它主要由两个步骤组成。首先,找到了管道的瓶颈。第二,这个阶段在某种程度上得到了优化;然后,如果没有达到性能目标,则重复第一步。请注意,优化步骤之后瓶颈可能位于同一位置,也可能不位于同一位置。最好只对瓶颈阶段进行足够的优化,使瓶颈转移到另一个阶段。在此阶段再次成为瓶颈之前,可能还需要优化其他几个阶段。因此,不应该在过度优化阶段上浪费精力。

瓶颈的位置可能在一个帧中发生变化,甚至在一个drawcall中也可能发生变化。在某一时刻,几何阶段可能是瓶颈,因为许多小三角形被渲染。稍后的帧像素处理可能会成为瓶颈,因为重量级的过程着色器会在每个像素处进行评估。在像素着色器中,由于纹理队列已满,执行可能会停滞,或者在到达特定循环或分支时花费更多时间。所以,当我们说到,比如说,应用程序阶段是瓶颈,我们的意思是,在那个框架中,大部分时间它是瓶颈。很少存在只有一个瓶颈的情况。

利用管道结构的另一种方法是认识到,当最慢的阶段无法进一步优化时,可以使其他阶段与最慢的阶段做一样多工作。这不会改变性能,因为最慢阶段的速度不会改变,但是额外的处理可以用来提高图像质量[1824]。例如,假设瓶颈在应用程序阶段,生成一个帧需要50毫秒(ms),而其他每个帧需要25毫秒。这意味着在不改变渲染管道的速度(50毫秒等于每秒20帧)的情况下,几何和光栅化阶段也可以在50毫秒内完成它们的工作。例如,我们可以使用更复杂的灯光模型,或者增加阴影和反射的真实感,假设这不会增加应用程序阶段的工作负载。

计算着色器还改变了我们对瓶颈和未使用资源的看法。例如,如果渲染阴影映射,顶点着色器和像素着色器很简单,如果栅格化器或像素合并等固定功能阶段成为瓶颈,GPU计算资源可能得不到充分利用。当这些条件出现时,将这些绘制与异步计算着色器重叠可以使着色器单元保持忙碌[1884]。本章最后一节将讨论基于任务的并行处理。

管道优化是一个过程,在这个过程中,我们首先最大化渲染速度,然后允许那些不是瓶颈的阶段消耗与瓶颈一样多的时间。尽管如此,它并不总是一个简单的过程,因为gpu和驱动程序可以有自己的特性和快速路径。读这一章的时候,格言“了解你的构架(KNOW YOUR ARCHITECTURE)”应该始终牢记在你心中,由于不同的体系结构,优化技术有很大的差异。也就是说,要谨慎地根据特定GPU实现的功能进行优化,因为硬件会随着时间而变化[530]。一个相关的格言是,简单地说,“测量、测量、测量(MEASURE, MEASURE, MEASURE.)”

18.1 Profile以及Debugging工具

分析和调试工具对于发现代码中的性能问题非常有用。

能力各不相同,可以包括:

  • 帧捕获和可视化。通常分步重放帧是可用的,显示使用的状态和资源。

  • 跨CPU和GPU的时间分析,包括调用图形API的时间。

  • 着色器调试,以及可能的热编辑,以查看更改代码的效果。

  • 在应用程序中使用调试标记集,以帮助识别代码区域。

分析和调试工具因操作系统、图形API和GPU供应商而异。大多数组合都有工具,这就是为什么神创造了谷歌。也就是说,我们将提到一些软件包的名称,特别是为可交互图形界面,让您开始您的寻找:

  • RenderDoc是一个针对DirectX、OpenGL和Vulkan的高质量Windows调试器,最初由Crytek开发,现在是开源的。

  • GPU PerfStudio是AMD的图形硬件套件,适用于Windows和Linux。提供的一个值得注意的工具是一个静态着色器分析器,它可以在不需要运行应用程序的情况下给出性能评估。AMD的Radeon GPU分析器是一个单独的,相关的工具。

  • NVIDIA Nsight是一个性能和调试系统,具有广泛的功能。它在Windows上集成了Visual Studio,在Mac OS和Linux上集成了Eclipse。

  • 微软的PIX长期以来一直被Xbox开发人员使用,并在Windows平台上为directx12重新启用。Visual Studio的图形诊断可以与早期版本的DirectX一起使用。

  • 来自微软的GPUView为使用了Event Tracing for Windows (ETW),这是一个高效的事件日志系统。GPUView是ETW会话的消费者程序之一。重点研究了CPU与GPU之间的交互,指出瓶颈所在[783]。

  • 图形性能分析器(GPA)是英特尔的一个套件,不针对他们的图形芯片,专注于性能和帧分析。

  • OSX上的Xcode提供了一些工具,其中有一些工具用于计时、性能、网络、内存泄漏等等。值得一提的是OpenGL ES Analysis,它检测性能和鲁棒问题并提出解决方案;Metal System Trace,它提供来自应用程序、驱动程序和GPU的跟踪信息。

这些是已经存在了几年的主要工具。也就是说,有时候没有工具可以完成这项工作。大多数api都内置了定时器查询调用,以帮助分析GPU的性能。一些供应商还提供了访问GPU计数器和线程跟踪的库。

18.2 定位瓶颈 Locating the Bottleneck

优化管道的第一步是找到最大的瓶颈[1679]。找到瓶颈的一种方法是设置几个测试,其中每个测试减少特定阶段执行的工作量。如果其中一个测试导致每秒帧数增加,就会发现瓶颈阶段。测试阶段的相关方法是减少其他阶段上的工作负载,而不减少正在测试阶段上的工作负载。如果性能没有改变,瓶颈就是工作负载没有改变的阶段。性能工具可以提供关于哪些API调用比较昂贵的详细信息,但不一定能准确指出管道中的哪个阶段正在减慢其余部分的速度。即使他们这样做了,理解每个测试背后的思想也是有用的。

下面是对用于测试各个阶段的一些思想的简要讨论,以说明如何进行此类测试。随着统一着色器体系结构的出现,理解底层硬件的重要性有了一个完美的例子。从2006年底开始,它成为许多gpu的基础。其思想是顶点、像素和其他着色器都使用相同的功能单元。GPU负责负载平衡,改变分配给顶点的单元与像素着色的比例。例如,如果渲染一个大四边形,只有少数着色器单元可以分配给顶点变换,而大部分着色器则被分配给片段处理任务。确定瓶颈是在顶点着色阶段还是像素着色阶段不那么明显[1961]。但是,着色器处理作为一个整体或另一个阶段仍然是瓶颈,所以我们依次讨论每种可能性。

18.2.1 测试应用程序阶段 Testing the Application Stage

如果所使用的平台提供了一个实用程序来测量处理器上的工作负载,那么该实用程序可以用来查看程序是否使用了CPU处理能力的100%(或接近100%)。如果CPU一直在使用,您的程序很可能受到CPU的限制。这并不总是绝对安全的,因为应用程序有时可能需要等待GPU完成一个帧。我们讨论的程序是CPU或GPU受限的,但是瓶颈可能在一个帧的生命周期内发生变化。

测试CPU限制的一个更聪明的方法是发送数据,导致GPU只做很少的工作或不做任何工作。对于某些系统,这可以通过简单地使用一个空驱动程序(一个接受调用但什么也不做的驱动程序)而不是一个真正的驱动程序来实现。这有效地为整个程序的运行速度设置了一个上限,因为您既不使用图形硬件,也不调用驱动程序,因此CPU上的应用程序总是瓶颈。通过这个测试,您可以了解到在应用程序阶段中没有运行的基于gpu的阶段有多大的改进空间。也就是说,请注意,使用空驱动程序也可能隐藏由于驱动程序处理本身以及CPU和GPU之间的通信而导致的任何瓶颈。驱动程序常常是cpu端瓶颈的原因,稍后我们将深入讨论这个主题。

如果可能的话,另一种更直接的方法是降低CPU的频率(underclock)[240]。如果性能与CPU速率成正比下降,则应用程序至少在一定程度上是CPU绑定的。同样的降低频率的方法也可以用于GPU。如果GPU变慢,性能下降,那么至少在某些时候应用程序是GPU绑定的。这些计时方法可以帮助识别瓶颈,但有时会导致以前不是瓶颈的阶段变成瓶颈。另一个选项是超频,但您没有在这里读到。

18.2.2 测试几何处理阶段 Testing the Geometry Processing Stage

几何阶段是最难测试的阶段。这是因为,如果这个阶段的工作负载发生了更改,那么一个或两个其他阶段的工作负载也经常发生更改。为了避免这个问题,Cebenoyan[240]给出了一系列测试,这些测试从栅格化器阶段开始,一直回溯到管道。

在几何阶段有两个主要的瓶颈区域:顶点获取和处理。若要查看瓶颈是否由对象数据传输造成,请增大顶点格式的大小。这可以通过为每个顶点发送几个额外的纹理坐标来实现。如果性能下降,这个区域就是瓶颈。

顶点处理由顶点着色器完成。对于顶点着色器瓶颈,测试包括使着色器程序更长。必须注意确保编译器没有优化掉这些额外的指令。

如果您的管道也使用几何着色器,那么它们的性能取决于输出大小和程序长度。如果您正在使用细分着色器,同样,程序长度影响性能,以及细分因子。更改这些元素中的任何一个,同时避免在其他阶段执行的工作中进行更改,可以帮助确定是否存在瓶颈。

18.2.3 测试栅格化阶段 Testing the Rasterization Stage

这个阶段包括三角建立和三角遍历。阴影地图的生成,使用非常简单的像素着色器,可能会在光栅化或合并阶段遇到瓶颈。虽然通常很少[1961],但三角形设置和栅格化可能成为来自细分或诸如草或叶子之类的对象的小三角形的瓶颈。然而,小三角形也可以增加顶点着色器和像素着色器的使用。在给定的区域中有更多的顶点会明显增加顶点着色器的负载。像素着色器的负载也会增加,因为每个三角形由一组2×2的四边形光栅化,所以每个三角形外的像素数量增加了[59]。这有时被称为四分过渲染quad overshading (23.1节)。要确定栅格化是否是真正的瓶颈,可以通过增加程序大小来增加顶点着色器和像素着色器的执行时间。

18.2.4 测试像素处理阶段 Testing the Pixel Processing Stage

像素着色程序的效果可以通过改变屏幕分辨率来测试。如果较低的屏幕分辨率导致帧率明显上升,像素着色器很可能成为瓶颈,至少在某些时候是这样。如果某个级别的详细系统已经就位,就必须谨慎行事。更小的屏幕也可能简化模型的显示,减少几何阶段上的负载。

降低显示分辨率还会影响三角形遍历、深度测试和混合以及纹理访问等方面的成本。为了避免这些因素并隔离瓶颈,一种方法与顶点着色程序相同,即添加更多的指令来查看对执行速度的影响。同样,重要的是要确定编译器没有优化掉这些额外的指令。如果帧渲染时间增加,像素着色器就会成为瓶颈(或者至少在执行成本增加的某个时候已经成为瓶颈)。另外,像素着色器可以简化为最少的指令数,这在顶点着色器中通常很难做到。如果整体渲染时间减少,就会发现瓶颈。纹理缓存命中失败也会造成很大的代价。如果用1×1分辨率的版本替换纹理可以获得更快的性能,那么纹理内存访问就是一个瓶颈。

着色器是独立的程序,它们有自己的优化技术。Persson[1383, 1385]提出了几个底层着色器优化,以及图形硬件如何发展和最佳实践如何改变的细节。

18.2.5 测试合并阶段 Testing the Merging Stage

在这个阶段,进行深度和模板测试,进行混合,并将存活的结果写入缓冲区。改变这些缓冲区的输出位深度是改变这个阶段带宽成本的一种方法,看看它是否会成为瓶颈。对于不透明的对象打开alpha混合,或者使用其他混合模式也会影响栅格操作执行的内存访问和处理的数量。

这个阶段可能是后处理pass、阴影、粒子系统渲染的瓶颈,在较小的程度上,也可能是渲染毛发和草地,因为顶点和像素着色器很简单,所以不需要做太多工作。

18.3 性能测量 Performance Measurements、

为了优化,我们需要度量。这里我们讨论GPU速度的不同度量。图形硬件制造商过去常常表示峰值速率,如每秒顶点数和像素数,这些速率最多难以达到。此外,由于我们处理的是管道系统,所以真正的性能并不像列出这些数字那么简单。这是因为瓶颈的位置可能从一个时间移动到另一个时间,并且不同的管道阶段在执行期间以不同的方式交互。由于这种复杂性,gpu的销售部分取决于它们的物理特性,比如内核的数量和时钟速率、内存大小、速度和带宽。

综上所述,GPU计数器和线程跟踪,如果可用,是重要的诊断工具,如果使用得当。如果某个给定部件的峰值性能已知且计数较低,则此区域不太可能成为瓶颈。一些供应商将计数器数据表示为每个阶段的利用率。这些值位于瓶颈可以移动的给定时间段内,因此不是完美的,但是对于找到瓶颈有很大的帮助。

越多越好,但即使是表面上简单的物理测量也很难精确比较。例如,同一GPU的时钟速率在IHV合作伙伴之间可能有所不同,因为每个合作伙伴都有自己的冷却解决方案,因此会将其GPU的时钟调到它认为安全的水平。即使在单个系统上进行FPS基准测试比较,也并不总是像听起来那么简单。NVIDIA的GPU Boost[1666]和AMD的PowerTune[31]技术就是我们的格言“了解你的架构”的好例子。英伟达的GPU销量大增,部分原因是一些合成基准测试同时在GPU的许多部分工作,因此将能耗推到了极限,这意味着英伟达不得不降低基准时钟频率,以防止芯片过热。许多应用程序并没有将管道的所有部分都执行到这种程度,因此可以安全地以较高的时钟速率运行。GPU Boost技术跟踪GPU的功率和温度特性,并相应地调整时钟速率。AMD和英特尔的gpu也有类似的性能优化。这种可变性会导致相同的基准以不同的速度运行,这取决于GPU的初始温度。为了避免这个问题,微软在directx12中提供了一种方法来锁定GPU核心时钟频率,以获得稳定的计时[121]。对于其他api,检查能耗状态是可能的,但是要复杂得多[354]。

在衡量cpu性能时,趋势是避免ip(每秒指令数)、FLOPS(每秒浮点运算数)、gigahertz和简单的短基准测试。相反,首选的方法是测量一系列不同的实际程序的挂钟时间[715],然后比较这些程序的运行时间。遵循这一趋势,大多数独立的图形基准测试在FPS中测量多个给定场景的实际帧率,以及各种不同的屏幕分辨率,以及抗锯齿和质量设置。许多图形密集型游戏都包含基准测试模式,或者由第三方创建,这些基准测试通常用于比较gpu。

虽然FPS是比较运行基准测试的gpu的有用缩写,但是在分析一系列帧速率时应该避免使用FPS。FPS的问题是它是一个倒数的度量,而不是线性的,因此会导致分析误差。例如,假设您发现应用程序在不同时间的帧速率分别为50、50和20 FPS。如果将这些值取平均值,得到40帧每秒。这个价值充其量只是误导。这些帧速率转换为20、20和50毫秒,因此平均帧时间为30毫秒,即33.3 FPS。同样,在测量单个算法的性能时,毫秒也是非常必要的。对于给定测试和给定机器的特定基准测试情况,可能会说某些特定的影子算法或后处理效果“花费”7 FPS,并且基准测试运行得慢得多。但是,泛化这个语句是没有意义的,因为这个值还取决于处理帧中其他所有内容所需的时间,并且因为不能将不同技术的FPS相加(但是可以相加时间)[1378]。

为了能够看到管道优化的潜在效果,重要的是在禁用双缓冲的情况下测量每帧的总呈现时间,即,在单缓冲模式下关闭垂直同步。这是因为在打开双缓冲的情况下,缓冲区的交换仅在与监视器的频率同步时发生,如2.1节中的示例所述。De Smedt[331]讨论了分析帧时间,以发现和修复CPU工作负载峰值导致的帧停顿问题,以及优化性能的其他有用技巧。通常需要使用统计分析。也可以使用GPU时间戳来了解帧内发生了什么[1167,1422]。

原始速度很重要,但对于移动设备来说,另一个目标是优化功耗。故意降低帧率,但保持应用程序的交互性可以显著延长电池寿命,对用户的体验几乎没有影响[1200]。Akenine-M¨oller和Johnsson[25,840]注意到每瓦的性能就像每秒帧数,具有与FPS相同的缺点。他们认为更有用的测量方法是每任务焦耳,例如每像素焦耳。

18.4 进行优化 Optimization

一旦找到瓶颈,我们希望优化该阶段以提高性能。在本节中,我们将介绍应用程序、几何、栅格化和像素处理阶段的优化技术。

18.4.1 应用程序阶段 Application Stage

应用程序阶段通过使代码更快或更少地访问程序的内存来优化。在这里,我们将讨论一些通常应用于cpu的代码优化的关键元素。

对于代码优化,在代码中找到花费大部分时间的位置是至关重要的。一个好的代码分析器对于找到这些代码热点是至关重要的,这些热点是花费最多时间的地方。然后在这些地方进行优化工作。程序中的这些位置通常是内部循环,即每帧执行多次的代码片段。

优化的基本规则是尝试各种策略:重新检查算法、假设和代码语法,尽可能尝试各种变体。CPU架构和编译器性能常常限制用户对如何编写速度最快的代码形成直觉的能力,所以请质疑您的假设并保持开放的心态。

第一步是对编译器的优化标志进行试验。通常有许多不同的标志可以尝试。对于要使用哪些优化选项,不要做太多假设(如果有的话)。例如,将编译器设置为使用更积极的循环优化可能导致更慢的代码。此外,如果可能的话,尝试不同的编译器,因为这些编译器以不同的方式进行优化,而且有些编译器明显优于其他编译器。您的分析器可以告诉您任何更改都有什么影响。

内存问题 Memory Issues

数年前,算术指令的数量是衡量算法效率的关键;现在的关键是内存访问模式。处理器速度的增长比DRAM的数据传输速度快得多,而DRAM的数据传输速度受到pin数的限制。从1980年到2005年,CPU性能大约每两年翻一番,DRAM性能大约每六年翻一番[1060]。这个问题被称为冯诺依曼瓶颈或内存墙。面向数据的设计将缓存一致性作为优化的一种手段。

在现代gpu上,重要的是数据传输的距离。速度和电力成本与这个距离成正比。缓存访问模式可以弥补性能差异的数量级[1206]。缓存是存在的一个小的快速内存区域,因为程序中通常有很多一致性,缓存可以利用这些一致性。也就是说,内存中的邻近位置往往被一个接一个地访问(空间位置),并且代码通常是按顺序访问的。此外,内存位置往往被重复访问(时间局部性),缓存也利用了这一点[389]。处理器缓存的访问速度很快,仅次于寄存器的速度。许多快速算法都能在尽可能小的范围内访问数据。

寄存器和本地缓存构成了内存层次结构的一端,它紧挨着动态随机访问内存(DRAM),然后扩展到ssd和硬盘上的存储。顶部是少量的快速、昂贵的内存,底部是大量的慢速、廉价的存储。在层次结构的每一层之间,速度下降了一些明显的因素。参见图18.1。例如,处理器寄存器通常在一个时钟周期内访问,而L1缓存内存则在几个周期内访问。以这种方式,每一次级别的更改都会增加延迟。正如第3.10节所讨论的,有时候延迟可以被架构所隐藏,但它始终是必须牢记的一个因素。

错误的内存访问模式很难在分析器中直接检测到。好的模式需要从一开始就构建到设计中[1060]。下面是在编程时应该考虑的要点列表。

  • 在代码中按顺序访问的数据也应该按顺序存储在内存中。例如,在呈现三角形网格时,如果按顺序访问纹理坐标#0、普通#0、颜色#0、顶点#0、纹理坐标#1和普通#1,则按顺序存储在内存中。这对GPU也很重要,就像post-transform vertex cache一样(16.4.4节)。关于为什么存储单独的数据流是有益的,请参阅第16.4.5节。

  • 避免指针间接、跳转和函数调用(在代码的关键部分),因为这些可能会显著降低CPU性能。当你跟随一个指针到另一个指针时,你会得到一个间接的指针。现代cpu试图推测性地执行指令(分支预测)和获取内存(缓存预取),以使所有功能单元忙于运行代码。当代码流在循环中保持一致,但在分支数据结构(如二叉树、链表和图)上失败时,这些技术非常有效;尽可能使用数组。McVoy和Staelin[1194]展示了一个代码示例,它通过指针跟随一个链表。这将导致前后数据的缓存丢失,并且它们的示例使CPU停机的时间比跟踪指针所需的时间长100多倍(如果缓存可以提供指针的地址)。Smits[1668]指出,使用skip指针将基于指针的树扁平化为列表可以显著改进层次结构遍历。使用van Emde Boas布局是另一种帮助避免缓存命中失败的方法—请参见第19.1.4节。高分支树通常比二叉树更好,因为它们减少了树的深度,从而减少了间接的数量。

  • 将经常使用的数据结构对齐到缓存线大小的倍数可以显著提高整体性能。例如,64字节高速缓存线在Intel和AMD处理器上很常见[1206]。编译器选项可能会有所帮助,但明智的做法是在设计数据结构时考虑对齐,即填充。用于Windows和Linux的VTune和CodeAnalyst、用于Mac的Instruments以及用于Linux的开源Valgrind等工具可以帮助识别缓存瓶颈。对齐也会影响GPU着色器的性能[331]。

  • 尝试不同组织的数据结构。例如,Hecker[698]展示了一个简单的矩阵乘法器是如何通过测试各种矩阵结构来节省惊人的大量时间的。一组结构,

     struct Vertex { float x,y,z ;};
     Vertex myvertices [1000];

    或者数组的结构

     struct VertexChunk { float x [1000] , y [1000] , z [1000];};
     VertexChunk myvertices ;

    对于给定的体系结构可能工作得更好。第二种结构更适合使用SIMD命令,但是随着顶点数量的增加,缓存失败的几率也会增加。随着数组大小的增加,一种混合方案,

     struct Vertex4 { float x[4] ,y[4] ,z [4];};
     Vertex4 myvertices [250];

    或许是最好的选择。

  • 在启动时为相同大小的对象分配一个大内存池,然后使用您自己的分配和自由例程来处理该池的内存,通常会更好[113,736]。Boost等库提供池分配。一组连续的记录比那些由单独分配创建的记录更可能具有缓存一致性。也就是说,对于具有垃圾收集的语言,如c#和Java,池实际上会降低性能。

虽然与内存访问模式没有直接关系,但值得避免在渲染循环中分配或释放内存。使用池并只分配一次划分出来的空间,并且只增加堆栈、数组和其他结构(使用变量或标志来注意哪些元素应该被视为已删除)。

18.4.2 API调用 API Calls

在本书中,我们根据硬件的一般趋势给出了建议。例如,索引顶点缓冲区对象通常是为加速器提供几何数据的最快方法(第16.4.5节)。本节讨论如何最好地调用图形API本身。大多数图形api都有类似的体系结构,并且有很好的方法可以有效地使用它们。

理解对象缓冲区分配和存储是高效渲染的基础[1679]。对于一个拥有CPU和独立的GPU的桌面系统,每个系统通常都有自己的内存。图形驱动程序通常控制对象驻留的位置,但它可以给出最佳存储位置的提示。常见的分类是静态缓冲区和动态缓冲区。如果缓冲区的数据正在改变每一帧,使用动态缓冲区(不需要GPU上的永久存储空间)是更好的选择。控制台、低功耗集成GPU的笔记本电脑和移动设备通常具有统一的内存,其中GPU和CPU共享相同的物理内存。即使在这些设置中,在正确的池中分配资源也很重要。正确地将资源标记为CPU-only或GPU-only仍然可以带来好处。一般来说,如果一个内存区域必须被两个芯片访问,那么当一个芯片对它进行写操作时,另一个芯片必须使它的缓存失效(这是一个昂贵的操作),以确保不会得到过时的数据。

如果一个对象没有变形,或者变形可以完全通过着色程序(如蒙皮)来实现,那么将对象的数据存储在GPU内存中是有利可图的。此对象的不变性质可以通过将其存储为静态缓冲区来表示。通过这种方式,它不必为呈现的每一帧都跨总线发送,从而避免了管道这一阶段的任何瓶颈。GPU的内部内存带宽通常比CPU和GPU之间的总线高得多。

状态切换 State Changes

调用API有几个相关的成本。在应用程序方面,更多的调用意味着花费更多的应用程序时间,而不管调用实际做了什么。这个代价可以很小,也可以很明显,而一个空驱动程序可以帮助识别它。由于无法与CPU同步,依赖于GPU值的查询函数可能会使帧速率减半[1167]。在这里,我们将深入研究优化一个常见的图形操作,为绘制网格做好准备。这个操作可能包括改变状态,例如,设置着色器及其制服,附加纹理,改变混合状态或使用的颜色缓冲,等等。

应用程序提高性能的主要方法是通过将具有类似渲染状态的对象分组来最小化状态更改。由于GPU是一个极其复杂的状态机,可能是计算机科学中最复杂的,因此改变状态可能是昂贵的。虽然一小部分成本可能涉及GPU,但大部分成本来自驱动程序在CPU上的执行。如果GPU很好地映射到API,状态更改的成本往往是可以预测的,但仍然很重要。如果GPU有一个严格的功率限制或有限的silicon footprint,如与一些移动设备,或有一个硬件缺陷,周围的工作,驱动程序可能不得不执行得很厉害,造成意想不到的高成本。

一个具体的例子是PowerVR架构如何支持混合。在较老的api中,使用固定函数类型的接口来指定混合。PowerVR的混合是可编程的,这意味着他们的驱动程序必须将当前的混合状态打补丁到像素着色器中[699]。在这种情况下,更高级的设计不能很好地映射到API,因此会在驱动程序中产生很大的设置成本。尽管在本章中我们注意到硬件体系结构和运行它的软件可能会影响各种优化的重要性,但是对于状态更改成本来说尤其如此。即使是特定的GPU类型和驱动程序版本也可能有影响。阅读时,请想象每一页上都用红色的大字印着“你的里程可能不同”。

Everitt和McDonald[451]注意到,不同类型的状态变化在成本上有很大差异,并给出了一些关于每秒在NVIDIA OpenGL驱动程序上可以执行多少次的粗略概念。以下是他们2014年的列表,从最耗时的到最节省的:

  • 渲染目标(帧缓冲目标),∼60k/sec.

  • 着色器程序,∼300 k /秒。

  • 混合模式(ROP),比如透明度。

  • 纹理绑定,∼1.5M/秒。

  • 顶点格式设置。

  • Uniform缓冲对象(UBO)绑定。

  • 顶点绑定。

  • Uniform更新,∼10M/秒。

这个近似的成本列表由其他公司证实[488,511,741]。一个更昂贵的改变是在GPU的渲染模式和计算着色模式之间切换[1971]。避免状态更改可以通过按着色器、然后按使用的纹理等按成本顺序对要显示的对象进行分组来实现。按状态排序有时称为批处理batching

另一种策略是重组对象的数据,以便进行更多的共享。最小化纹理绑定更改的一种常见方法是将多个纹理图像放入一个大的纹理中,或者更好的是放入一个纹理数组中。如果API支持它,无绑定纹理是另一个避免状态更改的选项(第6.2.5节)。与更新uniform相比,更改着色器程序通常比较昂贵,因此,使用“if”语句的单个着色器可以更好地表示一类材料中的变化。您也可以通过共享着色器[1609]来生成更大的合批。然而,让着色器更复杂也会降低GPU的性能。衡量什么是高效的是唯一的万无一失的方法。

对图形API进行更少、更有效的调用可以节省一些额外的开销。例如,通常可以将多个uniform定义为一个组并将其设置为一个组,因此绑定一个统一的缓冲区对象的效率要高得多[944]。在DirectX中,这些被称为常量缓冲区。正确使用这些方法可以节省每个函数的时间和在每个API调用中检查错误所花费的时间[331,613]。

现代驱动程序常常将设置状态延迟到遇到第一个draw调用时。如果在此之前进行了冗余API调用,驱动程序将过滤掉这些调用,从而避免了执行状态更改的需要。通常使用一个脏标志来说明需要进行状态更改,因此在每次draw调用之后返回到基本状态可能会代价高昂。例如,在准备绘制对象时,您可能希望假设状态X在默认情况下是关闭的。实现这一目标的一种方法是“Enable(X);画(M1);禁用(X);“然后”启用(X);画(M2);禁用(X);“从而在每次绘制后恢复状态。然而,在两次draw调用之间重新设置状态也可能会浪费大量时间,即使它们之间没有发生实际的状态更改。

通常,应用程序对何时需要状态更改具有更高级别的知识。例如,将不透明表面的“替换”混合模式更改为透明表面的“over”模式通常需要在帧期间执行一次。可以很容易地避免在渲染每个对象之前发出混合模式。Galeano[511]展示了忽略这种过滤并发出不需要的状态调用将如何花费WebGL应用程序高达2 ms/frame。但是,如果驱动程序已经有效地执行了这种冗余筛选,那么在应用程序中的每个调用中执行相同的测试可能是一种浪费。过滤API调用的工作量主要取决于底层驱动程序[443、488、741]。

合并和Instanceing —— Consolidating and Instancing

有效地使用API可以避免CPU成为瓶颈。该API的另一个关注点是小批次问题。如果忽略这一点,这可能是影响现代api性能的一个重要因素。简单地说,一些三角形填充的网格比许多小的、简单的网格渲染效率高得多。这是因为每个draw调用都有固定的开销,这是处理原语的开销,无论大小如何。

早在2003年,Wloka[1897]就表明,每批绘制两个三角形(相对较小)与GPU测试的最大吞吐量相差375倍。对于一个2.7 GHz的CPU,它的速率是4040万个三角形,而不是每秒1.5亿个三角形。对于由许多小而简单的对象组成的场景,每个对象只有几个三角形,性能完全受API的cpu约束;GPU没有能力增加它。也就是说,绘制调用在CPU上的处理时间大于GPU实际绘制网格所需的时间,因此GPU处于饥饿状态。

Wloka的经验法则是“每帧有X个批次。“这是你每帧可以进行的最大绘制调用数,完全是因为CPU是限制因素。在2003年,API成为瓶颈的断点大约是每个对象130个三角形。图18.2显示了断点如何在2006年上升到每个网格510个三角形。时代已经变了。为了改进这个draw call问题做了很多工作,cpu变得更快了。2003年的建议是每帧300个draw call;2012年,每帧1.6万次draw call是一个团队的上限[1381]。尽管如此,这个数字对于一些复杂的场景来说还是不够的。使用诸如directx12、Vulkan和Metal等现代api,驱动程序本身的成本可能会降到最低——这是它们的主要优势之一[946]。然而,GPU每网格会有自己的固定成本。

减少绘制调用的数量的一种方法是多个对象合并为一个网格,只需要一个画称之为呈现一组对象的集合。使用相同的状态,是静态的,至少对彼此,整合可以做一次,可以重用批处理每一帧(741、1322)。能够合并网格是考虑使用公共着色器和纹理共享技术避免状态更改的另一个原因。整合节省的成本不仅仅来自于避免API draw调用。应用程序本身处理更少的对象也可以节省成本。然而,如果批量比需要的大得多,可能会降低其他算法的效率,比如截锥剔除[1381]。一种实践是使用包围卷层次结构来帮助查找和分组彼此接近的静态对象。合并的另一个关注点是选择,因为所有静态对象在一个网格中是无区别的。一个典型的解决方案是在网格的每个顶点上存储一个对象标识符。

另一种最小化应用程序处理和API成本的方法是使用某种形式的实例化[232,741,1382]。大多数api都支持在一个调用中拥有一个对象并多次绘制它。这通常是通过指定一个基本模型并提供一个单独的数据结构来实现的,该数据结构包含有关所需的每个特定实例的信息。除了位置和方向之外,还可以为每个实例指定其他属性,比如叶子的颜色或风造成的曲率,或者着色程序可以用来影响模型的任何其他属性。郁郁葱葱的丛林场景可以通过自由使用实例创建。参见图18.3。人群场景非常适合实例化,通过从一组选项中选择不同的身体部位,每个角色看起来都是独一无二的。进一步的变化可以添加随机着色和贴花。实例化还可以与LOD相结合[122,1107,1108]。有关示例,请参见图18.4。

合并和实例化相结合的概念称为合并-实例化(merge-instancing),其中合并网格包含可能依次被实例化的对象[146,1382]。

理论上,几何着色器可以用于实例化,因为它可以创建传入网格的重复数据。实际上,如果需要很多实例,这个方法可能比使用实例化API命令要慢。几何着色器的目的是对数据进行局部的小规模放大[1827]。此外,对于一些架构,例如Mali的基于tile的渲染器,几何着色器是在软件中实现的。引用mali的最佳实践指南[69],“为你的问题找到更好的解决方案。几何着色器不是你的解决方案。”

18.4.3 几何阶段 Geometry Stage

几何阶段负责转换、每个顶点的光照、裁剪、投影和屏幕映射。其他章节讨论了减少流经管道的数据量的方法。高效的三角形网格存储、模型简化和顶点数据压缩(第16章)都节省了处理时间和内存。是视锥剔除和闭塞剔除这样的技术(第19章)避免将整个原语本身发送到管道中。在CPU上添加这种大规模技术可以完全改变应用程序的性能特征,因此值得在开发的早期进行尝试。在GPU上,这样的技术并不常见。一个值得注意的例子是如何使用计算着色器执行各种类型的筛选[1883,1884]。

光照元素的效果可以计算每个顶点、每个像素(在像素处理阶段),或者两者都计算。照明计算可以通过几种方式进行优化。首先,应该考虑光源的类型。所有三角形都需要照明吗?有时一个模型只需要纹理,在顶点上使用颜色纹理,或者只是在顶点上使用颜色纹理。

如果光源相对于几何而言是静态的,那么漫射光和环境光可以预先计算并以顶点的颜色存储。这样做通常被称为“烘焙”对照明。预光照的一种更精细的形式是预先计算场景中的漫射全局光照(第11.5.1节)。这种光照可以存储为顶点的颜色或强度,也可以存储为光照贴图。

对于向前渲染系统,光源的数量影响几何级的性能。更多的光源意味着更多的计算。减少工作的一种常见方法是禁用或削减本地照明,而是使用环境贴图(10.5节)。

18.4.4 光栅化阶段 Rasterization Stage

栅格化可以通过几种方式进行优化。对于封闭的(实体)对象和永远不会显示其背面的对象(例如,房间中墙壁的背面),应该启用背面剔除(第19.3节)。这减少了要栅格化的三角形数量的一半左右,从而减少了三角形遍历的负载。此外,当像素着色计算非常昂贵时,这尤其有用,因为这样就不会对背景进行着色。

18.4.5 像素处理阶段 Pixel Processing Stage

优化像素处理通常是有利可图的,因为通常有更多的像素渲染比顶点。但也有一些明显的例外。顶点总是要处理的,即使绘制结果没有生成任何可见的像素。在渲染引擎中无效的剔除可能会使顶点着色成本超过像素着色。太小的三角形不仅会导致比可能需要的更多的顶点着色计算,而且还会创建更多的部分覆盖的四边形,从而导致额外的工作。更重要的是,只覆盖几个像素的纹理网格通常具有较低的线程占用率。正如第3.10节所讨论的,采样纹理需要花费大量的时间,GPU通过切换到在其他片段上执行着色器程序来隐藏纹理,然后在获取纹理数据后返回。低占用率会导致较差的延迟隐藏。使用大量寄存器的复杂着色器也可能导致占用率低,因为它只允许一次使用更少的线程(第23.3节)。这种情况称为高寄存器压力。还有其他微妙之处,例如,频繁切换到其他Wrap可能会导致更多缓存丢失。Wronski[1911, 1914]讨论了各种占用问题和解决方案。

首先,使用原生纹理和像素格式,即,使用图形加速器内部使用的格式,以避免从一种格式到另一种格式的可能昂贵的转换[278]。另外两种与纹理相关的技术是只加载所需的mipmap级别(第19.10.1节)和使用纹理压缩(第6.2.6节)。通常,更小和更少的纹理意味着更少的内存使用,这反过来意味着更低的传输和访问时间。纹理压缩也可以提高缓存性能,因为相同数量的缓存内存被更多的像素占用。

细节技术的一个层次是使用不同的像素着色程序,这取决于对象与观察者的距离。例如,在一个场景中有三个飞碟模型,距离最近的模型可能有一个详细的凹凸贴图,而距离较远的两个模型则不需要。此外,最远的飞碟可能有高光简化或完全删除,既简化计算和减少“fireflies”,即。,来自欠采样的闪烁伪影。在简化模型上使用每个顶点的颜色可以带来额外的好处,即不需要因为纹理的改变而改变状态。

只有在三角形光栅化时片段可见时才调用像素着色器。GPU的Eraly z测试(章节23.7)根据z缓冲区检查片段的z深度。如果不可见,则在没有任何像素着色器计算的情况下丢弃该片段,从而节省了大量时间。虽然像素着色器可以修改z深度,但是这样做意味着不能执行Eraly z测试。

为了理解程序的行为,特别是像素处理阶段的负载,可视化深度复杂性是很有用的,它是覆盖一个像素的表面的数量。图18.5显示了一个示例。生成深度复杂度图像的一个简单方法是使用OpenGL的glBlendFunc(GL One,GL One)这样的调用,禁用z缓冲。首先,将图像清除为黑色。然后用颜色(1/255、1/255、1/255)呈现场景中的所有对象。混合函数设置的效果是,对于每个呈现的原语,所写像素的值将增加一个强度级别。深度复杂度为0的像素为黑色,深度复杂度为255的像素为纯白色(255、255、255)。

像素overdraw的数量与实际渲染了多少个表面有关。通过再次渲染场景可以找到像素着色器的计算次数,但是启用了z-buffer。Overdraw是计算一个表面的渲染所浪费的工作量,该表面随后将被随后的像素着色器调用所覆盖。延迟渲染(第20.1节)和光线跟踪的一个优点是,在执行所有可见性计算之后执行渲染。

假设两个三角形覆盖一个像素,那么深度复杂度是2。如果先绘制较远的三角形,则较近的三角形会将其覆盖,且覆盖的数量为1。如果先画的距离越近,则距离越远的三角形深度测试失败,没有绘制,因此没有绘制overdraw。对于任意一组覆盖像素的不透明三角形,平均绘制次数为调和级数[296]:

这背后的逻辑是,第一个渲染的三角形是一次绘图。第二个三角形要么在第一个三角形的前面,要么在第一个三角形的后面,概率是50/50。与前两个位置相比,第三个三角形可以有三个位置中的一个,其中三个位置中有一个是最前面的。当n趋于无穷时,

在γ= 0.57721…是Euler-Mascheroni 常数时。当深度复杂度较低时,Overdraw会快速上升,但很快就会减少。例如,深度复杂度为4时,平均绘制2.08张图,深度复杂度为11时,平均绘制3.02张图,但深度复杂度为12,367时,平均绘制10.00张图。

因此,overdraw并不一定像它看起来的那么糟糕,但是我们仍然希望在不花费太多CPU时间的情况下最小化它。粗略地对场景中的不透明对象进行排序,然后按近似的前后顺序(从近到远)绘制,这是减少overdraw的常用方法[240,44,48,511]。稍后绘制的闭塞对象不会写入颜色或z缓冲区(即,overdraw减少)。此外,在到达像素着色器程序之前,可以通过遮挡剔除硬件来拒绝像素片段(第23.5节)。排序可以通过任意数量的方法来完成。基于所有不透明对象的中心点沿视图方向的距离的显式排序是一种简单的技术。如果一个包围体层次结构或其他空间结构已经用于截锥体筛选,我们可以选择首先遍历的较近的子元素,在层次结构中向下遍历。

另一种技术可以用于具有复杂像素着色程序的表面。先执行z-prepass将几何图形呈现给z-buffer,然后正常渲染整个场景[643]。这消除了所有的overdraw着色器计算,但以整个单独运行所有几何图形为代价。Pettineo[1405]写道,他的团队在电子游戏中使用深度预置的主要原因是为了避免overdraw。然而,绘制一个粗略的前后顺序可以提供很多相同的好处,而不需要这种额外的工作。一种混合方法是首先识别并绘制几个大型、简单的遮挡器,这些遮挡器可能带来最大的好处[1768]。正如McGuire[1177]所指出的那样,完全预写对他的特定系统的性能没有帮助。测量是了解哪种技术(如果有的话)对您的应用最有效的唯一方法。

之前,我们推荐使用着色器和纹理进行分组,以最小化状态变化;这里我们讨论按距离排序的对象的呈现。这两个目标通常给出不同的对象绘制顺序,因此相互冲突。对于给定的场景和视角,总是有一些理想的绘制顺序,但这很难提前找到。混合方案是可能的,例如,根据深度对附近的物体进行排序,并根据材料对其他所有物体进行排序[1433]。一种常见的、灵活的解决方案[438,488,511,1434,1882]是为每个对象创建一个排序键,通过分配一组位来封装所有相关的标准。参见图18.6。

我们可以选择按距离排序,但通过限制存储深度的位的数量,我们可以允许按着色器分组,使其与给定距离范围内的对象相关。将绘制排序到两个或三个深度分区中并不少见。如果某些对象具有相同的深度,并且使用相同的着色器,则使用纹理标识符对对象进行排序,然后将具有相同纹理的对象分组在一起。

这是一个简单的例子,并且是情景化的,例如,呈现引擎本身可能将不透明的对象和透明的对象分开,因此不需要透明位。其他字段的位的数量当然会随着期望的着色器和纹理的最大数量而变化。可以添加或替换其他字段,比如一个用于混合状态,另一个用于z-buffer读写。最重要的是架构。例如,移动设备上的一些基于tile的GPU渲染器并没有从前向后排序中获得任何好处,因此状态排序是唯一需要优化的重要元素[1609]。这里的主要思想是将所有属性放到一个整数键中,这样就可以执行有效的排序,从而尽可能减少透支和状态更改。

18.4.6 帧缓冲技术 Framebuffer Techniques

渲染一个场景通常会导致对framebuffer的大量访问和许多像素着色器的执行。为了减少对缓存层次结构的压力,一个常见的建议是减少framebuffer中每个像素的存储大小。虽然每个颜色通道的16位浮点值提供了更高的精度,但是8位的值只有大小的一半,这意味着在精度足够的情况下访问速度更快。在许多图像和视频压缩方案中,如JPEG和MPEG,色度经常被亚采样。由于人类的视觉系统对亮度比色度更敏感,这一过程的视觉效果往往可以忽略不计。例如,Frostbite游戏引擎[1877]使用了色度子采样的概念来降低带宽成本,以便对每个通道的16位图像进行后处理。

Mavridis和Papaioannou[1144]提出,在栅格化过程中使用有损YCoCg变换对颜色缓冲区实现类似的效果,见第197页。它们的像素布局如图18.7所示。与RGBA相比,这减少了一半的颜色缓冲区存储需求(假设不需要A),并常常提高性能,这取决于体系结构。由于每个像素只有一个色度分量,因此需要一个重构滤波器来推断每个像素的YCoCg值,然后在显示前将其转换回RGB。例如,对于缺少协值的像素,可以使用四个最接近协值的平均值。然而,这并不能很好地重构边缘。因此,取而代之的是一个简单的边缘感知过滤器,它被实现为

对于没有Co的像素,其中Co、i、Li分别为当前像素的左、右、上、下的值,L为当前像素的亮度,t为边缘检测的阈值。Mavridis和Papaioannou使用t = 30/255。step(x)函数在x < 0时为0,否则为1。因此,如果亮度梯度|Li−L|大于t,则滤波器的权值wi要么为0,要么为1,二者均为0。

由于显示分辨率的不断提高和着色器执行成本的节约,使用棋盘格模式进行渲染已经在几个系统中使用[231,415,836,1885]。对于虚拟现实应用,Vlachos[1824]对视图外围的像素使用棋盘格模式,Answer[59]将每个2×2的四等分减少一到三个样本。

18.4.7 合并阶段 Merging Stage

确保只在有用时启用混合模式。理论上,可以为每个三角形设置“over”合成,无论是不透明的还是透明的,因为使用“over”的不透明表面将完全覆盖像素中的值。然而,这比简单的“替换”栅格操作要昂贵得多,因此使用裁剪纹理和透明材质跟踪对象是值得的。另外,还有一些光栅操作不需要额外花费。例如,当使用z缓冲区时,在某些系统上也不需要额外的时间来访问模板缓冲区。这是因为8位模板缓冲区值与24位z-depth值存储在同一个word中[890]。

仔细考虑何时需要使用或清除各种缓冲区是值得的。由于gpu具有快速清除机制(第23.5节),因此建议始终清除颜色和深度缓冲区,因为这可以提高这些缓冲区的内存传输效率。

如果可以,通常应该避免将渲染目标从GPU读入CPU。CPU对framebuffer的任何访问都会导致整个GPU管道在渲染返回之前被刷新,从而失去所有的并行性[1167,1609]。

如果您发现合并阶段是您的瓶颈,那么您可能需要重新考虑您的方法。您是否可以使用精度较低的输出目标,比如通过压缩?有没有什么方法可以重新排列你的算法来减轻这个阶段的压力?对于渲染,是否有方法缓存和重用没有移动的部分?

在本节中,我们讨论了通过搜索瓶颈和调优性能来很好地使用每个阶段的方法。也就是说,当使用完全不同的技术可以更好地服务于您时,请注意重复优化算法的危险。

18.5 多处理 Multiprocessing

传统api的发展方向是发出更少的调用,而每个调用做的更多[44,451]。新一代的apis – directx12, Vulkan, metal -采取了不同的策略。对于这些api,驱动程序被简化并最小化,验证状态的大部分复杂性和责任转移到了调用应用程序,以及内存分配和其他功能[249,1438,1826]。这种重新设计在很大程度上是为了将draw调用和状态更改开销最小化,这是因为必须将旧api映射到现代gpu。这些新API鼓励的另一个元素是使用多个CPU处理器来调用API。

2003年前后,由于散热和功耗等物理问题,cpu时钟速度不断上升的趋势在3.4 GHz左右趋于平缓[1725]。这些限制导致了多处理cpu的出现,在这种情况下,更多的cpu被放入一个芯片中,而不是更高的时钟速率。事实上,许多小内核在单位面积上提供了最好的性能[75],这也是gpu本身如此有效的主要原因。从那时起,创建利用并发性的高效可靠程序就一直是一个挑战。在本节中,我们将介绍CPU核心上高效多处理的基本概念,并在最后讨论图形api是如何演变为在驱动程序内部支持更多并发性的。

多处理器计算机可以大致分为消息传递体系结构和共享内存多处理器。多处理器计算机可以大致分为消息传递体系结构和共享内存多处理器。这些在实时渲染中并不常见。共享内存多处理器就像它们听上去的一样;所有处理器之间共享一个逻辑内存地址空间。大多数流行的多处理器系统使用共享内存,其中大多数都采用对称多处理(symmetric multiprocessing,SMP)设计。SMP意味着所有处理器都是相同的。多核PC系统是对称多处理体系结构的一个例子。

在这里,我们将介绍使用多个处理器进行实时图形处理的两种通用方法。第一个方法—多处理器流水线,也称为时间并行—将比第二个并行处理(也称为空间并行)更详细地介绍。这两种方法如图18.8所示。然后,将这两种类型的并行性与基于任务的多处理结合在一起,在多处理中,应用程序创建的作业可以由一个单独的核心来接收和处理。

18.5.1 多处理器管线 Multiprocessor Pipelining

正如我们所看到的,流水线是一种通过将作业划分为并行执行的某些流水线阶段来加速执行的方法。

一个管道阶段的结果被传递到下一个阶段。

对于n个管道阶段,理想的加速速度是n倍,而最慢的阶段(瓶颈)决定了实际的加速速度。

到目前为止,我们已经看到使用一个CPU核心和一个GPU并行地运行应用程序、几何处理、栅格化和像素处理。当主机上有多个处理器可用时,也可以使用管道,在这种情况下,称为多进程管道或软件管道。

这里我们描述一种软件流水线。无穷无尽的变化是可能的,方法应该适应于特定的应用。在本例中,应用阶段分为三个阶段[1508]:APP, CULL, DRAW。这是粗粒度管道,这意味着每个阶段都相对较长。APP阶段是管道中的第一个阶段,因此控制其他阶段。在这个阶段,应用程序程序员可以添加额外的代码,例如冲突检测。此阶段还更新视图。CULL阶段可执行:

  • 场景图上的遍历和层次视图截锥剔除(第19.4节)。

  • 细节选择级别(第19.9节)。

  • 状态排序,如18.4.5节所述。

  • 最后(并且总是执行),生成所有应该渲染对象的简单列表

DRAW阶段从CULL阶段获取列表,并发出此列表中的所有图形调用。这意味着它只是遍历列表并提供给GPU。图18.9显示了如何使用这个管道的一些示例。

如果一个处理器内核可用,那么所有三个阶段都在该内核上运行。如果有两个CPU内核可用,则可以在一个内核上执行APP和CULL,并在另一个内核上绘图。另一种配置是在一个核心上执行APP,然后在另一个核心上进行筛选和绘制。哪种方法最好取决于不同阶段的工作负载。最后,如果主机有三个可用的内核,那么每个阶段都可以在一个单独的内核上执行。这种可能性如图18.10所示。

该技术的优点是吞吐量,即,渲染速度增加。缺点是,与并行处理相比,延迟更大。延迟,或时间延迟,是从用户操作轮询到最终图像所花费的时间[1849]。这不应该与帧速率混淆,帧速率是每秒显示的帧数。例如,假设用户正在使用一个不系绳的头戴显示器。头部位置的确定可能需要10毫秒才能到达CPU,然后渲染帧需要15毫秒。从初始输入到显示的延迟为25毫秒。即使帧速率为66.7 Hz(1/0.015秒),如果不执行位置预测或其他补偿,由于将位置更改发送到CPU的时间延迟,交互性可能会感到迟缓。忽略由于用户交互造成的任何延迟(这在两个系统中都是常量),多处理比并行处理具有更多的延迟,因为它使用管道。正如在下一节中详细讨论的,并行处理将框架的工作分解为多个并发运行的部分。

与在主机上使用单个CPU相比,多处理器流水线提供了更高的帧速率,由于同步的成本,延迟几乎相同,甚至更大。延迟随着管道中阶段的数量增加而增加。对于一个平衡良好的应用程序,n个cpu的加速速度是n倍。

降低延迟的一种技术是在应用程序阶段结束时更新视点和其他延迟关键参数[1508]。这将减少(大约)一帧的延迟。另一种减少延迟的方法是执行CULL和DRAW重叠。这意味着一旦一切准备就绪,CULL的结果就会被发送到DRAW。为了实现这一点,必须在这些阶段之间进行一些缓冲,通常是FIFO。在空的和满的情况下,各阶段停止;即,当缓冲区满时,则CULL必须停止,当缓冲区空时,DRAW必须保持饥饿。缺点是,诸如状态排序之类的技术不能在相同的范围内使用,因为必须在经过CULL处理后立即呈现原语。图18.10显示了这种减少延迟的技术。

图中的管道最多使用三个cpu,每个阶段都有特定的任务。然而,这种技术并不局限于这种配置—相反,您可以使用任意数量的cpu并以任何您想要的方式分配工作。关键是要对整个要做的工作进行明智的划分,使整个流程趋于平衡。多处理器流水线技术需要最少的同步,因为它只需要在切换帧时同步。额外的处理器也可以用于并行处理,这需要更频繁的同步。

18.5.2 并行处理 Parallel Processing

使用多处理器管道技术的一个主要缺点是延迟会增加。对于某些应用程序,如飞行模拟器、第一人称射击器和虚拟现实呈现,这是不可接受的。当移动视点时,您通常需要即时(下一帧)响应,但是当延迟较长时,这将不会发生。也就是说,这要视情况而定。如果多处理将帧速率从1帧延迟的30帧提高到2帧延迟的60帧,那么额外的帧延迟将不会有明显的差异。

如果有多个处理器可用,还可以尝试同时运行代码的各个部分,这可能会导致更短的延迟。要做到这一点,程序的任务必须具有并行性的特征。并行化算法有几种不同的方法。假设有n个处理器可用。使用静态赋值[313],将整个工作包(例如对加速结构的遍历)划分为n个工作包。然后,每个处理器处理一个工作包,所有处理器并行执行它们的工作包。当所有处理器完成它们的工作包时,可能需要合并来自处理器的结果。要使其工作,工作负载必须是高度可预测的。

当情况不是这样时,可以使用适应不同工作负载的动态分配算法[313]。它们使用一个或多个工作池。当生成作业时,它们被放入工作池。当cpu完成当前任务后,它们可以从队列中获取一个或多个任务。必须注意,只有一个CPU可以获取特定的作业,并且维护队列的开销不会损害性能。更大的作业意味着维护队列的开销变得不那么成问题,但是,另一方面,如果作业太大,那么性能可能会由于系统中的不平衡而降低——也就是说,如果作业太大,那么性能可能会降低,一个或多个cpu可能会处于饥饿状态。

对于多处理器管道,对于运行在n个处理器上的并行程序,理想的加速速度是n倍。这叫做线性加速。尽管线性加速很少发生,但实际的结果有时可能接近它。

在807页的图18.8中,显示了一个多处理器流水线和一个具有三个cpu的并行处理系统。暂时假定这两种配置对每一帧都应该做相同的工作,并且两种配置都实现了线性加速。这意味着执行速度将比串行执行快三倍(即,在一个CPU上)。此外,我们假设每帧的总工作量为30 ms,这意味着单个CPU上的最大帧速率为1/0.03≈33帧每秒。

多处理器管道(理想情况下)将工作划分为三个大小相同的工作包,并让每个cpu负责一个工作包。然后,每个工作包应该花费10毫秒来完成。如果我们遵循管道中的工作流程,我们将看到管道中的第一个CPU的工作时间为10 ms(即三分之一的工作。)然后将其发送到下一个CPU。然后,第一个CPU开始处理下一帧的第一部分。当一个帧最终完成时,完成一个帧需要30 ms,但是由于工作是在流水线中并行完成的,所以每10 ms完成一个帧。因此,延迟是30毫秒,而加速是三倍(30/10),导致每秒100帧。

同一程序的并行版本还将作业划分为三个工作包,但是这三个包将在三个cpu上同时执行。这意味着延迟将为10毫秒,而一帧的工作也将花费10毫秒。结论是,当使用并行处理时,延迟比使用多处理器管道时要短得多。

18.5.3 基于任务的多处理 Task-Based Multiprocessing

了解了管道和并行处理技术之后,很自然就会将二者结合在一个系统中。如果只有几个处理器可用,那么使用一个简单的系统显式地将系统分配给特定的内核可能是有意义的。然而,考虑到许多cpu上有大量内核,使用基于任务的多处理已经成为趋势。正如可以为可并行化的流程创建多个任务(也称为作业)一样,可以将这个概念扩展到包括管道。任何核心生成的任何任务都会在生成时放入工作池。任何空闲处理器都有一个任务要处理。

转换为多处理的一种方法是采用应用程序的工作流并确定哪些系统依赖于其他系统。参见图18.11。

在等待同步时发生处理器故障意味着基于任务的应用程序版本甚至可能变得更慢,这是由于这种成本和任务管理的开销造成的[1854]。然而,许多程序和算法确实有大量的任务可以同时执行,因此可以从中受益。

下一步是确定每个系统的哪些部分可以分解为任务。一段代码的特征,这是一个很好的候选成为一个任务包括[45,1060,1854]:

  • 任务具有定义良好的输入和输出。

  • 任务运行时是独立的、无状态的,并且总是完成。

  • 它不是一个很大的任务,因此常常成为惟一运行的进程。

像c++ 11这样的语言都内置了多线程工具[1445]。在Intel兼容的系统上,Intel的开源线程构建块(TBB)是一个有效的库,它简化了任务生成、池和同步[92]。

当性能非常关键时,让应用程序创建自己的多处理任务集(如模拟、碰撞检测、遮挡测试和路径规划)是一个给定的条件[45、92、1445、1477、1854]。我们在这里再次注意到,GPU内核也有空闲的时候。例如,这些通常在阴影地图生成或深度预设置期间未得到充分利用。在这种空闲时间,计算着色器可以应用于其他任务[1313,1884]。根据架构、API和内容的不同,渲染管道有时不能让所有着色器保持忙碌,这意味着总是有一些池可用来计算着色。我们将不处理优化这些着色器的主题,因为Lauritzen提出了一个令人信服的论点,即由于硬件差异和语言限制,编写快速且可移植的计算着色器是不可能的[993]。如何优化核心渲染管道本身是下一节的主题。

18.5.4 图形API多处理支持 Graphics API Multiprocessing Support

并行处理通常不映射到硬件约束。例如,DirectX 10和更早版本一次只允许一个线程访问图形驱动程序,因此实际绘制阶段的并行处理更加困难[1477]。图形驱动程序中有两个操作可能使用多个处理器:资源创建和与租户相关的调用。创建诸如纹理和缓冲区之类的资源可以纯粹是cpu端操作,因此自然是可并行的。也就是说,创建和删除也可能是阻塞任务,因为它们可能触发GPU上的操作或需要特定的设备上下文。无论如何,较早的api是在消费者级多处理cpu存在之前创建的,因此需要重写以支持这种并发性。

使用的一个关键构造是命令缓冲区或命令列表,它追溯到一个称为显示列表的老OpenGL概念。命令缓冲区(CB)是API状态更改和绘制调用的列表。可以根据需要创建、存储和重播这样的列表。它们还可以组合成更长的命令缓冲区。只有一个CPU处理器通过驱动程序与GPU通信,因此可以发送一个CB来执行。但是,每个处理器(包括这个单处理器)都可以并行地创建或连接存储的命令缓冲区。

例如,在DirectX 11中,与驱动程序通信的处理器将其呈现调用发送给所谓的即时上下文。其他处理器都使用一个延迟上下文来生成命令缓冲区。顾名思义,它们不是直接发送到驱动程序的。相反,它们被发送到即时上下文进行渲染。参见图18.12。另外,可以将命令缓冲区发送到另一个延迟上下文,后者将其插入到自己的CB中。除了向驱动程序发送命令缓冲区以供执行外,即时上下文可以执行而延迟不能执行的主要操作是GPU查询和回调。否则,命令缓冲区管理在任何一种上下文中看起来都是一样的。

命令缓冲区及其前身显示列表的一个优点是可以存储和重播它们。命令缓冲区在创建时没有完全绑定,这有助于重用它们。例如,假设CB包含一个视图矩阵。摄像机移动,视图矩阵也随之改变。然而,视图矩阵存储在一个常量缓冲区中。常量缓冲区的内容不存储在CB中,只存储对它们的引用。可以更改常量缓冲区的内容,而不必重新构建CB。确定如何最好地最大化并行性涉及到选择合适的粒度(每个视图、每个对象、每个材料)来创建、存储和组合命令缓冲区[1971]。

在命令缓冲区成为现代api的一部分之前,这种多线程绘制系统已经存在多年了[1152,1349,1552,1554]。API支持使过程更简单,并允许更多的工具与创建的系统一起工作。然而,命令列表确实有与之相关的创建和内存成本。此外,在directx11和OpenGL中,将API的状态设置映射到底层GPU的开销仍然很高,如第18.4.2节所述。在这些系统中,当应用程序成为瓶颈时,命令缓冲区可以提供帮助,但是当驱动程序成为瓶颈时,命令缓冲区可能是有害的。

这些早期api中的某些语义不允许驱动程序并行化各种操作,这有助于推动Vulkan、DirectX 12和Metal的开发。一个可以很好地映射到现代gpu的瘦绘制提交接口可以将这些新api的驱动程序成本降到最低。命令缓冲区管理、内存分配和同步决策由应用程序而不是驱动程序负责。此外,使用这些新api的命令缓冲区在形成时只验证一次,因此重复播放的开销比使用诸如DirectX 11等早期api的开销要小。所有这些元素结合起来可以提高API效率,允许多处理,并减少驱动程序成为瓶颈的机会。

扩展阅读

移动设备在时间使用方面可以有不同的平衡,特别是如果它们使用Tile-Base的架构。Merry[1200]讨论了这些成本以及如何有效地使用这类GPU。Pranckeviˇcius和Zioma[1433]提供了一个深入的介绍为移动设备优化的许多方面。McCaffrey[1156]比较了移动架构和桌面架构以及性能特征。像素着色通常是移动gpu上最大的开销。Sathe[1545]和Etuaho[443]讨论了移动设备上的着色器精度问题和优化。

对于桌面,Wiesendanger[1882]给出了一个现代游戏引擎架构的完整演练。O ‘Donnell[1313]介绍了基于图形的呈现系统的优点。Zink等[1971]深入讨论了directx11。De Smedt[331]提供了关于视频游戏中常见热点的指导,包括针对DirectX 11和12、针对多gpu配置和虚拟现实的优化。Coombes[291]给出了DirectX 12个最佳实践的概要,Kubisch[946]提供了何时使用Vulkan的指南。有很多关于从旧api移植到DirectX 12和Vulkan(249,536,699,1438)的演示。当你读到这篇文章的时候,无疑会有更多。检查IHV开发站点,如NVIDIA、AMD和Intel;Khronos组织;以及整个网络,以及这本书的网站。

虽然有点过时,但塞贝诺扬的文章[240]仍然具有相关性。它概述了如何找到瓶颈和提高效率的技术。一些流行的c++优化指南是Fog的[476]和Isensee的[801],它们在web上是免费的。Hughes等人[783]对如何使用跟踪工具和GPUView分析瓶颈发生的位置提供了一个现代的、深入的讨论。虽然主要讨论虚拟现实系统,但是所讨论的技术适用于任何基于windows的机器。

Sutter[1725]讨论了CPU时钟速率是如何趋于稳定的,以及多处理器芯片组是如何产生的。有关这种变化发生的原因以及芯片设计方法的更多信息,请参见Asanovic等人的深入报告[75]。Foley[478]讨论了图形应用程序开发环境中的各种并行形式。Game Engine Gems 2[1024]有几篇关于为游戏引擎编写多线程元素的文章。Preshing[1445]解释了Ubisoft如何使用多线程,并给出了使用c++ 11的线程支持的细节。Tatarchuk[1749, 1750]详细介绍了游戏Destiny中使用的多线程架构和着色管道。

发表评论

电子邮件地址不会被公开。 必填项已用*标注

This site uses Akismet to reduce spam. Learn how your comment data is processed.