Skip to content

gpu: split vk_render into an rhi seam and a vulkan backend - #12

Merged
Force67 merged 21 commits into
masterfrom
gpu/rhi-split
Jul 27, 2026
Merged

gpu: split vk_render into an rhi seam and a vulkan backend#12
Force67 merged 21 commits into
masterfrom
gpu/rhi-split

Conversation

@Force67

@Force67 Force67 commented Jul 26, 2026

Copy link
Copy Markdown
Owner

vk_render.cpp had grown to 5252 lines behind a single 100-field State g.
This splits it along the seams that were already there.

Layering

gpu/rhi/      the renderer as its callers see it  (command.h, renderer.h)
gpu/vulkan/   the only backend implementing it
gpu/ps4/      PM4/Liverpool command processor  -> depends on rhi/ only
gpu/ps5/      AGC/gfx10.3 command processor    -> depends on rhi/ only

DrawInfo / ComputeInfo and the renderer entry points moved from gpu::vk
to gpu::rhi. A command processor now decodes guest packets into an
rhi::DrawInfo and calls rhi::draw; it never names a graphics API type and
never includes anything from vulkan/. Only rhi/ is reachable from outside
the module -- vulkan/ is on the module's private include path, so a second
backend can be added without touching a caller. Inspiration taken from the rx
engine's render/rhi + render/vulkan split.

vulkan/

The god State is decomposed into components that own their own data, one file
per 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.md has 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 endFrame extracted into
reportDeclines().

Verified by:

  • Line accounting. Every code line of the original appears the same number
    of times in the new tree; the 40 exceptions are all the documented mechanical
    edits above. No body is duplicated.
  • PS4 (Isaac, CUSA00792). Boots and renders at the same framerate, and the
    presented frame is byte-identical to a capture from a build of this tree
    with the split reverted.
  • PS5 (Isaac, PPSA03311). Boots and renders. The title is not run-to-run
    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.

Force67 added 12 commits July 26, 2026 22:01
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;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread delta/gpu/vulkan/vk_draw_recomp.cc Outdated
: 0;
ImageBarrier(g_frame.cmd, dst.image, dst.layout,
VK_IMAGE_LAYOUT_GENERAL, src_access,
VK_ACCESS_SHADER_WRITE_BIT);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Force67

Force67 commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

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:

  1. Fix RT generations, complete MRT attachment signatures, storage-image hazards, checked allocation, and shader-cache invalidation.
  2. Enable validation plus VK_EXT_debug_utils on the guest-rendering instance and use stable IDs in object names and frame/pass/draw/dispatch labels.
  3. Add content-addressed shader artifacts, decoded/normalized CFG IR, guest-PC source mapping, final-SPIR-V reflection, and validation of the post-optimization binaries actually submitted to Vulkan.
  4. Add a resource registry and access ledger covering guest ranges, Vulkan objects, descriptors, layout/stage/access history, allocations, generations, and submission lifetime.
  5. Add request-driven generic buffer/image readback and a versioned artifact bundle containing manifest.json, events.ndjson, resources.json, validation messages, shader bundles, image metadata, and the .rdc.
  6. Replace the ad hoc RenderDoc hook with an owned capture session using the official ABI, deterministic frame domains, exact output paths, guaranteed closure, and a graceful stop-after-capture result.
  7. Add a repository-owned .rdc JSON inspector and package the RenderDoc Python API or rdc-cli in the dev shell.
  8. Add Lavapipe validation tests for attachment switching, image hazards, upload/readback hashes, descriptor/resource lifetime, artifact schemas, and capture lifecycle.

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 ShaderArtifact, ResourceRegistry, AccessLedger, ReadbackQueue, SubmissionLedger, and CaptureSession units are sufficient.

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 nix develop; neither the RenderDoc Python module nor rdc-cli is currently available for structured LLM inspection.

Force67 added 7 commits July 27, 2026 15:32
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.
Force67 added 2 commits July 27, 2026 16:01
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.
@Force67
Force67 marked this pull request as ready for review July 27, 2026 15:24
@Force67
Force67 merged commit a1991b8 into master Jul 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant