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

Merged
DUDUYG merged 3 commits from perf/target-image-blocks into main 2026-11-10 04:33:55 +00:00
DUDUYG commented 2026-11-10 02:23:21 +00:00 (Migrated from github.com)

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

  • Setup: RTX 5070 12 GB, Ryzen 7 9800X3D, Windows 11, NVIDIA 621.01, 1080p.
  • Runs: headless runs of a Kokiri Forest save on the same scripted 36 s walk with camera turns, in-play part only (after the load).
  • A/B: one build, alternated. "Before" is RX_VK_TARGET_BLOCKS=0 RX_VK_DEAD_EVERY=60 RX_VK_TARGET_POOL_MB=1024, which is #1416 as it is.
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

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 - **Setup:** RTX 5070 12 GB, Ryzen 7 9800X3D, Windows 11, NVIDIA 621.01, 1080p. - **Runs:** headless runs of a Kokiri Forest save on the same scripted 36 s walk with camera turns, in-play part only (after the load). - **A/B:** one build, alternated. "Before" is `RX_VK_TARGET_BLOCKS=0 RX_VK_DEAD_EVERY=60 RX_VK_TARGET_POOL_MB=1024`, which is #1416 as it is. | | 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](https://claude.com/claude-code)
NotRexed commented 2026-11-10 03:05:42 +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:33 +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) approved these changes 2026-11-10 04:15:52 +00:00
hckrman101 (Migrated from github.com) left a comment

Looked at 7a9151b only (c34f350 is #1416). This looks good to me. Splitting the transient targets into their own blocks is the right fix for the holes the log shows, and the A/B numbers back it up.

What I checked:

  • Allocation path: target only goes to create_image for non-BC/ASTC images made with !for_sampling. The BC/ASTC storage retry still goes to the textures' blocks, which is right. If the targets' pool can't fit an image (bigger than a block, or a memory type the pool doesn't have), it falls through to the default VMA allocation exactly as before, dedicated memory included. WITHIN_BUDGET is copied into pai, so the new pool respects the budget too. Nothing here touches memory priority (the code doesn't use VK_EXT_memory_priority), so no change there.
  • No platform code, so Linux and Windows behave the same. No new settings option ids. The test file was already there and run_all.py finds it on its own (9 cases).
  • Sweeping every 10 presents also means the regular unused-for-120 eviction is checked 6x as often. That only changes how fine-grained it is, and the sort cost you measured is small.

Things to know, none of them blocking:

  • VMA keeps one empty block in each block vector unless the budget is exceeded (vk_vma.cpp already says so for the image pool). A second pool can therefore hold an extra idle 128 MB. That's fine on 12 GB, but it's worth keeping in mind for the 6-8 GB case you couldn't test. A smaller blockSize just for the targets' pool would cut that down. At 1080p the targets are around 10 MB each.
  • Merge order: this needs #1416 first. Both PRs currently conflict with main in gpu/vk/vk_texture.cpp (main is 41 commits past the base), so a rebase is needed once #1416 lands.
  • CI didn't report any checks on this branch.
Looked at 7a9151b only (c34f350 is #1416). This looks good to me. Splitting the transient targets into their own blocks is the right fix for the holes the log shows, and the A/B numbers back it up. What I checked: - Allocation path: `target` only goes to `create_image` for non-BC/ASTC images made with `!for_sampling`. The BC/ASTC storage retry still goes to the textures' blocks, which is right. If the targets' pool can't fit an image (bigger than a block, or a memory type the pool doesn't have), it falls through to the default VMA allocation exactly as before, dedicated memory included. `WITHIN_BUDGET` is copied into `pai`, so the new pool respects the budget too. Nothing here touches memory priority (the code doesn't use `VK_EXT_memory_priority`), so no change there. - No platform code, so Linux and Windows behave the same. No new settings option ids. The test file was already there and `run_all.py` finds it on its own (9 cases). - Sweeping every 10 presents also means the regular unused-for-120 eviction is checked 6x as often. That only changes how fine-grained it is, and the sort cost you measured is small. Things to know, none of them blocking: - VMA keeps one empty block in each block vector unless the budget is exceeded (vk_vma.cpp already says so for the image pool). A second pool can therefore hold an extra idle 128 MB. That's fine on 12 GB, but it's worth keeping in mind for the 6-8 GB case you couldn't test. A smaller `blockSize` just for the targets' pool would cut that down. At 1080p the targets are around 10 MB each. - Merge order: this needs #1416 first. Both PRs currently conflict with main in `gpu/vk/vk_texture.cpp` (main is 41 commits past the base), so a rebase is needed once #1416 lands. - CI didn't report any checks on this branch.
hckrman101 (Migrated from github.com) commented 2026-11-10 04:15:52 +00:00

Nit: the frame_no % 600 == 0 log of kept finalized textures further down only runs on sweep frames. With 10 or 60 that's fine. If someone sets RX_VK_DEAD_EVERY to a value that doesn't divide 600 (7, say), that log quietly stops showing. Worth a word in the comment, or switch that check to a >= last + 600 like dead_logged_at.

Nit: the `frame_no % 600 == 0` log of kept finalized textures further down only runs on sweep frames. With 10 or 60 that's fine. If someone sets `RX_VK_DEAD_EVERY` to a value that doesn't divide 600 (7, say), that log quietly stops showing. Worth a word in the comment, or switch that check to a `>= last + 600` like `dead_logged_at`.
@ -43,0 +47,4 @@
VmaPool target_image_pool = VK_NULL_HANDLE;
bool target_pool_tried = false;
VmaPool images_pool();
VmaPool target_images_pool() {
hckrman101 (Migrated from github.com) commented 2026-11-10 04:15:51 +00:00

Nit: apart from the handle, the log line and the images_pool() gate, this is the same as images_pool(). A small make_image_pool(VmaPool&) helper would keep the two from drifting, e.g. if one later gets its own block size.

Nit: apart from the handle, the log line and the `images_pool()` gate, this is the same as `images_pool()`. A small `make_image_pool(VmaPool&)` helper would keep the two from drifting, e.g. if one later gets its own block size.
hckrman101 (Migrated from github.com) commented 2026-11-10 04:15:51 +00:00

Nit: this /// (every heap: ...) comment belongs to vma_log_heaps(). The new function went in between, so it now sits above vma_log_image_blocks().

Nit: this `/// (every heap: ...)` comment belongs to `vma_log_heaps()`. The new function went in between, so it now sits above `vma_log_image_blocks()`.
Commenting is not possible because the repository is archived.
No description provided.