当我们谈优化时,我们谈些什么 - 知乎 (zhihu.com)
命令方向
基于材质/RenderState的排序
- 减少drawcall
- RenderState是一个比较笼统的称呼,对于OpenGL这类基于状态机的Graphics API,像是buffer/texture绑定,framebuffer切换,shader切换,depth/stencil/culling mode/blend mode等都属于状态切换,并且有性能开销。状态切换中涉及的开销包括driver端的命令验证及生成;GPU内部硬件状态机的重新配置;显存的读写;CPU/GPU之间的同步等。这里有一张图[25]大致量化了各类状态切换的开销:
Attribute方向
几何数据优化
减少顶点数量
- 减少顶点意味
- 对于Mobile GPU,还意味着更快的Bining Pass(主要是带宽压力)。(buffer)
- 更少的顶点从System Memory/DRAM里读取到Shader Core(带宽压力)
- 更少的 VS 执行(计算压力)
- 减少顶点的三种方法
- 模型的减面
- LOD
- normal map去代替高模体现细节。
- 各种剔除 Culling (视椎体剔除,背面剔除、遮挡剔除)
- 视椎体剔除(算出物体的中心到面的最小距离(带正负方向的)与包围球的半径做比较,如果小于半径,就表示在外面。)
- 背面剔除
- 遮挡剔除(虽然 gpu 有深度测试,会将有遮挡的物体进行剔除,但是我们仍然希望在提交 GPU 之前对遮挡关系进行判断,提前剔除掉一些东西,减少渲染压力。)
- 对于有early-z机制的GPU(IMR/TBR)是有效的,对TBDR无效。 减少每个顶点数据量
- 数据量更少意味着VAF阶段和Binning Pass阶段更少的读写开销。
- 我们可以在VS里使用一些快速的顶点数据压缩/解码方案[21][22](少量的计算开销换取更少的带宽开销)。
避免小三角形
- 小三角形最直观的缺点就是:在屏幕上占用的像素非常少,是一种视觉上的浪费。
- 由于硬件管线中,针对三角形有图元装配的环节(Triangle Setup),还有三角形的剔除(Vertex Culling/Triangle Clipping),因此主要是“构造一个三角形的固定开销”。文章[19]中还提到了一定要避免小于32像素的Triangle,我猜是因为小于32像素的三角形在PS阶段,组的Warp可能是不足32pixel的(有待考证)。近几年提出的GPU Driven Pipeline里面,已经有用Compute Shader去剔除小三角形的优化方法[23]。
优化索引缓冲
- 点被访问越多次,memory cache的命中率越高,相应地,带宽开销就越小。
- 可以通过重排index buffer,让一段indices patch内同一顶点被引用的次数尽可能地多[24]。
Interleaving Attributes vs. Seperate Attributes
- 自定义Attributes: 如果一堆属性在VS中始终是会被一起使用(比如skinned weight和skinned indices;Normal/Tangent),我们应该把它们放在一起以减少Graphics API bind的次数,
- 如果一堆属性在不同VS中使用频率相差很大(比如position非常频繁,但vertex color很少使用),那么我们应该存储在不同buffer。
- 这个原理和AoS/SoA的区别一样,也是尽量提高缓存的利用率(缓存加载的时候的最小单位是Cache Line,通常64/128Bytes,所以要保证每次memory access能load更多有用的数据到cache)。
Texture方向
贴图的优化
贴图优化的核心只有一个:Cache Friendly。
减少贴图尺寸
- 很多人都觉得减少贴图尺寸带来的最大优化是显存,对于主机/移动平台以及一些受显存大小限制的场景或许是对的。但从性能角度分析,减少贴图尺寸带来的最大好处是提高缓存命中率:假设把一张1024x1024的贴图换成一张1x1的贴图,shader不变,你会发现shader的执行速度变快了,因为对所有需要采样这个贴图的shader来说,真正从内存读取数据只需要一次,而后的每次采样,都只需要从cache里取那个像素数据即可。换句话说,我们关注的是每条cache line能够覆盖多少个像素的PS执行。贴图尺寸越小,每条cache line覆盖的被采样像素就越多。
使用压缩贴图
- 这个思路和顶点压缩是类似的,即牺牲一些计算量用于即时的数据解压缩,来换取更少的带宽消耗。诸如DXT/PVRTC/ASTC都是这样的思路。同样的思路还可以用在紧凑的G-Buffer生成,比如CryEngine曾经用Best Fit Normal和YCbCr色彩空间压缩G-Buffer[27][28]。
合并贴图到Texture Altas
- 这个其实是为了减少贴图的绑定开销,本质上是减少状态切换。如果有Bindless Texture[25]的情况下,这个优化就帮助不大。
使用Mipmap
- 通常我们使用Mipmap是为了防止uv变化比较快的地方(一般是远处)的贴图采样出现闪烁,但究其根源,闪烁是因为我们在对相邻像素进行着色的时候,采到的texel是不连续的。这其实就意味着cache miss。而Mipmaped texture会按级存储每一层mip(物理内存上连续),这就意味着当你使用Mipmap去采样的时候,缓存命中率是更高的,因此性能相比没有Mipmap的贴图也会更好。
存储结果到Buffer还是Texture?
- 有时候我们会把一些通用计算放在GPU上,结果存在buffer/texture上,理论上,如果能够选择的话,尽量把不是图片类型的数据存储在buffer上(比如particle的velocity/pos或者skinPallete,最好用buffer存) 。这听起来是一句废话,理由是:Buffer和Texture在内存中的存储布局不一样,Buffer是线性的,Textute是分块的,在非贴图数据的访存模式下,分块的内存布局往往不利于缓存命中。
Tips:当然,对于移动平台来说,Cache Friendly还意味着更少的发热。
用贴图缓存中间计算结果?
很多时候,我们会把一些数学上的中间计算结果缓存到一张贴图里,这些贴图的数值本身不代表视觉信息,而是纯粹的数字。比如Marschner Hair Mode用LUT去存BRDF[29];UE4用LUT去存储PBR的环境光BRDF[30]。
LUT带来的性能损耗有两点:
(1)贴图本身是数值,所以只能用无损格式,无法压缩,所以bytes per pixel是比较大的,比一般贴图占用更多读取带宽
(2)对于贴图的采样是基于LUT的uv计算的,而相邻像素算出的uv通常都没有空间连续性,这就表示每次LUT的采样几乎都会导致cache miss,所以这类LUT比一般贴图的采样更慢。
结论:尽量使用拟合函数去代替LUT采样,对于Mobile GPU来说,永远不要尝试用LUT去优化一段shader;对于Desktop GPU来说,慎重考虑使用LUT。
Shader方向
Shader的优化
减少分支?
我们已经解释过GPU是如何实现分支的,所以再回到是否要减少分支这件事,就不应该一味地认为分支总是对性能不好的。应该说,如果分支的结果依赖shader在运行时决定,并且这个结果在warp内差异很大,那么我们应该避免分支,实在无法避免时,尽量提取公共计算部分到分支外。近年来大部分的Deferred Shading框架,都依赖于Material ID去判断材质类型,并在shading阶段依赖动态分支去做不同的着色计算,这是因为材质在屏幕空间上的变化是比较少的(大部分使用标准PBR材质),所以分支带来的性能问题也不大。
另外,我们经常会用一些Uber Shader来实现不同的材质效果(但又共享很多公共计算)。实现的思路有两种:用宏定义基于Uber Shader生成不同的Sub Shader和Uber Shader内基于Uniform的分支。前者可能带来的shader切换的开销,后者反倒可能更有利于性能(当然,这个也要具体情况具体分析)。
精确指定数据类型
对于ALU来说,它的许多数学运算指令的时长/并发数是依赖于数据位宽的。因此应该尽量使用算法允许的最低精度数据类型来进行计算,比如GLSL中,可以通过highp/mediump/lowp去指定当前shader的数据计算精度。另外,在进行Int/Float的混合计算时,需要额外的指令对Int类型进行转换,因此,如无必要,尽量不要用Int类数据。
使用向量算法还是标量算法?
过去很多Shader Core的设计是Vector Based,即ALU只会进行向量的加减乘除,对于标量也会规约到向量运算。基于这类设计,就有一些奇技淫巧,去把一个计算尽量向量化[17]。但现在更多的是Scalar Based的Shader Core,所以也就无需太过关注这点,但是,我们还是应该尽量延迟向量和标量之间的运算,比如这个例子: