Runtime: the game's render thread on the reserved core (HOS_PIN_RENDER=0: off) #1034

Merged
jordanella merged 1 commit from render-thread-core into main 2026-11-09 15:15:28 +00:00
jordanella commented 2026-11-09 07:25:40 +00:00 (Migrated from github.com)

In the default synchronous mode (RX_VK_ASYNC unset), 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. Meanwhile hos_reserved_cpus() keeps the last physical core free of every game thread for the async replay thread, which only exists with RX_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 with RX_VK_ASYNC=1, where the core belongs to the replay thread, and HOS_PIN still takes precedence. HOS_PIN_RENDER=0 turns 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:

build runs fps replay per frame (submit phase)
main 2 34.7 / 35.1 23.6 / 23.4 ms
this branch 2 36.8 / 36.6 22.1 / 22.0 ms

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 main 3b4347d; the rebase did not change the patch (git range-diff shows it identical), and host/hos_thread.c has 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 main 3b4347d on this machine.

🤖 Generated with Claude Code

In the default synchronous mode (`RX_VK_ASYNC` unset), 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. Meanwhile `hos_reserved_cpus()` keeps the last physical core free of every game thread for the async replay thread, which only exists with `RX_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 with `RX_VK_ASYNC=1`, where the core belongs to the replay thread, and `HOS_PIN` still takes precedence. `HOS_PIN_RENDER=0` turns 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: | build | runs | fps | replay per frame (`submit` phase) | |---|---|---|---| | main | 2 | 34.7 / 35.1 | 23.6 / 23.4 ms | | this branch | 2 | 36.8 / 36.6 | 22.1 / 22.0 ms | 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 main 3b4347d; the rebase did not change the patch (`git range-diff` shows it identical), and `host/hos_thread.c` has 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 main 3b4347d on this machine. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
hckrman101 (Migrated from github.com) reviewed 2026-11-09 07:41:07 +00:00
hckrman101 (Migrated from github.com) left a comment

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.

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 each
frame's (4 of its 6 queues). The core kept for the replay thread had nothing on it then. On a Ryzen 9 3900X in Kokiri
Forest: 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) {
hckrman101 (Migrated from github.com) commented 2026-11-09 07:41:07 +00:00

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 to reserved_cpus) would be a single source of truth and not depend on names.

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 to `reserved_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");
hckrman101 (Migrated from github.com) commented 2026-11-09 07:41:07 +00:00

This re-parses RX_VK_ASYNC by hand rather than asking the back end (async_on() in gpu/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.

This re-parses `RX_VK_ASYNC` by hand rather than asking the back end (`async_on()` in `gpu/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;
hckrman101 (Migrated from github.com) commented 2026-11-09 07:41:07 +00:00

nit: exact strcmp here, while the HOS_PIN/HOS_PRIO name 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.

nit: exact `strcmp` here, while the `HOS_PIN`/`HOS_PRIO` name 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;
hckrman101 (Migrated from github.com) commented 2026-11-09 07:41:07 +00:00

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 (GetLogicalProcessorInformationEx EfficiencyClass)? It'd be good to measure on an Intel hybrid CPU and an 8-thread one too.

`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 (`GetLogicalProcessorInformationEx` `EfficiencyClass`)? It'd be good to measure on an Intel hybrid CPU and an 8-thread one too.
jordanella (Migrated from github.com) reviewed 2026-11-09 15:33:07 +00:00
@ -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;
jordanella (Migrated from github.com) commented 2026-11-09 15:33:07 +00:00

Thanks, and sorry this landed before the follow-up was ready: #1158 addresses it. hos_reserved_cpus() takes the last core of the highest EfficiencyClass (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.

Thanks, and sorry this landed before the follow-up was ready: #1158 addresses it. `hos_reserved_cpus()` takes the last core of the highest `EfficiencyClass` (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.
jordanella (Migrated from github.com) reviewed 2026-11-09 15:33:09 +00:00
@ -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");
jordanella (Migrated from github.com) commented 2026-11-09 15:33:09 +00:00

In #1158 the pin lives in vk_replay.cpp and asks async_on() itself, and a non-Vulkan back end is untouched.

In #1158 the pin lives in `vk_replay.cpp` and asks `async_on()` itself, and a non-Vulkan back end is untouched.
jordanella (Migrated from github.com) reviewed 2026-11-09 15:33:11 +00:00
@ -563,0 +564,4 @@
Vulkan back end replays a submission on the thread that submits it, and OoT's "Presentation Thread" submits most of each
frame's (4 of its 6 queues). The core kept for the replay thread had nothing on it then. On a Ryzen 9 3900X in Kokiri
Forest: 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) {
jordanella (Migrated from github.com) commented 2026-11-09 15:33:11 +00:00

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).

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).
jordanella (Migrated from github.com) reviewed 2026-11-09 15:33:13 +00:00
@ -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;
jordanella (Migrated from github.com) commented 2026-11-09 15:33:13 +00:00

Gone with the name match in #1158.

Gone with the name match in #1158.
Commenting is not possible because the repository is archived.
No description provided.