Skip to content

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

Merged
NotRexed merged 2 commits into
NotRexed:mainfrom
DUDUYG:perf/target-image-pool
Nov 10, 2026
Merged

NotRexed merged 2 commits into
NotRexed:mainfrom
DUDUYG:perf/target-image-pool

Conversation

@DUDUYG

@DUDUYG DUDUYG commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

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

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

…or each of OoT's shifting transient targets

OoT makes a frame's transient targets in a scratch heap whose offsets shift with what came before them in the frame, so
each shifted one is a texture with a new image, and every 60 presents the dead-target sweep (NotRexed#899) let go of 300-700 of
them at once, destroyed in the next present: 20-65 ms on the replaying thread, about once a second while the camera moves
(RTX 5070, Kokiri Forest: 10,500 images made and destroyed in 4.5 minutes, 17 GB).

A render target's image whose texture is gone now waits in a pool by its create info, with its views, and a new texture
of that create info takes it. It is still put into GENERAL from UNDEFINED and cleared as a new image is, so nothing it
held shows. Uncompressed colour and depth targets only; bounded by RX_VK_TARGET_POOL_MB (default 1024, 0: off, as before),
given back while VRAM is near what the driver allows, and an image unused in it for 120 presents is destroyed, at most 16
a present. tests/test_target_image_pool.py keeps the rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@NotRexed

Copy link
Copy Markdown
Owner

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

@NotRexed NotRexed closed this Nov 10, 2026
27thLiz pushed a commit to 27thLiz/Hookshot that referenced this pull request Nov 10, 2026
…wept every 10 presents; the target pool bound 256 MB

Follow-up to NotRexed#1416. OoT's transient targets, made and let go all the time, shared the image pool's 128 MB blocks with the
textures that stay: each one gone left a hole beside a texture that kept its block. In Kokiri Forest the pool's 47-54
blocks (6-6.9 GB) held 1.9-3.6 GB of images, VRAM sat at 93-98% of what the driver allows, and an image made then took
30-70 ms (RTX 5070).

- Images texture_get makes for a target (not for sampling) come from blocks of their own (RX_VK_TARGET_BLOCKS=0: as
  before), and only those go to the target pool.
- The dead targets are swept every 10 presents (RX_VK_DEAD_EVERY, 60 before): the pool got 300-700 at once and the new
  targets of the next second missed most of them. The choice is cheap (1930 textures ordered in 0.3 ms).
- The target pool's bound comes down to 256 MB.
- Every 600 presents the log says what the image blocks hold (vma_log_image_blocks).

tests/test_target_image_pool.py checks the new rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@NotRexed

Copy link
Copy Markdown
Owner

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.

@NotRexed NotRexed reopened this Nov 10, 2026
… main's retries for a refused compressed image)
@NotRexed
NotRexed merged commit ecc500b into NotRexed:main Nov 10, 2026
5 of 8 checks passed
NotRexed added a commit to DUDUYG/Hookshot that referenced this pull request Nov 10, 2026
…l size, target-only key and own blocks kept, with main's retries for a refused compressed image)
NotRexed added a commit that referenced this pull request Nov 10, 2026
…wept every 10 presents (follow-up to #1416) (#1436)

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

OoT makes a frame's transient targets in a scratch heap whose offsets shift with what came before them in the frame, so
each shifted one is a texture with a new image, and every 60 presents the dead-target sweep (#899) let go of 300-700 of
them at once, destroyed in the next present: 20-65 ms on the replaying thread, about once a second while the camera moves
(RTX 5070, Kokiri Forest: 10,500 images made and destroyed in 4.5 minutes, 17 GB).

A render target's image whose texture is gone now waits in a pool by its create info, with its views, and a new texture
of that create info takes it. It is still put into GENERAL from UNDEFINED and cleared as a new image is, so nothing it
held shows. Uncompressed colour and depth targets only; bounded by RX_VK_TARGET_POOL_MB (default 1024, 0: off, as before),
given back while VRAM is near what the driver allows, and an image unused in it for 120 presents is destroyed, at most 16
a present. tests/test_target_image_pool.py keeps the rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* vk: render targets' images in VMA blocks of their own; dead targets swept every 10 presents; the target pool bound 256 MB

Follow-up to #1416. OoT's transient targets, made and let go all the time, shared the image pool's 128 MB blocks with the
textures that stay: each one gone left a hole beside a texture that kept its block. In Kokiri Forest the pool's 47-54
blocks (6-6.9 GB) held 1.9-3.6 GB of images, VRAM sat at 93-98% of what the driver allows, and an image made then took
30-70 ms (RTX 5070).

- Images texture_get makes for a target (not for sampling) come from blocks of their own (RX_VK_TARGET_BLOCKS=0: as
  before), and only those go to the target pool.
- The dead targets are swept every 10 presents (RX_VK_DEAD_EVERY, 60 before): the pool got 300-700 at once and the new
  targets of the next second missed most of them. The choice is cheap (1930 textures ordered in 0.3 ms).
- The target pool's bound comes down to 256 MB.
- Every 600 presents the log says what the image blocks hold (vma_log_image_blocks).

tests/test_target_image_pool.py checks the new rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: NotRexed <therealnothingfn@gmail.com>
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.

2 participants