perf(vk): below 60 fps the eviction's waits are seconds, not presents: the compaction's hold and the GPU-drawn textures' keep #771
No reviewers
Labels
No labels
area/accessibility
area/audio
area/input
area/install
area/menu
area/renderer
area/saves
area/upscaling
beanstalk: approved @ 1697603
beanstalk: approved @ 2c5a62e
beanstalk: approved @ 4fd82b7
beanstalk: approved @ 64d6d09
beanstalk: approved @ 6f91fae
beanstalk: approved @ 70a78f9
beanstalk: approved @ 8d361dc
beanstalk: approved @ 9d8356e
beanstalk: approved @ abf2227
beanstalk: approved @ d82f202
beanstalk: approved @ e5f1e40
beanstalk: approved @ ee8ab5f
ci: approved
good first issue
gpu/amd
gpu/igpu
gpu/intel
gpu/nvidia
help wanted
kind
bug
kind
chore
kind
docs
kind
feature
kind
performance
kind
question
needs measurements
platform/linux
platform/macos
platform/steam-deck
platform/windows
preview
priority
critical
priority
high
priority
low
regression
status
blocked
status
confirmed
status
duplicate
status
invalid
status
needs-info
status
wontfix
testing wanted
ci: approved
No milestone
No project
No assignees
3 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Longshot!771
Loading…
Reference in a new issue
No description provided.
Delete branch "Yeetbot90/Longshot:perf/texture-age-at-60"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.
age_at_60ingpu/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).texture_frameingpu/vk/vk_texture.cppkeeps 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 callsvram_compact::hold_targetwithage_at_60(frame_no - last, present_rate). The eviction's own wait stays in presents:RX_VK_EVICT_MIN_UNUSED(60).at_rateingpu/vk/keep_rendered.h(the keep as many presents as it is seconds at 60 fps, 120 at least), used wheretexture_frametakeskeep_rendered::presents.tests/test_keep_rendered.cppand.py(at_rateat 60, 30, 3.5 and 0.5 fps and its floor; the call),tests/test_vram_compact.cpp(age_at_60andhold_targetwith 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/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-5e072fc8logs (UHD Graphics, i3-1115G4, 16 GB, 7.2 GB allowed)Method:
tests/test_vram_compact.py(compilesvram_compact.hand runs its rules, and readstexture_frame's source) against main's source and this PR's;tests/run_all.pyon both, one after the otherHow to reproduce:
python -I -B tests/test_vram_compact.py; for main, copy this PR'stests/test_vram_compact.*into a worktree ataa95d35fand run itaa95d35fa8a41ddftests/test_vram_compact.pyandtests/test_keep_rendered.py(this PR's versions)tests/run_all.pyFlags and settings
One new switch, to go back to counting in presents for both.
RX_VK_EVICT_BY_PRESENTS=1Testing
Tested: On this PC (Windows 11), the game-free checks:
tests/test_vram_compact.pyandtests/test_keep_rendered.pyfail on main's source and pass here;tests/test_texture_squeeze.pyandtests/test_blocks_at_start.pypass;tests/run_all.pyon 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:
VRAM:lines' textures to come down to their budget on this PR (on main they stay over it), and no more than a fewtextures madea frame more (by hand: thevk: VRAM:,textures retiredanda frame:lines inlongshot_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
type(scope): description) of the status line's type.main.python -I -B tests/run_all.pypasses (any failure that also fails onmainis named).third_party/untouched (a new folder with aPROVENANCE.mdinstead).When it applies
Assisted-by:/Co-Authored-By:trailers.mainand passing here.tests/that reads changed source updated in this PR, not weakened.perf(vk): the compaction's hold and reserve wait ten and thirty seconds at any frame rate, not 600 and 1800 presentsto perf(vk): below 60 fps the eviction's waits are seconds, not presents: the compaction's hold and the GPU-drawn textures' keepNew 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 isRX_VK_EVICT_BY_PRESENTSnow, for both.tests/test_keep_rendered.pyfails on main's source and passes here;tests/run_all.pyfails the same 18 checks as main (540 passed, 35 skipped). The description is updated.Rogasse referenced this pull request2026-11-15 13:23:20 +00:00
Windows RTX 4050 Laptop (6 GB) test of this PR at
a8a41ddfagainst its Nightly 189 baseaa95d35f. 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.pyandtest_keep_rendered.pypassed 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=1loaded 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:
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 backwhile 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.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_clockis monotonic.texture_frameruns once per replay present, so frame generation's made frames don't inflate it.frame_no - last < need_unusedskip runs beforehold_target, and the scaled age is never below the raw one, sohold_target'srule_totier is unchanged.at_rateandage_at_60are 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_frameisn'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:
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:textures made/textures retiredin 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 |"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.cppline 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));This is where a low rate after a stall does the most damage.
at_rate(3600, 2)andat_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 invk_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 onvk_texture.cppline 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;Problem: this rate is the presents over the span from the front mark to now. After a gap with no
texture_framecalls (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 oncemarks[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_60multiplies 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 beforelastto get the seconds since the texture's last use. Then comparemax(frame_no - last, (uint64_t)(secs * 60))against the 600/1800/3600/600 lines, with the 120-present floor kept onframe_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.