gpu: split vk_render into an rhi seam and a vulkan backend - #12
Conversation
vk_render.cpp was 5252 lines behind one god State. Callers now talk to gpu::rhi (command.h + renderer.h) and never name a graphics API type; the Vulkan backend lives in gpu/vulkan, one unit per decision. Pure code motion: the frame Isaac presents is byte-identical to the pre-split build.
g_scHist/dumpSyscallHist were defined inside #if DELTA_BACKEND_NATIVE but referenced unconditionally from null_handler, so the FEX build failed to link. Only the trampoline that increments g_sysHist is native-only.
Naming: functions CamelCase, members/locals/params snake_case, constants kCamelCase (incl. getenv-initialized static const gates), mutable globals g_snake_case. ~3000 renames across the module plus the callers of the renamed public API (rhi::Init/BeginFrame/Draw/EndFrame/Dispatch, gpu::SubmitDcb/SubmitCcb/EndFrame). AMD hardware mnemonics (IT_*, register names) keep their canonical spelling under NOLINT guards. Layout: every intra-module include is spelled from the delta root (gpu/...); the module-root and ps4/ include-root hacks are gone, and the gpu/cmd_processor.h forwarding header is deleted (callers include gpu/ps4/cmd_processor.h). vk_format's vk-prefixed mappers are renamed to say what they map (BlendFactor/BlendOp/VertexFormat/PrimitiveTopology). Enforcement: module-scoped .clang-format (Chromium base, applied) and .clang-tidy identifier-naming rules; tests/check_layering.py pins the directory dependency graph and the public surface (rhi/ + the two cmd_processor.h) and runs under CTest as gpu_layering. README documents the conventions and the deliberate deviations (.cpp extension, tests/ subdir, hardware mnemonics). Verified: full build + link, gcn_decode/gcn_detile/gpu_layering tests pass, Isaac renders the intro scene at ~700fps on the PS4 path.
git mv of every delta/gpu .cpp to .cc; the shared add_delta_module glob now accepts both extensions (.cc is gpu-only, the rest of the repo stays .cpp). Test sources, the layering checker, and comment/README references to the module's own files follow; references to external projects and to kern's gc_dev.cpp are untouched.
rhi::Renderer is now a struct the caller holds -- an opaque BackendState pointer plus a cheap available() query -- and every operation is a free function taking Renderer&. All Vulkan backend state moves into one BackendState value (vk_backend.h) that Renderer::state points at; the per-subsystem globals the implementation uses (g_dev, g_frame, g_ring, g_quad, g_tex, g_region, g_rts/g_depths/g_rt_pages, the dynamic-rendering PFNs) become reference aliases into it, so backend internals migrate to explicit state threading incrementally. DefaultRenderer() hands out the process-wide instance: the guest-called HLE submit paths cannot thread a handle, so it is the one piece of ambient state remaining at the seam. rhi::Available() is replaced by renderer.available(); Init() marks the renderer available only on success. Behavior-neutral: Isaac gate matches the pre-change fps curve.
| bool restart_region = g_region.cur_rt != d.rt_base || | ||
| g_region.cur_mrt_count != mrt_n || | ||
| g_region.cur_depth != d.depth_base || | ||
| transition_source || pending_depth_clear; |
There was a problem hiding this comment.
P0 correctness: This region key ignores changes to MRT1-MRT7. Two draws with the same MRT0/count/depth but different secondary attachments continue the old dynamic-rendering region and write into stale targets. Compare the complete attachment signature before deciding to reuse the region.
| (unsigned long)base, it->second.w, it->second.h, | ||
| (int)it->second.fmt, w, h, (int)fmt); | ||
| } | ||
| return &it->second; |
There was a problem hiding this comment.
P0 correctness: Returning the existing target after a dimension or format mismatch leaves an incompatible image/view bound under the new draw state. Key the target by guest range plus shape/format/generation, or retire and replace the old generation here; a warning does not make this reuse safe.
| : 0; | ||
| ImageBarrier(g_frame.cmd, dst.image, dst.layout, | ||
| VK_IMAGE_LAYOUT_GENERAL, src_access, | ||
| VK_ACCESS_SHADER_WRITE_BIT); |
There was a problem hiding this comment.
P0 correctness: A storage image already in GENERAL bypasses this barrier path, so consecutive fragment storage accesses can miss RAW/WAR/WAW dependencies. Track prior stage/access separately from layout and emit a memory dependency even when old and new layouts are identical; storage access should also include reads when the shader uses them.
| struct ShaderKey { | ||
| uint64_t vs, ps, fetch; | ||
| uint32_t gs_user_sgprs, ps_user_sgprs; | ||
| bool gl_clip; // clip convention is baked into the VS (see PA_CL_CLIP_CNTL) |
There was a problem hiding this comment.
P0 correctness: ps_input_ena affects RDNA fragment SPIR-V generation but is not represented by this key, so variants at the same addresses can alias. Prefer a content-derived shader ID; at minimum every translation-affecting register, including SPI_PS_INPUT_ENA, must participate in the key.
| app.pApplicationName = "prosperity-gpu"; | ||
| VkInstanceCreateInfo ic{VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO}; | ||
| ic.pApplicationInfo = &app; | ||
| VKOK(vkCreateInstance(&ic, nullptr, &g_dev.instance)); |
There was a problem hiding this comment.
P1 observability: This is the instance that executes guest rendering, but it enables neither validation nor VK_EXT_debug_utils; the existing validation toggle only covers the separate presentation instance. Apply a shared diagnostics configuration here, install a structured debug messenger, and name/label objects and command regions with stable shader/resource/event IDs.
| RdocApi* GetRdocApi() { | ||
| static RdocApi* api = []() -> RdocApi* { | ||
| using GetApi = int (*)(uint32_t version, void** out); | ||
| void* lib = dlopen("librenderdoc.so", RTLD_NOW | RTLD_NOLOAD); |
There was a problem hiding this comment.
P1 capture reliability: This hand-declared ABI plus RTLD_NOLOAD makes capture depend on prior injection and provides no capture path, ownership, or completion contract. Wrap the official RenderDoc ABI in an owned CaptureSession: preflight attachment, set an absolute template, track whether Prosperity started the capture, retrieve the actual filename, and close it on every error path.
| return false; | ||
| } | ||
| vkFreeCommandBuffers(g_dev.device, g_dev.pool, 1, &c); | ||
| const uint32_t* px = static_cast<const uint32_t*>(g_frame.readback_map); |
There was a problem hiding this comment.
P0 diagnostic safety: The readback allocation uses the real format size, but this code always indexes it as one uint32_t per pixel. R8/R16 targets can be read past the allocation, while wider and floating formats produce invalid statistics. Decode samples through the format-aware path used below for PPM conversion.
| if ((type_bits & (1u << i)) && | ||
| (mp.memoryTypes[i].propertyFlags & props) == props) | ||
| return i; | ||
| return 0; |
There was a problem hiding this comment.
P0 robustness: Memory type zero is not a valid failure sentinel; it can select an incompatible heap and turn allocation pressure or an unusual device layout into invalid Vulkan use. Return an optional/result, make allocation transactional, and report the required type bits/properties when no match exists.
| // unrelated shader pairs. | ||
| static std::unordered_map<ShaderKey, gcn::Recompiled, ShaderKeyHash> | ||
| sh_cache; | ||
| ShaderKey key{vs_a, ps_a, fetch}; |
There was a problem hiding this comment.
P0 correctness/inspection: This translated-shader cache is address-based. CachedProgram revalidates rewritten guest bytes, but this higher cache can keep the old Recompiled module indefinitely. Use a content-addressed shader artifact ID covering exact shader/fetch bytes and all compile options, with guest addresses retained only as metadata.
| @@ -71,18 +72,19 @@ struct Recompiled { | |||
| std::vector<uint32_t> vs_spirv; // emitted directly from GCN | |||
There was a problem hiding this comment.
P1 shader introspection: Recompiled carries the final binaries and manual binding plan but no durable provenance. Introduce a content-addressed ShaderArtifact containing source hashes/bytes, ISA and stage, translation options, decoded IR, pre/final SPIR-V, reflection, diagnostics, and guest-PC source mapping. The same artifact ID should drive shader and pipeline cache identity, Vulkan names, RenderDoc labels, and dump paths.
|
GPU renderer audit summary The split creates useful seams for diagnostics, but stable identity and machine-readable observability are still missing. The inline threads above cover the immediate correctness issues that must be fixed before diagnostic output can be trusted. Recommended implementation order:
Existing strengths worth preserving are the API-neutral RHI seam, explicit capture of the offscreen guest-rendering device, two-slot frame ownership, direct SPIR-V backend, GPU timestamps, device-fault support, and the address-overlap page table. A full render graph or a giant Vulkan resource manager is not required; narrow Verification performed: the six targeted GPU tests pass, but they are CPU-side helper tests only. RenderDoc 1.43 and its Vulkan capture layer are available through |
A failed image writeback (WritebackCsImage returning false for one range) was handled like a device fault everywhere FlushCsWrites' result was consumed: - EndFrame nulled renderer.state, permanently disabling the renderer -- there is no re-init path -- over a single range that could not be retiled back to guest memory. Before the flush learned to report failure it would simply have left that range stale. - The CP DMA mem->mem path dropped the guest->guest copy entirely, leaving the destination with old garbage; a possibly-stale source is strictly better. - HandleDispatch dropped the dispatch instead of re-resolving and using the dummy-resource fallback. Only a device fault (g_cs_failed, which nulls renderer.state inside the flush) is fatal now; callers distinguish the two by the renderer still being available. The flush loops also no longer stop at the first failed range, so one bad range cannot hold every other range stale forever, and the failed writeback is logged (first 8) instead of being silent.
The multi-tex storage branch hand-rolled the source access mask for the transition to GENERAL and covered only three source layouts; an RT arriving in TRANSFER_DST (the DELTA_GPU_CLEARRED vkCmdClearColorImage path) fell through to 0. Since the barrier refinement mapped access 0 to TOP_OF_PIPE, that left the transition unordered against the very clear that produced the image. Use ColorImageAccess like every neighbouring transition, and include SHADER_READ in the destination mask: storage images are read with imageLoad too, and the write-only mask gave those reads no visibility of prior attachment writes.
check_layering.py policed only the quoted "gpu/..." spelling, but the shared include root resolves more than that: angle-bracket <gpu/...> includes, bare same-directory filenames, and ../ paths all compiled and sailed past the check. Police all of them (relative forms are rejected as spelling violations), scan .inl/.hpp too, walk shared/ and tools/ as well as delta/, and narrow the vulkan -> ps4/gcn allowance from the whole directory to the two headers the backend actually consumes. SPIRV-Tools was a PUBLIC usage requirement of delta_gpu, putting its headers and DELTA_HAVE_SPIRV_BACKEND on every dependent's compile line; it is an implementation detail of the recompiler, so make it PRIVATE (via the absolute-path LINK_LIBRARIES so the final link still finds it). Give shaders/ a DisableFormat .clang-format: the generated *_spv.h word arrays were the only files failing the module's format config. The README and rhi/renderer.h claimed more than the tree delivers: the public surface is rhi/ plus the two cmd_processor.h entry headers, not "only rhi/"; vulkan/ also consumes the gcn translate/detile headers; rhi/command.h forward-declares the gcn recompiled-program types; and "private include path" is enforced by the gpu_layering test, not the build. Say so, and list the five vulkan/ units the table omitted.
New gpu/gpu_check.h defines GPU_BUGCHECK, active in every build flavour: the vendored BASE_BUGCHECK is stripped repo-wide (BASE_STRIP_BUGCHECK) and BASE_DCHECK's CHECK_BREAK is a no-op outside CONFIG_DEBUG, so neither ever fires in the Release builds this project actually runs. A violated invariant means the renderer's own bookkeeping is corrupt; aborting at the violation beats debugging the downstream artifact three frames later. Guest-triggerable conditions stay error paths. First checks: MemorySpanAllocator now traps a double free (overlapping free spans mean later Allocates hand out live memory) with a death test covering it; the texture budget traps on underflow; compute staging-slot indices are bounds-checked; and the budget's deliberate transient overshoot (retired images are freed two BeginFrames later) is now documented at the constant.
ImageMemoryPool: a vkBindImageMemory failure against pooled memory now releases the span and falls through to a dedicated allocation instead of failing the image outright (one pooled bind failure also skips the fresh-block attempt -- another suballocation is no more likely to bind). When no device-local memory type accepts the image, fall back to any allowed type instead of silently defaulting to index 0, which may not even be in memoryTypeBits. Freeing an allocation the pool does not own is now a bugcheck instead of a silent leak. Texture upload blocks (16 MB+, doubling) were appended on demand and never released, so one loading burst's worth of staging memory was retained forever per frame slot. ResetTextureUploads -- which runs after the slot's fence wait, the one point where no in-flight transfer can reference a block -- now destroys blocks idle for 300 consecutive resets. Slot and alignment preconditions are bugchecks.
SHADER_READ/WRITE mapped to the fragment stage only. That is correct today -- every image descriptor in the module is fragment-stage and compute synchronises its own buffers -- but the first vertex texture fetch or compute image access would silently under-cover its barrier, which is the least findable class of bug this module can grow. Map shader access to vertex|fragment|compute; the extra stage bits cost nothing when no such access exists in the frame.
vkDeviceWaitIdle does not cover the presentation engine's pending semaphore waits (that guarantee needs VK_EXT_swapchain_maintenance1), so destroying the per-image render-finished semaphores right after the idle during swapchain recreation could free a semaphore the engine was still waiting on. Recreation now parks the old set and destroys it at the following recreation -- a full swapchain generation plus another idle later. Shutdown destroys both generations.
New vulkan/vk_debug: VK_EXT_debug_utils object names and command labels, everything keyed by guest addresses so a RenderDoc capture lines up with the DELTA_GPU_* logs by eye. The event browser now nests frame N > region rt=... / cs batch > per-draw and per-dispatch markers (recomp vs/ps addresses, quad fallback, dispatch cs address + group counts), and the resource inspector shows rt/depth/tex/csbuf guest addresses, the upload rings, frame slots, and pipelines named by shader address instead of anonymous handles. Gated on a consumer actually listening: the loader always advertises the extension, but formatting labels for nobody costs ~0.3 ms per Isaac frame -- so labels only light up when librenderdoc.so is injected or DELTA_GPU_MARKERS=1 forces them (validation-layer runs). Complements the existing DELTA_RDOC_FRAME=N capture bracketing; the README documents the whole flow.
The emitter named only lds; everything else in a RenderDoc SPIR-V view was anonymous %ids. Name the resource and interface variables on both translators -- cbufN, texN/imgN, bufN (CS storage), user_data push constants, v_attrN vertex inputs, in_attrN/out_paramN interpolants, mrtN color outputs -- so the disassembly reads in the same vocabulary as the GCN it came from (sgpr/vgpr were already named). Builtins keep their gl_* names from the decorations.
vk_render.cpphad grown to 5252 lines behind a single 100-fieldState g.This splits it along the seams that were already there.
Layering
DrawInfo/ComputeInfoand the renderer entry points moved fromgpu::vkto
gpu::rhi. A command processor now decodes guest packets into anrhi::DrawInfoand callsrhi::draw; it never names a graphics API type andnever includes anything from
vulkan/. Onlyrhi/is reachable from outsidethe module --
vulkan/is on the module's private include path, so a secondbackend can be added without touching a caller. Inspiration taken from the rx
engine's
render/rhi+render/vulkansplit.vulkan/
The god
Stateis decomposed into components that own their own data, one fileper decision:
vk_device,vk_format,vk_hash,vk_upload_ring,vk_texture_cache,vk_render_target,vk_pipeline_cache,vk_compute,vk_draw/vk_draw_recomp,vk_frame,vk_perf,vk_capture,vk_present.delta/gpu/README.mdhas the table of what each one hides.No behaviour change
This is code motion. The only edits to moved code are mechanical:
g.foo->the owning component's global, default arguments moved from definitions to
declarations, and the decline tally in
endFrameextracted intoreportDeclines().Verified by:
of times in the new tree; the 40 exceptions are all the documented mechanical
edits above. No body is duplicated.
presented frame is byte-identical to a capture from a build of this tree
with the split reverted.
deterministic, so an exact comparison is not available: two runs of the same
binary at the same frame differ by 7.11% of bytes (max delta 177), while
pre-split vs post-split differ by 0.23% (max delta 5) -- well inside its own
noise.
Not in scope
No vtable and no second backend: with one backend, dispatch would be
speculative. The seam is a compile-time one, which is what makes the layering
rule enforceable today; introducing dispatch is a mechanical change at the point
a second backend actually lands.