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

Merged
hckrman101 merged 8 commits from perf/game-frame-time into main 2026-11-11 22:33:02 +00:00
hckrman101 commented 2026-11-11 15:03:52 +00:00 (Migrated from github.com)

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>.go exists, it records:

    • every wait of a game thread that actually blocks: events, semaphores, message queues, mutexes, condition variables, multi-waits, the kernel's waits, and waits for a virtual core;
    • every signal that can end one of those waits;
    • every guest sleep and every present.

    Each record has the time, the thread, the object's address and the guest caller. The records go to <file> when <file>.go is 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:

    • the frame's stretches: which thread was on the path, from which wake-up to which signal;
    • time by thread and kind: running, wake-ups, waits for a virtual core, sleeps;
    • the hand-offs between threads;
    • with an HOS_SAMPLE_ALL file of the same run, the functions on the path. In a HOS_CHAIN run every sample carries its own time (# at lines, 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.py no 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=0 turns it off): present_image hands the present to a thread of its own. That thread waits for the copy's fence, then calls vkQueuePresentKHR on a queue of its own, and the game's thread goes on at once.

    • Its own queue. The device gets a second queue of the replay's family where it has one (NVIDIA's has 16), else a queue of a compute-only family (RADV has one graphics queue). A family other than the replay's shares the swapchain's images (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.
    • No frame further ahead than before. One present is out at most: the next hand-off waits for it. Under V-Sync On (FIFO), with 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.
    • Presents stay in order. A swapchain is made again, or a lost surface dropped, only once the presenter is idle, and that wait comes before the queue's lock.
    • No wait without end: the copy's fence in 100 ms steps (after 5 s the present is skipped and said), the present wait in 20 ms steps, a drain gives up after 5 s; a device lost ends any of them.
    • Not used for frame interpolation's paced presents or with Reflex on (their presents stay on the game's thread).
  • 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 vkQueuePresentKHR was 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.py with tests/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.py for the Breakdown's split. Stubs for HOS_CHAIN in the host fixtures that cut host/ code (test_multiwait_wake, test_present_trace, test_short_sleep_timer, test_vcore_escape), and test_present_semaphores cuts present_out up to present_finish.

  • Docs: docs/development.md lists both switches, and docs/design.md has 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:

Stretch ms a frame
Presentation thread, from the main thread's message to its present (the replay of the frame) 12.5
Presentation thread, the present call until the event that releases the main thread 2.7 (2.0-2.4 of it inside vkQueuePresentKHR, in the driver)
Main thread, from that event to its next message 2.6 (about 2 of it the renderer's work on its own submits: capture copies, write collection, replay)
Waits for a virtual core (presentation thread, after its present) 0.27
Hand-offs 0.004-0.005 each
  • The game's job threads (ModuleSystemWorker, BaseProcExecutor) and background threads are on the path for under 0.1 ms a frame.
  • The main thread waits about 4.5 ms a frame for the presentation thread.
  • So in this scene the game's own code is not what holds the frame back. The replay, the present and the submit work on the game's threads are. An earlier all-thread sample read "the main thread is blocked 82%" as a sign that the game side bounds the frame; the walk shows that the main thread is blocked on the presentation thread.
  • The present's wait is the GPU finishing the frame (the FPS counter's Breakdown, #2181, counted it as Pacing; fixed here). On the presenter thread it is 1.75-1.92 ms for the copy's fence, then 0.18-0.22 ms in 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:

Scene Off: median / p99 / max / sd (ms) On: median / p99 / max / sd (ms) CPU a frame, off / on
Kokiri Forest (1250 draws) 22.5 / 33.2 / 565 / 29.7 20.6 / 32.0 / 530 / 22.1 144 / 135 ms
Hyrule Field, 60 s after arriving (695 draws) 15.0 / 164 / 2797 / 97 12.5 / 24.4 / 746 / 28 65 / 61 ms

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 vkQueuePresentKHR blocked 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_CHAIN stretches, 10 s each, alternating off/on/off/on):

Run Frame (ms) Present call, median (ms)
Off 22.1 2.0
On 20.1 0.10
Off 22.9 2.1
On 19.0 0.07

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):

  • Runs with about 1250 draws: median frame 19.6, 23.6, 22.2 ms off; 16.9, 18.2, 16.0 ms on.
  • The other pair drew a lighter scene: 16.8 ms off at 951 draws, 15.3 ms on at 955 draws.

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_CHAIN compiled 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

  • Windows: nothing was built or run there.
    • The recorder is plain C with the Win32 calls the rest of host/ uses, and the Windows sampler writes the # t0_ns line.
    • The per-sample # at times are Linux only; on Windows chain.py falls back to the samples' millisecond stamps.
    • Whether vkQueuePresentKHR waits 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.
  • Timed on this PC only, and only under memory pressure (above): V-Sync On, Uncapped, frame generation, Castle Town market and window changes were not timed; the window changes and V-Sync On were checked for correctness on the RX 580.
  • Intel GPUs and other NVIDIA drivers.
  • Threads that share a name (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 the vkQueuePresentKHR call, one present at a time, with no extra images or copies. The two touch the same functions (present_image and present_out). If both land, the presenter thread should stay off for #2172's worker presents (they report the present's result back).

Next steps

  • The renderer's work on the main thread's submits (about 2 ms of its 2.6 ms on the path). Written but not measured, so not in this PR: a pre-capture that copies the dirty pages on a helper while the main thread waits for the presentation thread, so the submit copies only the pages written since. Tried and not kept: claiming the copies in small chunks, the texture shadow's copies on the submitting thread alone, and a short spin before a virtual-core wait. None of them moved the stretches past the noise.
  • GPU write-back on demand rather than a size cut-off, and real occlusion queries answered without blocking (today every query answers "visible", so the game culls nothing and the replay draws everything). Both would cut replay or GPU work. Neither was tried. The second must not bring back the sun flicker that the fixed answer cured.
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>.go` exists, it records: - every wait of a game thread that actually blocks: events, semaphores, message queues, mutexes, condition variables, multi-waits, the kernel's waits, and waits for a virtual core; - every signal that can end one of those waits; - every guest sleep and every present. Each record has the time, the thread, the object's address and the guest caller. The records go to `<file>` when `<file>.go` is 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: - the frame's stretches: which thread was on the path, from which wake-up to which signal; - time by thread and kind: running, wake-ups, waits for a virtual core, sleeps; - the hand-offs between threads; - with an `HOS_SAMPLE_ALL` file of the same run, the functions on the path. In a `HOS_CHAIN` run every sample carries its own time (`# at` lines, 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.py`** no 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=0` turns it off): `present_image` hands the present to a thread of its own. That thread waits for the copy's fence, then calls `vkQueuePresentKHR` on a queue of its own, and the game's thread goes on at once. - **Its own queue.** The device gets a second queue of the replay's family where it has one (NVIDIA's has 16), else a queue of a compute-only family (RADV has one graphics queue). A family other than the replay's shares the swapchain's images (`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. - **No frame further ahead than before.** One present is out at most: the next hand-off waits for it. Under V-Sync On (FIFO), with `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. - Presents stay in order. A swapchain is made again, or a lost surface dropped, only once the presenter is idle, and that wait comes before the queue's lock. - No wait without end: the copy's fence in 100 ms steps (after 5 s the present is skipped and said), the present wait in 20 ms steps, a drain gives up after 5 s; a device lost ends any of them. - Not used for frame interpolation's paced presents or with Reflex on (their presents stay on the game's thread). - **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 `vkQueuePresentKHR` was 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.py` with `tests/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.py` for the Breakdown's split. Stubs for `HOS_CHAIN` in the host fixtures that cut `host/` code (`test_multiwait_wake`, `test_present_trace`, `test_short_sleep_timer`, `test_vcore_escape`), and `test_present_semaphores` cuts `present_out` up to `present_finish`. - Docs: `docs/development.md` lists both switches, and `docs/design.md` has 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: | Stretch | ms a frame | |---|---| | Presentation thread, from the main thread's message to its present (the replay of the frame) | 12.5 | | Presentation thread, the present call until the event that releases the main thread | 2.7 (2.0-2.4 of it inside `vkQueuePresentKHR`, in the driver) | | Main thread, from that event to its next message | 2.6 (about 2 of it the renderer's work on its own submits: capture copies, write collection, replay) | | Waits for a virtual core (presentation thread, after its present) | 0.27 | | Hand-offs | 0.004-0.005 each | - The game's job threads (ModuleSystemWorker, BaseProcExecutor) and background threads are on the path for under 0.1 ms a frame. - The main thread waits about 4.5 ms a frame for the presentation thread. - So in this scene the game's own code is not what holds the frame back. The replay, the present and the submit work on the game's threads are. An earlier all-thread sample read "the main thread is blocked 82%" as a sign that the game side bounds the frame; the walk shows that the main thread is blocked on the presentation thread. - The present's wait is the GPU finishing the frame (the FPS counter's Breakdown, #2181, counted it as Pacing; fixed here). On the presenter thread it is 1.75-1.92 ms for the copy's fence, then 0.18-0.22 ms in `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: | Scene | Off: median / p99 / max / sd (ms) | On: median / p99 / max / sd (ms) | CPU a frame, off / on | |---|---|---|---| | Kokiri Forest (1250 draws) | 22.5 / 33.2 / 565 / 29.7 | 20.6 / 32.0 / 530 / 22.1 | 144 / 135 ms | | Hyrule Field, 60 s after arriving (695 draws) | 15.0 / 164 / 2797 / 97 | 12.5 / 24.4 / 746 / 28 | 65 / 61 ms | 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 `vkQueuePresentKHR` blocked 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_CHAIN` stretches, 10 s each, alternating off/on/off/on): | Run | Frame (ms) | Present call, median (ms) | |---|---|---| | Off | 22.1 | 2.0 | | On | 20.1 | 0.10 | | Off | 22.9 | 2.1 | | On | 19.0 | 0.07 | 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): - Runs with about 1250 draws: median frame 19.6, 23.6, 22.2 ms off; 16.9, 18.2, 16.0 ms on. - The other pair drew a lighter scene: 16.8 ms off at 951 draws, 15.3 ms on at 955 draws. **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_CHAIN` compiled 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 - Windows: nothing was built or run there. - The recorder is plain C with the Win32 calls the rest of `host/` uses, and the Windows sampler writes the `# t0_ns` line. - The per-sample `# at` times are Linux only; on Windows `chain.py` falls back to the samples' millisecond stamps. - Whether `vkQueuePresentKHR` waits 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. - Timed on this PC only, and only under memory pressure (above): V-Sync On, Uncapped, frame generation, Castle Town market and window changes were not timed; the window changes and V-Sync On were checked for correctness on the RX 580. - Intel GPUs and other NVIDIA drivers. - Threads that share a name (`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 the `vkQueuePresentKHR` call, one present at a time, with no extra images or copies. The two touch the same functions (`present_image` and `present_out`). If both land, the presenter thread should stay off for #2172's worker presents (they report the present's result back). ## Next steps - **The renderer's work on the main thread's submits** (about 2 ms of its 2.6 ms on the path). Written but not measured, so not in this PR: a pre-capture that copies the dirty pages on a helper while the main thread waits for the presentation thread, so the submit copies only the pages written since. Tried and not kept: claiming the copies in small chunks, the texture shadow's copies on the submitting thread alone, and a short spin before a virtual-core wait. None of them moved the stretches past the noise. - **GPU write-back on demand** rather than a size cut-off, and **real occlusion queries** answered without blocking (today every query answers "visible", so the game culls nothing and the replay draws everything). Both would cut replay or GPU work. Neither was tried. The second must not bring back the sun flicker that the fixed answer cured.
Joey-Magnus commented 2026-11-11 22:17:33 +00:00 (Migrated from github.com)

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:

  • Every place that remakes the swapchain (present_image's resize/HDR/V-Sync branch, VK_ERROR_OUT_OF_DATE_KHR after acquire, present_out's out-of-date/suboptimal tail) drains the presenter before taking dev.present_mu, or after a drain that no new hand-off can follow (present_image_mu is held throughout). So the drain at the top of create_swapchain always finds the presenter idle when the caller holds the queue's lock, and there is no deadlock with the presenter's own dev.present_mu on the replay's queue. The startup callers in vk_main.cpp run before the presenter exists.
  • present_commit passes a locked qk, so the qk.unlock() in present_out's drain path is safe.
  • frames[3]: the fence present_image resets is never the one the presenter is waiting on, since at most one present is out.
  • Anti-Lag 2's present marker is called once on each path; the Reflex exclusion via pi.pNext holds.

Small things:

  1. The branch conflicts with main now (gpu/vk/vk_internal.h, gpu/vk/vk_main.cpp).
  2. hos_chain.c: recs is assigned and then hos_chain_on = 1 with no barrier between them. hos_chain_on is volatile, recs is 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 in hos_chain_note) would make it hold everywhere.
  3. queue_put notes a SIGNAL before it knows whether a try_only put will fail on a full queue. That adds a signal the walk could follow to the wrong sender, rarely.
  4. With V-Sync on, the game thread no longer blocks in the present, so it can start the next frame while the previous present is still queued. That may add up to a frame of input latency (bounded by the one-present-out rule); worth a measurement before this goes on by default.
  5. A GPU hang while the presenter waits on the copy's fence (UINT64_MAX) now blocks the game thread inside presenter_wait_idle rather 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_trace and test_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) with HOS_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:

Pair Kokiri off Kokiri on Field off Field on
1 9.7 10.1 10.5 11.7
2 9.4 10.0 10.8 11.4
3 9.0 9.8 10.0 10.8
4 (no load*) 10.2 (no load*) 10.4
5 10.2 12.3 10.8 13.7

* That off run never stood in the scene (4.2 ms frames), so it is left out.

  • No gain here, and on average about 0.5-1 ms worse with the presenter on, in both scenes.
  • The wait the PR targets does exist on this card: the presenter's log puts the copy's fence at 1.4-5.3 ms a present (0.04-0.09 ms for the present itself), so NVIDIA's driver does block here too.
  • The likely difference from the 3090 numbers: this machine runs the scene at about 100 fps, with GPU time 7.0-7.2 ms of a 9-10 ms frame (the renderer's own log). That is close to GPU-bound, so taking the CPU-side wait off the game's thread leaves little to gain, while the 3090 runs were at about 20 ms frames with the game's threads on the critical path.
  • The p99 and the stall counts (frames over 200 ms) showed no pattern between off and on. One early "on" run had about 14 s of 250-400 ms frames while the save loaded and at the teleport, but the other four "on" runs did not repeat it.

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_CHAIN stretches 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.

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: - Every place that remakes the swapchain (`present_image`'s resize/HDR/V-Sync branch, `VK_ERROR_OUT_OF_DATE_KHR` after acquire, `present_out`'s out-of-date/suboptimal tail) drains the presenter before taking `dev.present_mu`, or after a drain that no new hand-off can follow (`present_image_mu` is held throughout). So the drain at the top of `create_swapchain` always finds the presenter idle when the caller holds the queue's lock, and there is no deadlock with the presenter's own `dev.present_mu` on the replay's queue. The startup callers in `vk_main.cpp` run before the presenter exists. - `present_commit` passes a locked `qk`, so the `qk.unlock()` in `present_out`'s drain path is safe. - `frames[3]`: the fence `present_image` resets is never the one the presenter is waiting on, since at most one present is out. - Anti-Lag 2's present marker is called once on each path; the Reflex exclusion via `pi.pNext` holds. Small things: 1. **The branch conflicts with `main`** now (`gpu/vk/vk_internal.h`, `gpu/vk/vk_main.cpp`). 2. `hos_chain.c`: `recs` is assigned and then `hos_chain_on = 1` with no barrier between them. `hos_chain_on` is `volatile`, `recs` is 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 in `hos_chain_note`) would make it hold everywhere. 3. `queue_put` notes a `SIGNAL` before it knows whether a `try_only` put will fail on a full queue. That adds a signal the walk could follow to the wrong sender, rarely. 4. With V-Sync on, the game thread no longer blocks in the present, so it can start the next frame while the previous present is still queued. That may add up to a frame of input latency (bounded by the one-present-out rule); worth a measurement before this goes on by default. 5. A GPU hang while the presenter waits on the copy's fence (`UINT64_MAX`) now blocks the game thread inside `presenter_wait_idle` rather 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_trace` and `test_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) with `HOS_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: | Pair | Kokiri off | Kokiri on | Field off | Field on | | --- | --- | --- | --- | --- | | 1 | 9.7 | 10.1 | 10.5 | 11.7 | | 2 | 9.4 | 10.0 | 10.8 | 11.4 | | 3 | 9.0 | 9.8 | 10.0 | 10.8 | | 4 | (no load*) | 10.2 | (no load*) | 10.4 | | 5 | 10.2 | 12.3 | 10.8 | 13.7 | \* That off run never stood in the scene (4.2 ms frames), so it is left out. - **No gain here, and on average about 0.5-1 ms worse with the presenter on**, in both scenes. - The wait the PR targets does exist on this card: the presenter's log puts the copy's fence at **1.4-5.3 ms a present** (0.04-0.09 ms for the present itself), so NVIDIA's driver does block here too. - The likely difference from the 3090 numbers: this machine runs the scene at about 100 fps, with **GPU time 7.0-7.2 ms of a 9-10 ms frame** (the renderer's own log). That is close to GPU-bound, so taking the CPU-side wait off the game's thread leaves little to gain, while the 3090 runs were at about 20 ms frames with the game's threads on the critical path. - The p99 and the stall counts (frames over 200 ms) showed no pattern between off and on. One early "on" run had about 14 s of 250-400 ms frames while the save loaded and at the teleport, but the other four "on" runs did not repeat it. 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_CHAIN` stretches 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.
Commenting is not possible because the repository is archived.
No description provided.