perf(vk): import game memory in 4 MB chunks, not 16, so VRAM mirrors fit the game's pools #2685

Closed
github-sync wants to merge 4 commits from github/pr/2685 into main
Owner

@SadTransGirl opened this pull request on GitHub · 2026-11-12 06:09 UTC · original
Merged on GitHub in ec638a48e8

A pool's VRAM mirror covers its whole import, and imports are made in 16 MB chunks, so 28-39% of the mirrors' bytes hold memory outside any game pool. Importing in 4 MB chunks cuts the mirrors by roughly a quarter with no frame-rate cost measured.

Cause. Imports are made by chunk (import_chunk, RX_VK_IMPORT_CHUNK_MB) around what was bound, and a mirror copies its pool's whole range. A temporary measuring patch counted, for the live mirrors, the bytes outside every entry of guest_pools: 459 of 1,666 MB in a Hyrule Field walk at DLAA 2560x1440, 463 of 1,360 MB in Kokiri Forest, and up to 925 of 2,400 MB after Castle Town to the field. It also found almost no mirrors with no game pool in them at all, so the waste is padding inside live mirrors, which mirrors_give_back can't reach.

Change. import_chunk() defaults to 4 MB where the pools have VRAM mirrors (mirror_on(): discrete cards, not GCN). Without mirrors a chunk's padding costs no VRAM, so iGPUs and GCN keep 16 MB and take no extra imports. span_game_pools still takes a game pool across a chunk's end into one import (the Kokiri hitch it was made for did not come back). RX_VK_IMPORT_CHUNK_MB=16 gives the old size. docs/development.md gets a row for the switch.

Measured. RTX 4070 SUPER, Windows 11, DLAA 2560x1440, unattended, the same build (main 15646d18; the branch is rebased onto edaef2a9) with and without RX_VK_IMPORT_CHUNK_MB=16:

16 MB 4 MB
Hyrule Field walk, mirrors (two runs a side) 1,645-1,657 MB 1,236-1,268 MB
Hyrule Field walk, fps 59.0, 58.2 58.1, 58.6
Kokiri Forest, mirrors / VRAM mean 1,154 / 7,281 MB 843 / 6,672 MB
Kokiri Forest, fps 57.0 58.2
Castle Town to the field, VRAM mean / fps 8,357 MB / 50.4 7,859 MB / 50.5
6 GB allowance (RX_VK_TEST_ALLOWED_MB=6144), VRAM peak 6,135 / 6,129 MB 5,865 / 5,887 MB
6 GB allowance, GPU's longest frame 24.1 / 21.7 ms 13.5 / 12.5 ms
6 GB allowance, fps 50.3 / 54.9 53.0 / 52.4
Imports in a five-minute walk (driver time) 59-65 (0.7 s) 91-94 (0.8 s)

In the walk the freed VRAM is taken up by textures, as the budget allows, so the process's VRAM mean only drops 90-240 MB there; where the allowance is tight it shows as a lower peak. The 6 GB frame rate went either way across two pairs, so I'm not claiming a frame-rate gain.

Tests. tests/test_import_chunk_default.py checks the default (4 with mirrors, 16 without), that the switch still sets it, and that pool_lookup still spans pools across a chunk's end; it fails on the old default. nx2/test_import_windows.py's fixture gets a mirror_on() stub, since it compiles import_chunk.

Not tested. AMD (an import there cost 20-75 ms on the replay thread per the code's comments, so ~50% more of them may matter), Intel, Linux, a real 6 or 8 GB card.

Part of #993.

🤖 Generated with Claude Code

> [@SadTransGirl](https://github.com/SadTransGirl) opened this pull request on GitHub · 2026-11-12 06:09 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2685) > **Merged on GitHub** in https://git.magicbean.dev/MagicBeanDev/Hookshot/commit/ec638a48e8e7a2c26220436ce40cfd09fb0940ef A pool's VRAM mirror covers its whole import, and imports are made in 16 MB chunks, so 28-39% of the mirrors' bytes hold memory outside any game pool. Importing in 4 MB chunks cuts the mirrors by roughly a quarter with no frame-rate cost measured. **Cause.** Imports are made by chunk (`import_chunk`, `RX_VK_IMPORT_CHUNK_MB`) around what was bound, and a mirror copies its pool's whole range. A temporary measuring patch counted, for the live mirrors, the bytes outside every entry of `guest_pools`: 459 of 1,666 MB in a Hyrule Field walk at DLAA 2560x1440, 463 of 1,360 MB in Kokiri Forest, and up to 925 of 2,400 MB after Castle Town to the field. It also found almost no mirrors with no game pool in them at all, so the waste is padding inside live mirrors, which `mirrors_give_back` can't reach. **Change.** `import_chunk()` defaults to 4 MB where the pools have VRAM mirrors (`mirror_on()`: discrete cards, not GCN). Without mirrors a chunk's padding costs no VRAM, so iGPUs and GCN keep 16 MB and take no extra imports. `span_game_pools` still takes a game pool across a chunk's end into one import (the Kokiri hitch it was made for did not come back). `RX_VK_IMPORT_CHUNK_MB=16` gives the old size. `docs/development.md` gets a row for the switch. **Measured.** RTX 4070 SUPER, Windows 11, DLAA 2560x1440, unattended, the same build (main 15646d18; the branch is rebased onto edaef2a9) with and without `RX_VK_IMPORT_CHUNK_MB=16`: | | 16 MB | 4 MB | |---|---|---| | Hyrule Field walk, mirrors (two runs a side) | 1,645-1,657 MB | 1,236-1,268 MB | | Hyrule Field walk, fps | 59.0, 58.2 | 58.1, 58.6 | | Kokiri Forest, mirrors / VRAM mean | 1,154 / 7,281 MB | 843 / 6,672 MB | | Kokiri Forest, fps | 57.0 | 58.2 | | Castle Town to the field, VRAM mean / fps | 8,357 MB / 50.4 | 7,859 MB / 50.5 | | 6 GB allowance (`RX_VK_TEST_ALLOWED_MB=6144`), VRAM peak | 6,135 / 6,129 MB | 5,865 / 5,887 MB | | 6 GB allowance, GPU's longest frame | 24.1 / 21.7 ms | 13.5 / 12.5 ms | | 6 GB allowance, fps | 50.3 / 54.9 | 53.0 / 52.4 | | Imports in a five-minute walk (driver time) | 59-65 (0.7 s) | 91-94 (0.8 s) | In the walk the freed VRAM is taken up by textures, as the budget allows, so the process's VRAM mean only drops 90-240 MB there; where the allowance is tight it shows as a lower peak. The 6 GB frame rate went either way across two pairs, so I'm not claiming a frame-rate gain. **Tests.** `tests/test_import_chunk_default.py` checks the default (4 with mirrors, 16 without), that the switch still sets it, and that `pool_lookup` still spans pools across a chunk's end; it fails on the old default. `nx2/test_import_windows.py`'s fixture gets a `mirror_on()` stub, since it compiles `import_chunk`. **Not tested.** AMD (an import there cost 20-75 ms on the replay thread per the code's comments, so ~50% more of them may matter), Intel, Linux, a real 6 or 8 GB card. Part of #993. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- github-sync:item:2685 -->
A pool's VRAM mirror covers its import's whole range, and imports are made
by chunk around what was bound. With 16 MB chunks 28-39% of the mirrors'
bytes were outside every game pool: 459 of 1,666 MB in a Hyrule Field walk
at DLAA 2560x1440, up to 925 MB after Castle Town to the field.

The default chunk is now 4 MB where the pools have VRAM mirrors (discrete
cards). Without mirrors (iGPUs, GCN) a chunk's padding costs no VRAM, so
they keep 16 MB and no extra imports. span_game_pools still takes a game
pool across a chunk's end into one import. RX_VK_IMPORT_CHUNK_MB=16: as
before.

Measured on an RTX 4070 SUPER (Windows, DLAA 2560x1440, unattended):
- Hyrule Field walk, two runs a side: mirrors 1,236-1,268 MB against
  1,645-1,657 MB; 58.1 / 58.6 fps against 59.0 / 58.2.
- Kokiri Forest: mirrors 843 MB against 1,154, VRAM 6,672 MB mean
  against 7,281; 58.2 fps against 57.0.
- Castle Town to the field: VRAM 7,859 MB mean against 8,357; 50.5
  against 50.4 fps.
- A 6 GB allowance (RX_VK_TEST_ALLOWED_MB=6144), two runs a side: the
  VRAM peak 5,865-5,887 MB against 6,129-6,135, the GPU's longest frame
  12.5-13.5 ms against 21.7-24.1; the frame rate either way (53.0 / 52.4
  against 50.3 / 54.9 fps).
- Imports: ~50% more (91-94 against 59-65 in five minutes), each shorter,
  0.8 s of driver time in all against 0.7.

tests/test_import_chunk_default.py fails on the old default.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Author
Owner

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

Tested on an 8 GB card, where it matters most: in Castle Town Market it takes about 7 fps and most of the long frames away.

Windows 11, RTX 3060 Ti 8 GB (driver 32.0.16.2114), Ryzen 5 5600X (12 threads), 2560x1440 DLSS. main ba005ca4 with #2711 and #2731 merged locally (both arms), plus this PR, A/B with RX_VK_IMPORT_CHUNK_MB=16. Market child, 30 s of the camera turning after a settle, RX_VK_FRAMEGEN=0, HOS_FPS_MAX=240, arms alternated.

fps median fps mean frames over 50 ms in 30 s frame p99 GPU a frame mirrors (last line) texture budget
4 MB (this PR) 47.5 / 52.2 / 49.5 46.8 / 49.7 / 49.2 17 / 5 / 10 50.5 / 44.9 / 45.0 ms 11.3 ms 1,656-1,772 MB 1,809-2,112 MB
16 MB 44.3 / 42.5 / 41.8 42.0 / 41.6 / 41.3 35 / 34 / 53 62.1 / 62.3 / 77.7 ms 12.1 ms 1,968-2,000 MB 1,673-1,853 MB

The Market's GPU time moves between launches on this card, but all three 4 MB runs are above all three 16 MB ones. As in your walk, the freed VRAM goes to textures (the budget rises by ~250 MB); with fewer textures evicted and made again, the long frames drop.

> [@klikso](https://github.com/klikso) commented on GitHub · 2026-11-12 12:15 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2685#issuecomment-6823334401) Tested on an 8 GB card, where it matters most: in Castle Town Market it takes about 7 fps and most of the long frames away. Windows 11, RTX 3060 Ti 8 GB (driver 32.0.16.2114), Ryzen 5 5600X (12 threads), 2560x1440 DLSS. `main` ba005ca4 with #2711 and #2731 merged locally (both arms), plus this PR, A/B with `RX_VK_IMPORT_CHUNK_MB=16`. Market child, 30 s of the camera turning after a settle, `RX_VK_FRAMEGEN=0`, `HOS_FPS_MAX=240`, arms alternated. | | fps median | fps mean | frames over 50 ms in 30 s | frame p99 | GPU a frame | mirrors (last line) | texture budget | |---|---|---|---|---|---|---|---| | 4 MB (this PR) | 47.5 / 52.2 / 49.5 | 46.8 / 49.7 / 49.2 | 17 / 5 / 10 | 50.5 / 44.9 / 45.0 ms | 11.3 ms | 1,656-1,772 MB | 1,809-2,112 MB | | 16 MB | 44.3 / 42.5 / 41.8 | 42.0 / 41.6 / 41.3 | 35 / 34 / 53 | 62.1 / 62.3 / 77.7 ms | 12.1 ms | 1,968-2,000 MB | 1,673-1,853 MB | The Market's GPU time moves between launches on this card, but all three 4 MB runs are above all three 16 MB ones. As in your walk, the freed VRAM goes to textures (the budget rises by ~250 MB); with fewer textures evicted and made again, the long frames drop. <!-- github-sync:c:6823334401 -->
Author
Owner

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

A follow-up that may help with the mirrors on 8 GB cards in general: on this card, the Market's frame rate from launch to launch follows how much the mirrors hold.

Same machine as above (RTX 3060 Ti 8 GB, Windows). 37 runs in Castle Town Market today, across several builds (main with and without #2711, #2731, this PR, #2709, #2624), all the same 30-40 s camera-turning test. Last a frame: mirrors N (M MB) line of each run against its fps:

mirrors at the end runs fps mean frames over 50 ms
1,284-1,360 MB (75-79 mirrors) 5 51.2-54.4 1-7
1,632-1,772 MB 4 35.3-49.7 5-60
1,876-2,004 MB (121-338 mirrors) 28 28.2-44.8 26-169

Correlation between fps and mirror MB over the 37: -0.77. The slow launches are the ones whose mirrors reach the budget's 2 GB (vram / 4 on this card): textures then get ~1.8 GB where the fast launches get ~2.4-2.7 GB, and the GPU's frame goes from ~11 ms to 14-20 ms. That is the launch-to-launch "fast or slow" GPU time blushOFF described in #995.

I could not find what makes a launch build 75 mirrors or 300: the save, the route and the build were the same, and the five fast launches came in a row at the end of the session. With this PR the fast case is more common (its 4 MB arm was at 1.66-1.77 GB against 1.97-2.00 GB for 16 MB), which fits the gain above. A lower mirror budget on 8 GB cards might also be worth a test, though the comment in mirror_budget() says 1 GB made Hyrule Field stutter there.

> [@klikso](https://github.com/klikso) commented on GitHub · 2026-11-12 12:42 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2685#issuecomment-6823915908) A follow-up that may help with the mirrors on 8 GB cards in general: on this card, the Market's frame rate from launch to launch follows how much the mirrors hold. Same machine as above (RTX 3060 Ti 8 GB, Windows). 37 runs in Castle Town Market today, across several builds (`main` with and without #2711, #2731, this PR, #2709, #2624), all the same 30-40 s camera-turning test. Last `a frame: mirrors N (M MB)` line of each run against its fps: | mirrors at the end | runs | fps mean | frames over 50 ms | |---|---|---|---| | 1,284-1,360 MB (75-79 mirrors) | 5 | 51.2-54.4 | 1-7 | | 1,632-1,772 MB | 4 | 35.3-49.7 | 5-60 | | 1,876-2,004 MB (121-338 mirrors) | 28 | 28.2-44.8 | 26-169 | Correlation between fps and mirror MB over the 37: -0.77. The slow launches are the ones whose mirrors reach the budget's 2 GB (`vram / 4` on this card): textures then get ~1.8 GB where the fast launches get ~2.4-2.7 GB, and the GPU's frame goes from ~11 ms to 14-20 ms. That is the launch-to-launch "fast or slow" GPU time blushOFF described in #995. I could not find what makes a launch build 75 mirrors or 300: the save, the route and the build were the same, and the five fast launches came in a row at the end of the session. With this PR the fast case is more common (its 4 MB arm was at 1.66-1.77 GB against 1.97-2.00 GB for 16 MB), which fits the gain above. A lower mirror budget on 8 GB cards might also be worth a test, though the comment in `mirror_budget()` says 1 GB made Hyrule Field stutter there. <!-- github-sync:c:6823915908 -->
Author
Owner

@Joey-Magnus approved on GitHub · 2026-11-12 14:34 UTC · original

I merged this into current main and ran tests/test_import_chunk_default.py, nx2/test_import_windows.py (all three modes) and the import/mirror checks: all pass. mirror_on() reads dev.props, and the first import_chunk() call comes from an import, which is after device creation, so the cached default can't be decided before the GPU is known. iGPUs and GCN keep 16 MB, and span_game_pools still covers a pool across a chunk's end. Your table, plus klikso's independent A/B on an RTX 3060 Ti 8 GB in Castle Town Market (higher fps on all three 4 MB runs and about half the frames over 50 ms), is enough for me on NVIDIA. AMD RDNA is the tier to watch. It gets the new default too, the code's comments put an import there at 20-75 ms on the replay thread, and this makes ~50% more of them. If an AMD tester reports new hitches, RX_VK_IMPORT_CHUNK_MB=16 is the A/B. I found no other open PR that touches import_chunk. The docs row merges cleanly alongside #2684's.

> [@Joey-Magnus](https://github.com/Joey-Magnus) approved on GitHub · 2026-11-12 14:34 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2685#pullrequestreview-5876233122) I merged this into current main and ran `tests/test_import_chunk_default.py`, `nx2/test_import_windows.py` (all three modes) and the import/mirror checks: all pass. `mirror_on()` reads `dev.props`, and the first `import_chunk()` call comes from an import, which is after device creation, so the cached default can't be decided before the GPU is known. iGPUs and GCN keep 16 MB, and `span_game_pools` still covers a pool across a chunk's end. Your table, plus klikso's independent A/B on an RTX 3060 Ti 8 GB in Castle Town Market (higher fps on all three 4 MB runs and about half the frames over 50 ms), is enough for me on NVIDIA. AMD RDNA is the tier to watch. It gets the new default too, the code's comments put an import there at 20-75 ms on the replay thread, and this makes ~50% more of them. If an AMD tester reports new hitches, `RX_VK_IMPORT_CHUNK_MB=16` is the A/B. I found no other open PR that touches `import_chunk`. The docs row merges cleanly alongside #2684's. <!-- github-sync:r:5876233122 -->
github-sync closed this pull request 2026-11-12 16:55:42 +00:00
Author
Owner

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

A note on my measurements in this thread: this PC's DLSS model row was on Transformer 2 (L), not the default K, for all of them. L costs ~4 ms more GPU time a frame on this card and more VRAM (#2836), so my absolute fps are ~10 lower than the default would give in Hyrule Field and the card was shorter of memory than a default setup. The comparisons were all made with the same setting in both arms, so their direction stands, but their size is for that harder case.

> [@klikso](https://github.com/klikso) commented on GitHub · 2026-11-12 16:09 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2685#issuecomment-6828921077) A note on my measurements in this thread: this PC's DLSS model row was on Transformer 2 (L), not the default K, for all of them. L costs ~4 ms more GPU time a frame on this card and more VRAM (#2836), so my absolute fps are ~10 lower than the default would give in Hyrule Field and the card was shorter of memory than a default setup. The comparisons were all made with the same setting in both arms, so their direction stands, but their size is for that harder case. <!-- github-sync:c:6828921077 -->
Author
Owner

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

Thanks, this is a clean, well-measured change. I looked mainly at what the smaller chunks cost.

Allocations and driver limits. Each import is its own vkAllocateMemory (host-pointer import, or a dma-buf on Linux), so the import count is what counts against maxMemoryAllocationCount (4096 on the Windows drivers). Your walk makes 91-94 imports in five minutes. Even if every game pool were imported in 4 MB pieces, the live count stays far below the limit, and retired imports still go back on the existing rules. The mirrors go through VMA with create_device_buffer. At 4 MB, as at 16, they are well under half of the 64 MB block, so they share blocks rather than each taking its own memory object, and the extra mirrors don't add vkAllocateMemory calls one for one. No concern there.

CPU per sync. mirror_sync skips clean 2 MB regions, so its page walk follows the dirty pages, not the mirror's size. Smaller mirrors also mean less padding to walk. What grows is per-pool: one more loop step and at most one more vkCmdCopyBuffer per extra mirrored pool, plus the O(live pools) passes at each submission (the capture ranges, the idle scan, the pre-segment sync). On a few hundred pools that should be small. Your runs and klikso's are GPU-bound, though (DLAA/DLSS at 1440p), so they wouldn't show it. If your logs already have the memory-sync time per frame for both arms, a before/after line would settle it. That's not a blocker.

Import time. Your table counts every import_pool call, including remakes when a binding crosses a chunk end, so the +50% (and 0.7 s against 0.8 s of driver time, longest import 66 ms against 44 ms in Kokiri) is the full cost on NVIDIA. I agree with Joey-Magnus that AMD RDNA is the tier to watch. It gets the new default, and an import there cost 20-75 ms on the replay thread. That cost comes from the driver locking the chunk's pages, so a 4 MB import should cost less each, which offsets some of the larger count, but nobody has measured it. RX_VK_IMPORT_CHUNK_MB=16 is the A/B if an RX 6000/7000/9000 tester reports new hitches.

Memory saved and where. RTX 4070 SUPER 12 GB: mirrors 1,236-1,268 MB against 1,645-1,657 MB in the walk, and 843 against 1,154 MB in Kokiri. klikso's independent A/B on an RTX 3060 Ti 8 GB has all three 4 MB runs above all three 16 MB runs in the Market, with about half the long frames. Their note that those runs used DLSS model L changes the size of the gain, not its direction. That's enough for the default on NVIDIA. GCN and iGPUs keep 16 MB through mirror_on(), and Joey already checked that it is only read after device creation.

CI is green on 4d0271f and the PR merges cleanly. The only change since Joey's approval is the merge of main and the tested-configurations rows. #2825 (klikso's trim of mostly-dead imports) builds on this PR's 4 MB chunks and was measured on top of it, so this one should go first.

> [@hckrman101](https://github.com/hckrman101) approved on GitHub · 2026-11-12 16:34 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2685#pullrequestreview-5878221535) Thanks, this is a clean, well-measured change. I looked mainly at what the smaller chunks cost. **Allocations and driver limits.** Each import is its own `vkAllocateMemory` (host-pointer import, or a dma-buf on Linux), so the import count is what counts against `maxMemoryAllocationCount` (4096 on the Windows drivers). Your walk makes 91-94 imports in five minutes. Even if every game pool were imported in 4 MB pieces, the live count stays far below the limit, and retired imports still go back on the existing rules. The mirrors go through VMA with `create_device_buffer`. At 4 MB, as at 16, they are well under half of the 64 MB block, so they share blocks rather than each taking its own memory object, and the extra mirrors don't add `vkAllocateMemory` calls one for one. No concern there. **CPU per sync.** `mirror_sync` skips clean 2 MB regions, so its page walk follows the dirty pages, not the mirror's size. Smaller mirrors also mean less padding to walk. What grows is per-pool: one more loop step and at most one more `vkCmdCopyBuffer` per extra mirrored pool, plus the O(live pools) passes at each submission (the capture ranges, the idle scan, the pre-segment sync). On a few hundred pools that should be small. Your runs and klikso's are GPU-bound, though (DLAA/DLSS at 1440p), so they wouldn't show it. If your logs already have the memory-sync time per frame for both arms, a before/after line would settle it. That's not a blocker. **Import time.** Your table counts every `import_pool` call, including remakes when a binding crosses a chunk end, so the +50% (and 0.7 s against 0.8 s of driver time, longest import 66 ms against 44 ms in Kokiri) is the full cost on NVIDIA. I agree with Joey-Magnus that AMD RDNA is the tier to watch. It gets the new default, and an import there cost 20-75 ms on the replay thread. That cost comes from the driver locking the chunk's pages, so a 4 MB import should cost less each, which offsets some of the larger count, but nobody has measured it. `RX_VK_IMPORT_CHUNK_MB=16` is the A/B if an RX 6000/7000/9000 tester reports new hitches. **Memory saved and where.** RTX 4070 SUPER 12 GB: mirrors 1,236-1,268 MB against 1,645-1,657 MB in the walk, and 843 against 1,154 MB in Kokiri. klikso's independent A/B on an RTX 3060 Ti 8 GB has all three 4 MB runs above all three 16 MB runs in the Market, with about half the long frames. Their note that those runs used DLSS model L changes the size of the gain, not its direction. That's enough for the default on NVIDIA. GCN and iGPUs keep 16 MB through `mirror_on()`, and Joey already checked that it is only read after device creation. CI is green on 4d0271f and the PR merges cleanly. The only change since Joey's approval is the merge of main and the tested-configurations rows. #2825 (klikso's trim of mostly-dead imports) builds on this PR's 4 MB chunks and was measured on top of it, so this one should go first. <!-- github-sync:r:5878221535 -->
Commenting is not possible because the repository is archived.
No description provided.