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

Draft
p0lux wants to merge 1 commit from p0lux/Longshot:perf/readback-late into main
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. Most of that wait is one pair of 0x3980-byte ranges that the game reads right after submitting the work that fills them. Reading those without the wait is the largest single gain I have measured on the Deck. The finding is camilobt's, from Hookshot's issue 1000.

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 two ranges were found.
  • 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".

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,3980 on a Steam Deck after more testing Steam Deck OLED: Hyrule Field and heavy views (numbers below, to be redone on one binary) not known yet everything but a Steam Deck OLED
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

Not measured as this PR yet, which is why it is WIP: this commit is the tidy version of a local patch, and the comparison the template asks for (one binary, the switch unset against set, interleaved) is still to run on the Deck. It will also separate newest, 3980 and newest,3980.

For orientation only, the local patch with RX_VK_READBACK_LATE=newest,3980 against main, two binaries, runs not interleaved between the two sides:

main: 000f19fb (2026-11-13)
this PR: perf/readback-late @ c23f844e is not measured; the numbers are main 000f19fb + the local patch
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
Scene: a save standing still on a heavy view (about 2,500 draws a frame), and one standing still in Hyrule Field (about 1,600 draws); headless, handheld profile, Minimum preset, the game's own upscaler
Method: each run starts the game, loads the save by script, waits 3 log windows, then measures exactly 720 presents: fps over that span and the log's replay time
How to reproduce: to come with the real measurement

main @ 000f19fb main @ 000f19fb + the local patch, newest,3980
heavy view: fps 23.62 / 24.29 / 24.03 / 23.48 25.83 / 25.88 / 25.41
Hyrule Field: fps 22.55 / 22.60 27.25 (one run)

On Hookshot the same patch, one binary, the switch off against on: Hyrule Field 22.49 to 27.29 fps (one run each), a heavy view 23.6 to 24.7 (two runs each).

Testing

Tested: python -I -B tests/run_all.py on macOS with the Vulkan headers given: 472 passed, 19 failed, 36 skipped; the 19 fail the same way on main on that machine (18 for reasons of the machine: no /proc, no posix_fallocate, the Linux launch scripts; and test_texture_staging_publication, which fails on main since the log levels came in). test_readback_late.py fails on main (the header is not there) and passes here; test_readback_part.py, test_readback_settle.py and test_readback_wait_bounded.py pass. In the game, the local patch only: newest,3980 played on the Deck with the picture for two days on Hookshot and about an hour on Longshot today, with no flicker noticed by the player.

Not tested: this commit in the game (not built yet: the tidy-up moved the rule to a header and put the log behind a switch); the flash detector with the switch on; any hardware but a Steam Deck OLED; Windows.

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 the same two ranges and 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. 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, already measured case of that on a Deck (two ranges), plus the log that finds such ranges. If yours covers it, say so and I close this or reduce it to the log.

The rules are independent on purpose: newest alone may be enough, and the measurement will say which of newest, 3980 and both carries the gain.

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. Not yet: the numbers above are a local patch on two binaries; the switch off against on, on one binary, is to run on the Deck.
  • 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. With the measurement.
  • 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. Not built yet on either: Linux with the Deck measurement, Windows open.
  • 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. Most of that wait is one pair of 0x3980-byte ranges that the game reads right after submitting the work that fills them. Reading those without the wait is the largest single gain I have measured on the Deck. The finding is camilobt's, from Hookshot's issue 1000. ## 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 two ranges were found. - 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". ## 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,3980` on a Steam Deck after more testing | Steam Deck OLED: Hyrule Field and heavy views (numbers below, to be redone on one binary) | not known yet | everything but a Steam Deck OLED | | `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 Not measured as this PR yet, which is why it is WIP: this commit is the tidy version of a local patch, and the comparison the template asks for (one binary, the switch unset against set, interleaved) is still to run on the Deck. It will also separate `newest`, `3980` and `newest,3980`. For orientation only, the local patch with `RX_VK_READBACK_LATE=newest,3980` against main, two binaries, runs not interleaved between the two sides: main: 000f19fb (2026-11-13) this PR: perf/readback-late @ c23f844e is not measured; the numbers are main 000f19fb + the local patch 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 Scene: a save standing still on a heavy view (about 2,500 draws a frame), and one standing still in Hyrule Field (about 1,600 draws); headless, handheld profile, Minimum preset, the game's own upscaler Method: each run starts the game, loads the save by script, waits 3 log windows, then measures exactly 720 presents: fps over that span and the log's replay time How to reproduce: to come with the real measurement | | main @ 000f19fb | main @ 000f19fb + the local patch, `newest,3980` | |---|---|---| | heavy view: fps | 23.62 / 24.29 / 24.03 / 23.48 | 25.83 / 25.88 / 25.41 | | Hyrule Field: fps | 22.55 / 22.60 | 27.25 (one run) | On Hookshot the same patch, one binary, the switch off against on: Hyrule Field 22.49 to 27.29 fps (one run each), a heavy view 23.6 to 24.7 (two runs each). ## Testing **Tested:** `python -I -B tests/run_all.py` on macOS with the Vulkan headers given: 472 passed, 19 failed, 36 skipped; the 19 fail the same way on main on that machine (18 for reasons of the machine: no `/proc`, no `posix_fallocate`, the Linux launch scripts; and `test_texture_staging_publication`, which fails on main since the log levels came in). `test_readback_late.py` fails on main (the header is not there) and passes here; `test_readback_part.py`, `test_readback_settle.py` and `test_readback_wait_bounded.py` pass. In the game, the local patch only: `newest,3980` played on the Deck with the picture for two days on Hookshot and about an hour on Longshot today, with no flicker noticed by the player. **Not tested:** this commit in the game (not built yet: the tidy-up moved the rule to a header and put the log behind a switch); the flash detector with the switch on; any hardware but a Steam Deck OLED; Windows. **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 the same two ranges and 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. 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, already measured case of that on a Deck (two ranges), plus the log that finds such ranges. If yours covers it, say so and I close this or reduce it to the log. The rules are independent on purpose: `newest` alone may be enough, and the measurement will say which of `newest`, `3980` and both carries the gain. ## 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). - [ ] Measured: main and this PR at named commits, on the same PC, scene and settings, at least 2 interleaved runs each, the median reported. Not yet: the numbers above are a local patch on two binaries; the switch off against on, on one binary, is to run on the Deck. - [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). - [ ] A row in `docs/tested-configurations.md` for each measured run. With the measurement. - [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. Not built yet on either: Linux with the Deck measurement, Windows open. - [x] Docs updated.
perf(vk): RX_VK_READBACK_LATE, the readbacks a rule names are read without their wait (opt-in)
Some checks are pending
CI / Conventional Commit title (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Blocked by required conditions
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Blocked by required conditions
CI / Workflow lint (actionlint) (pull_request) Blocked by required conditions
Third-party parts / As their notes say (pull_request) Blocked by required conditions
Third-party parts / The same as upstream (pull_request) Blocked by required conditions
PR description / Description follows the template (pull_request_target) Successful in 28s
Website / Website on Cloudflare (pull_request_target) Has been skipped
c23f844e35
A CPU read of GPU results waits for the write-back of the submission that writes them. On a Steam Deck one pair of
0x3980-byte ranges is most of that wait, and the game reads them right after submitting the work that fills them: it
cannot count on that work being done. The finding is camilobt's, on Hookshot's issue 1000.

RX_VK_READBACK_LATE=<rule>[,<rule>...] names the ranges read without the wait: `newest` (the writer is the newest
submission handed out) and a hex size; either is enough (gpu/vk/readback_late.h). Off unless set: every readback
waits, as before. RX_VK_LOG_READBACK_RANGES=1 logs the readbacks by range, which is how such ranges are found.

tests/test_readback_late.py runs the rules; tests/test_readback_part.py follows invalidate's text and runs the wait
with and without a rule.

Assisted-by: Claude Fable 5.2
Co-Authored-By: Claude Fable 5.2 <[email protected]>
HumanBean added this to the v3.0.0 milestone 2026-11-13 17:46:22 +00:00
Some checks are pending
CI / Conventional Commit title (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Blocked by required conditions
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Blocked by required conditions
CI / Workflow lint (actionlint) (pull_request) Blocked by required conditions
Third-party parts / As their notes say (pull_request) Blocked by required conditions
Third-party parts / The same as upstream (pull_request) Blocked by required conditions
PR description / Description follows the template (pull_request_target) Successful in 28s
Website / Website on Cloudflare (pull_request_target) Has been skipped
This pull request has changes conflicting with the target branch.
  • docs/development.md
Some workflows are waiting to be reviewed.
View command line instructions

Manual merge helper

Use this merge commit message when completing the merge manually.

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u 'perf/readback-late:p0lux-perf/readback-late'
git switch 'p0lux-perf/readback-late'
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
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.