// 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 #include #include #include #include #include #define VK_NO_PROTOTYPES #include #include "vk_capture.hpp" namespace { double now_ms() { LARGE_INTEGER f, c; QueryPerformanceFrequency(&f); QueryPerformanceCounter(&c); return 1000.0 * static_cast(c.QuadPart) / static_cast(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(GetProcAddress(vk, "vkGetInstanceProcAddr")); if (gipa == nullptr) { std::printf("SKIP vk_capture_perf_test (no vkGetInstanceProcAddr)\n"); return 0; } #define IFN(name) reinterpret_cast(gipa(instance, "vk" #name)) VkInstance instance = VK_NULL_HANDLE; { auto create = reinterpret_cast(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 phys(n); EnumeratePhysicalDevices(instance, &n, phys.data()); VkPhysicalDevice gpu = phys[0]; std::uint32_t qn = 0; GetPhysicalDeviceQueueFamilyProperties(gpu, &qn, nullptr); std::vector 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(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(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(W) * H * 4; std::vector 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(y) * W + x) * 4]; p[0] = static_cast(x & 0xFF); // B p[1] = static_cast(y & 0xFF); // G p[2] = static_cast((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 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(ref_mapped); const size_t row = static_cast(W) * 4; for (std::uint32_t y = 0; y < H; ++y) { const unsigned char* in = src + static_cast(y) * row; unsigned char* o = ref_rgba.data() + static_cast(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(cap.frames_published())); // Let the reaper finish, then verify the captured image is byte-correct (BGRA->RGBA swizzle). Sleep(50); std::vector 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 }