减少 Vulkan® API 调用开销

首次发布:
Arseny Kapoulkine's avatar
Arseny Kapoulkine

与 OpenGL® 等其他 API 相比,Vulkan® 的设计旨在实现显著更小的 CPU 开销。这是通过多种方式实现的——API 的结构使其能够提前完成更多工作,例如一次性创建管道状态并多次绑定,而不是必须不断设置各种状态位;许多 API 调用每次调用的工作量更大,例如 `vkCmdBindVertexBuffers` 可以一次性绑定顶点着色器阶段使用的所有顶点缓冲区对象。然而,复杂的应用程序每帧仍然可能调用各种 Vulkan® 函数数万甚至数十万次。本文将探讨与之相关的成本,以及降低这些成本的方法。

加载器分派

默认情况下,Windows 上的应用程序链接到 `vulkan-1.dll`,API 调用会通过该 DLL 进行,其中包含 Vulkan® 加载器。虽然 SDK 提供了静态链接加载器(VKstatic.1.lib),但使用它可能会带来兼容性风险——如果加载 Vulkan 层/驱动的机制发生变化,旧的加载器代码未来可能无法正常工作。当然,将 `vulkan-1.dll` 与您的应用程序捆绑也是如此;最符合未来需求的做法似乎是依赖图形驱动程序安装到系统路径中的 `vulkan-1.dll`。加载器 (`vulkan-1.dll`) 导出了所有 Vulkan 函数;让我们看一下其中一个函数 `vkCmdDraw` 的源代码(位于 `trampoline.c`)

static inline VkLayerDispatchTable *loader_get_dispatch(const void *obj) {
return *((VkLayerDispatchTable **)obj);
}
LOADER_EXPORT VKAPI_ATTR void VKAPI_CALL vkCmdDraw(VkCommandBuffer commandBuffer,
uint32_t vertexCount, uint32_t instanceCount, uint32_t firstVertex, uint32_t firstInstance) {
const VkLayerDispatchTable *disp;
disp = loader_get_dispatch(commandBuffer);
disp->CmdDraw(commandBuffer, vertexCount, instanceCount, firstVertex, firstInstance);
}

每当您调用 Vulkan® 函数时,它都必须获取一个包含指向“实际”函数的指针的调度表——该函数通常位于图形驱动程序内部,或者如果启用了某个验证层,则位于验证层内部。指向该表的指针存储在可调度句柄指向的内存的开头——在本例中是 `VkCommandBuffer`。这允许您的代码在同一进程中加载了多个驱动程序/设备的情况下也能正常工作,看起来像是对虚拟函数调用的手动实现。这可能需要一定的成本,但成本有多高呢?

跟踪调用

让我们看看当您链接到 `vulkan-1.dll` 并调用 `VkCmdDraw` 时实际发生了什么!我们将检查 Vulkan® cube 演示的 Release 版本在 Windows® x86 上执行的指令(Windows® x64 版本的开销不那么显著,但仍可能降低几分之一的性能)。它始于应用程序调用 `vkCmdDraw`

vkCmdDraw(cmd_buf, 12 * 3, 1, 0, 0);
009937B7 6A 00 push 0
009937B9 6A 00 push 0
009937BB 6A 01 push 1
009937BD 6A 24 push 24h
009937BF 57 push edi
009937C0 E8 AB 46 00 00 call _vkCmdDraw@20 (0997E70h)

很简单——只需将所有参数推入堆栈并调用。我们调用的函数位于我们的可执行文件中,它只是一个用于实现 DLL 导入的跳板。

_vkCmdDraw@20:
00997E70 FF 25 24 92 99 00 jmp dword ptr [__imp__vkCmdDraw@20 (0999224h)]

该函数只是一个 `jmp` 指令,它跳转到从 DLL 导入表中加载的地址……

_vkCmdDraw@20:
50112800 E9 FB C5 03 00 jmp vkCmdDraw (5014EE00h)

该地址位于 `vulkan-1.dll` 内部,似乎指向另一个蹦床,后者最终跳转到我们已看到源代码的 `vkCmdDraw` 蹦床。然而,此函数的汇编代码却出人意料。

vkCmdDraw:
5014EE00 55 push ebp
5014EE01 8B EC mov ebp,esp
5014EE03 51 push ecx
5014EE04 A1 34 E0 1C 50 mov eax,dword ptr [__security_cookie (501CE034h)]
5014EE09 33 C5 xor eax,ebp
5014EE0B 89 45 FC mov dword ptr [ebp-4],eax
5014EE0E 8B 45 08 mov eax,dword ptr [commandBuffer]
5014EE11 56 push esi
5014EE12 FF 75 18 push dword ptr [firstInstance]
5014EE15 FF 75 14 push dword ptr [firstVertex]
5014EE18 8B 30 mov esi,dword ptr [eax]
5014EE1A FF 75 10 push dword ptr [instanceCount]
5014EE1D FF 75 0C push dword ptr [vertexCount]
5014EE20 8B B6 68 01 00 00 mov esi,dword ptr [esi+168h]
5014EE26 8B CE mov ecx,esi
5014EE28 50 push eax
5014EE29 FF 15 00 50 1D 50 call dword ptr [__guard_check_icall_fptr (501D5000h)]
5014EE2F FF D6 call esi
5014EE31 8B 4D FC mov ecx,dword ptr [ebp-4]
5014EE34 33 CD xor ecx,ebp
5014EE36 5E pop esi
5014EE37 E8 F4 A6 FC FF call @__security_check_cookie@4 (50119530h)
5014EE3C 8B E5 mov esp,ebp
5014EE3E 5D pop ebp
5014EE3F C2 14 00 ret 14h

请注意,除了重新排列堆栈上的参数外,此汇编序列还包含三个函数调用。第一个是 `__guard_check_icall_fptr`,它是 MSVC 编译器在启用控制流保护 (Control Flow Guard) 功能时(通过 `/guard:cf`)发出的。此功能会对间接函数调用进行插桩,并且每次调用都可以检查调用者指令是否预期能够调用目标函数,这可以防止覆盖函数指针并将其替换为不相关代码地址的漏洞利用。幸运的是,在我们的例子中,可执行文件本身是使用 CFG 编译的,这意味着 `__guard_check_icall_fptr` 指向 `_guard_check_icall_nop` 的蹦床。

_guard_check_icall_nop@4:
501198A0 E9 4B 11 04 00 jmp _guard_check_icall_nop (5015A9F0h)
_guard_check_icall_nop:
5015A9F0 C3 ret

因此,我们承担了间接 `call`、`jmp` 和 `ret` 的成本,但至少我们没有运行实际检查 CFG 表以验证函数调用的代码。原始 `vkCmdDraw` 蹦床中的第二个 `call` 指令是我们首先想要的——它调用驱动程序提供的 `vkCmdDraw` 实现(因为我们没有激活任何层)。不幸的是,驱动程序似乎还有一个蹦床,它看起来像另一个调度层,将 `__stdcall` 调用约定转换为 `__thiscall`;这是驱动程序特定的,并且可能会随着驱动程序更新而改变,或者根本不存在,但目前看来,对于所有三个供应商(NVIDIA、AMD、Intel)的 Windows 驱动程序都是如此。最后,对 `__security_check_cookie` 的第三次调用是在启用缓冲区安全检查(通过 `/GS`)时由 MSVC 编译器发出的;这可以在堆栈缓冲区溢出造成实际损坏并改变执行顺序之前捕获它们。该函数本身相对简短简单。

__security_check_cookie@4:
50119530 E9 5F 14 04 00 jmp __security_check_cookie (5015A994h)
__security_check_cookie:
5015A994 3B 0D 34 E0 1C 50 cmp ecx,dword ptr [__security_cookie (501CE034h)]
5015A99A F2 75 02 bnd jne failure (5015A99Fh)
5015A99D F2 C3 bnd ret

如您所见,我们本希望简单地调用驱动程序中的 `vkCmdDraw` 实现,但却不得不经过多层蹦床、跳板和安全基础设施调用。虽然所有这些的总成本并非灾难性,但它会累加起来,导致可衡量的开销。

获取函数指针以进行直接调用

幸运的是,设备分派的成本已在 Vulkan API 的设计中考虑在内;您可以通过调用 `vkGetDeviceProcAddr` 来获取执行*实际*工作的函数的指针。

PFN_vkCmdDraw CmdDraw = (PFN_vkCmdDraw)vkGetDeviceProcAddr(demo->device, "vkCmdDraw");
000637B7 68 C8 9E 06 00 push offset string "vkCmdDraw" (069EC8h)
000637BC FF B6 A4 00 00 00 push dword ptr [esi+0A4h]
000637C2 E8 57 45 00 00 call _vkGetDeviceProcAddr@8 (067D1Eh)
CmdDraw(cmd_buf, 12 * 3, 1, 0, 0);
000637C7 6A 00 push 0
000637C9 6A 00 push 0
000637CB 6A 01 push 1
000637CD 6A 24 push 24h
000637CF 57 push edi
000637D0 FF D0 call eax

当然,您希望只使用一次 `vkGetDeviceProcAddr` 并缓存结果;对结果函数指针的所有调用将指向第一个启用的层(如果存在),否则将指向驱动程序,并绕过所有与 DLL 跳板等相关的开销。如果您的应用程序只使用一个设备或设备组,您可以简单地使用全局函数指针来存储 `vkGetDeviceProcAddr` 的结果;如果您需要支持多个实例或设备,您需要将函数指针存储在一个结构体中,并且您在渲染代码中方便访问的每个设备都有一个该结构体的实例。使用设备函数指针获得的性能优势取决于您定位的平台、驱动程序/应用程序开销以及 Vulkan 调用的数量;对于典型的 Vulkan 应用程序,它可能在 1-5% 之间。这可能看起来微不足道,但积少成多;获得良好性能的技巧是让您的代码一次提高百分之一。

使用 volk 获取函数指针

Vulkan® API 包含许多可以从这种优化中受益的函数,虽然您可以手动加载您需要的函数,但从 `vk.xml`(这是从 `vulkan.h` 生成的 XML 文件)自动生成它们似乎是个好主意。除了生成加载设备函数指针的代码外,您可能还想加载其他函数的指针(使用 `vkGetInstanceProcAddr`)。这使您可以删除对 `vulkan-1.dll` 的静态依赖,从而更容易处理 Vulkan 加载器缺失的问题,方法是切换到其他渲染 API 或向用户提供更友好的错误消息。对于这两者,您都可以使用 volk,它是一个 Vulkan 的 MIT 许可的元加载器(类似于 OpenGL 的 GLEW)。它被设计为即插即用的头文件/源文件,适用于使用 Vulkan 的项目。该库会自动查找实际的 Vulkan 加载器并从中加载所有函数;它还可以通过 `vkGetDeviceProcAddr` 加载设备函数以实现更快的分派。要使用它,请将 `volk.c` 添加到您的项目中,并将所有 `#include ` 行替换为 `#include`(假设您已将 volk 文件夹添加到您的头文件搜索路径)。然后,在调用任何 Vulkan API(包括实例创建)之前调用以下函数对其进行初始化:

VkResult result = volkInitialize();

如果返回的结果不是 `VK_SUCCESS`,则表示您的系统上没有 Vulkan。如果调用成功,请继续按通常方式创建 Vulkan 实例,然后加载所有剩余的函数:

volkLoadInstance(instance);

最后,在创建设备后,您可以通过以下方式将全局函数指针替换为从 `vkGetDeviceProcAddr` 检索的函数:

volkLoadDevice(demo->device);

或者将函数指针加载到函数指针表中以进行直接调用,如下所示:

VolkDeviceTable table;
volkLoadDeviceTable(&table, device);

然后使用表中的函数代替

table.vkCmdDraw(cmd_buf, 12 * 3, 1, 0, 0);

第一种方法可以快速获得收益,而无需更改代码,但不适合想要通过创建多个 `VkDevice` 对象来使用显式多 GPU 的应用程序。请注意,为避免符号冲突,您必须确保应用程序中的所有翻译单元都包含 `volk.h` 而不是 `vulkan.h`,或者在项目范围内定义 `VK_NO_PROTOTYPES` 以确保您不会意外拾取实际 Vulkan® 加载器的符号。

更多 Vulkan® 内容

Arseny Kapoulkine's avatar

Arseny Kapoulkine

Arseny Kapoulkine 在游戏技术领域工作了十年。他曾在渲染、物理模拟、语言运行时、多线程等多个领域工作过,至今仍在游戏开发中发现需要低级思维的激动人心的挑战。在帮助发布多款 PS3 游戏(包括数款 FIFA 游戏)后,他于 2012 年加入 Roblox,并一直致力于内部引擎的开发,帮助年轻的游戏开发者实现他们的梦想。
© . This site is unofficial and not affiliated with AMD.