如何快速定位有性能问题的 Shader 和一些简单有效的优化方法
问题
在特效渲染的项目中,通常会有很多的实现不同功能的 Shader,比如:高斯模糊、液化、色彩校准、LUT等等。在使用特效时会遇到一些可能是 Shader 导致的性能问题,比如:渲染速度慢、实时渲染卡顿等等。这时候,就需要一个能够快速找到有性能问题的 Shader的方法,即本文。
定位
工欲善其事,必先利其器。
首先,需要一个能够快速评估性能的工具(详见之前的汇总Graphics API Debuggers),挑一个适合的测试真机+测试工具 (个人使用的是 Arm Performance Studio for Mobile 。
推荐观测的指标:
- FPS (对于实时渲染的项目)
- GPU 使用率
- CPU 使用率
- 内存使用量
其次,针使用场景创建一系列基准测试用例,用于比较不同Shader实现的性能,找出瓶颈所在。
对于定位 Shader 的性能问题,推荐使用以下方法:
- 整体,用一个简单的 Shader (或什么也不做的 NULL-Shader) 替换掉所有的 Shader,观察性能变化。如果性能有明显提升,说明瓶颈问题大概率在 Shader 上。
- 二分法定位,将 Shader 分为 AB两部分,分别测量 A + 原始Shader 和 原始Shader + B,进行性能对比,继续二分,重复对比,直到找出性能问题的 Shader。
除了用 NULL-Shader 外,也可以尝试以下办法定位问题 Shader:
- 减小渲染分辨率:如果性能没有变化,说明瓶颈在渲染行为无关的其它代码,建议关注 CPU Usage
- 减小输入纹理(sampler)的大小(如改为2x2):如性能明显提升,则代表瓶颈在GPU 带宽,建议采用压缩纹理、避免同步上传数据等措施改进
找到性能问题的 Shader 后,可以使用以下方法进行进一步定位问题点:
- 使用GPU厂商提供的 Shader Offline Compiler 进行离线 Shader 分析,遵从工具的说明,可知 Shader 本身的瓶颈所在。如 Mali Offline Compiler 可以评估出 Shader 在 Pipeline 中所消耗的流水线资源(寄存器/算数单元/纹理单元)的占用水平 。
1 | $ malioc --core Mali-T880 --opengles -D MaskCircle -f mask_layer.fs -d |
1 | Mali Offline Compiler v8.3.0 (Build 4a2310) |
- 使用实时抓帧工具(如 RenderDoc,Arm Graphics Analyzer)进行在线测量,观察 Shader 的执行情况。
- 禁用 Shader 中的一部分代码,逐步排查问题代码。
优化
找到性能问题的 Shader 后,可以使用以下方法进行优化:
- 减少整体指令数:减少不必要的计算(CPU上预计算,使用 uniform 变量 )、尽量避免数值类型转换、使用数学上的近似值等方法。
如模糊效果中权重的计算:
1 | float getWeight(in float i, in float sigma) { |
完全可以在 CPU 上计算好权重,然后通过 uniform 传递给 Shader,然后通过查表法替代实时计算:
1 | float weights[16]; |
类型转换也会占用指令周期:
1 | float a = ...; |
- 减少纹理访问:减少纹理采样次数可以显著提高性能,不过需要权衡效果,合理利用纹理线性(GL_LINEAR)过滤的特性。
双线性过滤已经实现了对目标坐标附近的4个texel(texture element)进行加权平均,所以在有类似需要的模糊算法中考虑这个特性,设置合理的纹理坐标偏移量,可以在实现相似效果前提下减少采样次数(详见 Efficient Gaussian blur with linear sampling)。
GL_LINEAR 的算法详见回答 https://stackoverflow.com/a/43490335/2236255
- 减少分支:使用向量操作函数替代在分量上的条件分支、使用条件编译(预处理宏)、使用特化常量(Specialization Constants,Metal 和 Vulkan可用,在 OpenGL 可以上用宏变量模拟类似效果)
1 | int i; |
1 | // 使用特化常量 |
根据苹果OpenGL ES Programming Guide
的建议,Shader 分支性能:
- 最佳:在编译期常量上进行分支。因为这种分支会在编译时被优化掉,基本不会影响性能
- 可接受:在 uniform变量上进行分支。uniform变量是全局共享的变量,意味着其值在渲染期间不会改变,有助于分支预测并进行优化。
- 尽可能避免:在Shader内部计算的值上进行分支。这种分支是最不可取的,因为它们会导致GPU难以预测分支路径,可能会降低渲染管线的并行性,从而影响性能。
此外,还有一种容易被忽视,避免将 varying
变量用作条件,对于 GPU 来说,varying
变量的值通常来自 vertex shader 中的计算,导致难以预测分支路径。
参见 stackoverflow 上10年前的一个例子 https://stackoverflow.com/a/19460747/2236255
- 降低精度:在移动端上很重要的瓶颈点是浮点数精度(FP16 的执行效率高于 FP32),可以尝试改为 medium 或 low 测试性能,如果提升明显,建议在效果保持不变的前提下可将除 position 和 depth 外的浮点数据精度都下调。
- 此外,还有个容易被忽视的 Shader 优化点:Shader 的编译的耗时。
Arm 开发者指南
里建议 Shader 编译只在应用启动时进行,尽量避免在运行时(尤其是实时渲染时)编译 Shader。另外,可以将 Shader 编译的二进制结果缓存起来下次使用 (glGetProgramBinary + glProgramBinary ),避免二次使用时重复编译。
代码示例:
1.保存编译后的二进制文件:
1 |
|
2.使用 glProgramBinary
加载二进制文件:
1 |
|
注意先检查是否支持二进制格式:
1 | GLint formats = 0; |
总结
本文着眼于 Shader 的性能问题,提供了一些简单有效的优化方法。当然,Shader 优化本身是一个极度抠细节繁琐的过程,需要结合具体的场景和测试用例,不断尝试、优化和对比结果。
注意,Shader 优化是个非常 trade-off 的,需要平衡渲染效果和性能,不要为了性能而牺牲效果,也不要为了效果而忽视性能。
另外,实践下来,Shader 很多场景下并不是性能瓶颈(或者说从Shader 的角度已经无法再优化),这时候就需要从其它方面入手,比如:优化渲染流水线、减少过度绘制、利用图形 API 特性和 Extension 等等,等有空另撰文分享。
Refs
- Arm Performance Studio
- Mali Offline Compiler
- Arm® 开发者指南 版本 4.2 优化移动游戏图形
- Understanding numerical precision guide
- Benchmarking floating-point precision in mobile GPUs
- Qualcomm® Adreno™ GPU Best Practices
- OpenGL ES Programming Guide #Best Practices for Shaders
- Arm GPU Best Practices Developer Guide
- Specialization Constants
- SIMT
- Texture Filtering
- Efficient Gaussian blur with linear sampling
- Saving and loading a shader binary
Author: Yrom
Link: https://yrom.net/blog/2024/04/08/how-to-find-problem-shaders/
License: 知识共享署名-非商业性使用 4.0 国际许可协议