Skip to content

Keep the graphics lock between graphics hooks (main thread) - #220

Merged
YuutaTsubasa merged 3 commits into
mainfrom
claude/perf-graphics-lock
Oct 20, 2026
Merged

YuutaTsubasa merged 3 commits into
mainfrom
claude/perf-graphics-lock

Conversation

@YuutaTsubasa

Copy link
Copy Markdown
Owner

Follow-up to #215, which measured the graphics lock as ~2% of the main thread on an AYN Thor and left it alone.

What the lock protects and who takes it

The graphics lock keeps one thread at a time in the native device: every Direct3D replacement (SFR_GRAPHICS_HOOK, SFR_GRAPHICS_HOST_HOOK) takes it at its observed entry and gives it back when its HookScope ends. HostGraphicsScope takes it for host code outside those hooks that changes the device (synchronize_resource_memory, the renderer's invalidate after MmFreePhysicalMemory, the Avatar model's draw). Lock order: menu, input, graphics; graphics before the permits.

New SFR_GRAPHICS_LOCK_STATS=1 prints GRAPHICS_LOCK every 120 frames. Full Dolphin Resort races on Windows and the Thor:

menus race
main-thread acquisitions per frame ~700 14,000-20,600
acquisitions that had to wait 0 0
acquisitions by other threads none none
HostGraphicsScope <1 0
no import, other hook or permit checkpoint since the last release 85% 91%

In a race the lock belongs to the main thread alone and is never contended. The cost is the uncontended pair itself: 33 ns on the Thor, 18 ns on the desktop. That compares with 14.5 ns and 6 ns for a bare atomic pair. Bionic's pthread_mutex_trylock/unlock go through the PLT, check the mutex type and then run the outline atomics __aarch64_cas2_acq and __aarch64_swp2_rel.

Change

SFR_GRAPHICS_LOCK_DEFER (on by default; 0 turns it off): when a graphics hook returns, the main thread keeps the lock, and the next graphics hook takes it over without an atomic. Outside a hook the lock is then held only while generated code runs. It is given back before anything that may wait or take another lock:

  • an import (dispatch_import)
  • any other hook: HookScope, SFR_CONCURRENT_HOOK, or the observed entry of a hook, before its menu or input lock
  • the permit's checkpoint, at least every 256 entries and loop labels and before the permit can hand off the core
  • a slow memory access
  • traced_host_wait

HostGraphicsScope takes a kept lock over, so nothing inside it gives the lock back early. While another thread waits for the lock, it is given back at every hook's return as before, and a waiter never waits longer than the main thread's next checkpoint. The lock order does not change, and the thread never waits or takes another lock while it keeps the graphics lock outside a hook.

Everything else that generated code calls is a memory, vector or reservation helper (leaf locks only), read_time_base, the job mid-asm hooks (whose waits go through traced_host_wait) or synchronize_resource_memory. Register save/restore helpers never wait, so they become SFR_REGISTER_HOOK and skip the check. The new flags sit in GuestEntryState's tail padding: the struct stays 48 bytes (static_assert), so the generated code's offsets do not move. With the lock kept, a race frame takes it ~1,100-1,500 times instead of ~16,000. Also: parallel_function_entry now returns before reading the permit's thread_local when the subsystem locks leave it nothing to do.

Thor A/B

simpleperf on the main thread, 40 s of the moving race from about frame 13800. Both runs use the same APK, one with SFR_GRAPHICS_LOCK_DEFER=0:

lock off kept
__aarch64_cas2_acq + __aarch64_swp2_rel 2.44% (0.35 ms/frame) 0.87% (0.12 ms/frame)
pthread_mutex_* + std::mutex 1.17% (0.17 ms/frame) 0.44% (0.06 ms/frame)
mutex total 0.52 ms/frame 0.18 ms/frame
main-thread CPU in the 40 s 34.2 s 32.6 s

The remaining 0.18 ms is other locks: critical sections, the layout's shared_mutex and events.

HOST_THREADS frame times (frames 13000-21000) are too noisy to show the ~0.34 ms. Between runs the main thread moves between the X3 and the A715s, and drawn frames sit at the 16.7 ms budget, so skipped swings from 34 to 176 for the same APK:

Build drawn_ms / undrawn_ms / cpu_ms / skipped of 9600
release-equivalent (0.10.13 code; one run with stats on) 16.03/8.60/13.85/34, 15.99/9.66/14.33/176
kept off (profiled) 15.89/9.53/14.24/157
kept 16.23/9.88/14.04/168, 15.62/9.42/14.26/117, 16.18/8.97/13.70/52 (profiled), 15.87/9.33/13.91/87

Tests

  • Unit tests:
    • Windows ctest: 164/165 in the full run. guest_threads hit its 10 s timeout under -j 8 while the machine was loaded; it then passed alone and 30 times in a row.
    • Linux (WSL, clang-15) ctest: 158/158.
  • Android arm64 build (API 29), packaged as test APKs (versionCode 61).
  • Windows Vulkan race with the Khronos validation layer (loader confirmed it inserted): race to present 17000, 0 validation messages, 455/455 pipelines.
  • Windows D3D12 race to present 17000, 465/465 pipelines.
  • Thor, all to the present limit at speed ~1.0, with no hang, stop or LOCK_LONG_WAIT: four races with the lock kept and one with it off.
  • Screenshots: both backends and the Thor render normally. Two fixed-step Windows races of the same build already differ at the same presents (AI positions) as much as kept vs. not kept, so the pictures were compared by eye.

Risks

  • The safety argument relies on every place where host code can wait or take a lock being one of the give-back points listed above. Any new host entry point added later must keep that true: a new SFR_CONCURRENT_HOOK or SFR_HOOK covers itself through its macro, but a new mid-asm hook that waits must use traced_host_wait or give the lock back itself.
  • Only the free race was measured. Other modes (Avatar on, Grand Prix loading, 2P) were not counted for other-thread acquisitions; if another thread does take the lock there, the waiter path gives it back at every hook return as before.
  • SFR_GRAPHICS_LOCK_DEFER=0 restores the old behaviour.

🤖 Generated with Claude Code

YuutaTsubasa and others added 2 commits October 20, 2026 21:10
A race frame enters about 15,000-20,000 graphics hooks on the main thread,
each taking and giving back the graphics lock (an uncontended std::mutex:
33 ns a pair on an AYN Thor, 18 ns on the desktop). SFR_GRAPHICS_LOCK_STATS=1
counted it over full races on Windows and the Thor: no other thread takes
the lock in a race and no acquisition ever waited, and 91% of them follow
the previous release with no import, other hook or permit checkpoint in
between.

SFR_GRAPHICS_LOCK_DEFER (on unless 0): the main thread keeps the lock when
a graphics hook returns and the next one takes it over without an atomic.
Outside a hook it is then held only while generated code runs, and it is
given back before anything that may wait or take another lock: an import,
any other hook (HookScope, SFR_CONCURRENT_HOOK, the observed entry of a
hook), the permit's checkpoint (at least every 256 entries and loop
labels), a slow memory access and traced_host_wait. While another thread
waits for the lock it is given back at every hook's return as before;
HostGraphicsScope takes a kept lock over. The lock order (menu, input,
graphics; graphics before the permits) is unchanged. The register save and
restore helpers, which never wait, use SFR_REGISTER_HOOK without the check.

Also: parallel_function_entry returns before reading the permit's
thread_local when the subsystem locks leave nothing to do (every graphics
hook paid that TLS access on Android).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 20, 2026 15:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@YuutaTsubasa
YuutaTsubasa merged commit 5167bd6 into main Oct 20, 2026
4 checks passed
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