perf(vk): capture buffers in size classes, kept up to 256 MB and made ahead on a worker #2437
No reviewers
Labels
No labels
accessibility
autorelease: pending
autorelease: tagged
bug
dependencies
documentation
duplicate
enhancement
github_actions
good first issue
help wanted
invalid
question
testing wanted
wontfix
No milestone
No assignees
1 participant
Due date
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Hookshot!2437
Loading…
Reference in a new issue
No description provided.
Delete branch "perf-capture-buffer-classes"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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).RX_VK_CAPTURE_KEEP_OLD=1restores the old rule;RX_VK_CAP_PREWARM=0turns it off; the worker does not touch the list while the process exits;memory syncinto 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
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_cachedgoes through VMA, which is internally synchronized, and these buffers were already made on the game's threads.cap_prewarm_classis only reached withcap_free_muheld, as its comment says. The exit flag is set under the same mutex the worker takes incap_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 readvk_memory.cpp(includingtest_mem_trim, which readscap_buf_sizeandcap_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 abovecap_prewarm_onsays "again as soon as one is taken". Butcap_prewarm_classhas exactly one caller: thestatic bool firstblock incap_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 callcap_prewarm_class(size)after a spare is taken incap_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 leastneed. 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 classneedrounds 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=0andRX_VK_CAPTURE_KEEP_OLD=1as ways out. CONTRIBUTING asks for switches to be documented with their neighbours;docs/development.mdlists 41RX_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, theWaitedstruct inmemory_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.pythatcap_buf_sizereturns the 32/64/128/192/256 classes (and the old rounding withRX_VK_CAPTURE_KEEP_OLD) would also pin the rule.I confirmed the earlier finding:
cap_prewarm_classis only called from thestatic bool firstblock incap_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_maxguard 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.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_classis only reached from thestatic bool firstblock, 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 emptyWaitedstruct.Two things to add:
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.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 ownstd::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.cppchecks 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()) {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.
github-sync referenced this pull request2026-11-12 06:22:54 +00:00