perf(vk): an import mostly outside every game pool is trimmed back to its live parts #2825

Closed
github-sync wants to merge 3 commits from github/pr/2825 into main
Owner

@klikso opened this pull request on GitHub · 2026-11-12 15:34 UTC · original
Merged on GitHub in ab2d4cc104

Why. On an 8 GB card, Castle Town Market runs at ~54 fps with almost no long frames when it is loaded from a save, and at ~45 fps with a long frame every second or two when the player walks in from Hyrule Field, for as long as they stay. Part of the difference is VRAM the previous scene's pools still hold: imports never shrink.

  • An import only grows: a binding across its end makes it again, larger, and a pool the game binds whole is imported whole. A pool's VRAM mirror, and its write-back shadow, are its import's whole range.
  • When the game frees a scene's large pools and makes the next scene's smaller ones at the same addresses, the import keeps the old range. It is bound every frame through the small live pool inside it, so no idle rule ever lets its mirror go.
  • Measured (the two largest; from a local diagnostic that lists the live mirrors, not in this PR): the field's 213 MB pool at 0x108c711000 leaves a 224 MB mirror where the Market loaded from a save has 48 MB; the culling lists' pool is 152 MB where it has 104 MB. With their shadows that is ~260 MB the scene does not bind, on a card where the Market's textures sit right at the 90% line.

This is the launch-to-launch "fast or slow Market" from #995 and my note on #2685: it was not the launch, it was whether the save was in the Market or in the field.

What

gpu/vk/mirror_trim.h (rules only) and mirrors_trim() in vk_memory.cpp, called from memory_begin_submission every 64 submissions:

  • Which import. One with a mirror whose game pools (the host's table, each out to its 4 MB chunks, parts closer than 8 MB joined) leave at least 24 MB and a quarter of it outside every pool, in 1 to 4 live parts, and that has stayed so for 1800 submissions (~5 s). Only while VRAM is over 80% of the allowance: with room to spare nothing is gained.
  • How. Its live parts are imported first (a failed import leaves everything as it was). Then it is retired with retire_pool, as a pool made again larger is, and each part gets its page table entries and a mirror in the same submission's uploads, before anything is recorded. Each new mirror takes what the GPU wrote from the old one through mirror_make's existing inherit (pages the CPU stamped since come from guest memory, as there). The old mirror goes once the GPU is done with it.
  • Room. Nothing is trimmed unless the new mirrors fit the mirror budget beside the old one.
  • No thrash. A range the game binds across again right after a trim is made larger again by the existing path; it then waits 4x and 16x longer, and after three trims in ~2 minutes it is left alone. The count starts over after 36,000 submissions without a trim, so a scene change every few minutes over a session is still trimmed.
  • Not touched: an import with no game pool in it at all (nothing binds it: the idle rules, and #2759's retire of finalized pools, cover that case; this PR is the partly-live one, which #2759 leaves as "alive"), parallel replay segments, cards without mirrors.
  • RX_VK_MIRROR_TRIM=0: off. One log line a trim.

What is given up: what the GPU wrote into the dead part of the old mirror and never wrote back. No game pool covers that memory any more, so nothing can bind it; a pool the game makes there later gets a mirror from guest memory, as any new pool does.

Measured

Note: every run below had this PC's DLSS model row on Transformer 2 (L), not the default K: the harder case (more GPU time and VRAM, #2836). With K the gain is smaller; those numbers are in the comment below.

Windows 11, RTX 3060 Ti 8 GB (driver 32.0.16.2114), Ryzen 5 5600X, 2560x1440 DLSS. main ba005ca4 with #2711, #2731 (at bce3d17e, where it was still on by default), #2685 and #2709 merged locally (the VRAM picture on this card without #2711 is the #2690 regression). Load the save, teleport to Hyrule Field, 10 s there, teleport to the Market, settle, then 80 s of the camera turning. RX_VK_FRAMEGEN=0, HOS_FPS_MAX=240. Three runs an arm, arms alternated. The fourth arm adds #2826 (the 90% rule evicting its excess instead of an eighth of the cache), which is the other half of the same stutter.

arm fps mean frames over 50 ms in 80 s after the first 20 s: fps after the first 20 s: frames over 50 ms per 20 s
main stack 44.4 / 45.0 / 45.0 100 / 69 / 60 46.5 12.3
this PR 45.7 / 47.2 / 47.4 66 / 37 / 28 48.5 4.7
#2826 alone 43.3 / 46.6 / 46.2 75 / 25 / 60 47.0 7.0
this PR + #2826 49.7 / 48.8 / 49.3 10 / 15 / 22 50.6 0.3
  • The first 20 s are the arrival itself (the trim only starts ~6 s after the old pools die), and are about the same in every arm.
  • Both trims happen in every run: pool 0x108c000000..0x109a400000 (228 MB) trimmed to its 1 live part(s), 48 MB and 0x1178400000..0x1181c00000 (152 MB) ... 104 MB. Mirrors at the end of a run: 1,584 MB against 1,820 MB. The new imports took under 2 ms each (no imported in line, which is logged from 2 ms).
  • Loaded from a save in the Market, no import qualifies and no trim runs (0 in two 30 s runs, 47.1 / 47.9 fps; with this and #2826 off 46.5 / 46.0). Those are lower than the 53-55 fps the same load gave in the morning: the driver's allowance was ~100 MB lower by then (6,944 against 7,043 MB), which is enough to put the direct load over the 90% line too.

A session: two rounds of 12 scenes by teleport (Hyrule Field, Kokiri Forest, Market, Kakariko, Lost Woods, Goron City, Zora's Domain, Graveyard, Death Mountain Trail, Zora's River, Sacred Forest Meadow, Gerudo Valley), 20 s of camera turning in each, with the flash detector on (RX_VK_FLASH=1, RX_VK_SHOT_EVERY=1): 24 of 24 scenes reached, 13 trims (600 MB given back in all), no flash detected, no device loss, no failed import. The mirrors moved between 1.2 and 2.0 GB and ended at 1,656 MB; the same tour before this sat at the 2,000 MB budget. Screenshots after the camera turn in Kokiri Forest (foliage, grass), Hyrule Field and the Market look right; I did not watch the runs live.

Not tested

  • AMD, Intel, Linux. On Windows the retired import goes back by the existing rules; AMD and Intel hold retired imports for a grace time there (retired_give_back.h), which this does not change but does feed.
  • RX_VK_PARALLEL (the trim does not run inside its segments, and was not run with it on).
  • A card with more VRAM than it needs: the 80% gate means it should never run there.
  • Long play (hours).

Tests

  • New tests/test_mirror_trim.py compiles and runs tests/test_mirror_trim.cpp (the Market's case, imports their pools fill, a small dead share, no live pool, parts joined and kept apart, too many parts, the waits and the count that starts over, the VRAM gate) and checks vk_memory.cpp keeps the order: parts imported before the retire, mirrors made before the old one stops waiting to be inherited, room in the budget first.
  • nx2/test_import_windows.py's model of Pool gets the two new fields pool_lookup carries over; tests/test_texel_buffer_ownership.py counts the trim's rollback among the callers of destroy_import_buffer (10).
  • python -I -B tests/run_all.py on Windows: 460 passed, 2 failed, 34 skipped; the two (nx2/test_nup_paths.py, tests/test_input_background.py) fail on unchanged main here too.

🤖 Generated with Claude Code

> [@klikso](https://github.com/klikso) opened this pull request on GitHub · 2026-11-12 15:34 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2825) > **Merged on GitHub** in https://git.magicbean.dev/MagicBeanDev/Hookshot/commit/ab2d4cc104066b155498731a7b8a10eef2f72f5e **Why.** On an 8 GB card, Castle Town Market runs at ~54 fps with almost no long frames when it is loaded from a save, and at ~45 fps with a long frame every second or two when the player walks in from Hyrule Field, for as long as they stay. Part of the difference is VRAM the previous scene's pools still hold: imports never shrink. - An import only grows: a binding across its end makes it again, larger, and a pool the game binds whole is imported whole. A pool's VRAM mirror, and its write-back shadow, are its import's whole range. - When the game frees a scene's large pools and makes the next scene's smaller ones at the same addresses, the import keeps the old range. It is bound every frame through the small live pool inside it, so no idle rule ever lets its mirror go. - Measured (the two largest; from a local diagnostic that lists the live mirrors, not in this PR): the field's 213 MB pool at `0x108c711000` leaves a **224 MB** mirror where the Market loaded from a save has **48 MB**; the culling lists' pool is **152 MB** where it has **104 MB**. With their shadows that is ~260 MB the scene does not bind, on a card where the Market's textures sit right at the 90% line. This is the launch-to-launch "fast or slow Market" from #995 and my note on #2685: it was not the launch, it was whether the save was in the Market or in the field. ## What `gpu/vk/mirror_trim.h` (rules only) and `mirrors_trim()` in `vk_memory.cpp`, called from `memory_begin_submission` every 64 submissions: - **Which import.** One with a mirror whose game pools (the host's table, each out to its 4 MB chunks, parts closer than 8 MB joined) leave at least 24 MB and a quarter of it outside every pool, in 1 to 4 live parts, and that has stayed so for 1800 submissions (~5 s). Only while VRAM is over 80% of the allowance: with room to spare nothing is gained. - **How.** Its live parts are imported first (a failed import leaves everything as it was). Then it is retired with `retire_pool`, as a pool made again larger is, and each part gets its page table entries and a mirror **in the same submission's uploads**, before anything is recorded. Each new mirror takes what the GPU wrote from the old one through `mirror_make`'s existing inherit (pages the CPU stamped since come from guest memory, as there). The old mirror goes once the GPU is done with it. - **Room.** Nothing is trimmed unless the new mirrors fit the mirror budget beside the old one. - **No thrash.** A range the game binds across again right after a trim is made larger again by the existing path; it then waits 4x and 16x longer, and after three trims in ~2 minutes it is left alone. The count starts over after 36,000 submissions without a trim, so a scene change every few minutes over a session is still trimmed. - **Not touched:** an import with no game pool in it at all (nothing binds it: the idle rules, and #2759's retire of finalized pools, cover that case; this PR is the partly-live one, which #2759 leaves as "alive"), parallel replay segments, cards without mirrors. - `RX_VK_MIRROR_TRIM=0`: off. One log line a trim. What is given up: what the GPU wrote into the **dead** part of the old mirror and never wrote back. No game pool covers that memory any more, so nothing can bind it; a pool the game makes there later gets a mirror from guest memory, as any new pool does. ## Measured **Note:** every run below had this PC's DLSS model row on Transformer 2 (L), not the default K: the harder case (more GPU time and VRAM, #2836). With K the gain is smaller; those numbers are in the comment below. Windows 11, RTX 3060 Ti 8 GB (driver 32.0.16.2114), Ryzen 5 5600X, 2560x1440 DLSS. `main` ba005ca4 with #2711, #2731 (at bce3d17e, where it was still on by default), #2685 and #2709 merged locally (the VRAM picture on this card without #2711 is the #2690 regression). Load the save, teleport to Hyrule Field, 10 s there, teleport to the Market, settle, then **80 s** of the camera turning. `RX_VK_FRAMEGEN=0`, `HOS_FPS_MAX=240`. Three runs an arm, arms alternated. The fourth arm adds #2826 (the 90% rule evicting its excess instead of an eighth of the cache), which is the other half of the same stutter. | arm | fps mean | frames over 50 ms in 80 s | after the first 20 s: fps | after the first 20 s: frames over 50 ms per 20 s | |---|---|---|---|---| | main stack | 44.4 / 45.0 / 45.0 | 100 / 69 / 60 | 46.5 | 12.3 | | **this PR** | 45.7 / 47.2 / 47.4 | 66 / 37 / 28 | 48.5 | 4.7 | | #2826 alone | 43.3 / 46.6 / 46.2 | 75 / 25 / 60 | 47.0 | 7.0 | | this PR + #2826 | 49.7 / 48.8 / 49.3 | 10 / 15 / 22 | 50.6 | 0.3 | - The first 20 s are the arrival itself (the trim only starts ~6 s after the old pools die), and are about the same in every arm. - Both trims happen in every run: `pool 0x108c000000..0x109a400000 (228 MB) trimmed to its 1 live part(s), 48 MB` and `0x1178400000..0x1181c00000 (152 MB) ... 104 MB`. Mirrors at the end of a run: 1,584 MB against 1,820 MB. The new imports took under 2 ms each (no `imported in` line, which is logged from 2 ms). - Loaded from a save in the Market, no import qualifies and no trim runs (0 in two 30 s runs, 47.1 / 47.9 fps; with this and #2826 off 46.5 / 46.0). Those are lower than the 53-55 fps the same load gave in the morning: the driver's allowance was ~100 MB lower by then (6,944 against 7,043 MB), which is enough to put the direct load over the 90% line too. **A session:** two rounds of 12 scenes by teleport (Hyrule Field, Kokiri Forest, Market, Kakariko, Lost Woods, Goron City, Zora's Domain, Graveyard, Death Mountain Trail, Zora's River, Sacred Forest Meadow, Gerudo Valley), 20 s of camera turning in each, with the flash detector on (`RX_VK_FLASH=1`, `RX_VK_SHOT_EVERY=1`): 24 of 24 scenes reached, **13 trims (600 MB given back in all), no flash detected, no device loss, no failed import**. The mirrors moved between 1.2 and 2.0 GB and ended at 1,656 MB; the same tour before this sat at the 2,000 MB budget. Screenshots after the camera turn in Kokiri Forest (foliage, grass), Hyrule Field and the Market look right; I did not watch the runs live. ## Not tested - AMD, Intel, Linux. On Windows the retired import goes back by the existing rules; AMD and Intel hold retired imports for a grace time there (`retired_give_back.h`), which this does not change but does feed. - `RX_VK_PARALLEL` (the trim does not run inside its segments, and was not run with it on). - A card with more VRAM than it needs: the 80% gate means it should never run there. - Long play (hours). ## Tests - New `tests/test_mirror_trim.py` compiles and runs `tests/test_mirror_trim.cpp` (the Market's case, imports their pools fill, a small dead share, no live pool, parts joined and kept apart, too many parts, the waits and the count that starts over, the VRAM gate) and checks `vk_memory.cpp` keeps the order: parts imported before the retire, mirrors made before the old one stops waiting to be inherited, room in the budget first. - `nx2/test_import_windows.py`'s model of `Pool` gets the two new fields `pool_lookup` carries over; `tests/test_texel_buffer_ownership.py` counts the trim's rollback among the callers of `destroy_import_buffer` (10). - `python -I -B tests/run_all.py` on Windows: 460 passed, 2 failed, 34 skipped; the two (`nx2/test_nup_paths.py`, `tests/test_input_background.py`) fail on unchanged `main` here too. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- github-sync:item:2825 -->
Imports only grew: a binding across an import's end makes it again larger, and a pool the game
binds whole is imported whole. A pool's VRAM mirror and its write-back shadow are its import's
whole range. After the game frees a scene's large pools and makes the next scene's smaller ones
at the same addresses, the import keeps the old range, bound every frame through the small live
pool inside it, and its mirror holds the dead rest for the session. Castle Town Market entered
from Hyrule Field on an 8 GB card: a 224 MB mirror where the Market loaded from a save has 48,
and 152 MB where it has 104.

While VRAM is over 80% full, a mirrored import that has been mostly outside every game pool for
1800 submissions is retired and each of its live parts imported again with a mirror made in the
same submission's uploads, taking the GPU's contents from the old mirror as a pool made again
larger does (gpu/vk/mirror_trim.h, mirrors_trim). RX_VK_MIRROR_TRIM=0: off.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Author
Owner

@klikso commented on GitHub · 2026-11-12 15:57 UTC · original

CI note: the one red check (Python source checks, ubuntu part 2/2) is tests/test_release_scripts.py's test_nightly_two_runs_at_once_end_at_the_newer_commit (AssertionError: 1 != 0 : nightly: moved forward from 1111... to bbbb...), the two-uploads-at-once case that has failed at random before (#2644's description mentions it). This PR touches gpu/vk, its tests and one doc row only; the other 233 checks of that part passed, and #2826, opened a minute later on the same base, is green. A re-run should clear it; I can't trigger one from a fork.

> [@klikso](https://github.com/klikso) commented on GitHub · 2026-11-12 15:57 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2825#issuecomment-6828621234) CI note: the one red check (Python source checks, ubuntu part 2/2) is `tests/test_release_scripts.py`'s `test_nightly_two_runs_at_once_end_at_the_newer_commit` (`AssertionError: 1 != 0 : nightly: moved forward from 1111... to bbbb...`), the two-uploads-at-once case that has failed at random before (#2644's description mentions it). This PR touches `gpu/vk`, its tests and one doc row only; the other 233 checks of that part passed, and #2826, opened a minute later on the same base, is green. A re-run should clear it; I can't trigger one from a fork. <!-- github-sync:c:6828621234 -->
Author
Owner

@klikso commented on GitHub · 2026-11-12 16:09 UTC · original

A correction to the measurements above, found an hour after opening this: this PC's DLSS model row was set to Transformer 2 (L), not the default (K), for every run in the description. L costs ~4 ms more GPU time a frame on this card (#2836) and, judging by the count, more VRAM too, so it is the harder case. The same test with the default model (RX_VK_DLSS_PRESET=K, everything else as in the description, 80 s, two runs an arm, alternated; "both" is #2825 + #2826):

arm (DLSS K) fps mean frames over 50 ms in 80 s fps by 20 s quarter
main stack 50.4 / 52.0 10 / 8 47.7 50.8 49.7 53.9 / 51.7 50.0 53.2 53.4
both 50.6 / 54.2 6 / 1 45.5 48.6 53.4 56.3 / 51.8 54.4 56.3 54.7
  • With K the Market entered from the field is much less short of memory to begin with (8-10 long frames in 80 s on the main stack, against 60-100 with L), so there is less for these two changes to win: about 1 fps and 4-9 fewer long frames, on two runs an arm, which is within what two runs can show.
  • The mechanism is the same in both (the trims happen, the rule's cut is the excess), and the description's numbers stand for a card that is further over the line: this one with L, or a card with less VRAM. I have not run the two changes apart with K.
  • I'll use K for anything further here, and say so.
> [@klikso](https://github.com/klikso) commented on GitHub · 2026-11-12 16:09 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2825#issuecomment-6828915054) A correction to the measurements above, found an hour after opening this: **this PC's DLSS model row was set to Transformer 2 (L), not the default (K)**, for every run in the description. L costs ~4 ms more GPU time a frame on this card (#2836) and, judging by the count, more VRAM too, so it is the harder case. The same test with the default model (`RX_VK_DLSS_PRESET=K`, everything else as in the description, 80 s, two runs an arm, alternated; "both" is #2825 + #2826): | arm (DLSS K) | fps mean | frames over 50 ms in 80 s | fps by 20 s quarter | |---|---|---|---| | main stack | 50.4 / 52.0 | 10 / 8 | 47.7 50.8 49.7 53.9 / 51.7 50.0 53.2 53.4 | | both | 50.6 / 54.2 | 6 / 1 | 45.5 48.6 53.4 56.3 / 51.8 54.4 56.3 54.7 | - With K the Market entered from the field is much less short of memory to begin with (8-10 long frames in 80 s on the main stack, against 60-100 with L), so there is less for these two changes to win: about 1 fps and 4-9 fewer long frames, on two runs an arm, which is within what two runs can show. - The mechanism is the same in both (the trims happen, the rule's cut is the excess), and the description's numbers stand for a card that is further over the line: this one with L, or a card with less VRAM. I have not run the two changes apart with K. - I'll use K for anything further here, and say so. <!-- github-sync:c:6828915054 -->
Author
Owner

@hckrman101 commented on gpu/vk/vk_memory.cpp line 3455 on GitHub · 2026-11-12 16:34 UTC · original

This clears inherit_pending on the old pool whether or not each part actually got a mirror. mirror_make can leave a part without one: there's mirror_budget(), where the check in mirrors_trim counts only kept and not the other pools already in mirror_wanted, which come first in the list; there's RX_VK_MIRROR_TIGHT; and there's create_device_buffer failing, which is more likely here because this only runs over 80% VRAM. A part left like that points at its import, and the old mirror is then freed once the GPU is done with it. If the old pool was gpu_only or gpu_written, what the GPU wrote in the live part is gone, and the part's mirror is later made from guest memory.

Suggestion: clear trimmed/inherit_pending only when every part's mstate is MIRROR_ON. Otherwise leave it pending, so that the part's next mirror_make still inherits, and the existing kIdleEpochs limit still bounds how long that can take. You could also count the current mirror_wanted in the room check (comment below). A check for this in test_mirror_trim.py would be good too.

> [@hckrman101](https://github.com/hckrman101) commented on `gpu/vk/vk_memory.cpp` line 3455 on GitHub · 2026-11-12 16:34 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2825#discussion_r4553303515) This clears `inherit_pending` on the old pool whether or not each part actually got a mirror. `mirror_make` can leave a part without one: there's `mirror_budget()`, where the check in `mirrors_trim` counts only `kept` and not the other pools already in `mirror_wanted`, which come first in the list; there's `RX_VK_MIRROR_TIGHT`; and there's `create_device_buffer` failing, which is more likely here because this only runs over 80% VRAM. A part left like that points at its import, and the old mirror is then freed once the GPU is done with it. If the old pool was `gpu_only` or `gpu_written`, what the GPU wrote in the live part is gone, and the part's mirror is later made from guest memory. Suggestion: clear `trimmed`/`inherit_pending` only when every part's `mstate` is `MIRROR_ON`. Otherwise leave it pending, so that the part's next `mirror_make` still inherits, and the existing `kIdleEpochs` limit still bounds how long that can take. You could also count the current `mirror_wanted` in the room check (comment below). A check for this in `test_mirror_trim.py` would be good too. <!-- github-sync:rc:4553303515 -->
Author
Owner

@hckrman101 commented on gpu/vk/vk_memory.cpp line 3397 on GitHub · 2026-11-12 16:34 UTC · original

The room check should also count what's already in mirror_wanted (as memory_begin_submission does just after). Otherwise the parts can be the ones refused for budget, as described above.

> [@hckrman101](https://github.com/hckrman101) commented on `gpu/vk/vk_memory.cpp` line 3397 on GitHub · 2026-11-12 16:34 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2825#discussion_r4553303528) The room check should also count what's already in `mirror_wanted` (as `memory_begin_submission` does just after). Otherwise the parts can be the ones refused for budget, as described above. <!-- github-sync:rc:4553303528 -->
Author
Owner

@hckrman101 commented on gpu/vk/vk_memory.cpp line 3373 on GitHub · 2026-11-12 16:34 UTC · original

Nit: with atoi, RX_VK_MIRROR_TRIM=on (or true/yes) turns the trim off. The docs' on/off rule accepts those words, so I'd parse it the way the other switches do: for example off only when the value is one of 0/off/false/no, like the env_switch helpers in vk_dlss.cpp/vk_fsr4.cpp.

> [@hckrman101](https://github.com/hckrman101) commented on `gpu/vk/vk_memory.cpp` line 3373 on GitHub · 2026-11-12 16:34 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2825#discussion_r4553303538) Nit: with `atoi`, `RX_VK_MIRROR_TRIM=on` (or `true`/`yes`) turns the trim *off*. The docs' on/off rule accepts those words, so I'd parse it the way the other switches do: for example off only when the value is one of `0`/`off`/`false`/`no`, like the `env_switch` helpers in `vk_dlss.cpp`/`vk_fsr4.cpp`. <!-- github-sync:rc:4553303538 -->
Author
Owner

@hckrman101 reviewed on GitHub · 2026-11-12 16:34 UTC · original

Thanks, this is a well-argued change, and the honest correction about the DLSS model row helps a lot. The rules header is easy to follow, and so is the "import all parts first, then retire" order. Reusing retire_pool and the existing inherit means in-flight work is covered the same way a pool made again larger is: the old import and mirror stay until retired_value is done, the page table entries go at retirement, and the parts' mirrors are made in this submission's uploads before anything is recorded. I don't see a sync problem there.

One real issue (inline): the old mirror stops waiting to be inherited even when a part didn't get its mirror. Then what the GPU wrote there is lost for that part, which is the Kokiri-foliage class of bug that the inherit was added for.

Other points:

  • Chunk size. The description, the header and the tests all assume 4 MB chunks ("each out to its 4 MB chunks"), but import_chunk() is still 16 MB on main and at this head. 4 MB comes from #2685, which was in your local stack, as is the mirror_on() stub this PR adds to nx2/test_import_windows.py (the same line #2685 adds). On main alone the parts round out to 16 MB, so less is given back (the culling lists' 152 MB import is close to the quarter line), and the measured numbers don't describe this PR on its own. I'd rather land #2685 first and this after it. Please say that in the description; I'll handle the order.
  • Measurements. Hardware is named and the A/B is done properly, thanks. But at the default DLSS model (K) the gain is ~1 fps and a few fewer long frames, from two runs an arm, with #2825 and #2826 measured together. Could you add three runs an arm with K for main+#2685 against main+#2685+this PR (without #2826)? That would show what this PR does on its own at the default. The L numbers can stay as the hard case.
  • Not tested on AMD, Intel or Linux. The code path is the same one a pool made again larger takes, so I don't expect a problem there. On Linux, the parts are imported while the old import still holds the same pages, as happens today when a pool grows. Still, one run on an AMD card (Windows, where retired imports wait 30 s) or on Linux would be good before this goes on by default. Even a short session log with the trimmed to lines and no device loss would do.
  • Conflict. GitHub reports a conflict with main. It's only the include line next to #2731's #include "wb_learn.h", so a merge of main fixes it.
  • CI. The red check is tests/test_release_scripts.py's two-nightlies-at-once case, which has nothing to do with this PR. I'll re-run it.
  • Overlaps. #2795 (give back retired imports whose mirror was refused) and #2839 (Linux cap for idle live imports) touch other hunks and are independent. If a part is ever refused a mirror, #2795 lets its import go back once that part is later retired. #2759 also edits memory_begin_submission and retirement (finalized pools, no inherit), so whichever lands second will need a small rebase. Your description already explains how the two cover different cases.
> [@hckrman101](https://github.com/hckrman101) reviewed on GitHub · 2026-11-12 16:34 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2825#pullrequestreview-5878233551) Thanks, this is a well-argued change, and the honest correction about the DLSS model row helps a lot. The rules header is easy to follow, and so is the "import all parts first, then retire" order. Reusing `retire_pool` and the existing inherit means in-flight work is covered the same way a pool made again larger is: the old import and mirror stay until `retired_value` is done, the page table entries go at retirement, and the parts' mirrors are made in this submission's uploads before anything is recorded. I don't see a sync problem there. One real issue (inline): the old mirror stops waiting to be inherited even when a part didn't get its mirror. Then what the GPU wrote there is lost for that part, which is the Kokiri-foliage class of bug that the inherit was added for. Other points: - **Chunk size.** The description, the header and the tests all assume 4 MB chunks ("each out to its 4 MB chunks"), but `import_chunk()` is still 16 MB on main and at this head. 4 MB comes from #2685, which was in your local stack, as is the `mirror_on()` stub this PR adds to `nx2/test_import_windows.py` (the same line #2685 adds). On main alone the parts round out to 16 MB, so less is given back (the culling lists' 152 MB import is close to the quarter line), and the measured numbers don't describe this PR on its own. I'd rather land #2685 first and this after it. Please say that in the description; I'll handle the order. - **Measurements.** Hardware is named and the A/B is done properly, thanks. But at the default DLSS model (K) the gain is ~1 fps and a few fewer long frames, from two runs an arm, with #2825 and #2826 measured together. Could you add three runs an arm with K for main+#2685 against main+#2685+this PR (without #2826)? That would show what this PR does on its own at the default. The L numbers can stay as the hard case. - **Not tested on AMD, Intel or Linux.** The code path is the same one a pool made again larger takes, so I don't expect a problem there. On Linux, the parts are imported while the old import still holds the same pages, as happens today when a pool grows. Still, one run on an AMD card (Windows, where retired imports wait 30 s) or on Linux would be good before this goes on by default. Even a short session log with the `trimmed to` lines and no device loss would do. - **Conflict.** GitHub reports a conflict with main. It's only the include line next to #2731's `#include "wb_learn.h"`, so a merge of main fixes it. - **CI.** The red check is `tests/test_release_scripts.py`'s two-nightlies-at-once case, which has nothing to do with this PR. I'll re-run it. - **Overlaps.** #2795 (give back retired imports whose mirror was refused) and #2839 (Linux cap for idle live imports) touch other hunks and are independent. If a part is ever refused a mirror, #2795 lets its import go back once that part is later retired. #2759 also edits `memory_begin_submission` and retirement (finalized pools, no inherit), so whichever lands second will need a small rebase. Your description already explains how the two cover different cases. <!-- github-sync:r:5878233551 -->
Author
Owner

@hckrman101 approved on GitHub · 2026-11-12 16:56 UTC · original

I pushed two commits on top of yours (a fast-forward, nothing of yours rewritten):

  • daf6b17: merges main. The only conflict was the include line, and both mirror_trim.h and wb_learn.h are kept.
  • 18a733a: the fix for the refused-mirror case from my last review. A new trims_settle() keeps the old mirror waiting to be inherited until every pool over its range has a mirror of its own. A part refused one (budget, RX_VK_MIRROR_TIGHT, a failed allocation) still inherits from it when its mirror is made later, as a pool made again larger does, and kIdleEpochs still bounds the wait. A part refused for a failed allocation is tried again at its next binding. The room check now also counts what's already in mirror_wanted, and no trim runs while the tight rule would refuse every mirror. RX_VK_MIRROR_TRIM is read with env_value_off, so on/true/yes no longer turn it off. test_mirror_trim.py now also compiles trims_settle and checks that a part refused its mirror keeps the old one.

The related checks pass locally, and so does the renderer build with the ABI check. Approved as the maintainer. Still welcome but not blocking: the K-model runs I asked for, and an AMD or Linux run with the trimmed to lines in the log.

> [@hckrman101](https://github.com/hckrman101) approved on GitHub · 2026-11-12 16:56 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2825#pullrequestreview-5878563076) I pushed two commits on top of yours (a fast-forward, nothing of yours rewritten): - daf6b17: merges main. The only conflict was the include line, and both `mirror_trim.h` and `wb_learn.h` are kept. - 18a733a: the fix for the refused-mirror case from my last review. A new `trims_settle()` keeps the old mirror waiting to be inherited until every pool over its range has a mirror of its own. A part refused one (budget, `RX_VK_MIRROR_TIGHT`, a failed allocation) still inherits from it when its mirror is made later, as a pool made again larger does, and `kIdleEpochs` still bounds the wait. A part refused for a failed allocation is tried again at its next binding. The room check now also counts what's already in `mirror_wanted`, and no trim runs while the tight rule would refuse every mirror. `RX_VK_MIRROR_TRIM` is read with `env_value_off`, so `on`/`true`/`yes` no longer turn it off. `test_mirror_trim.py` now also compiles `trims_settle` and checks that a part refused its mirror keeps the old one. The related checks pass locally, and so does the renderer build with the ABI check. Approved as the maintainer. Still welcome but not blocking: the K-model runs I asked for, and an AMD or Linux run with the `trimmed to` lines in the log. <!-- github-sync:r:5878563076 -->
After a trim, memory_begin_submission let the old mirror go even when a live
part was refused its own (the mirror budget, RX_VK_MIRROR_TIGHT, or a failed
allocation), so what the GPU wrote there was lost. trims_settle now lets it
go only once every pool over its range is mirrored; meanwhile a part's mirror
made later still inherits from it, and kIdleEpochs bounds the wait. A part
refused for a failed allocation is tried again at its next binding.

The trim's room check also counts the mirrors already wanted, which are made
first, and does not trim while RX_VK_MIRROR_TIGHT refuses every mirror.
RX_VK_MIRROR_TRIM is read by the switches' rule (env_value_off), so on, true
or yes no longer turn it off.

test_mirror_trim runs trims_settle with a part refused its mirror.
github-sync closed this pull request 2026-11-12 17:59:37 +00:00
Author
Owner

@klikso commented on GitHub · 2026-11-12 17:30 UTC · original

The runs with the default DLSS model (K) that were asked for, on main as merged (bd9b07e6: #2711, #2685, #2624, #2731 opt-in, #2825 with the maintainer's commit, #2826). One build; the arms are the two switches (RX_VK_MIRROR_TRIM=0, RX_VK_EVICT_EXCESS=0), so "off / off" is main without the two.

Windows 11, RTX 3060 Ti 8 GB (driver 32.0.16.2114), Ryzen 5 5600X, 2560x1440, RX_VK_DLSS_PRESET=K. Save, teleport to Hyrule Field, 10 s, teleport to the Market, settle, 80 s of the camera turning. RX_VK_FRAMEGEN=0, HOS_FPS_MAX=240. Three launches an arm, alternated. The counts are from the log inside the measured window: uploads from the 120-present lines, the rest from the textures retired and VRAM compaction lines.

arm fps mean frames over 50 ms uploads a frame textures let go for the budget (passes) texture cache, low-high MB compaction runs replay submit
trim off, excess off 53.3 / 50.6 / 51.5 8 / 25 / 12 5.5 / 6.4 / 6.6 22,323 (203) / 24,588 (222) / 23,415 (209) 2,099-2,862 / 2,076-2,883 / 2,196-2,921 19 / 20 / 20 12.8 / 12.9 / 12.3 ms
#2825 alone (excess off) 56.5 / 56.0 / 53.5 2 / 4 / 22 2.6 / 2.3 / 3.3 10,559 (66) / 8,285 (47) / 13,359 (108) 2,381-2,908 / 2,313-2,854 / 2,355-2,903 8 / 9 / 15 12.3 / 12.2 / 12.1 ms
#2825 + #2826 56.5 / 54.7 / 54.4 7 / 2 / 16 0.7 / 2.4 / 1.3 2,282 (34) / 9,231 (135) / 3,299 (51) 2,498-3,078 / 2,483-3,009 / 2,585-3,008 4 / 12 / 5 11.4 / 11.4 / 11.3 ms
  • #2825 alone, K: +3.5 fps on the means (51.8 -> 55.3), and the eviction loop about halves: 23,400 -> 10,700 textures let go in 80 s, 6.2 -> 2.7 uploads a frame, 20 -> 11 compaction runs. The low end of the sawtooth rises from ~2.1 to ~2.35 GB.
  • #2826 on top: the frame rate is the same within these three (55.3 -> 55.2), but the mechanism the description claims is visible: 10,700 -> 4,900 textures let go, 2.7 -> 1.5 uploads a frame, the cache's low end ~2.5 GB, and the replay's submit 0.8 ms shorter in all three (12.2 -> 11.4 ms). I did not run #2826 without #2825 with K.
  • The long frames are noisy at this length (one launch of each arm has 16-25), so I would not rank the arms by them.

The 6 GB model (RX_VK_TEST_ALLOWED_MB=5632) for #2826's 97% path is still to come, later today.

> [@klikso](https://github.com/klikso) commented on GitHub · 2026-11-12 17:30 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2825#issuecomment-6830994365) The runs with the default DLSS model (K) that were asked for, on `main` as merged (`bd9b07e6`: #2711, #2685, #2624, #2731 opt-in, #2825 with the maintainer's commit, #2826). One build; the arms are the two switches (`RX_VK_MIRROR_TRIM=0`, `RX_VK_EVICT_EXCESS=0`), so "off / off" is main without the two. Windows 11, RTX 3060 Ti 8 GB (driver 32.0.16.2114), Ryzen 5 5600X, 2560x1440, `RX_VK_DLSS_PRESET=K`. Save, teleport to Hyrule Field, 10 s, teleport to the Market, settle, 80 s of the camera turning. `RX_VK_FRAMEGEN=0`, `HOS_FPS_MAX=240`. Three launches an arm, alternated. The counts are from the log inside the measured window: uploads from the 120-present lines, the rest from the `textures retired` and `VRAM compaction` lines. | arm | fps mean | frames over 50 ms | uploads a frame | textures let go for the budget (passes) | texture cache, low-high MB | compaction runs | replay submit | |---|---|---|---|---|---|---|---| | trim off, excess off | 53.3 / 50.6 / 51.5 | 8 / 25 / 12 | 5.5 / 6.4 / 6.6 | 22,323 (203) / 24,588 (222) / 23,415 (209) | 2,099-2,862 / 2,076-2,883 / 2,196-2,921 | 19 / 20 / 20 | 12.8 / 12.9 / 12.3 ms | | **#2825 alone** (excess off) | 56.5 / 56.0 / 53.5 | 2 / 4 / 22 | 2.6 / 2.3 / 3.3 | 10,559 (66) / 8,285 (47) / 13,359 (108) | 2,381-2,908 / 2,313-2,854 / 2,355-2,903 | 8 / 9 / 15 | 12.3 / 12.2 / 12.1 ms | | **#2825 + #2826** | 56.5 / 54.7 / 54.4 | 7 / 2 / 16 | 0.7 / 2.4 / 1.3 | 2,282 (34) / 9,231 (135) / 3,299 (51) | 2,498-3,078 / 2,483-3,009 / 2,585-3,008 | 4 / 12 / 5 | 11.4 / 11.4 / 11.3 ms | - **#2825 alone, K:** +3.5 fps on the means (51.8 -> 55.3), and the eviction loop about halves: 23,400 -> 10,700 textures let go in 80 s, 6.2 -> 2.7 uploads a frame, 20 -> 11 compaction runs. The low end of the sawtooth rises from ~2.1 to ~2.35 GB. - **#2826 on top:** the frame rate is the same within these three (55.3 -> 55.2), but the mechanism the description claims is visible: 10,700 -> 4,900 textures let go, 2.7 -> 1.5 uploads a frame, the cache's low end ~2.5 GB, and the replay's submit 0.8 ms shorter in all three (12.2 -> 11.4 ms). I did not run #2826 without #2825 with K. - The long frames are noisy at this length (one launch of each arm has 16-25), so I would not rank the arms by them. The 6 GB model (`RX_VK_TEST_ALLOWED_MB=5632`) for #2826's 97% path is still to come, later today. <!-- github-sync:c:6830994365 -->
Commenting is not possible because the repository is archived.
No description provided.