Writing an Efficient Vulkan Renderer

本文出自GPU Zen 2。

Vulkan 是一个新的显式跨平台图形 API。它引入了许多新概念,即使是经验丰富的图形程序员也可能不熟悉。Vulkan 的主要目标是性能------然而,获得良好的性能需要深入了解这些概念及其高效应用方法,以及特定驱动程序实现的实现方式。本文将探讨内存分配、描述符集管理、命令缓冲区记录、管道障碍、渲染通道等主题,并讨论如何优化当前桌面/移动 Vulkan 渲染器的 CPU 和 GPU 性能,同时展望未来的 Vulkan 渲染器可以进行哪些不同的改进。

现代渲染器变得越来越复杂,必须支持许多不同的图形 API,它们具有不同程度的硬件抽象和不相交的概念集。这有时使得支持所有平台达到相同的效率水平具有挑战性。幸运的是,对于大多数任务,Vulkan 提供了多种选择,可以简单地重新实现其他 API 的概念,以更高的效率实现,具体取决于将代码专门针对渲染器需求,或重新设计大型系统以使其最优于 Vulkan。我们将在适用时尝试覆盖这两个极端------最终,这是一个在 Vulkan 兼容系统上的最大效率与每个引擎需要仔细选择的实现和维护成本之间的权衡。此外,效率往往取决于应用程序------本文中的指导是通用的,最终最佳性能是通过在目标平台上对目标应用程序进行分析并基于结果做出明智的实现决策来实现的。

本文假定读者已经熟悉 Vulkan API 的基础知识,并希望更好地理解它们和/或学习如何高效使用该 API。

4.1 内存管理

内存管理一直是一个极其复杂的话题,而在 Vulkan 中,由于不同硬件的堆配置多样性,它变得更加复杂。早期的 API 采用了资源为中心的概念------程序员并不直接管理图形内存,而是管理图形资源,并且不同的驱动程序可以根据 API 使用标志和一系列启发式方法来自行管理资源内存。而 Vulkan 强制要求在前期就考虑内存管理,因为你必须手动分配内存来创建资源。

一个合理的第一步是集成 VulkanMemoryAllocator(以下简称 VMA),它是 AMD 开发的开源库,能够通过在 Vulkan 函数之上提供通用资源分配器来解决一些内存管理的细节。即使你使用了这个库,仍然有多个性能考虑因素适用;本节的其余部分将讨论内存注意事项,而不假定你使用 VMA;所有的指导对 VMA 同样适用。

4.1.1 内存堆选择

在 Vulkan 中创建资源时,你需要选择一个堆来分配内存。Vulkan 设备暴露了一组内存类型,每种内存类型都有定义该内存行为的标志和可用大小的堆索引。大多数 Vulkan 实现暴露以下两种或三种标志组合:

cpp 复制代码
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT

这通常指的是 GPU 内存,该内存从 CPU 端不可直接访问;GPU 访问此内存的速度最快,应该用来存储所有渲染目标、仅限 GPU 资源(如计算用的缓冲区)以及所有静态资源(如纹理和几何缓冲区)。

cpp 复制代码
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT

在 AMD 硬件上,这种内存类型指的是 CPU 可以直接写入的高达 256 MB 的显存,非常适合分配由 CPU 每帧写入的数据,如统一缓冲区或动态顶点/索引缓冲区。

cpp 复制代码
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT

这指的是 GPU 可以直接访问的 CPU 内存;对这类内存的读取通过 PCI Express 总线进行。如果没有前一种内存类型,一般情况下这类内存应该是统一缓冲区或动态顶点/索引缓冲区的首选,还应该用于存储阶段缓冲区,这些缓冲区用于将数据填充到使用 VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT 分配的静态资源中。

cpp 复制代码
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT

这指的是在平铺架构上用于渲染目标的 GPU 内存,可能永远不需要分配。建议使用延迟分配的内存来节省物理内存,用于从未存储的大型渲染目标,如 MSAA 图像或深度图像。

对于集成 GPU,没有 GPU 和 CPU 内存的区别------这些设备通常暴露

cpp 复制代码
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT

,通过这些可以分配所有静态资源。

在处理动态资源时,通常在非设备本地的主机可见内存中分配效果良好------这简化了应用程序管理,并且由于 GPU 端缓存只读数据而高效。然而,对于高随机访问度的资源,如动态纹理,最好在 VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT 中分配它们,并使用在 VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT 内存中分配的阶段缓冲区上传数据------类似于处理静态纹理的方式。在某些情况下,你可能也需要为缓冲区这样做------虽然统一缓冲区通常不会受到影响,但在一些应用程序中,使用大存储缓冲区且具有高度随机访问模式,除非你先将缓冲区复制到 GPU,否则可能会产生过多的 PCIe 事务;此外,从 GPU 侧访问主机内存的延迟较高,可能会影响许多小绘制调用的性能。

当从 VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT 分配资源时,如果出现 VRAM 过度订阅的情况,你可能会耗尽内存;此时,你应退回到在非设备本地 VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT 内存中分配资源。当然,你应该首先确保大且频繁使用的资源(如渲染目标)被优先分配。还有一些其他方法可以在过度订阅事件中处理,如将较不常用的资源从 GPU 内存迁移到 CPU 内存------这超出了本文的讨论范围;此外,在某些操作系统(如 Windows 10)上,正确处理过度订阅需要当前 Vulkan 中尚未提供的 API。

4.1.2 内存子分配

与其他一些允许为每个资源执行一次内存分配的 API 不同,在 Vulkan 中,对于大型应用程序而言,这种做法并不实际------驱动程序仅需支持最多 4096 个独立的分配。除了总数量有限外,分配的执行速度可能会很慢,由于假设了最坏情况下的对齐要求,还可能会浪费内存,并且在命令缓冲区提交时需要额外的开销来确保内存驻留。因此,子分配是必要的。Vulkan 的典型工作模式涉及使用 vkAllocateMemory 进行大规模分配(例如,16 MB 到 256 MB,具体取决于内存需求的动态性),并在此内存中对对象进行子分配,有效地自行管理这些内存。至关重要的是,应用程序需要正确处理内存请求的对齐问题,以及限制缓冲区和图像有效配置的 bufferImageGranularity 限制。

简而言之,bufferImageGranularity 限制了在同一分配中缓冲区和图像资源的相对位置,要求在各个分配之间进行额外的填充。处理这种情况有几种方法:

始终超对齐图像资源(因为它们通常有更大的对齐要求),通过 bufferImageGranularity,实际上使用最大所需对齐和 bufferImageGranularity 进行地址和大小对齐。

跟踪每个分配的资源类型,如果前一个或后一个资源是不同类型的,则让分配器添加所需的填充。这需要一个稍微复杂一些的分配算法。

将图像和缓冲区分别分配在不同的 Vulkan 分配中,从而避免整个问题。这减少了由于较小对齐填充带来的内部碎片,但如果后备分配过大(例如 256 MB),可能会浪费更多内存。

在许多 GPU 上,图像资源所需的对齐要求比缓冲区要大得多,这使得最后一个选项具有吸引力------除了由于缺少缓冲区和图像之间额外填充而减少浪费外,它还减少了图像对齐造成的内部碎片,当图像跟随缓冲区资源时。VMA 提供了第二种(默认)和第三种选项的实现(参见 VMA_POOL_CREATE_IGNORE_BUFFER_IMAGE_GRANULARITY_BIT)。

4.1.3 专用分配

尽管Vulkan提供的内存管理模型意味着应用程序执行大规模分配并使用子分配将多个资源放置在一个分配中,但在某些GPU上,将某些资源作为专用分配进行分配更为高效。这样,驱动程序可以在特殊情况下在更快的内存中分配这些资源。

为此,Vulkan提供了一个扩展(在1.1版本中为核心功能)来执行专用分配------在分配内存时,您可以指定要为这个单独的资源分配内存,而不是作为一个不透明的块。要知道这是否值得,您可以通过vkGetImageMemoryRequirements2KHR或vkGetBufferMemoryRequirements2KHR查询扩展内存需求;结果结构VkMemoryDedicatedRequirementsKHR将包含requiresDedicatedAllocation(如果所分配的资源需要与其他进程共享,则可能会设置此标志)和prefersDedicatedAllocation标志。

一般来说,根据硬件和驱动程序,应用程序可能会在需要大量读/写带宽的大型渲染目标上看到专用分配带来的性能提升。

4.1.4 内存映射

Vulkan提供了两种选项来映射内存以获取可见于CPU的指针:

  • 在CPU需要向分配写入数据之前执行此操作,并在写入完成后取消映射。
  • 在主机可见内存被分配后立即执行此操作,并且永远不取消映射内存。
    第二种选项通常称为持久映射,通常是一个更好的权衡------它最小化了获取可写指针所需的时间(vkMapMemory在某些驱动程序上并不是特别便宜),消除了处理来自同一内存对象的多个资源需要同时写入的情况(对已经被映射且未取消映射的分配调用vkMapMemory是无效的),并简化了代码。
    唯一的缺点是,这种技术使得在"内存堆选择"中描述的AMD GPU上的256 MB主机可见且设备本地的显存块变得不那么有用------在使用Windows 7和AMD GPU的系统上,在此内存上使用持久映射可能会迫使WDDM将分配迁移到系统内存。如果这种组合是您用户的重要性能目标,那么根据需要映射和取消映射内存可能更合适。

4.2 描述符集

与早期采用基于插槽绑定模型的API不同,在Vulkan中,应用程序在将资源传递给着色器时有更多自由。资源被分组到描述符集中,这些描述符集具有应用程序指定的布局,每个着色器可以使用多个描述符集,这些描述符集可以单独绑定。应用程序负责管理描述符集,以确保CPU不会更新GPU正在使用的描述符集,并提供具有CPU侧更新成本和GPU侧访问成本之间最佳平衡的描述符布局。此外,由于不同渲染API使用不同的资源绑定模型,并且没有一个模型完全匹配Vulkan模型,因此以高效和跨平台方式使用该API变得具有挑战性。我们将概述几种可能的方法来处理Vulkan描述符集,这些方法在可用性和性能之间取得不同平衡。

4.2.1 Mental Model

在处理Vulkan描述符集时,拥有一种 Mental Model 以了解它们如何映射到硬件是有益的。一种这样的可能性------也是预期设计------是描述符集映射到包含描述符的一块GPU内存中------这些是不透明的数据块,其大小为16-64字节,具体取决于资源,它们完全指定了着色器访问资源数据所需的所有资源参数。在调度着色器工作时,CPU可以指定有限数量指向描述符集的指针;这些指针将在着色器线程启动时对着色器可用。

考虑到这一点,Vulkan API可以或多或少直接映射到该模型------创建一个描述符集池将分配一块足够大的GPU内存,以容纳最大指定数量的描述符。从描述符池中分配一个集合可以像通过VkDescriptorSetLayout确定已分配描述符累积大小那样简单(请注意,这样实现不支持从池中释放单个描述符时进行内存回收;vkResetDescriptorPool将指针重置回池内存开始位置,并使整个池再次可用于分配)。最后,vkCmdBindDescriptorSets将发出命令缓冲区命令,以设置与描述符集指针对应的GPU寄存器。

请注意,此模型忽略了一些复杂性,例如动态缓冲区偏移、限制数量硬件资源用于描述符集等。此外,这只是一个可能实现------一些GPU具有较少通用的描述符模型,需要驱动程序在将描述符集绑定到管道时执行额外处理。然而,这是规划描述符集分配/使用时有用的一种模型。

4.2.2 动态描述符集管理

基于上述Mental Model,您可以将描述符集视为可见于GPU的内存------应用程序负责将描述符集合并成池,并保留它们直到GPU完成读取它们。

一种有效的方法是使用空闲列表来管理描述符池;每当您需要一个描述符池时,从空闲列表中分配一个并用于当前帧当前线程中的后续描述符集分配。一旦当前池中的描述符耗尽,就会申请新的池。在给定帧中使用过的任何池都需要保留;一旦帧完成渲染(由相关围栏对象确定),则可以通过vkResetDescriptorPool重置这些描述符池并返回空闲列表。虽然可以通过VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT从池中释放单个描述符,但这会使驱动端记忆管理变得复杂,不推荐使用。

当创建一个描述符池时,应用程序指定从中分配的最大数量以及每种类型可以从中分配的最大数量。在Vulkan 1.1版本中,应用程序不必处理这些限制------它只需调用vkAllocateDescriptorSets,并通过切换到新的描述符池来处理该调用产生的错误。不幸的是,在没有任何扩展名的Vulkan 1.0版本中,如果池没有可用空间,则调用vkAllocateDescriptorSets会出错,因此应用程序必须跟踪每种类型集合和描绘数,以提前知道何时切换到不同池。

不同管道对象可能使用不同数量的描绘,这就引出了池配置的问题。一种简单的方法是创建所有配置相同、针对每种类型使用最坏情况数量描绘配置。例如,如果每个集合最多可以使用16个纹理和8个缓冲区描绘,则可以以maxSets = 1024、纹理描绘16 × 1024和缓冲区描绘8 × 1024配置所有池。这种方法可以工作,但实际上可能导致对于具有不同描绘计数着色器产生非常显著的内存浪费------您不能从上述配置中的任何池中申请超过1024个描绘集合,因此如果您的大多数管道对象只需4个纹理,则您将在纹理描绘内存上浪费75%。

提供更好平衡以优化内存利用率有两个替代方案:

  • 测量特征场景下每种类型在着色器管道中的平均描绘数,并相应地配置池大小。例如,如果在给定场景中我们需要3000个描绘集合、13400个纹理描绘和1700个缓冲区描绘,则每个集合平均需要4.47个纹理(向上舍入至5)和0.57个缓冲区(向上舍入至1),因此合理配置为maxSets = 1024、5 × 1024纹理描绘、1024缓冲区描绘。当某个类型出现耗尽时,我们就申请新的。这一方案保证能工作,并且平均效率应该合理。
  • 将着色器管道对象按大小类别进行归类,以近似常见模式并选择适当大小类别下获得合适配置。这是一种扩展上述方案以涵盖多个大小类别的方法。例如,在场景中通常会有大量阴影/深度预通道绘制调用,以及大量常规绘制调用,但这两个组所需描绘数不同,其中阴影绘制调用通常每组要求0到1个纹理和0到1个缓冲区(当动态缓冲区偏移被使用时)。为了优化内存利用率,更合适的是分别为阴影/深度和其他绘制调用单独申请描绘集合。类似于通用目的分配器,可以根据给定应用程序优化特定大小类别,这仍然能够在较低级别管理层进行管理,只要提前配置好特定于应用程序用途。

4.2.3 选择合适的描述符类型

对于每种资源类型,Vulkan提供了几种在着色器中访问这些资源的选项;应用程序负责选择最佳的描述符类型。

对于缓冲区,应用程序必须在统一缓冲区和存储缓冲区之间进行选择,并决定是否使用动态偏移。统一缓冲区对最大可寻址大小有一个限制------在桌面硬件上,您可以获得最多64 KB的数据,但在移动硬件上,有些GPU仅提供16 KB的数据(这也是规范所保证的最小值)。缓冲资源可以大于此限制,但着色器只能通过一个描述符访问这么多数据。

在某些硬件上,统一缓冲区和存储缓冲区之间的访问速度没有区别,然而对于其他硬件,根据访问模式,统一缓冲区可能显著更快。对于小到中等大小的数据,特别是当访问模式固定时(例如,用于材质或场景常量的缓冲区),优先使用统一缓冲区。当您需要比统一缓冲区限制更大的数组,并且在着色器中动态索引时,存储缓冲区更为合适。

对于纹理,如果需要过滤,则可以选择组合图像/采样器描述符(类似于OpenGL,其中描述符同时指定纹理数据源和过滤/寻址属性)、分开的图像和采样器描述符(更好地映射到Direct3D 11模型),以及带有不可变采样器描述符的图像描述符,其中采样器属性必须在创建管道对象时指定。

这些方法的相对性能高度依赖于使用模式;然而,一般来说,不可变描述符更符合其他新API(如Direct3D 12)中推荐的使用模型,并给驱动程序更多自由来优化着色器。这确实在一定程度上改变了渲染器设计,使得实现某些动态部分的采样器状态成为必要,例如在流式传输过程中用于纹理淡入的每个纹理LOD偏差,使用着色器ALU指令。

4.2.4 基于插槽的绑定

Vulkan绑定模型的一种简单替代方案是Metal/Direct3D11模型,其中应用程序可以将资源绑定到插槽,而运行时/驱动程序管理描述符内存和描述符集参数。该模型可以建立在Vulkan描述符集之上;虽然不提供最优结果,但通常是移植现有渲染器时一个不错的起点,并且通过仔细实现,它可能会出奇地高效。

为了使这个模型工作,应用程序需要决定有多少资源命名空间,以及它们如何映射到Vulkan集/插槽索引。例如,在Metal中,每个阶段(VS、FS、CS)都有三个资源命名空间------纹理、缓冲区、采样器------没有区别,例如统一缓冲区和存储缓冲区。在Direct3D 11中,命名空间更加复杂,因为只读结构化缓冲区与纹理属于同一命名空间,但与无序访问一起使用的纹理和缓冲区则位于一个单独的命名空间。

Vulkan规范仅保证整个管道(跨所有阶段)至少有4个可访问的描述符集;因此,最方便的映射选项是让资源绑定在所有阶段之间匹配------例如,无论从哪个阶段访问,纹理插槽3都应包含相同的纹理资源------并为不同类型使用不同的描述符集,例如,将0集用于缓冲区,将1集用于纹理,将2集用于采样器。或者,应用程序可以为每个阶段使用一个描述符集,并执行静态索引重映射(例如,插槽0-16将用于纹理,插槽17-24将用于统一缓冲区等)------然而,这可能会使用更多描述符集内存,因此不推荐。最后,可以为每个着色器阶段实现优化紧凑型动态插槽重映射(例如,如果顶点着色器使用纹理插槽0、4、5,则它们映射到集合0中的Vulkan描述符索引0、1、2,在运行时应用程序通过此重映射表提取相关纹理信息)。

在所有这些情况下,将纹理设置到给定插槽的一般实现不会运行任何Vulkan命令,而只是更新阴影状态;在绘制调用或调度之前,需要从适当池分配一个新的描述符集,用新描述符更新它,然后使用vkCmdBindDescriptorSets绑定所有描述符集。请注意,如果一个描述符集中有5个资源,而自上次绘制调用以来只有其中一个发生了变化,则仍需分配一个新的包含5个资源的描述符集并更新它们全部。

要通过这种方法达到良好的性能,需要遵循几个准则:

  • 如果集合中没有任何内容发生变化,则不要分配或更新描述符集。在不同阶段之间共享插槽模型下,这意味着如果两个绘制调用之间没有设置任何纹理,则不需要分配/更新带有纹理描述符的描述符集。
  • 如果可能,请批量调用vkAllocateDescriptorSets------在某些驱动程序上,每次调用都有可测量的开销,因此如果需要更新多个集合,将两者一起分配可能会更快。
  • 要更新描述符集,可以使用带有写入数组的vkUpdateDescriptorSets,或从Vulkan 1.1开始使用vkUpdateDescriptorSetWithTemplate。虽然利用vkUpdateDescriptorSets复制大多数来自先前分配数组中的描绘是诱人的,但这可能会在从写合并内存中分配描绘时变得很慢。 描述符模板可以减少应用程序进行更新所需工作的数量------因为在此方案中,需要从由应用程序维护的阴影状态中读取描述符信息,因此,通过告诉驱动程序阴影状态布局,使得某些驱动上的更新速度显著加快。
  • 最后,更倾向于动态统一缓冲区而不是更新统一缓冲区描绘。动态统一缓冲区允许通过vkCmdBindDescriptorSets中的pDynamicOffsets参数指定对缓冲对象的偏移,而无需分配和更新新的描绘。这与动态常量管理很好地结合,其中绘制调用常量从大型统一缓冲区中分配,从而显著降低CPU开销,并且可能对GPU更高效。虽然在某些GPU上,为避免驱动中的额外开销,需要保持动态缓冲区数量较少,但一两个动态统一缓冲区应能很好地适应此方案,在所有架构上均如此。
    一般而言,上述方法可以非常高效地提升性能------尽管不如下面所述具有更多静态描绘集合的方法那样高效,但如果仔细实施,它仍然能够超越旧API。不幸的是,在某些驱动程序上,分配和更新路径并不是非常优化------在某些移动硬件上,如果可以在帧内重复使用,那么根据包含的描绘缓存描绘集合可能是合理的。

4.2.5 基于频率的描述符集

虽然基于插槽的资源绑定模型简单且熟悉,但并未产生最佳性能。一些移动硬件可能不支持多个描绘集合;然而,总体而言,Vulkan API和驱动期望应用程序根据变化频率管理描绘集合。

一种更以Vulkan为中心的渲染器将根据变化频率将着色器所需的数据组织成组,并为各个频率使用单独集合,其中set = 0表示变化最少,而set = 3表示变化最多。例如,一个典型配置将包括:

  • Set = 0 描述符集合包含具有全局、每帧或每视图数据的统一缓冲区,以及全球可用纹理,如阴影贴图纹理数组/图集
  • Set = 1 描述符集合包含材料数据所需统一缓冲区和纹理描绘,如反照率贴图、菲涅尔系数等
  • Set = 2 描述符集合包含具有逐绘制数据(如世界变换数组)的动态统一缓冲区

对于set = 0,我们期望其每帧仅改变几次;因此,可以采用类似上一节所述的方法进行动态分配方案。

对于set = 1,我们期望大多数对象材料数据在帧间保持不变,因此只需在游戏代码更改材料数据时进行分配和更新。

对于set = 2,该数据将完全动态;由于使用了动态统一缓冲区,我们很少需要分配和更新此描绘集合------假设动态常量上传到一系列大型逐帧缓存,对于大多数绘制,我们需要用常量数据更新缓存,并调用vkCmdBindDescriptorSets以新偏移量进行绑定。

请注意,由于管道对象之间兼容性规则,在大多数情况下,只需每当材料发生变化时绑定sets 1和2,当材料与上一次绘制调用相同则仅绑定set 2。这导致每次绘制调用只需一次vkCmdBindDescriptorSets调用。

对于复杂渲染器,不同着色器可能需要使用不同布局------例如,并非所有着色器都需要就材料数据达成一致。在极少数情况下,根据帧结构也可能合理地使用超过3个集合。此外,考虑到Vulkan灵活性,不严格要求对场景中的所有绘制调用采用相同资源绑定系统。例如,后处理绘制调用链通常高度动态,各个绘制调用之间纹理/常量数据完全改变。一些渲染器最初实现了前一节中的动态基于插槽绑定模型,然后继续额外实现基于频率的数据以进行世界渲染,以尽量减少集合管理带来的性能损失,同时仍保持基于插槽模型对渲染管道较为动态部分简单性。

上述方案假设,在大多数情况下,每次绘制的数据大小超过了通过推送常量有效设置大小。推送常量可以无需更新或重新绑定描绘集合即可设置;由于每次绘制调用保证限制为128字节,因此很容易将其用于逐画数据,例如对象的一组4x3变换矩阵。然而,在一些架构上,可推送常量实际数量快速取决于着色器所用描绘设置,更接近12字节左右。超过此限制可能迫使驱动将推送常量溢出到由驱动管理环形缓存,这最终可能比将此数据移动到应用侧动态统一缓存更昂贵。虽然有限使用推送常量对某些设计仍然是好主意,但在下一节中全面无绑定方案下更合适地使用它们。

4.2.6 无绑定描绘设计

基于频率的描绘集合减少了描绘集合绑定开销;然而,对于每个绘制调用仍然需要绑定一两个描绘集合。维护材料描绘集合需要管理层,该层需要随时更新GPU可见描绘集合,以便材料参数发生变化;此外,由于材质数据中缓存了纹理描绘,这使得全局纹理流系统难以处理------无论何时某些mipmap级别被流入或流出,都需要更新所有引用该纹理材料。这要求材质系统与纹理流系统之间复杂交互,并引入额外开销,每当调整文本时都会出现这种情况,这部分抵消了基于频率方案带来的好处。最后,由于每次绘制调用都需要设置描绘集合,因此很难将上述任何方案适应GPU基础剔除或命令提交。

可以设计一种无绑定方案,其中世界渲染所需设置绑定调用数量保持不变,从而解耦材质与纹理描写,使得实现全局流系统更加容易,并促进GPU基础提交。如前面的方案一样,可以结合小型场景部分采用动态临时说明书更新,当那些地方抽象出较少数量时灵活性重要,例如后处理。

为了充分利用无绑定功能,仅靠核心Vulkan可能不足够;一些无绑定实现要求在更新后无需重新绑定即可更新描写集合,而这在核心Vulkan 1.0或1.1中不可用,但可以通过VK_EXT_descriptor_indexing扩展来实现。然而,如下所述基本设计可以无需扩展而工作,只要设定足够高的信息限度。这要求双重缓存上述所述文本说明书数组,以便不断被GPU访问并能及时更新单独说明书。

类似于基于频率设计,我们将着色器数据拆分为全局统一变量和纹理(set 0)、材质数据以及逐画数据。全局统一变量和纹理可以通过前面章节相同方式指定说明书。

对于逐材质数据,我们将把文本说明书移动到大型文本说明书数组中(注意:这与文本数组概念不同------文本数组只用一个说明书并强迫所有文本具有相同大小和格式;说明书数组没有这一限制,可以作为数组元素包含任意文本说明书,包括文本数组说明书)。材料数据中的每种材料将在此数组中拥有一个索引,而不是文本说明书;该索引将成为材料数据的一部分,还会包含其他材料常数。

场景中所有材料的大型存储缓存将在这里存在;虽然支持多种材质类型是可能但为了简单起见,我们假设所有材料都能用相同的数据指定。以下是材质数据结构示例:

cpp 复制代码
struct MaterialData
{
 vec4 albedoTint;
 float tilingX;
 float tilingY;
 float reflectance;
 float unused0; // pad to vec4
 uint albedoTexture;
 uint normalTexture;
 uint roughnessTexture;
 uint unused1; // pad to vec4
};

类似地,场景中所有对象的每次绘制常量可以驻留在另一个大的存储缓冲区中;为简单起见,我们假设所有的 per-draw 常量具有相同的结构。为了在这样的方案中支持蒙皮对象,我们将把transform 数据提取到一个单独的第三个存储缓冲区中:

cpp 复制代码
struct TransformData
{
 vec4 transform[3];
}; 

我们迄今为止忽略的一个方面是顶点数据规范。虽然Vulkan通过调用vkCmdBindVertexBuffers提供了一种一流的方法来指定顶点数据,但在每次绘制时绑定顶点缓冲区对于完全无绑定设计来说并不可行。此外,一些硬件不支持将顶点缓冲区作为一类实体,驱动程序必须模拟顶点缓冲区绑定,这在使用vkCmdBindVertexBuffers时会导致一些CPU端的性能下降。在完全无绑定设计中,我们需要假设所有顶点缓冲区都在一个大型缓冲区中进行子分配,并使用每次绘制的顶点偏移(vkCmdDrawIndexed的firstVertex参数)让硬件从中获取数据,或者在每次绘制调用中将此缓冲区中的偏移量传递给着色器,并在着色器中从缓冲区获取数据。这两种方法都可以很好地工作,具体效率可能因GPU而异;在这里我们假设顶点着色器将执行手动顶点获取。

因此,对于每个绘制调用,我们需要向着色器指定三个整数:

  • 材质索引;用于从材质存储缓冲区查找材质数据。然后可以使用材质数据中的索引和描述符数组访问纹理。
  • 变换数据索引;用于从变换存储缓冲区查找变换数据。
  • 顶点数据偏移;用于从顶点存储缓冲区查找顶点属性。

如果需要,我们可以通过绘制数据指定这些索引和附加数据:

cpp 复制代码
struct DrawData
{
 uint materialIndex;
 uint transformOffset;
 uint vertexOffset;
 uint unused0; // vec4 padding
 // ... extra gameplay data goes here
};

着色器需要访问包含MaterialData、TransformData、DrawData的存储缓冲区,以及包含顶点数据的存储缓冲区。这些可以通过全局描述符集绑定到着色器;唯一剩下的信息是绘制数据索引,可以通过推送常量传递。

通过这种方案,我们需要在每帧更新材料和绘制调用使用的存储缓冲区,并使用我们的全局描述符集绑定它们一次;此外,我们还需要绑定索引数据------假设像顶点数据一样,索引数据分配在一个大型索引缓冲区中,我们只需使用vkCmdBindIndexBuffer绑定一次。

完成全局设置后,对于每个绘制调用,如果着色器发生变化,我们需要调用vkCmdBindPipeline,然后是vkCmdPushConstants以指定绘制数据缓冲区中的索引,最后是vkCmdDrawIndexed。

在以GPU为中心的设计中,我们可以使用vkCmdDrawIndirect或vkCmdDrawIndirectCountKHR(由KHR_draw_indirect_count扩展提供),并使用gl_DrawIDARB(由KHR_shader_draw_parameters扩展提供)作为索引来获取每次绘制常量,而不是推送常量。唯一的注意事项是,对于基于GPU的提交,我们需要根据管道对象对CPU上的绘制调用进行分组,因为否则不支持切换管道对象。

这样,变换顶点的顶点着色器代码可能如下所示:

cpp 复制代码
DrawData dd = drawData[gl_DrawIDARB];
TransformData td = transformData[dd.transformOffset];
vec4 positionLocal = vec4(positionData[gl_VertexIndex + dd.vertexOffset], 1.0);
vec3 positionWorld = mat4x3(td.transform[0], td.transform[1], td.transform[2]) * positionLocal;

采样材质纹理的片段着色器代码可能如下所示:

cpp 复制代码
DrawData dd = drawData[drawId];
MaterialData md = materialData[dd.materialIndex];
vec4 albedo = texture(sampler2D(materialTextures[md.albedoTexture], albedoSampler), uv * vec2(md.tilingX, md.tilingY));

该方案最小化了CPU端开销。当然,从根本上说,这是多个因素之间的平衡:

  • 虽然该方案可以扩展到多种格式的材质、绘制和顶点数据,但管理起来会更加困难。
  • 在某些架构上,仅使用存储缓冲区而不是统一缓冲区可能会增加GPU时间。
  • 从由材质索引的数据数组中获取纹理描述符相较于某些替代设计可能会增加GPU上的额外间接访问。
  • 在某些硬件上,各种描述符集限制可能使得该技术难以实施;为了能够从着色器动态索引任意纹理,maxPerStageDescriptorSampledImages应该足够大,以容纳所有材质纹理------虽然许多桌面驱动程序在这里暴露了较大的限制,但规范仅保证16个,因此在某些其他支持Vulkan的硬件上,无绑定仍然无法实现。

随着渲染器变得越来越复杂,无绑定设计将变得更加复杂,并最终允许将更大部分渲染管道移动到GPU;由于硬件限制,这种设计并不适用于每一个兼容Vulkan的设备,但在为未来硬件设计新的渲染路径时绝对值得考虑。

4.3 命令缓冲记录与提交

在旧API中,GPU命令有一个单一时间线;CPU执行的命令按顺序执行到GPU,因为通常只有一个线程记录它们;没有精确控制CPU何时向GPU提交命令,而驱动程序被期望优化管理命令流所用内存以及提交点。

相比之下,在Vulkan中,应用程序负责管理命令缓冲内存,在多个线程中记录命令到多个命令缓冲,并以适当粒度提交它们以供执行。尽管经过仔细编写代码后,单核Vulkan渲染器可以显著快于旧API,但通过利用系统中的多个核心进行命令记录,可以获得峰值效率和最小延迟,这需要仔细管理内存。

4.3.1 Mental Model

类似于描述符集,命令缓冲是从命令池中分配的;理解驱动程序如何实现这一点对于推测成本和使用影响非常重要。

命令池必须管理将被CPU填充命令并随后由GPU命令处理器读取的内存。命令占用内存量不能静态确定;池的一般实现因此涉及固定大小页面的空闲列表。命令缓冲将包含实际命令页面列表,以及特殊跳转指令,将控制从每个页面转移到下一个页面,以便GPU能够按顺序执行所有这些指令。每当需要从命令缓冲分配指令时,它将编码到当前页面;如果当前页面没有空间,驱动程序将使用关联池中的空闲列表分配下一个页面,将跳转编码到当前页面并切换到下一个页面以进行后续指令记录。

每个命令池只能由一个线程同时使用,因此上述操作不需要线程安全。使用vkFreeCommandBuffers释放命令缓冲可能会通过将其添加到空闲列表来返回被该命令缓冲占用的页面。当重置命令池时,可以将所有被所有命令缓冲占用的页面放入池空闲列表;当VK_COMMAND_POOL_RESET_RELEASE_RESOURCES_BIT被使用时,这些页面可以返回给系统,以便其他池可以重用它们。

请注意,没有保证vkFreeCommandBuffers实际上会将内存返回给池;替代设计可能涉及多个命令缓冲在较大页面内分配块,这使得vkFreeCommandBuffers很难回收内存。实际上,在某个移动厂商上,当默认设置下没有VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT时,需要vkResetCommandPool才能重新利用未来的指令记录所需内存。

4.3.2 多线程命令记录

Vulkan对命令池使用有两个关键限制:

  • 从一个池分配的命令缓存不得被多个线程同时记录
  • GPU仍在执行相关指令时,不得释放或重置命令缓存和池

因此,典型线程设置要求一组命令缓存池。该集合必须包含F T*个池,其中F是帧队列长度------F通常为2(一帧由CPU记录,而另一帧正在由GPU执行)或3;T是可以同时记录指令的线程数,这个数可以高达系统上的核心数量。当从线程记录指示时,该线程需要使用与当前帧和线程关联的池分配一个指示缓存,并将指示记录到其中。假设指示缓存不会跨帧边界进行记录,并且在帧边界处,通过等待队列中的最后一帧完成执行来强制实施帧队列长度,那么我们就可以释放为该帧分配的所有指示缓存并重置所有关联命令池。

此外,在调用vkResetCommandPool之后,可以重用指示缓存,而不是释放它们------这意味着不必再次分配指示缓存。虽然理论上分配指示缓存可能很便宜,但一些驱动程序实现与指示缓存分配相关联有可测量开销。这也确保驱动程序永远不需要将指示内存返回给系统,从而使这些缓存提交更便宜。

请注意,根据帧结构,上述设置可能导致跨线程的不平衡内存消耗;例如,阴影绘制调用通常需要更少设置和更少命令内存。当结合许多作业调度程序产生有效随机工作负载分布时,这可能导致所有命名空间都根据最坏情况消耗进行调整。如果应用程序受限于内存且这成为问题,则可以限制每个单独通行证并根据录制通行证选择相应的指示缓存/池,以限制浪费。

这要求向指示缓存管理器引入大小类别概念。通过为每个线程创建一个命令池,并手动重用已分配指示缓存,如上所述,可以为每个大小类别保持空闲列表,根据绘制调用数量(例如,"<100"、"100--400"等)和/或单个绘制调用复杂性(仅深度、gbuffer)定义大小类别。根据预期用途选择缓存可导致更稳定的内存消耗。此外,对于过小通行证,在录制这些通行证时减少并行性也是值得考虑。例如,如果通行证有<100个绘制调用,则相比于在4核系统上将其拆分为4个录制作业,将其作为一个作业录制可能更高效,因为这样可以减少对指示内存管理和提交开销。

4.3.3 命令缓存提交

虽然为了提高效率,在多个线程上记录多个指示缓存很重要,但由于状态不会跨越不同缓存重复利用且存在其他调度限制,因此,为确保GPU不会在处理期间处于空闲状态,每个提交必须合理大。此外,每次提交都会产生一定开销,包括CPU侧和GPU侧。一般而言,一个Vulkan应用应针对每帧<10次提交(每次提交占据0.5毫秒或更多GPU工作负载),以及<100个每帧(每个指示缓存占据0.1毫秒或更多GPU工作负载)。这可能需要根据特定通行证调整并发限制,例如,如果特定光源阴影通行证具有<100个绘制调用,则可能有必要限制此通行证录制过程中的并发性至仅一个线程。此外,对于更短通行证,将其与相邻通行证组合成一个指示缓存也变得有利可图。最后,每帧提交次数越少越好------不过这必须与早期为框架提交足够工作以增加CPU和GPU并行性相平衡,例如,在录入框架其他部分之前,有意义的是先提交所有阴影渲染相关联缚内容。

关键的是,此处提及提交次数是针对所有vkQueueSubmit调用中总共提交VkSubmitInfo结构体数量,而不是针对单独vkQueueSubmit调用次数。例如,当提交10个指示缓存时,与逐一处理10条VkSubmitInfo结构体相比,更有效地使用一个VkSubmitInfo来一次性提交10条,即使两者情况下仅执行一次vkQueueSubmit调用。本质上,VkSubmitInfo是同步/调度单位,因为它有自己的一组围栏/信号量。

4.3.4 次级命名空间

当应用程序中的某一渲染通道包含大量绘制调用,例如gbuffer通道,为了提高CPU提交流程效率,将绘制调用拆分成多个组并在多个线程上记录非常重要。有两种方法来做到这一点:

  • 记录主指挥块,将绘图块渲染到同一帧图像中,使用vkCmdBeginRenderPass和vkCmdEndRenderPass; 使用vkQueueSubmit执行生成结果
  • 记录次级指挥块,将图形块渲染传递给 vkBeginCommandBuffer 以及 VK_COMMAND_BUFFER_USAGE_RENDER_PASS_CONTINUE_BIT; 在主控制块中使用 vkCmdBeginRenderPass 和 VK_SUBPASS_CONTENTS_SECONDARY_COMMAND_BUFFERS ,然后再跟随 vkCmdExecuteCommands 执行所有已记录次级控制块

虽然立即模式 GPU 的第一种方法可行,而且就 CPU 的同步点而言,它稍微容易管理,但对于采用切片渲染技术 GPU 而言,第二种方法至关重要。在切片模式 GPU 上采用第一种方式则要求各切片内容需刷新至内存,然后再加载回内容,这对性能来说是灾难性的。

4.3.5 命名空间重用

根据上述关于提交控制块指导原则,在大多数情况下,多次提交流程后重新利用单一控制块变得不切实际。一般来说,为场景某部分预先录入控制块的方法反而适得其反,因为它们可能因保持控制块负载庞大而导致过多 GPU 负载,同时还触发某些切片渲染器上的低效代码路径,因此应用程序应该专注于改善 CPU 上线程及绘图提交流程成本。因此,应采用VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT以确保驱动程序有自由生成无需重复播放多次的新任务。

这一规则偶尔会存在例外。例如,对于 VR 渲染,应用程序可能希望仅首次录入左眼与右眼之间合成视锥体对应控制块。如果逐眼数据显示来自单一统一控制块,则此统一控制块可随后通过 vkCmdUpdateBuffer 更新,再跟随 vkCmdExecuteCommands 如果采用次级控制块的话,否则则为 vkQueueSubmit。不过,要指出的是,对于 VR 来说,如果可用的话探索 VK_KHR_multiview 扩展也是值得考虑,因为它应允许驱动进行类似优化。

4.4 管道障碍

管道障碍仍然是 Vulkan 代码最具挑战性的部分之一。在旧API中,当发生诸如片段着色器读取之前已经呈现过纹理等危害情况时,运行时和驱动负责确保适当特定硬件同步。这要求仔细跟踪每项资源绑定,从而导致不得不付出过高 CPU 开销去执行有时过多 GPU 同步(例如,Direct3D 11 驱动通常会插入障碍,以防任何两个连续计算调度共享相同UAV,即使根据应用逻辑这些危害本身不存在)。因为快速且最佳地插入障碍通常要求了解应用如何利用资源,所以 Vulkan 要求应用自行处理这一过程。

为了实现最佳渲染,管道障碍设置必须完美。如果缺失障碍风险应用遇到未测试或更糟糕的是尚未存在体系结构上的时间依赖错误,那么最坏情况下甚至会导致 GPU 崩溃。不必要障碍则会降低 GPU 利用率,因为这减少了潜在平行执行机会------甚至更糟糕的是,会触发非常昂贵解压缩操作等问题。更糟糕的是,目前虽然工具如Radeon Graphics Profiler能够可视化过多障碍成本,但缺失障碍一般不会被验证工具检测出来。因此了解障碍行为、过度指定它们带来的后果以及如何与之协作至关重要。

4.4.1 Mental Model

规范以执行依赖关系和管道阶段之间内存可见性来描述障碍(例如,一个资源之前被计算着色器阶段写入,并将在传输阶段读取),以及图像布局变化(例如,一个资源之前处于通过颜色附加输出写入最佳格式,应转换为通过着色器读取最佳格式)。然而,从其后果来看思考障碍也许更容易------即,当使用障碍时 GPU 会发生什么情况。请注意 GPU 行为当然依赖于具体供应商及体系结构,但帮助映射抽象方式指定之障碍至更具体构造能理解其性能影响非常重要。

障碍能造成三种不同结果:

  1. 阻塞特定阶段直到另一个阶段完成当前所有工作。例如,如果渲染通道向纹理输出数据,而随后的渲染通道则利用顶点着色器读取此输出,则 GPU 必须等待所有待处理片段着色器及ROP工作完成,然后才能启动随后的阶段进行顶点工作。大多数阻塞操作将在某些阶段导致执行阻塞。
  2. 刷新或失效内部 GPU 缓存,并等待事务完成,以确保另一个阶段能读取结果工作。例如,在某些架构下 ROP 写入可能经过 L2 纹理缓存,而传输阶段则直接操作内存。如果纹理已在渲染过程中呈现,则后续传输操作读出陈旧的数据除非先刷新该缓存。同样,如果纹理阶段需读取通过传输阶段复制得到图像,则 L2 纹理缓存需失效以确保不含陈旧数据。不一定所有阻塞操作都需如此处理。
  3. 转换资源所储藏格式,一般来说就是解压缩资源储藏。例如,在某些架构下 MSAA 纹理以压缩形式保存,每像素都有样本掩码显示此像素包含多少独特颜色,以及样本数据另做保存。如果传输阶段或着色器阶段无法直接读取压缩纹理,则阻塞转换自VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL至VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL或VK_IMAGE_USAGE_TRANSFER_SRC_BIT就需解压缩纹理,将所有样本写入内存。但大多数阻塞操作不必如此处理,但那些确实如此处理成本极高。

考虑到这一点,让我们尝试理解关于阻塞运用指导原则。

4.4.2 性能指导原则

当生成针对各自阻塞操作之独立事务时,驱动仅对该阻塞持局部视角,对过去与未来事务均无了解。因此,第一个重要规则就是要尽量批量化阻塞事务。如果存在意味着等待片段阶段为空闲状态及L2纹理高速缓存刷新的阻隔,那么驱动将在您呼叫 vkCmdPipelineBarrier 时忠实生成该事务。如果您指定多个资源于单条 vkCmdPipelineBarrier 调用之中,只要其中任意转换必要,则驱动仅生成一次L2高速缓存刷新事务,从而降低成本。

为了确保阻隔成本不会高于必要,仅需包括相关阶段。例如,其中一种最常见类型就是把资源状态转变自VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL至VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL。当指定该阻隔的时候,应明确哪些真实读取此资源之着色器阶段 via dstStageMask 。尽管诱人地想把stage mask指定为VK_PIPELINE_STAGE_ALL_COMMANDS_BIT,以支持计算着色器或顶点着色器读取。然而这样做意味着后续绘图请求中的顶点着色工作不能启动,这是有问题:

  • 在立即模式渲染者里稍微降低了各请求间平行性,使得所有片段线程必须完成才能开始任何顶点线程,这导致最终经过这个过程之后 GPU 利用率降至0,然后逐渐提升至,希望达到100%;
  • 在切片模式渲染者里,对于某些设计期望后续请求中的所有顶点工作完成才能开始片段工作,因此等待片段工作结束才能开始任何其它请求完全消除了各自间平行性,这是许多天真移植 Vulkan 标题遇见最大潜力性能问题之一。

请注意,即使正确地指定了阻隔------假设我们从片段阶段读取贴图,此dstStageMask应设定为VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT------但仍然存在执行依赖关系,这仍然会导致降低 GPU 利用率。这种情况包括计算场景,其中若想从由另计算场景生成的数据读取,就需表达CS与CS间依赖关系,但规定管道阻隔则保证完全排干 GPU 的计算任务,然后再慢慢填充回来。因此,不妨考虑透过所谓"拆分"方式规定依赖关系:不是简单地运用 vkCmdPipelineBarrier ,而是在写入操作完成之后呼叫 vkCmdSetEvent ,然后再在读操作开始前呼叫 vkCmdWaitEvents。当然,如果紧接 vkCmdSetEvent 就呼叫 vkCmdWaitEvents 则事倍功半且速度甚至比 vkCmdPipelineBarrier 更慢,而应尝试重构算法确保 Set 与 Wait 间存在足够任务,从而等待处理请求时,该事件已基本信号,无损效率。

另外,有时候算法能够改组减少同步节点数量,同时仍然运用管道阻隔,使开销显得不那么显著。例如,一个基于 GPU 的粒子模拟可能对每粒子效果运行两个计算调度:第一个用于发射新粒子,而第二个用于模拟粒子。这两个调度之间要求有管道阻隔以同步执行,如果粒子系统依顺序模拟则则需逐粒子系统施加管道阻隔。一种更优实现首先提交通知发射粒子的全部调度(彼此间无依赖),接下来再发送用于同步发射与模拟调度之管道阻隔,然后再发送全部模拟粒子的调度------这就能让 GPU 长时间保持良好利用率。从那之后运用拆分式屏蔽能够帮助彻底隐藏同步成本。

关于资源解压缩,很难给出普遍建议------某些架构下这种情况根本不存在,而另一些架构下确实如此,不过取决于算法也许无法避免。在理解解压缩对画面的性能影响方面,通过供应商专属工具如Radeon Graphics Profiler 是至关重要;在某些情况下,也许能够调整算法,使其根本无需解压缩,例如通过把任务移动到不同阶段。当然,需要指出的是,当完全没有必要发生资源解压缩且由于过度规定屏蔽造成这种情况发生,例如,如果您呈现目标框架,其中包含深度目标且未来永远不会读取深度内容,应保持深度目标处于VK_IMAGE_LAYOUT_DEPTH_STENCIL_OPTIMAL布局,而非徒劳地转换成VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,这样反而触发解压缩(记住,驱动无法知道您未来是否要读取该资源!)。

4.4.3 简化屏蔽规范

面对规定屏蔽所涉及复杂性,有助于拥有常见需求屏蔽实例作为参考。有幸的是Khronos Group 提供了许多有效且最佳屏蔽实例,用于各种类型同步作为Vulkan文档库的一部分,可以访问GitHub:
Synchronization Examples · KhronosGroup/Vulkan-Docs Wiki · GitHub

这些实例能够帮助提升对一般屏蔽行为理解,也能直接用于发布应用程序。同时,对于未覆盖这些例子的情况,以及一般而言,为简化规范代码使其更加正确,可切换至一种简易模型,其中不仅全面规定访问掩码、各阶段及图像布局,只需了解有关资源概念即封装可利用该资源之各阶及最常见访问类型之状态即可。那么所有转换都涉及把资源从状态A转变至状态B,这样理解容易得多。因此Khronos Group成员及Vulkan规范共同作者Tobias Hector撰写了一款开源库simple_vulkan_synchronization,该库将资源状态(否则称作访问类型)转换成 Vulkan 阻挡规范。这个库小巧简单,同时支持拆分式屏蔽以及完整管线屏蔽功能。

4.4.4 用渲染图预测未来

上一节概述性能指导原则很难实际遵循,尤其是在传统立即模式渲染架构下。在确保各阶及图像布局转换未过度规定方面,很重要的一步就是了解未来如何运用该资源------如果想要呈现完结后的管线屏蔽,没有这些信息通常只能强迫发出带有目标布局及目的阶掩码全部内容的信息。而解决此问题,相比之后读出资源前发出屏蔽信息似乎颇具吸引力,因为那时候就能知道如何书写此资源。然而这又使批量处理屏蔽信息变得困难。例如,在具有三个渲染通路A、B、C 的框架中,其中C分别读取A输出与B输出两条独立请求,为尽量减少贴图高速缓存刷新的数量及其他屏蔽工作的数量,一般而言最好是在C之前准确指定正确转换A、B输出的信息。而实际情况却是在C 的每条请求前都有一道屏蔽信息。在一些情况下拆分式屏蔽能够减少相关费用,但一般来说即时屏蔽代价太高。同时运用即时屏蔽要求追踪资源状态以了解前一次布局;这是很难做到正确,多线程系统因为最终GPU上的执行顺序只有等全部任务录入线性化后才可知晓。

由于上述问题,许多现代渲染器开始尝试使用渲染图作为一种声明性地指定帧资源之间所有依赖关系的方法。基于生成的有向无环图(DAG)结构,可以建立正确的障碍,包括在多个队列之间同步工作的障碍,并以最小的物理内存使用量分配瞬态资源。关于渲染图系统的完整描述超出了本文的范围,但感兴趣的读者可以参考以下演讲和文章:

  • FrameGraph: Extensible Rendering Architecture in Frostbite, Yuriy O'Donnell, GDC 2017。
  • Advanced Graphics Tech: Moving to DirectX 12: Lessons Learned, Tiago Rodrigues, GDC 2017。
  • Render graphs and Vulkan---a deep dive, Hans-Kristian Arntzen。

不同的引擎选择不同的解决方案参数,例如Frostbite渲染图由应用程序使用最终执行顺序指定(本文作者发现这种方式更可预测且更可取),而另外两个演示则基于某些启发式方法对图进行线性化,以尝试找到更优的执行顺序。无论如何,重要的是必须提前声明通道之间的依赖关系,以确保可以适当地发出障碍。值得注意的是,帧图系统在数量有限且占据所需障碍大部分的瞬态资源方面表现良好;虽然可以将资源上传和类似流式工作的所需障碍作为同一系统的一部分进行指定,但这可能使图变得过于复杂,处理时间过长,因此通常最好在帧图系统之外处理这些。

4.5 渲染通道

与旧API和新的显式API相比,Vulkan中相对独特的一个概念是渲染通道。渲染通道允许应用程序将其渲染帧的大部分指定为一类对象,将工作负载拆分为单独的子通道,并明确列出子通道之间的依赖关系,以便驱动程序能够调度工作并放置适当的同步命令。从这个意义上说,渲染通道类似于上述描述的渲染图,并且可以用来实现这些功能,但存在一些限制(例如,当前渲染通道只能表达光栅化工作负载,这意味着如果需要支持计算工作负载,则应使用多个渲染通道)。然而,本节将重点关注更简单、更实用地集成到现有渲染器中的渲染通道使用,同时仍提供性能收益。

4.5.1 加载和存储操作

渲染通道最重要的特性之一是能够指定加载和存储操作。通过这些操作,应用程序可以选择每个帧缓冲附件的初始内容是否需要清除、从内存加载或保持未指定并未被应用程序使用,以及在渲染通道完成后附件是否需要存储到内存中。这些操作至关重要------在切片架构上,使用冗余加载或存储操作会导致带宽浪费,从而降低性能并增加功耗。在非切片架构上,驱动程序仍然可以利用这些操作为后续渲染执行某些优化------例如,如果附件之前内容不相关但有相关压缩元数据,则驱动程序可能会清除这些元数据,以提高后续渲染效率。

为了最大限度地给予驱动程序自由,非常重要的一点是指定所需最弱加载/存储操作------例如,当向写入所有像素的附件绘制全屏四边形时,在切片GPU上VK_ATTACHMENT_LOAD_OP_CLEAR可能比VK_ATTACHMENT_LOAD_OP_LOAD更快,而在立即模式GPU上LOAD可能更快------指定VK_ATTACHMENT_LOAD_OP_DONT_CARE非常重要,以便驱动程序能够做出最佳选择。在某些情况下,VK_ATTACHMENT_LOAD_OP_DONT_CARE可能比LOAD或CLEAR更好,因为它允许驱动程序避免对图像内容进行昂贵的清除操作,但仍然清除图像元数据以加速后续渲染。

同样,如果应用程序不期望读取绘制到附件的数据,则应使用VK_ATTACHMENT_STORE_OP_DONT_CARE------这通常适用于深度缓冲区和MSAA目标。

4.5.2 快速 MSAA 解决

在向 MSAA 纹理绘制数据后,通常会将其解析为非 MSAA 纹理以进行进一步处理。如果固定功能解析功能足够,有两种方法可以在 Vulkan 中实现这一点:

  • 对于 MSAA 纹理使用 VK_ATTACHMENT_STORE_OP_STORE,并在渲染通道结束后调用 vkCmdResolveImage。
  • 对于 MSAA 纹理使用 VK_ATTACHMENT_STORE_OP_DONT_CARE,并通过 VkSubpassDescription 的 pResolveAttachments 成员指定解析目标。

在后一种情况下,驱动程序将在子通道/渲染通道结束时执行必要工作以解析 MSAA 内容。

第二种方法可能显著更高效。在切片架构上,采用第一种方法需要将整个 MSAA 纹理存储到主内存中,然后再从内存读取并解析到目标;第二种方法可以以最有效方式执行切片内部解析。在立即模式架构上,一些实现可能不支持通过传输阶段读取压缩 MSAA 纹理------API 在调用 vkCmdResolveImage 前需要转换为 VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL 布局,这可能导致 MSAA 纹理解压缩,从而浪费带宽和性能。通过 pResolveAttachments,驱动程序可以以最大性能执行解析操作,而不考虑架构。

在某些情况下,固定功能 MSAA 解析是不足够的。在这种情况下,需要将纹理转换为 VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,并在单独的渲染通道中进行解析。在切片架构上,这与 vkCmdResolveImage 固定功能方法具有相同效率问题;在立即模式架构上,其效率取决于 GPU 和驱动。一个可能的替代方案是使用额外子通道,通过输入附件读取 MSAA 纹理。

为了使其正常工作,第一个向 MSAA 纹理绘制的子通道必须通过 pColorAttachments 指定 MSAA 纹理,同时将 VK_ATTACHMENT_STORE_OP_DONT_CARE 设置为存储操作。执行解析的第二个子通道需要通过 pInputAttachments 指定 MSAA 纹理,并通过 pColorAttachments 指定解析目标;然后该子通道需要用着色器绘制全屏四边形或三角形,该着色器使用 subpassInputMS 资源读取 MSAA 数据。此外,应用程序需要指定两个子通道之间依赖关系,这表明阶段/访问掩码,与管线障碍类似,以及依赖标志 VK_DEPENDENCY_BY_REGION_BIT。有了这些信息,驱动应该有足够的信息安排执行,以便在切片 GPU 上,MSAA 内容永远不会离开切片内存,而是在切片内部解决,将结果写入主内存。请注意,这是否发生取决于驱动,并且不太可能导致立即模式 GPU 上显著节省。

4.6 管线对象

旧API通常根据功能单元将GPU状态拆分为块------例如,在Direct3D 11中,可以使用各种阶段(VS、PS、GS、HS、DS)的着色器对象集合以及状态对象集(光栅化、混合、深度模板)、输入汇编配置(输入布局、基元拓扑)以及一些其他隐式位(如输出呈现目标格式)来描述GPU完整状态(模组资源绑定)。然后API用户可以分别设置状态单独位,而无需考虑底层硬件设计或复杂性。不幸的是,这一模型并不符合硬件通常使用模型,因此会出现几种性能陷阱:

  • 虽然单个状态对象应模拟GPU状态的一部分并且可以直接转移到设置GPU状态命令中,但在某些GPU上所需的数据来自多个不同状态块。因此,驱动通常必须保留所有状态副本,并在Draw/DrawIndexed时将状态转换为实际GPU命令。
  • 随着光栅化管线变得更加复杂并获得更多可编程阶段,一些GPU没有直接映射它们到硬件阶段,这意味着着色器微代码可能依赖于其他着色器阶段是否处于活动状态,在某些情况下还依赖于其他阶段特定微代码;这意味着驱动可能必须根据只能在Draw/DrawIndexed时发现的数据编译新的着色器微代码。
  • 同样,在某些GPU上,从API描述中固定功能单元作为其中一个着色器阶段的一部分实现------改变顶点输入格式、混合设置或呈现目标格式都可能影响着色器微代码。由于状态仅在Draw/DrawIndexed时已知,因此最终微代码必须重新编译。

虽然第一个问题较轻微,但第二个和第三个问题会导致显著延迟,因为由于现代着色器和着色器编译管线复杂性,着色器编译根据硬件情况可能花费数十到数百毫秒。为了解决这个问题,Vulkan和其他新API引入了管线对象概念------它封装了大多数GPU状态,包括顶点输入格式、呈现目标格式、所有阶段状态以及所有阶段着色器模块。期望是在每个支持GPU上,此状态足以构建最终着色器微代码和设置所需GPU命令,因此驱动无需在绘制时编译微代码,并尽可能优化管线对象设置。

然而,该模型在Vulkan之上实现渲染器时面临挑战。有多种方法解决此问题,各自涉及不同复杂性、效率与设计权衡。

4.6.1 即时编译

支持Vulkan最简单的方法是对管线对象进行即时编译。在许多引擎中,由于缺乏与Vulkan匹配的一类概念,因此呈现后端必须根据各种状态设置调用收集有关管线状态各部分的信息,与Direct3D 11驱动所做类似。然后,就在绘制/调度之前,当完整状态已知时,将所有单独位聚集起来并查找哈希表;如果缓存中已有管线状态对象,则可直接使用,否则创建新对象。这一方案能使应用运行起来,但存在两个性能陷阱。

一个小问题是,需要哈希组合起来的状态潜力较大;对于每次绘制调用这样做,当缓存已包含所有相关对象时,会非常耗时。这可以通过将状态分组到对象中并哈希指针来减轻,一般来说从高级API视角简化状态规范也能有所帮助。

然而,一个主要问题是,对于必须创建任何管线状态对象而言,驱动可能需要编译多个着色器至最终GPU微代码。这一过程耗时较长;此外,它无法与即时编译模型最佳线程化------如果应用仅用一个线程提交命令,则该线程通常也会同时编译管线状态对象;即使有多个线程,也常常会有多个线程请求相同管线对象,从而串行化编译,又或者一个线程需要多个新管线对象,这增加了整体提交延迟,因为其他线程先完成却没有任何工作可做。

对于多线程提交,即使缓存已满也会导致核心间争用访问缓存。不过幸运的是,可以通过如下双级缓存方案解决此问题:

缓存将有两个部分,不变部分与变换部分。在执行管线缓存查找时,我们首先检查不变缓存是否包含该对象------此过程无需任何同步。如果发生缓存未命中的情况,我们锁定关键区域并检查变换缓存是否包含该对象;如果没有,则解锁关键区域、创建管线对象,然后再次锁定并将其插入缓存,有可能替换掉另一个对象(如果两个线程请求相同对象,仅发起一次编译请求则需额外同步)。帧结束时,将所有变换缓存中的对象添加至不变缓存,并清空变换缓存,以便下一帧访问这些对象可自由线程化。

4.6.2 管线缓存与缓存预热

虽然即时编译能够工作,但会导致游戏过程中显著卡顿。当具有新一组着色器/状态之物体进入帧时,我们最终不得不为其编译管线对象,而这过程缓慢。这与Direct3D 11标题面临的问题相似,不过Direct3D 11 驱动后台做了大量工作以隐藏编译延迟,更早预先编译一些着色器,并实施自定义方案动态修补字节码,无需完全重新编译。而在Vulkan中,希望应用手工智能地处理管线对象创建,因此幼稚的方法效果不佳。

为了使即时编译更加实用,非常重要的一点是利用Vulkan管线缓存,在运行之间序列化它,并从多个线程预热前面章节描述之内存中的缓存。

Vulkan提供了一种管线缓存对象VkPipelineCache,可以存储特定于驱动程序的一组位及着色器微代码,从而改善管线对象之编译时间。例如,如果应用创建两个设置完全相同但剔除模式不同之管线对象,则着色器微代码通常相同。为了确保驱动仅需一次完成该物体生成,应让应用传递相同VkPipelineCache实例给vkCreateGraphicsPipelines两次,此情况下第一次调用会完成着色器微代码生成,而第二次则能复用它。如果这些调用同时发生于不同线程,则因数据仅当其中一次调用完成后才添加至缓存,所以驱动仍然可能重复生成两次该着色器。

创建所有管线对象时务必使用相同VkPipelineCache物体,并利用vkGetPipelineCacheData及VkPipelineCacheCreateInfo中的pInitialData成员序列化至磁盘。这确保已生成物体将在运行间复用,从而减少随后的应用运行期间框架波动时间。

遗憾的是,在第一次游戏过程中,由于尚未包含所有组合,因此仍然会出现着色器编译波峰。此外,即便管线缓存包含必要微代码,但vkCreateGraphicsPipelines不是免费的,因此新建管线物体之编译仍然会增加框架时间方差。要解决这一点,可以选择预热内存中的缓存(和/或vkPipelineCache)以提升加载速度。

这里一种可行方案是在游戏过程结束后,让渲染者保存内存中的管线缓存数据------哪些着色器与哪些状态一起被用过------至数据库。然后,在QA测试过程中,该数据库可通过多次测试填充来自不同图形设置等的数据------有效地收集出实际游戏过程中很可能被用到的一系列状态。

这个数据库随后可以随游戏一起发布;游戏启动时,可利用来自该数据库的数据预填充内存中的缓冲区(或者,根据当前图形设置下各类组合数量,此预热阶段也可仅限当前设定下各类组合)。此过程应当在线程间进行以降低加载时间影响;首次运行仍会有较长加载时间(这可通过Steam预缓冲等特性进一步减少),但因即时创建造成框架波峰基本可以避免。

如果QA测试过程中未发现特定组合集,该系统仍然能正常运作,只不过代价是一定程度上的卡顿。这一结果方案基本普遍且实用,但需要付出潜力巨大的努力去经历足够关卡及不同设定,以捕捉大多数现实工作负载,使其管理起来稍显困难。

4.6.3 提前编译

"完美"解决方案,即Vulkan设计初衷,就是去掉即时编译缓存及预热,而让每个潜在管线物体提前就绪。

这通常要求改变渲染者设计,将管线路径概念整合进材质系统,使得材质能够完全指定其路径。在这里存在多种设计选项;本节将概述其中一种,但重要的是理解这一一般原则。

通常,一个物体与材质关联,该材质规定了呈现该物体所需之图形路径及资源绑定。在这种情况下,将资源绑定与图形路径分开非常重要,因为目标是能够提前枚举出所有组合路径。我们称这一集合为"技术"(这一术语故意与Direct3D Effect Framework中的术语相似,不过在那里路径被保存在pass中)。技术随后可被归类成效果,而材质则引用效果以及某种键值来指定效果中的技术。

效果集合及每个效果中的技术集合都是静态;效果集合也是静态。尽管效果不是能够预先编译出管线路径的重要因素,但它们能作为技术有效语义分组。例如,经常材料创建期间分配给材料效应,但技术则依据物体被呈现位置(例如阴影传递、gbuffer传递、反射传递)或活跃游戏效果变化(例如高亮)而变化。

关键的是,每项技术必须静态提前规定创建管线路径所需全部路径------通常作为某文本文件定义的一部分,无论是在类似D3DFX DSL 的形式下还是 JSON/XML 文件中。其中必须包括全部着色器、混合态、剔除态、顶点格式、呈现目标格式及深度态。

这儿给出一个示例:

cpp 复制代码
technique gbuffer
{
    vertex_shader gbuffer_vs
    fragment_shader gbuffer_fs
#ifdef DECAL
    depth_state less_equal false
    blend_state src_alpha one_minus_src_alpha
#else
    depth_state less_equal true
    blend_state disabled
#endif

    render_target 0 rgba16f
    render_target 1 rgba8_unorm
    render_target 2 rgba8_unorm
    vertex_layout gbuffer_vertex_struct
}

假设包括用于后处理等目的之所有绘制调用均运用效应系统来制定呈现路径,同时假设效应与技术集合静态,那么就很简单地能预先创建所有管线路径------每项技术只需一次即可------利用多线程方式加载时间,在运行时期则能运用非常高效代码,无需内存缓冲或框架波峰概率。

实际上,在现代渲染者中实现此系统是一项复杂性管理练习。经常运用复杂shader或state排列组合,例如,为双面呈现通常需要改变剔除态,也许还要改变shader以实现双面照明。而对于骨骼动画呈现,需要改变顶点格式并添加一些代码到顶点shader中,用骨骼矩阵转变属性。在某些图形设置下,也许决定呈现目标格式需要浮点R10G11B10而不是RGBA16F,以节省带宽。这些组合都会成倍增加,需要您能够简洁高效地表示它们,同时准确意识到不断增长组合数量以及适当重构/简化它们。一些效果稀少到甚至可单独传递而不会增加排列数量。有些计算简单到始终运算它们比增加排列数量要划算。而一些呈现技术提供了更好的解耦与关注分离,也能减少排列数量。

不过值得注意的是,将state排列组合加入其中使得问题更加困难,却不会使其有所区别------许多renderer无论如何都要解决大量shader排列组合的问题,一旦您把所有render state纳入shader/技术规范,同时专注于减少技术排列数量,相同复杂性管理解决方案也适用于这两者的问题。

实施这样的系统好处就是对所有所需组合拥有完美知识(相比依赖脆弱排列发现系统),极佳性能且随后的帧间方差最低,包括首次加载,以及强迫机制保持呈现代码复杂性受控。

4.7 总结

Vulkan API 将大量责任从驱动开发人员转移到了应用开发人员身上。当众多实现选项可供选择时,各种渲染功能间导航变得更加棘手;写出正确Vulkan renderer已经足够具有挑战性,但性能和内存消耗至关重要。本文试图讨论处理Vulkan中特定问题时的重要考虑因素,展示提供不同复杂性、易用性和性能权衡多种实现方法,以及涵盖从移植现有renderer 到围绕Vulkan重新设计renderer 范围的问题。

最终,很难给出适用于所有供应商和renderer 的普遍建议。因此,在目标平台/供应商上分析生成代码至关重要------对于Vulkan而言,非常重要的是监控计划发布游戏上的各供应商性能,因为应用做出的选择尤为重要,而且在某些情况下,如固定功能顶点缓冲绑定,是一个供应商快速路径但另一个却慢速路径。

除了利用验证层确保代码正确性以及诸如AMD Radeon Graphics Profiler 或 NVidia Nsight Graphics 等特定供应商分析工具外,还有许多开源库可以帮助优化您的Vulkan renderer:

最后,一些供应商开发了Linux 的开源 Vulkan 驱动;研究其源代码有助于深入了解某些 Vulkan 构造性能:

致谢

作者感谢Alex Smith (Feral Interactive)、Daniel Rákos (AMD)、Hans-Kristian Arntzen (ARM)、Matthäus Chajdas (AMD)、Wessam Bahnassi (INFramez Technology Corp) 和Wolfgang Engel (CONFETTI) 审阅本文草稿并帮助改进内容质量。

相关推荐
被AI抢饭碗的人9 小时前
c++:vector
开发语言·c++
_zwy9 小时前
【Linux权限】—— 于虚拟殿堂,轻拨密钥启华章
linux·运维·c++·深度学习·神经网络
qystca9 小时前
【16届蓝桥杯寒假刷题营】第2期DAY4
数据结构·c++·算法·蓝桥杯·哈希
Xzh042310 小时前
c语言网 1127 尼科彻斯定理
数据结构·c++·算法
qystca12 小时前
【16届蓝桥杯寒假刷题营】第2期DAY5
c++·算法·蓝桥杯·贡献度
这是我5812 小时前
链表的介绍
数据结构·c++·其他·链表·visual studio·介绍·图文结合
涛ing13 小时前
29. C语言 可变参数详解
linux·服务器·c语言·c++·windows·vscode·visual studio
智能与优化13 小时前
Windows 程序设计6:错误码的查看
开发语言·c++·windows
Golinie13 小时前
【C++高并发服务器WebServer】-10:网络编程基础概述
linux·服务器·网络·c++·socket
taoyong00117 小时前
代码随想录算法训练营第三十九天-动态规划-198. 打家劫舍
c++·算法·leetcode·动态规划