Skip to content

fix(vk): a fence wait covers every part of a split submission, not only the first - #2419

Merged
hckrman101 merged 3 commits into
NotRexed:mainfrom
willcrisis:fix/value-rec-last-part
Nov 11, 2026
Merged

hckrman101 merged 3 commits into
NotRexed:mainfrom
willcrisis:fix/value-rec-last-part

Conversation

@willcrisis

@willcrisis willcrisis commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

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 (#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

Screenshots

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

before-1 before-2 before-3 before-4

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

after-1 after-2-largest-spike-is-a-sword-hit

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

🤖 Generated with Claude Code

willcrisis and others added 2 commits November 11, 2026 15:04
…ly the first

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

A submission longer than RX_VK_CHUNK draws (default 120), or one stopped at an event wait, is
recorded in several recordings that all carry its value. submit_recording kept the first one's
seq (value_rec.emplace), so wait_recorded -- the game's fence waits and the exposure readback
wait -- ended once the first part was done. Without a VRAM mirror the later parts read guest
memory in place, which the game had already filled with the next frame's uniforms, vertices and
exposure meter.

Each value now maps to the latest recording that carries it. tests/test_value_rec_last_part.py
runs the statements cut from submit_recording; it fails on the old code (the split and the
stopped case).

See NotRexed#997

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@willcrisis
willcrisis marked this pull request as ready for review November 11, 2026 18:30

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

This looks right to me, and it's a well-made PR: a clear cause, a fixture cut from the real code, and before/after recordings on hardware. I confirmed that emplace keeps the first part's seq, and that maybe_split (gpu/vk/vk_replay.cpp:1321), the parallel segments (:1480) and finish_recording (:1892) all submit recordings that carry the same r.value. Before this change, wait_recorded therefore waited on the first part. I ran test_value_rec_last_part, test_draw_stream_visibility, test_capture_slot_ownership and test_parallel_rb_refresh on the PR's head, and they pass. As a negative control I pointed the new test at main's vk_replay.cpp with --source: it fails the two cases you list. The frame-rate cost is honest and expected, since the waits now cover the work they stand for.

question gpu/vk/vk_replay.cpp:382-388 and :2143-2150: a window remains while a submission is still being recorded. Once the first part has gone through maybe_split, value_rec[v] is that part's seq until the last part is submitted. wait() doesn't take replay_mu, and q_last/value_queue are set before submit_job (:4261). So another game thread that waits on that queue's fence in that window still has mine true (the queue isn't blocked) and returns after the first part only. The same holds after a stopped submission is let go and while its remainder is being recorded. This is much narrower than before, when the mapping stayed wrong for good, so I don't think it should hold the PR up. If you want to close it, one option is to mark the value as "still recording" (for example, a set under rec_map_mu that run_submit fills while it records and finish_recording clears). wait_recorded would then return false for a marked value and fall back to the timeline wait. If it isn't worth it, a one-line note in the comment that the map is final only once the last part is submitted would help the next reader.

minor: the PR conflicts with main in docs/tested-configurations.md only (rows added at the top since). The code merges cleanly.

@hckrman101
hckrman101 merged commit e0dbf40 into NotRexed:main Nov 11, 2026
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)
elkarbeenus pushed a commit to elkarbeenus/LakuenRecompBuilder that referenced this pull request Nov 11, 2026
…neration off on first start; upstream NotRexed#2419 NotRexed#2420 NotRexed#2439

A Steam Deck ran ~20 fps short of CPU (the GPU waited on 0.4 ms a frame): short sleeps no longer spin in yield
loops there (HOS_SHORT_SLEEP_TIMER decides), and made frames no longer take GPU work out of its power budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7fdTPXbwDRohew9qanvbp
hckrman101 added a commit to Erfboom/longshot that referenced this pull request Nov 11, 2026
vk_replay.cpp: main (NotRexed#2419) already keeps each value's latest recording in
value_rec; keep main's version of that hunk.
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 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>
Sellierm added a commit to Sellierm/Hookshot that referenced this pull request Nov 12, 2026
… no VRAM mirror

With no VRAM mirror (mirror_on() off) a draw's uniform buffers are bound in
the guest pools, and the GPU reads them when it runs the frame: by then the
game has written the next frame's per-object matrices over them. On Intel
Core Ultra iGPUs the Kokiri depth prepass drew the ground off screen and the
fog pass painted sky over it (the white veil, NotRexed#999). Still there on main
after NotRexed#2419.

RX_VK_UB_SNAPSHOT (copy the uniforms into the stream as submitted) is now on
by default on Intel when the pools are not mirrored. Other unmirrored GPUs
(AMD APUs, GCN since NotRexed#2343, RX_VK_MIRROR=0) keep it off until measured.
RX_VK_UB_SNAPSHOT=0/1 overrides.

Assisted-by: Claude Code
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.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>
hckrman101 added a commit that referenced this pull request Nov 12, 2026
…nd no game thread waits behind a present for nothing (#2602)

Hyrule Field runs about 15 fps slower on `main` than on v2.0.11-588
(`ba86aee`), and uses almost twice the CPU. One of the game's job
threads sits blocked for 5.5 ms of every frame in a CPU read of GPU
results, and the other job threads poll while they wait for it. This PR
removes that wait without undoing what #2125 and #2419 fixed.

## What regressed

RTX 3090, Ryzen 9 7900X, Linux, 2560x1440, uncapped, standing in Hyrule
Field from the same save:

| | v2.0.11-588 (`ba86aee`) | main (`fce53453`) |
|---|---|---|
| fps | 79 | 64-71 |
| CPU a frame, all threads | 39-40 ms | 64-73 ms |
| readbacks that waited, of 3000 | ~1300, 0.13 ms each | ~172, 5.5 ms
each |
| time blocked in readbacks, per 3000 | ~170 ms | ~940 ms |

GPU time and the replay's own time are the same on both.

## Cause

An all-thread sample of both builds (`HOS_SAMPLE_ALL`) shows the extra
CPU is one item: `sched_yield` in the host's short-sleep loop, 0.28
cores on the old build and 1.92 on main. It is the game's six job
threads (`ModuleSystemWorker1-5` and `sead::MainThread`), which poll
with 20 us sleeps while one of them is blocked. On main a job thread is
inside a readback at 38.7 % of all instants, with 3.9 threads polling
meanwhile.

The blocked read is of the results that the first part of the scene's
submission writes (two buffers, alternating by frame). That submission
then stops at an event wait and is finished later, on the presenting
thread. Three things held the read up:

1. **It waited for the whole submission.** Since #2419 a submission's
value maps to its last part's recording, which is right for a fence. A
readback used the same map, so it waited for all of the scene's GPU work
(3-4 ms) for pages the first part had already written back.
2. **It took `sync_order_mu` whenever any queue was stopped.** #2125
added a look at the stopped queues before the read. The presenting
thread holds that mutex for the whole replay of its submission and for
its present, so the read waited for both (24 % of all instants, 3.9 ms a
frame) for a look that let nothing on.
3. **It took `dev.present_mu` to flush held submits.** Held submits
exist only with batched submits (RADV). A present holds that mutex about
4 ms a frame. A readback whose recording was already done waited there,
and so did the host's copy bookkeeping (`rx_nvn_replayed`, called under
its own lock), which queued the other job threads behind it. The old
build has this one too.

## The fix

All of it is on by default; the two switches only turn parts off, for
A/B.

1. **A readback waits for the part that writes its pages back.** Each
part of a split submission copies back, at its own end, what the GPU
wrote in it. The page table now keeps that part with the page's value,
and the read waits for the newest part of its pages. Fence waits are
unchanged. `RX_VK_READBACK_PART=0` restores the last part. The read
still waits for its submission's last part, as on main, in two cases:
- a page of its range was written in place (no mirror, as on the iGPUs
of issue 997);
- a part after its own reads guest memory in place: a pool bound with no
mirror, a mirror's dirty pages that no capture holds, a mirror's first
copy, a large texture unswizzled on the GPU, a texel buffer's view. The
log's readbacks line counts these.
2. **The look at the stopped queues happens only when it can let
something on.** That is after a CPU signal (`nvnEventSignal`) nobody has
looked at since, or while a queue is stopped at an event that neither
the stream nor `nvnEventSignal` ever set, whose value is read from guest
memory. A look counts as taken only once the burst it ran in has
finished its recording, so its write-backs are in the page table. In the
field 0-9 of 3000 readbacks look; on main every readback that found a
queue stopped did. `RX_VK_READBACK_SETTLE_ALWAYS=1` restores the look
whenever anything is stopped.
3. **A flush with nothing held does not take `dev.present_mu`.** An
atomic says whether any submit is held. With batched submits a recording
is now put where waiters find it only after its submit is in the held
list, under the mutex, which also closes a window main has. A wait
returns at once when the recordings' timeline has already reached it. No
switch.

## The trade-off against main

Two reads of guest memory at the GPU's time are deliberately not counted
for the part wait. Both behave as they did in v2.0.14 and before, that
is before #2419:

- **Shaders following pointers into a pool that was never bound, and so
has no mirror.** No call of the renderer's is made for those reads, so
they cannot be noted. On a discrete GPU this is the remaining way to a
frame of issue 997's kind that current main's whole-submission wait
would have prevented: the game, let go after the first part, writes
memory that a later part still reads through such a pointer. GPUs with
no mirrors at all (997's iGPUs) are not affected: every page there is
written in place and keeps the whole wait.
- **The refresh of readback pages into their mirrors**
(`memory_rb_refresh`, in every part's uploads). These copies are how a
CPU write to a page it reads back reaches the mirror at all, and a later
part that writes such a page again is waited for through its own part.
Counting them would put an in-place read in every part and cancel the
change. What they carry along is any other data that shares a 4 KB page
with a readback range. mooki000 counted it on an RTX 4090 (see their
comment in this PR). In Kokiri Forest 0 of 20,371 readbacks shared a
page this way. In Hyrule Field 610 of 45,916 did, all with read-only
bindings, and in every case the reading part's recording was already
complete when the readback's wait returned.

## Review

Three commits:

- `798d625a`: the three parts above.
- `9daa0718`: first review. The part wait gives way to in-place reads in
later parts; the look also covers events whose value is in guest memory.
- `4ba31740`: mooki000's two findings on this PR, both real, and the
second review.
- A look was counted at its own end, before its burst's recording was
finished; a readback in between took no look and read the page table
without that recording's pages (#2125's hole again). Fixed: counted at
the burst's end.
- A held submit's recording could be found before the submit was in the
held list, so a waiter skipped the flush and waited until something else
sent the batch. Fixed as in part 3.
- The second review added the unswizzle and texel-buffer reads, and
bindings made outside a recording's uploads, to the in-place reads.

## Measured

Hyrule Field, 30 s each, alternating builds in one session. Rounds 1-3
are `798d625a`'s renderer (built before its rebase onto `6f7ab2a7`) on
main's game binary (`fce53453`); the last two rows are the later
commits, built as committed.

| round | fix | v2.0.11-588 | main |
|---|---|---|---|
| 1 | 78.6 fps, 46.7 ms CPU | 81.3 fps, 38.3 ms | 63.1 fps, 71.0 ms |
| 2 | 78.7 fps, 45.1 ms | 73.9 fps, 40.0 ms | 65.9 fps, 69.5 ms |
| 3 | 69.9 fps, 50.5 ms (disturbed) | 74.8 fps, 42.5 ms | 67.2 fps, 68.8
ms |
| `9daa0718` | 76.8 fps, 47.6 ms | 66.4 fps, 42.4 ms (hitched) | 60.9
fps, 73.4 ms |
| `4ba31740` | 77.2 fps, 47.0 ms | not run | 61.1 fps, 75.5 ms |

Round 3's fix run had 367,000 pages swapped in on the PC during its 30 s
(the others 20,000-135,000), so I count it as disturbed. The PC was
short of memory for the whole session, which is also why the old build
swings between 66 and 81.

Time blocked in readbacks, per 3000: about 220 ms with the fix
(1500-1540 waits of 0.14-0.16 ms), about 230 ms on the old build, about
1100 ms on main. The counts themselves are not comparable between
builds: main counts a readback as waited whenever the replay was behind.

Kokiri Forest, `798d625a`, two rounds: fix 65.3 / 58.2 fps at 68-69 ms
of CPU a frame, main 40.5 (a hitched run) / 45.9 at 113-117 ms, the old
build 50.8 / 56.0 at 84-88 ms. The number of draws there varies from run
to run (955 to 1255) on every build, so these are rougher than the
field's.

The CPU is still 6-8 ms a frame above the old build in the field. The
read now makes one real wait a frame, for the first part on the GPU (1-2
ms), and about three job threads poll during it; on the old build those
threads sat blocked on mutexes instead. That is the yield loop for
sleeps under a millisecond. `HOS_SHORT_SLEEP_TIMER=1` on main brings the
CPU to 37.5 ms a frame at the same fps, so it is a separate follow-up in
the host, not part of this PR.

SadTransGirl measured the cost of #2419 independently with an
`RX_VK_WAIT_FIRST_PART` switch
([branch](https://github.com/SadTransGirl/Hookshot-Gummi/tree/feat/wait-first-part-ab))
on an RTX 4070 SUPER, DLAA 1440p: about 4-7 fps (Town to Hyrule Field
55.9-56.2 before #2419, 47.4-47.5 on main, 52.7-54.2 with the switch).

## Tested

- `python -I -B tests/run_all.py` on Linux at `4ba31740`: 447 passed, 3
failed, 25 skipped. The three are not this change:
`test_bake_portability` and `test_builder_log_read_only` (no `zstandard`
or `tkinter` on this PC) and `test_env_on` (`HOS_FPS_LOG` in
`host/oot_high_fps.c`, on main).
- New: `tests/test_readback_part.py` runs the page table, the part
bookkeeping, the readback's choice of wait, the look at the stopped
queues and the burst's end, cut from the source, and counts every copy
out of a guest pool. `tests/test_flush_nothing_held.py` checks the order
in which a held submit is listed, flagged and made findable. Both fail
on the commit before the fix they cover.
- Updated for the new code: `tests/test_readback_settle.py`,
`tests/test_recorded_wait_flush.py`, and five fixtures that compile the
changed functions.
- Flashes, `RX_VK_FRAMELOG` with `scripts/framelog.py`, 30 s each,
`798d625a` against the same build with both switches off:
- Hyrule Field: 0 events in 2374 frames with the fix, 0 in 1869 without.
- Kokiri Forest: 0 events without; 4 with the fix, all one 20x20 block
on four consecutive frames. I looked at them: a leaf falling across the
sky.
- Mean brightness is the same with and without (field 37.6 / 37.6,
Kokiri 89.7 / 90.0), so nothing is hidden by a blown-out picture.
- The same pass on `4ba31740`, fix only: Hyrule Field 0 events in 2558
frames (brightness 37.6); Kokiri Forest 0 events in 1692 frames. That
Kokiri run was the 964-draw variant of the scene, darker at 79.1, and I
have no switches-off run of that variant to compare its brightness with.
- The game ran on NVIDIA 620.70.06, Vulkan 1.4, RTX 3090 (24 GB), VRAM
mirrors on, batched submits off. In these runs no readback fell back for
in-place reads and no pages were read live from guest memory.

## Not yet verified

- `4ba31740` in the game beyond one field pair and one framelog pass
(above): no Kokiri timing, no run of the old build beside it.
- The batched-submit path (RADV) on hardware. Part 3 changed in
`4ba31740`, so a check of `798d625a` does not cover it; it is covered by
a source test only.
- A GPU with no VRAM mirrors (issue 997's iGPUs), and a discrete GPU
short of VRAM, where the in-place fallback would actually trigger.
- Windows.
- If a part's pages are noted and that part is then never submitted (a
lost device), a read of them waits 250 ms before it falls back.

---------

Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
sheepbun added a commit to sheepbun/Bunny-Hood that referenced this pull request Nov 12, 2026
… them (NotRexed#2602)

Session 6 found the synced build's lost quarter of its frame rate in the
game's readbacks of GPU results: upstream NotRexed#2419's wait for a submission's
last part and NotRexed#2125's settle under sync_order_mu. A fix written here (kept
as backup/readback-by-part-mine) took Kokiri Forest from 39 to 53.5 fps on
the i7-11700 and RTX 3080; upstream merged the same fix, more complete, as
NotRexed#2602, which this fork takes with its next sync instead.

Area: docs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NotRexed pushed a commit that referenced this pull request Nov 12, 2026
… no VRAM mirror (#2464)

This is my first contribution to Hookshot, so I am happy to change
anything about the approach, the style or the scope. I did the
investigation and the change with the help of AI, as the commit trailers
say. I checked the results on my own machine, described below.

On Intel Core Ultra iGPUs, Kokiri Forest is covered by a white veil: the
ground is missing and the sky/fog is painted over it. The mechanism is
not Intel-specific (any GPU whose buffer pools have no VRAM mirror can
read uniform buffers stale), but the new default is limited to Intel for
now.

## Why

With no VRAM mirror (`mirror_on()` off: integrated GPUs, GCN since
#2343, `RX_VK_MIRROR=0`), a draw's uniform buffers are bound straight
into the imported guest pools. The GPU reads them when it runs the
frame. By then the game has already written the next frame's per-object
matrices over the ones that were submitted.

In Kokiri, the depth prepass (generic program `ffb53ed102dd9908`) drew
the terrain with the wrong `c4` matrices, so the terrain landed off
screen. The depth there stayed at far (1.0). The fog/atmosphere
composite (`1a7482c0ec9991ea`) then treats every pixel above 0.9999 as
sky and painted fog over the ground. The forward pass draws the ground
colour correctly, which is why only the fog layer looked wrong. On a
discrete GPU the mirror is copied at submission time, so it never sees
the rewrite.

## What changed

`RX_VK_UB_SNAPSHOT` already copies a uniform buffer into the stream as
submitted (`memory_read_as_submitted`). It is now on by default on Intel
when the pools are not mirrored (`vendorID == 0x8086 &&
!pools_mirrored()`). Other GPUs without a mirror (AMD APUs, Steam Deck,
GCN, `RX_VK_MIRROR=0`) keep it off until someone measures the cost
there. `RX_VK_UB_SNAPSHOT=0` turns it off; `=1` forces it on anywhere.

## How it was found

- `RX_VK_DUMP_EACH=ffb53ed102dd9908` + `RX_VK_DUMP_ARGS` read the depth
back after each prepass draw. 551 of 558 draws added no depth at all,
including 12k-24k-index terrain meshes drawn while the depth was still
empty.
- A CPU projection of the same draws, using the logged vertices and
`c3`/`c4`, put 454 of them on screen with depth ~0.99.
- Writing depth with compare ALWAYS, no cull and depth clamp changed
nothing. Neither did copying the vertex and index buffers into the
stream as submitted. Copying the uniform buffers did: the prepass depth
went from ~45,000 far samples (of 57,600) to ~1,000, and the veil went
away.

## Testing

- **Machine:** Lenovo laptop (type 83DJ).
  - **CPU:** Intel Core Ultra 7 155H (Meteor Lake).
- **GPU:** integrated Intel Arc Graphics (8 Xe-cores), no dedicated GPU;
video memory is shared with system RAM.
  - **RAM:** 32 GB LPDDR5x-7467.
  - **OS:** Windows 11 Pro, build 26300 (Insider).
- **Driver:** Intel 32.0.101.9175, installed by hand (the version from
#741).
- **Game settings:** output resolution setting 3 (`HOS_OUTPUT_RES=3`),
`RX_VK_FIFO=1`, `RX_VK_PREPARE_BACKGROUND=1`.
- **Build:** v2.1.0 with this change; then current `main` (3e32949, with
#2419) built from source with this branch rebased on it.
- **Current main without this change:** the veil is still there (#2419
does not cover it). Numbers and screenshots in the comment below.
- **Kokiri, from the house to the village:**
  - before: white veil over the ground in every wide view;
  - after: ground, paths, deck and ivy correct, light fog as intended;
  - frame rate unchanged, ~8.5-11 fps in Kokiri at the same settings.
- No AMD or discrete GPU tested; the default there is unchanged.

Related: #999 (Intel tracker); report and workaround:
#999 (comment)

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
sheepbun added a commit to sheepbun/Bunny-Hood that referenced this pull request Nov 12, 2026
… them (NotRexed#2602)

Session 6 found the synced build's lost quarter of its frame rate in the
game's readbacks of GPU results: upstream NotRexed#2419's wait for a submission's
last part and NotRexed#2125's settle under sync_order_mu. A fix written here (kept
as backup/readback-by-part-mine) took Kokiri Forest from 39 to 53.5 fps on
the i7-11700 and RTX 3080; upstream merged the same fix, more complete, as
NotRexed#2602, which this fork takes with its next sync instead.

Area: docs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DFN-master pushed a commit to DFN-master/Hookshot that referenced this pull request Nov 12, 2026
…nd no game thread waits behind a present for nothing

Hyrule Field lost about 15 fps between v2.0.11-588 (ba86aee) and main (RTX 3090, 2560x1440, uncapped: 79 -> 64-71 fps,
CPU 39 -> 64-73 ms a frame). One of the game's job threads was blocked 5.5 ms a frame in a CPU read of GPU results
(0.1 ms before), and the game's other job threads polled with 20 us sleeps meanwhile (1.9 cores in sched_yield against
0.3).

The read is of the culling results the first part of the scene's submission writes; that submission then stops at an
event wait and is finished later, on the presenting thread. Three things held the read up:

- since NotRexed#2419 a submission's value maps to its last part's recording, so the read waited for the GPU's whole
  submission. Each part writes back the pages the GPU wrote in it, at its own end: the page table now keeps the part with
  the value, and the read waits for the newest part of its pages. A page written in place (no mirror) keeps the wait
  for its whole submission, whose later parts read and write guest memory itself (issue 997).
  RX_VK_READBACK_PART=0: the last part, as before.
- since NotRexed#2125 the read took sync_order_mu whenever a queue was stopped, behind the presenting thread's whole replay and
  its present. A queue can be stopped at a met wait with nobody to let it on only after a CPU signal; the read looks at
  the stopped queues when the CPU has signalled since the last look (2-9 of 3000 readbacks, all of them before).
  RX_VK_READBACK_SETTLE_ALWAYS=1: whenever anything is stopped, as before.
- a wait whose recording was already done, and the host's copy bookkeeping (rx_nvn_replayed, under its own lock), took
  dev.present_mu to flush held submits, which exist only with batched submits. A present holds that mutex 4 ms a frame.
  The flushes look at an atomic first; a wait returns at once when the recordings' timeline is already there.

Hyrule Field, same session, fix / base / main: 78.6 / 81.3 / 63.1 and 78.7 / 73.9 / 65.9 fps; CPU 46.7 / 38.3 / 71.0
and 45.1 / 40.0 / 69.5 ms a frame. Readbacks that waited: ~1350 of 3000 at 0.14 ms (main 172 at 5.5 ms, base ~1300 at
0.13 ms).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
dazemc added a commit to dazemc/Hookshot-rs that referenced this pull request Nov 12, 2026
A submission longer than RX_VK_CHUNK draws, or one stopped at an event
wait and finished later, is recorded in several recordings that all carry
its value. submit_recording mapped the value with emplace, which kept the
FIRST part's seq, so wait_recorded (the game's fence waits and the exposure
readback wait) returned once the first ~120 draws were done. With no VRAM
mirror (integrated GPUs) the later parts read guest memory in place, and the
game had written the next frame's uniforms, vertices and exposure meter into
it: a frame of the HUD stretched across the screen, grass white, whole-screen
flashes (issue 997).

Each value now maps to the latest recording that carries it, raised to its
seq in place; a stopped submission's value is still registered by the parts
before the one that signals.

The rule moved to gpu/vk/rec_value.h so tests/test_rec_value.cpp can check
it without a device.

Ported from upstream e0dbf40 (NotRexed#2419).
sheepbun added a commit to sheepbun/Bunny-Hood that referenced this pull request Nov 12, 2026
… them (NotRexed#2602)

Session 6 found the synced build's lost quarter of its frame rate in the
game's readbacks of GPU results: upstream NotRexed#2419's wait for a submission's
last part and NotRexed#2125's settle under sync_order_mu. A fix written here (kept
as backup/readback-by-part-mine) took Kokiri Forest from 39 to 53.5 fps on
the i7-11700 and RTX 3080; upstream merged the same fix, more complete, as
NotRexed#2602, which this fork takes with its next sync instead.

Area: docs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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