Skip to content

perf(vk): mirror the pools the GPU reads most, in place of colder ones - #1377

Open
aquinas22 wants to merge 6 commits into
NotRexed:mainfrom
aquinas22:perf/mirrors-by-heat
Open

aquinas22 wants to merge 6 commits into
NotRexed:mainfrom
aquinas22:perf/mirrors-by-heat

Conversation

@aquinas22

Copy link
Copy Markdown
Contributor

What

Mirrors go to the pools the GPU reads most. When the hottest pool waiting for a mirror does not fit in the budget, it takes the mirrors of pools that read a quarter as much or less, coldest first.

Why

A pool refused for the mirrors' budget got a mirror only once another mirror went unbound for 600 submissions (mirrors_give_back). In play the budget fills with pools that are bound now and then but read little, so they never go idle. A pool bound in nearly every submission then stays in guest memory for the whole session, and the GPU reads it over PCIe.

From hookshot_log.txt on an RTX A1000 Laptop (4 GB, mirror budget 495 MB):

vk: pool 0x7fd7000000 (32 MB) stays in system memory: the mirrors have 480 MB of 495
vk:   bound in guest memory, by pool (the GPU reads these over PCIe; ... over these 600 submits, 33205.3 MB in all):
      0x116b000000 (144 MB, state 1): 4118 binds, 3098 MB; ... 0x107f000000 (160 MB, state 1): 513 binds, 29408 MB; ...

Here the GPU sat at about 70% use, drawing 15 W of its 45 W, and memory sync took 14–19 ms of each frame.

How

  • Each Pool counts the bytes of the ranges bound from it. This covers every binding, including bind-memo hits (the memo now keeps the pool), and is one relaxed atomic_ref add. The count is halved every 64 submissions (heat).
  • mirrors_swap_for_hot, at a submission's start, only for the hottest wanted pool:
    • victims must be mirrors the GPU is done with (last_value <= mem_completed, the same rule mirrors_give_back uses), made 600+ submissions ago, and each under a quarter of the wanted pool's heat;
    • all victims together must be under a quarter of the wanted pool's heat as well;
    • the wanted pool itself needs 64+ MB of ranges per window;
    • at most one swap per 16 submissions;
    • the wanted pool is made first, so the space freed goes to it.
  • Freeing uses the existing mirror_free: the page table goes back to guest memory, and pools_gen changes, which also invalidates the bind memo.
  • RX_VK_MIRROR_HEAT=0 restores the old behaviour. It is documented beside RX_VK_MIRROR_MB.
  • The choice itself is mirror_swap_pick, a pure function covered by tests/test_mirror_heat.py.

Testing

  • Hardware and setup: RTX A1000 Laptop GPU (4 GB, driver 596.58), i7-12800HX, Windows 11. Minimum (4GB) settings: handheld profile, Resolution 50%, frame generation off.

  • In play: run as hookshot_vk.dll built from v2.0.11 plus this change. I compiled the same diff on main, but I have not run that build in game.

  • First version, now fixed: tried every wanted pool and had no heat floor, so 16 MB pools traded mirrors back and forth (256 swaps), kept the mirrors young, and the 160 MB pool never got in. This version tries the hottest pool only, makes it first, and adds the 64 MB floor.

  • This version: the 160 MB pool took the mirrors of four cold pools on its first swap:

    vk: pool 0x107f000000 (160 MB, 158077 MB bound a window) takes the mirrors of 4 colder pools (64 MB, 39 MB bound a window; 1 swaps so; ...)
    

    Over a long session there were 16 swaps, each a hot pool taking cold ones, with no back-and-forth and no device lost.

Ranges bound in guest memory, per 600 submits Memory sync per frame
Before 32–33 GB 15–19 ms
After 3.2–3.3 GB 9–10 ms (in a scene with twice the draws)
  • Not shown: a clean fps gain. After the change I was in a heavier scene (3,959 draws a frame against 1,600–1,900), where the frame was limited by the replay's draw recording (about 9 µs a draw both before and after), so the frame rates aren't comparable. On this card the change moves traffic off PCIe, but I can't claim a measured fps gain. An A/B with RX_VK_MIRROR_HEAT=0 in the same spot on more cards would settle it.
  • python -I -B tests/run_all.py mirror_heat passes.

A pool refused for the mirrors' budget got a mirror only once another went unbound for
600 submissions. With the budget full of pools bound now and then but read little, a
pool bound every submission stayed in guest memory and the GPU read it over PCIe for
the rest of the session.

Each pool now counts the bytes of the ranges bound from it (every binding, bind-memo
hits included; a relaxed atomic add), halved every 64 submissions. When the hottest
wanted pool does not fit, it takes the mirrors of pools with a quarter of its heat or
less, all of them together, coldest first: only mirrors the GPU is done with
(last_value <= mem_completed, as mirrors_give_back), made 600 submissions ago or more,
one swap per 16 submissions, and only for a pool with 64 MB of ranges a window or
more. The wanted pool is made first, so the space is its. RX_VK_MIRROR_HEAT=0: off.

tests/test_mirror_heat.py runs mirror_swap_pick, the choice, on the cases that matter.
NotRexed and others added 3 commits November 10, 2026 09:24
Conflicts: tests/run_all.py (now per-file declarations: test_mirror_heat.py declares
RUN_ALL_ARGS itself) and gpu_buffer_bind's unbound-binding path (main's shadow_forget
kept, with this branch's remember(p, ...)). test_parallel_rb_refresh.py's end marker
follows remember's new signature.
Freeing a mirror is lossless only if guest memory has everything the GPU wrote
in it. A pool bound every frame through 64 bytes of a bare entry, with the
culling writing past them, was nearly cold and so the first victim: its records
went back to stale guest memory (the missing-foliage class of bug).

Pool::gpu_only is set for every mirror memory_note_gpu_write touches (a bare
entry's guessed range, the pointer table's, a wide binding) and for a written
binding whose write-back RX_VK_WB_MAX_KB skips; it is cleared when the mirror is
made or freed. mirrors_swap_for_hot skips those, and is off with
RX_VK_FOLLOW_STORES, whose stores through the page table nothing sees.

Also: the swap makes the hot pool's mirror itself and logs when that fails after
the victims were freed, and the log and comments say what heat is (about twice
a window's bound bytes).
@aquinas22

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review. Merged main again (the conflicts were tests/run_all.py, now per-file declarations, so test_mirror_heat.py declares its own RUN_ALL_ARGS, and the unbound-binding path in gpu_buffer_bind, where main's shadow_forget and this PR's remember(p, ...) both stay). Then:

  • Victims (the open point): went with the per-pool flag. Pool::gpu_only is set when the GPU may write a mirror somewhere no write-back copies: every pool memory_note_gpu_write touches (a bare entry's guessed range past its 64 bytes, the pointer table's, a wide binding), and a written binding whose write-back main's new RX_VK_WB_MAX_KB skips. It's cleared when the mirror is made or freed, and mirrors_swap_for_hot never takes a gpu_only mirror. Stores through the page table aren't seen anywhere, so with RX_VK_FOLLOW_STORES set the swap is off entirely. mirrors_give_back's own 600-idle rule is unchanged.
  • CI: test_parallel_rb_refresh.py's end marker is now "return remember(p, {p->mirror".
  • Nits: the swap now makes the hot pool's mirror itself (taking it out of mirror_wanted) and logs when that fails after the victims were freed. The log and comments now say heat is about 2x a window's bytes ("~N MB bound a window" prints heat/2; the 64 MB floor is described as ~32 MB a window). I left the floor's value alone, since that's what was tuned on the A1000. I'll also reword the description from "reads" to "binds".

test_mirror_heat.py also checks that the swap skips gpu_only mirrors and follows the FOLLOW_STORES rule. Built the Windows Vulkan back end locally with the builder's llvm-mingw.

@Joey-Magnus Joey-Magnus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The approval still stands, and the gpu_only change addresses the victim concern. What blocks it now is a single conflict hunk in gpu/vk/vk_memory.cpp, in the not-mirrored path of gpu_buffer_bind. Main replaced the inline direct_write_epoch and wb_pages_note lines with note_direct_write(*p, off, size). The fix is to keep main's call and keep your return remember(p, {p->buffer, off, size, 0});.

I checked the other remember( sites: main and the branch call it in the same five places, so heat counting doesn't miss any binding path added since. Since #2343, GCN cards have no VRAM mirrors by default, so the swap does nothing there. That's fine.

After the rebase, please re-run CI so the Python source checks can be compared against current main's results.

gpu_buffer_bind's not-mirrored path keeps main's note_direct_write call and this branch's remember(p, ...).
test_direct_buffer_writes cuts heat_add out with BindMemo, which the memo's hits now call.
…T as on/off switches

RX_VK_FOLLOW_STORES=0 turned the swap off, the set-means-on reading tests/test_env_on.py catches. Both now read
through env_on and env_value_off, as main's switches do.
@aquinas22 aquinas22 changed the title Mirror the pools the GPU reads most, in place of colder ones perf(vk): mirror the pools the GPU reads most, in place of colder ones Nov 12, 2026
sheepbun pushed a commit to sheepbun/Bunny-Hood that referenced this pull request Nov 12, 2026
…fused for good

On a 4 GB card the textures in use and the retired pools' mirrors often have VRAM full when a scene's hottest pool is
first bound. mirror_make's allocation failed, the pool was MIRROR_REFUSED, and the GPU read it over PCIe for the rest of
the session: Kokiri Forest's 144 MB pool, ~2.3 GB bound in 600 submits. Now it is asked for again 64 submissions on.

RTX A1000 Laptop (4 GB), Kokiri Forest, headless, 90 s after 45 s of play, v2.1.0 with NotRexed#1377 and RX_VK_WB_MAX_KB=64:
refused 18-22 fps (the pool mirrored only when a launch happened to bind it before VRAM filled); asked for again,
mirrored ~70 submissions after its first refusal, ~0 MB read over PCIe, 30.2 and 30.3 fps (25.1 in a third launch measured after only 20 s, the mirrors still arriving).
RX_VK_MIRROR_NO_RETRY=1: as before.

(cherry picked from commit 5883487)

@Joey-Magnus Joey-Magnus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The approval and the gpu_only victim rule still stand, but main has moved under them. #2154 (merged) added its own Pool::gpu_only and gpu_written in vk_memory.cpp, and they mean something different: on main both flags are sticky, and they keep a mirror from ever being given back in mirrors_give_back. A trial merge of 59edbbe into current main conflicts in four hunks: memory_note_gpu_write, and three remember() returns in gpu_buffer_bind that now call note_in_place_read. The parts that merge silently are the real problem:

  • The struct ends up with two bool gpu_only members (lines 80 and 86 of the merge), so it won't build.
  • Your clears in mirror_make and mirror_free would reset main's flag, which undoes #2154's keep rule (its fix for cyan after a pause on the RX 5500M).
  • mirrors_swap_for_hot checks gpu_only but not gpu_written, so a swap could still free a mirror that #2154 keeps.

I'd drop your flag, use main's gpu_only and gpu_written as they are (never cleared), and have the swap skip both. The only red in CI on this head is test_env_on on HOS_FPS_LOG, which was main's own failure at the time.

sheepbun pushed a commit to sheepbun/Bunny-Hood that referenced this pull request Nov 12, 2026
…fused for good

On a 4 GB card the textures in use and the retired pools' mirrors often have VRAM full when a scene's hottest pool is
first bound. mirror_make's allocation failed, the pool was MIRROR_REFUSED, and the GPU read it over PCIe for the rest of
the session: Kokiri Forest's 144 MB pool, ~2.3 GB bound in 600 submits. Now it is asked for again 64 submissions on.

RTX A1000 Laptop (4 GB), Kokiri Forest, headless, 90 s after 45 s of play, v2.1.0 with NotRexed#1377 and RX_VK_WB_MAX_KB=64:
refused 18-22 fps (the pool mirrored only when a launch happened to bind it before VRAM filled); asked for again,
mirrored ~70 submissions after its first refusal, ~0 MB read over PCIe, 30.2 and 30.3 fps (25.1 in a third launch measured after only 20 s, the mirrors still arriving).
RX_VK_MIRROR_NO_RETRY=1: as before.

(cherry picked from commit 5883487)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants