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:
2026-06-23 08:19:47 +02:00
parent 60957775b7
commit 304857dcf0
13 changed files with 1564 additions and 799 deletions

View File

@@ -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)

View File

@@ -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();

View File

@@ -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;

View File

@@ -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;

View 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

View 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
}