编写高效的Vulkan渲染器

2020-09-04 23:05:09

2018年,我为GPU Zen 2图书撰写了一篇文章《编写高效的Vulkan渲染器》,并于2019年出版。在本文中,我试图聚合尽可能多的关于Vulkan性能的信息-而不是试图集中在一个特定的方面或应用程序,而是试图涵盖广泛的主题,让读者了解不同API在实际硬件上的行为,并为每个需要解决的问题提供一系列选项。

在发表这篇文章的时候,这本书的Kindle版在亚马逊上的售价是2.99美元-比一杯咖啡还便宜,绝对值得你花时间和钱。它包含许多关于渲染效果和设计的优秀文章。

然而,这是这篇文章的完整的免费副本-希望它能帮助图形程序员理解和充分使用Vulkan。这篇文章经过了轻微的编辑,在适用的情况下提到了Vulkan 1.1/1.2的促销活动-幸运的是,在过去的两年里,Vulkan的表演没有太大的变化,所以内容应该仍然大部分是准确的。

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

现代渲染器正变得越来越复杂,并且必须支持具有不同硬件抽象级别和互不相交的概念集的许多不同的图形API。这有时会使以相同的效率支持所有平台变得具有挑战性。幸运的是,对于大多数任务,Vulkan提供了多个选项,既可以像重新实现其他API中的概念一样简单,也可以像重新设计大型系统以使它们最适合Vulkan一样困难,因为代码专门针对呈现器需求而更高效地重新实现。我们将在适用的情况下尝试涵盖这两个极端-最终,这是在支持Vulkan的系统上实现最高效率与每个引擎需要谨慎选择的实施和维护成本之间的权衡。此外,效率通常取决于应用程序-本文中的指导是通用的,最终通过在目标平台上分析目标应用程序并根据结果做出明智的实施决策来实现最佳性能。

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

内存管理仍然是一个极其复杂的主题,在Vulkan中,由于不同硬件上的堆配置的多样性,内存管理变得更加复杂。早期的API采用了以资源为中心的概念-程序员没有图形内存的概念,只有图形资源的概念,不同的驱动程序可以根据API使用标志和一组启发式规则自由地管理资源内存。然而,Vulkan强制预先考虑内存管理,因为您必须手动分配内存才能创建资源。

完全合理的第一步是集成VulkanMemoryAllocator(以下简称VMA),它是由AMD开发的开源库,通过在Vulkan函数之上提供通用资源分配器,为您解决了一些内存管理细节问题。即使您确实使用了该库,仍有多个性能注意事项适用;本节的其余部分将在不假定您使用VMA的情况下讨论内存限制;所有指南都同样适用于VMA。

在Vulkan中创建资源时,您必须选择从中分配内存的堆。Vulkan设备公开一组内存类型,其中每种内存类型都有定义该内存行为的标志和定义可用大小的堆索引。

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

VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT|VK_MEMORY_PROPERTY_HOST_VIEW_BIT-在AMD硬件上,此内存类型指的是CPU可以直接写入的256 MB视频内存,非常适合分配CPU每帧写入的合理数据量,如统一缓冲区或动态顶点/索引缓冲区。

VK_MEMORY_PROPERTY_HOST_VISPLICE_BIT|VK_MEMORY_PROPERTY_HOST_COLERENCE_BIT 2-这指的是可从GPU直接看到的CPU内存;从该内存读取通过PCI-Express总线。在没有以前的内存类型的情况下,这通常应该是统一缓冲区或动态顶点/索引缓冲区的选择,也应该用于存储分段缓冲区,这些缓冲区用于用数据填充使用VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT分配的静态资源。

VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT|VK_MEMORY_PROPERTY_LAZLY_ALLOCATED_BIT-这是指可能永远不需要为平铺架构上的渲染目标分配的GPU内存。建议使用延迟分配的内存为从未存储到的大型渲染目标(如MSAA图像或深度图像)节省物理内存。在集成GPU上,GPU和CPU内存之间没有区别-这些设备通常公开VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT|VK_MEMORY_PROPERTY_HOST_VISPLICE_BIT,您也可以通过这些设备分配所有静态资源。

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

从VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT分配资源时,如果VRAM超额订阅,您可能会耗尽内存;在这种情况下,您应该退回到在非设备本地VK_MEMORY_PROPERTY_HOST_VISPLICE_BIT内存中分配资源。当然,您应该确保首先分配大量频繁使用的资源,如呈现目标。在超额订阅的情况下,您还可以执行其他操作,例如将资源从GPU内存迁移到CPU内存,以获得不太频繁使用的资源-这超出了本文的讨论范围;此外,在某些操作系统(如Windows 10)上,正确处理超额订阅需要Vulkan中当前不可用的API。

与其他一些允许为每个资源执行一次内存分配的API不同,在Vulkan中,这对于大型应用程序来说是不切实际的-驱动程序只需要支持多达4096个单独的分配。除了总数受到限制外,分配执行起来可能很慢,可能会由于假设最坏情况下可能的对齐要求而浪费内存,并且在命令缓冲区提交期间还需要额外的开销以确保内存驻留。正因为如此,再分配是必要的。使用Vulkan的典型模式包括使用vkAllocateMemory执行大型(例如,16MB-256MB,取决于内存需求的动态程度)分配,并在此内存中执行子分配,从而有效地自己管理它。至关重要的是,应用程序需要正确处理内存请求的对齐,以及限制缓冲区和图像有效配置的BufferImageGranulality限制。

简而言之,BufferImageGranulality限制了缓冲区和图像资源在同一分配中的相对位置,需要在各个分配之间进行额外的填充。有几种方法可以处理此问题:

始终通过BufferImageGranulality过度对齐图像资源(因为它们一开始通常有较大的对齐方式),本质上是使用所需的最大对齐方式,而将BufferImageGranulality用于地址和大小对齐。

跟踪每个分配的资源类型,只有在前一个或后一个资源属于不同类型时,才让分配器添加必需的填充。这需要稍微复杂一些的分配算法。

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

在许多GPU上,图像资源所需的对齐比缓冲区大得多,这使得最后一个选项更具吸引力-除了减少由于缓冲区和图像之间缺少额外填充而造成的浪费外,它还减少了图像跟随缓冲区资源时由于图像对齐而导致的内部碎片。VMA提供选项2(默认情况下)和选项3(请参阅VMA_POOL_CREATE_IGNORE_BUFFER_IMAGE_GRANULARITY_BIT).)的实施。

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

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

通常,应用程序可能会在需要大量读/写带宽的大型渲染目标上进行专用分配,这取决于硬件和驱动程序。

在CPU需要将数据写入分配之前执行此操作,并在写入完成后取消映射。

第二种选择也称为持久映射,通常是一种更好的折衷方法-它最大限度地缩短了获取可写指针所需的时间(vkMapMemory在某些驱动程序上并不是特别便宜),消除了需要同时写入同一内存对象中的多个资源的情况(对已映射且未取消映射的分配调用vkMapMemory是无效的),并从总体上简化了代码。

唯一的缺点是,这种技术使得在“内存堆选择”中描述的在AMD GPU上主机可见且设备本地的256 MB VRAM区块变得不那么有用-在使用Windows 7和AMD GPU的系统上,在此内存上使用永久映射可能会强制WDDM将分配迁移到系统内存。如果此组合是用户的关键性能目标,则在需要时映射和取消映射内存可能更合适。

与早期具有基于槽的绑定模型的API不同,在Vulkan中,应用程序在如何将资源传递给着色器方面拥有更多自由。资源被分组为具有应用程序指定布局的描述符集,并且每个着色器可以使用多个可以单独绑定的描述符集。应用程序负责管理描述符集,以确保CPU不会更新GPU正在使用的描述符集,并提供在CPU端更新成本和GPU端访问成本之间具有最佳平衡的描述符布局。此外,由于不同的呈现API使用不同的模型进行资源绑定,并且没有一个模型与Vulkan模型完全匹配,因此高效和跨平台地使用API成为一个挑战。我们将概述几种可能的使用Vulkan描述符集的方法,这些方法在可用性和性能的尺度上达到了不同的程度。

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

考虑到这一点,VulkanAPI或多或少可以直接映射到这个模型-创建描述符集池将分配足够大的GPU内存块,以包含指定的最大数量的描述符。分配一组描述符池非常简单,只需将池中的指针递增由VkDescriptorSetLayout确定的已分配描述符的累积大小(请注意,当从池中释放单个描述符时,这样的实现将不支持内存回收;vkResetDescriptorPool会将指针设置回池内存的起点,并使整个池再次可供分配)。最后,vkCmdBindDescriptorSets将发出设置与描述符集指针相对应的GPU寄存器的命令缓冲区命令。

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

根据上面的心理模型,您可以将描述符集视为GPU可见内存-应用程序负责将描述符集分组到池中,并保留它们,直到GPU完成读取。

一个不错的方案是使用描述符集池的空闲列表;无论何时需要描述符集池,您都可以从空闲列表中分配一个,并将其用于在当前线程的当前帧中进行后续的描述符集分配。当前池中的描述符集用完后,您将分配一个新池。给定帧中使用的任何池都需要保留;一旦帧完成渲染(由关联的围栏对象确定),描述符集池就可以通过vkResetDescriptorPool重置并返回到空闲列表。虽然可以通过VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT,从池中释放单个描述符,但这会使驱动程序端的内存管理变得复杂,因此不建议这样做。

创建描述符集池时,应用程序指定从其分配的描述符集的最大数量,以及可以从其分配的每种类型的最大描述符的数量。在Vulkan1.1中,应用程序不必考虑这些限制-它只需调用vkAllocateDescriptorSets,并通过切换到新的描述符集池来处理该调用的错误。不幸的是,在没有任何扩展的Vulkan1.0中,如果池没有可用空间,调用vkAllocateDescriptorSets是错误的,因此应用程序必须跟踪每种类型的集合和描述符的数量,以便事先知道何时切换到不同的池。

不同的管道对象可能使用不同数量的描述符,这就提出了池配置的问题。一种简单的方法是使用相同的配置创建所有池,该配置对每种类型使用最差数量的描述符-例如,如果每组最多可以使用16个纹理描述符和8个缓冲区描述符,则可以分配maxSets=1024的所有池,池大小为16*1024用于纹理描述符,8*1024用于缓冲区描述符。此方法可以工作,但在实践中,它可能会导致具有不同描述符计数的着色器的内存浪费非常严重-您无法使用上述配置从池中分配超过1024个描述符集,因此,如果大多数管线对象使用4个纹理,将浪费75%的纹理描述符内存。

测量特征场景的每种类型的着色器管道中使用的描述符的平均数,并相应地分配池大小。例如,如果在给定场景中需要3,000个描述符集、13400个纹理描述符和1,700个缓冲区描述符,则每个集的平均描述符数为4.47个纹理(四舍五入为5)和0.57个缓冲区(四舍五入为1),因此池的合理配置为maxSets=1024,5*1024纹理描述符,1024个缓冲区描述符。当池超出给定类型的描述符时,我们会分配一个新的描述符-因此该方案可以保证正常工作,并且平均而言应该是相当有效的。

将着色器管线对象分组为SIZE类,近似描述符使用的常见模式,并使用适当的SIZE类拾取描述符集池。这是上述方案到多于一个大小类别的扩展。例如,通常在场景中有大量的阴影/深度预过程绘制调用和大量的常规绘制调用,但这两组所需的描述符的数量不同,使用动态缓冲区偏移时,阴影绘制调用通常需要每组0到1个纹理和0到1个缓冲区。为了优化内存使用,为阴影/深度和其他绘制调用分别分配描述符集池更为合适。与可以具有最适合给定应用程序的大小类的通用分配器类似,只要事先配置了特定于应用程序的描述符集用法,它仍然可以在较低级别的描述符集管理层中进行管理。

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

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

在一些硬件上,统一缓冲器和存储缓冲器之间的访问速度没有差别,但是对于取决于访问模式的其他硬件,统一缓冲器可以显著更快。首选不

.