Skip to content

fix(vk): HUD and lighting artifacts on GPUs without buffer mirrors - #1795

Open
Brett-D wants to merge 7 commits into
NotRexed:mainfrom
Brett-D:fix/igpu-ub-snapshot
Open

Brett-D wants to merge 7 commits into
NotRexed:mainfrom
Brett-D:fix/igpu-ub-snapshot

Conversation

@Brett-D

@Brett-D Brett-D commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

On integrated GPUs without VRAM mirrors, a uniform buffer bound in place is the game's own memory, and the game rewrites it right after the submit while the GPU still runs. #2464 fixed that for Intel; this PR extends the same copy to the other integrated GPUs (measured on a Steam Deck) and reuses the copy within a recording.

What it changes

Steam Deck, current main (6749ba21 + this)

Kokiri Forest, Game Mode, 30 s frame logs, 80 screenshots a start. Frame generation was on, so FPS is presented; the game ran ~14-15 real.

starts presented FPS p95 freezes
snapshot on (this PR) ~25 28.7-30.1 64-66 ms 0
snapshot off (main's default here) 3 30.1, 30.0, froze 54 ms 1: a capture's and a readback's write-back never came, in a conversation
main's defaults (snapshot off, event waits on) 3 27.8, 10.7 (a 767 ms stop at an event wait), load stall 68 / 469 ms 2 of 3 bad

With the snapshot off on current main, the screenshots are clean: #2419 did remove the slabs and stretched HUD seen before. The copy is still needed on the Deck for stability, at ~1 fps. The Linux suite in an Ubuntu 22.04 builder fails only what main fails there (test_prof_clock, test_cpp_preflight: the container's g++ 11).

Brett-D and others added 3 commits November 10, 2026 08:45
On integrated GPUs (no buffer mirrors) a uniform buffer bound in place is guest memory the game rewrites right after the submit while the GPU still runs. On the Steam Deck this drew giant HUD hearts, white and orange slabs and stretched geometry in 8-10 of 40 gameplay screenshots; with the copy, none of 40. RX_VK_UB_SNAPSHOT=0 restores the in-place binding.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A range copied as submitted serves later bindings of the same address and size in the recording while the capture view is unchanged, so a fresh read would return the same bytes. RX_VK_UB_SNAPSHOT_REUSE=0: copy every binding.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
On integrated GPUs the replay runs far behind the game, and a submission stopped at an event wait resolved texture handles against registrations made since. On the Steam Deck hearts and button glyphs came out black, misplaced or as other HUD text in most frames and the exposure stepped; in submit order every heart was right in 80 of 80 shots and the luminance spread fell from 8.6 to 1.4, at about 1.5 fps less. RX_VK_EVENT_WAITS=1 restores event ordering.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@Brett-D Brett-D changed the title fix(vk): copy uniform buffers as submitted on GPUs without buffer mirrors fix(vk): HUD and lighting artifacts on GPUs without buffer mirrors Nov 10, 2026
@Brett-D

Brett-D commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

@NotRexed When you have time, could you please review this? It fixes HUD and lighting artifacts on integrated GPUs (measured on a Steam Deck); discrete GPUs keep their current behavior. Thank you!

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

# Conflicts:
#	tests/run_all.py
@Brett-D

Brett-D commented Nov 11, 2026

Copy link
Copy Markdown
Contributor Author

Merged current main (f8efe05) into the branch. The only conflict was tests/run_all.py: main now reads each test's own declarations, so the two new tests declare RUN_ALL_ARGS = ["--cxx", "{cxx}"] (and skip on Windows, as their fixtures set the switch with POSIX setenv). The diff against main is the three commits again. Linux game-free suite: identical to clean main apart from the two added passing tests; a full Linux x86-64-v3 game build of main plus this compiles with 0 failures.

@Brett-D

Brett-D commented Nov 11, 2026

Copy link
Copy Markdown
Contributor Author

Related finding from Steam Deck testing with this PR's defaults (no mirrors, so RX_VK_EVENT_WAITS=0): on current main, invalidate()'s readback wait can block forever in wait_value(). It hung the game after a save loaded in 2 of 8 starts. #2222 bounds that wait at 250 ms (0 of 4 hung, gameplay unchanged). The two PRs are independent, but #2222 is the one that matters for anyone running with RX_VK_EVENT_WAITS=0.

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

Two things on main change the picture since the Deck numbers were taken. #2419 (merged) fixed fence waits returning after the first part of a split submission, and its description lists the same Deck symptoms (stretched HUD, white grass, whole-screen flashes). Could you re-run the 40-shot test on current main with RX_VK_UB_SNAPSHOT unset? If the slabs are gone, the default flip may not be needed at all. Second, #2343 turned mirrors off by default on GCN discrete cards (vk_memory.cpp:2102). Keying on memory_mirrors_on() now also flips both defaults on RX 400/500 and Vega, and nobody has measured that. The rebase conflicts with main's env_on read at vk_draw.cpp:2774. The new reads use getenv/atoi and treat an empty RX_VK_UB_SNAPSHOT as on, which is the opposite of env_on (#2042), so please switch them to env_on. hckrman101's point on commit 3 (event waits off without mirrors, against docs/design.md rule 1) is still open. I'd split that commit out.

blessedplummer added a commit to blessedplummer/Hookshot-optimi that referenced this pull request Nov 11, 2026
A dGPU has its own fast memory and a slower bus to system RAM; stock mirrors
every guest pool into VRAM and keeps both copies coherent with captures,
shadows and write-backs. At 4K that coherence is CPU-bound: the write-back
diff compares ~3.1 GB per 600 submits and mirror syncs move ~137k dirty
pages, for 35.6 fps where 1080p runs 60 (issue NotRexed#2099).

Under RX_VK_DGPU=1 a pool's first binding makes no mirror (MIRROR_REFUSED,
the state iGPU runs use today): reads take the lock-free TLS path and GPU
reads/writes land in the imported guest memory, coherent by construction -
no mirror sync, no write-back. Every 600-submit window the heaviest
guest-memory-read pool with >=1 MB of reads and not write-dominated runs a
measured trial (120 frames direct -> 12 settle, held until the mirror is
really on -> 120 mirrored, on the recordings' own timestamps); the mirror
stays only when the win is both >10% (RX_VK_DGPU_GAIN) and >0.25 ms
(RX_VK_DGPU_FLOOR) on present-to-present frame time, so the verdict sees
the mirror's CPU cost and its PCIe benefit alike. Trials whose windows did
not measure the same workload (zero binds, a >2x bind shift, a mirror that
never came) abort with no verdict and no skip line; a pool that aborts
three times sits out the session, and iGPUs run no trials. A lost verdict
persists to hookshot_mirror_skip.txt beside the game's own state files,
keyed to the device and driver (a foreign or header-less file is ignored
and re-trialled). RX_VK_DGPU_TRIALS=0 is pure direct. Trials need serial
replay (documented); under RX_VK_PARALLEL the policy runs trial-less.
Unset = stock exactly: the added counters and logging are gated on the
switch.

Refused pools read uniform buffers as submitted (NotRexed#1795's per-pool form:
the snapshot engages for mirror-less pools at the read, trial-won pools
keep in-place mirror binds), so the artifact class the issue documents on
mirror-less GPUs is covered under the policy too.

Measured on RX 9070 XT / RADV, 4K, Uncapped frame cap, warp-normalized
adjacent pairs (Hyrule Field): 70.2->78.0, 71.0->77.0, 59.6->76.6 fps
(+9.7% median), draw parity 705 vs 709, GPU 9.2 ms of a ~14 ms frame.
Mirror-sync dirty pages 136,872 -> 0; write-back diff 3.06-3.13 GB ->
330 MB or less per 600 submits.

Review round 2 (external): RX_VK_DGPU_TRIALS=0's predicate was inverted
and now matches the docs (=0 is pure direct); the window's read counter
moved off a shared map into the pool (dgpu_read, both direct paths);
Settle holds until the mirror is actually live; zero-bind baselines and
>2x bind drift between the direct and mirrored windows abort the trial
un-blacklisted; keep_line refuses signed and trailing-garbage lines.

Review round 3 (hckrman101, Joey-Magnus): refused pools read UBs as
submitted (NotRexed#1795 per-pool, live-smoked at 1.26M copies fault-free);
verdicts decide on present-to-present frame time instead of GPU-only
time (the mirror's CPU and upload-buffer cost is what the frame pays);
the policy runs trial-less under RX_VK_PARALLEL and its fast-path
counters are parallel-safe; no trials on iGPUs and a 3-abort cap; the
skip list carries a device+driver header and ignores foreign files; the
test driver uses {cxx}. Rebased onto main's env_on switch rule (NotRexed#2042)
and the split-submission fence work (NotRexed#2419).

Co-authored-by: GLM-5.3-Flash <glm-5.3-flash@opencode.noreply.github.com>
blessedplummer added a commit to blessedplummer/Hookshot-optimi that referenced this pull request Nov 11, 2026
A dGPU has its own fast memory and a slower bus to system RAM; stock mirrors
every guest pool into VRAM and keeps both copies coherent with captures,
shadows and write-backs. At 4K that coherence is CPU-bound: the write-back
diff compares ~3.1 GB per 600 submits and mirror syncs move ~137k dirty
pages, for 35.6 fps where 1080p runs 60 (issue NotRexed#2099).

Under RX_VK_DGPU=1 a pool's first binding makes no mirror (MIRROR_REFUSED,
the state iGPU runs use today): reads take the lock-free TLS path and GPU
reads/writes land in the imported guest memory, coherent by construction -
no mirror sync, no write-back. Every 600-submit window the heaviest
guest-memory-read pool with >=1 MB of reads and not write-dominated runs a
measured trial (120 frames direct -> 12 settle, held until the mirror is
really on -> 120 mirrored, on the recordings' own timestamps); the mirror
stays only when the win is both >10% (RX_VK_DGPU_GAIN) and >0.25 ms
(RX_VK_DGPU_FLOOR) on present-to-present frame time, so the verdict sees
the mirror's CPU cost and its PCIe benefit alike. Trials whose windows did
not measure the same workload (zero binds, a >2x bind shift, a mirror that
never came) abort with no verdict and no skip line; a pool that aborts
three times sits out the session, and iGPUs run no trials. A lost verdict
persists to hookshot_mirror_skip.txt beside the game's own state files,
keyed to the device and driver (a foreign or header-less file is ignored
and re-trialled). RX_VK_DGPU_TRIALS=0 is pure direct. Trials need serial
replay (documented); under RX_VK_PARALLEL the policy runs trial-less.
Unset = stock exactly: the added counters and logging are gated on the
switch.

Refused pools read uniform buffers as submitted (NotRexed#1795's per-pool form:
the snapshot engages for mirror-less pools at the read, trial-won pools
keep in-place mirror binds), so the artifact class the issue documents on
mirror-less GPUs is covered under the policy too.

Measured on RX 9070 XT / RADV, 4K, Uncapped frame cap, warp-normalized
adjacent pairs (Hyrule Field): 70.2->78.0, 71.0->77.0, 59.6->76.6 fps
(+9.7% median), draw parity 705 vs 709, GPU 9.2 ms of a ~14 ms frame.
Mirror-sync dirty pages 136,872 -> 0; write-back diff 3.06-3.13 GB ->
330 MB or less per 600 submits.

Review round 2 (external): RX_VK_DGPU_TRIALS=0's predicate was inverted
and now matches the docs (=0 is pure direct); the window's read counter
moved off a shared map into the pool (dgpu_read, both direct paths);
Settle holds until the mirror is actually live; zero-bind baselines and
>2x bind drift between the direct and mirrored windows abort the trial
un-blacklisted; keep_line refuses signed and trailing-garbage lines.

Review round 3 (hckrman101, Joey-Magnus): refused pools read UBs as
submitted (NotRexed#1795 per-pool, live-smoked at 1.26M copies fault-free);
verdicts decide on present-to-present frame time instead of GPU-only
time (the mirror's CPU and upload-buffer cost is what the frame pays);
the policy runs trial-less under RX_VK_PARALLEL and its fast-path
counters are parallel-safe; no trials on iGPUs and a 3-abort cap; the
skip list carries a device+driver header and ignores foreign files; the
test driver uses {cxx}. Rebased onto main's env_on switch rule (NotRexed#2042)
and the split-submission fence work (NotRexed#2419).

Co-authored-by: GLM-5.3-Flash <glm-5.3-flash@opencode.noreply.github.com>
blessedplummer added a commit to blessedplummer/Hookshot-optimi that referenced this pull request Nov 11, 2026
A dGPU has its own fast memory and a slower bus to system RAM; stock mirrors
every guest pool into VRAM and keeps both copies coherent with captures,
shadows and write-backs. At 4K that coherence is CPU-bound: the write-back
diff compares ~3.1 GB per 600 submits and mirror syncs move ~137k dirty
pages, for 35.6 fps where 1080p runs 60 (issue NotRexed#2099).

Under RX_VK_DGPU=1 a pool's first binding makes no mirror (MIRROR_REFUSED,
the state iGPU runs use today): reads take the lock-free TLS path and GPU
reads/writes land in the imported guest memory, coherent by construction -
no mirror sync, no write-back. Every 600-submit window the heaviest
guest-memory-read pool with >=1 MB of reads and not write-dominated runs a
measured trial (120 frames direct -> 12 settle, held until the mirror is
really on -> 120 mirrored, on the recordings' own timestamps); the mirror
stays only when the win is both >10% (RX_VK_DGPU_GAIN) and >0.25 ms
(RX_VK_DGPU_FLOOR) on present-to-present frame time, so the verdict sees
the mirror's CPU cost and its PCIe benefit alike. Trials whose windows did
not measure the same workload (zero binds, a >2x bind shift, a mirror that
never came) abort with no verdict and no skip line; a pool that aborts
three times sits out the session, and iGPUs run no trials. A lost verdict
persists to hookshot_mirror_skip.txt beside the game's own state files,
keyed to the device and driver (a foreign or header-less file is ignored
and re-trialled). RX_VK_DGPU_TRIALS=0 is pure direct. Trials need serial
replay (documented); under RX_VK_PARALLEL the policy runs trial-less.
Unset = stock exactly: the added counters and logging are gated on the
switch.

Refused pools read uniform buffers as submitted (NotRexed#1795's per-pool form:
the snapshot engages for mirror-less pools at the read, trial-won pools
keep in-place mirror binds), so the artifact class the issue documents on
mirror-less GPUs is covered under the policy too.

Measured on RX 9070 XT / RADV, 4K, Uncapped frame cap, warp-normalized
adjacent pairs (Hyrule Field): 70.2->78.0, 71.0->77.0, 59.6->76.6 fps
(+9.7% median), draw parity 705 vs 709, GPU 9.2 ms of a ~14 ms frame.
Mirror-sync dirty pages 136,872 -> 0; write-back diff 3.06-3.13 GB ->
330 MB or less per 600 submits.

Review round 2 (external): RX_VK_DGPU_TRIALS=0's predicate was inverted
and now matches the docs (=0 is pure direct); the window's read counter
moved off a shared map into the pool (dgpu_read, both direct paths);
Settle holds until the mirror is actually live; zero-bind baselines and
>2x bind drift between the direct and mirrored windows abort the trial
un-blacklisted; keep_line refuses signed and trailing-garbage lines.

Review round 3 (hckrman101, Joey-Magnus): refused pools read UBs as
submitted (NotRexed#1795 per-pool, live-smoked at 1.26M copies fault-free);
verdicts decide on present-to-present frame time instead of GPU-only
time (the mirror's CPU and upload-buffer cost is what the frame pays);
the policy runs trial-less under RX_VK_PARALLEL and its fast-path
counters are parallel-safe; no trials on iGPUs and a 3-abort cap; the
skip list carries a device+driver header and ignores foreign files; the
test driver uses {cxx}. Rebased onto main's env_on switch rule (NotRexed#2042) and the split-submission
fence work (NotRexed#2419); the trials' functions sit outside the write-back
observation's compile-span check (NotRexed#2131) and the GCN mirror-less default
(NotRexed#2343) composes unchanged (on GCN the switch runs trial-less direct plus
the UB snapshot).

Co-authored-by: GLM-5.3-Flash <glm-5.3-flash@opencode.noreply.github.com>
elkarbeenus pushed a commit to elkarbeenus/LakuenRecompBuilder that referenced this pull request Nov 11, 2026
…ly the first (NotRexed#2419)

On AMD integrated GPUs (ROG Ally, Legion Go, Steam Deck) the picture
breaks for one frame at a time: the HUD drawn stretched across the
screen, grass flat white, the whole screen flashing. The game rewrites
memory the GPU is still reading, because its fence waits return after
the first part of a submission.

## Cause

A submission longer than `RX_VK_CHUNK` draws (default 120), or one
stopped at an event wait and finished later, is recorded in several
recordings that all carry its value. `submit_recording` maps the value
to its recording with `value_rec.emplace`, which keeps the **first**
one: when the last part finishes the submission, the key is already
there and nothing changes. So `wait_recorded` (the game's fence waits in
`wait()`, and the exposure readback wait) returns once the first ~120
draws are done.

With no VRAM mirror of the buffer pools (integrated GPUs), the later
parts read guest memory in place, and the game has by then written the
next frame's uniforms, vertices and exposure meter into it. The HUD is
drawn late in the frame, so it gets the wrong transforms most often; the
stale exposure readback gives the whole-screen flashes.

myylkn found the same root cause on a Radeon 8060S (NotRexed#997, with a patch
in a comment). This PR is a variant of that patch:

- `find` and an update in place rather than `value_rec[v]`, so it builds
against `tests/draw_stream_visibility_test.cpp`, which models
`value_rec` as a `std::multimap`.
- It keeps registering `r.value` when the recording does not signal (the
parts split off before the last), raised to the latest seq. A wait on a
stopped submission still finds its recording, instead of falling back to
the timeline wait, which forces stopped work past its event waits.

## Change

- `gpu/vk/vk_replay.cpp`, `submit_recording`: each value maps to the
latest recording that carries it.
- `tests/test_value_rec_last_part.py`: runs the statements cut from
`submit_recording`, for a submission split in three parts and for a
stopped submission finished in a later recording. It fails on `main`
(`value 10 -> recording 1 (expected 3)`, `value 11 -> recording 4
(expected 5)`) and passes with the change.
- `docs/tested-configurations.md`: the three runs below.

## Tested

ROG Ally X (Ryzen Z1 Extreme, RADV PHOENIX, Mesa 26.2.3), Linux
(Anatase, Fedora-based), counting one-frame luminance spikes in screen
recordings (a frame brighter or darker than both its neighbours):

| v2.0.13, Hyrule Field | One-frame spikes | 15/255 or more | `GPU waits
in 5 s` |
|---|---|---|---|
| without the change (69 s) | 104 | 15 | 4–109 sync waits, 31–232 ms |
| with the change (42 s) | 3 | 0 (the largest is a sword hit) | 103–137
sync waits, 1.4–1.8 s |

Then about a day and a half of play with the change, rebuilt for v2.0.14
and v2.1.0 (the renderer alone, `RX_VK_ONLY=1`): no flashes or stray HUD
seen in Hyrule Field, Death Mountain Trail and Goron City, in Desktop
Mode and in Steam's Gaming Mode. A 57 s Gaming Mode recording on v2.0.14
had no one-frame spikes at all.

**It costs frame rate on this device.** The FPS overlay in the field
read roughly 45–70 before and 38–49 after (two recordings, not a
controlled benchmark): part of the old frame rate was the game running
ahead of the GPU. The waits now last as long as the GPU work they stand
for.

`python -I -B tests/run_all.py` with the Vulkan headers: the same
results on a clean checkout of `main` with and without the change, apart
from the new test, which passes. `test_draw_stream_visibility`,
`test_capture_slot_ownership` and `test_parallel_rb_refresh`, which
compile `submit_recording`, pass.

**Not tested:** Windows, NVIDIA and discrete AMD cards (the early wait
is there too, but their VRAM mirror is synced at the submit, which
should hide it; not measured), `RX_VK_PARALLEL` segments (off by
default; the same mapping applies to them), and the Steam Deck.

## Related

- Part of NotRexed#997 (AMD handhelds and APUs tracker).
- NotRexed#1795 copies uniform buffers on GPUs without mirrors for the same
symptoms. The two can coexist: that one keeps a late write from reaching
the GPU; this one makes the fence waits cover the work they stand for.
An A/B of NotRexed#1795 with this change in and `RX_VK_UB_SNAPSHOT=0` would show
how much of its effect was this wait.
- NotRexed#2125 (merged) lets stopped work finish before a CPU readback.
Complementary: the readback's wait that follows goes through
`value_rec`, which this corrects.

## Screenshots

Before (v2.0.13, Hyrule Field and Castle Town; each strip is the frame
before, the broken frame, the frame after):

<img width="1920" height="335" alt="before-1"
src="https://github.com/user-attachments/assets/4d3ee32e-8575-462e-b972-b3bb16ec2f3e"
/>

<img width="1920" height="335" alt="before-2"
src="https://github.com/user-attachments/assets/2231f72b-3394-46b0-b67d-647674712a29"
/>

<img width="1920" height="335" alt="before-3"
src="https://github.com/user-attachments/assets/d2d645a7-bf2a-41c3-979a-19b2a36562ff"
/>

<img width="1920" height="335" alt="before-4"
src="https://github.com/user-attachments/assets/366157ae-41e2-48a4-b4cd-7fc96f817eaa"
/>

After (v2.0.13 with the change, Hyrule Field, fights included; the last
strip is the largest one-frame spike left, a sword hit):

<img width="1920" height="333" alt="after-1"
src="https://github.com/user-attachments/assets/9f025025-1034-47ab-bfe9-0ea1476db075"
/>

<img width="1920" height="333"
alt="after-2-largest-spike-is-a-sword-hit"
src="https://github.com/user-attachments/assets/e7e26d16-ba97-4982-bed0-670306352113"
/>

The analysis and the change were made with AI assistance (Claude); the
testing and recordings are mine.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: hckrman101 <239247847+hckrman101@users.noreply.github.com>
(cherry picked from commit e0dbf40)
blessedplummer pushed a commit to blessedplummer/Hookshot-optimi that referenced this pull request Nov 11, 2026
The review round's bind-drift invalidation read the mirror doing its job
as the workload moving: dgpu_binds counted only the binds served from
guest memory, so a working mirror collapsed them (~70x, measured: 12420
before, 180 after) and the check voided the trial - 42-44 voids a run,
only pools the mirror does not help ever reached a verdict, the 3-abort
cap never fired (it covered only mirror-never-on), and the make/upload/
void/free churn repeated every run without converging.

- dgpu_binds now counts every bind of the pool at its serving path - the
  fast path's direct and mirror returns, the locked direct paths, the
  locked mirror return - so the volume guard compares the game's actual
  binding work, before and after. The window reset skips the trial pool
  (its count is phase-owned); every other pool's counts the trailing
  window for the scene set.
- The scene moving is its own signal: the pools each window bound
  meaningfully (>= 64 binds of the trailing ~600-submit window) are
  compared as a set at the verdict - a scene change swaps them, a working
  mirror does not. dgpu::ab_voided(base, binds, scene_moved) is the rule,
  with the measured case pinned in the fixture.
- Three no-verdict endings (voids and settle give-ups share the count)
  sit a pool out for the session, like the abort cap: the churn cannot
  repeat even where detection is imperfect. Measured live: binds 240
  before, 240 after with the mirror serving (was 12420 -> 180), and the
  third no-verdict ending sits the pool out.
- The tests follow: the fixture pins the rule, the source checks read the
  new shapes, and the two cut-and-compile fixtures that carry the binding
  paths build against the changed tail (dgpu_on stubbed where the cut has
  no trial state; the now-unused trial stub dropped).

The NotRexed#1795 UB snapshot is left as the measured-informed review found it:
its only consumer is the uniform-buffer bind - exactly the class NotRexed#1795's
own fix covers - and with mirrors earning again, the heavy readers' binds
serve from mirrors the snapshot never sees. The re-measurement gives the
honest number.

Co-authored-by: GLM-5.3-Flash <glm-5.3-flash@opencode.noreply.github.com>
blessedplummer added a commit to blessedplummer/Hookshot-optimi that referenced this pull request Nov 11, 2026
A dGPU has its own fast memory and a slower bus to system RAM; stock mirrors
every guest pool into VRAM and keeps both copies coherent with captures,
shadows and write-backs. At 4K that coherence is CPU-bound: the write-back
diff compares ~3.1 GB per 600 submits and mirror syncs move ~137k dirty
pages, for 35.6 fps where 1080p runs 60 (issue NotRexed#2099).

Under RX_VK_DGPU=1 a pool's first binding makes no mirror (MIRROR_REFUSED,
the state iGPU runs use today): reads take the lock-free TLS path and GPU
reads/writes land in the imported guest memory, coherent by construction -
no mirror sync, no write-back. Every 600-submit window the heaviest
guest-memory-read pool with >=1 MB of reads and not write-dominated runs a
measured trial (120 frames direct -> 12 settle, held until the mirror is
really on -> 120 mirrored, on the recordings' own timestamps); the mirror
stays only when the win is both >10% (RX_VK_DGPU_GAIN) and >0.25 ms
(RX_VK_DGPU_FLOOR) on present-to-present frame time, so the verdict sees
the mirror's CPU cost and its PCIe benefit alike. Trials whose windows did
not measure the same workload (zero binds, a >2x bind shift, a mirror that
never came) abort with no verdict and no skip line; a pool that aborts
three times sits out the session, and iGPUs run no trials. A lost verdict
persists to hookshot_mirror_skip.txt beside the game's own state files,
keyed to the device and driver (a foreign or header-less file is ignored
and re-trialled). RX_VK_DGPU_TRIALS=0 is pure direct. Trials need serial
replay (documented); under RX_VK_PARALLEL the policy runs trial-less.
Unset = stock exactly: the added counters and logging are gated on the
switch.

Refused pools read uniform buffers as submitted (NotRexed#1795's per-pool form:
the snapshot engages for mirror-less pools at the read, trial-won pools
keep in-place mirror binds), so the artifact class the issue documents on
mirror-less GPUs is covered under the policy too.

Measured on RX 9070 XT / RADV, 4K, Uncapped frame cap, warp-normalized
adjacent pairs (Hyrule Field): 70.2->78.0, 71.0->77.0, 59.6->76.6 fps
(+9.7% median), draw parity 705 vs 709, GPU 9.2 ms of a ~14 ms frame.
Mirror-sync dirty pages 136,872 -> 0; write-back diff 3.06-3.13 GB ->
330 MB or less per 600 submits.

Review round 2 (external): RX_VK_DGPU_TRIALS=0's predicate was inverted
and now matches the docs (=0 is pure direct); the window's read counter
moved off a shared map into the pool (dgpu_read, both direct paths);
Settle holds until the mirror is actually live; zero-bind baselines and
>2x bind drift between the direct and mirrored windows abort the trial
un-blacklisted; keep_line refuses signed and trailing-garbage lines.

Review round 3 (hckrman101, Joey-Magnus): refused pools read UBs as
submitted (NotRexed#1795 per-pool, live-smoked at 1.26M copies fault-free);
verdicts decide on present-to-present frame time instead of GPU-only
time (the mirror's CPU and upload-buffer cost is what the frame pays);
the policy runs trial-less under RX_VK_PARALLEL and its fast-path
counters are parallel-safe; no trials on iGPUs and a 3-abort cap; the
skip list carries a device+driver header and ignores foreign files; the
test driver uses {cxx}. Follow-up fix: the drift check counted served
volume, so a working mirror's read collapse voided 40+ trials a run and
trials never converged; binds now count once at the serving path and the
drift rule compares the scene's pool set, with a three-no-verdict cap. Rebased onto main's env_on switch rule (NotRexed#2042) and the split-submission
fence work (NotRexed#2419); the trials' functions sit outside the write-back
observation's compile-span check (NotRexed#2131) and the GCN mirror-less default
(NotRexed#2343) composes unchanged (on GCN the switch runs trial-less direct plus
the UB snapshot).

Co-authored-by: GLM-5.3-Flash <glm-5.3-flash@opencode.noreply.github.com>
blessedplummer pushed a commit to blessedplummer/Hookshot-optimi that referenced this pull request Nov 11, 2026
The review round's bind-drift invalidation read the mirror doing its job
as the workload moving: dgpu_binds counted only the binds served from
guest memory, so a working mirror collapsed them (~70x, measured: 12420
before, 180 after) and the check voided the trial - 42-44 voids a run,
only pools the mirror does not help ever reached a verdict, the 3-abort
cap never fired (it covered only mirror-never-on), and the make/upload/
void/free churn repeated every run without converging.

- dgpu_binds now counts every bind of the pool at its serving path - the
  fast path's direct and mirror returns, the locked direct paths, the
  locked mirror return - so the volume guard compares the game's actual
  binding work, before and after. The window reset skips the trial pool
  (its count is phase-owned); every other pool's counts the trailing
  window for the scene set.
- The scene moving is its own signal: the pools each window bound
  meaningfully (>= 64 binds of the trailing ~600-submit window) are
  compared as a set at the verdict - a scene change swaps them, a working
  mirror does not. dgpu::ab_voided(base, binds, scene_moved) is the rule,
  with the measured case pinned in the fixture.
- Three no-verdict endings (voids and settle give-ups share the count)
  sit a pool out for the session, like the abort cap: the churn cannot
  repeat even where detection is imperfect. Measured live: binds 240
  before, 240 after with the mirror serving (was 12420 -> 180), and the
  third no-verdict ending sits the pool out.
- The tests follow: the fixture pins the rule, the source checks read the
  new shapes, and the two cut-and-compile fixtures that carry the binding
  paths build against the changed tail (dgpu_on stubbed where the cut has
  no trial state; the now-unused trial stub dropped).

The NotRexed#1795 UB snapshot is left as the measured-informed review found it:
its only consumer is the uniform-buffer bind - exactly the class NotRexed#1795's
own fix covers - and with mirrors earning again, the heavy readers' binds
serve from mirrors the snapshot never sees. The re-measurement gives the
honest number.

Co-authored-by: GLM-5.3-Flash <glm-5.3-flash@opencode.noreply.github.com>
blessedplummer added a commit to blessedplummer/Hookshot-optimi that referenced this pull request Nov 11, 2026
A dGPU has its own fast memory and a slower bus to system RAM; stock mirrors
every guest pool into VRAM and keeps both copies coherent with captures,
shadows and write-backs. At 4K that coherence is CPU-bound: the write-back
diff compares ~3.1 GB per 600 submits and mirror syncs move ~137k dirty
pages, for 35.6 fps where 1080p runs 60 (issue NotRexed#2099).

Under RX_VK_DGPU=1 a pool's first binding makes no mirror (MIRROR_REFUSED,
the state iGPU runs use today): reads take the lock-free TLS path and GPU
reads/writes land in the imported guest memory, coherent by construction -
no mirror sync, no write-back. Every 600-submit window the heaviest
guest-memory-read pool with >=1 MB of reads and not write-dominated runs a
measured trial (120 frames direct -> 12 settle, held until the mirror is
really on -> 120 mirrored, on the recordings' own timestamps); the mirror
stays only when the win is both >10% (RX_VK_DGPU_GAIN) and >0.25 ms
(RX_VK_DGPU_FLOOR) on present-to-present frame time, so the verdict sees
the mirror's CPU cost and its PCIe benefit alike. Trials whose windows did
not measure the same workload (zero binds, a >2x bind shift, a mirror that
never came) abort with no verdict and no skip line; a pool that aborts
three times sits out the session, and iGPUs run no trials. A lost verdict
persists to hookshot_mirror_skip.txt beside the game's own state files,
keyed to the device and driver (a foreign or header-less file is ignored
and re-trialled). RX_VK_DGPU_TRIALS=0 is pure direct. Trials need serial
replay (documented); under RX_VK_PARALLEL the policy runs trial-less.
Unset = stock exactly: the added counters and logging are gated on the
switch.

Refused pools read uniform buffers as submitted (NotRexed#1795's per-pool form:
the snapshot engages for mirror-less pools at the read, trial-won pools
keep in-place mirror binds), so the artifact class the issue documents on
mirror-less GPUs is covered under the policy too.

Measured on RX 9070 XT / RADV, 4K, Uncapped frame cap, warp-normalized
adjacent pairs (Hyrule Field): 70.2->78.0, 71.0->77.0, 59.6->76.6 fps
(+9.7% median), draw parity 705 vs 709, GPU 9.2 ms of a ~14 ms frame.
Mirror-sync dirty pages 136,872 -> 0; write-back diff 3.06-3.13 GB ->
330 MB or less per 600 submits.

Review round 2 (external): RX_VK_DGPU_TRIALS=0's predicate was inverted
and now matches the docs (=0 is pure direct); the window's read counter
moved off a shared map into the pool (dgpu_read, both direct paths);
Settle holds until the mirror is actually live; zero-bind baselines and
>2x bind drift between the direct and mirrored windows abort the trial
un-blacklisted; keep_line refuses signed and trailing-garbage lines.

Review round 3 (hckrman101, Joey-Magnus): refused pools read UBs as
submitted (NotRexed#1795 per-pool, live-smoked at 1.26M copies fault-free);
verdicts decide on present-to-present frame time instead of GPU-only
time (the mirror's CPU and upload-buffer cost is what the frame pays);
the policy runs trial-less under RX_VK_PARALLEL and its fast-path
counters are parallel-safe; no trials on iGPUs and a 3-abort cap; the
skip list carries a device+driver header and ignores foreign files; the
test driver uses {cxx}. Follow-up fix: the drift check counted served
volume, so a working mirror's read collapse voided 40+ trials a run and
trials never converged; binds now count once at the serving path and the
drift rule compares the scene's pool set, with a three-no-verdict cap. Rebased onto main's env_on switch rule (NotRexed#2042) and the split-submission
fence work (NotRexed#2419); the trials' functions sit outside the write-back
observation's compile-span check (NotRexed#2131) and the GCN mirror-less default
(NotRexed#2343) composes unchanged (on GCN the switch runs trial-less direct plus
the UB snapshot). After the presenter-thread refactor (NotRexed#2325) the
present-to-present tick lives in present_finish, which every made present
passes exactly once in either threading mode, with a headless twin so
offscreen runs stay measurable.

Co-authored-by: GLM-5.3-Flash <glm-5.3-flash@opencode.noreply.github.com>
blessedplummer added a commit to blessedplummer/Hookshot-optimi that referenced this pull request Nov 12, 2026
A dGPU has its own fast memory and a slower bus to system RAM; stock mirrors
every guest pool into VRAM and keeps both copies coherent with captures,
shadows and write-backs. At 4K that coherence is CPU-bound: the write-back
diff compares ~3.1 GB per 600 submits and mirror syncs move ~137k dirty
pages, for 35.6 fps where 1080p runs 60 (issue NotRexed#2099).

Under RX_VK_DGPU=1 a pool's first binding makes no mirror (MIRROR_REFUSED,
the state iGPU runs use today): reads take the lock-free TLS path and GPU
reads/writes land in the imported guest memory, coherent by construction -
no mirror sync, no write-back. Every 600-submit window the heaviest
guest-memory-read pool with >=1 MB of reads and not write-dominated runs a
measured trial (120 frames direct -> 12 settle, held until the mirror is
really on -> 120 mirrored, on the recordings' own timestamps); the mirror
stays only when the win is both >10% (RX_VK_DGPU_GAIN) and >0.25 ms
(RX_VK_DGPU_FLOOR) on present-to-present frame time, so the verdict sees
the mirror's CPU cost and its PCIe benefit alike. Trials whose windows did
not measure the same workload (zero binds, a >2x bind shift, a mirror that
never came) abort with no verdict and no skip line; a pool that aborts
three times sits out the session, and iGPUs run no trials. A lost verdict
persists to hookshot_mirror_skip.txt beside the game's own state files,
keyed to the device and driver (a foreign or header-less file is ignored
and re-trialled). RX_VK_DGPU_TRIALS=0 is pure direct. Trials need serial
replay (documented); under RX_VK_PARALLEL the policy runs trial-less.
Unset = stock exactly: the added counters and logging are gated on the
switch.

Refused pools read uniform buffers as submitted (NotRexed#1795's per-pool form:
the snapshot engages for mirror-less pools at the read, trial-won pools
keep in-place mirror binds), so the artifact class the issue documents on
mirror-less GPUs is covered under the policy too.

Measured on RX 9070 XT / RADV, 4K, Uncapped frame cap, warp-normalized
adjacent pairs (Hyrule Field): 70.2->78.0, 71.0->77.0, 59.6->76.6 fps
(+9.7% median), draw parity 705 vs 709, GPU 9.2 ms of a ~14 ms frame.
Mirror-sync dirty pages 136,872 -> 0; write-back diff 3.06-3.13 GB ->
330 MB or less per 600 submits.

Review round 2 (external): RX_VK_DGPU_TRIALS=0's predicate was inverted
and now matches the docs (=0 is pure direct); the window's read counter
moved off a shared map into the pool (dgpu_read, both direct paths);
Settle holds until the mirror is actually live; zero-bind baselines and
>2x bind drift between the direct and mirrored windows abort the trial
un-blacklisted; keep_line refuses signed and trailing-garbage lines.

Review round 3 (hckrman101, Joey-Magnus): refused pools read UBs as
submitted (NotRexed#1795 per-pool, live-smoked at 1.26M copies fault-free);
verdicts decide on present-to-present frame time instead of GPU-only
time (the mirror's CPU and upload-buffer cost is what the frame pays);
the policy runs trial-less under RX_VK_PARALLEL and its fast-path
counters are parallel-safe; no trials on iGPUs and a 3-abort cap; the
skip list carries a device+driver header and ignores foreign files; the
test driver uses {cxx}. Follow-up fix: the drift check counted served
volume, so a working mirror's read collapse voided 40+ trials a run and
trials never converged; binds now count once at the serving path and the
drift rule compares the scene's pool set, with a three-no-verdict cap.
The set rule still voided every trial in a streaming scene, so anchors
now match by guest range (a recycled pool is the same anchor), anchor at
4x the floor, void only when a substantial fraction of the scene moves,
and the candidate floor rose to 256 MB of reads a window (the verdict
floor is unreachable below it - a trial there is churn by construction).
Rebased onto main's env_on switch rule (NotRexed#2042) and the split-submission
fence work (NotRexed#2419); the trials' functions sit outside the write-back
observation's compile-span check (NotRexed#2131) and the GCN mirror-less default
(NotRexed#2343) composes unchanged (on GCN the switch runs trial-less direct plus
the UB snapshot). After the presenter-thread refactor (NotRexed#2325) the
present-to-present tick lives in present_finish, which every made present
passes exactly once in either threading mode, with a headless twin so
offscreen runs stay measurable.

Co-authored-by: GLM-5.3-Flash <glm-5.3-flash@opencode.noreply.github.com>
Brett-D and others added 3 commits November 12, 2026 00:22
main's NotRexed#2464 copies uniform buffers on Intel without mirrors; this keeps that and extends the default to every integrated GPU without mirrors (the Steam Deck), not to discrete GPUs without them (GCN since NotRexed#2343). The reads use env_on; a copy that could not be read is not kept for reuse. The event-wait default (e5d5054) is taken out here, to go separately with the design note.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@Brett-D

Brett-D commented Nov 12, 2026

Copy link
Copy Markdown
Contributor Author

@Joey-Magnus @hckrman101 Thanks for the reviews. I merged current main (46780503) and changed the PR as you asked:

  • Re-run on current main with RX_VK_UB_SNAPSHOT unset (main's default on AMD is off): the screenshots are clean now. No slabs and no stretched HUD in 160 shots, so fix(vk): a fence wait covers every part of a split submission, not only the first #2419 fixed those. But 1 of 3 starts froze in a conversation, and the log shows a capture's and a readback's wait for write-back N timing out with the timeline stuck at N-1. Main's own defaults (snapshot off, event waits on) went 1 good start of 3: one stopped up to 767 ms at an event wait (10.7 fps, p95 469 ms), and one stalled while loading. With the copy, there were 0 freezes in ~25 starts, at ~1 fps. So I've kept the default on, narrowed as below.
  • GCN discrete cards: the default is now "Intel (fix(vk): snapshot uniform buffers on Intel when the buffer pools have no VRAM mirror #2464's rule) or VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU, with no mirrors", so mirrorless discrete GPUs keep main's behaviour. memory_mirrors_on() is gone and the PR uses main's pools_mirrored().
  • env_on: RX_VK_UB_SNAPSHOT and RX_VK_UB_SNAPSHOT_REUSE follow hookshot_env.h. The test covers unset, empty, 0/off/no/false and 1/on/yes/true, plus integrated, discrete and Intel.
  • Unreadable copy: a copy whose memory_read_as_submitted failed is no longer kept for reuse.
  • Event waits (old commit 3): removed from this PR, as you suggested. On the Deck, event ordering on current main still shows problems: the stalls above, and with the copy on, missing sunlit ground lighting in every shot at ~1.5 fps less. I haven't checked hckrman101's theory (hold texture registrations while blocked_n > 0). If that commit comes back, it will be its own PR with the design.md rule 1 update.

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

My points are handled. The default now applies to Intel or VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU without mirrors, using main's pools_mirrored(), so GCN discrete cards keep main's behaviour. Both switches follow env_on's rule, a copy whose read failed isn't kept, and the event-wait commit is gone. Thanks for re-running on current main with the snapshot off, which answers whether the copy is still needed. I merged d89045e into current main (clean) and ran test_ub_snapshot_default and test_env_on, and both pass. The only red CI job is test_release_scripts, which is flaky.

One thing for maintainers: INTEGRATED_GPU covers more than the Deck. AMD APUs on Windows (the Ally, 680M and 780M laptops) get the copy by default too, and it was only measured on the Deck. Design rule 2 asks for the copy, so I think it's the right default, but it's worth knowing.

The title no longer matches the change. Since #2419 the HUD and lighting artifacts are gone, and what this fixes now is the Deck freezing in conversations. Something like "fix(vk): copy uniform buffers as submitted on integrated GPUs without mirrors" would read better in the changelog.

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.

2 participants