Skip to content

vk: render targets' images in VMA blocks of their own; dead targets swept every 10 presents (follow-up to #1416) - #1436

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

NotRexed merged 3 commits into
NotRexed:mainfrom
DUDUYG:perf/target-image-blocks

Conversation

@DUDUYG

@DUDUYG DUDUYG commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #1416. Only the second commit, 7a9151b, is new. The first commit, c34f350, is #1416 as it landed in the v2.0.12 branch, with the same hash, so this applies there as it is. Against main the diff shows both commits until v2.0.12 reaches it.

What

#1416 took away the once-a-second burst of destroyed images. A longer play session (18 minutes on an RTX 5070) then showed the next layer, which is VRAM.

  • The blocks: OoT's transient render targets shared the image pool's 128 MB blocks with the textures that stay. Each one that went left a hole next to a texture that kept its block, so the blocks could not be given back.
  • What the log showed: the pool's 47-54 blocks (6-6.9 GB) held only 1.9-3.6 GB of images, with 3-4 GB free in them.
  • The cost: VRAM sat at 93-98% of what the driver allows. A new image then took 30-70 ms to make: 7-15 times a run, all "in a block" of the image pool, never in system memory.
  • The pool's misses: the dead targets reached the pool 300-700 at a time, every 60 presents. The new targets of the next second missed most of them, so many were still made new and many pooled ones were destroyed unused.

Change

  • gpu/vk/vk_vma.cpp: a second VMA pool, target_images_pool(), with the same memory type and block size, for render targets' images. create_image(..., target) takes its images from there. RX_VK_TARGET_BLOCKS=0 puts them in the textures' blocks, as before.
  • gpu/vk/vk_texture.cpp:
    • create() knows when texture_get asked for a target (!for_sampling). Only those images go to the targets' blocks and to the target pool.
    • The dead-target sweep runs every 10 presents, set by RX_VK_DEAD_EVERY (it was 60). The choice is cheap: 1930 textures ordered in 0.3 ms, 251 chosen in 0.2 ms.
    • The target pool's bound comes down from 1024 MB to 256 MB.
  • Log: every 600 presents, vma_log_image_blocks says what each image pool's blocks hold, and the largest free range in them.

Measured

before (2 runs) this PR (2 runs)
images that took 3 ms or more to make (worst) 7 / 15 (67.2 / 85.7 ms) 1 / 3 (4.6 / 4.5 ms)
slow submits/presents over 30 ms 6 / 9 0 / 0
worst one 88.8 / 90.5 ms 29.2 / 26.7 ms
time in slow submits/presents over 15 ms 870 / 1170 ms 158 / 192 ms
VRAM used, of what the driver allows 9639 / 9011 MB (89 / 85%) 7870 / 7946 MB (75%)
free in VMA's blocks 3578 / 4021 MB 1263 / 1153 MB
the textures' image blocks: held / blocks 2973 MB / 6400 MB, 1681 MB / 5632 MB 2818 MB / 2944 MB, 2905 MB / 2944 MB
fps 58.4 / 58.0 59.6 / 59.3
  • What stays over 15 ms with this PR:
    • most of the lines are presents of 15-17 ms, which is a 60 fps frame;
    • one pool import of about 20 ms;
    • one memory sync or upload of 27-29 ms.
  • The render targets' own blocks (11 blocks, 1.4 GB) are where the churn now lives. They are mostly free between bursts, and the textures' blocks stay dense.

Not covered

  • One GPU (NVIDIA, Windows) and one scene, headless runs.
  • Bursts: the target pool still misses some targets. The game asks for a set of targets of one shape in a burst when the camera turns, and the dead ones of that shape reach the pool a few presents later and age out after 120. Keeping them longer is a possible next step. I left it out here.
  • Small cards: on 6-8 GB cards the pool still relies on the existing VRAM pressure check to be given back. I could not test that.

Tests

  • tests/test_target_image_pool.py, 9 cases, adds:
    • a texture made for a target is one, and its image comes from the targets' blocks (with RX_VK_TARGET_BLOCKS);
    • the sweep runs every RX_VK_DEAD_EVERY (10) presents;
    • the pool's bound is 256.
  • tests/run_all.py on every test that reads vk_texture.cpp, vk_texture.h or vk_vma.cpp: 9 passed, 2 skipped. The 2 skipped ones need the Vulkan headers, and both pass when run with them: test_vulkan_clear_mip.py and test_parallel_init_order.py.

🤖 Generated with Claude Code

DUDUYG and others added 2 commits November 9, 2026 22:12
…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>
…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

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
@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
…l size, target-only key and own blocks kept, with main's retries for a refused compressed image)
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