perf(vk): RX_VK_READBACK_LATE, the readbacks a rule names are read without their wait (opt-in) #238

Merged
HumanBean merged 2 commits from p0lux/Longshot:perf/readback-late into main 2026-11-14 01:02:46 +00:00
Contributor

Why

On a Steam Deck the frame spends several milliseconds waiting on read-backs: a CPU read of GPU results waits for the write-back of the submission that writes them, and the game asks for most of them right after submitting the work that fills them. Reading those without the wait takes a heavy view from 24.3 to 30.3 fps on a Steam Deck OLED, and Hyrule Field from 21.9 to 26.5. The starting point is camilobt's finding on Hookshot's issue 1000 (the two 0x3980-byte ranges).

What changed

  • replay: invalidate() in gpu/vk/vk_replay.cpp asks readback_late_wanted(size, newest) before its wait. A range a rule names is read as guest memory holds it (the frame before's values); every other range waits as before.
  • rules: gpu/vk/readback_late.h, parse and wanted. newest: the range's writer is the newest submission handed out. A hex size: the ranges of exactly that many bytes. Either is enough.
  • log: rb_range_note and rb_ranges_log in vk_replay.cpp, the read-backs by range every 3,000 calls, behind their own switch. That log is how the ranges were found, and what shows below that the 0x3980 pair is no longer most of the wait.
  • checks: tests/test_readback_late.py (new) runs the rules and checks the wiring; tests/test_readback_part.py follows invalidate()'s text and runs the wait with and without a rule.
  • docs: both switches in docs/development.md, "Switches for testing and debugging", with what was measured; a row in docs/tested-configurations.md.

Flags and settings

Flag What it does Default now Recommendation Helps on Doesn't help on Untested on
RX_VK_READBACK_LATE=<rule>[,<rule>...] The read-backs a rule names are read without their wait: newest, or a hex size such as 3980 unset: every read-back waits, as before off by default; newest (or newest,3980) on a Steam Deck after more testing on other hardware Steam Deck OLED: +25% on a heavy view, +21% in Hyrule Field, +14% in Kokiri Forest (below) not known yet everything but a Steam Deck OLED; PCs whose pools have VRAM mirrors
RX_VK_LOG_READBACK_RANGES=1 Logs the read-backs by range every 3,000 calls: calls, waits and their time, how many had the newest submission as their writer, how many were read late off off (a developer switch) - - -

Measurements

main: fbd42024 (2026-11-13)
this PR: perf/readback-late @ 3a6fbc0c, the change on that main. The branch is now that change rebased onto main 514591a3 (bcc2496d) and a docs commit (0f7148ac: the row, and a comment and a paragraph that said what Hookshot measured). The rebase conflicted in the docs only; the change's code applied as it was. Main moved 10 commits in between, one of them in the replay (#260, the occlusion counts): the numbers below are fbd42024's, and main 514591a3 against the PR is to be run again.
Hardware: Steam Deck OLED (AMD Custom APU 0932, RADV VANGOGH, Mesa 26.2.0-devel), 16 GB, SteamOS 3.9.5, kernel 7.2.7-valve2; the GPU's clock fixed at 1600 MHz (Steam's performance menu), read back as 1600 MHz during every run
Scene: three saves, standing still: a heavy view (about 2,150 draws a frame), Hyrule Field (about 1,600), Kokiri Forest (about 2,600); no screen (headless), handheld profile, Minimum preset, the game's own upscaler, no frame generation, no cap
Method: the back end built alone from each commit, on a host and game folder of main 000f19fb (vk_api.h is the same). Each run starts the game, loads the save by script, waits 3 log windows, then measures exactly 720 presents: fps over that span; the wait is the log's "readbacks of GPU results" line. Five arms alternated in each round: main; this PR with no rule (RX_VK_READBACK_LATE=0, which names none); newest; 3980; newest,3980. Heavy view and Hyrule Field 3 rounds, Kokiri Forest 2; medians (Kokiri: the mean of its two)
How to reproduce: a save standing in Hyrule Field, RX_VK_READBACK_LATE=newest in longshot_env.txt against unset, "N presents, X fps" in longshot_log.txt once the view has settled; RX_VK_LOG_READBACK_RANGES=1 for the ranges

fps main @ fbd42024 this PR @ 3a6fbc0c, no rule this PR @ 3a6fbc0c, newest this PR @ 3a6fbc0c, 3980 this PR @ 3a6fbc0c, newest,3980
heavy view (3 rounds) 24.28 24.30 30.13 24.86 30.33
Hyrule Field (3 rounds) 21.88 22.00 26.40 23.08 26.48
Kokiri Forest (2 rounds) 23.44 23.32 26.30 23.86 26.78
a read-back that waits, heavy view 2.36 ms 2.39 ms 0.01 ms 1.58 ms 0.03 ms

Every run, fps:

heavy view    main 24.17 24.41 24.28 | no rule 24.04 24.30 24.30 | newest 30.13 30.10 30.40 | 3980 24.82 24.86 25.06 | newest,3980 30.47 30.31 30.33
Hyrule Field  main 21.79 21.88 21.92 | no rule 22.00 22.09 21.86 | newest 26.12 26.40 26.52 | 3980 22.58 23.08 23.09 | newest,3980 26.48 26.41 26.51
Kokiri Forest main 23.70 23.18       | no rule 23.22 23.42       | newest 26.17 26.42       | 3980 23.86 23.85       | newest,3980 26.68 26.87

The log line the switch moves (heavy view, first round; Hyrule Field with no rule and with both):

main:         vk: readbacks of GPU results: 1024 of 3000 waited for their write-back, 2.36 ms each
no rule:      vk: readbacks of GPU results: 974 of 3000 waited for their write-back, 2.39 ms each
newest:       vk: readbacks of GPU results: 1458 of 3000 waited for their write-back, 0.01 ms each
3980:         vk: readbacks of GPU results: 1283 of 3000 waited for their write-back, 1.58 ms each
newest,3980:  vk: readbacks of GPU results: 1434 of 3000 waited for their write-back, 0.03 ms each
Hyrule Field, no rule:      784 of 3000 waited for their write-back, 4.47 ms each
Hyrule Field, newest,3980:  1473 of 3000 waited for their write-back, 0.50 ms each

Which ranges wait, with no rule (RX_VK_LOG_READBACK_RANGES=1, the heavy view, 3,000 calls): the two 0x3980-byte ranges are 971 ms of the 2,147 ms of the eight ranges that wait most (45%; 40% in Hyrule Field). The rest is six ranges of 0x8b040 and 0x9b040 bytes. On Hookshot the 0x3980 pair was 94% of the wait, which is why the rule by size is here; on main it is newest that carries the gain.

vk: readback ranges at present 2177: 94 ranges, 0 read late | 0x113b648000 +0x3980: 220 calls, 220 waited 514.0 ms, 182 newest, 0 late | 0x113b643000 +0x3980: 184 calls, 184 waited 457.3 ms, 174 newest, 0 late | 0x117cfbb040 +0x9b040: 91 calls, 91 waited 293.7 ms, 78 newest, 0 late | 0x117cf20000 +0x9b040: 90 calls, 90 waited 228.2 ms, 86 newest, 0 late | 0x117d8f0000 +0x8b040: 90 calls, 87 waited 200.3 ms, 66 newest, 0 late | 0x117d97b040 +0x8b040: 91 calls, 77 waited 193.6 ms, 38 newest, 0 late | 0x117da08000 +0x9b040: 90 calls, 67 waited 160.0 ms, 38 newest, 0 late | 0x117daa3040 +0x9b040: 90 calls, 38 waited 99.8 ms, 14 newest, 0 late

Testing

Tested:

  • python -I -B tests/run_all.py on macOS with the Vulkan headers given: 467 passed, 30 failed, 37 skipped on the rebased branch; the 30 fail the same way on main 514591a3 on that machine (no /proc, no posix_fallocate, the Linux launch scripts and the like). test_readback_late.py fails on main (the header is not there) and passes here; test_readback_part.py passes.
  • In the game, a Steam Deck OLED, the commit measured: 48 runs without a screen on the three scenes (the arms above, the flash detector, the by-range log), no error, no GPU timeout in the kernel log.
  • The flash detector with the camera turning (RX_VK_FLASH=1, about 1,800 presents a run), Kokiri Forest and Hyrule Field: 0 flashes with no rule, 0 with newest,3980.
  • With the picture: the local patch this commit tidies (the same rules, newest,3980) played on the Deck for two days on Hookshot and about an hour on Longshot, with no flicker noticed by the player.
  • Linux: the back end builds (clang, on the Deck).

Not tested:

  • This exact commit with the picture (the runs above have no screen; the player's sessions were the local patch).
  • Any hardware but a Steam Deck OLED. A PC whose pools have VRAM mirrors (a discrete card): the Deck has none, and which read-backs wait there may differ.
  • Windows: the back end's cross-build with llvm-mingw is still to run; not run in the game there.
  • In Hyrule Field the still view is about 1,572 draws a frame with newest and 1,600 to 1,634 without: a small difference in what the game draws, not looked into.

Needs other testers:

  • NVIDIA or AMD desktop: with RX_VK_LOG_READBACK_RANGES=1, which ranges wait the most and for how long, then whether RX_VK_READBACK_LATE=newest flickers or gains anything there, expecting no flicker (request to open)

Screenshots

Nothing on screen.

Risk and rollback

With the switch unset nothing changes but one test of a constant a read-back (main and the PR with no rule measure the same above). With it set, a range read late holds the frame before's values: right for a range the game reads before its work can be done, wrong for one whose late read matters. The exposure meter is such a one (the white flashes the wait is there for), which is why this is a rule by range and off by default, not RX_VK_NO_READBACK_WAIT. Unset the switch to turn it off.

Reviewer notes

  • @dukenheimer claimed frame-ahead with in-order delivery of the read-back pages measured safe a frame late. This is the narrow case of that on a Deck, plus the log that finds such ranges. If yours covers it, say so and I close this or reduce it to the log.
  • #248 removes the late pages' wait "on PCs whose pools have VRAM mirrors" (its description). A Steam Deck has none (mirror_on() is off on an integrated GPU), so this switch is for the machines that one does not reach.
  • 3980 alone is weak on main (24.3 to 24.9 fps). The rule by size is kept because it is what the log lets a tester try on other hardware; say if you would rather have newest only.

Issues

None.

Checklist

For everyone

  • One topic.
  • The title is a Conventional Commit (type(scope): description).
  • Rebased on current main.
  • python -I -B tests/run_all.py run and passing (any failure that also fails on main is named above).
  • Tested in the game, or what wasn't is named under Testing.
  • Testing filled in: what was tested, what wasn't, and what needs other testers (with test requests).
  • Measured: main and this PR at named commits, on the same PC, scene and settings, at least 2 interleaved runs each, the median reported.
  • No game files, dumps, build output or generated files.
  • third_party/ untouched (a new folder with a PROVENANCE.md instead).
  • The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled.

If applicable

  • AI assistance recorded with Assisted-by: / Co-Authored-By: trailers.
  • Flags noted, and the switch reference updated (docs/development.md, the README).
  • A row in docs/tested-configurations.md for each measured run.
  • A game-free test added for the fix, failing on main and passing here.
  • A check under tests/ that reads changed source updated in this PR, not weakened.
  • Host code builds on both Windows (llvm-mingw) and Linux, with UTF-8 paths. Linux: built on the Steam Deck. Windows: the cross-build with llvm-mingw is still to run.
  • Docs updated.
## Why On a Steam Deck the frame spends several milliseconds waiting on read-backs: a CPU read of GPU results waits for the write-back of the submission that writes them, and the game asks for most of them right after submitting the work that fills them. Reading those without the wait takes a heavy view from 24.3 to 30.3 fps on a Steam Deck OLED, and Hyrule Field from 21.9 to 26.5. The starting point is camilobt's finding on Hookshot's issue 1000 (the two 0x3980-byte ranges). ## What changed - replay: `invalidate()` in `gpu/vk/vk_replay.cpp` asks `readback_late_wanted(size, newest)` before its wait. A range a rule names is read as guest memory holds it (the frame before's values); every other range waits as before. - rules: `gpu/vk/readback_late.h`, `parse` and `wanted`. `newest`: the range's writer is the newest submission handed out. A hex size: the ranges of exactly that many bytes. Either is enough. - log: `rb_range_note` and `rb_ranges_log` in `vk_replay.cpp`, the read-backs by range every 3,000 calls, behind their own switch. That log is how the ranges were found, and what shows below that the 0x3980 pair is no longer most of the wait. - checks: `tests/test_readback_late.py` (new) runs the rules and checks the wiring; `tests/test_readback_part.py` follows `invalidate()`'s text and runs the wait with and without a rule. - docs: both switches in `docs/development.md`, "Switches for testing and debugging", with what was measured; a row in `docs/tested-configurations.md`. ## Flags and settings | Flag | What it does | Default now | Recommendation | Helps on | Doesn't help on | Untested on | |---|---|---|---|---|---|---| | `RX_VK_READBACK_LATE=<rule>[,<rule>...]` | The read-backs a rule names are read without their wait: `newest`, or a hex size such as `3980` | unset: every read-back waits, as before | off by default; `newest` (or `newest,3980`) on a Steam Deck after more testing on other hardware | Steam Deck OLED: +25% on a heavy view, +21% in Hyrule Field, +14% in Kokiri Forest (below) | not known yet | everything but a Steam Deck OLED; PCs whose pools have VRAM mirrors | | `RX_VK_LOG_READBACK_RANGES=1` | Logs the read-backs by range every 3,000 calls: calls, waits and their time, how many had the newest submission as their writer, how many were read late | off | off (a developer switch) | - | - | - | ## Measurements main: fbd42024 (2026-11-13) this PR: perf/readback-late @ 3a6fbc0c, the change on that main. The branch is now that change rebased onto main 514591a3 (bcc2496d) and a docs commit (0f7148ac: the row, and a comment and a paragraph that said what Hookshot measured). The rebase conflicted in the docs only; the change's code applied as it was. Main moved 10 commits in between, one of them in the replay (#260, the occlusion counts): the numbers below are fbd42024's, and main 514591a3 against the PR is to be run again. Hardware: Steam Deck OLED (AMD Custom APU 0932, RADV VANGOGH, Mesa 26.2.0-devel), 16 GB, SteamOS 3.9.5, kernel 7.2.7-valve2; the GPU's clock fixed at 1600 MHz (Steam's performance menu), read back as 1600 MHz during every run Scene: three saves, standing still: a heavy view (about 2,150 draws a frame), Hyrule Field (about 1,600), Kokiri Forest (about 2,600); no screen (headless), handheld profile, Minimum preset, the game's own upscaler, no frame generation, no cap Method: the back end built alone from each commit, on a host and game folder of main 000f19fb (`vk_api.h` is the same). Each run starts the game, loads the save by script, waits 3 log windows, then measures exactly 720 presents: fps over that span; the wait is the log's "readbacks of GPU results" line. Five arms alternated in each round: main; this PR with no rule (`RX_VK_READBACK_LATE=0`, which names none); `newest`; `3980`; `newest,3980`. Heavy view and Hyrule Field 3 rounds, Kokiri Forest 2; medians (Kokiri: the mean of its two) How to reproduce: a save standing in Hyrule Field, `RX_VK_READBACK_LATE=newest` in `longshot_env.txt` against unset, "N presents, X fps" in `longshot_log.txt` once the view has settled; `RX_VK_LOG_READBACK_RANGES=1` for the ranges | fps | main @ fbd42024 | this PR @ 3a6fbc0c, no rule | this PR @ 3a6fbc0c, `newest` | this PR @ 3a6fbc0c, `3980` | this PR @ 3a6fbc0c, `newest,3980` | |---|---|---|---|---|---| | heavy view (3 rounds) | 24.28 | 24.30 | 30.13 | 24.86 | 30.33 | | Hyrule Field (3 rounds) | 21.88 | 22.00 | 26.40 | 23.08 | 26.48 | | Kokiri Forest (2 rounds) | 23.44 | 23.32 | 26.30 | 23.86 | 26.78 | | a read-back that waits, heavy view | 2.36 ms | 2.39 ms | 0.01 ms | 1.58 ms | 0.03 ms | Every run, fps: ``` heavy view main 24.17 24.41 24.28 | no rule 24.04 24.30 24.30 | newest 30.13 30.10 30.40 | 3980 24.82 24.86 25.06 | newest,3980 30.47 30.31 30.33 Hyrule Field main 21.79 21.88 21.92 | no rule 22.00 22.09 21.86 | newest 26.12 26.40 26.52 | 3980 22.58 23.08 23.09 | newest,3980 26.48 26.41 26.51 Kokiri Forest main 23.70 23.18 | no rule 23.22 23.42 | newest 26.17 26.42 | 3980 23.86 23.85 | newest,3980 26.68 26.87 ``` The log line the switch moves (heavy view, first round; Hyrule Field with no rule and with both): ``` main: vk: readbacks of GPU results: 1024 of 3000 waited for their write-back, 2.36 ms each no rule: vk: readbacks of GPU results: 974 of 3000 waited for their write-back, 2.39 ms each newest: vk: readbacks of GPU results: 1458 of 3000 waited for their write-back, 0.01 ms each 3980: vk: readbacks of GPU results: 1283 of 3000 waited for their write-back, 1.58 ms each newest,3980: vk: readbacks of GPU results: 1434 of 3000 waited for their write-back, 0.03 ms each Hyrule Field, no rule: 784 of 3000 waited for their write-back, 4.47 ms each Hyrule Field, newest,3980: 1473 of 3000 waited for their write-back, 0.50 ms each ``` Which ranges wait, with no rule (`RX_VK_LOG_READBACK_RANGES=1`, the heavy view, 3,000 calls): the two 0x3980-byte ranges are 971 ms of the 2,147 ms of the eight ranges that wait most (45%; 40% in Hyrule Field). The rest is six ranges of 0x8b040 and 0x9b040 bytes. On Hookshot the 0x3980 pair was 94% of the wait, which is why the rule by size is here; on main it is `newest` that carries the gain. ``` vk: readback ranges at present 2177: 94 ranges, 0 read late | 0x113b648000 +0x3980: 220 calls, 220 waited 514.0 ms, 182 newest, 0 late | 0x113b643000 +0x3980: 184 calls, 184 waited 457.3 ms, 174 newest, 0 late | 0x117cfbb040 +0x9b040: 91 calls, 91 waited 293.7 ms, 78 newest, 0 late | 0x117cf20000 +0x9b040: 90 calls, 90 waited 228.2 ms, 86 newest, 0 late | 0x117d8f0000 +0x8b040: 90 calls, 87 waited 200.3 ms, 66 newest, 0 late | 0x117d97b040 +0x8b040: 91 calls, 77 waited 193.6 ms, 38 newest, 0 late | 0x117da08000 +0x9b040: 90 calls, 67 waited 160.0 ms, 38 newest, 0 late | 0x117daa3040 +0x9b040: 90 calls, 38 waited 99.8 ms, 14 newest, 0 late ``` ## Testing **Tested:** - `python -I -B tests/run_all.py` on macOS with the Vulkan headers given: 467 passed, 30 failed, 37 skipped on the rebased branch; the 30 fail the same way on main 514591a3 on that machine (no `/proc`, no `posix_fallocate`, the Linux launch scripts and the like). `test_readback_late.py` fails on main (the header is not there) and passes here; `test_readback_part.py` passes. - In the game, a Steam Deck OLED, the commit measured: 48 runs without a screen on the three scenes (the arms above, the flash detector, the by-range log), no error, no GPU timeout in the kernel log. - The flash detector with the camera turning (`RX_VK_FLASH=1`, about 1,800 presents a run), Kokiri Forest and Hyrule Field: 0 flashes with no rule, 0 with `newest,3980`. - With the picture: the local patch this commit tidies (the same rules, `newest,3980`) played on the Deck for two days on Hookshot and about an hour on Longshot, with no flicker noticed by the player. - Linux: the back end builds (clang, on the Deck). **Not tested:** - This exact commit with the picture (the runs above have no screen; the player's sessions were the local patch). - Any hardware but a Steam Deck OLED. A PC whose pools have VRAM mirrors (a discrete card): the Deck has none, and which read-backs wait there may differ. - Windows: the back end's cross-build with llvm-mingw is still to run; not run in the game there. - In Hyrule Field the still view is about 1,572 draws a frame with `newest` and 1,600 to 1,634 without: a small difference in what the game draws, not looked into. **Needs other testers:** - NVIDIA or AMD desktop: with `RX_VK_LOG_READBACK_RANGES=1`, which ranges wait the most and for how long, then whether `RX_VK_READBACK_LATE=newest` flickers or gains anything there, expecting no flicker (request to open) ## Screenshots Nothing on screen. ## Risk and rollback With the switch unset nothing changes but one test of a constant a read-back (main and the PR with no rule measure the same above). With it set, a range read late holds the frame before's values: right for a range the game reads before its work can be done, wrong for one whose late read matters. The exposure meter is such a one (the white flashes the wait is there for), which is why this is a rule by range and off by default, not `RX_VK_NO_READBACK_WAIT`. Unset the switch to turn it off. ## Reviewer notes - @dukenheimer claimed frame-ahead with in-order delivery of the read-back pages measured safe a frame late. This is the narrow case of that on a Deck, plus the log that finds such ranges. If yours covers it, say so and I close this or reduce it to the log. - https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/248 removes the late pages' wait "on PCs whose pools have VRAM mirrors" (its description). A Steam Deck has none (`mirror_on()` is off on an integrated GPU), so this switch is for the machines that one does not reach. - `3980` alone is weak on main (24.3 to 24.9 fps). The rule by size is kept because it is what the log lets a tester try on other hardware; say if you would rather have `newest` only. ## Issues None. ## Checklist ### For everyone - [x] One topic. - [x] The title is a Conventional Commit (`type(scope): description`). - [x] Rebased on current `main`. - [x] `python -I -B tests/run_all.py` run and passing (any failure that also fails on `main` is named above). - [x] Tested in the game, or what wasn't is named under Testing. - [x] Testing filled in: what was tested, what wasn't, and what needs other testers (with test requests). - [x] Measured: main and this PR at named commits, on the same PC, scene and settings, at least 2 interleaved runs each, the median reported. - [x] No game files, dumps, build output or generated files. - [x] `third_party/` untouched (a new folder with a `PROVENANCE.md` instead). - [x] The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled. ### If applicable - [x] AI assistance recorded with `Assisted-by:` / `Co-Authored-By:` trailers. - [x] Flags noted, and the switch reference updated (docs/development.md, the README). - [x] A row in `docs/tested-configurations.md` for each measured run. - [x] A game-free test added for the fix, failing on `main` and passing here. - [x] A check under `tests/` that reads changed source updated in this PR, not weakened. - [ ] Host code builds on both Windows (llvm-mingw) and Linux, with UTF-8 paths. Linux: built on the Steam Deck. Windows: the cross-build with llvm-mingw is still to run. - [x] Docs updated.
HumanBean added this to the v3.0.0 milestone 2026-11-13 17:46:22 +00:00
p0lux force-pushed perf/readback-late from c23f844e35
Some checks failed
PR description / Description follows the template (pull_request_target) Successful in 28s
Website / Website on Cloudflare (pull_request_target) Has been skipped
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been cancelled
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been cancelled
CI / Workflow lint (actionlint) (pull_request) Has been cancelled
Third-party parts / As their notes say (pull_request) Has been cancelled
Third-party parts / The same as upstream (pull_request) Has been cancelled
to 0f7148aca3
Some checks failed
Website / Website on Cloudflare (pull_request) Blocked by required conditions
PR description / Description follows the template (pull_request_target) Failing after 3s
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been cancelled
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been cancelled
CI / Workflow lint (actionlint) (pull_request) Has been cancelled
Third-party parts / As their notes say (pull_request) Has been cancelled
Third-party parts / The same as upstream (pull_request) Has been cancelled
2026-11-13 22:16:02 +00:00
Compare
Author
Contributor

Measured on a Steam Deck OLED, and rebased onto main 514591a3 (the rebase conflicted in the docs only).

  • Five arms alternated on three scenes (main fbd42024; the change with no rule; newest; 3980; newest,3980). With both rules: 24.3 to 30.3 fps on a heavy view, 21.9 to 26.5 in Hyrule Field, 23.4 to 26.8 in Kokiri Forest. With no rule the PR measures as main.
  • newest carries the gain (30.1 fps on the heavy view by itself); 3980 alone gives 24.9. On main the two 0x3980-byte ranges are 40 to 45% of the read-backs' wait, not the 94% they were on Hookshot. The header's comment and the docs said the latter; they now say what was measured.
  • The flash detector with the camera turning, Kokiri Forest and Hyrule Field: 0 flashes with and without the rules.
  • Still WIP for one box: the back end's Windows cross-build. And the numbers are main fbd42024's: main has moved since (#260 is in the replay), so main against the PR will be run again on it.

The description has the tables and the log lines, docs/tested-configurations.md the row.

Measured on a Steam Deck OLED, and rebased onto main 514591a3 (the rebase conflicted in the docs only). - Five arms alternated on three scenes (main fbd42024; the change with no rule; `newest`; `3980`; `newest,3980`). With both rules: 24.3 to 30.3 fps on a heavy view, 21.9 to 26.5 in Hyrule Field, 23.4 to 26.8 in Kokiri Forest. With no rule the PR measures as main. - `newest` carries the gain (30.1 fps on the heavy view by itself); `3980` alone gives 24.9. On main the two 0x3980-byte ranges are 40 to 45% of the read-backs' wait, not the 94% they were on Hookshot. The header's comment and the docs said the latter; they now say what was measured. - The flash detector with the camera turning, Kokiri Forest and Hyrule Field: 0 flashes with and without the rules. - Still WIP for one box: the back end's Windows cross-build. And the numbers are main fbd42024's: main has moved since (#260 is in the replay), so main against the PR will be run again on it. The description has the tables and the log lines, `docs/tested-configurations.md` the row.
p0lux changed title from WIP: perf(vk): RX_VK_READBACK_LATE, the readbacks a rule names are read without their wait (opt-in) to perf(vk): RX_VK_READBACK_LATE, the readbacks a rule names are read without their wait (opt-in) 2026-11-13 22:17:45 +00:00
HumanBean left a comment

Review of PR 238 (RX_VK_READBACK_LATE), head 0f7148ac. Code review only: I ran no tests or builds.

The change is clean and small. With the switch unset, invalidate() does what it did before: late is false, so pending && !late is the old v > completed(). The rules are easy to follow, both checks follow the new text, and the by-range log is behind its own switch. Two things still need settling before merge, both from the rules in CONTRIBUTING.md and AGENTS.md:

  1. The PR is marked ready, but one box can't be ticked yet. "Host code builds on both Windows (llvm-mingw) and Linux" is unticked because the cross-build hasn't run. The description check fails on it (run https://git.magicbean.dev/MagicBeanDev/Longshot/actions/runs/2705, pr status 238: "1 to fix"), and CI here has no Windows build job to settle it. AGENTS.md says a box that can't be true yet keeps the PR WIP. The diff only uses standard C++ (getenv, strtoull, std::mutex), so I expect the llvm-mingw build of longshot_vk.dll to pass. Please run it and tick the box, or move the PR back to WIP until someone has.
  2. The numbers come from before the rebase. They are from main fbd42024 / 3a6fbc0c, and the branch now sits on main 514591a3. The 10 commits in between include a replay change (#260, occlusion query numbering across queues). The description says "main 514591a3 against the PR is to be run again". CONTRIBUTING.md asks you to measure again or say why the numbers still hold. Either would do: a short re-run of newest against no rule in one scene, or a line on why #260 can't move the readback wait.

Smaller points, in the inline comments:

  • parse() keeps any entry that starts with hex digits: all is read as 0xa, 1 as size 1, newestx as newest.
  • A question on newest with ranges the CPU writes back, on a GPU with no mirrors such as the Deck.
  • Ranges read late but caught up first still count as "waited" in both logs.

The open test request for a desktop GPU: the testing wanted label is on, but pr status still counts it as "1 to open". Please check the request card exists.

Review of PR 238 (`RX_VK_READBACK_LATE`), head 0f7148ac. Code review only: I ran no tests or builds. The change is clean and small. With the switch unset, `invalidate()` does what it did before: `late` is false, so `pending && !late` is the old `v > completed()`. The rules are easy to follow, both checks follow the new text, and the by-range log is behind its own switch. Two things still need settling before merge, both from the rules in CONTRIBUTING.md and AGENTS.md: 1. **The PR is marked ready, but one box can't be ticked yet.** "Host code builds on both Windows (llvm-mingw) and Linux" is unticked because the cross-build hasn't run. The description check fails on it (run https://git.magicbean.dev/MagicBeanDev/Longshot/actions/runs/2705, `pr status 238`: "1 to fix"), and CI here has no Windows build job to settle it. AGENTS.md says a box that can't be true yet keeps the PR WIP. The diff only uses standard C++ (`getenv`, `strtoull`, `std::mutex`), so I expect the llvm-mingw build of `longshot_vk.dll` to pass. Please run it and tick the box, or move the PR back to WIP until someone has. 2. **The numbers come from before the rebase.** They are from main fbd42024 / 3a6fbc0c, and the branch now sits on main 514591a3. The 10 commits in between include a replay change (https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/260, occlusion query numbering across queues). The description says "main 514591a3 against the PR is to be run again". CONTRIBUTING.md asks you to measure again or say why the numbers still hold. Either would do: a short re-run of `newest` against no rule in one scene, or a line on why https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/260 can't move the readback wait. Smaller points, in the inline comments: - `parse()` keeps any entry that starts with hex digits: `all` is read as 0xa, `1` as size 1, `newestx` as `newest`. - A question on `newest` with ranges the CPU writes back, on a GPU with no mirrors such as the Deck. - Ranges read late but caught up first still count as "waited" in both logs. The open test request for a desktop GPU: the `testing wanted` label is on, but `pr status` still counts it as "1 to open". Please check the request card exists.
@ -0,0 +28,4 @@
Rules r;
for (const char* p = text; p && *p;) {
if (!strncmp(p, "newest", 6)) r.newest = true;
else if (const uint64_t n = strtoull(p, nullptr, 16)) r.sizes.push_back(n);
Owner

Nit: an entry is kept as soon as it starts with hex digits. RX_VK_READBACK_LATE=all gives size 0xa, =1 (someone switching it "on") gives size 1, and newestfoo matches newest on line 30 because of the prefix compare. Nothing breaks: the start-up line shows (newest 0, 1 sizes). Still, a mistyped rule quietly turns into a different rule. Suggest keeping an entry only when it is whole: newest followed by , or the end, or strtoull with an end pointer that stops at ,/\0. A junk = parse("all,1x") case in test_readback_late.py would cover it.

Nit: an entry is kept as soon as it starts with hex digits. `RX_VK_READBACK_LATE=all` gives size 0xa, `=1` (someone switching it "on") gives size 1, and `newestfoo` matches `newest` on line 30 because of the prefix compare. Nothing breaks: the start-up line shows `(newest 0, 1 sizes)`. Still, a mistyped rule quietly turns into a different rule. Suggest keeping an entry only when it is whole: `newest` followed by `,` or the end, or `strtoull` with an end pointer that stops at `,`/`\0`. A `junk = parse("all,1x")` case in test_readback_late.py would cover it.
@ -2966,2 +3025,3 @@
}
if (v > completed()) {
const bool pending = v > completed(), newest = pending && v >= f0;
const bool late = pending && readback_late_wanted(size, newest); // (RX_VK_READBACK_LATE: read without its wait)
Owner

Question on newest with ranges the CPU writes back. The exposure meter is read, then reset to 0 by the CPU. If a range like that is read late, the CPU's write can land before the pending submission's write-back, which then overwrites it: the 2x sum in the comment above. On the Deck there are no mirrors (whole, pages written in place), and the wait is all that orders the CPU's write against the GPU's. newest decides by who wrote the range, not by whether the game writes it back. The flash detector found 0 flashes, so the meter wasn't "newest" in those runs. Is that guaranteed by how OoT submits, or only by timing? If it's timing, please say so where the switch is documented (development.md / readback_late.h), so desktop testers know what a flash with newest means.

Question on `newest` with ranges the CPU writes back. The exposure meter is read, then reset to 0 by the CPU. If a range like that is read late, the CPU's write can land before the pending submission's write-back, which then overwrites it: the 2x sum in the comment above. On the Deck there are no mirrors (`whole`, pages written in place), and the wait is all that orders the CPU's write against the GPU's. `newest` decides by who wrote the range, not by whether the game writes it back. The flash detector found 0 flashes, so the meter wasn't "newest" in those runs. Is that guaranteed by how OoT submits, or only by timing? If it's timing, please say so where the switch is documented (development.md / readback_late.h), so desktop testers know what a flash with `newest` means.
@ -2994,1 +3055,4 @@
}
static const bool log_ranges = env_on("RX_VK_LOG_READBACK_RANGES");
if (log_ranges)
rb_range_note(gpu, size, w,
Owner

Nit: a range read late can still have w == true from the catch-up above (behind0 && !no_catchup sets it before the late check). Such a read is noted here as both waited and late, and it also counts in the 3000-call "waited for their write-back" line. The numbers stay right, but with a rule set the by-range log can show a late range as "N waited". Either note only the part wait as waited, or say in the log's comment that the catch-up counts.

Nit: a range read late can still have `w == true` from the catch-up above (`behind0 && !no_catchup` sets it before the late check). Such a read is noted here as both waited and late, and it also counts in the 3000-call "waited for their write-back" line. The numbers stay right, but with a rule set the by-range log can show a late range as "N waited". Either note only the part wait as `waited`, or say in the log's comment that the catch-up counts.
HumanBean force-pushed perf/readback-late from 0f7148aca3
Some checks failed
Website / Website on Cloudflare (pull_request) Blocked by required conditions
PR description / Description follows the template (pull_request_target) Failing after 3s
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been cancelled
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been cancelled
CI / Workflow lint (actionlint) (pull_request) Has been cancelled
Third-party parts / As their notes say (pull_request) Has been cancelled
Third-party parts / The same as upstream (pull_request) Has been cancelled
to 68ea13561a
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
PR description / Description follows the template (pull_request_target) Failing after 3s
CI / Conventional Commit title (pull_request) Successful in 4s
CI / Workflow lint (actionlint) (pull_request) Successful in 7s
Third-party parts / As their notes say (pull_request) Successful in 8s
2026-11-14 00:52:16 +00:00
Compare
Owner

Rebased onto main 5b81a8a1 (was on 514591a3) to clear the merge conflict; nothing else in the PR changed.

  • Conflict: docs/tested-configurations.md only. Main added three 2026-11-13 rows at the top of the table (the 6 GB mirror budget, #237); this PR's Steam Deck OLED row is kept on top as before, main's three rows follow it unchanged.
  • The code commit (gpu/vk/vk_replay.cpp, gpu/vk/readback_late.h, the tests) and docs/development.md applied without conflict.
  • The diff against main has exactly the same added and removed lines as the PR's diff against its old base.
  • New head: 68ea1356 (the two commits are still authored by p0lux).

No tests, builds or compilers were run here.

Rebased onto main `5b81a8a1` (was on `514591a3`) to clear the merge conflict; nothing else in the PR changed. - Conflict: `docs/tested-configurations.md` only. Main added three 2026-11-13 rows at the top of the table (the 6 GB mirror budget, https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/237); this PR's Steam Deck OLED row is kept on top as before, main's three rows follow it unchanged. - The code commit (`gpu/vk/vk_replay.cpp`, `gpu/vk/readback_late.h`, the tests) and `docs/development.md` applied without conflict. - The diff against main has exactly the same added and removed lines as the PR's diff against its old base. - New head: `68ea1356` (the two commits are still authored by p0lux). No tests, builds or compilers were run here.
HumanBean deleted branch perf/readback-late 2026-11-14 01:02:46 +00:00
First-time contributor

@p0lux, a question before I combine this with #303 (RX_VK_READBACK_EARLY). Both act on the same readback waits in invalidate(), so I've rebased #303 over this PR and would like your view on how they should fit together. I haven't decided anything yet.

How they differ

  • RX_VK_READBACK_LATE reads a range a rule names without waiting: the frame before's values.
  • RX_VK_READBACK_EARLY (no VRAM mirrors, so the Deck) still waits, but only until the GPU finishes the part of the submission that wrote the range, so the values are exact. On the Deck, the 0x3980 block's writer was done 6.4 ms into an 18.9 ms wait, and 0 bytes changed after that part. On the moving Kokiri route at stock clocks it gave +2.2 to +3.0 real fps (15.2 → 18.2 on the user build, 13.9 → 16.5/16.0 on #303's head).
  • So #303 doesn't cover the frame-ahead case you mentioned: it shortens the exact wait and doesn't skip it.

The order in the rebase, for now

  • A range your rule names: read late, unchanged.
  • Any other read of pages written in place: with EARLY on, it waits for its writer's part.
  • Neither switch: as before.
  • With newest set, EARLY is left with the reads whose writer is an older submission, so I expect it to add little on top.

Options

  1. Keep two switches with that order. They do different things: one trades exactness for speed on named ranges, the other shortens the exact waits.
  2. One switch, with EARLY as a mode of RX_VK_READBACK_LATE. Then a switch called LATE would sometimes read exact values, which seems confusing to me.
  3. If newest is as fast on the Deck with an equally clean picture, close #303, or keep it only as the exact option for ranges where a frame-old value is wrong.

I'm asking for one Deck A/B from a single build: off / newest / EARLY / both, on the moving Kokiri route at stock clocks, with the screenshots checked. The results will go on #303. Which would you prefer, or do you see another way to combine them?

@p0lux, a question before I combine this with #303 (`RX_VK_READBACK_EARLY`). Both act on the same readback waits in `invalidate()`, so I've rebased #303 over this PR and would like your view on how they should fit together. I haven't decided anything yet. **How they differ** - `RX_VK_READBACK_LATE` reads a range a rule names without waiting: the frame before's values. - `RX_VK_READBACK_EARLY` (no VRAM mirrors, so the Deck) still waits, but only until the GPU finishes the part of the submission that wrote the range, so the values are exact. On the Deck, the 0x3980 block's writer was done 6.4 ms into an 18.9 ms wait, and 0 bytes changed after that part. On the moving Kokiri route at stock clocks it gave +2.2 to +3.0 real fps (15.2 → 18.2 on the user build, 13.9 → 16.5/16.0 on #303's head). - So #303 doesn't cover the frame-ahead case you mentioned: it shortens the exact wait and doesn't skip it. **The order in the rebase, for now** - A range your rule names: read late, unchanged. - Any other read of pages written in place: with EARLY on, it waits for its writer's part. - Neither switch: as before. - With `newest` set, EARLY is left with the reads whose writer is an older submission, so I expect it to add little on top. **Options** 1. Keep two switches with that order. They do different things: one trades exactness for speed on named ranges, the other shortens the exact waits. 2. One switch, with EARLY as a mode of `RX_VK_READBACK_LATE`. Then a switch called LATE would sometimes read exact values, which seems confusing to me. 3. If `newest` is as fast on the Deck with an equally clean picture, close #303, or keep it only as the exact option for ranges where a frame-old value is wrong. I'm asking for one Deck A/B from a single build: off / `newest` / EARLY / both, on the moving Kokiri route at stock clocks, with the screenshots checked. The results will go on #303. Which would you prefer, or do you see another way to combine them?
First-time contributor

@p0lux, the Deck comparison I mentioned is in (details on #303: #303).

Setup: one build on main cf605c85 (with this PR), the player's settings (handheld, 1536x864 at 100%, FG 3x), stock clocks, the moving Kokiri route, interleaved, two runs per arm.

arm real fps
nothing set 10.8, 11.4
newest 12.0, 11.6
RX_VK_READBACK_EARLY=1 12.6, 12.8
both 12.7, 13.0

Picture: route shots match in every arm. In the flash runs (one per arm, unmeasured): nothing set 1 flash (Navi's light), newest 0, both 2 (Navi again, and a Kokiri boy white for one present).

My reading: on the Deck, newest adds nothing measurable on top of EARLY, so I'd keep the two switches as they are (one exact, one late) rather than merge them, and not change newest. Your view is still welcome, as is the result of the desktop runs HumanBean mentioned. One difference worth knowing: EARLY makes the next capture wait for late pages (~20 ms of memory sync a frame), and newest doesn't. A follow-up for that is being measured.

@p0lux, the Deck comparison I mentioned is in (details on #303: https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/303). **Setup:** one build on main cf605c85 (with this PR), the player's settings (handheld, 1536x864 at 100%, FG 3x), stock clocks, the moving Kokiri route, interleaved, two runs per arm. | arm | real fps | |---|---| | nothing set | 10.8, 11.4 | | `newest` | 12.0, 11.6 | | `RX_VK_READBACK_EARLY=1` | 12.6, 12.8 | | both | 12.7, 13.0 | **Picture:** route shots match in every arm. In the flash runs (one per arm, unmeasured): nothing set 1 flash (Navi's light), `newest` 0, both 2 (Navi again, and a Kokiri boy white for one present). **My reading:** on the Deck, `newest` adds nothing measurable on top of EARLY, so I'd keep the two switches as they are (one exact, one late) rather than merge them, and not change `newest`. Your view is still welcome, as is the result of the desktop runs HumanBean mentioned. One difference worth knowing: EARLY makes the next capture wait for late pages (~20 ms of memory sync a frame), and `newest` doesn't. A follow-up for that is being measured.
Author
Contributor

A follow-up after the merge, as announced above: the measurement again on a newer main, and the Windows build that was the one box left unticked.

Main 514591a3 against the change rebased on it (0f7148ac), four arms alternated on the same three scenes, the same Steam Deck OLED and method (720 presents a run; heavy view 3 rounds, the others 2):

fps main @ 514591a3 the change @ 0f7148ac, no rule the change @ 0f7148ac, newest the change @ 0f7148ac, newest,3980
heavy view 24.08 24.07 28.97 28.98
Hyrule Field 21.24 21.21 25.42 25.54
Kokiri Forest 22.71 22.72 24.98 24.95
a read-back that waits, heavy view 2.35 ms 2.55 ms 0.02 ms 0.03 ms
heavy view    main 24.08 23.97 24.53 | no rule 24.06 24.09 24.07 | newest 29.26 28.95 28.97 | newest,3980 28.64 29.24 28.98
Hyrule Field  main 21.24 21.23       | no rule 21.12 21.30       | newest 25.39 25.45       | newest,3980 25.48 25.60
Kokiri Forest main 22.83 22.59       | no rule 22.73 22.71       | newest 25.08 24.87       | newest,3980 24.97 24.92

So the gain holds there: +20% on the heavy view and in Hyrule Field, +10% in Kokiri Forest, and with no rule it measures as main. No error in 30 runs.

Windows: longshot_vk.dll cross-builds from 0f7148ac with llvm-mingw 20261104 on Linux (RX_TARGET=windows RX_VK_ONLY=1 RX_RELEASE=1 nx2/build.py --standalone), 0 failed. Not run in the game on Windows.

A follow-up after the merge, as announced above: the measurement again on a newer main, and the Windows build that was the one box left unticked. Main 514591a3 against the change rebased on it (0f7148ac), four arms alternated on the same three scenes, the same Steam Deck OLED and method (720 presents a run; heavy view 3 rounds, the others 2): | fps | main @ 514591a3 | the change @ 0f7148ac, no rule | the change @ 0f7148ac, `newest` | the change @ 0f7148ac, `newest,3980` | |---|---|---|---|---| | heavy view | 24.08 | 24.07 | 28.97 | 28.98 | | Hyrule Field | 21.24 | 21.21 | 25.42 | 25.54 | | Kokiri Forest | 22.71 | 22.72 | 24.98 | 24.95 | | a read-back that waits, heavy view | 2.35 ms | 2.55 ms | 0.02 ms | 0.03 ms | ``` heavy view main 24.08 23.97 24.53 | no rule 24.06 24.09 24.07 | newest 29.26 28.95 28.97 | newest,3980 28.64 29.24 28.98 Hyrule Field main 21.24 21.23 | no rule 21.12 21.30 | newest 25.39 25.45 | newest,3980 25.48 25.60 Kokiri Forest main 22.83 22.59 | no rule 22.73 22.71 | newest 25.08 24.87 | newest,3980 24.97 24.92 ``` So the gain holds there: +20% on the heavy view and in Hyrule Field, +10% in Kokiri Forest, and with no rule it measures as main. No error in 30 runs. Windows: `longshot_vk.dll` cross-builds from 0f7148ac with llvm-mingw 20261104 on Linux (`RX_TARGET=windows RX_VK_ONLY=1 RX_RELEASE=1 nx2/build.py --standalone`), 0 failed. Not run in the game on Windows.
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
PR description / Description follows the template (pull_request_target) Failing after 3s
CI / Conventional Commit title (pull_request) Successful in 4s
CI / Workflow lint (actionlint) (pull_request) Successful in 7s
Third-party parts / As their notes say (pull_request) Successful in 8s
Some workflows are waiting to be reviewed.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
MagicBeanDev/Longshot!238
No description provided.