perf(vk): below 60 fps the eviction's waits are seconds, not presents: the compaction's hold and the GPU-drawn textures' keep #771

Merged
HumanBean merged 2 commits from Yeetbot90/Longshot:perf/texture-age-at-60 into main 2026-11-15 18:31:30 +00:00
Contributor

Type: perf · Risk: low · Tested on: Windows 11, game-free checks (no game run) · Needs: Windows, a GPU under 60 fps with VRAM near its limit (an integrated GPU or a 4-8 GB card)

Why

When video memory runs short, the compaction lets go of textures nobody has drawn for ten seconds, and of more once they are thirty seconds old. Those times are written as ten and thirty seconds, but they are counted as 600 and 1800 frames, which is only right at 60 fps. Below 60 fps the wait gets longer: at 20 fps it is 30 s and 90 s, and at 3.5 fps (Unity's UHD Graphics laptop, an i3-1115G4) it is three and nine minutes. That laptop held 1,776 MB of textures against a budget of 1,299 MB, and the compaction freed 0 to 13 textures a pass. Textures the GPU drew have a wait of their own, a minute (3600 frames; 600 near the driver's limit), which nothing frees sooner: at 3.5 fps that is 17 minutes, and on that laptop 1,011 MB of the 1,776 MB were GPU-drawn, with 47 MB of all textures used in the last 8 frames. Players below 60 fps are the ones with the least VRAM to spare, the same players behind the worklist's top row (memory filling up over play, worst on 4-8 GB cards). See NotRexed/Hookshot#993.

What changed

The compaction's two waits and the GPU-drawn textures' keep now go by time, from the frame rate over the last ten seconds. At 60 fps or more nothing changes. The keep never goes under 120 frames, and the eviction's own one-second wait stays counted in frames, because the game samples some GPU-drawn textures every so many of its own frames (a terrain normal map every 40) and one let go between two samples comes back empty.

  • memory: age_at_60 in gpu/vk/vram_compact.h (a texture's unused presents as at 60 fps; unchanged at 60 fps or more or with no rate known; under 2 fps counted as 2 fps, so a load's stall does not age every texture at once).
  • memory: texture_frame in gpu/vk/vk_texture.cpp keeps a mark (time, present count) at each budget look (every 10 presents) and measures the presents a second over the last ten seconds; the hold's pass calls vram_compact::hold_target with age_at_60(frame_no - last, present_rate). The eviction's own wait stays in presents: RX_VK_EVICT_MIN_UNUSED (60).
  • memory: at_rate in gpu/vk/keep_rendered.h (the keep as many presents as it is seconds at 60 fps, 120 at least), used where texture_frame takes keep_rendered::presents.
  • tests: tests/test_keep_rendered.cpp and .py (at_rate at 60, 30, 3.5 and 0.5 fps and its floor; the call), tests/test_vram_compact.cpp (age_at_60 and hold_target with it, Unity's 3.5 fps), tests/test_vram_compact.py (the call, the rate, the switch, and the eviction's own wait still in presents).
  • docs: docs/development.md (RX_VK_EVICT_BY_PRESENTS).

Measurements

Verdict: the game-free checks pass; nothing was measured in the game. This PC runs the game at well over 60 fps, where the change does nothing, and has no game data in this worktree. The in-game run is under Must be tested.

Hardware: Windows 11, llvm-mingw clang 20261104 (the nightly's toolchain), Python 3
Scene: none (the checks need no game). The 3.5 fps and the textures and budget come from Unity's nightly-132-5e072fc8 logs (UHD Graphics, i3-1115G4, 16 GB, 7.2 GB allowed)
Method: tests/test_vram_compact.py (compiles vram_compact.h and runs its rules, and reads texture_frame's source) against main's source and this PR's; tests/run_all.py on both, one after the other
How to reproduce: python -I -B tests/test_vram_compact.py; for main, copy this PR's tests/test_vram_compact.* into a worktree at aa95d35f and run it

Before: main @ aa95d35f After: this PR @ a8a41ddf
A texture unused 10 s at 3.5 fps (35 presents): the hold's line not reached (35 of 600; reached after 171 s) reached (age 600)
Unused 30 s at 3.5 fps (105 presents): the reserve's line not reached (reached after 514 s) reached (age 1800)
A GPU-drawn texture unused a minute at 3.5 fps (210 presents) kept (3600 presents: 17 minutes) may go (keep 210)
The same near the driver's limit, unused 10 s at 3.5 fps kept (600 presents: 3 minutes) kept to 120 presents (34 s), the floor
Any frame rate of 60 fps or more 600 and 1800 presents; keep 3600 or 600 the same
tests/test_vram_compact.py and tests/test_keep_rendered.py (this PR's versions) both fail both pass
tests/run_all.py 540 passed, 18 failed, 35 skipped 540 passed, 18 failed, 35 skipped: the same 18 (this worktree's setup: packaging, the builder's preflight, release; none reads the changed files)
main @ aa95d35f, with this PR's test_vram_compact:
vk_texture.cpp: texture_frame() no longer has: const uint64_t to = vram_compact::hold_target(vram_compact::age_at_60(frame_no - last, present_rate), need_unused,

main @ aa95d35f, with this PR's test_keep_rendered:
vk_texture.cpp no longer has: keep_rendered::at_rate(keep_rendered::presents(known, used, allowed, keep_over_only), present_rate)

this PR:
PASS: the compaction's rules
keep rendered: all checks passed

Unity's log (nightly-132, the last look before Hyrule Castle):
vk: VRAM: 6709 MB used of 7172 MB allowed (1121 MB of it free in the blocks); textures 1776 MB (budget 1299 MB), 1208 textures
vk: textures: decoded ASTC 0 MB, rendered 1011 MB (32 drawn in one frame, kept: 4634 KB), other 449 MB; used in the last 8 presents 47 MB
vk: 7 textures retired (1156 left, 1988 MB): budget 0, drawn over 0, dead targets 7
vi: 13439 frames presented (3.8 fps over the last 5.2 s, interval 1)

Flags and settings

One new switch, to go back to counting in presents for both.

Flag What it is Before this PR After this PR Effect Helps / doesn't help / untested Recommendation
RX_VK_EVICT_BY_PRESENTS=1 The compaction's waits and the GPU-drawn textures' keep counted in presents whatever the frame rate (always so) unset: waits in seconds Below 60 fps unused textures go after 10 s and 30 s instead of 600 and 1800 presents, GPU-drawn ones after a minute (120 presents at least) instead of 3600 Helps: under 60 fps with VRAM over the compaction's lines. Doesn't: 60 fps or more (no change). Untested: in the game Unset: only for A/B tests

Testing

Tested: On this PC (Windows 11), the game-free checks: tests/test_vram_compact.py and tests/test_keep_rendered.py fail on main's source and pass here; tests/test_texture_squeeze.py and tests/test_blocks_at_start.py pass; tests/run_all.py on main and on this PR (Measurements). The renderer (longshot_vk.dll) builds with llvm-mingw.

Not tested: The game, on any GPU. Not measured: how many more textures go, and come back as uploads, on a PC below 60 fps; whether ten seconds is still the right wait at 20-30 fps (it was measured at 60). Not built on Linux (the change is portable C++: std::chrono, std::deque).

Must be tested:

  • Windows, an integrated GPU or a 4-8 GB card running under 30 fps (Unity's UHD Graphics laptop is one): play 10 minutes through Hyrule Field into Castle Town on main and on this PR, same settings, expecting the VRAM: lines' textures to come down to their budget on this PR (on main they stay over it), and no more than a few textures made a frame more (by hand: the vk: VRAM:, textures retired and a frame: lines in longshot_log.txt; test request T-JA2B, waiting for the testing wanted label)

Risks

What could break: Below 60 fps, with VRAM over the compaction's lines, textures unused for 10 or 30 seconds now go where they stayed for minutes; one seen again is uploaded again, a hitch on a slow PC. Textures the GPU drew now go after a minute below 60 fps too; the 120-present floor and the eviction's own 60 are what keep a periodically sampled one from coming back empty, and a game texture sampled less often than every 120 of its frames but drawn only once would not be covered (none is known). A frame rate measured too low (a load) could age textures faster: the age goes as at 2 fps at most.

Turning it off: RX_VK_EVICT_BY_PRESENTS=1, or revert the commits.

Compatibility: Nothing changes: no save, options, cache or interface change.

Issues

See NotRexed/Hookshot#993 (memory filling up over play, the worklist's top row)

Checklist

Everyone

  • One topic.
  • The title is a Conventional Commit (type(scope): description) of the status line's type.
  • Why gives the reason plainly and links the issues that fit (the tracker searched).
  • Measurements has a verdict, or one line saying why there is nothing to measure.
  • Testing names what was tested, what wasn't, and what must still be tested, where and how.
  • Rebased on current main.
  • python -I -B tests/run_all.py passes (any failure that also fails on main is named).
  • 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.

When it applies

  • AI assistance recorded with Assisted-by: / Co-Authored-By: trailers.
  • Switches documented (docs/development.md; the README for a menu row) and shipped at their recommended value.
  • 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.
  • Docs updated.
**Type:** perf · **Risk:** low · **Tested on:** Windows 11, game-free checks (no game run) · **Needs:** Windows, a GPU under 60 fps with VRAM near its limit (an integrated GPU or a 4-8 GB card) ## Why When video memory runs short, the compaction lets go of textures nobody has drawn for ten seconds, and of more once they are thirty seconds old. Those times are written as ten and thirty seconds, but they are counted as 600 and 1800 frames, which is only right at 60 fps. Below 60 fps the wait gets longer: at 20 fps it is 30 s and 90 s, and at 3.5 fps (Unity's UHD Graphics laptop, an i3-1115G4) it is three and nine minutes. That laptop held 1,776 MB of textures against a budget of 1,299 MB, and the compaction freed 0 to 13 textures a pass. Textures the GPU drew have a wait of their own, a minute (3600 frames; 600 near the driver's limit), which nothing frees sooner: at 3.5 fps that is 17 minutes, and on that laptop 1,011 MB of the 1,776 MB were GPU-drawn, with 47 MB of all textures used in the last 8 frames. Players below 60 fps are the ones with the least VRAM to spare, the same players behind the worklist's top row (memory filling up over play, worst on 4-8 GB cards). See [NotRexed/Hookshot#993](https://github.com/NotRexed/Hookshot/issues/993). ## What changed The compaction's two waits and the GPU-drawn textures' keep now go by time, from the frame rate over the last ten seconds. At 60 fps or more nothing changes. The keep never goes under 120 frames, and the eviction's own one-second wait stays counted in frames, because the game samples some GPU-drawn textures every so many of its own frames (a terrain normal map every 40) and one let go between two samples comes back empty. - memory: `age_at_60` in `gpu/vk/vram_compact.h` (a texture's unused presents as at 60 fps; unchanged at 60 fps or more or with no rate known; under 2 fps counted as 2 fps, so a load's stall does not age every texture at once). - memory: `texture_frame` in `gpu/vk/vk_texture.cpp` keeps a mark (time, present count) at each budget look (every 10 presents) and measures the presents a second over the last ten seconds; the hold's pass calls `vram_compact::hold_target` with `age_at_60(frame_no - last, present_rate)`. The eviction's own wait stays in presents: `RX_VK_EVICT_MIN_UNUSED` (60). - memory: `at_rate` in `gpu/vk/keep_rendered.h` (the keep as many presents as it is seconds at 60 fps, 120 at least), used where `texture_frame` takes `keep_rendered::presents`. - tests: `tests/test_keep_rendered.cpp` and `.py` (`at_rate` at 60, 30, 3.5 and 0.5 fps and its floor; the call), `tests/test_vram_compact.cpp` (`age_at_60` and `hold_target` with it, Unity's 3.5 fps), `tests/test_vram_compact.py` (the call, the rate, the switch, and the eviction's own wait still in presents). - docs: `docs/development.md` (`RX_VK_EVICT_BY_PRESENTS`). ## Measurements **Verdict:** the game-free checks pass; nothing was measured in the game. This PC runs the game at well over 60 fps, where the change does nothing, and has no game data in this worktree. The in-game run is under Must be tested. Hardware: Windows 11, llvm-mingw clang 20261104 (the nightly's toolchain), Python 3 Scene: none (the checks need no game). The 3.5 fps and the textures and budget come from Unity's `nightly-132-5e072fc8` logs (UHD Graphics, i3-1115G4, 16 GB, 7.2 GB allowed) Method: `tests/test_vram_compact.py` (compiles `vram_compact.h` and runs its rules, and reads `texture_frame`'s source) against main's source and this PR's; `tests/run_all.py` on both, one after the other How to reproduce: `python -I -B tests/test_vram_compact.py`; for main, copy this PR's `tests/test_vram_compact.*` into a worktree at aa95d35f and run it | | Before: main @ aa95d35f | After: this PR @ a8a41ddf | |---|---|---| | A texture unused 10 s at 3.5 fps (35 presents): the hold's line | not reached (35 of 600; reached after 171 s) | reached (age 600) | | Unused 30 s at 3.5 fps (105 presents): the reserve's line | not reached (reached after 514 s) | reached (age 1800) | | A GPU-drawn texture unused a minute at 3.5 fps (210 presents) | kept (3600 presents: 17 minutes) | may go (keep 210) | | The same near the driver's limit, unused 10 s at 3.5 fps | kept (600 presents: 3 minutes) | kept to 120 presents (34 s), the floor | | Any frame rate of 60 fps or more | 600 and 1800 presents; keep 3600 or 600 | the same | | `tests/test_vram_compact.py` and `tests/test_keep_rendered.py` (this PR's versions) | both fail | both pass | | `tests/run_all.py` | 540 passed, 18 failed, 35 skipped | 540 passed, 18 failed, 35 skipped: the same 18 (this worktree's setup: packaging, the builder's preflight, release; none reads the changed files) | ``` main @ aa95d35f, with this PR's test_vram_compact: vk_texture.cpp: texture_frame() no longer has: const uint64_t to = vram_compact::hold_target(vram_compact::age_at_60(frame_no - last, present_rate), need_unused, main @ aa95d35f, with this PR's test_keep_rendered: vk_texture.cpp no longer has: keep_rendered::at_rate(keep_rendered::presents(known, used, allowed, keep_over_only), present_rate) this PR: PASS: the compaction's rules keep rendered: all checks passed Unity's log (nightly-132, the last look before Hyrule Castle): vk: VRAM: 6709 MB used of 7172 MB allowed (1121 MB of it free in the blocks); textures 1776 MB (budget 1299 MB), 1208 textures vk: textures: decoded ASTC 0 MB, rendered 1011 MB (32 drawn in one frame, kept: 4634 KB), other 449 MB; used in the last 8 presents 47 MB vk: 7 textures retired (1156 left, 1988 MB): budget 0, drawn over 0, dead targets 7 vi: 13439 frames presented (3.8 fps over the last 5.2 s, interval 1) ``` ### Flags and settings One new switch, to go back to counting in presents for both. | Flag | What it is | Before this PR | After this PR | Effect | Helps / doesn't help / untested | Recommendation | |---|---|---|---|---|---|---| | `RX_VK_EVICT_BY_PRESENTS=1` | The compaction's waits and the GPU-drawn textures' keep counted in presents whatever the frame rate | (always so) | unset: waits in seconds | Below 60 fps unused textures go after 10 s and 30 s instead of 600 and 1800 presents, GPU-drawn ones after a minute (120 presents at least) instead of 3600 | Helps: under 60 fps with VRAM over the compaction's lines. Doesn't: 60 fps or more (no change). Untested: in the game | Unset: only for A/B tests | ## Testing **Tested:** On this PC (Windows 11), the game-free checks: `tests/test_vram_compact.py` and `tests/test_keep_rendered.py` fail on main's source and pass here; `tests/test_texture_squeeze.py` and `tests/test_blocks_at_start.py` pass; `tests/run_all.py` on main and on this PR (Measurements). The renderer (`longshot_vk.dll`) builds with llvm-mingw. **Not tested:** The game, on any GPU. Not measured: how many more textures go, and come back as uploads, on a PC below 60 fps; whether ten seconds is still the right wait at 20-30 fps (it was measured at 60). Not built on Linux (the change is portable C++: `std::chrono`, `std::deque`). **Must be tested:** - Windows, an integrated GPU or a 4-8 GB card running under 30 fps (Unity's UHD Graphics laptop is one): play 10 minutes through Hyrule Field into Castle Town on main and on this PR, same settings, expecting the `VRAM:` lines' textures to come down to their budget on this PR (on main they stay over it), and no more than a few `textures made` a frame more (by hand: the `vk: VRAM:`, `textures retired` and `a frame:` lines in `longshot_log.txt`; test request T-JA2B, waiting for the testing wanted label) ## Risks **What could break:** Below 60 fps, with VRAM over the compaction's lines, textures unused for 10 or 30 seconds now go where they stayed for minutes; one seen again is uploaded again, a hitch on a slow PC. Textures the GPU drew now go after a minute below 60 fps too; the 120-present floor and the eviction's own 60 are what keep a periodically sampled one from coming back empty, and a game texture sampled less often than every 120 of its frames but drawn only once would not be covered (none is known). A frame rate measured too low (a load) could age textures faster: the age goes as at 2 fps at most. **Turning it off:** `RX_VK_EVICT_BY_PRESENTS=1`, or revert the commits. **Compatibility:** Nothing changes: no save, options, cache or interface change. ## Issues See [NotRexed/Hookshot#993](https://github.com/NotRexed/Hookshot/issues/993) (memory filling up over play, the worklist's top row) ## Checklist ### Everyone - [x] One topic. - [x] The title is a Conventional Commit (`type(scope): description`) of the status line's type. - [x] Why gives the reason plainly and links the issues that fit (the tracker searched). - [x] Measurements has a verdict, or one line saying why there is nothing to measure. - [x] Testing names what was tested, what wasn't, and what must still be tested, where and how. - [x] Rebased on current `main`. - [x] `python -I -B tests/run_all.py` passes (any failure that also fails on `main` is named). - [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. ### When it applies - [x] AI assistance recorded with `Assisted-by:` / `Co-Authored-By:` trailers. - [x] Switches documented (docs/development.md; the README for a menu row) and shipped at their recommended value. - [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. - [x] Docs updated.
perf(vk): the compaction's hold and reserve wait ten and thirty seconds at any frame rate, not 600 and 1800 presents
Some checks failed
PR description / Description follows the template (pull_request_target) Successful in 16s
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
Guide screenshots / Guide images and local UI source review (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
18ba9357f5
The hold lets go of live textures unused for 600 presents and the reserve for 1800: ten and thirty seconds at 60 fps, as
vram_compact.h says, but they were counted in presents. Below 60 fps they stretched: at 3.5 fps (Unity's UHD Graphics
laptop, i3-1115G4) three and nine minutes, and with 1,776 MB of textures held against a budget of 1,299 MB the hold
retired 0 to 13 textures a pass. A texture's age now goes to hold_target as at 60 fps (vram_compact::age_at_60), by the
presents a second over the last ten seconds, measured at each budget look. At 60 fps or more nothing changes.

The eviction's own wait (RX_VK_EVICT_MIN_UNUSED, 60) and the rendered textures' keep (keep_rendered.h) stay in presents
and still run first: the game samples some of those every so many of its frames, and a GPU-drawn one evicted between
two samples comes back empty. Under 2 fps (a load's stall) the age goes as at 2 fps. RX_VK_HOLD_BY_PRESENTS=1: as before.

Co-Authored-By: Cursor <[email protected]>
perf(vk): the GPU-drawn textures' keep is a minute at any frame rate, 120 presents at least
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
Guide screenshots / Guide images and local UI source review (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 3s
Website / Website on Cloudflare (pull_request_target) Has been skipped
a8a41ddffc
An unused texture the GPU drew is kept 3600 presents (a minute at 60 fps; 600 near the driver's limit) before the eviction
may let it go, and the compaction's hold cannot take it sooner. Below 60 fps that stretched like the hold's waits: 17 and
3 minutes at 3.5 fps. On Unity's UHD Graphics laptop 1,011 MB of the 1,776 MB of textures were GPU-drawn, 47 MB of all of
them used in the last 8 presents. keep_rendered::at_rate gives the keep as many presents as it is seconds at 60 fps, by
the presents a second the hold's waits use, and never fewer than 120: the game samples some GPU-drawn textures every so
many of its own frames (a terrain normal map every 40), and one let go between two samples comes back empty.

RX_VK_HOLD_BY_PRESENTS is RX_VK_EVICT_BY_PRESENTS now, for both.

Co-Authored-By: Cursor <[email protected]>
Yeetbot90 changed title from perf(vk): the compaction's hold and reserve wait ten and thirty seconds at any frame rate, not 600 and 1800 presents to perf(vk): below 60 fps the eviction's waits are seconds, not presents: the compaction's hold and the GPU-drawn textures' keep 2026-11-15 11:09:54 +00:00
Author
Contributor

New commit a8a41ddf: the GPU-drawn textures' keep (a minute, 3600 presents; 600 near the driver's limit) also goes by seconds below 60 fps, never under 120 presents (keep_rendered::at_rate). On Unity's UHD Graphics laptop 1,011 MB of the 1,776 MB of textures were GPU-drawn and the keep was 17 minutes at 3.5 fps, so the first commit alone could not free them. The switch is RX_VK_EVICT_BY_PRESENTS now, for both. tests/test_keep_rendered.py fails on main's source and passes here; tests/run_all.py fails the same 18 checks as main (540 passed, 35 skipped). The description is updated.

New commit a8a41ddf: the GPU-drawn textures' keep (a minute, 3600 presents; 600 near the driver's limit) also goes by seconds below 60 fps, never under 120 presents (`keep_rendered::at_rate`). On Unity's UHD Graphics laptop 1,011 MB of the 1,776 MB of textures were GPU-drawn and the keep was 17 minutes at 3.5 fps, so the first commit alone could not free them. The switch is `RX_VK_EVICT_BY_PRESENTS` now, for both. `tests/test_keep_rendered.py` fails on main's source and passes here; `tests/run_all.py` fails the same 18 checks as main (540 passed, 35 skipped). The description is updated.
First-time contributor

Windows RTX 4050 Laptop (6 GB) test of this PR at a8a41ddf against its Nightly 189 base aa95d35f. This is relevant to my Castle Town low-FPS report, but it does not establish an FPS gain or test the requested 10-minute memory behavior.

Hardware: Windows 11 Home build 26200, i5-13420H, RTX 4050 Laptop 6 GB, NVIDIA 621.14, 32 GB RAM. Active screen: laptop panel at 1920×1080/60 Hz. I built only the renderer DLL from this PR and used a separate copy of the same Nightly 189 game; the original renderer and my normal saves were untouched. test_vram_compact.py and test_keep_rendered.py passed locally. I did not run the full source suite. Both versions loaded and exited normally.

Both arms started from the same copied child-Market save with byte-identical options: Handheld profile, dynamic internal resolution, the game's upscaler, fullscreen, Auto frame generation. The numbers below are base game presents, not generated frames.

Scripted route: Two runs per arm entered Market from Hyrule Field and walked straight in three 90-frame holds, with the camera at -180 degrees and near, but not identical, positions. The mean of three rolling-FPS snapshots per run was base 25.6 / 32.0 and PR 32.4 / 25.7. After 15 seconds standing still: base 35.0 / 35.4, PR 34.1 / 32.2. The first pair favored the PR and the second favored base, so there is no repeatable FPS gain here. An earlier route with turns was excluded because its cameras and paths diverged. A brief PR opt-out smoke launch with RX_VK_EVICT_BY_PRESENTS=1 loaded successfully, but used different graphics settings and was not a performance comparison.

Player-controlled route: I moved Link for 60 seconds in direct Market, teleported to Hyrule Field and moved for 60 seconds, then returned to Market. A per-game-present trace gave:

Phase Base mean FPS PR mean FPS Base p99 frame time PR p99 frame time
Direct Market, 60 s 30.51 28.00 67.24 ms 72.08 ms
Hyrule Field, 60 s 32.46 31.30 72.84 ms 82.39 ms
Return Market, first 20 s 23.21 24.05 106.10 ms 91.85 ms

The player-controlled camera/path differed between arms. On the PR return to Market, Link stopped moving after about 20 seconds; the remaining 40 seconds were mostly stationary and must not be compared with the base's full moving minute. The PR's scheduled return teleport was briefly refused because a game event was playing; it worked after the event ended.

Both logs showed textures above the changing texture budget and VRAM short: given back while playing. The routes were only a few minutes, not the PR's requested 10-minute Field-to-Market test, and they do not show whether the revised eviction ages reduce long-session texture pressure or cause more reuploads. So far, the low-FPS symptom remains on this 6 GB PC; the memory effect of #771 is still unverified. I can run the longer matched route if that is the most useful next test. No game files or saves are attached.

Windows RTX 4050 Laptop (6 GB) test of this PR at `a8a41ddf` against its Nightly 189 base `aa95d35f`. This is relevant to my [Castle Town low-FPS report](https://git.magicbean.dev/MagicBeanDev/Longshot/issues/813), but **it does not establish an FPS gain or test the requested 10-minute memory behavior**. Hardware: Windows 11 Home build 26200, i5-13420H, RTX 4050 Laptop 6 GB, NVIDIA 621.14, 32 GB RAM. Active screen: laptop panel at 1920×1080/60 Hz. I built only the renderer DLL from this PR and used a separate copy of the same Nightly 189 game; the original renderer and my normal saves were untouched. `test_vram_compact.py` and `test_keep_rendered.py` passed locally. I did not run the full source suite. Both versions loaded and exited normally. Both arms started from the same copied child-Market save with byte-identical options: Handheld profile, dynamic internal resolution, the game's upscaler, fullscreen, Auto frame generation. The numbers below are **base game presents, not generated frames**. **Scripted route:** Two runs per arm entered Market from Hyrule Field and walked straight in three 90-frame holds, with the camera at -180 degrees and near, but not identical, positions. The mean of three rolling-FPS snapshots per run was **base 25.6 / 32.0** and **PR 32.4 / 25.7**. After 15 seconds standing still: base **35.0 / 35.4**, PR **34.1 / 32.2**. The first pair favored the PR and the second favored base, so there is no repeatable FPS gain here. An earlier route with turns was excluded because its cameras and paths diverged. A brief PR opt-out smoke launch with `RX_VK_EVICT_BY_PRESENTS=1` loaded successfully, but used different graphics settings and was not a performance comparison. **Player-controlled route:** I moved Link for 60 seconds in direct Market, teleported to Hyrule Field and moved for 60 seconds, then returned to Market. A per-game-present trace gave: | Phase | Base mean FPS | PR mean FPS | Base p99 frame time | PR p99 frame time | |---|---:|---:|---:|---:| | Direct Market, 60 s | 30.51 | 28.00 | 67.24 ms | 72.08 ms | | Hyrule Field, 60 s | 32.46 | 31.30 | 72.84 ms | 82.39 ms | | Return Market, first 20 s | 23.21 | 24.05 | 106.10 ms | 91.85 ms | The player-controlled camera/path differed between arms. On the PR return to Market, Link stopped moving after about 20 seconds; the remaining 40 seconds were mostly stationary and **must not be compared** with the base's full moving minute. The PR's scheduled return teleport was briefly refused because a game event was playing; it worked after the event ended. Both logs showed textures above the changing texture budget and `VRAM short: given back` while playing. The routes were only a few minutes, not the PR's requested 10-minute Field-to-Market test, and they do not show whether the revised eviction ages reduce long-session texture pressure or cause more reuploads. So far, **the low-FPS symptom remains on this 6 GB PC; the memory effect of #771 is still unverified**. I can run the longer matched route if that is the most useful next test. No game files or saves are attached.
HumanBean left a comment

The goal is right. Below 60 fps the hold's ten and thirty seconds and the GPU-drawn textures' minute stretch into minutes, and the Unity log shows the hold freeing almost nothing. Switching to time is the fix. The new code is careful in several places:

  • steady_clock is monotonic.
  • The rate counts the game's own presents. texture_frame runs once per replay present, so frame generation's made frames don't inflate it.
  • Caps above 60 return early, so they keep counting in presents.
  • The eviction's own 60-present wait stays in presents. The outer loop's frame_no - last < need_unused skip runs before hold_target, and the scaled age is never below the raw one, so hold_target's rule_to tier is unchanged.
  • at_rate and age_at_60 are continuous at 60 fps. A 59.94 Hz V-Sync measures as 59.9 and changes nothing that matters.

What has to change: the code applies one rate, averaged over the last ten seconds, to every texture's whole age. A gap of seconds with no presents breaks this. A load's stall is one such gap: the replay thread blocks, so texture_frame isn't called. Main now has one of 6 s per ASTC array on the AMD desktop iGPUs. A long hitch is another, and a minimised window too if presents stop there. After the gap, the front mark is the one from before it, and it stays there for ten more seconds. So the rate reads far under the real one for those ten seconds, and every texture is aged by it, including the ones used only since the gap.

At 60 fps after a 30 s gap the rate reads about 2 fps one second in (ages 30 times too large) and about 8.6 fps five seconds in (ages 7 times too large). Five seconds in:

  • A texture unused 1.4 s counts as past the hold's ten-second line.
  • A texture unused 4.3 s counts as past the reserve's thirty-second line.
  • The keep for GPU-drawn textures falls from a minute to between 120 presents (2 s) and 514 presents.

So "nothing changes at 60 fps or more" doesn't hold right after a load or a hitch. The window just after an area loads is when the most textures are new and VRAM is tightest, so it is when the hold runs. The textures it drops get uploaded again in the next frames, and a GPU-drawn one can come back empty, which the keep exists to prevent. The same error appears without a gap when the frame rate changes, for example going from Hyrule Field at 60 into the market at 20: ages get stretched or shrunk by the new rate. Details and a suggested fix are inline.

No rebase needed: main has moved 11 commits, but none touches these lines, and Forgejo shows it mergeable.

Testing: only the game-free checks so far (no tests were run here; this is a code review). Rogasse's run at a8a41ddf, on a 6 GB RTX 4050 laptop at 25-35 fps, inconclusive on fps, doesn't cover the 10-minute Field-to-Market route or the memory effect. Before merge, this needs:

  • the Must-be-tested run under 30 fps (test request T-JA2B);
  • once the fix is in, one check at 60 fps across a load or alt-tab: textures made / textures retired in the ten seconds after it, against main.

Requesting changes for the stall/rate issue.

The goal is right. Below 60 fps the hold's ten and thirty seconds and the GPU-drawn textures' minute stretch into minutes, and the Unity log shows the hold freeing almost nothing. Switching to time is the fix. The new code is careful in several places: - `steady_clock` is monotonic. - The rate counts the game's own presents. `texture_frame` runs once per replay present, so frame generation's made frames don't inflate it. - Caps above 60 return early, so they keep counting in presents. - The eviction's own 60-present wait stays in presents. The outer loop's `frame_no - last < need_unused` skip runs before `hold_target`, and the scaled age is never below the raw one, so `hold_target`'s `rule_to` tier is unchanged. - `at_rate` and `age_at_60` are continuous at 60 fps. A 59.94 Hz V-Sync measures as 59.9 and changes nothing that matters. **What has to change:** the code applies one rate, averaged over the last ten seconds, to every texture's whole age. A gap of seconds with no presents breaks this. A load's stall is one such gap: the replay thread blocks, so `texture_frame` isn't called. Main now has one of 6 s per ASTC array on the AMD desktop iGPUs. A long hitch is another, and a minimised window too if presents stop there. After the gap, the front mark is the one from before it, and it stays there for ten more seconds. So the rate reads far under the real one for those ten seconds, and every texture is aged by it, including the ones used only since the gap. At 60 fps after a 30 s gap the rate reads about 2 fps one second in (ages 30 times too large) and about 8.6 fps five seconds in (ages 7 times too large). Five seconds in: - A texture unused 1.4 s counts as past the hold's ten-second line. - A texture unused 4.3 s counts as past the reserve's thirty-second line. - The keep for GPU-drawn textures falls from a minute to between 120 presents (2 s) and 514 presents. So "nothing changes at 60 fps or more" doesn't hold right after a load or a hitch. The window just after an area loads is when the most textures are new and VRAM is tightest, so it is when the hold runs. The textures it drops get uploaded again in the next frames, and a GPU-drawn one can come back empty, which the keep exists to prevent. The same error appears without a gap when the frame rate changes, for example going from Hyrule Field at 60 into the market at 20: ages get stretched or shrunk by the new rate. Details and a suggested fix are inline. No rebase needed: main has moved 11 commits, but none touches these lines, and Forgejo shows it mergeable. **Testing:** only the game-free checks so far (no tests were run here; this is a code review). Rogasse's run at a8a41ddf, on a 6 GB RTX 4050 laptop at 25-35 fps, inconclusive on fps, doesn't cover the 10-minute Field-to-Market route or the memory effect. Before merge, this needs: - the Must-be-tested run under 30 fps (test request T-JA2B); - once the fix is in, one check at 60 fps across a load or alt-tab: `textures made` / `textures retired` in the ten seconds after it, against main. Requesting changes for the stall/rate issue.
@ -500,6 +500,7 @@ Pipeline-cache file ownership, temporary storage and failure behavior are descri
| `RX_VK_AUTO_PRESET=0`, `RX_VK_AUTO_PRESET_VRAM_MB=<MB>` | A first start keeps the defaults instead of starting on the Quality preset Auto (this PC) (`gpu/vk/auto_settings.h`); the card's video memory taken as that many MB, to see what Auto chooses for a smaller or larger card |
| `RX_VK_AUTO_TUNE=0` | Auto (this PC) keeps its Render resolution instead of measuring the GPU in play and stepping it: down while frames are missed with the GPU over 90% busy, up while the cap holds with it under half busy (`auto_settings.h` `tune`) |
| `RX_VK_COMPACT=0`, `RX_VK_COMPACT_PCT=<50..90>`, `RX_VK_COMPACT_MB=<MB>` | VRAM compaction (on by default; `=0` turns it off): the textures that are not render targets in a pool of their own, moved out of sparsely used blocks a few a present so that the blocks go back to the driver, and the texture budget holding the driver's count at that share of what it allows (85) by letting go of the render targets drawn over since and of textures unused for ten seconds, with a reserve five points under it (80) where those unused for thirty seconds go (what the budget's size, the 90% rule and the driver's limit ask for on their own still goes from textures unused for a second, as with it off); the free space that starts a run (512; 192 while the count is over 90%) |
| `RX_VK_EVICT_BY_PRESENTS=1` | The eviction's waits in seconds counted in presents whatever the frame rate, as before: the compaction's ten and thirty seconds (600 and 1800 presents) and the GPU-drawn textures' keep (a minute, 3600; 600 near the driver's limit). By default, below 60 fps, they go by the presents a second over the last ten seconds (`vram_compact::age_at_60`, `keep_rendered::at_rate`), so they are still those seconds (at 3.5 fps they were three, nine and seventeen minutes); the keep never under 120 presents, as the game samples some GPU-drawn textures every so many of its frames. Nothing changes at 60 fps or more |
Owner

"Nothing changes at 60 fps or more" and "the presents a second over the last ten seconds": for ten seconds after a load or hitch, the measured rate drops well under 60 even when the game runs at 60, and the waits shrink with it (see vk_texture.cpp line 4255). Please update this line along with the fix, and the PR description's Risks line "A frame rate measured too low (a load) could age textures faster: the age goes as at 2 fps at most": at 60 fps, "as at 2 fps" means a 30× faster age.

"Nothing changes at 60 fps or more" and "the presents a second over the last ten seconds": for ten seconds after a load or hitch, the measured rate drops well under 60 even when the game runs at 60, and the waits shrink with it (see `vk_texture.cpp` line 4255). Please update this line along with the fix, and the PR description's Risks line "A frame rate measured too low (a load) could age textures faster: the age goes as at 2 fps at most": at 60 fps, "as at 2 fps" means a 30× faster age.
@ -18,0 +23,4 @@
inline constexpr uint64_t kFloor = 120;
inline uint64_t at_rate(uint64_t keep, double fps) {
if (!(fps > 0) || fps >= 60) return keep;
return std::max<uint64_t>((uint64_t)((double)keep * std::max(fps, 2.0) / 60.0), std::min(keep, kFloor));
Owner

This is where a low rate after a stall does the most damage. at_rate(3600, 2) and at_rate(600, 2) are both 120. So in the first seconds after a load, even at 60 fps, a GPU-drawn texture the game still holds goes after 2 s unused, not a minute. Those are the textures the keep protects, because one made again over its storage starts empty (the sky/course texture case in vk_texture.cpp). The floor covers the 40-frame normal map but not a texture drawn at the area's load and sampled a few seconds later. The fix on vk_texture.cpp line 4255 avoids this, because the age is measured per texture rather than through a rate.

This is where a low rate after a stall does the most damage. `at_rate(3600, 2)` and `at_rate(600, 2)` are both 120. So in the first seconds after a load, even at 60 fps, a GPU-drawn texture the game still holds goes after 2 s unused, not a minute. Those are the textures the keep protects, because one made again over its storage starts empty (the sky/course texture case in `vk_texture.cpp`). The floor covers the 40-frame normal map but not a texture drawn at the area's load and sampled a few seconds later. The fix on `vk_texture.cpp` line 4255 avoids this, because the age is measured per texture rather than through a rate.
@ -4244,0 +4252,4 @@
marks.push_back({now, frame_no});
while (marks.size() > 2 && now - marks[1].first >= std::chrono::seconds(10)) marks.pop_front();
const double span = std::chrono::duration<double>(now - marks.front().first).count();
if (!by_presents && span >= 2) present_rate = (double)(frame_no - marks.front().second) / span;
Owner

Problem: this rate is the presents over the span from the front mark to now. After a gap with no texture_frame calls (a load's stall, a multi-second hitch, a minimised window if presents stop), the front mark is the one from before the gap. It stays the front for the next ten seconds, because the trim only pops it once marks[1], the first mark after the gap, is 10 s old.

Example: 60 fps after a 30 s gap. One second in, the rate is 60/31 ≈ 1.9 fps (clamped to 2). Five seconds in it is 300/35 ≈ 8.6 fps. age_at_60 multiplies every texture's present count by 7 to 30, including the textures used only since the gap. A texture unused 86 presents (1.4 s) is past the hold's ten-second line, and one unused 258 presents (4.3 s) is past the reserve's.

The frame rate changing has the same effect on a smaller scale: one rate is applied to an age that built up at another rate.

Suggested fix: measure each texture's age in seconds rather than through an average rate. Keep a play clock that advances at each look by min(dt, 10 / vram_compact::kMinFps), so a gap counts at most 5 s. Store (frame_no, play_s) marks for the last minute, the keep's longest wait. Look up the mark at or before last to get the seconds since the texture's last use. Then compare max(frame_no - last, (uint64_t)(secs * 60)) against the 600/1800/3600/600 lines, with the 120-present floor kept on frame_no - last.

At 60 fps or more secs * 60 <= presents, so nothing changes there and no rate threshold is needed. Below 60 the seconds win. Around 1,200 textures with a binary search each, once every 10 presents, costs nothing.

**Problem:** this rate is the presents over the span from the front mark to now. After a gap with no `texture_frame` calls (a load's stall, a multi-second hitch, a minimised window if presents stop), the front mark is the one from before the gap. It stays the front for the next ten seconds, because the trim only pops it once `marks[1]`, the first mark after the gap, is 10 s old. **Example:** 60 fps after a 30 s gap. One second in, the rate is 60/31 ≈ 1.9 fps (clamped to 2). Five seconds in it is 300/35 ≈ 8.6 fps. `age_at_60` multiplies every texture's present count by 7 to 30, including the textures used only since the gap. A texture unused 86 presents (1.4 s) is past the hold's ten-second line, and one unused 258 presents (4.3 s) is past the reserve's. The frame rate changing has the same effect on a smaller scale: one rate is applied to an age that built up at another rate. **Suggested fix:** measure each texture's age in seconds rather than through an average rate. Keep a play clock that advances at each look by `min(dt, 10 / vram_compact::kMinFps)`, so a gap counts at most 5 s. Store `(frame_no, play_s)` marks for the last minute, the keep's longest wait. Look up the mark at or before `last` to get the seconds since the texture's last use. Then compare `max(frame_no - last, (uint64_t)(secs * 60))` against the 600/1800/3600/600 lines, with the 120-present floor kept on `frame_no - last`. At 60 fps or more `secs * 60 <= presents`, so nothing changes there and no rate threshold is needed. Below 60 the seconds win. Around 1,200 textures with a binary search each, once every 10 presents, costs nothing.
HumanBean deleted branch perf/texture-age-at-60 2026-11-15 18:31:31 +00:00
Owner

This was merged while the review's request for changes still stood (the rate read too low for ten seconds after a load or a hitch, so textures are aged too fast even at 60 fps: #771 (comment)). So that it isn't in the next release candidate as it is:

  • #873 reverts it on main.
  • #875 brings your two commits back, unchanged, stacked on the revert, as work in progress. The fix from the review goes there: each texture's age measured in seconds, not through one rate averaged over ten seconds.

The idea stays the right one: it's only the rate measurement that needs the change.

This was merged while the review's request for changes still stood (the rate read too low for ten seconds after a load or a hitch, so textures are aged too fast even at 60 fps: https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/771#issuecomment-26880). So that it isn't in the next release candidate as it is: - [#873](https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/873) reverts it on `main`. - [#875](https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/875) brings your two commits back, unchanged, stacked on the revert, as work in progress. The fix from the review goes there: each texture's age measured in seconds, not through one rate averaged over ten seconds. The idea stays the right one: it's only the rate measurement that needs the change.
Author
Contributor

Ok sorry for not changing it. Was not at home so didn't see it

Ok sorry for not changing it. Was not at home so didn't see it
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
Guide screenshots / Guide images and local UI source review (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 3s
Website / Website on Cloudflare (pull_request_target) Has been skipped
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!771
No description provided.