fix(linux): sleep short waits on the timer instead of yielding #2313

Closed
Kolkka wants to merge 2 commits from Kolkka/linux/short-sleep-timer into main
Kolkka commented 2026-11-11 14:27:43 +00:00 (Migrated from github.com)

On Linux, the game's many short waits spin the CPU in the kernel. On the Steam Deck that takes the power the GPU needs: Kokiri Forest runs at 6.6 fps.

What changed: sleeps under 1 ms yield in a loop until the time, because Windows' timer is too coarse (~0.5 ms) for them. On Linux, clock_nanosleep wakes within ~50 µs, so the loop only burned CPU. OoT's job wait (f_800998a0, a 20 µs SleepThread between checks) ran about 35,000 times a second, and each worker spent 0.4–0.6 s per second in the kernel with ~20,000 context switches. The Deck's APU shares 15 W, so the CPU took about 11.8 W and the GPU was held at ~220 MHz. Linux now sleeps short waits on the timer, and Windows keeps the yield loop. HOS_SHORT_SLEEP_TIMER=0 restores the yields on Linux.

Tested: Steam Deck LCD, Kokiri Forest, standing still, 50% resolution:

Kernel time per job worker Kokiri fps
Before 0.4–0.6 s/s 6.4–6.6
After ~0.04 s/s 7.9–8.3

The rest of the gap is the Deck's power split: with the GPU clock set manually, the same build runs at 14.3 fps.

tests/run_all.py: no new failures against main.

🤖 Generated with Claude Code

On Linux, the game's many short waits spin the CPU in the kernel. On the Steam Deck that takes the power the GPU needs: Kokiri Forest runs at 6.6 fps. **What changed:** sleeps under 1 ms yield in a loop until the time, because Windows' timer is too coarse (~0.5 ms) for them. On Linux, `clock_nanosleep` wakes within ~50 µs, so the loop only burned CPU. OoT's job wait (`f_800998a0`, a 20 µs SleepThread between checks) ran about 35,000 times a second, and each worker spent 0.4–0.6 s per second in the kernel with ~20,000 context switches. The Deck's APU shares 15 W, so the CPU took about 11.8 W and the GPU was held at ~220 MHz. Linux now sleeps short waits on the timer, and Windows keeps the yield loop. `HOS_SHORT_SLEEP_TIMER=0` restores the yields on Linux. **Tested:** Steam Deck LCD, Kokiri Forest, standing still, 50% resolution: | | Kernel time per job worker | Kokiri fps | |---|---|---| | Before | 0.4–0.6 s/s | 6.4–6.6 | | After | ~0.04 s/s | 7.9–8.3 | The rest of the gap is the Deck's power split: with the GPU clock set manually, the same build runs at 14.3 fps. `tests/run_all.py`: no new failures against `main`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Joey-Magnus (Migrated from github.com) requested changes 2026-11-11 20:08:05 +00:00
Joey-Magnus (Migrated from github.com) left a comment

The measurements make a good case: on Linux the yield loop only burns CPU, and on the Deck that power comes out of the GPU's share. I'd like this fix in. But main changed this exact block an hour before the PR was opened, so it can't merge as it is.

blocking: merge conflict in host/hos_thread.c with #2263 (4d488bc0). That PR moved the decision into hos_short_sleep_timer(asked, threads) and gave sleep_ns a guest argument. Short sleeps can now go three ways: all yield; the game's own sleeps (SleepThread, through hos_sleep_logged) wait on the timer while the host's yield; or all wait on the timer. git merge-tree against origin/main conflicts in this hunk. Rather than resolving the conflict line by line, I'd fold the Linux rule into that design. For example, on non-Windows, non-web builds, have the unset case return 1 (or 2, see below) instead of going through the processor count, and keep hos_short_sleep_timer the single place the choice is made.

blocking: host/hos_thread.c:874 reads the switch with atoi(getenv(...)) != 0. Since #2042, switches follow env_on/env_value_off, and HOS_SHORT_SLEEP_TIMER=on (or true/yes) would parse as 0 here and turn the timer off, the opposite of what was asked. Main's tests/test_short_sleep_timer.py has a broken variant for exactly this ("read as a number instead of by env_on's rule"). Reusing hos_short_sleep_timer fixes it for free.

should fix: decide on purpose which sleeps the timer applies to. As written, the PR puts every sleep under 1 ms on the Linux timer, including the host's: the frame limiter (host/nn_vi.c:30, :177) and the audio renderer (host/nn_audio.c:1168). #2263 deliberately kept those yielding so that pacing isn't late. With Linux's ~50 µs slack that may well be fine, but it is a separate change from the job-worker fix you measured. Either use mode 1 (only the game's sleeps on the timer), which covers f_800998a0, or keep mode 2 and say in the PR that frame pacing and audio were checked on the Deck.

should fix: tests. tests/test_short_sleep_timer.py forces _WIN32 on the cut code (line 53), so it would not see the Linux default at all. Please add cases for the non-Windows build: unset means the timer, and 0/off means yield. Also run that test against the rebased head. Right now CI has only run the title check; the other workflows are waiting for approval.

minor: the log line in main's version says "where Windows has the high-resolution one". If Linux now logs through the same path, word it for both platforms. The measurement covered the Deck only. A desktop Linux run with many threads (where #2263 found yielding helps TOTK-style 20 µs waits on Windows) would show whether "always the timer on Linux" or "the timer by processor count" is the better rule. The ~50 µs slack makes me expect the former, but it's worth one number.

The measurements make a good case: on Linux the yield loop only burns CPU, and on the Deck that power comes out of the GPU's share. I'd like this fix in. But main changed this exact block an hour before the PR was opened, so it can't merge as it is. **blocking**: merge conflict in `host/hos_thread.c` with #2263 (4d488bc0). That PR moved the decision into `hos_short_sleep_timer(asked, threads)` and gave `sleep_ns` a `guest` argument. Short sleeps can now go three ways: all yield; the game's own sleeps (`SleepThread`, through `hos_sleep_logged`) wait on the timer while the host's yield; or all wait on the timer. `git merge-tree` against `origin/main` conflicts in this hunk. Rather than resolving the conflict line by line, I'd fold the Linux rule into that design. For example, on non-Windows, non-web builds, have the unset case return 1 (or 2, see below) instead of going through the processor count, and keep `hos_short_sleep_timer` the single place the choice is made. **blocking**: `host/hos_thread.c:874` reads the switch with `atoi(getenv(...)) != 0`. Since #2042, switches follow `env_on`/`env_value_off`, and `HOS_SHORT_SLEEP_TIMER=on` (or `true`/`yes`) would parse as 0 here and turn the timer off, the opposite of what was asked. Main's `tests/test_short_sleep_timer.py` has a broken variant for exactly this ("read as a number instead of by env_on's rule"). Reusing `hos_short_sleep_timer` fixes it for free. **should fix**: decide on purpose which sleeps the timer applies to. As written, the PR puts every sleep under 1 ms on the Linux timer, including the host's: the frame limiter (`host/nn_vi.c:30`, `:177`) and the audio renderer (`host/nn_audio.c:1168`). #2263 deliberately kept those yielding so that pacing isn't late. With Linux's ~50 µs slack that may well be fine, but it is a separate change from the job-worker fix you measured. Either use mode 1 (only the game's sleeps on the timer), which covers `f_800998a0`, or keep mode 2 and say in the PR that frame pacing and audio were checked on the Deck. **should fix**: tests. `tests/test_short_sleep_timer.py` forces `_WIN32` on the cut code (line 53), so it would not see the Linux default at all. Please add cases for the non-Windows build: unset means the timer, and `0`/`off` means yield. Also run that test against the rebased head. Right now CI has only run the title check; the other workflows are waiting for approval. **minor**: the log line in main's version says "where Windows has the high-resolution one". If Linux now logs through the same path, word it for both platforms. The measurement covered the Deck only. A desktop Linux run with many threads (where #2263 found yielding helps TOTK-style 20 µs waits on Windows) would show whether "always the timer on Linux" or "the timer by processor count" is the better rule. The ~50 µs slack makes me expect the former, but it's worth one number.
Joey-Magnus (Migrated from github.com) reviewed 2026-11-11 21:26:29 +00:00
Joey-Magnus (Migrated from github.com) left a comment

It conflicts in host/hos_thread.c with #2263's hos_short_sleep_timer, so it needs a rebase onto that. Two other things:

  • atoi(getenv("HOS_SHORT_SLEEP_TIMER")) != 0 makes =on turn the timer off, which tests/test_short_sleep_timer.py exists to catch.
  • As written, the change also moves the frame limiter (nn_vi.c) and audio (nn_audio.c) sleeps onto the timer, not just the job waits that were measured.
It conflicts in `host/hos_thread.c` with #2263's `hos_short_sleep_timer`, so it needs a rebase onto that. Two other things: - `atoi(getenv("HOS_SHORT_SLEEP_TIMER")) != 0` makes `=on` turn the timer off, which `tests/test_short_sleep_timer.py` exists to catch. - As written, the change also moves the frame limiter (`nn_vi.c`) and audio (`nn_audio.c`) sleeps onto the timer, not just the job waits that were measured.
hckrman101 commented 2026-11-11 22:55:47 +00:00 (Migrated from github.com)

This now conflicts with main in host/hos_thread.c. Could you merge main into the branch (or rebase) when you get a chance? I'll take another look once it's up to date.

This now conflicts with main in `host/hos_thread.c`. Could you merge main into the branch (or rebase) when you get a chance? I'll take another look once it's up to date.
Kolkka commented 2026-11-11 23:40:16 +00:00 (Migrated from github.com)

Merged main into the branch.

Merged main into the branch.
github-sync closed this pull request 2026-11-12 13:39:21 +00:00
Owner

@Kolkka commented on GitHub · 2026-11-12 12:39 UTC · original

Superseded by #2637

> [@Kolkka](https://github.com/Kolkka) commented on GitHub · 2026-11-12 12:39 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2313#issuecomment-6823846651) Superseded by #2637 <!-- github-sync:c:6823846651 -->
Commenting is not possible because the repository is archived.
No description provided.