perf(host): the game's short sleeps wait on the timer where the process has six logical processors or fewer #2263
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!2263
Loading…
Reference in a new issue
No description provided.
Delete branch "perf/short-sleep-timer-few-threads"
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?
On a PC with few logical processors the game's idle job workers take processor time the frame's threads need: the frame rate is lower and some frames take a quarter of a second or more. This makes those workers really sleep there. Nothing changes with 8 logical processors or more.
Why it happens
ModuleSystemWorker*,OptimizeChangeBanc), with nothing to do, sleep 20 us and look at their queues again: about 7,600SleepThreadcalls a second in Hyrule Field.sleep_nsinhost/hos_thread.canswers a sleep under a millisecond by yielding until its time (SwitchToThreadin a loop). That is exact, and it keeps a processor busy for every sleeping thread.What changes
host/hos_thread.c:hos_short_sleep_timerdecides once how sleeps under a millisecond wait. With six logical processors or fewer in the process's affinity mask, the game's own sleeps (nn::os::SleepThreadand the kernel'sSleepThread, both throughhos_sleep_logged) wait on the timer. With more, and when the count is not known (the call failed, or the PC has more than one processor group, where the mask is one group's), they yield as before.hos_sleep_ns: the frame limiter innn_vi.c, the audio renderer, timer events) keep yielding in both cases, so pacing is not made half a millisecond late.HOS_SHORT_SLEEP_TIMERhas three states. Not set: the rule above. Set: it followsenv_on's rule from #2042 (on: the timer for every short sleep, which is what the switch did before;0,false,off,noor empty: yielding for every one).tests/test_short_sleep_timer.py: the realhos_short_sleep_timer,sleep_away,hos_sleep_ns,sleep_nsandhos_sleep_logged, cut from the source, over stubs that count yields and timer waits, one process a case: 26 cases of processor count and switch value, the processor-group case, and six broken variants that must fail (the processors not looked at, the switch read by presence, the switch read as a number instead of byenv_on's rule, the game's sleeps passed on as the host's, the host's sleeps put on the timer, one group's mask taken for the process's).Tested
Windows 11, Ryzen 7 5700X3D (8 cores, 16 threads), Radeon RX 9070 XT (Vulkan 1.4, driver 0x8001a0), 2560x720 window (32:9), frame rate cap 60.
Fewer processors are emulated: the game is started with an affinity mask (inherited from its first instruction), on this one CPU. No PC with a 4- or 6-thread CPU was tested; such a CPU also has slower cores and less cache, and the renderer still sized its pools for 16 threads here.
Each cell is 4 pairs of runs on one binary, control
HOS_SHORT_SLEEP_TIMER=0(all yield, as main does when the switch is not set) against the switch unset (this PR's rule), alternating. A run measures 20 s from 3 s after the save has loaded: Hyrule Field with the camera turning (a player's save), Kokiri Forest standing (the default save). The log line confirms the rule in all 16 variant runs and in none of the control runs. The binary is maind8ce191with this PR's change tohos_thread.cas it was before the merge with #2042 (so the PC's prepared shader caches matched; the merge changed only how a set switch's value is read). On the PR's head, merged with mainb744b1ec, I ran the tests and a whole-file syntax compile ofhost/*.c, not the game.HOS_SHORT_SLEEP_TIMER=1, every short sleep on the timer, release binary of maind8ce191, 4 pairs a cell):HOS_SPIN_US=5) was tried beside it on 4 processors: no effect. Not part of this PR.=0on 4 processors in Temple of Time, Hyrule Field and Kokiri Forest (frames captured every 120 to 600 frames, validation layers off): world and HUD intact in both arms, the only differences animation timing (the fairy, a title fading). Saves unchanged, no fatal line.python -I -B tests/test_short_sleep_timer.py --cc gcc(MinGW gcc 16.2) and with clang, andtests/test_env_on.py: pass. The fulltests/run_all.pyon the merged head (Windows, MinGW gcc): 324 passed, 6 failed, 58 skipped. None of the six is this PR's:test_vcore_fs_openandtest_vcore_video_acquirefail the same way on unchanged mainb744b1ec(fixed on main since by #2380), andtest_fpx_sticky,test_pipeline_play_cache,nx2/test_nup_pathsandtest_precompile_compute_guardfailed the same way on unchanged mainebbba2eaon this PC.Not covered
Kind of change: performance on small CPUs, a behaviour change in scheduling there (who runs when), default on only where measured, with the switch to turn it off (
HOS_SHORT_SLEEP_TIMER=0). Related: #995 (low fps with the GPU not fully used); no report from that issue was reproduced.🤖 Generated with Claude Code
Merged main (
b744b1ec) to resolve the conflict with #2042 inhost/hos_thread.c.HOS_SHORT_SLEEP_TIMERkeeps its third state (not set: the processor count decides); when it is set, its value now followsenv_on's rule, where this branch read it as a number (so=onwould have been off).HOS_SLEEP_LOGis main'senv_on. The test has a broken variant for that (the switch read as a number must fail), andtests/test_env_on.pypasses. The description is updated.🤖 Generated with Claude Code
Looks good to me. The split is the right one: only the game's own SleepThread waits go on the timer, the host's frame limiter and audio keep the exact yield, and the rule only kicks in at 6 logical processors or fewer, with
HOS_SHORT_SLEEP_TIMERas an escape hatch both ways. The numbers back it up: the 250+ ms frames go away on 4 threads, and fps and CPU time a frame either improve or stay the same on 6.What I checked:
CreateWaitableTimerExW(..., CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, ...)fails (Windows older than 10 1803), a short sleep falls through to the sameSwitchToThreadloop, so nothing breaks and you just keep today's behaviour (see the inline nit about the log).timeBeginPeriod(1)in hos_main.c doesn't matter for this path, since the high-resolution timer doesn't depend on it.short_timeris a plain static that several threads may start concurrently, but they all compute the same value, and the log line is guarded byInterlockedExchange. That's fine.nn::os::SleepThreadand svc 0x0B) go throughhos_sleep_logged→sleep_away(ns, 1).hos_sleep_nsstays host-side.GetActiveProcessorGroupCount() == 1) is right: on a multi-group machine the count is left unknown and sleeps yield as before.test_short_sleep_timer.pyandtest_env_on.pypass on Windows and Ubuntu. The red checks aren't caused by this PR:test_native_astc_texture_countfails the same way on main's latest source run (71096934), and the appimage job timed out installing packages.Linux parity and #2313: the two PRs rewrite the same
short_timerlines insleep_ns, so whichever lands second needs a rebase. #2313 is also based on main from before #2042 (it reads the switch by presence andatoi, notenv_on). They don't overlap in intent, though. This PR leaves Linux yielding unless the switch is set, while #2313 makes the timer the default on Linux, whereclock_nanosleepwakes within ~50 us (measured on the Deck: kernel time per worker 0.4-0.6 s/s → 0.04). I'd land this one first, then rebase #2313 ontohos_short_sleep_timer: on non-Windows, an unset switch would return 2 (all sleeps on the timer) instead of 0, and the new test would need one case for it. @Kolkka FYI.Nits only. Approving.
Nit: this logs "wait on the timer" even when this thread's
CreateWaitableTimerExW(... HIGH_RESOLUTION ...)ends up NULL (Windows before 10 1803). In that case a short sleep still goes through the yield loop at the bottom ofsleep_ns, so the log line overstates what happens. That's harmless, but if someone reports "the log says timer but CPU use didn't drop", this is the reason. A word in the comment would be enough.Fixed in
d106855e. The log line now reads "wait on the timer where Windows has the high-resolution one", and the comment above it says the line is written when the choice is made, before any thread has created its timer: whereCreateWaitableTimerExW(HIGH_RESOLUTION)fails (Windows before 10 1803) a short sleep falls through to the yield loop at the end ofsleep_nsand nothing changes.🤖 Addressed by Claude Code
Re-approving at
d106855. The only change since my approval is the nit fix: the log line now says "where Windows has the high-resolution one" and the comment above it explains the pre-1803 fallback to the yield loop. That covers it, nothing new.CI is green except Windows part 1, where only tests/test_native_astc_texture_count.py fails. That test is unrelated to this change (it doesn't touch host/hos_thread.c), so I'd treat it as main's flake or an existing failure. Worth a re-run before merging.
github-sync referenced this pull request2026-11-12 06:22:54 +00:00