Skip to content

perf(agc): spin before blocking on the GPU mutex and the draw committer hand-offs - #6632

Merged
oneandonlydean merged 2 commits into
boykopovar:mainfrom
AlxcBTW:perf/handoff-spin
Oct 17, 2026
Merged

oneandonlydean merged 2 commits into
boykopovar:mainfrom
AlxcBTW:perf/handoff-spin

Conversation

@AlxcBTW

@AlxcBTW AlxcBTW commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

What

Companion: #6631 (independent of this one; the two together give more than the sum, see Tested).

At the Minecraft Dungeons II (PPSA16064) title screen (3840x2160, about 8 FPS) the GPU idles for 44 ms of every 126 ms frame. A trace of the plateau shows that for 32 % of the window the GPU waited for a batch that had not been submitted yet, and that the three threads which submit it are not out of CPU: the queue 0 worker is 34 % busy, the draw committer 22 %, the compute worker 21 %, and every other thread under 2 %. They hand work to each other 49,000 times per 10 s (GPU mutex, the committer's condition variable, DrawPipeline::Drain), and each hand-off that has to wait costs a kernel wake-up (its latency was not timed on its own; the effect is what the rows below measure). The queue 0 worker spends 39 % of its time waiting for the committer in Drain, the committer is idle for half of the window, and the GPU mutex is held 79 % of the time.

  • GpuMutexType::lock retries try_lock for a window before it blocks; the committer polls its item count for the window before it waits on its condition variable; DrawPipeline::Drain polls the item count for twice the window before it waits for the committer. A hand-off that completes within the window costs no wake-up. HandoffSpin.hpp holds the policy (HandoffSpinWindowFor) and the two helpers (SpinUntil, LockSpinning).
  • Ordering, the lock's recursion and its statistics are unchanged; after the window the thread blocks exactly as before.
  • The window is 150 us when the process may run on more than 8 logical processors (its affinity mask, CpuTopology; hardware_concurrency() where the mask is unknown) and 0 otherwise, where spinning threads would compete with the thread they wait for, which may be a sibling hyperthread of the spinner. APS5_HANDOFF_SPIN_US=<n> sets it (0 turns it off, values above 10000 are limited to 10000). docs/dev/ARCHITECTURE.md: one paragraph.

Tested

Windows 11, MinGW-w64 GCC 15.2, NVIDIA GeForce RTX 5070 Laptop GPU (driver 617.42), 20 logical processors (Ryzen AI 9 365) for the title rows; ubuntu-latest (4 logical processors, lavapipe) for the tests. The title rows ran on this change on top of 44df21632; the branch is squashed onto 5d9dcc42d (the 59 commits in between merge without a conflict and the changed lines are the same).

  • Test agc_handoff_spin (new, no GPU): the window policy for usable processor counts (9 spin, 8 and 4 do not) and the processor count itself, and APS5_HANDOFF_SPIN_US values (unset, 0, number, negative, text, above the limit), SpinUntil (ready at once, zero window, ready on the fifth evaluation, never ready for a window, ready from another thread), four threads contending on std::mutex and std::recursive_mutex with windows of 0, 50 us, 1 ms and 150 us (never two holders, no lost increment), the fallback to the blocking lock when the holder outlasts the window, recursion. Fork CI (Linux, lavapipe, ubuntu-latest; this squashed head on origin/main 5d9dcc42d): https://github.com/AlxcBTW/AnyPS5/actions/runs/38817879745, agc_handoff_spin passes in 0.05 s. After the guard change (head 23a804e39, on origin/main eb901bcf4): https://github.com/AlxcBTW/AnyPS5/actions/runs/38943017617, agc_handoff_spin passes in 0.04 s and the driver and recorder tests pass with APS5_HANDOFF_SPIN_US=150; the one failure is agc_driver_graphics, which also fails without this change.
  • Driver tests with the spin on: the 4-processor runner would not spin by default, so the fork workflow sets APS5_HANDOFF_SPIN_US=150; the 455 tests of ^(agc_handoff_spin|agc_driver_|agc_recorder) pass, including the pipelined draw tests that drain the committer (same run).
  • Build: Windows libs and relinker compile on the fork CI (MinGW-w64 GCC): https://github.com/AlxcBTW/AnyPS5/actions/runs/38817908816 (this squashed head). tools/check_conventions.py --base origin/main passes.
  • Title rows (Minecraft Dungeons II title screen, 3840x2160, 150 s rows, the first three 10 s windows of each row dropped, APS5_TEXTURE_CACHE_MIB=3072, APS5_PROFILE_DRAW=1, APS5_PROFILE_GPU=1, APS5_NO_PAD_INPUT=1; the main arm is the binary with both switches off, which an earlier session measured against a build of origin/main: 7.80 against 7.93 FPS):
arm (one binary, both branches merged; the arms are switches) rows / windows FPS GPU busy per frame GPU idle per frame
main: APS5_NO_DCC_PRESYNC=1 APS5_HANDOFF_SPIN_US=0 2 / 16 7.35 80.5 ms 54.7 ms
companion PR alone: APS5_HANDOFF_SPIN_US=0 2 / 16 7.64 (+4.0 %) 80.0 ms 49.8 ms
this PR alone: APS5_NO_DCC_PRESYNC=1 2 / 16 7.57 (+3.0 %) 81.2 ms 46.3 ms
both 1 / 8 8.55 (+16.3 %) 79.6 ms 36.3 ms

Rows run in the order both, companion, this PR, main, main, this PR, companion, both. The warm-up row and the first both row, the first two after the relink, found no title windows (probably cold caches), so both has one row of eight windows. Rows of identical code differ by up to 0.8 FPS (the two main rows read 7.08 and 7.62, the two rows of this PR alone 7.16 and 7.97), about 10 %, so a single row cannot resolve an effect of that size; the pooled arms and the repetition in other sessions can. The same scene on the same machine in two earlier sessions: origin/main against the first commit of the companion with this PR, alternating rows, 7.93 against 8.76 FPS (+10.5 %); an earlier lab build, levers off against both levers on, 7.94 against 8.76 (+10.2 %), with the companion's lever alone at +2.6 % and this PR's at +2.9 %. The baseline therefore lands between 7.35 and 7.94 FPS from session to session, and the changed build between 8.55 and 8.76 FPS: +10 % to +16 %, +12 % on average of the three sessions. Alone, this PR gives +2.9 % and +3.0 %, the companion +2.6 % and +4.0 %; together they give more than the sum, and the GPU busy time stays at about 80 ms while its idle time per frame falls from 54.7 to 36.3 ms.
Spin window, measured with the DCC pre-sync of the companion PR on (two rows of eight windows each, anchor without both 7.88 FPS): 50 us 8.28 FPS, 150 us 8.62, 400 us 8.59; the compute worker's label-wait spin (1500 us) and a mutex priority for the queue 0 worker and the committer added nothing on top (8.53 and 8.56) and are not in this PR.

  • Performance report protocol (the scene ran once and the title was restarted, so the caches are warm; only APS5_TRACE_SECONDS=60:10 is set besides APS5_TEXTURE_CACHE_MIB=3072, APS5_NO_PAD_INPUT=1 and the trace directory; same machine, scene and session, one capture per build, no event dropped): origin/main, 81 presents in 10.0 s = 8.09 FPS, frame time p50 120.7 / p90 138.8 / p99 152.7 ms, GPU busy 79.2 ms; both PRs, 80 presents in 10.0 s = 7.97 FPS, p50 120.1 / p90 153.0 / p99 176.4 ms, GPU busy 78.5 ms. These two captures do not show the difference: a capture is 80 frames of one row, and rows of identical code differ by more than the effect (see above). The captures of the earlier session (20 s, APS5_TRACE_SECONDS=60:20, the queue 0 worker's event buffer full after 12 s, so they are flagged trace_dropped) read 8.27 FPS (p99 151.9 ms) for origin/main and 8.99 FPS (p99 130.6 ms) for the first commit of the companion with this PR (+8.7 %). The summary lines of the four captures follow as a comment; the traces are kept and available on request.
  • CPU cost (per-CPU load of the whole machine, 100 s of the plateau, spin and the companion's pre-sync on against both off): 4.24 against 3.97 of 20 logical processors busy (Zen 5 cores 27 % against 24 %, Zen 5c cores 18 % against 17 %): +0.27 cores for +9 % FPS.
  • Spin numbers: all title rows and the spin window sweep above were measured on 20 logical processors, all usable by the process, so they are unchanged by the guard.
  • Not checked: machines with 8 or fewer usable logical processors (the spin is off there by default), a restricted affinity mask on a running title, other titles, GPUs and drivers, macOS.

Checklist

  • Based on current main; no other open PR implements the same functions
  • One topic per PR; follow-ups go in a new PR
  • No comments in code except technical debt
  • Unimplemented paths throw (NotImplemented_nid_no_patch); silent stubs are listed under Silent stubs in their library's technical debt file
  • No notes, investigation .md files or images added to the repository (attach them to this PR)
  • New third-party code is a submodule built from source
  • Generic behaviour, not specific to one title
  • Depends on: none
  • Unblocks: none
  • GPUs tested: NVIDIA GeForce RTX 5070 Laptop GPU, driver 617.42, Windows 11 (title rows); lavapipe (Mesa) on ubuntu-latest (tests)
  • AI-assisted: yes

@AlxcBTW

AlxcBTW commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

The performance report captures for this PR and #6631 are in #6631 (comment) (one comment for both PRs, since the captures ran with both levers together and with neither).

AI-assisted: yes

@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Other open pull requests touch the same code. Merging one will break the other, or both implement the same thing. If this one builds on them, list them in Depends on.

Pull request Conflicting files (lines on main) Same exports Same new files
PR 6208 docs/dev/ARCHITECTURE.md L100-101
PR 6341 core/libs/prx/libSceAgcDriver/Execution/src/Driver/Draw/DrawPipeline.cpp L3
PR 6931 docs/dev/ARCHITECTURE.md L100-101

Some comparisons could not be completed because a pull request conflicts with the base branch. Review these shared files manually before concluding that there is no overlap.

Pull request Shared files not compared
PR 1341 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 1368 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 1395 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 2434 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 4339 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 4448 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
PR 4650 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 4891 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 5021 core/libs/prx/libSceAgcDriver/CMakeLists.txt
core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
PR 5371 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 5426 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 5431 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 5499 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 5925 core/libs/prx/libSceAgcDriver/CMakeLists.txt
docs/dev/ARCHITECTURE.md
PR 5958 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 5998 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6012 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6044 core/libs/prx/libSceAgcDriver/CMakeLists.txt
docs/dev/ARCHITECTURE.md
PR 6130 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6146 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6176 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6188 core/libs/prx/libSceAgcDriver/CMakeLists.txt
core/libs/prx/libSceAgcDriver/Execution/src/Driver/Draw/DrawPipeline.cpp
core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
PR 6222 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
PR 6243 docs/dev/ARCHITECTURE.md
PR 6259 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6273 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
PR 6292 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
PR 6300 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6320 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6412 core/libs/prx/libSceAgcDriver/CMakeLists.txt
docs/dev/ARCHITECTURE.md
PR 6454 core/libs/prx/libSceAgcDriver/CMakeLists.txt
core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
docs/dev/ARCHITECTURE.md
PR 6481 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6535 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6538 core/libs/prx/libSceAgcDriver/Execution/src/Driver/Draw/DrawPipeline.cpp
PR 6601 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6615 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
PR 6741 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6747 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6788 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6808 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6887 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6965 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6973 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6987 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6989 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 6995 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 7002 core/libs/prx/libSceAgcDriver/CMakeLists.txt
PR 7201 core/libs/prx/libSceAgcDriver/CMakeLists.txt
core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
PR 7223 core/libs/prx/libSceAgcDriver/CMakeLists.txt

@AlxcBTW

AlxcBTW commented Oct 16, 2026

Copy link
Copy Markdown
Contributor Author

Second-machine numbers for this PR and #6631 are on #6631. They were measured by @parl0v on an i5-14400F with an RTX 4060 8 GB, at the Minecraft Dungeons II title over three alternating rounds. This PR alone gives +3.2 % against main (+4.3 / +4.1 / +1.0 % per round), and both PRs together give +10.3 %. The gain is in the idle gaps, and there were no fatal lines or wait timeouts. Link: #6631

@github-actions github-actions Bot added the needs rebase Conflicts with main, rebase needed label Oct 16, 2026
…er hand-offs

At the Minecraft Dungeons II title the queue-0 worker, the draw committer and the compute worker
hand work to each other 49,000 times per 10 s through the GPU mutex, the committer's condition
variable and DrawPipeline::Drain, and each hand-off that has to wait costs a kernel wake-up. The
three workers are busy 21-34 % of the time: the GPU idles because the chain of hand-offs is slow,
not because a thread runs out of CPU.

GpuMutexType::lock retries try_lock for a window before it blocks, the committer polls its item
count for the window before it waits on its condition variable, and Drain polls for twice the
window before it waits for the committer. A hand-off that completes within the window costs no
wake-up. The window is 150 us on machines with at least 8 logical processors and 0 below that;
APS5_HANDOFF_SPIN_US sets it (0 turns it off). Ordering and the lock's semantics are unchanged.
The test agc_handoff_spin covers the window policy, the spin helpers and contended locks, and
docs/dev/ARCHITECTURE.md describes the spin.
@AlxcBTW

AlxcBTW commented Oct 16, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main eb901bc, which has #1747, #4943, #4649, #5875, #5872 and #6784. Head is now e74484e. The only conflict was the queue-0 paragraph of docs/dev/ARCHITECTURE.md. I kept upstream's text there, including #1747's sentence on pipelined dispatches, and put this PR's paragraph next to it. The code merged without conflicts and is unchanged.

Fork CI, Linux with lavapipe, every agc_ test: 626 of 627 pass (run 38899806599). The one failure is agc_driver_graphics, which fails on main + #6784 without this PR as well (run 38898087865). The game numbers on this PR and on #6631 were measured on the earlier base and were not re-run on this one.

AI-assisted: yes

@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Oct 16, 2026
…ff spin guard

The automatic window needed 8 logical processors, which a 4-core CPU with SMT passes while the waited-for thread may run on a sibling thread, and ignored the process affinity. It now needs more than 8 processors in the affinity mask (CpuTopology), falling back to hardware_concurrency.
@Raghav314

Raghav314 commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Data point from #5867 (now closed in favour of this), which polled only in the committer's wait, for the core-count policy here. Hades II (PPSA36082), level UI/menu, Linux, 7800X3D (8 cores / 16 threads), RTX 4090, pipelined draws at the default depth. One build that switched a 50 us poll off and on every 15 s in one sitting, 4 windows each, MangoHud fps per window:

off on
all 16 threads: mean fps (windows) 53.2 (52.7, 53.0, 53.3, 53.7) 54.8 (55.9, 53.6, 54.2, 55.4)
all 16 threads: p99 frame time 24.1 ms 23.6 ms
taskset -c 0-3: mean fps 49.4 49.5
taskset -c 0-3: p99 frame time 26.8 ms 31.4 ms

With cores to spare the poll won every pair; on 4 cores it gave nothing and lengthened the tail, which is what a window of 0 at 8 or fewer logical processors avoids.

LLM assistance used.

@teepean

teepean commented Oct 18, 2026

Copy link
Copy Markdown

Data point from a CPU with two L3 domains, where the spin costs Stray (PPSA02100) about 10 %. The committer's poll accounts for about half of that.

Setup. Linux (CachyOS, kernel 7.2.10), Ryzen 9 9950X (16 cores in two CCDs, each with its own L3: CPUs 0-7/16-23 and 8-15/24-31), RTX 3090 (NVIDIA 615.78.08), GCC 16.2.1 Release, 3840x2160, the scx_lavd scheduler (sched_ext, lavd 1.1.3 with --autopilot), stock engine.ini. Stray stands at the same save. The numbers come from APS5_TRACE_SECONDS=185:20 summaries (the runner redirects the log to a file), one run at a time, with the arms alternating. The process may run on all 32 logical processors, so the window is 150 us. Stray is CPU-bound here: GPU busy per present is 89 to 99 ms in every arm below.

On and off, frame p50 and presents in the 20 s window:

arm frame p50 (ms) presents
main f1af6c5 190.1, 221.6, 191.4 107, 96, 104
the same binary with APS5_HANDOFF_SPIN_US=0 169.6, 155.7, 169.4, 168.6 111, 120, 111, 113
main b51c65d (before #6631 and this PR) 168.4, 159.5, 170.3, 170.7 114, 119, 114, 111

One more main f1af6c5 run hit the #5372 import race (about 1 fps) and is left out.

Per site. These runs use a local build of f1af6c5 that reads one window per site from the environment. That build is not proposed. Three alternating rounds:

arm frame p50 (ms) presents presents vs all on
all on 164.8, 194.1, 197.5 108, 103, 103
committer poll off 164.2, 168.8, 170.5 112, 112, 107 +5.4 %
GPU mutex spin off 182.0, 185.2, 202.9 110, 109, 102 +2.2 %
Drain poll off 162.5, 190.8, 192.0 110, 107, 102 +1.6 %
all off 167.3, 168.7, 155.9 114, 115, 121 +11.5 %

Where the cost comes from. I sampled per-thread CPU time and the CPU each thread last ran on from /proc, once a second at 140-170 s of every run.

  • With its poll, the committer uses 84-86 % of a CPU and blocks 14,700-15,400 times a second. Without it, it uses 72-74 % and blocks 27,400-28,100 times. So the poll does catch about 12,500 hand-offs a second.
  • The CPU the spin burns is not what costs: with only the mutex spin off, the process uses more CPU (282 % against 277 %), and its presents are still 2 % higher.
  • What changes is where the threads run. With all spins on, the committer was on the second CCD in 41 % of the samples and the thread that calls Drain in 42 %. With all off, they were there in 6 % and 16 %, and with only the committer poll off in 11 % and 20 %.
  • The work of queue worker 0 gets slower with them (per frame, all on against all off): Driver.Draw 124 against 106 ms, shader_compile_and_link 76 against 62 ms.

My guess is that a hand-off partner that blocks less often looks less latency-critical to lavd, which then moves it off the best-ranked cores, and those are all on CCD0 here. I have not checked that in lavd's code.

On one CCD the spin helps. I ran the same build with the whole process on CCD0 (taskset -c 0-7,16-23). That is 16 logical processors, so the window stays 150 us. Three alternating rounds; one run of each CCD0 arm hit the import race and is left out:

arm frame p50 (ms) presents
unpinned, spin on 187.0, 168.4, 197.6 107, 110, 104
unpinned, spin off 172.9, 157.0, 171.1 110, 121, 112
CCD0, spin on 157.2, 145.7 127, 136
CCD0, spin off 168.8, 166.8 117, 119
  • On CCD0 the spin gives +11 % presents (131.5 against 118.0).
  • CCD0 with the spin is 23 % above unpinned with the spin, and its frames are steadier: p90 152-164 ms against 221-227 ms.
  • On CCD0 the committer and the Drain caller change CPU 26-196 times a second. Unpinned with the spin, they change CPU 3,300-6,900 times.

So the spin itself is fine here. The cost comes from the scheduler spreading the hand-off partners over both L3 domains, and keeping the process in one domain would remove it without turning the spin off.

EEVDF does this by itself. With sched_ext off, the kernel's scheduler keeps every busy thread on CCD0 (0 of 90-97 samples on CCD1). On main 1c523bd, which has the spin, Stray runs at 149.5 ms p50 and 132.7 presents (three runs). That is where CCD0 with the spin is under scx_lavd. I didn't repeat the per-site on/off under EEVDF.

Not checked: Windows on this machine, other titles.

AI-assisted: yes (analysis and text with Claude Code; the runs are from my machine).

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants