本网站内容使用人工智能(AI)或机器翻译技术翻译,可能存在错误。

Skip to content

使用 Vulkan 构建强大的管道缓存

在构建 Vulkan 渲染器时,需要学习许多新概念。其中有些比其他概念更容易掌握,而管道缓存便是相对直观的新增功能之一。为了确保管道创建尽可能高效,您需要创建一个管道缓存,并在每次需要创建新管道时使用它。 为了确保应用程序后续运行时无需花费时间反复编译着色器微代码,你需要将管道缓存数据保存到文件中,并在下次应用程序启动时加载它。这能有多难?

事实证明,这其实相当困难。

管道缓存包含什么

管道缓存数据是一个(基本上)不透明的数据块;您创建一个 VkPipelineCache 对象,可能需要提供初始数据块作为起点,随后在某个时刻可以从该对象中检索数据块。

虽然除了阅读图形驱动程序源代码外,我们对该数据块的内容知之甚少,¹ 但可以确定的是,管道缓存数据必定以一个标识设备的结构体开头,其形式大致如下:

struct VkPipelineCacheHeaderOne<br>
{
 uint32_t length; // == sizeof(VkPipelineCacheHeaderOne)
 uint32_t version; // == VK_PIPELINE_CACHE_HEADER_VERSION_ONE
 uint32_t vendorID;
 uint32_t deviceID;
 uint8_t uuid[VK_UUID_SIZE];
}; 

头部之后是驱动程序特有的信息,通常包含着色器微代码片段(其格式取决于 GPU)以及可能包含任意驱动程序定义结构的辅助数据。 有些驱动程序将该数据块视为结构化文件流并从中读取数据,有些驱动程序则将驱动程序源代码中定义的原始结构存储在该数据块中,并使用memcpy或指针强制转换来遍历数据;毋庸置疑,驱动程序更新可能会导致数据存储方式失效。

理论上,应用程序只需在达到稳态后(例如在应用程序退出之前……)使用 `vkGetPipelineCacheData` 检索数据块,将该块保存到文件中,然后在下一次运行创建管道缓存时通过 `VkPipelineCacheCreateInfo::pInitialData` 传递该块。 如果数据块的内容不适用于当前版本的驱动程序——可能是驱动程序已更新,也可能是用户切换到了不同的 GPU——驱动程序理应忽略初始数据并创建一个空的管道缓存。

但理论与实践存在些许差异。实践中的经验法则是:驱动程序只能正确处理此前由完全相同的驱动程序提供给应用程序的那个确切二进制数据块,而问题正由此开始。2

驱动程序是否相同?

规范假设不同设备之间的缓存不兼容(这也是为什么头文件中包含 vendorID 和 deviceID),并依赖驱动程序建立一个管道 UUID(即 16 字节的 GUID),该 UUID 能准确标识出所有导致能够解析管道缓存 blob 的因素——你可以将其视为管道缓存格式的版本号。 例如,在驱动程序升级过程中,可能出现管道缓存格式更新的情况,此时 UUID 通常不应改变,应用程序也不需要从头重新编译着色器。

然而,实际环境中的驱动程序往往会出现两种类型的问题。

某些(较旧的)驱动程序未能正确验证 UUID。因此,在驱动程序更新期间,应用程序可能会尝试将带有过期 UUID 的二进制数据块传递给驱动程序,驱动程序会将其解释为最新数据,结果可能导致 `vkCreatePipelineCache` 崩溃。请注意,通常 `vkCreatePipelineCache` 并不保证接受任意数据并能干净地处理它。

某些驱动程序(包括相当新近的版本)在驱动更新时可能忽略更新 UUID,这实际上会破坏着色器管道二进制文件的兼容性。这种情况可能发生在驱动版本更新时(尽管较为罕见),或者(至少在某家主要厂商的当前驱动中经常发生)发生在针对不同 ABI 构建的、基于同一版本的驱动二进制文件之间。 如果同一系统上发布的 32 位驱动程序和 64 位驱动程序具有相同的管道 UUID,那么从 32 位版本的应用程序中保存缓存并将其加载到 64 位版本中可能会导致驱动程序崩溃——这正是当你发布 32 位版本的应用程序,然后按照 Google 的指南将其更新为 64 位时发生的情况。

数据是否相同?

既然我们已经了解了头部验证会面临什么情况,接下来就是验证数据。在调用vkGetPipelineCacheData之后,应用程序会保存该二进制数据块,并在下次运行时加载完全相同的二进制数据块。

事实证明,将数据保存到文件中基本上无法做到完美。文件系统问题以及进程稳定性问题在某些情况下可能会导致文件被部分写入、末尾填充零(甚至垃圾数据),或者(作为特例)文件被创建但始终为零大小。 在移动端,情况会更加复杂,因为应用程序很可能在任意时刻被用户或操作系统突然终止,而这种情况在桌面端则较少发生。在 Android 上,使用多进程(多活动)应用程序也很常见,如果您的管道缓存代码在两个进程中运行且共享同一个输出文件,这些挑战将变得更加难以解决。

零大小文件之所以特别值得关注,是因为我们曾遇到至少一个驱动程序版本,在创建管道缓存时,若传入非空的pInitialDatainitialDataSize == 0参数,系统会返回错误。这也引出了最后一个注意事项。

错误处理很棘手

尽管规范规定vkCreatePipelineCache在内存未耗尽的情况下基本应始终成功,但规范中的此类陈述往往并不准确。在创建管道缓存时,驱动程序理应忽略不兼容的初始数据。这种情况可能发生在数据为零大小时、存储的UUID与预期UUID不匹配时,或因其他原因导致反序列化失败时。而某些驱动程序却会直接导致管道缓存创建失败。

这绝非用户的过错,因此终止应用程序并不妥当。虽然通常可以在没有管道缓存的情况下继续运行,但这通常是个糟糕的主意,因为这意味着每个管道都必须从头重新编译。也就是说,即使管道缓存未序列化到磁盘,它们仍有其价值,因为它们允许驱动程序在内存中缓存跨管道对象的编译结果。

这一切自然引出了……

如果他们真的想对付你,那就不算多疑

……解决方案。在将管道缓存数据序列化到文件时,我们会使用一个包含足够验证信息的首部,管道缓存数据紧随其后:

  struct PipelineCachePrefixHeader<br> 
{<br> 
  uint32_t magic; // an arbitrary magic header to make sure this is actually our file<br> 
  uint32_t dataSize; // equal to *pDataSize returned by vkGetPipelineCacheData<br> 
  uint64_t dataHash; // a hash of pipeline cache data, including the header<br> 
  uint32_t vendorID; // equal to VkPhysicalDeviceProperties::vendorID<br> 
  uint32_t deviceID; // equal to VkPhysicalDeviceProperties::deviceID<br> 
  uint32_t driverVersion; // equal to VkPhysicalDeviceProperties::driverVersion<br> 
  uint32_t driverABI; // equal to sizeof(void*)<br> 
  uint8_t uuid[VK_UUID_SIZE]; // equal to VkPhysicalDeviceProperties::pipelineCacheUUID<br> 
};

管道缓存数据的哈希值将使我们能够验证数据的完整性。为了降低 I/O 错误实际导致数据完整性问题的可能性,我们会创建一个临时文件,将该头部写入文件并紧接着写入管道缓存数据,然后使用 rename 命令将文件移动到目标位置。3

在加载管道缓存时,我们会读取头部信息,读取数据,利用 dataSize 和 dataHash 验证所读取的数据,随后通过将剩余字段与设备属性进行比对,确认数据可安全地传递给驱动程序。4

如果数据有效,则调用vkCreatePipelineCache并传入正确的初始数据。关键在于,如果此调用失败,则表明驱动程序实现了我们的逻辑无法自行检测到的额外检查。因此,在这种情况下,我们不会在没有管道缓存的情况下继续操作,而是通过再次调用vkCreatePipelineCache(不带初始数据)来创建一个空的管道缓存。

此外,若未找到管道缓存文件,或我们的验证逻辑判定数据不可用,我们也会创建空的管道缓存。

注意:由于我们在头文件中纳入了 driverVersion,任何驱动程序更新都会导致管道缓存被重建;我们加入此检查是因为这能彻底消除管道缓存 UUID 未按预期更新的问题——通常 driverVersion 会在构建过程中自动更新,而 UUID 的更新则更多是手动操作。 对于仅针对桌面的应用程序,这可能过于激进——通常桌面驱动程序在处理管道缓存有效性方面表现得更为规范,因此并非所有建议都适用。

结论

遗憾的是,Vulkan 驱动程序并不总是正确的,也并非总是严格遵循规范。管道缓存数据是 Vulkan 渲染器中尤为脆弱的部分,因为 I/O 操作很难做到完全正确,且驱动程序中通常几乎没有或根本没有完整性检查。不过,通过充分的应用程序端验证,实际上可以消除因管道缓存处理而导致的稳定性问题——只是需要付出一些努力。

  1. 如今这完全可行!例如,这里是 radv 实现的 vkGetPipelineCacheData 函数。↩
  2. 本文剩余部分基于持续在 Android 平台发布支持 Vulkan 的 Roblox 客户端的经验,涵盖了应对各种 Android 系统更新、驱动程序更新,以及处理所有主要供应商的早期和当前 Vulkan 驱动程序的实践。↩
  3. 理论上重命名应是原子操作,但在实践中,其确切语义和保证因文件系统而异;哈希值可作为一种进行稳健比较的有效手段。↩
  4. 根据具体应用场景,您可能还希望基于供应商ID(vendorID)或驱动程序ABI等参数使用不同的文件名;这种做法在桌面端更为常见,而在移动端则较少采用。↩

原文发布于:https://zeux.io/2019/07/17/serializing-pipeline-cache/

Arseny Kapoulkine 过去十年一直致力于游戏技术研发。他在渲染、物理模拟、语言运行时、多线程等众多领域都有所涉猎,至今仍在游戏开发中不断发现那些需要低级思维来解决的激动人心的问题。在协助推出多款 PS3 游戏(包括数款《FIFA》系列作品)后,他于 2012 年加入 Roblox,此后一直致力于内部引擎的开发,帮助年轻的游戏开发者实现他们的梦想。

Roblox 公司及本博客均不认可或支持任何公司或服务。此外,对于本博客所含信息的准确性、可靠性或完整性,我们不作任何保证或承诺。

©2021 Roblox Corporation。Roblox、Roblox 标志及“Powering Imagination”均为我们在美国及其他国家/地区的注册及未注册商标。