perf: a frame critical-path recorder (HOS_CHAIN), and an opt-in presenter thread that takes NVIDIA's present wait off the game's thread #2325
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!2325
Loading…
Reference in a new issue
No description provided.
Delete branch "perf/game-frame-time"
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?
It was guesswork where a frame's time goes once it is spread over several threads. The samplers say what each thread runs, but not which of them the frame was waiting for. This PR adds a switch that records the game threads' waits and wake-ups, and a script that follows each frame back from its present along them. It also moves the biggest single wait the walk found, the present's wait for the GPU, off the game's thread: a presenter thread with a queue of its own, on by default.
What changes
HOS_CHAIN=<file>(diagnostic, off by default). While<file>.goexists, it records:Each record has the time, the thread, the object's address and the guest caller. The records go to
<file>when<file>.gois removed (about 2 million records at most, 64 MB). When the switch is off, the cost is one load and one branch per blocking wait and per signal.scripts/chain.py <file> [<HOS_SAMPLE_ALL file>]walks each frame back from its present: the thread that ran, the wait that stopped it, the signal that ended that wait and the thread that sent it, and so on back to the frame before. It prints:HOS_SAMPLE_ALLfile of the same run, the functions on the path. In aHOS_CHAINrun every sample carries its own time (# atlines, which the other scripts skip).A wait for a virtual core counts as the waiting thread's own time (contention, not a dependency).
scripts/crit.pyno longer counts a sample whose leaf is outside Hookshot as blocked. libc's copies and the driver's own work are CPU time; a sample is blocked only under a known wait (prof.WAIT_FRAMES). In Kokiri this moves the presentation thread from "running 45%" to "running 70%".A presenter thread, on by default (
RX_VK_PRESENT_THREAD=0turns it off):present_imagehands the present to a thread of its own. That thread waits for the copy's fence, then callsvkQueuePresentKHRon a queue of its own, and the game's thread goes on at once.VK_SHARING_MODE_CONCURRENT). The presenter never takes the replay's queue lock. A device with no second queue that can present to the window (checked on each new surface) keeps the presents on the game's thread, as before.VK_KHR_present_id+VK_KHR_present_wait, the hand-off waits until that present is shown, and the log says how long after the present that was. Under mailbox and immediate it does not wait for the present to be shown: that would cap Uncapped (#2122) at the refresh rate, since a replaced mailbox present counts as shown only at the next refresh. There the depth is the present call's return, as on the game's thread before.The FPS counter's Breakdown (#2181) counted the present's whole wait as Pacing. On NVIDIA's driver on Linux most of it is the GPU finishing the frame's copy (below), so it now goes to GPU wait when the present waited for it: the copy was not done when
vkQueuePresentKHRwas called and was done when it returned (two zero-timeout fence reads, only while the Breakdown shows; nothing waits). Any other present stays Pacing (vsync). With the presenter thread, the game thread's wait for it is split where the presenter's wait for the copy ended.Tests:
tests/test_chain.py;tests/test_present_thread.pywithtests/presenter_queue_test.cpp(the queue choice on NVIDIA's and RADV's family layouts, a taken compute queue, a lone queue);test_fps_stats.pyfor the Breakdown's split. Stubs forHOS_CHAINin the host fixtures that cuthost/code (test_multiwait_wake,test_present_trace,test_short_sleep_timer,test_vcore_escape), andtest_present_semaphorescutspresent_outup topresent_finish.Docs:
docs/development.mdlists both switches, anddocs/design.mdhas the findings below.What the walk found
Setup: RTX 3090, Ryzen 9 7900X, Linux, NVIDIA 620.70, Kokiri Forest standing, 2560x1440, about 1250 draws, recording on. Every frame is one loop through two threads:
vkQueuePresentKHR, in the driver)vkQueuePresentKHR. None of these changed it:RX_VK_NO_WRITEBACK=1, a 1280x720 output, a 240 Hz compositor,RX_VK_IMMEDIATE=1.Measured
Same PC and scene, in a private headless sway with a 240 Hz output and
HOS_FPS_MAX=240. Every timed run went through the timing lock (its own CPU complex). Other work on the machine moved single runs by 10-30%, so these are alternating runs.Presenter thread on its own queue (this version), alternating off/on under the timing lock, 25 s each. The PC was short of memory during all of these (other jobs; pages swapped in during every run), so single runs moved by 10-30% and the stalls over 100 ms include paging:
Frames over 100 ms in each 15 s after arriving in Hyrule Field: 3, 23, 14, 4, 7, 4 off; 11, 13, 6, 2, 2, 2 on. No texture upload went over its budget after the teleport in either run. Flashes (
RX_VK_FRAMELOG+scripts/framelog.py): 0 events in 2,119 frames off and 2,882 on over these four runs.The first version presented on the replay's queue under its lock. In Hyrule Field its
vkQueuePresentKHRblocked for up to hundreds of ms after the map loaded, the replay's submits waited on that lock, and the stalls went on past the load (15-60 s after arriving: 21-25 frames over 100 ms per 15 s, against 2-3 off). That is why the presenter now has a queue of its own.On an RX 580 with RADV (a correctness run, not timed: GPU-bound at ~27 fps there), the presenter took compute family 1, queue 0 with
VK_KHR_present_wait. The picture was right with it on and off, through V-Sync On, fullscreen, three resizes and minimize/restore; every swapchain was made again cleanly, with no device lost and no failed present.Presenter thread, first version (it presented on the replay's queue;
HOS_CHAINstretches, 10 s each, alternating off/on/off/on):With the present off the path, the main thread's job waits start to show on it.
Presenter thread, frame times without recording (30 s each, 4 alternating pairs):
Flashes (
RX_VK_FRAMELOG+scripts/framelog.py, same runs): 0 events in 4,945 frames off; 1 event in 6,199 frames on. That one was a single 20x20 block, darker, peak 14: not a frame-wide flash. Four more 30 s runs of this version (Kokiri, off/on/off/on): 0 events in 3,338 frames off and 3,641 on.HOS_CHAINcompiled in but off, against main (30 s each, 3 alternating pairs): median frame 18.4 / 18.7, 19.8 / 17.3 and 21.0 / 19.4 ms (main / this). The replay's submit time is the same within noise (14.0 / 14.0, 14.2 / 13.5, 17.4 / 14.8 ms). No cost shows.Tests: game-free suite 425 passed, 1 failed (
test_linux_saves_move.py, which passes when run on its own; the branch does not touch the saves).Not tested
host/uses, and the Windows sampler writes the# t0_nsline.# attimes are Linux only; on Windowschain.pyfalls back to the samples' millisecond stamps.vkQueuePresentKHRwaits for the GPU there is not known. The presenter thread is on there too (its own queue, the same rules), so it moves whatever the present waits for off the game's thread, or nothing.ParallelDecompThread) are left out of the sample attribution, since samples carry names, not thread ids.Relation to #2172
#2172 (
RX_VK_ASYNC_PRESENT=1) moves the whole native present (frame fence, acquire, blit and present) to a worker. Frames are queued in three owned images, so it adds a copy a frame and up to three frames of buffering. Its measured gain is for the combination with an external layer and a second graphics queue. The presenter thread here moves only the wait for the frame's copy and thevkQueuePresentKHRcall, one present at a time, with no extra images or copies. The two touch the same functions (present_imageandpresent_out). If both land, the presenter thread should stay off for #2172's worker presents (they report the present's result back).Next steps
Reviewed the diff and ran the presenter thread on a second NVIDIA setup, which the PR lists as untested.
Review
The presenter thread holds up. I traced each lock path:
present_image's resize/HDR/V-Sync branch,VK_ERROR_OUT_OF_DATE_KHRafter acquire,present_out's out-of-date/suboptimal tail) drains the presenter before takingdev.present_mu, or after a drain that no new hand-off can follow (present_image_muis held throughout). So the drain at the top ofcreate_swapchainalways finds the presenter idle when the caller holds the queue's lock, and there is no deadlock with the presenter's owndev.present_muon the replay's queue. The startup callers invk_main.cpprun before the presenter exists.present_commitpasses a lockedqk, so theqk.unlock()inpresent_out's drain path is safe.frames[3]: the fencepresent_imageresets is never the one the presenter is waiting on, since at most one present is out.pi.pNextholds.Small things:
mainnow (gpu/vk/vk_internal.h,gpu/vk/vk_main.cpp).hos_chain.c:recsis assigned and thenhos_chain_on = 1with no barrier between them.hos_chain_onisvolatile,recsis not, so the compiler may reorder the two, and on ARM so may the CPU; a thread can see the flag before the pointer. Fine on x86 in practice; an__atomic_store_n(&hos_chain_on, 1, __ATOMIC_RELEASE)(and an acquire load inhos_chain_note) would make it hold everywhere.queue_putnotes aSIGNALbefore it knows whether atry_onlyput will fail on a full queue. That adds a signal the walk could follow to the wrong sender, rarely.UINT64_MAX) now blocks the game thread insidepresenter_wait_idlerather than in the present. Only a note: #2407's watchdog covers that case the same way.The PR's own tests pass here:
test_chain,test_present_thread,test_fps_stats,test_multiwait_wake,test_present_traceandtest_present_semaphores.Measured: RTX 4070 Ti, Ryzen 7 9800X3D, Linux, NVIDIA 595.99.02
Branch head
60ab512, full game build. Each run was in a private KWin 6.6 (kwin_wayland --virtual, 2560x1440, swapchain in mailbox mode with V-Sync Auto) withHOS_FPS_MAX=240. Each run loaded the same save, teleported to Kokiri Forest (about 840 draws) and stood for 35 s, then teleported to Hyrule Field at the Lost Woods bridge (about 1270 draws) and stood for another 35 s. The frame time is the gap between presents (RX_VK_FRAME_LOG's timestamps), runs alternating off/on.Median frame time, ms:
* That off run never stood in the scene (4.2 ms frames), so it is left out.
Caveat: another game build and its shader preparation ran on the same PC during both series, and this CPU is a single core complex, so I could not give the game cores of its own. The pairs alternate, so both arms carry the same noise, but the absolute numbers are noisier than the PR's.
HOS_CHAINstretches were not recorded.So on a faster, nearly GPU-bound setup the presenter thread does not help. It looks right to keep it opt-in, and the "check other GPUs before default" step in the PR should probably include a GPU-bound case like this one.
github-sync referenced this pull request2026-11-12 06:22:54 +00:00