【RTR4笔记】 第十八章 管线优化

定位管线瓶颈以及优化

Posted Kongouuu's Blog on August 24, 2022

18.1 Profiling and Debugging Tools

在我们试图去优化我们的程序的时候,ProfilingDebugging 一定是不能少的。这些包括:

  1. 截帧来查看资源状态
  2. 分析 CPUGPU 之间交互的开销
  3. 调试着色器,可以的话曝露一些变量在运行时查看修改结果
  4. 使用编辑器的断点

当我们要查看 GPU 方面的开销的时候,则可以选择像 RenderDoc / GPU PerfStudio/ NVIDIA Nsight/ PIX 这样的软件去进行 GPU 资源状态的查看与调试。

18.2 Locating the Bottleneck

我们的渲染管线分为了很多不同步骤,而我们为了优化整体流程需要找到有瓶颈的那一个阶段。一般来说我们只需要挨个阶段去测试,也就是挨个阶段的减少工作量来查看是否有帧数提升,就能知道哪个阶段造成了最大的瓶颈。

18.2.1 Testing the Application Stage

要测试应用阶段是否有什么问题的话,我们只需要暂时的把所有 GPU 的工作关闭,也就是不去呼叫图形 API, 就可以简单的测试我们的应用阶段。

18.2.2 Testing the Geometry Processing Stage

我们没办法像测试应用阶段一样测试几何处理阶段,因为他是直接关联到其他阶段的。几何处理阶段主要分为获取顶点以及着色器操作,我们可以增加每个顶点的数据来查看获取顶点部分的开销,并且增加着色器的长度来查看顶点着色器对运行的影响。

18.2.3 Testing the Rasterization Stage

光栅化包含三角形设置以及遍历。这部分会根据小三角形的数量来产生一定的瓶颈问题。不过由于它跟像素以及顶点着色器过于关联,我们一般是不太方便直接测试。一般来说会把顶点和像素着色器里面的程式长度增加,如果渲染的消耗时间没增加,那么瓶颈就是光栅化环节。

18.2.4 Testing the Pixel Processing Stage

要检查像素着色器是否是当前性能运行的问题的话,只需要调整分辨率就可以了。如果调整分辨率后我们的帧数没有太大的变化,那么就表示我们的像素着色器并没有瓶颈。

18.2.5 Testing the Merging Stage

这一阶段我们在进行深度测试、模板测试、混合等操作,调整这些测试所需要的缓冲的位深度可以稍微的测试一下这个部分是否有成为瓶颈。

18.3 Performance Measurement

18.4 Optimization

18.4.1 Application Stage

假设我们想要优化我们的应用阶段,主要需要考虑的是让代码跑的快一点,然后内存访问少一点。一般我们会使用 Profiler 帮忙定位执行较多的代码部分,并且对其进行一定程度的优化。

Memory Issues

随着硬件技术的发展,比起运行指令的数量,现在对效率最关键的部分是内存的访问。我们的处理器本身现在的执行速度是没有任何性能上的大问题的,或者说在整体运行环境下它对我们应用阶段的限制是较小的,性能的问题主要是出在跟内存相关的部分,例如数据访问以及存储。由于我们数据传输的速度并没有很理想,因为快速的内存的价格是十分昂贵的,因此现在的内存多半是层级构造。越贵的储存结构拥有越少的储存空间,并且离我们的处理器越近。

我们的代码中去访问内存的部分如果设计不好的话,会导致过多额外的内存层级的访问,这会导致整体执行需要的时间变长,也就是造成效率的问题。但是我们的 Profiler 是没有办法判断出我们的代码造成的内存访问到底有什么问题的。我们能做的就是在写代码的时候注意一下下面的这些情况:

  1. 会按照顺序被访问的数据应该也按顺序的储存在内存中。往细了说,如果顶点数据是 TexCoord / Normal / Color 这个顺序,那最好我们的程序中也是按照这个顺序去访问。这对我们的 GPU 来说也是一样的。往广了说,我们会一起用到的数据最好都储存在邻近的位置,这样访问的时候可以一次性把所有数据拉到最近的层级进行多次的快速访问。

  2. 尽量减少间接指针、跳转、函数调用等会影响到 CPU 运行的功能的使用。现在的处理器都会去进行分支预测和缓存预取,这就表示即使我们使用了循环或者 if 这些调用,也可以不用一直去访问比较深的内存层级。

    不过跳转、间接指针、函数调用都是不一样的。我们就算使用缓存预取也没办法把一个例如 链表 的数据结果所需要的所有数据提前拿下来。如果我们要使用类似链表的结构,那我们在运行的时候会一直因为无法在缓存中找到下一个结点的信息,一直去重新的搜索更深层的内存层级 ( 也就是 cache miss ) 。函数调用也是一个道理,当我们调用函数的时候也要重新的去拉数据到缓存,造成整体运行的不连贯性。

  3. 可以的话要自己注意一下内存对齐方面的问题。虽然说我们编译器大部分时候会帮我们解决内存对齐的问题,不过最好还是要自己注意一下,不要太过于以来我们编译器。
  4. 要注意我们应用的目标硬件,不同的硬件在面对不同的数据的处理方式都是不一样的。例如在我们可以使用 储存大量结构的数组 的时候,不妨试试 含有大量数组的结构 这种方式,可能会适配到不同的硬件上。
  5. 尽量去提前申请好一段区域内需要用到的内存,也就相当于是内存池。这样的手法在提高缓存命中率的同时还可以避免内存碎片的出现。多方面来看内存池都可以提高我们的运行性能,不过对于有垃圾回收的语言来说内存池会有负面的效果。

18.4.2 API Calls

在我们使用图形 API 的时候,虽然我们没有办法直接为 GPU 处理器进行内存的管理,不过我们还是可以提供不同的提示来让 GPU 正确的创建缓冲。例如我们要好好的去区分动态与静态的缓冲来降低 GPU 现存上的开销。正确的设定静态的缓冲可以防止在每一帧中去反复的发送其内容到总线。

同时对于像手机这样的设备,CPUGPU 是共享内存的情况下,我们依旧还是要打上 (GPU-only/CPU-only) 这样的标签让他们使用的内存区分开来。如果我们不这样做的话那么在一个芯片写入内存的时候,另一个就要无效化自己的缓存,这是相当昂贵的操作。

State Changes

基本上因为 CPUGPU 是分开的,他们的通信本身就是很耗时的,加上还有同步的问题。不过最主要的部分还是我们 GPU 管线以及资源状态变换的一个开销。我们一般在设计程序的时候都要尽量减少状态的变化,例如应该一起渲染的物体都要在同一个管线状态下一次进行完绘制,不要一直切换状态等。不过要注意的是这些状态的切换开销不完全是 GPU 那边造成的,不如说 GPU 侧的开销是完全可以预测的,更多的问题还是出在 CPU 侧的通信。这一点要

一个非常常见的优化办法是把多个纹理放到一个大纹理里面一次绑定,这样在渲染多个物体的时候不需要反复的切换纹理绑定状态。不过更好的办法是如果我们的 API 支持 Bindless Texture, 那么就不需要去做这些麻烦的操作了。

切换 Shader 也是一个开销较大的操作,在实践中也是最好使用一个偏大的 Shader, 里面有大量的分支,并且使用一个全局常量去控制它。要注意的是一般来说分支可能会看起来有一些性能的问题,因为我们的 GPU 没办法并行的处理不同的指令。不过如果分支很简短,使用分支预测平铺开来其实开销也不大;如果分支很长,由于我们当前渲染状态已经使用全局常量控制好了分支走向,因此使用跳转可以完全不执行其他的分支。说到 Shader 的全局常量,比起定义多个独立的 Uniform, 我们不如把多个常量放在一起,例如 DirectXConstBuffer

Consolidating and Instancing

像上面提到过,CPU 在提交工作给 GPU 的过程实际上是有相当多的开销的。如果我们提交大量的 Draw Call, 那么即使我们的 GPU 能没有压力的绘制完这些物体,它还是会因为要等待 CPU 动作而没办法一直处于工作状态。意思就是说我们如果提交过多的 Draw Call, 那么我们的 CPU 就会变成整体运行的瓶颈。

一个很好的解决办法就是我们把大量的物体合并成一个单独的 Mesh, 通过单次的绘制提交绘制。一般我们把这个叫做 Batch Rendering, 不过这样的做法也会造成一些问题,当我们把物体合并之后,我们的视锥体裁剪会变得不那么有效果 ( 当合并后的物体有一大部分在视锥体外,却因为一小部分在里面会没法被剔除,导致着色器还是需要处理这部分的内容 )

一般提到批处理就一定会提到另一个类似的技术, 实例化 Instancing。 如果说批处理是把多个物体当成一个进行一次绘制,那么实例化就是让一个物体被绘制很多次。就比如说我们要绘制很多的方块,比起呼叫很多次方块绘制,我们可以直接提交一次绘制让我们提供的方块信息绘制出十几个方块。要注意的是实例化的物体的位置、颜色、纹理等都是可以更改的,共用的只有模型空间的顶点位置;因为我们可以根据实例化的实例 id 去给予不同的调整。

18.4.3 Geometry Stage

几何阶段的开销主要是考虑到处理所有的顶点变换,以及在某些场景下需要用到的顶点光照。

对于变换这里,我们有很多阶段外的处理可以帮助,例如视锥体裁剪和顶点压缩等。使用这些可以让我们几何处理阶段的开销降低。除此之外如果我们想要考虑到为每个顶点计算光照,而不是像素 。或者说我们把接收的光强信息储存在顶点这样的操作的话,可以给光源加一些限制,让每个顶点需要计算的光源数量降低。

实际上这一部分书上写得还算是挺模糊的,可以感觉得到几何处理阶段没什么要优化的东西,或者说要优化的可能是提前在应用阶段准备好的。

18.4.4 Rasterization

对于封闭的物体或者一些背景的物体,那些我们不会看到他们的背面的物体;我们可以把他们放到一个开启了背面剔除的批次进行渲染,这样就可以减少大量的三角形的设置以及绘制。

18.4.5 Pixel Processing Stage

对像素着色阶段来说,我们会不希望让 GPU 有低占用率的发生,但是在很多情况下也不太好避免。比如说如果我们的线程在操作像素的时候遇到了纹理采样,在等待结果的同时会去先操作其它的像素来节省时间,但是如果我们在等纹理采样的时候并没有其它的待处理的像素,就会出现低占用的问题。如果我们画面中有大量较小的三角形,那么这个问题就会出现,因为小三角形的像素较少,很多时候会让我们的线程没有多余的像素可以操作。除此之外,如果我们在着色器种使用了大量的寄存器来储存不同的变量,可以处理像素的线程就会减少 ( 因为寄存器堆里面的寄存器是有限的,如果每个线程都要使用更多寄存器,能够跑的线程就比较少 ) 。

纹理方面的话最好使用图形处理器默认的格式。可以的话只加载使用得到的 Mip Level ,以及适当的做纹理压缩。这样的话也同时可以减少我们像素着色阶段的压力。

LOD 也是为这个阶段降压的一个很好的办法,对于较远的物体我们不去使用像高度贴图这样的纹理,来减少寄存器的使用以及增加处理的线程。同时对于很远的物体,我们除了降低像素着色器的复杂度以外,还可以把颜色的处理放到顶端着色器里面,进一步的降低像素着色的开销。

像素着色阶段很大的开销来自于 OverDraw , 也就是绘制一些并不会出现在屏幕上的像素。 这个原因是当我们先绘制了远方的物体,后面才绘制同个像素内较近的物体的话,远方的会被覆写,但绘制远方物体的开销并不会不见。

  • Early-Z: 如果我们的程序并不打算在像素着色阶段修改物体的 z 值,那么我们也可以启用 Early-Z 来减少像素着色器的不必要运行。 Early-Z 的话是这样的,一般来说我们会在像素着色后的混合阶段进行深度测试。但是由于深度缓冲是共享的已知数据,那么我们完全可以在像素着色运行前先比对深度来决定到底要不要渲染这个像素。
  • 排序: 或者我们可以把场景内的物体按照远近进行排序,比如说使用一些空间划分的结构 ( 例如BVH ) 来在绘制时保证近的物体先被绘制
  • Z-Prepass: 也有一个方法是我们先单独的跑一次额外的顶点着色阶段。这样可以把场景的深度记录下来,那么之后我们进行正式渲染的时候就可以非常简单的抛弃掉那些不该被绘制的像素。不过这种做法在减轻像素着色开销的同时,如果场景复杂度过高,会造成反效果,因为几何着色也是有开销的。不过我们可以混合去进行操作,比如说先用

18.5 Multiprocessing

18.5.1 Multiprocessor Pipelining

CPU 核心增加的时候,一个非常直观的能增加吞吐量的做法就是流水线化我们的流程。比如说我们把 CPU 的工作分成三个阶段:

  1. APP(应用阶段): 负责准备要渲染的数据
  2. CULL(裁剪): 负责进行 CPU 上的裁剪,以及排序
  3. DRAW(绘制): 负责调用图形的 API 进行绘制

这样的做法好处是我们可以增加吞吐量,也就是我们整个画面的帧数。不过缺点也很明显,就是流水线化会导致我们每一次完整绘制的延迟变高。意思就是说从进入当前更新的应用阶段一直到当前更新绘制完毕所花费的时间比较长。像射击游戏这种比较吃响应速度的场景,可能并不太合适。

18.5.2 Parallel Processing

比起把工作分成三个部分,更理想的办法是我们让所有的 CPU 核心都同时处理一个工作。这样理想的话可以达到与流水线化一样的帧数,并且更低的延迟。但并发的让多个处理器处理同一个工作也许会有资源同步上的问题,以及合并上也会有一定的开销。这方面管线化由于是每个 CPU 处理单个阶段,不大会有冲突的问题。

18.5.3 Task-Based Multiprocessing

基于任务的多处理是相较于上面两种来说更灵活的一种做法。我们的应用可以把所有的计算归类成任务,当然任务是要有明确的输入输出,以及运行时独立等条件。然后在运行的时候持续把任务放到任务池里面,当我们有空闲的 CPU 的时候就会自然的拿出任务来操作。

18.5.4 Graphics API Multiprocessing Support

DirectX10 和之前的版本,一个时间只能有一个线程提交数据到图形处理器,因此多处理在应对绘制的时候会有比较大的同步问题。后来的版本多出了一个命令缓冲区,可以把我们对图形处理器的呼叫放到一个缓冲区里面,并且让 GPU 在任何时候都可以去施行下一个指令。而且命令缓冲区是保留状态的,就比如说我们渲染中使用到了一个摄像机变换的矩阵,然后在绘制前应用侧对这个矩阵进行了更新,由于有状态保留问题,正在队列里的绘制指令并不会受到资源更新的影响。相当于是给 CPUGPU 加了一层比较智能的同步处理。这样我们在考虑 CPU 多处理的时候不用过在意 GPU 那边的状态。