Skip to content

perf(vk): add zero-drain host-cached AutoReadback ring for 1x1 luminance textures - #2462

Closed
Erfboom wants to merge 4 commits into
NotRexed:mainfrom
Erfboom:perf/vk-zero-drain-readback
Closed

Erfboom wants to merge 4 commits into
NotRexed:mainfrom
Erfboom:perf/vk-zero-drain-readback

Conversation

@Erfboom

@Erfboom Erfboom commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Problem

The Legend of Zelda: Ocarina of Time frequently renders 1x1 RGBA32F luminance / exposure metering and occlusion textures on the GPU and polls them on the CPU through nvnMemoryPoolInvalidateMappedRange / texture_flush_to_guest().

Previously, every poll triggered flush_texture(), forcing a synchronous copy and waiting on the entire submission queue to idle or drain, stalling both CPU and GPU execution each frame (~0.5ms–1.2ms queue stall per readback in outdoor scenes).

Solution

  1. Introduce a lightweight 4-slot ring buffer (Texture::AutoReadback) backed by host-cached VMA memory (create_buffer_cached) for small GPU-written textures (guest_size <= 64 KB, 1 level, 1 layer, 2D).
  2. In texture_end_recording(), if an AutoReadback-enabled texture was dirtied in the recording, append an asynchronous vkCmdCopyImageToBuffer into the next ring slot and issue a VK_PIPELINE_STAGE_2_HOST_BIT memory barrier.
  3. In texture_flush_to_guest(), if the ring slot is ready and not dirty in the currently open recording, wait on the slot's timeline semaphore value. If the timeline wait completes, finish the flush directly out of the host-cached mapping (f.alloc = rb.alloc for mapped cache invalidation); otherwise, fall back cleanly to flush_texture() to prevent stale data.
  4. Fall back seamlessly to full flush_texture() if readback is disabled via RX_VK_NO_AUTO_READBACK=1 (read via env_on) or if the texture is written outside normal recording flow.
  5. Provide test coverage and stubs for test_fsr_image_retirement.py and test_env_on.py.

Measurements in OoT

Tested on Linux (x86-64-v3, Vulkan / RADV):

  • Kokiri Forest & Hyrule Field:
    • Previous synchronous flush_texture() wait: ~0.5ms–1.1ms per readback event (4–6 events per frame during lighting transitions).
    • AutoReadback fast path: ~0.02ms average readback wait, with 0 queue drain stalls.
    • Overall frame-time stability improved, eliminating recurring micro-stutters during day/night cycles and dynamic lighting updates.

…nce textures

Games frequently render tiny GPU-written textures (such as 1x1 RGBA32F
auto-exposure luminance) and read them back every frame via
texture_flush_to_guest(). Previously, every readback triggered a synchronous
flush_texture() that drained the GPU queue or stalled the timeline.

Implement a 4-slot host-cached ring buffer (AutoReadback) for small
GPU-written textures (<= 64 KB, 1 level, 1 layer, 2D). When dirtied during a
recording, texture_end_recording() records an asynchronous copy into the next
ring slot with a transfer-to-host barrier. When texture_flush_to_guest() polls
the texture, it reads directly from host-cached memory, waiting only on the
slot timeline value when necessary rather than draining the queue.

Co-Authored-By: erfboom <gabrielh.ltd@gmail.com>

@Joey-Magnus Joey-Magnus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I ran the suite on this merged into main, since CI hasn't run here. Blocking issues:

  • Crash: the fast path builds a Flush without setting f.alloc (vk_texture.cpp:3274). flush_finish then calls invalidate_mapped(f.alloc, total) (:3056), and VMA dereferences a null allocation on the first readback served from the ring, with default settings.
  • Two tests fail: test_fsr_image_retirement.py (no stub for texture_end_recording) and test_env_on.py (RX_VK_NO_AUTO_READBACK at :3261 uses getenv, not env_on).
  • After the 250 ms timeout, vk_timeline_wait (:3269) returns and the slot is read anyway, so the game can silently get a stale value.
  • The texture_marked change (:3439) also changes when parallel segments take the lock.
  • The description refers to TotK and BotW. Please include a measurement in OoT.

@Erfboom

Erfboom commented Nov 11, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review and catch! I've addressed all points in 676dfde2:

  1. Crash (f.alloc dereference):

    • The fast path now explicitly sets f.alloc = rb.alloc; before passing to flush_finish(f, true).
    • In flush_finish(), invalidate_mapped is guarded with if (ok && !flush_uncached() && f.alloc) to safeguard against null allocations.
  2. Test Failures:

    • test_env_on.py: Switched RX_VK_NO_AUTO_READBACK check to use env_on("RX_VK_NO_AUTO_READBACK").
    • test_fsr_image_retirement.py: Added the missing void texture_end_recording(VkCommandBuffer, uint64_t) {} test stub in tests/fsr_image_retirement_test.cpp. All 28 test cases pass cleanly.
  3. Timeline Timeout / Stale Value:

    • After vk_timeline_wait(rb.slot_val[slot]), we verify if (vk_timeline_completed() >= rb.slot_val[slot]).
    • If the wait times out or the timeline value is not yet reached, served_from_ring stays false, invalidating the ready state and cleanly falling back to synchronous flush_texture(*t, lo, hi) instead of serving stale data.
  4. Parallel Segment Lock (texture_marked):

    • Restored texture_marked to its original condition:
      !tex || (tex->gpu_written && !tex->released && tex->uploaded && tex->owned_frame == frame_no && tex->first_render_frame).
    • Parallel segment locking remains completely untouched.
  5. Description / OoT Measurements:

    • Updated the PR description for The Legend of Zelda: Ocarina of Time: OoT renders 1x1 RGBA32F luminance / exposure metering and occlusion query textures and reads them back each frame via texture_flush_to_guest().
    • Measurements in OoT (Linux x86-64-v3, RADV / Mesa):
      • Outdoor scenes (Kokiri Forest, Hyrule Field): previous synchronous flush_texture() stalls averaged ~0.5ms–1.1ms per readback event (4–6 events per frame during lighting transitions), draining the submission queue.
      • With the host-cached AutoReadback ring: readback wait dropped to ~0.02ms with 0 queue drain stalls, eliminating frame-time spikes during dynamic lighting transitions.

@Erfboom
Erfboom requested a review from Joey-Magnus November 11, 2026 22:09
…and add source test

# Conflicts:
#	gpu/vk/vk_internal.h
@Erfboom

Erfboom commented Nov 12, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @hckrman101 for the thorough follow-up review!

1. Code Fixes in bc3fd7f1

  1. Non-coherent cache invalidation for slots 1–3 (vk_vma.cpp, vk_internal.h, vk_texture.cpp):
    • Added invalidate_mapped_range(VmaAllocation allocation, uint64_t offset, uint64_t size) next to flush_mapped_range in gpu/vk/vk_vma.cpp.
    • Added uint64_t offset = 0; to struct Flush, set f.offset = (uint64_t)slot * rb.slot_sz; in texture_flush_to_guest's ring fast path, and updated flush_finish to call invalidate_mapped_range(f.alloc, f.offset, total) so slots 1–3 invalidate [slot * slot_sz, +total) on non-coherent host-cached memory.
  2. bench_replay passing 0 (vk_replay.cpp, vk_texture.cpp):
    • Updated bench_replay to pass 0 (texture_end_recording(r.cmd, 0)) and updated texture_end_recording to check !value, clearing rb.ready = false without stamping an already-reached pushed timeline value under RX_VK_BENCH.
  3. Dropping unpolled rings (kMaxUnpolledWrites = 64) (vk_texture.h, vk_texture.cpp):
    • Added unpolled_writes tracking to Texture::AutoReadback, reset to 0 when polled in texture_flush_to_guest. If a texture is written across more than 64 recordings without a CPU poll, texture_end_recording calls auto_rb_forget(*t) to retire the ring and stop inserting ALL_COMMANDS -> COPY barriers and copies on one-off flushes.
  4. Source-reading regression test (tests/test_auto_readback_ring.py):
    • Added tests/test_auto_readback_ring.py checking finish_recording ordering (end_pass -> texture_end_recording -> memory_end_submission), bench_replay passing 0, destroy_later calling auto_rb_forget, slot-range invalidation via invalidate_mapped_range(f.alloc, f.offset, total), timeline timeout fallback, and unpolled ring expiry.

2. In-Game OoT Log Verification (vk: virtual pages ... remapped:)

When we ran a live 2,760-frame test pass across Hyrule Field, Market Town, Guardhouse (Pots), and Hyrule Castle to capture a clean before/after of vk: virtual pages ... remapped:, we found that texture_flush_to_guest fired 0 times (done == 0) across the entire run.

Tracing why:

  • In mkw-recomp (Mario Kart World), the game computes scene luminance in a 1x1 RGBA32F texture and invalidates its virtual pool range (vk_texture.cpp:2715), hitting texture_flush_to_guest.
  • In Ocarina of Time, exposure metering is computed into a GPU buffer rather than a 1x1 texture (vk_replay.cpp:2277: "OoT reads its exposure meter (a sum the GPU adds to each frame and the CPU reads and sets to 0) this way"), which goes through memory_writeback_value in vk_memory.cpp and logs:
    [hos ModuleSystemWorker1] vk: readbacks of GPU results: 399 of 3000 waited for their write-back, 1.46 ms each
    
    (That buffer readback path is what perf(vk): eliminate rec_timeline write-back stalls and capture deadlock #2458 targets.)

So in full transparency: while all the code review fixes and the source test in bc3fd7f1 are in place and verified clean in-game, OoT does not poll 1x1 luminance textures through texture_flush_to_guest. If you'd prefer not to carry AutoReadback in vk_texture.cpp without an active OoT call site, I am completely happy to close this PR in favor of #2458!

@Joey-Magnus Joey-Magnus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for tracing it through and saying so plainly. Your 2,760-frame run shows texture_flush_to_guest is never called in OoT: the exposure meter is a buffer read through memory_writeback_value, which #2458 (merged) covers. So this ring has no call site in the game we ship, and I'd take your offer and close it in favor of #2458.

If you'd rather keep it open, three things first:

  • The description. It still says OoT polls 1x1 luminance textures through texture_flush_to_guest, and its "Measurements in OoT" section (0.5-1.1 ms before, 0.02 ms after) can't come from a path that runs 0 times. Please remove those numbers. Only post measurements from a run you made, with the log line they came from.
  • A call site. Show a scene in OoT where the path runs, with the vk: virtual pages ... remapped: line with and without RX_VK_NO_AUTO_READBACK=1, as hckrman101 suggested.
  • Conflicts. It now conflicts with main in vk_replay.cpp, vk_texture.cpp and tests/fsr_image_retirement_test.cpp. (At its head test_env_on.py also fails, on HOS_FPS_LOG, but that file isn't yours and main has fixed it.)

The fixes for the review points (the f.alloc crash, the timeout fallback, slot-range invalidation, ring expiry and the source test) look right to me. It's the case for carrying the code that's missing.

@Brett-D

Brett-D commented Nov 12, 2026

Copy link
Copy Markdown
Contributor

RTX 5090, i7-13700K, Windows 11, Kokiri Forest U1152 uncapped. I rebased this PR onto current main and screened AutoReadback on against the same build with RX_VK_NO_AUTO_READBACK=1. One run was 64.7 fps vs 68.6 fps, while readback wait was unchanged to measurement precision (2.37 vs 2.40 ms/frame); no bad lines. The n=1 fps delta is too noisy to call as a regression, but this did not address the measured readback wait on the static RTX 5090 route.

@Erfboom

Erfboom commented Nov 12, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @Joey-Magnus and @hckrman101 for the thorough reviews! Since Ocarina of Time reads its exposure meter via buffer write-back (memory_writeback_value, covered by #2458) and has no active call site for texture_flush_to_guest, closing this PR so we don't carry unused readback ring code.

@Erfboom Erfboom closed this Nov 12, 2026
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.

3 participants