Fix Vulkan capture perf collapse: read back off the present thread
Against Sphere Spectacle (144 FPS, runs without Steam) the implicit-layer
capture dropped the game to ~3 FPS. Measured cause (per-stage trace in the
layer): the read-back ran on the game's PRESENT THREAD and spent ~370 ms per
1080p frame -- not the GPU copy (~2 ms) but the CPU swizzle, because the staging
buffer was a plain HOST_VISIBLE|HOST_COHERENT type (write-combined / uncached on
a discrete GPU), where a scattered CPU read runs at PCIe latency. 3 captures/s =
the 3 FPS the user saw.
Test-first: tests/vk_capture_perf_test reproduces the stall as a deterministic
unit test (372 ms/present, ratio 1.0 -> FAIL via `--sync`), then proves the fix
(0.02 ms/present, byte-correct BGRA->RGBA, ratio ~0 -> PASS).
Fix: extract the near-identical read-back from vk_hook.cpp and coop_vk_layer.cpp
into one shared coop::hook::VkCapture that:
* has the present thread only record + submit the copy (sub-ms) and return;
* runs a dedicated reaper thread for the fence wait + swizzle + D3D upload, off
the critical path, with a ring of in-flight slots (game never waits);
* allocates HOST_CACHED staging (fast CPU read), invalidating when non-coherent;
* throttles capture to ~150 Hz (a guest stream is <= the host refresh; no point
mirroring an uncapped 400+ FPS game and burning reaper CPU).
Real-game A/B: present rate now matches the no-capture baseline (605->470 vs
593->405 over the same ramp) with the mirror at ~130 fps -- no measurable impact.
Also adds present-thread overhead guards to the other GPU backends' hook tests
(present_overhead.hpp): DX11 0.05 ms, DX12 0.34 ms, OpenGL 0.09 ms overhead, all
asserted < one 60 Hz frame, so any future synchronous-stall regression fails.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -232,6 +232,20 @@ target_link_libraries(opengl_hook_test PRIVATE
|
||||
|
||||
add_test(NAME opengl_hook_test COMMAND opengl_hook_test)
|
||||
|
||||
# Reproducer + regression guard for the Vulkan capture performance collapse (the ~370 ms/frame
|
||||
# present-thread stall that dropped a 144 FPS game to ~3 FPS). Reuses the shipping VkCapture; builds
|
||||
# a known image on a real device and asserts the fixed present-thread cost is a small fraction of a
|
||||
# synchronous read-back (and the captured image is byte-correct). `--sync` re-demonstrates the
|
||||
# regression (the assertion fails on the synchronous design). Skips without a Vulkan ICD.
|
||||
add_executable(vk_capture_perf_test
|
||||
vk_capture_perf_test.cpp
|
||||
${CMAKE_SOURCE_DIR}/hook/src/vk_capture.cpp)
|
||||
target_include_directories(vk_capture_perf_test PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/hook/src
|
||||
${CMAKE_SOURCE_DIR}/third_party/Vulkan-Headers/include)
|
||||
target_link_libraries(vk_capture_perf_test PRIVATE coop_common d3d11 dxgi)
|
||||
add_test(NAME vk_capture_perf_test COMMAND vk_capture_perf_test)
|
||||
|
||||
# Headless UI-fit check (M1): drives the real Controllers + Audio panels at reference
|
||||
# resolutions with Debug details on and the richest content, and asserts no panel overflows
|
||||
# its assigned size. Pure ImGui layout (no GPU / window). The Audio panel renders in its
|
||||
@@ -278,4 +292,5 @@ coop_output_subdir(tests
|
||||
opengl_hook_test
|
||||
mock_game_test
|
||||
audio_verify_test
|
||||
vk_capture_perf_test
|
||||
ui_fit_test)
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#include "coop/shared_memory.hpp"
|
||||
#include "ipc_client.hpp"
|
||||
#include "present_hook.hpp"
|
||||
#include "present_overhead.hpp"
|
||||
|
||||
using namespace coop;
|
||||
|
||||
@@ -265,6 +266,45 @@ int main()
|
||||
}
|
||||
release(ctxB);
|
||||
release(devB);
|
||||
|
||||
// --- Performance regression guard: the D3D11On12 bridge + copy the hook does inside Present
|
||||
// must stay off the present thread (measure a full frame with the hook live vs. removed). ---
|
||||
auto frame = [&] {
|
||||
const UINT idx = sc3->GetCurrentBackBufferIndex();
|
||||
allocator->Reset();
|
||||
cmdlist->Reset(allocator, nullptr);
|
||||
D3D12_RESOURCE_BARRIER b{};
|
||||
b.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
|
||||
b.Transition.pResource = render_targets[idx];
|
||||
b.Transition.Subresource = D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES;
|
||||
b.Transition.StateBefore = D3D12_RESOURCE_STATE_PRESENT;
|
||||
b.Transition.StateAfter = D3D12_RESOURCE_STATE_RENDER_TARGET;
|
||||
cmdlist->ResourceBarrier(1, &b);
|
||||
D3D12_CPU_DESCRIPTOR_HANDLE rtv = rtv_heap->GetCPUDescriptorHandleForHeapStart();
|
||||
rtv.ptr += static_cast<SIZE_T>(idx) * rtv_size;
|
||||
cmdlist->ClearRenderTargetView(rtv, kClear, 0, nullptr);
|
||||
std::swap(b.Transition.StateBefore, b.Transition.StateAfter);
|
||||
cmdlist->ResourceBarrier(1, &b);
|
||||
cmdlist->Close();
|
||||
ID3D12CommandList* lists[] = {cmdlist};
|
||||
queue->ExecuteCommandLists(1, lists);
|
||||
};
|
||||
auto present = [&] {
|
||||
swapchain->Present(0, 0);
|
||||
queue->Signal(fence, ++fence_value);
|
||||
if (fence->GetCompletedValue() < fence_value)
|
||||
{
|
||||
fence->SetEventOnCompletion(fence_value, fence_event);
|
||||
WaitForSingleObject(fence_event, 1000);
|
||||
}
|
||||
};
|
||||
const double hooked = cooptest::avg_present_ms(60, frame, present);
|
||||
hook::remove_present_hooks(); // baseline: same swapchain, hook removed
|
||||
const double base = cooptest::avg_present_ms(60, frame, present);
|
||||
std::printf("present-thread: hooked %.3f ms, unhooked %.3f ms, capture overhead %.3f ms\n", hooked, base,
|
||||
hooked - base);
|
||||
check(hooked - base < cooptest::kPresentOverheadBudgetMs,
|
||||
"D3D12 present hook stays off the present thread (overhead < one 60 Hz frame)");
|
||||
}
|
||||
|
||||
hook::remove_present_hooks();
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
#include "coop/shared_memory.hpp"
|
||||
#include "ipc_client.hpp"
|
||||
#include "opengl_hook.hpp"
|
||||
#include "present_overhead.hpp"
|
||||
|
||||
using namespace coop;
|
||||
|
||||
@@ -191,12 +192,30 @@ int main()
|
||||
release(devB);
|
||||
}
|
||||
|
||||
// --- Performance regression guard: the SwapBuffers hook's glReadPixels read-back must not stall
|
||||
// the present thread (measure SwapBuffers with the hook live vs. removed). ---
|
||||
{
|
||||
auto render = [&] {
|
||||
glViewport(0, 0, kW, kH);
|
||||
glClearColor(0.20f, 0.40f, 0.60f, 1.0f);
|
||||
glClear(GL_COLOR_BUFFER_BIT);
|
||||
glFinish();
|
||||
};
|
||||
auto present = [&] { SwapBuffers(hdc); };
|
||||
const double hooked = cooptest::avg_present_ms(120, render, present);
|
||||
hook::remove_opengl_hooks(); // baseline: same context, hook removed
|
||||
const double base = cooptest::avg_present_ms(120, render, present);
|
||||
std::printf("present-thread: hooked %.3f ms, unhooked %.3f ms, capture overhead %.3f ms\n", hooked, base,
|
||||
hooked - base);
|
||||
check(hooked - base < cooptest::kPresentOverheadBudgetMs,
|
||||
"OpenGL capture stays off the present thread (overhead < one 60 Hz frame)");
|
||||
}
|
||||
|
||||
wglMakeCurrent(nullptr, nullptr);
|
||||
wglDeleteContext(glrc);
|
||||
ReleaseDC(hwnd, hdc);
|
||||
DestroyWindow(hwnd);
|
||||
UnregisterClassW(wc.lpszClassName, wc.hInstance);
|
||||
hook::remove_opengl_hooks();
|
||||
|
||||
std::printf(g_failures == 0 ? "OPENGL HOOK TEST PASS\n" : "OPENGL HOOK TEST FAILED (%d)\n", g_failures);
|
||||
return g_failures == 0 ? 0 : 1;
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
#include "coop/shared_memory.hpp"
|
||||
#include "ipc_client.hpp"
|
||||
#include "present_hook.hpp"
|
||||
#include "present_overhead.hpp"
|
||||
|
||||
using namespace coop;
|
||||
|
||||
@@ -216,12 +217,38 @@ int main()
|
||||
release(devB);
|
||||
}
|
||||
|
||||
// --- Performance regression guard: the Present hook's backbuffer copy must stay off the present
|
||||
// thread's critical path (measure the present cost with the hook live vs. removed). ---
|
||||
{
|
||||
auto render = [&] {
|
||||
ID3D11Texture2D* back = nullptr;
|
||||
if (SUCCEEDED(swapchain->GetBuffer(0, __uuidof(ID3D11Texture2D), reinterpret_cast<void**>(&back))))
|
||||
{
|
||||
ID3D11RenderTargetView* rtv = nullptr;
|
||||
if (SUCCEEDED(device->CreateRenderTargetView(back, nullptr, &rtv)))
|
||||
{
|
||||
ctx->ClearRenderTargetView(rtv, kClear);
|
||||
ctx->Flush();
|
||||
rtv->Release();
|
||||
}
|
||||
back->Release();
|
||||
}
|
||||
};
|
||||
auto present = [&] { swapchain->Present(0, 0); };
|
||||
const double hooked = cooptest::avg_present_ms(120, render, present);
|
||||
hook::remove_present_hooks(); // baseline: same swapchain, hook removed
|
||||
const double base = cooptest::avg_present_ms(120, render, present);
|
||||
std::printf("present-thread: hooked %.3f ms, unhooked %.3f ms, capture overhead %.3f ms\n", hooked, base,
|
||||
hooked - base);
|
||||
check(hooked - base < cooptest::kPresentOverheadBudgetMs,
|
||||
"Present-hook capture stays off the present thread (overhead < one 60 Hz frame)");
|
||||
}
|
||||
|
||||
release(swapchain);
|
||||
release(ctx);
|
||||
release(device);
|
||||
DestroyWindow(hwnd);
|
||||
UnregisterClassW(wc.lpszClassName, wc.hInstance);
|
||||
hook::remove_present_hooks();
|
||||
|
||||
std::printf(g_failures == 0 ? "PRESENT HOOK TEST PASS\n" : "PRESENT HOOK TEST FAILED (%d)\n", g_failures);
|
||||
return g_failures == 0 ? 0 : 1;
|
||||
|
||||
45
tests/present_overhead.hpp
Normal file
45
tests/present_overhead.hpp
Normal file
@@ -0,0 +1,45 @@
|
||||
// Shared helper for the per-backend present-thread overhead guards.
|
||||
//
|
||||
// The capture path (Present / SwapBuffers hook, or the Vulkan read-back) runs on the game's present
|
||||
// thread. If it stalls there, it caps the game's frame rate -- the Vulkan layer did exactly this,
|
||||
// dropping a 144 FPS game to ~3 FPS by spending ~370 ms per present reading write-combined memory.
|
||||
// Each GPU backend's in-process hook test measures the wall time its present spends with the hook
|
||||
// live vs. removed and asserts the added cost stays under one display frame, so a future regression
|
||||
// that puts a synchronous read-back / stall back on the present thread fails the test.
|
||||
#pragma once
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
namespace cooptest
|
||||
{
|
||||
inline double now_ms()
|
||||
{
|
||||
LARGE_INTEGER f, c;
|
||||
QueryPerformanceFrequency(&f);
|
||||
QueryPerformanceCounter(&c);
|
||||
return 1000.0 * static_cast<double>(c.QuadPart) / static_cast<double>(f.QuadPart);
|
||||
}
|
||||
|
||||
// Average wall time of `present()` over n frames, calling `render()` (untimed) before each so a
|
||||
// fresh frame is produced. Returns milliseconds per present.
|
||||
template <class RenderFn, class PresentFn>
|
||||
double avg_present_ms(int n, RenderFn render, PresentFn present)
|
||||
{
|
||||
render();
|
||||
present(); // warm (first present/resource setup)
|
||||
double total = 0;
|
||||
for (int i = 0; i < n; ++i)
|
||||
{
|
||||
render();
|
||||
const double a = now_ms();
|
||||
present();
|
||||
total += now_ms() - a;
|
||||
}
|
||||
return n > 0 ? total / n : 0.0;
|
||||
}
|
||||
|
||||
// One 60 Hz display frame. The capture's added present-thread cost must stay well under this or it
|
||||
// throttles the game; the Vulkan bug added ~370 ms (22x over budget). Generous on purpose -- the
|
||||
// guard targets the catastrophic-stall class, not micro-overhead, so it never flakes on jitter.
|
||||
inline constexpr double kPresentOverheadBudgetMs = 16.7;
|
||||
} // namespace cooptest
|
||||
552
tests/vk_capture_perf_test.cpp
Normal file
552
tests/vk_capture_perf_test.cpp
Normal file
@@ -0,0 +1,552 @@
|
||||
// Reproducer + regression guard for the Vulkan capture performance collapse.
|
||||
//
|
||||
// Against a real 144 FPS game (Sphere Spectacle) the implicit-layer / inline-hook capture dropped
|
||||
// the game to ~3 FPS. Measured cause: the read-back ran on the game's PRESENT THREAD and spent
|
||||
// ~370 ms per 1080p frame reading the mapped staging buffer -- because the staging memory was a
|
||||
// plain HOST_VISIBLE|HOST_COHERENT type (write-combined / uncached on a discrete GPU), where a
|
||||
// scattered CPU read runs at PCIe latency.
|
||||
//
|
||||
// This test builds a known gradient image (in PRESENT_SRC layout) on a real device and measures the
|
||||
// time the *present thread* spends per capture for two implementations:
|
||||
// * a synchronous reference that mirrors the OLD code (copy + WaitForFences + read the
|
||||
// HOST_COHERENT mapping + swizzle, all inline) -> reproduces the stall, and
|
||||
// * coop::hook::VkCapture (the fix: present thread only records+submits; a reaper thread does the
|
||||
// HOST_CACHED read-back + swizzle + upload off the critical path).
|
||||
// It asserts the fixed present-thread cost is a small fraction of the synchronous cost, and that the
|
||||
// captured image is byte-correct (BGRA->RGBA swizzle). `--sync` routes the measured path through the
|
||||
// synchronous reference so the same assertion FAILS, demonstrating the test catches the regression.
|
||||
//
|
||||
// Needs a working Vulkan ICD (the dev box has one). With no vulkan-1.dll / no device it SKIPs.
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#define VK_NO_PROTOTYPES
|
||||
#include <vulkan/vulkan.h>
|
||||
|
||||
#include "vk_capture.hpp"
|
||||
|
||||
namespace
|
||||
{
|
||||
double now_ms()
|
||||
{
|
||||
LARGE_INTEGER f, c;
|
||||
QueryPerformanceFrequency(&f);
|
||||
QueryPerformanceCounter(&c);
|
||||
return 1000.0 * static_cast<double>(c.QuadPart) / static_cast<double>(f.QuadPart);
|
||||
}
|
||||
|
||||
int g_failures = 0;
|
||||
void check(bool ok, const char* what)
|
||||
{
|
||||
std::printf("%s %s\n", ok ? " ok:" : "FAIL:", what);
|
||||
if (!ok)
|
||||
{
|
||||
++g_failures;
|
||||
}
|
||||
}
|
||||
|
||||
// Everything the test resolves from the device (superset of VkCapture::Fns + image-build helpers).
|
||||
struct DevFns
|
||||
{
|
||||
PFN_vkGetDeviceProcAddr GetDeviceProcAddr;
|
||||
PFN_vkGetDeviceQueue GetDeviceQueue;
|
||||
PFN_vkCreateCommandPool CreateCommandPool;
|
||||
PFN_vkDestroyCommandPool DestroyCommandPool;
|
||||
PFN_vkAllocateCommandBuffers AllocateCommandBuffers;
|
||||
PFN_vkBeginCommandBuffer BeginCommandBuffer;
|
||||
PFN_vkEndCommandBuffer EndCommandBuffer;
|
||||
PFN_vkResetCommandBuffer ResetCommandBuffer;
|
||||
PFN_vkCmdPipelineBarrier CmdPipelineBarrier;
|
||||
PFN_vkCmdCopyImageToBuffer CmdCopyImageToBuffer;
|
||||
PFN_vkCmdCopyBufferToImage CmdCopyBufferToImage;
|
||||
PFN_vkQueueSubmit QueueSubmit;
|
||||
PFN_vkQueueWaitIdle QueueWaitIdle;
|
||||
PFN_vkCreateFence CreateFence;
|
||||
PFN_vkDestroyFence DestroyFence;
|
||||
PFN_vkWaitForFences WaitForFences;
|
||||
PFN_vkResetFences ResetFences;
|
||||
PFN_vkGetFenceStatus GetFenceStatus;
|
||||
PFN_vkCreateSemaphore CreateSemaphore;
|
||||
PFN_vkDestroySemaphore DestroySemaphore;
|
||||
PFN_vkCreateBuffer CreateBuffer;
|
||||
PFN_vkDestroyBuffer DestroyBuffer;
|
||||
PFN_vkGetBufferMemoryRequirements GetBufferMemoryRequirements;
|
||||
PFN_vkCreateImage CreateImage;
|
||||
PFN_vkDestroyImage DestroyImage;
|
||||
PFN_vkGetImageMemoryRequirements GetImageMemoryRequirements;
|
||||
PFN_vkAllocateMemory AllocateMemory;
|
||||
PFN_vkFreeMemory FreeMemory;
|
||||
PFN_vkBindBufferMemory BindBufferMemory;
|
||||
PFN_vkBindImageMemory BindImageMemory;
|
||||
PFN_vkMapMemory MapMemory;
|
||||
PFN_vkUnmapMemory UnmapMemory;
|
||||
PFN_vkInvalidateMappedMemoryRanges InvalidateMappedMemoryRanges;
|
||||
PFN_vkDeviceWaitIdle DeviceWaitIdle;
|
||||
PFN_vkGetPhysicalDeviceMemoryProperties GetPhysicalDeviceMemoryProperties;
|
||||
};
|
||||
|
||||
VkPhysicalDeviceMemoryProperties g_memprops{};
|
||||
|
||||
bool find_mem(std::uint32_t type_bits, VkMemoryPropertyFlags want, std::uint32_t& out)
|
||||
{
|
||||
for (std::uint32_t i = 0; i < g_memprops.memoryTypeCount; ++i)
|
||||
{
|
||||
if ((type_bits & (1u << i)) && (g_memprops.memoryTypes[i].propertyFlags & want) == want)
|
||||
{
|
||||
out = i;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
coop::hook::VkCapture::Fns capture_fns(const DevFns& d)
|
||||
{
|
||||
coop::hook::VkCapture::Fns f{};
|
||||
f.GetDeviceQueue = d.GetDeviceQueue;
|
||||
f.CreateCommandPool = d.CreateCommandPool;
|
||||
f.DestroyCommandPool = d.DestroyCommandPool;
|
||||
f.AllocateCommandBuffers = d.AllocateCommandBuffers;
|
||||
f.BeginCommandBuffer = d.BeginCommandBuffer;
|
||||
f.EndCommandBuffer = d.EndCommandBuffer;
|
||||
f.ResetCommandBuffer = d.ResetCommandBuffer;
|
||||
f.CmdPipelineBarrier = d.CmdPipelineBarrier;
|
||||
f.CmdCopyImageToBuffer = d.CmdCopyImageToBuffer;
|
||||
f.QueueSubmit = d.QueueSubmit;
|
||||
f.CreateFence = d.CreateFence;
|
||||
f.DestroyFence = d.DestroyFence;
|
||||
f.WaitForFences = d.WaitForFences;
|
||||
f.ResetFences = d.ResetFences;
|
||||
f.GetFenceStatus = d.GetFenceStatus;
|
||||
f.CreateSemaphore = d.CreateSemaphore;
|
||||
f.DestroySemaphore = d.DestroySemaphore;
|
||||
f.CreateBuffer = d.CreateBuffer;
|
||||
f.DestroyBuffer = d.DestroyBuffer;
|
||||
f.GetBufferMemoryRequirements = d.GetBufferMemoryRequirements;
|
||||
f.AllocateMemory = d.AllocateMemory;
|
||||
f.FreeMemory = d.FreeMemory;
|
||||
f.BindBufferMemory = d.BindBufferMemory;
|
||||
f.MapMemory = d.MapMemory;
|
||||
f.UnmapMemory = d.UnmapMemory;
|
||||
f.InvalidateMappedMemoryRanges = d.InvalidateMappedMemoryRanges;
|
||||
f.DeviceWaitIdle = d.DeviceWaitIdle;
|
||||
f.GetPhysicalDeviceMemoryProperties = d.GetPhysicalDeviceMemoryProperties;
|
||||
return f;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
const bool sync_repro = argc > 1 && std::strcmp(argv[1], "--sync") == 0;
|
||||
const std::uint32_t W = 1920, H = 1080; // the resolution where the stall was measured
|
||||
|
||||
HMODULE vk = LoadLibraryW(L"vulkan-1.dll");
|
||||
if (vk == nullptr)
|
||||
{
|
||||
std::printf("SKIP vk_capture_perf_test (no vulkan-1.dll)\n");
|
||||
return 0;
|
||||
}
|
||||
auto gipa = reinterpret_cast<PFN_vkGetInstanceProcAddr>(GetProcAddress(vk, "vkGetInstanceProcAddr"));
|
||||
if (gipa == nullptr)
|
||||
{
|
||||
std::printf("SKIP vk_capture_perf_test (no vkGetInstanceProcAddr)\n");
|
||||
return 0;
|
||||
}
|
||||
#define IFN(name) reinterpret_cast<PFN_vk##name>(gipa(instance, "vk" #name))
|
||||
|
||||
VkInstance instance = VK_NULL_HANDLE;
|
||||
{
|
||||
auto create = reinterpret_cast<PFN_vkCreateInstance>(gipa(nullptr, "vkCreateInstance"));
|
||||
VkApplicationInfo app{VK_STRUCTURE_TYPE_APPLICATION_INFO};
|
||||
app.apiVersion = VK_API_VERSION_1_1;
|
||||
VkInstanceCreateInfo ci{VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO};
|
||||
ci.pApplicationInfo = &app;
|
||||
if (create == nullptr || create(&ci, nullptr, &instance) != VK_SUCCESS)
|
||||
{
|
||||
std::printf("SKIP vk_capture_perf_test (vkCreateInstance failed)\n");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
auto EnumeratePhysicalDevices = IFN(EnumeratePhysicalDevices);
|
||||
auto GetPhysicalDeviceQueueFamilyProperties = IFN(GetPhysicalDeviceQueueFamilyProperties);
|
||||
auto CreateDevice = IFN(CreateDevice);
|
||||
auto DestroyDevice = IFN(DestroyDevice);
|
||||
auto DestroyInstance = IFN(DestroyInstance);
|
||||
auto gdpa = IFN(GetDeviceProcAddr);
|
||||
|
||||
std::uint32_t n = 0;
|
||||
EnumeratePhysicalDevices(instance, &n, nullptr);
|
||||
if (n == 0)
|
||||
{
|
||||
std::printf("SKIP vk_capture_perf_test (no physical devices)\n");
|
||||
DestroyInstance(instance, nullptr);
|
||||
return 0;
|
||||
}
|
||||
std::vector<VkPhysicalDevice> phys(n);
|
||||
EnumeratePhysicalDevices(instance, &n, phys.data());
|
||||
VkPhysicalDevice gpu = phys[0];
|
||||
|
||||
std::uint32_t qn = 0;
|
||||
GetPhysicalDeviceQueueFamilyProperties(gpu, &qn, nullptr);
|
||||
std::vector<VkQueueFamilyProperties> qf(qn);
|
||||
GetPhysicalDeviceQueueFamilyProperties(gpu, &qn, qf.data());
|
||||
std::uint32_t qfam = UINT32_MAX;
|
||||
for (std::uint32_t i = 0; i < qn; ++i)
|
||||
{
|
||||
if (qf[i].queueFlags & VK_QUEUE_GRAPHICS_BIT)
|
||||
{
|
||||
qfam = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (qfam == UINT32_MAX)
|
||||
{
|
||||
std::printf("SKIP vk_capture_perf_test (no graphics queue)\n");
|
||||
DestroyInstance(instance, nullptr);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const float prio = 1.0f;
|
||||
VkDeviceQueueCreateInfo qci{VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO};
|
||||
qci.queueFamilyIndex = qfam;
|
||||
qci.queueCount = 1;
|
||||
qci.pQueuePriorities = &prio;
|
||||
VkDeviceCreateInfo dci{VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO};
|
||||
dci.queueCreateInfoCount = 1;
|
||||
dci.pQueueCreateInfos = &qci;
|
||||
VkDevice device = VK_NULL_HANDLE;
|
||||
if (CreateDevice(gpu, &dci, nullptr, &device) != VK_SUCCESS)
|
||||
{
|
||||
std::printf("SKIP vk_capture_perf_test (vkCreateDevice failed)\n");
|
||||
DestroyInstance(instance, nullptr);
|
||||
return 0;
|
||||
}
|
||||
|
||||
#define DFN(name) reinterpret_cast<PFN_vk##name>(gdpa(device, "vk" #name))
|
||||
DevFns d{};
|
||||
d.GetDeviceProcAddr = gdpa;
|
||||
d.GetDeviceQueue = DFN(GetDeviceQueue);
|
||||
d.CreateCommandPool = DFN(CreateCommandPool);
|
||||
d.DestroyCommandPool = DFN(DestroyCommandPool);
|
||||
d.AllocateCommandBuffers = DFN(AllocateCommandBuffers);
|
||||
d.BeginCommandBuffer = DFN(BeginCommandBuffer);
|
||||
d.EndCommandBuffer = DFN(EndCommandBuffer);
|
||||
d.ResetCommandBuffer = DFN(ResetCommandBuffer);
|
||||
d.CmdPipelineBarrier = DFN(CmdPipelineBarrier);
|
||||
d.CmdCopyImageToBuffer = DFN(CmdCopyImageToBuffer);
|
||||
d.CmdCopyBufferToImage = DFN(CmdCopyBufferToImage);
|
||||
d.QueueSubmit = DFN(QueueSubmit);
|
||||
d.QueueWaitIdle = DFN(QueueWaitIdle);
|
||||
d.CreateFence = DFN(CreateFence);
|
||||
d.DestroyFence = DFN(DestroyFence);
|
||||
d.WaitForFences = DFN(WaitForFences);
|
||||
d.ResetFences = DFN(ResetFences);
|
||||
d.GetFenceStatus = DFN(GetFenceStatus);
|
||||
d.CreateSemaphore = DFN(CreateSemaphore);
|
||||
d.DestroySemaphore = DFN(DestroySemaphore);
|
||||
d.CreateBuffer = DFN(CreateBuffer);
|
||||
d.DestroyBuffer = DFN(DestroyBuffer);
|
||||
d.GetBufferMemoryRequirements = DFN(GetBufferMemoryRequirements);
|
||||
d.CreateImage = DFN(CreateImage);
|
||||
d.DestroyImage = DFN(DestroyImage);
|
||||
d.GetImageMemoryRequirements = DFN(GetImageMemoryRequirements);
|
||||
d.AllocateMemory = DFN(AllocateMemory);
|
||||
d.FreeMemory = DFN(FreeMemory);
|
||||
d.BindBufferMemory = DFN(BindBufferMemory);
|
||||
d.BindImageMemory = DFN(BindImageMemory);
|
||||
d.MapMemory = DFN(MapMemory);
|
||||
d.UnmapMemory = DFN(UnmapMemory);
|
||||
d.InvalidateMappedMemoryRanges = DFN(InvalidateMappedMemoryRanges);
|
||||
d.DeviceWaitIdle = DFN(DeviceWaitIdle);
|
||||
d.GetPhysicalDeviceMemoryProperties =
|
||||
reinterpret_cast<PFN_vkGetPhysicalDeviceMemoryProperties>(gipa(instance, "vkGetPhysicalDeviceMemoryProperties"));
|
||||
|
||||
d.GetPhysicalDeviceMemoryProperties(gpu, &g_memprops);
|
||||
|
||||
VkQueue queue = VK_NULL_HANDLE;
|
||||
d.GetDeviceQueue(device, qfam, 0, &queue);
|
||||
|
||||
// A small command pool + fence the test uses for setup and for the synchronous reference.
|
||||
VkCommandPool pool = VK_NULL_HANDLE;
|
||||
VkCommandPoolCreateInfo pci{VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO};
|
||||
pci.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;
|
||||
pci.queueFamilyIndex = qfam;
|
||||
d.CreateCommandPool(device, &pci, nullptr, &pool);
|
||||
VkCommandBuffer cb = VK_NULL_HANDLE;
|
||||
VkCommandBufferAllocateInfo cbai{VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO};
|
||||
cbai.commandPool = pool;
|
||||
cbai.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
|
||||
cbai.commandBufferCount = 1;
|
||||
d.AllocateCommandBuffers(device, &cbai, &cb);
|
||||
VkFence fence = VK_NULL_HANDLE;
|
||||
VkFenceCreateInfo fci{VK_STRUCTURE_TYPE_FENCE_CREATE_INFO};
|
||||
d.CreateFence(device, &fci, nullptr, &fence);
|
||||
|
||||
auto submit_wait = [&](VkCommandBuffer c) {
|
||||
VkSubmitInfo si{VK_STRUCTURE_TYPE_SUBMIT_INFO};
|
||||
si.commandBufferCount = 1;
|
||||
si.pCommandBuffers = &c;
|
||||
d.ResetFences(device, 1, &fence);
|
||||
d.QueueSubmit(queue, 1, &si, fence);
|
||||
d.WaitForFences(device, 1, &fence, VK_TRUE, UINT64_MAX);
|
||||
};
|
||||
|
||||
// --- Build the known source image (BGRA gradient) in PRESENT_SRC layout ------------------------
|
||||
const VkDeviceSize bytes = static_cast<VkDeviceSize>(W) * H * 4;
|
||||
std::vector<unsigned char> gradient(bytes);
|
||||
for (std::uint32_t y = 0; y < H; ++y)
|
||||
{
|
||||
for (std::uint32_t x = 0; x < W; ++x)
|
||||
{
|
||||
unsigned char* p = &gradient[(static_cast<size_t>(y) * W + x) * 4];
|
||||
p[0] = static_cast<unsigned char>(x & 0xFF); // B
|
||||
p[1] = static_cast<unsigned char>(y & 0xFF); // G
|
||||
p[2] = static_cast<unsigned char>((x + y) & 0xFF); // R
|
||||
p[3] = 255;
|
||||
}
|
||||
}
|
||||
// Upload buffer (host-visible).
|
||||
VkBuffer upbuf = VK_NULL_HANDLE;
|
||||
VkDeviceMemory upmem = VK_NULL_HANDLE;
|
||||
{
|
||||
VkBufferCreateInfo bci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
|
||||
bci.size = bytes;
|
||||
bci.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT;
|
||||
d.CreateBuffer(device, &bci, nullptr, &upbuf);
|
||||
VkMemoryRequirements mr{};
|
||||
d.GetBufferMemoryRequirements(device, upbuf, &mr);
|
||||
std::uint32_t mt = 0;
|
||||
find_mem(mr.memoryTypeBits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, mt);
|
||||
VkMemoryAllocateInfo mai{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO};
|
||||
mai.allocationSize = mr.size;
|
||||
mai.memoryTypeIndex = mt;
|
||||
d.AllocateMemory(device, &mai, nullptr, &upmem);
|
||||
d.BindBufferMemory(device, upbuf, upmem, 0);
|
||||
void* mp = nullptr;
|
||||
d.MapMemory(device, upmem, 0, VK_WHOLE_SIZE, 0, &mp);
|
||||
std::memcpy(mp, gradient.data(), bytes);
|
||||
d.UnmapMemory(device, upmem);
|
||||
}
|
||||
// Device-local source image.
|
||||
VkImage img = VK_NULL_HANDLE;
|
||||
VkDeviceMemory imgmem = VK_NULL_HANDLE;
|
||||
{
|
||||
VkImageCreateInfo ici{VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO};
|
||||
ici.imageType = VK_IMAGE_TYPE_2D;
|
||||
ici.format = VK_FORMAT_B8G8R8A8_UNORM;
|
||||
ici.extent = {W, H, 1};
|
||||
ici.mipLevels = 1;
|
||||
ici.arrayLayers = 1;
|
||||
ici.samples = VK_SAMPLE_COUNT_1_BIT;
|
||||
ici.tiling = VK_IMAGE_TILING_OPTIMAL;
|
||||
ici.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT;
|
||||
ici.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
|
||||
if (d.CreateImage(device, &ici, nullptr, &img) != VK_SUCCESS)
|
||||
{
|
||||
std::printf("SKIP vk_capture_perf_test (CreateImage failed)\n");
|
||||
return 0;
|
||||
}
|
||||
VkMemoryRequirements mr{};
|
||||
d.GetImageMemoryRequirements(device, img, &mr);
|
||||
std::uint32_t mt = 0;
|
||||
find_mem(mr.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, mt);
|
||||
VkMemoryAllocateInfo mai{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO};
|
||||
mai.allocationSize = mr.size;
|
||||
mai.memoryTypeIndex = mt;
|
||||
d.AllocateMemory(device, &mai, nullptr, &imgmem);
|
||||
d.BindImageMemory(device, img, imgmem, 0);
|
||||
}
|
||||
auto barrier = [&](VkCommandBuffer c, VkImageLayout from, VkImageLayout to, VkAccessFlags src,
|
||||
VkAccessFlags dst) {
|
||||
VkImageMemoryBarrier b{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER};
|
||||
b.srcAccessMask = src;
|
||||
b.dstAccessMask = dst;
|
||||
b.oldLayout = from;
|
||||
b.newLayout = to;
|
||||
b.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
|
||||
b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
|
||||
b.image = img;
|
||||
b.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
|
||||
d.CmdPipelineBarrier(c, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, 0, 0,
|
||||
nullptr, 0, nullptr, 1, &b);
|
||||
};
|
||||
{
|
||||
VkCommandBufferBeginInfo bi{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO};
|
||||
bi.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
|
||||
d.BeginCommandBuffer(cb, &bi);
|
||||
barrier(cb, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 0, VK_ACCESS_TRANSFER_WRITE_BIT);
|
||||
VkBufferImageCopy r{};
|
||||
r.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
|
||||
r.imageExtent = {W, H, 1};
|
||||
d.CmdCopyBufferToImage(cb, upbuf, img, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &r);
|
||||
barrier(cb, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
|
||||
VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_MEMORY_READ_BIT);
|
||||
d.EndCommandBuffer(cb);
|
||||
submit_wait(cb);
|
||||
}
|
||||
|
||||
// --- Synchronous reference (mirrors the OLD code: HOST_COHERENT staging, inline read-back) ------
|
||||
VkBuffer ref_buf = VK_NULL_HANDLE;
|
||||
VkDeviceMemory ref_mem = VK_NULL_HANDLE;
|
||||
void* ref_mapped = nullptr;
|
||||
{
|
||||
VkBufferCreateInfo bci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
|
||||
bci.size = bytes;
|
||||
bci.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT;
|
||||
d.CreateBuffer(device, &bci, nullptr, &ref_buf);
|
||||
VkMemoryRequirements mr{};
|
||||
d.GetBufferMemoryRequirements(device, ref_buf, &mr);
|
||||
std::uint32_t mt = 0; // OLD selection: first HOST_VISIBLE|HOST_COHERENT (often write-combined)
|
||||
find_mem(mr.memoryTypeBits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, mt);
|
||||
VkMemoryAllocateInfo mai{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO};
|
||||
mai.allocationSize = mr.size;
|
||||
mai.memoryTypeIndex = mt;
|
||||
d.AllocateMemory(device, &mai, nullptr, &ref_mem);
|
||||
d.BindBufferMemory(device, ref_buf, ref_mem, 0);
|
||||
d.MapMemory(device, ref_mem, 0, VK_WHOLE_SIZE, 0, &ref_mapped);
|
||||
}
|
||||
std::vector<unsigned char> ref_rgba(bytes);
|
||||
auto sync_capture = [&]() {
|
||||
VkCommandBufferBeginInfo bi{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO};
|
||||
bi.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
|
||||
d.ResetCommandBuffer(cb, 0);
|
||||
d.BeginCommandBuffer(cb, &bi);
|
||||
barrier(cb, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
|
||||
VK_ACCESS_MEMORY_READ_BIT, VK_ACCESS_TRANSFER_READ_BIT);
|
||||
VkBufferImageCopy r{};
|
||||
r.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
|
||||
r.imageExtent = {W, H, 1};
|
||||
d.CmdCopyImageToBuffer(cb, img, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, ref_buf, 1, &r);
|
||||
barrier(cb, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
|
||||
VK_ACCESS_TRANSFER_READ_BIT, VK_ACCESS_MEMORY_READ_BIT);
|
||||
d.EndCommandBuffer(cb);
|
||||
submit_wait(cb);
|
||||
const auto* src = static_cast<const unsigned char*>(ref_mapped);
|
||||
const size_t row = static_cast<size_t>(W) * 4;
|
||||
for (std::uint32_t y = 0; y < H; ++y)
|
||||
{
|
||||
const unsigned char* in = src + static_cast<size_t>(y) * row;
|
||||
unsigned char* o = ref_rgba.data() + static_cast<size_t>(y) * row;
|
||||
for (std::uint32_t x = 0; x < W; ++x)
|
||||
{
|
||||
o[x * 4 + 0] = in[x * 4 + 2];
|
||||
o[x * 4 + 1] = in[x * 4 + 1];
|
||||
o[x * 4 + 2] = in[x * 4 + 0];
|
||||
o[x * 4 + 3] = 255;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Time the synchronous reference (a few iterations; this is the per-present cost the OLD code put
|
||||
// on the game's present thread).
|
||||
sync_capture(); // warm
|
||||
double sync_ms = 0;
|
||||
const int iters = 8;
|
||||
for (int i = 0; i < iters; ++i)
|
||||
{
|
||||
const double t0 = now_ms();
|
||||
sync_capture();
|
||||
sync_ms += now_ms() - t0;
|
||||
}
|
||||
sync_ms /= iters;
|
||||
std::printf("synchronous reference (old design): %.2f ms per present-thread capture (%ux%u)\n", sync_ms, W, H);
|
||||
|
||||
double sut_ms = sync_ms; // in --sync repro mode the measured path IS the synchronous one
|
||||
bool image_ok = true;
|
||||
|
||||
if (!sync_repro)
|
||||
{
|
||||
// --- The fix under test: VkCapture (async reaper) -----------------------------------------
|
||||
coop::hook::VkCapture cap;
|
||||
cap.init(gpu, device, qfam, capture_fns(d), GetCurrentProcessId(), nullptr);
|
||||
|
||||
// Consume the present semaphore VkCapture hands back, exactly like a real vkQueuePresentKHR.
|
||||
auto consume = [&](VkSemaphore sem) {
|
||||
VkPipelineStageFlags stage = VK_PIPELINE_STAGE_ALL_COMMANDS_BIT;
|
||||
VkSubmitInfo si{VK_STRUCTURE_TYPE_SUBMIT_INFO};
|
||||
si.waitSemaphoreCount = 1;
|
||||
si.pWaitSemaphores = &sem;
|
||||
si.pWaitDstStageMask = &stage;
|
||||
d.QueueSubmit(queue, 1, &si, VK_NULL_HANDLE);
|
||||
};
|
||||
|
||||
// Warm up (first calls allocate staging / create the D3D texture on the reaper).
|
||||
for (int i = 0; i < 16; ++i)
|
||||
{
|
||||
VkSemaphore sem = VK_NULL_HANDLE;
|
||||
if (cap.present(img, VK_FORMAT_B8G8R8A8_UNORM, W, H, nullptr, 0, sem))
|
||||
{
|
||||
consume(sem);
|
||||
}
|
||||
Sleep(2);
|
||||
}
|
||||
// Steady-state present-thread cost.
|
||||
int queued = 0;
|
||||
double t = 0;
|
||||
const int loop = 240;
|
||||
for (int i = 0; i < loop; ++i)
|
||||
{
|
||||
VkSemaphore sem = VK_NULL_HANDLE;
|
||||
const double t0 = now_ms();
|
||||
const bool did = cap.present(img, VK_FORMAT_B8G8R8A8_UNORM, W, H, nullptr, 0, sem);
|
||||
t += now_ms() - t0;
|
||||
if (did)
|
||||
{
|
||||
consume(sem);
|
||||
++queued;
|
||||
}
|
||||
Sleep(1); // ~1 kHz present loop; lets the reaper drain
|
||||
}
|
||||
sut_ms = t / loop;
|
||||
std::printf("VkCapture (fix): %.3f ms per present-thread call (queued %d/%d, published %llu)\n", sut_ms,
|
||||
queued, loop, static_cast<unsigned long long>(cap.frames_published()));
|
||||
|
||||
// Let the reaper finish, then verify the captured image is byte-correct (BGRA->RGBA swizzle).
|
||||
Sleep(50);
|
||||
std::vector<unsigned char> got;
|
||||
std::uint32_t gw = 0, gh = 0;
|
||||
if (cap.last_frame(got, gw, gh) && gw == W && gh == H)
|
||||
{
|
||||
image_ok = std::memcmp(got.data(), ref_rgba.data(), bytes) == 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
image_ok = false;
|
||||
}
|
||||
check(cap.frames_published() > 0, "VkCapture published frames");
|
||||
check(image_ok, "VkCapture image matches the source gradient (BGRA->RGBA swizzle correct)");
|
||||
|
||||
d.DeviceWaitIdle(device);
|
||||
cap.shutdown();
|
||||
}
|
||||
|
||||
// The core assertion: the measured present-thread cost must be a small fraction of the synchronous
|
||||
// read-back cost (the fix moves the read-back off the present thread). In --sync repro mode the
|
||||
// measured path IS the synchronous one, so this fails -- demonstrating the test catches the bug.
|
||||
std::printf("present-thread cost: measured %.3f ms vs synchronous %.2f ms (ratio %.3f)\n", sut_ms, sync_ms,
|
||||
sut_ms / sync_ms);
|
||||
check(sut_ms * 4.0 < sync_ms, "capture stays off the present thread (measured << synchronous)");
|
||||
|
||||
// cleanup
|
||||
d.DeviceWaitIdle(device);
|
||||
d.UnmapMemory(device, ref_mem);
|
||||
d.DestroyBuffer(device, ref_buf, nullptr);
|
||||
d.FreeMemory(device, ref_mem, nullptr);
|
||||
d.DestroyImage(device, img, nullptr);
|
||||
d.FreeMemory(device, imgmem, nullptr);
|
||||
d.DestroyBuffer(device, upbuf, nullptr);
|
||||
d.FreeMemory(device, upmem, nullptr);
|
||||
d.DestroyFence(device, fence, nullptr);
|
||||
d.DestroyCommandPool(device, pool, nullptr);
|
||||
DestroyDevice(device, nullptr);
|
||||
DestroyInstance(instance, nullptr);
|
||||
|
||||
std::printf(g_failures == 0 ? "PASS vk_capture_perf_test\n" : "FAILED vk_capture_perf_test (%d)\n", g_failures);
|
||||
return g_failures == 0 ? 1 - 1 : 1; // 0 on pass
|
||||
}
|
||||
Reference in New Issue
Block a user