Runtime: the game's render thread on the reserved core (HOS_PIN_RENDER=0: off) #1034
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!1034
Loading…
Reference in a new issue
No description provided.
Delete branch "render-thread-core"
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?
In the default synchronous mode (
RX_VK_ASYNCunset), the Vulkan back end replays each submission on the game thread that submits it. In OoT that is mostly the "Presentation Thread", which submits 4 of the 6 queues. Meanwhilehos_reserved_cpus()keeps the last physical core free of every game thread for the async replay thread, which only exists withRX_VK_ASYNC=1. So on any machine with 8+ logical processors that core sat empty, and the replay shared cores with the game's threads.This change gives the Presentation Thread the reserved core, and that thread alone. The mask is the one
hos_reserved_cpus()already works out, so nothing is CPU-specific and no core is taken from the game that wasn't already reserved. It is skipped withRX_VK_ASYNC=1, where the core belongs to the replay thread, andHOS_PINstill takes precedence.HOS_PIN_RENDER=0turns it off. The log says once which processors it got.Measured on a Ryzen 9 3900X (12 cores / 24 threads, 4 CCXs) with an RTX 3080 on Windows 11. Kokiri Forest, standing still after loading a save, headless, input timed in game frames (
HOS_INPUT_FRAME_CLOCK=1), the same route every run, live numbers over presents 3120-5520:submitphase)Every replay phase got faster by about the same fraction, which fits contention rather than any one piece of code: the same frame replayed in isolation (
RX_VK_BENCH) was ~25% faster than during play before the change. Giving more threads cores of their own (MainThread, or a whole CCX for the two) measured within noise of this, and so did raising the thread's priority.Not measured: CPUs with 6 or 8 physical cores, where the reserved core is a bigger share of the machine. The reservation itself is unchanged by this PR, though, so the trade is the same as today's: the core was already kept from the game.
The numbers were measured on v2.0.5 (
232eecb). The branch is rebased onto main3b4347d; the rebase did not change the patch (git range-diffshows it identical), andhost/hos_thread.chas no upstream changes since.python -I -B tests/run_all.py: 104 passed, 3 failed, 13 skipped on Windows. The three failures (test_changelog.py,test_fps_overlay_order.py,test_precompile_compute_guard.py) fail the same way on main3b4347don this machine.🤖 Generated with Claude Code
Nice find on the 3900X. My main worry is how this behaves on other CPUs: the reserved core was chosen to be a core nothing important runs on, and this makes the main render path depend on it.
@ -563,0 +564,4 @@Vulkan back end replays a submission on the thread that submits it, and OoT's "Presentation Thread" submits most of eachframe's (4 of its 6 queues). The core kept for the replay thread had nothing on it then. On a Ryzen 9 3900X in KokiriForest: the replay ~24 -> ~22 ms a frame, 34 -> 36 fps (2026-11-08). HOS_PIN_RENDER=0: kept off the core as before */static int render_thread_on_reserved(const char *name) {Bigger picture: this hard-codes a game thread name in host code to compensate for the Vulkan back end replaying on the submitting thread. Threads that submit but have other names (the other 2 of OoT's 6 queues) get nothing, and this shared code also applies to any other title with a "Presentation Thread" without being measured there. Having the back end pin whichever thread does the sync replay (e.g. on its first
do_submit, through a host hook toreserved_cpus) would be a single source of truth and not depend on names.@ -563,0 +567,4 @@static int render_thread_on_reserved(const char *name) {static int on = -1;if (on < 0) {const char *e = getenv("HOS_PIN_RENDER"), *async = getenv("RX_VK_ASYNC");This re-parses
RX_VK_ASYNCby hand rather than asking the back end (async_on()ingpu/vk/vk_replay.cpp). If the async default or a fallback ever changes there, the two disagree and the Presentation Thread ends up sharing the reserved core with the replay thread, the slowdown the reserved core exists to prevent. With a non-Vulkan back end it also moves the thread for no benefit.@ -563,0 +570,4 @@const char *e = getenv("HOS_PIN_RENDER"), *async = getenv("RX_VK_ASYNC");on = (!e || atoi(e) != 0) && !(async && atoi(async) != 0);}return on && name && strcmp(name, "Presentation Thread") == 0;nit: exact
strcmphere, while theHOS_PIN/HOS_PRIOname matching just below uses substring matching. A name like "Presentation Thread 0" would silently miss the pin. Matching the same way would be more consistent.@ -568,1 +581,3 @@if ((uint64_t)proc & ~reserved) SetThreadAffinityMask(h, (DWORD_PTR)((uint64_t)proc & ~reserved));if (render_thread_on_reserved(name) && ((uint64_t)proc & reserved)) {SetThreadAffinityMask(h, (DWORD_PTR)((uint64_t)proc & reserved));static volatile LONG told;hos_reserved_cpus()picks the core with the highest processor mask. On Intel hybrid CPUs (12th gen+) that's an E-core: on an i7-12700K, logical 16-19 are E-cores, so in the default sync mode the thread doing most of each frame's replay is confined to one slow E-core, which would likely cost fps. Before this, picking that core was harmless because nothing heavy ran on it.It's also now restricted to exactly one core (one logical processor without SMT), so an interrupt/DPC or another process on that processor can't be dodged. That risks frame-time spikes even if the average improves. Maybe allow the reserved core in addition to the others (proc mask unchanged) rather than only it, or only pin when the reserved core is a P-core (
GetLogicalProcessorInformationExEfficiencyClass)? It'd be good to measure on an Intel hybrid CPU and an 8-thread one too.@ -568,1 +581,3 @@if ((uint64_t)proc & ~reserved) SetThreadAffinityMask(h, (DWORD_PTR)((uint64_t)proc & ~reserved));if (render_thread_on_reserved(name) && ((uint64_t)proc & reserved)) {SetThreadAffinityMask(h, (DWORD_PTR)((uint64_t)proc & reserved));static volatile LONG told;Thanks, and sorry this landed before the follow-up was ready: #1158 addresses it.
hos_reserved_cpus()takes the last core of the highestEfficiencyClass(a P-core on hybrid CPUs), and the pin holds only while a thread replays. I measured "in addition" as its own mode (the core as the ideal processor, every processor still allowed): no gain at all, 36.6 fps standing against 36.6 without a pin and 39.1 pinned. On spikes, frames over 50 ms while panning were 51/61 without a pin, 32/34 with #1034's, and 24/29 with #1158's. I don't have a hybrid or 8-thread CPU to measure on.@ -563,0 +567,4 @@static int render_thread_on_reserved(const char *name) {static int on = -1;if (on < 0) {const char *e = getenv("HOS_PIN_RENDER"), *async = getenv("RX_VK_ASYNC");In #1158 the pin lives in
vk_replay.cppand asksasync_on()itself, and a non-Vulkan back end is untouched.@ -563,0 +564,4 @@Vulkan back end replays a submission on the thread that submits it, and OoT's "Presentation Thread" submits most of eachframe's (4 of its 6 queues). The core kept for the replay thread had nothing on it then. On a Ryzen 9 3900X in KokiriForest: the replay ~24 -> ~22 ms a frame, 34 -> 36 fps (2026-11-08). HOS_PIN_RENDER=0: kept off the core as before */static int render_thread_on_reserved(const char *name) {Agreed. #1158 does it that way: the back end pins whichever thread replays, for the length of the replay, through
host->reserved_cpus, and the name match goes. It covers the other two queues too, and measured a little better than the pin by name (39.1 against 38.6 fps standing).@ -563,0 +570,4 @@const char *e = getenv("HOS_PIN_RENDER"), *async = getenv("RX_VK_ASYNC");on = (!e || atoi(e) != 0) && !(async && atoi(async) != 0);}return on && name && strcmp(name, "Presentation Thread") == 0;Gone with the name match in #1158.