Skip to content

perf(host): three per-call costs on hot paths removed (own-thread lookups, a Signal with no waiter, switches read once) - #2708

Open
klimovich008 wants to merge 6 commits into
NotRexed:mainfrom
klimovich008:perf/os-small-calls
Open

klimovich008 wants to merge 6 commits into
NotRexed:mainfrom
klimovich008:perf/os-small-calls

Conversation

@klimovich008

@klimovich008 klimovich008 commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

The game calls a few nn::os and host paths thousands of times a frame, and each call does work whose answer is already known. Each change below removes that work and keeps its result. They are small, and together they are hard to measure apart (see Measured).

The change

  • The calling thread's own ThreadType (host/hos_thread.c thread_arg). GetThreadPriority, GetThreadId, ChangeThreadPriority and the others scanned every thread under threads_lock. The game asks about its own thread most of the time (GetThreadPriority(GetCurrentThread()), about 2,400 a frame in Hyrule Field).
    • When the ThreadType is the caller's own, the caller's record answers without the scan.
    • Any other ThreadType is looked up as before.
    • Same result, so no switch.
  • SignalConditionVariable with no waiter (host/nn_os_sync.c). It returns at once when the condition variable's waiter count is 0, without the lock pair and wake call. HOS_CV_SIGNAL_ALWAYS=1: as before.
    • Ordering: a waiter counts itself in (cv_enter, a seq-cst add under the condition variable's lock) before it gives the guest mutex back (mutex_release_all). A signaller that holds the mutex therefore sees the waiter, and the waiter cannot be skipped.
    • A signaller that does not hold the mutex could already miss a waiter that had not yet started to wait, before this change as well (that is the condition-variable contract).
  • Switches read once. These were read from the environment on every call: menu_vsync (RX_VK_FIFO / RX_VK_IMMEDIATE, twice a present) and HOS_FS_OVERLAY (every OpenFile). They hold for a whole run. Each is now read once and published atomically (HOS_FS_OVERLAY as one atomic pointer).
    • RX_VK_DUMP_STAGES stays a per-read getenv: vk_shader.cpp is part of the shader-cache stamp, so changing it would make every player's prepared shaders stale.
    • RX_VK_TAAU_KEYS stays a per-dispatch read: tests/test_taau_env_lookup.py pins that.

Measured

Provisional: measured on 57ed3aa, which predates #2602's fix of a main regression (41 → 53 fps on this test); to be re-measured on 4678050.

Windows 11, Ryzen 7 5700X3D (16 logical processors), RX 9070 XT, AMD driver 26.10.2. Standard Hyrule Field test: right stick held right from the load, HOS_FPS_MAX=240, 2560x720, 20 s window, warm caches, 6 alternating pairs, base main 57ed3aa.

These changes were measured only together with #2709: CPU a frame 128.4 → 122.8 ms (−4.4 %), fps 39.6 → 40.1, no frames over 100 ms, no vcore escapes, game clock 1.000. The arms with only one of #2709's two changes and HOS_CV_SIGNAL_ALWAYS=1 read 124.5–124.9 ms. That does not separate the Signal change from the other two, and the own-thread lookup and the switch reads have no switch to compare. This PR's own share is not measured.

Tested

  • tests/test_thread_arg_self.py (new): thread_arg from the source, over own and other ThreadTypes. Broken variants must fail: every call scanning, the caller's record returned for any ThreadType.
  • tests/test_cv_signal_early.py (new): Signal over waiter counts, with and without HOS_CV_SIGNAL_ALWAYS. Broken variants must fail: the early return taken even with HOS_CV_SIGNAL_ALWAYS=1, the early return reading the wrong count, a waiter that does not count itself in. A structural check: cv_enter comes before mutex_release_all in both waits.
  • tests/test_vsync_row.py: one process per switch setting now (the value is read once), with a negative control.
  • tests/run_all.py; syntax compile of every changed host/*.c; a gpt-6.1-sol review (three rounds, last clean).

Not tested

  • This PR alone in the game; other scenes; Linux.

🤖 Generated with Claude Code

klimovich008 and others added 5 commits November 12, 2026 08:20
…skips the scan of every thread

thread_arg (GetThreadPriority, GetThreadId, ChangeThreadPriority...) scanned every thread under threads_lock; the game
asks about its own thread most of the time (GetThreadPriority(GetCurrentThread()), ~2,400 a frame in Hyrule). The
calling thread's record answers that, compared with its current ThreadType.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The waiter count is read atomically before the lock pair and wake call; waiters count themselves in under the
condition variable's lock before they give the guest mutex back, so a signaller holding the mutex sees them.
HOS_CV_SIGNAL_ALWAYS=1: as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
menu_vsync (two getenv a present), HOS_FS_OVERLAY (every OpenFile) and RX_VK_DUMP_STAGES (every code read) asked the
environment each time. They are set for a whole run; the V-Sync fixture now runs a process per switch setting.
(RX_VK_TAAU_KEYS stays per dispatch: tests/test_taau_env_lookup.py pins that read, about once a frame.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(sol review: two first OpenFile calls could see the flag set before the pointer.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(sol review round 2: the first signals of several threads read and wrote it at once.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vk_shader.cpp is hashed into the shader-cache stamp (nx2/build.py), so any change there makes every player's translated
programs and prepared pipelines stale: a full re-prepare for a debug switch on the translation path, which does not run
in play once programs are prepared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@Joey-Magnus Joey-Magnus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I merged this into current main (51da046) and ran tests/run_all.py: everything passes except test_lift_cache.py, which fails on main here too. The new test_thread_arg_self.py and test_cv_signal_early.py pass, and so does the reworked test_vsync_row.py.

I checked the Signal early return closely, since a missed wake would hang a thread. cv_enter counts the waiter in under cv->lk before mutex_release_all, and the gen snapshot is taken there too. So a signaller that skips at host/nn_os_sync.c:605 is ordered before the waiter's entry, and the old code would have missed that waiter in the same way. thread_arg's shortcut returns the same record hos_thread_of would. The once-read switches are run-wide.

Two notes. The Measured section still says provisional, on 57ed3aa and only together with #2709; please put in the re-measure on current main when you have it. #2765 adds BroadcastConditionVariable right below Signal. It merges cleanly with this, but it could take the same no-waiter return later.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants