在应用程序中使用显式 16 位算术

此示例的源代码可在 Khronos Vulkan 示例 github 仓库中找到。

概述

在移动 GPU 的世界中,mediump 长期以来一直被用作性能和带宽的关键优化。桌面 GPU 和 API 对原生 16 位操作的支持不多,但在最近的架构中,此功能变得越来越普遍,尤其是 FP16 变得越来越常见。在此示例中,我们演示了 VK_KHR_shader_float16_int8,它增加了对 FP16 算术(和 INT8 算术)的标准化支持。

启用 16 位浮点算术支持

要添加 FP16 算术支持,请启用 VK_KHR_shader_float16_int8 扩展。您还需要使用 VkPhysicalDeviceShaderFloat16Int8Features 结构查询 vkGetPhysicalDeviceFeatures2。在这里,我们可以查询并启用两个单独的功能

  • shaderFloat16

  • shaderInt8

启用这些功能后,我们可以创建启用了 Float16Int8 功能的 SPIR-V 模块。请注意,此功能不包括对 16 位或 8 位存储的支持。对于 16 位存储,请参阅 VK_KHR_16bit_storage

8 位算术?

虽然此扩展还增加了对使用 8 位整数算术的支持,但此示例未使用此功能。

16 位整数?

Vulkan 1.0 已经具有 shaderInt16 功能。

启用 16 位存储支持

使用 16 位算术时,很可能也会在缓冲区中使用 16 位值。为此,此示例还演示了如何在 SSBO 和推送常量中使用 16 位存储。

在这种情况下,我们需要启用 VK_KHR_16bit_storage 扩展,以及 VK_KHR_storage_buffer_storage_class,这是 VK_KHR_16bit_storage 所必需的。

vkGetPhysicalDeviceFeatures2 中,我们检查 VkPhysicalDevice16BitStorageFeatures,并启用

  • storageBuffer16BitAccess

  • storagePushConstant16

16 位算术示例

此示例旨在用 16 位浮点算术来冲击 GPU,以观察算术吞吐量的显着提升。该示例是完全蛮力的,并计算一些程序颜色环。为了动画目的,这些环在屏幕上移动,并且它们的外观会随着时间而变化。这并非旨在成为渲染这种效果的有效方法,恰恰相反。每个像素都无条件地测试每个环,并且为了确保我们完全隔离算术吞吐量作为主要瓶颈,计算最终颜色所花费的数学运算被大大夸大了。

32-bit arithmetic

在这里,关键的算术开销是

// This is very arbitrary. Expends a ton of arithmetic to compute
// something that looks similar to a lens flare.
vec4 compute_blob(vec2 pos, vec4 blob, float seed)
{
    vec2 offset = pos - blob.xy;
    vec2 s_offset = offset * (1.1 + seed);
    vec2 r_offset = offset * 0.95;
    vec2 g_offset = offset * 1.0;
    vec2 b_offset = offset * 1.05;

    float r_dot = dot(r_offset, r_offset);
    float g_dot = dot(g_offset, g_offset);
    float b_dot = dot(b_offset, b_offset);
    float s_dot = dot(s_offset, s_offset);

    vec4 dots = vec4(r_dot, g_dot, b_dot, s_dot) * blob.w;

    // Now we have square distances to blob center.

    // Gotta have some FMAs, right? :D
    dots = dots * dots + dots;
    dots = dots * dots + dots;
    dots = dots * dots + dots;
    dots = dots * dots + dots;
    dots = dots * dots + dots;
    dots = dots * dots + dots;

    vec4 parabolas = max(vec4(1.0, 1.0, 1.0, 0.9) - dots, vec4(0.0));
    parabolas -= parabolas.w;
    parabolas = max(parabolas, vec4(0.0));
    return parabolas;
}
16-bit arithmetic

在这个版本中,我们将 `compute_blob` 和着色器的其余部分重写为尽可能纯粹的 FP16

// Allows us to use float16_t for arithmetic purposes.
#extension GL_EXT_shader_explicit_arithmetic_types_float16 : require

// Allows us to use int16_t, uint16_t and float16_t for buffers.
#extension GL_EXT_shader_16bit_storage : require
// This is very arbitrary. Expends a ton of arithmetic to compute
// something that looks similar to a lens flare.
f16vec4 compute_blob(f16vec2 pos, f16vec4 blob, float16_t seed)
{
    f16vec2 offset = pos - blob.xy;
    f16vec4 rg_offset = offset.xxyy * f16vec4(0.95hf, 1.0hf, 0.95hf, 1.0hf);
    f16vec4 bs_offset = offset.xxyy * f16vec4(1.05hf, 1.1hf + seed, 1.05hf, 1.1hf + seed);

    f16vec4 rg_dot = rg_offset * rg_offset;
    f16vec4 bs_dot = bs_offset * bs_offset;

    // Dot products can be somewhat awkward in FP16, since the result is a scalar 16-bit value, and we don't want that.
    // To that end, we compute at least two dot products side by side, and rg_offset and bs_offset are swizzled
    // such that we avoid swizzling across a 32-bit boundary.
    f16vec4 dots = f16vec4(rg_dot.xy + rg_dot.zw, bs_dot.xy + bs_dot.zw) * blob.w;

    // Now we have square distances to blob center.

    // Gotta have some FMAs, right? :D
    dots = dots * dots + dots;
    dots = dots * dots + dots;
    dots = dots * dots + dots;
    dots = dots * dots + dots;
    dots = dots * dots + dots;
    dots = dots * dots + dots;

    f16vec4 parabolas = max(f16vec4(1.0hf, 1.0hf, 1.0hf, 0.9hf) - dots, f16vec4(0.0hf));
    parabolas -= parabolas.w;
    parabolas = max(parabolas, f16vec4(0.0hf));
    return parabolas;
}

显式 16 位算术 vs. `mediump` / `RelaxedPrecision`

显式的、标准化的 16 位算术支持在图形 API 领域是相当新的,但 `mediump` 对许多移动(OpenGL ES)开发者来说应该很熟悉。在 SPIR-V 中,这会转换为 `RelaxedPrecision` 修饰符。

`mediump` 的主要问题一直在于,你并不确定驱动程序是否真的使用了该精度限定符。`mediump` 只是表示“在这里使用 FP16 是可以的,但编译器可以自由地忽略它,而直接使用 FP32”的意图。这给开发者(和用户!)带来了很多麻烦,因为开发者可能会添加 `mediump`,观察到在其实现上一切渲染正常,但在不同的实现上尝试时却发现渲染出现问题。如果你使用显式 FP16,则可以保证所讨论的设备实际上正在使用 FP16,无需猜测。

`mediump` 在 Vulkan GLSL 中即使在桌面配置文件中也受支持,并且一些桌面驱动程序和 GPU 实际上确实使用了生成的 `RelaxedPrecision` 限定符。在 Vulkan 中使用 `mediump` 是一种合理策略。这样做的好处是,你无需实现着色器变体来处理 FP16 与 FP32,因为并非所有设备都支持显式 FP16 算术。特别是对于渲染普通图形的片段着色器,仅仅为了这种情况而添加更多着色器变体可能会令人头痛。`mediump` 在这里可以是一个有用的工具,因为它可以在任何地方工作,但你必须接受不同设备上不同的渲染结果。

显式 FP16 在计算工作负载中表现出色,在这种情况下,对着色器变体的考虑较少,并且你可以实现和调整 FP16 内核。

FP16 的隐藏好处:降低寄存器压力

使用较小的算术类型的隐藏好处不仅仅是更高的吞吐量潜力,还包括减少寄存器使用。GPU 性能很大程度上取决于运行着色器所需的寄存器数量。使用的寄存器越多,可以同时运行的线程就越少,因此,它在隐藏指令延迟方面的效果就越差。内存操作(如加载和存储)以及纹理操作往往具有较高的延迟,如果寄存器使用量过高,着色器核心就无法有效地“隐藏”这种延迟。这直接导致性能下降,因为着色器核心会花费周期执行无用的操作。

在计算着色器中,你还可以使用具有小型算术类型的共享内存,这也很不错。

在示例中演示这些效果是相当困难的,因为它取决于许多未知因素,但是可以使用供应商工具或 `VK_KHR_pipeline_executable_properties` 扩展来研究这些效果,后者通常会报告寄存器使用率/占用率。

最佳实践总结

应该做的

  • 如果你在算术吞吐量或寄存器压力方面遇到困难,请考虑使用 FP16。

  • 仔细基准测试你的算法改进。使用 FP16 时很难保证性能提升。代码越复杂,成功利用 FP16 就越困难。如果问题几乎完全可以用 FMA 表示,则很容易看到性能提升。

  • 如果你不想显式使用 FP16,或者你需要使用大量的着色器变体来在 FP32 和 FP16 之间进行选择,请考虑使用 `mediump` / `RelaxedPrecision`。这里最常见的情况是典型的图形片段着色器,它很容易出现变体的组合爆炸。使用专门优化的计算着色器是显式 FP16 更可行的方案。

  • 如果使用 `mediump`,请确保在多种实现上进行测试,以实际观察使用它时出现的精度损失。

  • 如果使用 FP16,请确保通过使用 `f16vec2` 或 `f16vec4` 来仔细向量化代码。现代 GPU 架构依赖于“打包”的 f16x2 指令来实现改进的算术性能。标量 `float16_t` 不会有太多(如果有的话)好处。

不应该做的

  • 不要在 FP16 和 FP32 之间进行过多的转换。大多数 GPU 在 FP16 和 FP32 之间转换时需要花费周期。

  • 不要在没有在多种实现上进行测试的情况下就依赖 `mediump`。

影响

  • 不利用 FP16 可能会留下一些优化潜力。

  • 不利用 FP16 可能会导致着色器占用率低,即使用了太多的寄存器。这反过来会导致着色器核心上的执行气泡,从而浪费周期。

调试

  • 调试算术吞吐量的唯一合理方法是使用可以提供相关统计信息的性能分析器。

  • 要调试着色器占用率,可以使用离线编译器、供应商工具或标准的 `VK_KHR_pipeline_executable_properties` 扩展来帮助获取此类信息。