a brief discussion of performance optimization

this is an article about performance optimization in graphics

本文为RTR4 18章管线优化总结。

Donald Knuth——“We should forget about small efficiencies, say about 97% of the time:Premature optimization is the root of all evil.”

图形渲染是基于流水线架构的,因此效率取决于管线中的最慢的一个步骤。值得注意的是,当最慢的步骤无法继续优化时,可以将其他步骤使用额外的计算提高画面的质量。

KNOW YOUR ARCHITECTURE

优化是需要针对特定硬件而言的。

MEASURE , MEASURE , MEASURE.

实际的性能测试才能反映你的优化是否有效。

性能瓶颈定位

想要进行性能优化,首先得定位性能瓶颈在哪。

应用阶段测试

  • 发送让GPU很少工作或根本不工作的数据。或者使用一个空驱动程序。(这个方法可能会导致一些因为驱动程序本身导致的问题。)
  • 降低CPU时钟频率。如果性能降低,那么可以说明程序至少在某种程度上与CPU绑定。GPU也可以使用类似的降频(underclock)方法。

几何处理阶段

这个部分是最难测试的阶段,因为其他一些阶段的工作负载也会发生相应的变化。

几何处理阶段的瓶颈通常出现在顶点获取和顶点处理。

顶点获取的测试方式:

  1. 增大顶点格式的大小。例如:在每个顶点上发送一些额外的纹理坐标用来增大顶点的数据。
  2. 顶点处理由顶点着色器(Vertex Shader)完成。通过让Vertex Shader 变得更长更复杂可以测试瓶颈。但需要担心编译器的优化导致额外指令的无效。几何着色器和曲面细分着色器也会在顶点处理阶段导致额外的性能开销。通常来说,可以使用控制变量法,可以帮助我们确定这些元素是否是性能瓶颈。

光栅化阶段

光栅化一共包含两个阶段,三角形设置和三角形遍历。三角形设置会计算三角形的微分,边界方程和其他数据,并将这些数据用于三角形遍历。

在如生成shadow map 时,由于像素着色器相当简单,所以可能导致光栅化阶段成为瓶颈。 如果场景内的微小三角形过多,会因为三角形会以2 $\times$ 2 的四边形为一组进行光栅化,导致辅助像素的数量过多,出现 overshading 的情况。

  • 为了确定光栅化阶段是否为瓶颈,可以通过增加Vertex Shader 和 Fragment Shader 的大小,如果时间不变,那么瓶颈可能位于光栅化阶段。

像素处理阶段

可以通过改变屏幕分辨率来进行测试,但是如果软件系统设计良好,低分辨率会同时导致使用简化模型,导致几何处理的负载变化。还可能会影响三角形遍历、深度测试、混合和纹理访问等方面的开销。

  • 可以增大Fragment Shader的复杂度,并观察渲染时间的变化。
  • 此外,还可以将像素着色器简化到最少(这在Vertex Shader 中通常很难做到)。
  • 纹理缓存未命中导致的开销也是十分高的,如果使用一个 1 $\times$ 1 的纹理导致了性能的提高,那么纹理访问是一个瓶颈。

合并阶段测试

这个阶段会进行深度测试和模板测试,并进行混合操作。这个阶段可能会成为后处理 pass、阴影、粒子系统渲染的瓶颈。

  • 改变这些缓冲区的输出 bit 深度是一种改变此阶段带宽成本的方法,这样可以帮助我们查看这个阶段是否会成为瓶颈。

优化手段

应用阶段

内存问题

在很多年前,算术指令的数量是衡量算法效率的关键指标,而如今则是内存访问模式(memory access pattern) 。处理器的速度在过去的很多年间快速增长,而 DRAM 的数据传输速度则增长有限,因为 DRAM会受到引脚数量的限制。 因此,尽量确保需要读取的数据在缓存(cache)上,可以显著的增加程序的效率。从CPU的寄存器,到L1,L2,L3级缓存,再到DRAM(dynamic random access memory , 即大家常说的内存),再到SSD和HDD,再到云盘/网盘/服务器数据。自顶向下,呈现出从昂贵到廉价,从小容量到大容量,从高速到低速的趋势,值得一提的是,高速到低速的差距通常而言是指数级别的。 内存中的相邻位置通常而言会被依次访问(空间局部性)。而同一位置往往也会被重复访问(时间局部性)。

  • 代码中按顺序访问的数据,也应当按顺序存储在内存中。
  • 避免间接指针、跳转和函数调用
  • 将经常使用的数据结构和缓存行大小的倍数进行对齐,可以显著提升性能。
  • 在启动的时候,为相同大小的对象分配一个较大的内存池,然后使用我们自行编写的分配程序和释放程序来管理这个内存池中的内存。

图形API调用

状态改变

一个常见的图形操作是对管线进行准备来绘制一个网格,这个操作会涉及一些状态改变,例如:设置着色器和uniform变量,附加的纹理,更改混合状态,更改所使用的颜色缓冲等。提高应用程序性能的一个主要方法是将具有相似渲染状态的对象进行分组,从而最小化状态更改所带来的开销。 不同类型的状态改变有着不同的开销,这里是一个例子:

  • GPU 的渲染模式和计算着色器模式之间进行切换
  • 渲染目标(render target)(framebuffer 对象),大约 60k/秒。
  • 着色器程序,大约 300k/秒。
  • 混合模式(ROP),例如透明度。
  • 纹理绑定,1.5M/秒。
  • 顶点格式。
  • 统一缓冲区对象(uniform buffer object,UBO)绑定。
  • 顶点绑定。
  • 统一变量更新,大约 10M/秒。

可以对要显示的对象按照着色器类型进行分组,然后按照使用的纹理进行分组,以此类推(按照成本顺序)进行排序分组。按照状态进行排序有时会被称为批处理(batching) 。 另一种策略是重构对象的数据组织方式。如使用纹理数组,或者在API支持的情况下使用无绑定纹理。

由于对着色器的修改成本比修改uniform变量/纹理的开销高得多。因此同一种材质的变化可以使用"if"进行切换。也可以通过共享一个着色器来实现更大的批次,不过,更复杂的shader也会导致性能下降,所以实事求是地测试是唯一万无一失的方法。

将多个uniform 变量打包成一个组比绑定单个统一缓冲区对象的效率要高得多,这在DX中称为Constant Buffer。

在每次 draw call 之后都返回一个基本状态可能会变得成本很高。例如:当我们要绘制一个对象时,我们可能会假设状态 X默认是关闭的。实现这一目标的一种方法是“启用( X );绘制( M1​); 禁用( X) ”,然后再“启用( X);绘制( M2); ​禁用( X) ”,即在每次绘制操作之后都会恢复初始状态。然而,在两次 draw call 之间对状态进行再次设置很可能会浪费大量时间,即使它们之间并没有发生实际的状态改变。

合并和实例化

一个被三角形填充的网格,渲染起来要比大量小而简单的网格更加高效。这是因为无论这个图元的大小如何,每个 draw call 都有固定的成本开销(即处理图元的成本) 。 早在 2003 年,Wloka 就指出,每个批次仅仅绘制两个(尺寸相对较小的)三角形,其效率距离GPU 的最大吞吐量还差 375 倍。对于那些由许多小而简单的物体所组成的场景,这些物体只包含很少的几个三角形,其渲染性能完全受 API 的 CPU 限制,GPU 的能力再强也没法增加渲染效率。也就是说,这些 draw call 在 CPU 上的处理时间,要大于 GPU 实际渲染网格所需的时间,即 GPU 没有被充分利用。 减少 draw call 次数的一种方法是将多个物体合并到一个网格中,这样就只需要一次 draw call 来渲染该集合即可。因为这些静态物体在同一个网格中是没有区别的,物体选择是一个合并导致的问题。一个典型的解决方案是,在网格的每个顶点中都存储一个对象标识符来进行标记。 另一种最小化应用程序和API成本的方法是使用Instancing。即在一次draw call中对同一个物体绘制多次。这通常是指定一个基础模型,并提供一个单独的数据结构,并在其中包含了每个特定示例的所需要的信息。除了位置和朝向之外,还可以指定其他的如树叶的颜色或者由风所引起的曲率变化等等任何可以被shader用来影响模型的数据。LOD也可以与Instance技术一起使用,

几何处理阶段

  • 高效的三角形网格存储方式、模型简化和顶点数据压缩
  • 截锥体剔除和遮挡剔除
  • 烘焙以减少运行时计算。

光栅化阶段

  • 背面剔除

像素处理阶段

  • 避免过小的三角形,较小的三角形会生成大量部分覆盖的的四边形,而且,那些只覆盖几个像素的纹理网格通常会导致warp的占用率较低,导致纹理采样的延迟的隐藏效果较差。
  • 而如果是需要使用大量寄存器的复杂着色器,也会导致同一时间内的线程数量减少,这种情况被称为寄存器压力(register pressure)。
  • 使用原生的纹理格式和像素格式,从而避免格式之间的转换。
  • 只加载需要的mipmap层级。
  • 使用纹理压缩技术。
  • 通过LOD技术,对于不同的距离使用不同的fragment shader。可以简化远处的模型的计算,甚至可以简化高光甚至完全移除高光。
  • GPU的early-z测试可以提前剔除不可见的片元。

帧缓冲

  • 压缩颜色精度,如从16bit压缩到8bit。或者使用Yuv有损压缩。

多处理

多处理系统分为multiprocessor pipelining(多处理器流水线),即时间并行(temporal parallelism),以及parallel processing(并行处理),空间并行(spatial parallelism)。在理想情况下n个处理器均可提升 n倍的处理速度。

时间并行

举例:一个应用程序分为APP,CULL,DRAW三个阶段。

  • APP:APP 阶段是流水线中的第一个阶段,它控制着其他的后续阶段。在这个阶段中,开发人员可以添加额外的代码,例如进行碰撞检测等。同时 APP 阶段还会对视点进行更新。
  • CULL:视锥体剔除,LOD选择,状态排序,生成渲染的所有物体的列表。
  • DRAW:获取CULL的物体列表,执行图形调用,向GPU发送数据。

这种技术可以提高吞吐量与渲染速度,但是从轮询用户操作,到显示最终的图像的操作的延迟被加大了。

空间并行

使用这种并行处理必须需要程序的任务必须拥有并行性,可以将任务并行地进行,每个处理器都负责处理一个工作,当所有的CPU完成各自的工作时,将结果合并。 比如一个单CPU需要30ms完成的任务,可以分解成3个CPU耗费10ms完成,并将最终的结果合并一起发送到GPU绘制。这种情况下,会显著地降低任务的延迟。

基于任务的多处理

考虑到许多 CPU 上都有很多核心,现在的技术趋势是使用基于任务的多处理方法。就像是可以为一个并行化进程创建多个任务(也称为作业)一样,这种思想也可以扩展到流水线上。由任何核心生成的任何任务,首先都会被放入工作池中,任何处于空闲状态的处理器都会获取一个任务来进行处理。转换为多处理的一种方法是,获取应用程序的工作流程,并确定其中哪些系统需要依赖于其他系统。

注意到有:有时 GPU 内核也会处于空闲状态,例如在生成阴影贴图或者进行深度 prepass 的时候,很多 GPU 核心并未被充分利用。在这样的空闲时间中,可以使用计算着色器来计算其他任务。

Licensed under CC BY-NC-SA 4.0