fix(vk): a shader preparation the GPU driver stops answering in goes on or ends, and says why (#2107) #2195

Merged
nullifyshot merged 5 commits from fix/prepare-hang-guard into main 2026-11-11 17:14:24 +00:00
nullifyshot commented 2026-11-11 07:02:15 +00:00 (Migrated from github.com)

Since v2.1.0, some builds stop for good while preparing shaders (#2107). #2186 made the slow-but-working case visible: the pipeline steps now log their progress every 10 s. A run where the GPU driver itself stops answering still waits forever, though. This makes that run go on, or end with a reason, so the game's first start doesn't hang in the same place.

This replaces #2123. It's the same work, rebased onto #2186, with hckrman101's review there addressed, and with the status lines #2186 now covers taken out.

What happens

One player's log (an RX 580) shows a likely cause:

vk: the driver faulted compiling a graphics pipeline (exception 0xc0000005): that one is left out
vk: graphics pipeline for 6ee656c480d6beea recovered with optimization disabled
  • guarded_graphics catches the driver's crash by jumping out of the compile, and that leaves the compile unfinished inside the driver. #1823's process dump of issue 996 shows what follows on AMD: later builds of that program wait inside the driver forever.
  • The step it stops in is predict_run, which runs at build time since #2032. Unlike precompile_run, it had no stall guard.
  • It also started all its workers' first pipelines at once. precompile_run already avoids that, because of a known NVIDIA fault (621.14, RTX 4070 Ti Super).
  • Most reports in #2107 were slow rather than stuck, which #2186 now shows. This PR changes nothing for those (no fault, still moving) beyond the lines below.

Changes

predict_run (gpu/vk/vk_draw.cpp)

  • Builds its first pipeline alone, as precompile_run does.
  • If no pipeline finishes for 30 s (RX_VK_PREDICT_STALL_S, 0 waits for all), it goes on and leaves the stuck workers in the driver. Their state lives on the heap, as in precompile_run. This happens:
    • anywhere in the run, only after a driver fault was caught in it (driver_faults());
    • otherwise only once every program has been handed out, as precompile_run's guard does. So only the last stragglers are given up, never the bulk. A slow driver that still works keeps its pipelines, at the game's own start too. A real hang before then is the watchdog's to end.
  • An abandoned worker builds no more of its program's list.
  • The count moves per pipeline, not per program, so a slow program doesn't look stuck. #2186's progress line reads the same count.
  • predict_run and precompile_run record when they leave workers in the driver (prepare_left_workers()).

A watchdog for the whole --prepare-shaders run (gpu/vk/vk_main.cpp), covering every part on every GPU:

  • What it's waiting on. Every 30 s that nothing has moved, it logs the part, its count, and what it's waiting on: "the GPU driver or the disk", "the GPU driver, which faulted (above)", or "the GPU driver, which still has pipelines it did not finish". It logs this even while #2186's 10-second lines keep the log busy, because those repeat the same count during a hang. A driver fault it caught gets a line of its own as soon as it happens.

  • The shader programs' count. That step logs no count of its own, so the watchdog writes one after 30 s of a quiet log. It writes nothing of its own while the pipeline steps are moving, since #2186's lines cover them.

  • Stops when nothing moves for 5 minutes (RX_VK_PREPARE_STALL_S, 0 never stops). It then:

    • logs STOPPED: ... the GPU driver stopped responding
    • leaves hookshot_prepare_skipped, so the game's own starts don't hang in the same place (what wasn't prepared is built when first drawn)
    • ends with exit status 4 through TerminateProcess, not exit(), which would unload a driver that can still hold workers

    The 5-minute limit is long on purpose: reading the caches and deciding the predictions report no count for a while on a slow disk or CPU.

  • The pipeline cache's save, the last step:

    • If workers were left in the driver, or a fault was caught: the save is stopped the same way, since vkGetPipelineCacheData can wait on them. A run that gets through it ends at once with TerminateProcess (after @@done shaders).
    • Otherwise: the save is only reported, and its lines stop after 300 s, so make_game's PREPARE_SILENCE (15 minutes with no line) still ends a save that never finishes.
  • The part, its count and the time are read under one lock.

Elsewhere

  • make_game.py maps exit status 4 to "Left to the game's first start: the GPU driver stopped responding". Before, players waited out PREPARE_SILENCE. #2155 then cleans up the leftover process, if any, before an update.
  • driver_faults(): the fault guards count what they catch. These are the same four guard blocks #1823 changes, so whichever lands second needs a mechanical rebase. After #1823, the count would follow its kDriverFault path.

Tested

  • tests/test_prepare_stall.py (new) compiles predict_run and the watchdog from the source with a stand-in driver. All 14 cases pass:
    • predict_run:
      • every pipeline built, with the first one alone
      • the last pipelines never finish: the run goes on
      • a slow driver that still finishes pipelines, with each program longer than the limit: no stop
      • a fault, then nothing finishes early in the run: the run goes on
      • the same with no fault: waited for
    • The watchdog:
      • stuck, after a fault, and while the shader programs move
      • stuck and moving while other lines come every 200 ms, as #2186's do: it still says what it's waiting on and stops, and says nothing of its own while things move
      • a long clean save: reported, then silent, never stopped
      • a save with workers left in the driver: stopped
      • a run ending through prepare_end
      • in each case, the lines it logs, the exit status and the skip file
    • make_game.py: its message for exit status 4
  • vk_main.cpp and vk_draw.cpp compile with the Windows toolchain's flags (llvm-mingw, -fms-extensions).
  • Updated fixtures for the new calls: test_prepare_cache_save, test_precompile_compute_guard, test_linux_driver_fault_guard.
  • python -I -B tests/run_all.py on Windows: 341 passed, 3 failed. test_recorder_sync, test_fpx_sticky and test_precompile_compute_guard fail the same way on an untouched copy of main on this PC (the last needs MSVC's libraries). The Linux-only checks, test_linux_driver_fault_guard among them, are left to CI.
  • Not tested: a real hang on one of the affected cards. I couldn't reproduce it here (RTX 5080).

🤖 Generated with Claude Code

Since v2.1.0, some builds stop for good while preparing shaders (#2107). #2186 made the slow-but-working case visible: the pipeline steps now log their progress every 10 s. A run where the GPU driver itself stops answering still waits forever, though. This makes that run go on, or end with a reason, so the game's first start doesn't hang in the same place. This replaces #2123. It's the same work, rebased onto #2186, with hckrman101's review there addressed, and with the status lines #2186 now covers taken out. ### What happens One player's log (an RX 580) shows a likely cause: ``` vk: the driver faulted compiling a graphics pipeline (exception 0xc0000005): that one is left out vk: graphics pipeline for 6ee656c480d6beea recovered with optimization disabled ``` - `guarded_graphics` catches the driver's crash by jumping out of the compile, and that leaves the compile unfinished inside the driver. #1823's process dump of issue 996 shows what follows on AMD: later builds of that program wait inside the driver forever. - The step it stops in is `predict_run`, which runs at build time since #2032. Unlike `precompile_run`, it had no stall guard. - It also started all its workers' first pipelines at once. `precompile_run` already avoids that, because of a known NVIDIA fault (621.14, RTX 4070 Ti Super). - Most reports in #2107 were slow rather than stuck, which #2186 now shows. This PR changes nothing for those (no fault, still moving) beyond the lines below. ### Changes **`predict_run`** (`gpu/vk/vk_draw.cpp`) - Builds its first pipeline alone, as `precompile_run` does. - If no pipeline finishes for 30 s (`RX_VK_PREDICT_STALL_S`, 0 waits for all), it goes on and leaves the stuck workers in the driver. Their state lives on the heap, as in `precompile_run`. This happens: - **anywhere in the run**, only after a driver fault was caught in it (`driver_faults()`); - **otherwise only once every program has been handed out**, as `precompile_run`'s guard does. So only the last stragglers are given up, never the bulk. A slow driver that still works keeps its pipelines, at the game's own start too. A real hang before then is the watchdog's to end. - An abandoned worker builds no more of its program's list. - The count moves per pipeline, not per program, so a slow program doesn't look stuck. #2186's progress line reads the same count. - `predict_run` and `precompile_run` record when they leave workers in the driver (`prepare_left_workers()`). **A watchdog for the whole `--prepare-shaders` run** (`gpu/vk/vk_main.cpp`), covering every part on every GPU: - **What it's waiting on.** Every 30 s that nothing has moved, it logs the part, its count, and what it's waiting on: "the GPU driver or the disk", "the GPU driver, which faulted (above)", or "the GPU driver, which still has pipelines it did not finish". It logs this even while #2186's 10-second lines keep the log busy, because those repeat the same count during a hang. A driver fault it caught gets a line of its own as soon as it happens. - **The shader programs' count.** That step logs no count of its own, so the watchdog writes one after 30 s of a quiet log. It writes nothing of its own while the pipeline steps are moving, since #2186's lines cover them. - **Stops when nothing moves for 5 minutes** (`RX_VK_PREPARE_STALL_S`, 0 never stops). It then: - logs `STOPPED: ... the GPU driver stopped responding` - leaves `hookshot_prepare_skipped`, so the game's own starts don't hang in the same place (what wasn't prepared is built when first drawn) - ends with exit status 4 through `TerminateProcess`, not `exit()`, which would unload a driver that can still hold workers The 5-minute limit is long on purpose: reading the caches and deciding the predictions report no count for a while on a slow disk or CPU. - **The pipeline cache's save, the last step:** - **If workers were left in the driver, or a fault was caught:** the save is stopped the same way, since `vkGetPipelineCacheData` can wait on them. A run that gets through it ends at once with `TerminateProcess` (after `@@done shaders`). - **Otherwise:** the save is only reported, and its lines stop after 300 s, so make_game's `PREPARE_SILENCE` (15 minutes with no line) still ends a save that never finishes. - The part, its count and the time are read under one lock. **Elsewhere** - `make_game.py` maps exit status 4 to "Left to the game's first start: the GPU driver stopped responding". Before, players waited out `PREPARE_SILENCE`. #2155 then cleans up the leftover process, if any, before an update. - `driver_faults()`: the fault guards count what they catch. These are the same four guard blocks #1823 changes, so whichever lands second needs a mechanical rebase. After #1823, the count would follow its `kDriverFault` path. ### Tested - `tests/test_prepare_stall.py` (new) compiles `predict_run` and the watchdog from the source with a stand-in driver. All 14 cases pass: - **`predict_run`:** - every pipeline built, with the first one alone - the last pipelines never finish: the run goes on - a slow driver that still finishes pipelines, with each program longer than the limit: no stop - a fault, then nothing finishes early in the run: the run goes on - the same with no fault: waited for - **The watchdog:** - stuck, after a fault, and while the shader programs move - stuck and moving while other lines come every 200 ms, as #2186's do: it still says what it's waiting on and stops, and says nothing of its own while things move - a long clean save: reported, then silent, never stopped - a save with workers left in the driver: stopped - a run ending through `prepare_end` - in each case, the lines it logs, the exit status and the skip file - **`make_game.py`:** its message for exit status 4 - `vk_main.cpp` and `vk_draw.cpp` compile with the Windows toolchain's flags (llvm-mingw, `-fms-extensions`). - Updated fixtures for the new calls: `test_prepare_cache_save`, `test_precompile_compute_guard`, `test_linux_driver_fault_guard`. - `python -I -B tests/run_all.py` on Windows: 341 passed, 3 failed. `test_recorder_sync`, `test_fpx_sticky` and `test_precompile_compute_guard` fail the same way on an untouched copy of main on this PC (the last needs MSVC's libraries). The Linux-only checks, `test_linux_driver_fault_guard` among them, are left to CI. - **Not tested:** a real hang on one of the affected cards. I couldn't reproduce it here (RTX 5080). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
hckrman101 (Migrated from github.com) approved these changes 2026-11-11 16:30:32 +00:00
hckrman101 (Migrated from github.com) left a comment

Thanks for carrying this over onto #2186. I checked it against my review of #2123, and all of it is addressed:

  • Windows test build: -D_CRT_SECURE_NO_WARNINGS is on the compile line, and both windows-2022 parts are green.
  • The save hanging behind left-over workers: prepare_left_workers() is set by both predict_run and precompile_run when they go on. In that case, or after a fault, the save is stopped at the limit. A clean save only reports, and its lines stop after 300 s, so PREPARE_SILENCE still applies. Runs that get through end via prepare_end (TerminateProcess / _Exit) after @@done shaders instead of exit(). pipeline_cache_save writes to .tmp and renames, so a stop in the middle can't leave a torn cache file.
  • The 30 s guard catching slow drivers: it now fires anywhere in the run only after a counted fault, and otherwise only on the tail (next >= keys.size()), as precompile_run does. The slow-program case is in the test.
  • Nits: the abandoned check is per pipeline now, and PrepareSeen is read under one lock.

Moving the "waiting on ..." line to every 30 s of no movement, instead of 30 s of a quiet log, is the right call now that #2186's 10 s lines repeat the same count during a hang. The watchdog only starts in the prepare_only() branch, so it can't end a real game start. The detached predict workers only touch Pre and their own stack (program_cache_borrow holds no lock while it runs fn), so leaving them behind is safe.

#1823: the overlap is the same four guard blocks plus the two test stubs, and it's mechanical. Your note there (the AMD retry recovers before fault_quarantine().end(...) runs, so the program isn't quarantined) is a good catch for #1823 to look at. Either order works.

CI: the red Linux, SPIR-V and AppImage jobs are the runners failing to resolve the Ubuntu apt mirrors (Temporary failure resolving 'azure.archive.ubuntu.com'), so clang, spirv-val and glslang never got installed. The only failures are the checks that need those tools. test_prepare_stall, test_linux_driver_fault_guard and test_prepare_cache_save pass on Linux even in that run. A re-run should go green.

Two nits inline, neither blocking. Approving.

Thanks for carrying this over onto #2186. I checked it against my review of #2123, and all of it is addressed: - **Windows test build:** `-D_CRT_SECURE_NO_WARNINGS` is on the compile line, and both windows-2022 parts are green. - **The save hanging behind left-over workers:** `prepare_left_workers()` is set by both `predict_run` and `precompile_run` when they go on. In that case, or after a fault, the save is stopped at the limit. A clean save only reports, and its lines stop after 300 s, so `PREPARE_SILENCE` still applies. Runs that get through end via `prepare_end` (TerminateProcess / `_Exit`) after `@@done shaders` instead of `exit()`. `pipeline_cache_save` writes to `.tmp` and renames, so a stop in the middle can't leave a torn cache file. - **The 30 s guard catching slow drivers:** it now fires anywhere in the run only after a counted fault, and otherwise only on the tail (`next >= keys.size()`), as `precompile_run` does. The slow-program case is in the test. - **Nits:** the abandoned check is per pipeline now, and `PrepareSeen` is read under one lock. Moving the "waiting on ..." line to every 30 s of no movement, instead of 30 s of a quiet log, is the right call now that #2186's 10 s lines repeat the same count during a hang. The watchdog only starts in the `prepare_only()` branch, so it can't end a real game start. The detached predict workers only touch `Pre` and their own stack (`program_cache_borrow` holds no lock while it runs `fn`), so leaving them behind is safe. **#1823:** the overlap is the same four guard blocks plus the two test stubs, and it's mechanical. Your note there (the AMD retry recovers before `fault_quarantine().end(...)` runs, so the program isn't quarantined) is a good catch for #1823 to look at. Either order works. **CI:** the red Linux, SPIR-V and AppImage jobs are the runners failing to resolve the Ubuntu apt mirrors (`Temporary failure resolving 'azure.archive.ubuntu.com'`), so clang, spirv-val and glslang never got installed. The only failures are the checks that need those tools. `test_prepare_stall`, `test_linux_driver_fault_guard` and `test_prepare_cache_save` pass on Linux even in that run. A re-run should go green. Two nits inline, neither blocking. Approving.
hckrman101 (Migrated from github.com) commented 2026-11-11 16:30:31 +00:00

Nit: faults_before is read after the 64 workers have started, so a fault in their first few milliseconds is counted as "before", and that run falls back to the tail-only rule. Reading it just above the threads.emplace_back loop would close that window.

Nit: `faults_before` is read after the 64 workers have started, so a fault in their first few milliseconds is counted as "before", and that run falls back to the tail-only rule. Reading it just above the `threads.emplace_back` loop would close that window.
hckrman101 (Migrated from github.com) commented 2026-11-11 16:30:31 +00:00

Nit: faults > 0 also counts a fault the AMD retry recovered from ("recovered with optimization disabled", the exact line in the #2107 RX 580 log). On that card every run is "risky": a save that's just slow for 5 minutes gets stopped with exit 4 and leaves the skip file. That's probably rare enough not to matter, and it does keep the TerminateProcess ending, which is what you want. It might still be worth a word in the comment, or a longer cap for the save than for the other parts.

Nit: `faults > 0` also counts a fault the AMD retry recovered from ("recovered with optimization disabled", the exact line in the #2107 RX 580 log). On that card every run is "risky": a save that's just slow for 5 minutes gets stopped with exit 4 and leaves the skip file. That's probably rare enough not to matter, and it does keep the TerminateProcess ending, which is what you want. It might still be worth a word in the comment, or a longer cap for the save than for the other parts.
nullifyshot (Migrated from github.com) reviewed 2026-11-11 17:13:24 +00:00
nullifyshot (Migrated from github.com) commented 2026-11-11 17:13:24 +00:00

Good catch, fixed. faults_before is now read just above the threads.emplace_back loop, so a fault in the workers' first milliseconds counts as this run's.

Good catch, fixed. `faults_before` is now read just above the `threads.emplace_back` loop, so a fault in the workers' first milliseconds counts as this run's.
nullifyshot (Migrated from github.com) reviewed 2026-11-11 17:13:26 +00:00
nullifyshot (Migrated from github.com) commented 2026-11-11 17:13:26 +00:00

Agreed, I did both. A risky save now gets twice the parts' limit (600 s by default) before it's stopped. The "still writing" and "STOPPED" lines say 600 s. The header comment now says why: a fault that AMD's retry recovered from ("recovered with optimization disabled") still counts, so on that card every run is risky.

test_prepare_stall's save-left case now checks that the save isn't stopped at the parts' 4 s. It's stopped at 8 s instead.

Agreed, I did both. A risky save now gets twice the parts' limit (600 s by default) before it's stopped. The "still writing" and "STOPPED" lines say 600 s. The header comment now says why: a fault that AMD's retry recovered from ("recovered with optimization disabled") still counts, so on that card every run is risky. `test_prepare_stall`'s `save-left` case now checks that the save isn't stopped at the parts' 4 s. It's stopped at 8 s instead.
nullifyshot commented 2026-11-11 17:13:28 +00:00 (Migrated from github.com)

Thanks for the review. I rebased onto main and fixed both nits (replies inline).

The rebase over #1823 and #2042:

  • #1823: the four guard blocks keep both its *pl = VK_NULL_HANDLE; and this PR's note_driver_fault();.
  • #2042: predict_run uses env_on("RX_VK_NO_PREDICT"). test_prepare_stall's predict fixture has a stand-in for it.

test_prepare_stall passes, vk_main.cpp and vk_draw.cpp compile cleanly, and the rest of the suite shows no new failures. The earlier CI failures were the runners' DNS (the apt mirrors), as you said, not this change.

Thanks for the review. I rebased onto main and fixed both nits (replies inline). The rebase over #1823 and #2042: - **#1823:** the four guard blocks keep both its `*pl = VK_NULL_HANDLE;` and this PR's `note_driver_fault();`. - **#2042:** `predict_run` uses `env_on("RX_VK_NO_PREDICT")`. `test_prepare_stall`'s predict fixture has a stand-in for it. `test_prepare_stall` passes, `vk_main.cpp` and `vk_draw.cpp` compile cleanly, and the rest of the suite shows no new failures. The earlier CI failures were the runners' DNS (the apt mirrors), as you said, not this change.
Commenting is not possible because the repository is archived.
No description provided.