perf(vk): capture buffers in size classes, kept up to 256 MB and made ahead on a worker #2437

Merged
lucasmazzoni45-del merged 1 commit from perf-capture-buffer-classes into main 2026-11-12 03:04:44 +00:00
lucasmazzoni45-del commented 2026-11-11 19:15:55 +00:00 (Migrated from github.com)

A scene load's capture holds 16 to 256 MB of written pages, and its host buffer was made on the game's thread. On AMD's Windows driver that allocation costs ~0.8 ms a MB. In a 19-minute RX 7800 XT session the slow-submit lines had 5 memory syncs of 17 to 108 ms, all in capture, each right after a load, none in steady play (maps 0.0 (lock waits 0.0), GPU wait for late pages 0.000).

  • sizes above 16 MB round up to 32/64/128/192/256 MB classes, so a returned buffer fits the next capture;
  • spares kept up to 256 MB (768 MB in all) instead of 64 MB; RX_VK_CAPTURE_KEEP_OLD=1 restores the old rule;
  • spares of the 32/64/128/192 MB classes are made on a worker at the first capture and again when one is taken; RX_VK_CAP_PREWARM=0 turns it off; the worker does not touch the list while the process exits;
  • the slow-submit line splits memory sync into maps (lock waits), capture and collect.

New log lines on start: capture buffer prewarm ... on, a 128 MB capture buffer made ahead in 22.9 ms, off the game's thread.

Tested: the Vulkan back end builds (llvm-mingw, RX_VK_ONLY) on top of v2.1.0, and a full build of that tree boots to the title at 60 fps on an RX 7800 XT / Ryzen 7 5800X3D (Windows 11, driver 0x8001a0) with the new log lines present. The repository test suite was not run, and I have not yet played a long session on this build, so the effect in real play is not shown. Related data: #995.

🤖 Generated with Claude Code

A scene load's capture holds 16 to 256 MB of written pages, and its host buffer was made on the game's thread. On AMD's Windows driver that allocation costs ~0.8 ms a MB. In a 19-minute RX 7800 XT session the slow-submit lines had 5 memory syncs of 17 to 108 ms, all in `capture`, each right after a load, none in steady play (`maps 0.0 (lock waits 0.0)`, `GPU wait for late pages 0.000`). - sizes above 16 MB round up to 32/64/128/192/256 MB classes, so a returned buffer fits the next capture; - spares kept up to 256 MB (768 MB in all) instead of 64 MB; `RX_VK_CAPTURE_KEEP_OLD=1` restores the old rule; - spares of the 32/64/128/192 MB classes are made on a worker at the first capture and again when one is taken; `RX_VK_CAP_PREWARM=0` turns it off; the worker does not touch the list while the process exits; - the slow-submit line splits `memory sync` into maps (lock waits), capture and collect. New log lines on start: `capture buffer prewarm ... on`, `a 128 MB capture buffer made ahead in 22.9 ms, off the game's thread`. **Tested:** the Vulkan back end builds (llvm-mingw, `RX_VK_ONLY`) on top of v2.1.0, and a full build of that tree boots to the title at 60 fps on an RX 7800 XT / Ryzen 7 5800X3D (Windows 11, driver 0x8001a0) with the new log lines present. The repository test suite was **not** run, and I have not yet played a long session on this build, so the effect in real play is not shown. Related data: #995. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Joey-Magnus (Migrated from github.com) reviewed 2026-11-11 20:08:44 +00:00
Joey-Magnus (Migrated from github.com) left a comment

The diagnosis is convincing: the slow-submit lines put the stalls in capture, at loads only, and they scale with the buffer size. Size classes plus a worker-made spare is a reasonable answer. I checked the threading. create_buffer_cached goes through VMA, which is internally synchronized, and these buffers were already made on the game's threads. cap_prewarm_class is only reached with cap_free_mu held, as its comment says. The exit flag is set under the same mutex the worker takes in cap_buf_free. CI hasn't run on this head (title check only), and the PR says the suite wasn't run, so I ran the 25 checks that read vk_memory.cpp (including test_mem_trim, which reads cap_buf_size and cap_buf_get). All pass on Linux.

should fix — spares are never remade after the first capture (gpu/vk/vk_memory.cpp:975-982): the description says spares are made "at the first capture and again when one is taken", and the comment above cap_prewarm_on says "again as soon as one is taken". But cap_prewarm_class has exactly one caller: the static bool first block in cap_buf_get. Once a load takes the 128 MB spare, nothing makes another, so the second load in a session pays for the allocation on the game's thread again, which was the problem in the first place. Either call cap_prewarm_class(size) after a spare is taken in cap_buf_get (the mutex is already held, as the function expects), or correct the description and the comment.

question — big spares going to small captures (gpu/vk/vk_memory.cpp:984-986): best-fit takes the smallest spare at least need. For the first 24 captures nothing has retired yet (kKeep = 24), so a 2-4 MB per-frame capture made just after start-up will take the prewarmed 32 MB spare, and then the 64 and 128 MB ones. They come back on retirement, but in the meantime they aren't there for a load. Would capping the fit (take a spare only if it's at most about twice the class need rounds to) keep them for the captures they were made for? A log of which class each load got would show whether this matters.

should fix — document the new switches: prewarming is on by default, with RX_VK_CAP_PREWARM=0 and RX_VK_CAPTURE_KEEP_OLD=1 as ways out. CONTRIBUTING asks for switches to be documented with their neighbours; docs/development.md lists 41 RX_VK_* switches. Since these change the default behaviour, a line each there would help anyone bisecting a memory or stutter report.

minor — memory on integrated GPUs: the first capture now commits 32 + 64 + 128 + 192 = 416 MB of host memory, whether or not a load ever needs it. The cap_spare_max() guard turns this off on cards reporting 6.5 GB or less. An iGPU reporting a large shared heap passes that guard, though, and there it's system RAM out of a ~10 GB run. Please say in the PR what the extra footprint is, and whether the guard should also look at UMA devices.

minor — comments and log disagree with the code: the start-up log says "(32, 64, 128 MB spares made on a worker)", and cap_prewarm_class's comment says "32, 64 or 128 MB classes only", but 192 MB is prewarmed as well. Separately, the Waited struct in memory_sync (vk_memory.cpp:583-586) has an empty destructor and does nothing; drop it.

Testing: the PR shows the build boots, but not the stall going away. Before/after slow-submit lines from a few loads on the RX 7800 XT would show whether this does what it's meant to. A small check in tests/test_mem_trim.py that cap_buf_size returns the 32/64/128/192/256 classes (and the old rounding with RX_VK_CAPTURE_KEEP_OLD) would also pin the rule.

The diagnosis is convincing: the slow-submit lines put the stalls in `capture`, at loads only, and they scale with the buffer size. Size classes plus a worker-made spare is a reasonable answer. I checked the threading. `create_buffer_cached` goes through VMA, which is internally synchronized, and these buffers were already made on the game's threads. `cap_prewarm_class` is only reached with `cap_free_mu` held, as its comment says. The exit flag is set under the same mutex the worker takes in `cap_buf_free`. CI hasn't run on this head (title check only), and the PR says the suite wasn't run, so I ran the 25 checks that read `vk_memory.cpp` (including `test_mem_trim`, which reads `cap_buf_size` and `cap_buf_get`). All pass on Linux. **should fix — spares are never remade after the first capture** (`gpu/vk/vk_memory.cpp:975-982`): the description says spares are made "at the first capture and again when one is taken", and the comment above `cap_prewarm_on` says "again as soon as one is taken". But `cap_prewarm_class` has exactly one caller: the `static bool first` block in `cap_buf_get`. Once a load takes the 128 MB spare, nothing makes another, so the second load in a session pays for the allocation on the game's thread again, which was the problem in the first place. Either call `cap_prewarm_class(size)` after a spare is taken in `cap_buf_get` (the mutex is already held, as the function expects), or correct the description and the comment. **question — big spares going to small captures** (`gpu/vk/vk_memory.cpp:984-986`): best-fit takes the smallest spare at least `need`. For the first 24 captures nothing has retired yet (`kKeep = 24`), so a 2-4 MB per-frame capture made just after start-up will take the prewarmed 32 MB spare, and then the 64 and 128 MB ones. They come back on retirement, but in the meantime they aren't there for a load. Would capping the fit (take a spare only if it's at most about twice the class `need` rounds to) keep them for the captures they were made for? A log of which class each load got would show whether this matters. **should fix — document the new switches:** prewarming is on by default, with `RX_VK_CAP_PREWARM=0` and `RX_VK_CAPTURE_KEEP_OLD=1` as ways out. CONTRIBUTING asks for switches to be documented with their neighbours; `docs/development.md` lists 41 `RX_VK_*` switches. Since these change the default behaviour, a line each there would help anyone bisecting a memory or stutter report. **minor — memory on integrated GPUs:** the first capture now commits 32 + 64 + 128 + 192 = 416 MB of host memory, whether or not a load ever needs it. The `cap_spare_max()` guard turns this off on cards reporting 6.5 GB or less. An iGPU reporting a large shared heap passes that guard, though, and there it's system RAM out of a ~10 GB run. Please say in the PR what the extra footprint is, and whether the guard should also look at UMA devices. **minor — comments and log disagree with the code:** the start-up log says "(32, 64, 128 MB spares made on a worker)", and `cap_prewarm_class`'s comment says "32, 64 or 128 MB classes only", but 192 MB is prewarmed as well. Separately, the `Waited` struct in `memory_sync` (`vk_memory.cpp:583-586`) has an empty destructor and does nothing; drop it. **Testing:** the PR shows the build boots, but not the stall going away. Before/after slow-submit lines from a few loads on the RX 7800 XT would show whether this does what it's meant to. A small check in `tests/test_mem_trim.py` that `cap_buf_size` returns the 32/64/128/192/256 classes (and the old rounding with `RX_VK_CAPTURE_KEEP_OLD`) would also pin the rule.
Joey-Magnus (Migrated from github.com) requested changes 2026-11-11 21:24:17 +00:00
Joey-Magnus (Migrated from github.com) left a comment

I confirmed the earlier finding: cap_prewarm_class is only called from the static bool first block in cap_buf_get. Once the first load takes the spare, nothing remakes it, so the next load stalls on the game's thread again, which is what this PR is meant to fix.

It's also on by default and commits 32+64+128+192 = 416 MB at the first capture, and the cap_spare_max guard doesn't cover iGPUs that report a large shared heap. The new switches aren't documented, and a before/after measurement would help. The detached threads at shutdown are fine, since main never destroys the allocator.

I confirmed the earlier finding: `cap_prewarm_class` is only called from the `static bool first` block in `cap_buf_get`. Once the first load takes the spare, nothing remakes it, so the next load stalls on the game's thread again, which is what this PR is meant to fix. It's also on by default and commits 32+64+128+192 = 416 MB at the first capture, and the `cap_spare_max` guard doesn't cover iGPUs that report a large shared heap. The new switches aren't documented, and a before/after measurement would help. The detached threads at shutdown are fine, since main never destroys the allocator.
hckrman101 (Migrated from github.com) reviewed 2026-11-11 22:19:52 +00:00
hckrman101 (Migrated from github.com) left a comment

Thanks, the slow-submit numbers make a good case that the load stalls are the capture buffer's allocation, and size classes plus a spare made off the game's thread is a sensible fix.

I agree with @Joey-Magnus's two reviews and won't repeat them. The blocking point is still open on this head: cap_prewarm_class is only reached from the static bool first block, so a spare is never made again once a load takes it, and the second load stalls like before. His other points also stand: small early captures can take the big spares, the 416 MB committed at the first capture, the two undocumented switches, the 32/64/128 log text vs the 192 MB class actually made, and the empty Waited struct.

Two things to add:

  • Scope of the default. The cost you measured (~0.8 ms a MB at vkAllocateMemory) is AMD's Windows driver, but prewarming and the larger spare list (up to 256 MB each, 768 MB in all) are on for every vendor and both platforms. On NVIDIA or Mesa, where that allocation is cheap, it's host-visible memory held for nothing. Either limit the default to where it was measured or show it doesn't cost elsewhere. The iGPU question Joey raised is part of the same thing.
  • Siblings. #2438 and #2440 touch different files (vk_vma.cpp, vk_jobs.cpp), so no conflicts. But #2438 also commits memory up front (about 2.4 GB of VRAM on a 16 GB card), and with this one the start-up footprint adds up on one machine, so it's worth stating the combined figure. The prewarm threads here are their own std::threads, not #2440's workers, so the worker count doesn't bound them (up to three at once).

CI has only run the title check on this head, and the PR says the suite wasn't run. Joey ran the vk_memory.cpp checks locally, and they pass. A before/after of the slow-submit lines over a few loads would show the stall going away, which is the point of the PR.

Thanks, the slow-submit numbers make a good case that the load stalls are the capture buffer's allocation, and size classes plus a spare made off the game's thread is a sensible fix. I agree with @Joey-Magnus's two reviews and won't repeat them. The blocking point is still open on this head: `cap_prewarm_class` is only reached from the `static bool first` block, so a spare is never made again once a load takes it, and the second load stalls like before. His other points also stand: small early captures can take the big spares, the 416 MB committed at the first capture, the two undocumented switches, the 32/64/128 log text vs the 192 MB class actually made, and the empty `Waited` struct. Two things to add: - **Scope of the default.** The cost you measured (~0.8 ms a MB at `vkAllocateMemory`) is AMD's Windows driver, but prewarming and the larger spare list (up to 256 MB each, 768 MB in all) are on for every vendor and both platforms. On NVIDIA or Mesa, where that allocation is cheap, it's host-visible memory held for nothing. Either limit the default to where it was measured or show it doesn't cost elsewhere. The iGPU question Joey raised is part of the same thing. - **Siblings.** #2438 and #2440 touch different files (`vk_vma.cpp`, `vk_jobs.cpp`), so no conflicts. But #2438 also commits memory up front (about 2.4 GB of VRAM on a 16 GB card), and with this one the start-up footprint adds up on one machine, so it's worth stating the combined figure. The prewarm threads here are their own `std::thread`s, not #2440's workers, so the worker count doesn't bound them (up to three at once). CI has only run the title check on this head, and the PR says the suite wasn't run. Joey ran the `vk_memory.cpp` checks locally, and they pass. A before/after of the slow-submit lines over a few loads would show the stall going away, which is the point of the PR.
@ -889,3 +974,4 @@
}
bool cap_buf_get(uint64_t need, VkBuffer& buffer, VmaAllocation& alloc, void*& map, uint64_t& size) {
std::lock_guard lk(cap_free_mu);
if (cap_prewarm_on()) {
hckrman101 (Migrated from github.com) commented 2026-11-11 22:19:52 +00:00

On by default for every GPU and OS, while the allocation cost it avoids was measured on AMD's Windows driver only. Consider defaulting it on where that was measured (or after a quick check that NVIDIA/Mesa gain anything), and leave the switch for the rest.

On by default for every GPU and OS, while the allocation cost it avoids was measured on AMD's Windows driver only. Consider defaulting it on where that was measured (or after a quick check that NVIDIA/Mesa gain anything), and leave the switch for the rest.
Commenting is not possible because the repository is archived.
No description provided.