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

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