问题

在特效渲染的项目中,通常会有很多的实现不同功能的 Shader,比如:高斯模糊、液化、色彩校准、LUT等等。在使用特效时会遇到一些可能是 Shader 导致的性能问题,比如:渲染速度慢、实时渲染卡顿等等。这时候,就需要一个能够快速找到有性能问题的 Shader的方法,即本文。

定位

工欲善其事,必先利其器。

首先,需要一个能够快速评估性能的工具(详见之前的汇总Graphics API Debuggers),挑一个适合的测试真机+测试工具 (个人使用的是 Arm Performance Studio for Mobile

推荐观测的指标:

  • FPS (对于实时渲染的项目)
  • GPU 使用率
  • CPU 使用率
  • 内存使用量

其次,针使用场景创建一系列基准测试用例,用于比较不同Shader实现的性能,找出瓶颈所在。

对于定位 Shader 的性能问题,推荐使用以下方法:

  1. 整体,用一个简单的 Shader (或什么也不做的 NULL-Shader) 替换掉所有的 Shader,观察性能变化。如果性能有明显提升,说明瓶颈问题大概率在 Shader 上。
  2. 二分法定位,将 Shader 分为 AB两部分,分别测量 A + 原始Shader 和 原始Shader + B,进行性能对比,继续二分,重复对比,直到找出性能问题的 Shader。

除了用 NULL-Shader 外,也可以尝试以下办法定位问题 Shader:

  1. 减小渲染分辨率:如果性能没有变化,说明瓶颈在渲染行为无关的其它代码,建议关注 CPU Usage
  2. 减小输入纹理(sampler)的大小(如改为2x2):如性能明显提升,则代表瓶颈在GPU 带宽,建议采用压缩纹理、避免同步上传数据等措施改进

找到性能问题的 Shader 后,可以使用以下方法进行进一步定位问题点:

  1. 使用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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Mali Offline Compiler v8.3.0 (Build 4a2310)
Copyright (c) 2007-2024 Arm Limited. All rights reserved.

Configuration
=============

Hardware: Mali-T880 r2p0
Architecture: Midgard
Driver: r23p0-00rel0
Shader type: OpenGL ES Fragment

Main shader
===========

Work registers: 2 (50% used at 100% occupancy)
Uniform registers: 5 (22% used)
Stack spilling: false

A LS T Bound
Total instruction cycles: 8.33 1.00 0.00 A
Shortest path cycles: 3.96 1.00 0.00 A
Longest path cycles: 4.62 1.00 0.00 A

A = Arithmetic, LS = Load/Store, T = Texture

Shader properties
=================

Has uniform computation: true
  1. 使用实时抓帧工具(如 RenderDoc,Arm Graphics Analyzer)进行在线测量,观察 Shader 的执行情况。
  2. 禁用 Shader 中的一部分代码,逐步排查问题代码。

优化

找到性能问题的 Shader 后,可以使用以下方法进行优化:

  1. 减少整体指令数:减少不必要的计算(CPU上预计算,使用 uniform 变量 )、尽量避免数值类型转换、使用数学上的近似值等方法。

如模糊效果中权重的计算:

1
2
3
float getWeight(in float i, in float sigma) {
return (1.0 / sqrt(2.0 * PI * sigma * sigma)) * exp(-float(i * i) / (2.0 * sigma * sigma));
}

完全可以在 CPU 上计算好权重,然后通过 uniform 传递给 Shader,然后通过查表法替代实时计算:

1
2
3
4
5
float weights[16];
for (int i = 0; i < 16; i++) {
weights[i] = getWeight(i, sigma);
}
glUniform1fv(glGetUniformLocation(program, "weights"), 16, weights);

类型转换也会占用指令周期:

1
2
float a = ...;
int b = int(a); // 避免出现这种类型转换
  1. 减少纹理访问:减少纹理采样次数可以显著提高性能,不过需要权衡效果,合理利用纹理线性(GL_LINEAR)过滤的特性。

双线性过滤已经实现了对目标坐标附近的4个texel(texture element)进行加权平均,所以在有类似需要的模糊算法中考虑这个特性,设置合理的纹理坐标偏移量,可以在实现相似效果前提下减少采样次数(详见 Efficient Gaussian blur with linear sampling)。

GL_LINEAR 的算法详见回答 https://stackoverflow.com/a/43490335/2236255

GL_LINEAR

  1. 减少分支:使用向量操作函数替代在分量上的条件分支、使用条件编译(预处理宏)、使用特化常量(Specialization Constants,Metal 和 Vulkan可用,在 OpenGL 可以上用宏变量模拟类似效果)
1
2
3
4
5
6
7
8
9
int i;
float f;
vec4 v;

for(i = 0; i < 4; i++)
v[i] += f;

// 可以优化为
v += vec4(f);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 使用特化常量
layout(constant_id = 0) const int BLEND_MODE = 1;

// 或者用宏变量模拟
#ifndef BLEND_MODE
#define BLEND_MODE 1
#endif

void main()
{
if (BLEND_MODE == 1) { // 分支会在 Shader 编译期间会被优化掉
...
}
}

根据苹果OpenGL ES Programming Guide 的建议,Shader 分支性能:

  • 最佳:在编译期常量上进行分支。因为这种分支会在编译时被优化掉,基本不会影响性能
  • 可接受:在 uniform变量上进行分支。uniform变量是全局共享的变量,意味着其值在渲染期间不会改变,有助于分支预测并进行优化。
  • 尽可能避免:在Shader内部计算的值上进行分支。这种分支是最不可取的,因为它们会导致GPU难以预测分支路径,可能会降低渲染管线的并行性,从而影响性能。

此外,还有一种容易被忽视,避免将 varying 变量用作条件,对于 GPU 来说,varying 变量的值通常来自 vertex shader 中的计算,导致难以预测分支路径。
参见 stackoverflow 上10年前的一个例子 https://stackoverflow.com/a/19460747/2236255

  1. 降低精度:在移动端上很重要的瓶颈点是浮点数精度(FP16 的执行效率高于 FP32),可以尝试改为 medium 或 low 测试性能,如果提升明显,建议在效果保持不变的前提下可将除 position 和 depth 外的浮点数据精度都下调。
  1. 此外,还有个容易被忽视的 Shader 优化点:Shader 的编译的耗时。

Arm 开发者指南 里建议 Shader 编译只在应用启动时进行,尽量避免在运行时(尤其是实时渲染时)编译 Shader。另外,可以将 Shader 编译的二进制结果缓存起来下次使用 (glGetProgramBinary + glProgramBinary ),避免二次使用时重复编译。

代码示例:

1.保存编译后的二进制文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

const char *source = R"(
#version 410 core
layout(location = 0) in vec3 aPos;
void main() {
gl_Position = vec4(aPos, 1.0);
}
)"
// First create and compile the shader
GLuint shader;
shader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(shader, 1, suorce, NULL);
glCompileShader(shader);
// Create the program and attach the shader to it
GLuint program;
program = glCreateProgram();
glAttachShader(program, shader);
// Set the binary retrievable hint and link the program
glProgramParameteri(program, GL_PROGRAM_BINARY_RETRIEVABLE_HINT, GL_TRUE);
glLinkProgram(program);

// Check for success/failure
GLint status;
glGetprogramiv(program, GL_LINK_STATUS, &status);

if (GL_FALSE == status) {
// Handle failure ...
return EXIT_FAILURE;
}

// Get the expected size of the program binary
GLint length = 0;
glGetProgramiv(program, GL_PROGRAM_BINARY_LENGTH, &length);

// Retrieve the binary code
std::vector<GLubyte> buffer(length);
GLenum format = GL_NONE;
glGetProgramBinary(program, length, nullptr, &format, buffer.data());

// Write the binary to a file.
std::string fName("shader.bin");
std::ofstream out(fName.c_str(), std::ios::binary);
out.write(reinterpret_cast<char *>(buffer.data()), length);
out.close();
std::cout << "Writed to " << fName << ", binary format = " <<format << std::endl;

2.使用 glProgramBinary 加载二进制文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

GLuint program = glCreateProgram();
// Load binary from file
std::ifstream inputStream("shader.bin", std::ios::binary);
if (!inputStream) {
std::cerr << "Failed to open shader.bin" << std::endl;
// Handle error...
return;
}
std::istreambuf_iterator<char> begin(inputStream), end;
// Read the file into a buffer
std::vector<char> buffer(begin, end);
inputStream.close();

glProgramBinary(program, format, buffer.data(), buffer.size() );

// Check for success/failure
GLint status;
glGetprogramiv(program, GL_LINK_STATUS, &status);
if (GL_FALSE == status) {
// Handle failure ...
}

注意先检查是否支持二进制格式:

1
2
3
4
5
GLint formats = 0;
glGetIntegerv(GL_NUM_PROGRAM_BINARY_FORMATS, &formats);
if (formats < 1) {
std::cerr << "Driver does not support any binary formats." << std::endl;
}

总结

本文着眼于 Shader 的性能问题,提供了一些简单有效的优化方法。当然,Shader 优化本身是一个极度抠细节繁琐的过程,需要结合具体的场景和测试用例,不断尝试、优化和对比结果。

注意,Shader 优化是个非常 trade-off 的,需要平衡渲染效果和性能,不要为了性能而牺牲效果,也不要为了效果而忽视性能

另外,实践下来,Shader 很多场景下并不是性能瓶颈(或者说从Shader 的角度已经无法再优化),这时候就需要从其它方面入手,比如:优化渲染流水线、减少过度绘制、利用图形 API 特性和 Extension 等等,等有空另撰文分享。

Refs