vk: render target images reused from a pool, not made and destroyed for each of OoT's shifting transient targets #1416

Merged
DUDUYG merged 2 commits from perf/target-image-pool into main 2026-11-10 04:33:12 +00:00
DUDUYG commented 2026-11-10 01:17:39 +00:00 (Migrated from github.com)

What

Render target images are reused from a pool instead of being made and destroyed for each of OoT's shifting transient targets.

OoT makes a frame's transient targets in a scratch heap, and their offsets shift with what came before them in the frame. So each shifted target is a texture with a new image. Every 60 presents, the dead-target sweep (#899) lets go of the dead ones, and the next present destroys them all at once. In Kokiri Forest that is 300-700 images per sweep, at about 0.07 ms each: a 20-65 ms stall on the replaying thread, about once a second while the camera moves.

In one 4.5-minute run, 10,500 images (17 GB) were made and destroyed this way. The sweep's choice is cheap (under 1 ms). All of the stall is the destruction.

Change (gpu/vk/vk_texture.cpp, vk_texture.h)

  • Into the pool: when a render target's texture goes, its image and views wait in a pool, keyed by the whole VkImageCreateInfo (pool_key_of). The dead list puts them there once the GPU is done with them, instead of destroying them.
  • Out of the pool: a new texture with the same create info takes a pooled image before create_image is asked. It is still put into GENERAL from UNDEFINED and cleared exactly as a new image is, so nothing it held shows.
  • What is pooled: only uncompressed colour and depth targets. Sparse, BC and ASTC images are destroyed as before.
  • Bounds:
    • RX_VK_TARGET_POOL_MB (default 1024; 0 turns the pool off and every image is destroyed as before).
    • The pool is given back while VRAM is near what the driver allows, using the budget's existing pressure check.
    • An image unused in the pool for 120 presents is destroyed.
    • pool_trim destroys at most 16 images a present (64 while VRAM is short), so there is never a burst.
  • Log: every 600 presents: vk: render target images: N reused from the pool, M made new, K destroyed from it; it holds X (Y MB).

Measured

  • Setup: RTX 5070 12 GB, Ryzen 7 9800X3D, Windows 11, NVIDIA 621.01, 1080p output.
  • Runs: headless (HOS_HEADLESS, HOS_AUDIO=0), a Kokiri Forest save, the same scripted 36 s walk with camera turns each run. The numbers are the in-play part only (presents 3300 and on, after the save's load).
  • Build: main 0828863 with #1127, #1338 and local timing logs (not in this PR). The A/B is the same build with RX_VK_TARGET_POOL_MB=0, alternated.
pool off (2 runs) pool on (2 runs)
housekeeping presents over 8 ms (worst) 27 / 28 (60.0 / 65.7 ms) 0 / 0
slow submits/presents over 30 ms 9 / 10 1 / 2
time in slow submits/presents over 15 ms 815 / 742 ms 250 / 345 ms
fps 58.5 / 58.8 58.4 / 59.0
images reused / made new / destroyed from the pool - 7076 / 6422 / 5448, 6091 / 6422 / 5609
  • The remaining long stalls with the pool on are the first new pipeline in play (151 ms: opening the pipeline notes file, a separate PR) and texture lookups (57-74 ms).
  • With both changes: 3 runs had no stall over 30 ms in play. The worst was 15.7-19.9 ms, at 59.5-60.0 fps.
  • The picture: screenshots taken during the runs look the same with the pool on and off (fog, bloom, lighting, HUD).

Not covered

  • One GPU (NVIDIA, Windows) and one scene, headless runs. AMD, Intel and Linux are untested.
  • Small cards: the pool held up to 835 MB at its peak here. On 6-8 GB cards it relies on the existing VRAM pressure check (pressure > allowed / 10 * 9) to be given back. I could not test that on a small card.
  • Screenshots only, no per-frame brightness log (the headless frame log stays empty).

Tests

  • tests/test_target_image_pool.py (source checks, 7 cases):
    • the key covers the whole create info;
    • only uncompressed attachment images are pooled, and 0 turns the pool off;
    • a pooled image is taken before create_image and still goes UNDEFINED→GENERAL and is cleared;
    • a gone texture's views go with its image;
    • the dead list pools before it destroys, and the trim runs every present;
    • the trim is bounded and gives back idle and excess images;
    • the numbers are logged.
  • These pass on Windows: tests/run_all.py on every test that reads vk_texture.cpp, plus test_vulkan_clear_mip.py and test_parallel_init_order.py with the Vulkan headers.
  • The renderer builds with llvm-mingw (RX_VK_ONLY=1 nx2/build.py --standalone).

🤖 Generated with Claude Code

## What Render target images are reused from a pool instead of being made and destroyed for each of OoT's shifting transient targets. OoT makes a frame's transient targets in a scratch heap, and their offsets shift with what came before them in the frame. So each shifted target is a texture with a new image. Every 60 presents, the dead-target sweep (#899) lets go of the dead ones, and the next present destroys them all at once. In Kokiri Forest that is 300-700 images per sweep, at about 0.07 ms each: **a 20-65 ms stall on the replaying thread, about once a second while the camera moves**. In one 4.5-minute run, 10,500 images (17 GB) were made and destroyed this way. The sweep's choice is cheap (under 1 ms). All of the stall is the destruction. ## Change (`gpu/vk/vk_texture.cpp`, `vk_texture.h`) - **Into the pool:** when a render target's texture goes, its image and views wait in a pool, keyed by the whole `VkImageCreateInfo` (`pool_key_of`). The dead list puts them there once the GPU is done with them, instead of destroying them. - **Out of the pool:** a new texture with the same create info takes a pooled image before `create_image` is asked. It is still put into `GENERAL` from `UNDEFINED` and cleared exactly as a new image is, so nothing it held shows. - **What is pooled:** only uncompressed colour and depth targets. Sparse, BC and ASTC images are destroyed as before. - **Bounds:** - `RX_VK_TARGET_POOL_MB` (default 1024; `0` turns the pool off and every image is destroyed as before). - The pool is given back while VRAM is near what the driver allows, using the budget's existing pressure check. - An image unused in the pool for 120 presents is destroyed. - `pool_trim` destroys at most 16 images a present (64 while VRAM is short), so there is never a burst. - **Log:** every 600 presents: `vk: render target images: N reused from the pool, M made new, K destroyed from it; it holds X (Y MB)`. ## Measured - **Setup:** RTX 5070 12 GB, Ryzen 7 9800X3D, Windows 11, NVIDIA 621.01, 1080p output. - **Runs:** headless (`HOS_HEADLESS`, `HOS_AUDIO=0`), a Kokiri Forest save, the same scripted 36 s walk with camera turns each run. The numbers are the in-play part only (presents 3300 and on, after the save's load). - **Build:** main 0828863 with #1127, #1338 and local timing logs (not in this PR). The A/B is the same build with `RX_VK_TARGET_POOL_MB=0`, alternated. | | pool off (2 runs) | pool on (2 runs) | |---|---|---| | housekeeping presents over 8 ms (worst) | 27 / 28 (60.0 / 65.7 ms) | **0 / 0** | | slow submits/presents over 30 ms | 9 / 10 | **1 / 2** | | time in slow submits/presents over 15 ms | 815 / 742 ms | **250 / 345 ms** | | fps | 58.5 / 58.8 | 58.4 / 59.0 | | images reused / made new / destroyed from the pool | - | 7076 / 6422 / 5448, 6091 / 6422 / 5609 | - **The remaining long stalls with the pool on** are the first new pipeline in play (151 ms: opening the pipeline notes file, a separate PR) and texture lookups (57-74 ms). - **With both changes:** 3 runs had **no stall over 30 ms** in play. The worst was 15.7-19.9 ms, at 59.5-60.0 fps. - **The picture:** screenshots taken during the runs look the same with the pool on and off (fog, bloom, lighting, HUD). ## Not covered - One GPU (NVIDIA, Windows) and one scene, headless runs. AMD, Intel and Linux are untested. - **Small cards:** the pool held up to 835 MB at its peak here. On 6-8 GB cards it relies on the existing VRAM pressure check (`pressure > allowed / 10 * 9`) to be given back. I could not test that on a small card. - Screenshots only, no per-frame brightness log (the headless frame log stays empty). ## Tests - `tests/test_target_image_pool.py` (source checks, 7 cases): - the key covers the whole create info; - only uncompressed attachment images are pooled, and `0` turns the pool off; - a pooled image is taken before `create_image` and still goes `UNDEFINED`→`GENERAL` and is cleared; - a gone texture's views go with its image; - the dead list pools before it destroys, and the trim runs every present; - the trim is bounded and gives back idle and excess images; - the numbers are logged. - These pass on Windows: `tests/run_all.py` on every test that reads `vk_texture.cpp`, plus `test_vulkan_clear_mip.py` and `test_parallel_init_order.py` with the Vulkan headers. - The renderer builds with llvm-mingw (`RX_VK_ONLY=1 nx2/build.py --standalone`). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
NotRexed commented 2026-11-10 02:07:11 +00:00 (Migrated from github.com)

This landed in the v2.0.12 branch (merged there for testing; it reaches main with that release), so closing. Thanks!

This landed in the v2.0.12 branch (merged there for testing; it reaches `main` with that release), so closing. Thanks!
NotRexed commented 2026-11-10 03:55:21 +00:00 (Migrated from github.com)

Reopened: closing this was a mistake on my side. It was tested in the v2.0.12 branch, and it will be merged into main here, so it shows as merged. Sorry for the noise.

Reopened: closing this was a mistake on my side. It was tested in the v2.0.12 branch, and it will be merged into main here, so it shows as merged. Sorry for the noise.
hckrman101 (Migrated from github.com) reviewed 2026-11-10 04:16:05 +00:00
hckrman101 (Migrated from github.com) left a comment

Thanks, this is a nice win and the reuse itself looks safe to me. I checked the hazards I'd worry about with a pool like this:

  • In flight: images only reach the pool from the dead list once value <= completed, the same gate as destruction, and pool_trim only destroys images that are already idle there. Fine.
  • Old state: the new Texture starts fresh, and the reused image still goes through the UNDEFINED -> GENERAL barrier and the clear (both are unconditional after the create_image chain). Fine.
  • Views: every input to make_view is in texture_view's view key (use, type, format, aspect, level/layer range, swizzle), and depth/cube/3D come from the create info, so moving the view map along with the image is correct. The few view caches outside the texture (destroy_gen) only ever see the same view handle on the same image. Fine.
  • Keying: pool_key_of covers every field create() sets on ci. pNext is never set, and sparse plus the BC/ASTC retry are excluded before the key is used.

The points that are still open:

  1. The pool takes every uncompressed texture, not just render targets. create() gives COLOR_ATTACHMENT to every non-BC, non-ASTC colour image, decoded ASTC (RGBA8) included, so the usage check doesn't filter anything. Uploaded textures, decoded ASTC arrays and textures the budget evicts all end up in the pool too. One effect is that budget eviction no longer frees VRAM for up to 120 presents or 1 GB. The description and the test name say "render targets only". #1436 fixes this with as_target (!for_sampling), which suggests that's the intent. Either pull that one line into this PR or reword the description.
  2. The pool's memory squeezes live textures. Pooled images are VMA allocations, so they count in pressure (used - block_free) but not in total_bytes. Once pressure goes past 90%, the budget is cut by the whole over in the same pass, so live textures get evicted for memory that the pool is only giving back 64 images per present. Taking pool_bytes out of over (or skipping the cut while the pool can still cover it) would let the pool give way first. This matters most on the 6-8 GB cards you flagged as untested. There, the default cap of 1024 MB comes on top of a texture budget of 70% of what the driver allows.
  3. Conflicts with main. GitHub shows the PR as not mergeable. #1338 (BC raw colour attachment views) changed the same create_image retry chain in create(), so this needs a rebase. When you rebase, keep !is_bc in the pool rule, because main now gives BC images COLOR_ATTACHMENT too.

Order with #1436: that PR is stacked on this one (its first commit is c34f350), so this one should land first and #1436 after it. Point 1 is already handled there, and its 256 MB default softens point 2 but doesn't remove it.

Nits: pool_trim goes through shapes in hash-map order, so it doesn't free the oldest images across the whole pool first. Also, st_pool_made is counted before create_image succeeds.

No CI checks ran on this branch. tests/run_all.py picks up the new test on its own, so it doesn't need to be registered.

Thanks, this is a nice win and the reuse itself looks safe to me. I checked the hazards I'd worry about with a pool like this: - In flight: images only reach the pool from the dead list once `value <= completed`, the same gate as destruction, and `pool_trim` only destroys images that are already idle there. Fine. - Old state: the new `Texture` starts fresh, and the reused image still goes through the `UNDEFINED -> GENERAL` barrier and the clear (both are unconditional after the `create_image` chain). Fine. - Views: every input to `make_view` is in `texture_view`'s view key (use, type, format, aspect, level/layer range, swizzle), and depth/cube/3D come from the create info, so moving the view map along with the image is correct. The few view caches outside the texture (`destroy_gen`) only ever see the same view handle on the same image. Fine. - Keying: `pool_key_of` covers every field `create()` sets on `ci`. `pNext` is never set, and sparse plus the BC/ASTC retry are excluded before the key is used. The points that are still open: 1. **The pool takes every uncompressed texture, not just render targets.** `create()` gives `COLOR_ATTACHMENT` to every non-BC, non-ASTC colour image, decoded ASTC (RGBA8) included, so the usage check doesn't filter anything. Uploaded textures, decoded ASTC arrays and textures the budget evicts all end up in the pool too. One effect is that budget eviction no longer frees VRAM for up to 120 presents or 1 GB. The description and the test name say "render targets only". #1436 fixes this with `as_target` (`!for_sampling`), which suggests that's the intent. Either pull that one line into this PR or reword the description. 2. **The pool's memory squeezes live textures.** Pooled images are VMA allocations, so they count in `pressure` (`used - block_free`) but not in `total_bytes`. Once pressure goes past 90%, the budget is cut by the whole `over` in the same pass, so live textures get evicted for memory that the pool is only giving back 64 images per present. Taking `pool_bytes` out of `over` (or skipping the cut while the pool can still cover it) would let the pool give way first. This matters most on the 6-8 GB cards you flagged as untested. There, the default cap of 1024 MB comes on top of a texture budget of 70% of what the driver allows. 3. **Conflicts with main.** GitHub shows the PR as not mergeable. #1338 (BC raw colour attachment views) changed the same `create_image` retry chain in `create()`, so this needs a rebase. When you rebase, keep `!is_bc` in the pool rule, because main now gives BC images `COLOR_ATTACHMENT` too. Order with #1436: that PR is stacked on this one (its first commit is c34f350), so this one should land first and #1436 after it. Point 1 is already handled there, and its 256 MB default softens point 2 but doesn't remove it. Nits: `pool_trim` goes through shapes in hash-map order, so it doesn't free the oldest images across the whole pool first. Also, `st_pool_made` is counted before `create_image` succeeds. No CI checks ran on this branch. tests/run_all.py picks up the new test on its own, so it doesn't need to be registered.
hckrman101 (Migrated from github.com) commented 2026-11-10 04:16:06 +00:00

Every non-BC/ASTC colour image gets COLOR_ATTACHMENT in create() (around L1730), decoded ASTC included, so this check passes for all of them, not just targets. Uploaded and evicted textures end up pooled too. #1436's as_target && is the fix.

Every non-BC/ASTC colour image gets `COLOR_ATTACHMENT` in `create()` (around L1730), decoded ASTC included, so this check passes for all of them, not just targets. Uploaded and evicted textures end up pooled too. #1436's `as_target &&` is the fix.
hckrman101 (Migrated from github.com) commented 2026-11-10 04:16:06 +00:00

pressure includes pool_bytes, but the budget cut just below takes the whole over out of live textures in this same pass, while the pool drains at only 64 images per present. I'd subtract pool_bytes from over here (over > pool_bytes ? over - pool_bytes : 0) so the pool gives way first.

`pressure` includes `pool_bytes`, but the budget cut just below takes the whole `over` out of live textures in this same pass, while the pool drains at only 64 images per present. I'd subtract `pool_bytes` from `over` here (`over > pool_bytes ? over - pool_bytes : 0`) so the pool gives way first.
Commenting is not possible because the repository is archived.
No description provided.