65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 Mali 离线编译器进行着色器优化

emptyStarIconemptyStarIconemptyStarIconemptyStarIconemptyStarIcon

0/5 (0投票)

2021年7月5日

CPOL

11分钟阅读

viewsIcon

4425

在本文中,我们将向您介绍在游戏开发工作流中,将 Mali 离线编译器作为关键步骤的使用方法及其预期收益。

Mali 离线编译器是一个命令行工具,用于验证着色器并生成模拟性能报告。

虽然之前已过时的离线编译器版本用于生成可发布的着色器二进制文件,但此功能已被弃用,取而代之的是我们将在本文讨论的新工作流。预编译的着色器可以在运行时加载得更快。然而,它们没有完整的程序优化,这通常只能在设备上的链接时进行。预编译的二进制文件也缺少用户安装的驱动程序更新后可用的优化,这会在游戏的发布周期中造成摩擦。

为了解决这个问题,Arm 已经投入了类似 LLVM-MCA 的工具来分析着色器并在源级别指导优化。

在本文中,我们将向您介绍在游戏开发工作流中,将 Mali 离线编译器作为关键步骤的使用方法及其预期收益。

为了在本文中继续学习,请先安装 Arm Mobile Studio,它附带 Malioc(Mali 离线编译器),并在 Linux 或 macOS 上工作时确保 `/mali_offline_compiler` 在您的路径中(如果您使用的是 Windows 安装程序,则会自动完成此操作)。

着色器优化背景

与在 CPU 上运行的代码不同,着色器通常很难单独进行基准测试。

首先,着色器性能非常依赖缓存,内存访问模式可能会因着色器的使用方式、绑定到着色器的资源以及着色器的调度时间而异。

此外,着色器可以编译成不同的指令集架构 (ISA),因此在不同架构上具有不同的性能特征。

Malioc 不尝试为每个平台和设备驱动程序运行单独的基准测试,而是执行一种静态分析来生成如下所示的报告。

我们将很快解释如何解读此报告。但首先,让我们通过快速总结使一个着色器比另一个着色器 *慢* 的一些属性,以一般性术语讨论着色器性能。

比较两个着色器的性能时,第一个(也是最清晰的)比较项是操作计数。一个着色器执行的内存加载和存储次数比另一个多多少?纹理采样次数是多少?操作次数是多少?

我们应该预期,在其他条件相同的情况下,执行更多工作的着色器性能会更慢。但是,请记住,快速目视检查着色器可能无法公平地评估要完成的工作。例如,很容易忽略纹理采样指令,因为被采样的纹理是立方体纹理,最终会更昂贵。对于调用着色器头文件中定义的函数的更复杂的着色器,手动统计着色器正在执行多少工作可能是一项昂贵的核算工作。

比较两个着色器时要考虑的另一个方面是实现差异集,这会导致寄存器使用量不同。即使执行相同的计算,实现差异也会导致一个着色器分配更少的寄存器,或者如果可用寄存器数量不足,则会溢出到堆栈。此外,实现差异会创建加载存储依赖关系,从而延长关键路径或阻止编译器隐藏来自缓冲区或纹理内存的加载延迟。

使用各种着色器指令会对管道性能产生影响。例如,在片段(像素)着色器中修改 z 会阻止早期 z 测试优化激活。但是,如果片段着色器不修改 z,驱动程序可能能够一次对一组片段执行 z 测试,从而减少调度的工作量。禁用早期 z 测试的着色器对移动设备的性能尤其不利,因为它们会禁用 HSR(隐藏表面移除)剔除。

着色器在缓存一致性、指令选择(如融合乘加指令)、局部数据共享 (LDS) 使用、浮点精度、内存银行利用率、属性插值压力等方面也可能存在差异。

让我们通过一些关键示例,使用 Mali 离线编译器来研究我们自己的着色器性能。

Mali 离线编译器一般用法

安装(附带 Malioc 的)Arm Mobile Studio 后,使用该实用程序很简单。首先,创建一个名为 *test.frag* 的文件,内容如下。

#version 450
layout (binding = 1) uniform sampler2D u_sampler;
layout (location = 0) in vec2 in_uv;
layout (location = 0) out vec4 out_color;

void main() {
    out_color = texture(u_sampler, in_uv);
}

此片段着色器为一个说明目的而设计,很简单。它接收片段的 UV 坐标,使用该坐标对纹理进行采样,并将颜色写入绑定到槽 0 的渲染目标。

为了分析此着色器,我们可以调用以下命令:

malioc --vulkan test.frag

由于我们在着色器中使用 Vulkan 绑定,因此我们传递 `--vulkan` 标志。如果您的着色器使用 OpenGLES 绑定,则可以省略此标志。

此命令会为默认 Mali GPU(此处为 Valhall 架构家族的 Mali-G78)生成报告,但您可以使用 `-c` 标志明确指定其他 Mali GPU。在这种情况下生成的报告如下:

Mali Offline Compiler v7.2.0 (Build 05290c)
Copyright 2007-2020 Arm Limited, all rights reserved

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

Hardware: Mali-G78 r1p1
Architecture: Valhall
Driver: r25p0-00rel0
Shader type: Vulkan Fragment

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

Work registers: 5
Uniform registers: 2
Stack spilling: false
16-bit arithmetic: 0%

                           FMA    CVT    SFU     LS      V      T   Bound
Total instruction cycles: 0.00   0.02   0.00   0.00   0.25   0.25    V, T
Shortest path cycles:     0.00   0.02   0.00   0.00   0.25   0.25    V, T
Longest path cycles:      0.00   0.02   0.00   0.00   0.25   0.25    V, T

FMA = Arith FMA, CVT = Arith CVT, SFU = Arith SFU,
LS = Load/Store, V = Varying, T = Texture

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

Has uniform computation: false
Has side-effects: false
Modifies coverage: false
Uses late ZS test: false
Uses late ZS update: false
Reads color buffer: false

Note: This tool shows only the shader-visible property state.
API configuration may also impact the value of some properties.

注意:此工具仅显示着色器可见的属性状态。

API 配置也可能影响某些属性的值。

根据选择的架构,报告中可能不会显示某些列。例如,Valhall ISA 公开了三个不同的 ALU 列,分别对应 FMA(融合乘加)、CVT(转换)和 SFU(特殊功能单元)管道。与每组单元相关的周期会分别进行分析和显示,如上面的报告所示。相比之下,运行相同的命令并传递 `-c Mali-G76` 来为 Bifrost 架构家族的 Mali-G76 GPU 编译,将生成以下报告(已缩写以突出显示差异)。

                                A      LS       V       T    Bound
Total instruction cycles:    0.12    0.00    0.25    0.50        T
Shortest path cycles:        0.12    0.00    0.25    0.50        T
Longest path cycles:         0.12    0.00    0.25    0.50        T

与 Valhall 报告不同,所有算术指令都分组到一个对应于统一算术管道的列中。

在本文的其余部分,我们将继续关注 Valhall 架构,但这些概念将适用于其他现有或未来的架构。有关每种架构可用管道的特定信息,请参阅 Arm Mali 离线编译器用户指南

重要的是要记住,离线编译器模拟的 *不是* GPU 调度前端,它跟踪 warp 状态并将指令发送到后端。这意味着线程占用率和 GPU 工作调度的次要影响必须通过真实设备上的直接基准测试来衡量。

接下来,让我们分析同一个着色器,它采样立方体纹理(而不是 2D 纹理,就像我们采样天空盒一样)。为此,请更改着色器的内容为:

#version 450
layout(binding = 1) uniform samplerCube u_sampler;

layout(location = 0) in vec3 in_uv;

layout(location = 0) out vec4 out_color;

void main() {
    out_color = texture(u_sampler, in_uv);
}

如果我们重新运行报告,应该会看到以下结果(为简洁起见已缩写,仅显示寄存器使用情况和性能表)。

Work registers: 6
Uniform registers: 4
Stack spilling: false
16-bit arithmetic: 0%

                           FMA    CVT    SFU     LS      V      T    Bound
Total instruction cycles: 0.05   0.02   0.38   0.00   0.38   0.25   SFU, V
Shortest path cycles:     0.05   0.02   0.38   0.00   0.38   0.25   SFU, V
Longest path cycles:      0.05   0.02   0.38   0.00   0.38   0.25   SFU, V

请注意,与我们之前看到的相比,所需的寄存器和周期的总数都增加了。这是因为过滤立方体纹理需要额外的隐式工作。

您可能会问:“如果我想点采样立方体纹理怎么办?”离线编译器无法知道您将在运行时绑定哪个采样器。因此,它必须对采样器状态进行假设,并假设所有采样器都执行双线性过滤。对于纹理格式也做出了类似的假设,编译器假定纹理单元可以以全速率处理。我们在 Mali 数据表 中可以看到,这并不总是正确的;Mali-G77 每纹素的宽度大于 32 位的格式将以半速率运行。

要解释性能表各列中的数字,经验法则是越低越好。

请记住,内存延迟无法准确模拟,因为内存访问取决于运行时资源如何绑定和利用。

此外,在尝试优化着色器时,报告中报告的对于最长或最短路径的周期数最高的列是进行有针对性优化的一个不错的候选。管道可以并行处理多个着色器,因此负载最高的管道往往是关键路径。

到目前为止,我们所有的示例在“总指令周期”、“最短路径周期”和“最长路径周期”之间都有相同的行。在下一节中,我们将看一些示例,这些示例演示了分析如何反映着色器中分支指令的效果。

分析分支着色器

假设您有一个计算着色器,它根据给定图块中的光照来着色像素(如 Forward+ 平铺延迟渲染算法)。每个调用必须执行以下任务:

  1. 检索调用片段位置处的场景颜色
  2. 对于与调用图块关联的每盏灯
    • 根据片段位置和深度剔除光照
    • 累积光照的辐射贡献
  3. 写出光照结果

由于此类着色器的完整内容对于本文来说有点冗长,我们将而是看一个具有相似但不太复杂的、有代表性的着色器。

#version 450
// Different invocations will index into a buffer non-uniformly
#extension GL_EXT_nonuniform_qualifier : require

// Dispatch 16x16 tiles
layout (local_size_x = 16, local_size_y = 16) in;

layout (binding = 0, rgba8) uniform image2D scene;

// Each tile has an associated offset pointing to count "lights"
layout (binding = 1) buffer Tile
{
    uint count;
    uint indices[];
} tiles[];

layout (binding = 2) buffer Lights
{
    uint light_type;

    // In a real world example, this might contain 
    // information about the light type (spotlight, 
    // point light, etc), light transform, and other 
    // light parameters. 
    // In this simple example, we simply treat the 
    // light as an additive contribution based on 
    // the light type above
    vec4 color;
} lights[];

void main() {
    // For simplicity, assume that all invocations 
    // are in range and map to a valid tile and pixel
    // in the scene raster
    ivec2 uv = ivec2(gl_GlobalInvocationID.x, gl_GlobalInvocationID.y);
    vec4 color = imageLoad(scene, uv);

    uint tile_id = gl_WorkGroupID.x + gl_WorkGroupID.y * gl_NumWorkGroups.x;
    uint count = tiles[tile_id].count;

    // Loop through lights assigned to this tile and 
    // apply them
    for (uint i = 0; i != count; ++i) {
        uint light_index = tiles[tile_id].indices[i];
        uint light_type = lights[light_index].light_type;
        // Instead of having different light shapes, 
        // we'll simulate branching within the loop
        // by having each light's contribution do something
        // different depending on the light type
        switch (light_type) {
        case 0:
            color += lights[light_index].color;
            break;
        case 1:
            // Non-physical "anti-light"
            color -= lights[light_index].color;
            break;
        default:
            color *= lights[light_index].color;
            break;
        }
    }
    
    // Write out the accumulated result
    imageStore(scene, uv, color);
}

此着色器的功能没有实际意义——它只是说明了一个常见的模式:每个调用循环、循环内的分支以及发散的访问模式。在着色器上运行 Malioc 会生成以下报告:

Mali Offline Compiler v7.2.0 (Build 05290c)
Copyright 2007-2020 Arm Limited, all rights reserved

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

Hardware: Mali-G78 r1p1
Architecture: Valhall
Driver: r25p0-00rel0
Shader type: Vulkan Compute

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

Work registers: 22
Uniform registers: 4
Stack spilling: false
16-bit arithmetic: 0%

                              FMA     CVT     SFU      LS       T   Bound
Total instruction cycles:    0.19    0.30    0.19   11.00    0.00      LS
Shortest path cycles:        0.00    0.06    0.12    5.00    0.00      LS
Longest path cycles:          N/A     N/A     N/A     N/A     N/A     N/A

FMA = Arith FMA, CVT = Arith CVT, SFU = Arith SFU, LS = Load/Store, T = Texture

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

Has uniform computation: true
Has side-effects: true

这里有几点值得注意。

每个管道的“最长路径周期”被标记为 N/A。这是因为循环迭代次数在静态上是未知的。如果您想确定代表性循环计数(例如,对于每个图块预期的光照数量)的估计周期成本,您可以暂时硬编码该值并重新运行分析。

大部分工作似乎发生在加载-存储管道中。在这种情况下,这是预期的,因为与内存访问量相比,我们的着色器在计算方面相对较轻。因此,我们可以预期此着色器会受内存限制,而不是 ALU 限制。

通过更多实验,您应该会发现更改工作组大小不会影响模拟结果(计算着色器本身的调度未被建模)。此外,尽管算术管道使用率相对较低,但全局降低精度确实对估计的 FMA 周期数产生了有利影响,正如您所期望的那样。

您可能希望尝试标量化、使用 LDS 等技术,以了解它们对寄存器使用的影响。

优化的顶点着色器

移动 GPU 会大力优化内存带宽效率,因为访问 DRAM 是 GPU 可以执行的最耗电的操作之一。

为了优化顶点获取带宽,最近的 Mali GPU 会将每个顶点着色器编译成两个单独的二进制文件。一个二进制文件仅输出位置,用于剔除和绘制图元。另一个二进制文件将生成所有其他需要进行插值并转发到片段着色器的顶点着色器输出。

这种方法的优点是第二个二进制文件仅为通过剔除的图元执行,因此对于屏幕外或背面剔除的图元,将跳过相关的着色器执行和数据获取。

幸运的是,Malioc 会方便地为顶点着色器的两个变体生成分析。例如,考虑以下简单的顶点着色器:

#version 450

layout (binding = 0) uniform View {
    mat4 camera;
    mat4 projection;
};

// Model parameters passed as push constants
layout (push_constant) uniform Model {
    vec2 uv_scale;
    vec3 tint;
    mat4 model;
};

// Vertex attributes
layout (location = 0) in vec3 v_position;
layout (location = 1) in vec2 v_uv;
layout (location = 2) in vec3 v_color;

// Interpolants
layout (location = 0) out vec2 f_uv;
layout (location = 1) out vec3 f_color;

void main() {
    f_uv = uv_scale * v_uv;
    f_color = tint * v_color;
    gl_Position = projection * camera * model * vec4(v_position, 1.0);
}

分析上述着色器应生成类似以下的报告:

Mali Offline Compiler v7.2.0 (Build 05290c)
Copyright 2007-2020 Arm Limited, all rights reserved

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

Hardware: Mali-G78 r1p1
Architecture: Valhall
Driver: r25p0-00rel0
Shader type: Vulkan Vertex

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

Position variant
----------------

Work registers: 19
Uniform registers: 30
Stack spilling: false
16-bit arithmetic: 0%

                              FMA     CVT     SFU      LS       T    Bound
Total instruction cycles:    0.25    0.00    0.00    3.00    0.00       LS
Shortest path cycles:        0.25    0.00    0.00    3.00    0.00       LS
Longest path cycles:         0.25    0.00    0.00    3.00    0.00       LS

FMA = Arith FMA, CVT = Arith CVT, SFU = Arith SFU, LS = Load/Store, T = Texture

Varying variant
---------------

Work registers: 12
Uniform registers: 30
Stack spilling: false
16-bit arithmetic: 0%

                              FMA     CVT     SFU      LS       T    Bound
Total instruction cycles:    0.08    0.00    0.00    5.00    0.00       LS
Shortest path cycles:        0.08    0.00    0.00    5.00    0.00       LS
Longest path cycles:         0.08    0.00    0.00    5.00    0.00       LS

FMA = Arith FMA, CVT = Arith CVT, SFU = Arith SFU, LS = Load/Store, T = Texture

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

Has uniform computation: true
Has side-effects: false

在这里,我们可以看到不仅一个,而是两个变体的结果,分别对应于位置和变化的变体。作为事后验证,位置变体更昂贵(正如预期的那样),因为它需要额外的矩阵乘法才能将顶点转换为剪辑空间。

早期 ZS 和隐藏表面移除

现代 GPU 支持早期深度和模板测试,以在片段着色之前去除冗余处理(对应于被遮挡的片段)。此外,大多数移动 GPU 都包含某种形式的隐藏表面移除,即使它们是按从前到后顺序渲染的,也可以移除被遮挡的片段,这对于正常的早期 ZS 测试触发来说是不正确的顺序。

某些片段着色器功能可以禁用这些优化。例如,具有内存可见副作用(如写入原子)的着色器不能被隐藏表面移除杀死——这样做会改变程序的行为。

报告中的“着色器属性”部分突出显示了正在使用的着色器功能,这有助于识别无法进行早期 ZS 和 HSR 的情况。

Has side-effects: false
Modifies coverage: false
Uses late ZS test: false
Uses late ZS update: false
Reads color buffer: false

结论

Mali 离线编译器是任何着色器或材质管道的绝佳补充。尽管它无法考虑运行时状态(纹理格式、采样器状态、通道分歧、全程序优化等),但它可以提供数据来促使进行有针对性的优化。

例如,Malioc 分析将强调着色器是否可能是 ALU 密集型或内存密集型。此外,分析将表明是否应优化所分析的着色器以降低寄存器压力。

离线编译器可以以 JSON 格式输出其结果,使其适合集成到自动化管道中。例如,可以训练持续集成 (CI) 服务来标记以前允许 HSR 但现在不允许的材质。或者,可以保存分析快照来展示标题开发时间轴中关键着色器估计周期数的变化。也许 CI 会在材质从以前避免堆栈溢出突然开始溢出到堆栈时标记一个提交。

维护一个有助于控制性能的健壮管道的可能性令人信服。

有关进一步阅读,请查阅 Arm Mali 离线编译器用户指南。本指南包含有关模拟的架构、用法说明以及关于优化内容和方式的注意事项的关键信息。

© . All rights reserved.