WIP: fix(vk): on Intel a pipeline cache's data is fetched with no build under way #190

Draft
Guep wants to merge 4 commits from Guep/Longshot:fix-intel-pcache-fetch into main
First-time contributor

Why

On Intel GPUs under Windows the game closes with no message every few minutes: Intel's driver crashes (igvk64.dll+0x79ddf3, an access violation) in vkGetPipelineCacheData when a pipeline cache's data is fetched while other threads build pipelines with a cache.

What changed

  • vk pipelines: gpu/vk/vk_draw.cpp, PcacheFetch / PcacheUse around one std::shared_timed_mutex: on Intel GPUs under Windows, each pipeline build with a cache (guarded_graphics_held / guarded_compute_held: create_cached's builds, graphics_with's other caches, the warm pass's computes) holds it shared, and each vkGetPipelineCacheData call (pipeline_cache_save's worker, play_file_write, pcache_saved_now, the size read in pcache_init) holds it alone. Vulkan allows both at once; this is a driver bug.
  • The lock is held around the vkGetPipelineCacheData calls only: never across a file's write, and never across a wait for the builds. A builder counted into a play generation's users holds the lock only around each of create_cached's builds, so a fetch that held it across play_cache_save()'s wait for those users would wait for that builder for good (the deadlock HumanBean found in review; fixed in 7569238a).
  • The background save does not queue behind the builds: it polls for up to 2 s for a moment with no build under way, else is put off to the next request (logged each time, the first 4 then every 16th, its count of pipelines to save kept); after 3 put-offs in a row the next one queues for up to 10 s, so back-to-back builds cannot put it off for good. The forced saves (the prepare, Restart now) wait. The window's close waits at most 10 s, for the lock and for a generation's users, and leaves that play file unwritten rather than hang when a build is stuck in the driver.
  • Leaving the background save out on Intel (the fix posted in NotRexed/Hookshot#999 for the Arc 140V) is not enough on current main: what play compiles would then never be written to the play files. NotRexed/Hookshot#2456 ("pipeline cache saves apart from builds") took the same approach and was tested at 0 crashes in 10 runs on a UHD 770; it can no longer be opened.
  • tests: new tests/test_pcache_fetch_lock.py (the lock cut out of vk_draw.cpp, with threads: a fetch waits for a build and a build for a fetch, the background save gives up instead of stalling, a bounded queued fetch gives up on a build that does not end, nested fetches on one thread; and the source: the lock around vkGetPipelineCacheData alone, never across play_cache_save). tests/test_pipeline_play_cache.py now compiles the real lock with the play caches, and its new case lock-users runs a save while a build sits between two of create_cached's builds (counted in, holding no lock: the save must finish without holding the lock while it waits), then a bounded save with a build stuck in the generation (kept for a later save, no hang). tests/pipeline_cache_save_test.cpp gets a stand-in.
  • docs: the switch in docs/development.md, a row in docs/tested-configurations.md.

Flags and settings

Flag What it does Default now Recommendation Helps on Doesn't help on Untested on
RX_VK_PCACHE_FETCH_LOCK (new) 1: a cache's data is fetched (vkGetPipelineCacheData) only with no pipeline build under way; 0: as before 1 on Intel GPUs under Windows, 0 elsewhere (Linux: only when set) On by default on Intel under Windows; off elsewhere, where no such crash is known Intel Arc iGPU (Meteor Lake), Windows - Arc discrete, Lunar Lake on this build, Mesa ANV; should help on Intel UHD (the same crash at igvk64.dll+0x14d102, NotRexed/Hookshot#2456)

Measurements

main: 9a186f71 (2026-11-13)
this PR: ls/fix-intel-pcache-fetch @ 308edb68, before the review's fix (7569238a, the lock around the fetches alone: not measured yet)
Hardware: Lenovo 83DJ; Intel Arc Graphics (Core Ultra 7 155H iGPU, Meteor Lake), driver 32.0.101.9175; Core Ultra 7 155H; 32 GB; Windows 11 Pro (26300)
Scene: a save in Kokiri Forest, then the control bridge's teleport to Kakariko Village and to Death Mountain Trail, about 130 s in each; output 1920x1080, Render resolution 4, handheld profile
Method: 3 runs per side, interleaved with the other Intel PRs' builds (main, split, pcache, fadd, then again), RX_VK_NO_PREDICT=1 on every side. Per run and area: the median of the fps over the last 5 s windows (the first 4 after the teleport left out: the load) and of the GPU time ... ms a frame lines in longshot_log.txt; an area counts only with 5 windows or more. The table gives the median over the runs, n the runs that count, and the runs that ended in a device loss, with where
How to reproduce: a save outside Link's house, the options above, RX_VK_NO_PREDICT=1; teleport with the control bridge (cheats: teleport) to Kakariko Village, then Death Mountain Trail, about 130 s each; read the fps and GPU time lines of longshot_log.txt per area

main @ 9a186f71 this PR @ 308edb68 Δ
Kokiri Forest: fps, median 5.5 (n=3) 5.5 (n=3) +0.0 fps (0%)
Kokiri Forest: GPU time a frame, median 160.5 ms (n=3) 133.2 ms (n=3) -27.3 ms (-17%)
Kakariko Village: fps, median 7.4 (n=2) 8.2 (n=2) +0.8 fps (+11%)
Kakariko Village: GPU time a frame, median 120.4 ms (n=2) 107.9 ms (n=2) -12.5 ms (-10%)
Death Mountain Trail: fps, median 8.7 (n=1) 9.1 (n=2) +0.4 fps (+5%)
Death Mountain Trail: GPU time a frame, median 102.0 ms (n=1) 67.8 ms (n=2) -34.2 ms (-34%)
Runs measured 3 3
Runs that lost the device 2 of 3 (Kakariko Village x2) 2 of 3 (Death Mountain Trail, Kakariko Village)

The GPU-time differences cannot come from this change, which touches only when a cache's data is fetched: they are run-to-run noise (3 runs a side, fewer where the device was lost), not a result. What the table shows is no cost in fps.

(tools/arm_medians.py over tools/route_report.py, which read the runs' longshot_log.txt)
main    runs 3  kokiri: fps  5.5 gpu  160.5 (n=3) | Kakariko: fps  7.4 gpu  120.4 (n=2) | DeathMou: fps  8.7 gpu  102.0 (n=1)  lost 2/3 n_main2:KakarikoVill n_main3:KakarikoVill
pcache  runs 3  kokiri: fps  5.5 gpu  133.2 (n=3) | Kakariko: fps  8.2 gpu  107.9 (n=2) | DeathMou: fps  9.1 gpu   67.8 (n=2)  lost 2/3 n_pcache2:DeathMountai n_pcache3:KakarikoVill

Testing

Tested: Windows 11, Intel Arc iGPU (Meteor Lake): the route above, 3 runs per side of about 9 minutes, interleaved with main and the two other Intel PRs' builds; the play files are on with this driver (vk: pipeline cache: what play makes goes into play files in every run's log), so the deadlock found in review was reachable in those runs and none froze. No igvk64.dll crash in Windows' Application log on either side: this series did not reproduce the crash on main, so it shows no cost rather than the fix. The earlier 300 s series in docs/tested-configurations.md (no crash in 12 runs, main 4 of 6) ran one build with this change, #189 and the 32-lane pin change together, so it cannot credit the fix to this change alone either: what shows this change does what it says is the game-free checks. The review's fix (7569238a) is checked game-free only so far.

Not tested: the review's fix in the game (to run before pr ready); other Intel GPUs on Longshot's build; Linux (the lock is off there: Mesa ANV has no such crash reported); the window's close with a build stuck in the driver (game-free only: lock-users).

Needs other testers:

  • Intel Arc discrete or Lunar Lake, Windows: play Kokiri Forest and Kakariko 10+ minutes on a first visit, expecting no igvk64.dll crash in the Windows Application log and pipeline cache saved lines in longshot_log.txt (request to open)

Screenshots

Nothing on screen.

Risk and rollback

On Intel, a build waits while a cache's data is fetched: the length of one vkGetPipelineCacheData call (the files are written outside the lock), up to the cache's full size once it has grown past 64 MB. The background save only takes the lock at a moment no build holds it, except after 3 put-offs in a row, when it queues for up to 10 s and the builds that start meanwhile wait behind it for as long as the builds under way take. The forced saves (the prepare, Restart now) wait for the builds under way. At the window's close a build stuck in the driver costs that play file, not the close. RX_VK_PCACHE_FETCH_LOCK=0 turns it all off; reverting the commit is enough.

Reviewer notes

#40 (new pipelines in the game's own cache before their first draw) touches graphics_with and the cache saves too; whichever lands second needs a rebase. The device losses seen in these runs are in compute programs that use the float-add loop (see the native float add PR).

Issues

See NotRexed/Hookshot#999

Checklist

For everyone

  • One topic.
  • The title is a Conventional Commit (type(scope): description).
  • Rebased on current main. (on fbd42024; measured on 9a186f71 before the rebase)
  • python -I -B tests/run_all.py run and passing (any failure that also fails on main is named above). (run on 7569238a: 448 passed, 10 failed, 72 skipped; main fbd42024 fails 9 on this Windows PC the same way: test_bake_portability, test_bug_report, test_check_third_party, test_fpx_sticky, test_portable_builder_home, test_portable_builder_startup, test_prepare_at_build, test_release, test_spawn_emit (all but test_prepare_at_build fail here too); plus test_frame_wait_bounds and test_jobs_wait_runs_queued, timing checks that ran out of time while a 12-job game build ran beside them, in code this PR does not touch: to run again on an idle PC before ticking)
  • Tested in the game, or what wasn't is named under Testing.
  • Testing filled in: what was tested, what wasn't, and what needs other testers (with test requests). (the test requests are still to open)
  • Measured: main and this PR at named commits, on the same PC, scene and settings, at least 2 interleaved runs each, the median reported. (fewer than 2 runs that count: main Death Mountain Trail: 1 run; and the review's fix not measured yet)
  • No game files, dumps, build output or generated files.
  • third_party/ untouched (a new folder with a PROVENANCE.md instead).
  • The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled.

If applicable

  • AI assistance recorded with Assisted-by: / Co-Authored-By: trailers.
  • Flags noted, and the switch reference updated (docs/development.md, the README).
  • A row in docs/tested-configurations.md for each measured run. (the row there is the earlier 300 s series; the route series above still needs one)
  • A game-free test added for the fix, failing on main and passing here.
  • A check under tests/ that reads changed source updated in this PR, not weakened.
  • Host code builds on both Windows (llvm-mingw) and Linux, with UTF-8 paths. (built on Windows only)
  • Docs updated.
## Why On Intel GPUs under Windows the game closes with no message every few minutes: Intel's driver crashes (`igvk64.dll+0x79ddf3`, an access violation) in `vkGetPipelineCacheData` when a pipeline cache's data is fetched while other threads build pipelines with a cache. ## What changed - vk pipelines: `gpu/vk/vk_draw.cpp`, `PcacheFetch` / `PcacheUse` around one `std::shared_timed_mutex`: on Intel GPUs under Windows, each pipeline build with a cache (`guarded_graphics_held` / `guarded_compute_held`: `create_cached`'s builds, `graphics_with`'s other caches, the warm pass's computes) holds it shared, and each `vkGetPipelineCacheData` call (`pipeline_cache_save`'s worker, `play_file_write`, `pcache_saved_now`, the size read in `pcache_init`) holds it alone. Vulkan allows both at once; this is a driver bug. - The lock is held around the `vkGetPipelineCacheData` calls only: never across a file's write, and never across a wait for the builds. A builder counted into a play generation's users holds the lock only around each of `create_cached`'s builds, so a fetch that held it across `play_cache_save()`'s wait for those users would wait for that builder for good (the deadlock HumanBean found in review; fixed in 7569238a). - The background save does not queue behind the builds: it polls for up to 2 s for a moment with no build under way, else is put off to the next request (logged each time, the first 4 then every 16th, its count of pipelines to save kept); after 3 put-offs in a row the next one queues for up to 10 s, so back-to-back builds cannot put it off for good. The forced saves (the prepare, Restart now) wait. The window's close waits at most 10 s, for the lock and for a generation's users, and leaves that play file unwritten rather than hang when a build is stuck in the driver. - Leaving the background save out on Intel (the fix posted in [NotRexed/Hookshot#999](https://github.com/NotRexed/Hookshot/issues/999) for the Arc 140V) is not enough on current `main`: what play compiles would then never be written to the play files. [NotRexed/Hookshot#2456](https://github.com/NotRexed/Hookshot/pull/2456) ("pipeline cache saves apart from builds") took the same approach and was tested at 0 crashes in 10 runs on a UHD 770; it can no longer be opened. - tests: new `tests/test_pcache_fetch_lock.py` (the lock cut out of `vk_draw.cpp`, with threads: a fetch waits for a build and a build for a fetch, the background save gives up instead of stalling, a bounded queued fetch gives up on a build that does not end, nested fetches on one thread; and the source: the lock around `vkGetPipelineCacheData` alone, never across `play_cache_save`). `tests/test_pipeline_play_cache.py` now compiles the real lock with the play caches, and its new case `lock-users` runs a save while a build sits between two of `create_cached`'s builds (counted in, holding no lock: the save must finish without holding the lock while it waits), then a bounded save with a build stuck in the generation (kept for a later save, no hang). `tests/pipeline_cache_save_test.cpp` gets a stand-in. - docs: the switch in `docs/development.md`, a row in `docs/tested-configurations.md`. ## Flags and settings | Flag | What it does | Default now | Recommendation | Helps on | Doesn't help on | Untested on | |---|---|---|---|---|---|---| | `RX_VK_PCACHE_FETCH_LOCK` (new) | `1`: a cache's data is fetched (`vkGetPipelineCacheData`) only with no pipeline build under way; `0`: as before | `1` on Intel GPUs under Windows, `0` elsewhere (Linux: only when set) | On by default on Intel under Windows; off elsewhere, where no such crash is known | Intel Arc iGPU (Meteor Lake), Windows | - | Arc discrete, Lunar Lake on this build, Mesa ANV; should help on Intel UHD (the same crash at `igvk64.dll+0x14d102`, [NotRexed/Hookshot#2456](https://github.com/NotRexed/Hookshot/pull/2456)) | ## Measurements main: 9a186f71 (2026-11-13) this PR: ls/fix-intel-pcache-fetch @ 308edb68, before the review's fix (7569238a, the lock around the fetches alone: not measured yet) Hardware: Lenovo 83DJ; Intel Arc Graphics (Core Ultra 7 155H iGPU, Meteor Lake), driver 32.0.101.9175; Core Ultra 7 155H; 32 GB; Windows 11 Pro (26300) Scene: a save in Kokiri Forest, then the control bridge's teleport to Kakariko Village and to Death Mountain Trail, about 130 s in each; output 1920x1080, Render resolution 4, handheld profile Method: 3 runs per side, interleaved with the other Intel PRs' builds (main, split, pcache, fadd, then again), `RX_VK_NO_PREDICT=1` on every side. Per run and area: the median of the `fps over the last 5 s` windows (the first 4 after the teleport left out: the load) and of the `GPU time ... ms a frame` lines in `longshot_log.txt`; an area counts only with 5 windows or more. The table gives the median over the runs, n the runs that count, and the runs that ended in a device loss, with where How to reproduce: a save outside Link's house, the options above, `RX_VK_NO_PREDICT=1`; teleport with the control bridge (cheats: teleport) to Kakariko Village, then Death Mountain Trail, about 130 s each; read the fps and GPU time lines of `longshot_log.txt` per area | | main @ 9a186f71 | this PR @ 308edb68 | Δ | |---|---|---|---| | Kokiri Forest: fps, median | 5.5 (n=3) | 5.5 (n=3) | +0.0 fps (0%) | | Kokiri Forest: GPU time a frame, median | 160.5 ms (n=3) | 133.2 ms (n=3) | -27.3 ms (-17%) | | Kakariko Village: fps, median | 7.4 (n=2) | 8.2 (n=2) | +0.8 fps (+11%) | | Kakariko Village: GPU time a frame, median | 120.4 ms (n=2) | 107.9 ms (n=2) | -12.5 ms (-10%) | | Death Mountain Trail: fps, median | 8.7 (n=1) | 9.1 (n=2) | +0.4 fps (+5%) | | Death Mountain Trail: GPU time a frame, median | 102.0 ms (n=1) | 67.8 ms (n=2) | -34.2 ms (-34%) | | Runs measured | 3 | 3 | | | Runs that lost the device | 2 of 3 (Kakariko Village x2) | 2 of 3 (Death Mountain Trail, Kakariko Village) | | The GPU-time differences cannot come from this change, which touches only when a cache's data is fetched: they are run-to-run noise (3 runs a side, fewer where the device was lost), not a result. What the table shows is no cost in fps. ``` (tools/arm_medians.py over tools/route_report.py, which read the runs' longshot_log.txt) main runs 3 kokiri: fps 5.5 gpu 160.5 (n=3) | Kakariko: fps 7.4 gpu 120.4 (n=2) | DeathMou: fps 8.7 gpu 102.0 (n=1) lost 2/3 n_main2:KakarikoVill n_main3:KakarikoVill pcache runs 3 kokiri: fps 5.5 gpu 133.2 (n=3) | Kakariko: fps 8.2 gpu 107.9 (n=2) | DeathMou: fps 9.1 gpu 67.8 (n=2) lost 2/3 n_pcache2:DeathMountai n_pcache3:KakarikoVill ``` ## Testing **Tested:** Windows 11, Intel Arc iGPU (Meteor Lake): the route above, 3 runs per side of about 9 minutes, interleaved with main and the two other Intel PRs' builds; the play files are on with this driver (`vk: pipeline cache: what play makes goes into play files` in every run's log), so the deadlock found in review was reachable in those runs and none froze. No `igvk64.dll` crash in Windows' Application log on either side: this series did not reproduce the crash on main, so it shows no cost rather than the fix. The earlier 300 s series in `docs/tested-configurations.md` (no crash in 12 runs, main 4 of 6) ran one build with this change, #189 and the 32-lane pin change together, so it cannot credit the fix to this change alone either: what shows this change does what it says is the game-free checks. The review's fix (7569238a) is checked game-free only so far. **Not tested:** the review's fix in the game (to run before `pr ready`); other Intel GPUs on Longshot's build; Linux (the lock is off there: Mesa ANV has no such crash reported); the window's close with a build stuck in the driver (game-free only: `lock-users`). **Needs other testers:** - Intel Arc discrete or Lunar Lake, Windows: play Kokiri Forest and Kakariko 10+ minutes on a first visit, expecting no `igvk64.dll` crash in the Windows Application log and `pipeline cache saved` lines in `longshot_log.txt` (request to open) ## Screenshots Nothing on screen. ## Risk and rollback On Intel, a build waits while a cache's data is fetched: the length of one `vkGetPipelineCacheData` call (the files are written outside the lock), up to the cache's full size once it has grown past 64 MB. The background save only takes the lock at a moment no build holds it, except after 3 put-offs in a row, when it queues for up to 10 s and the builds that start meanwhile wait behind it for as long as the builds under way take. The forced saves (the prepare, Restart now) wait for the builds under way. At the window's close a build stuck in the driver costs that play file, not the close. `RX_VK_PCACHE_FETCH_LOCK=0` turns it all off; reverting the commit is enough. ## Reviewer notes #40 (new pipelines in the game's own cache before their first draw) touches `graphics_with` and the cache saves too; whichever lands second needs a rebase. The device losses seen in these runs are in compute programs that use the float-add loop (see the native float add PR). ## Issues See [NotRexed/Hookshot#999](https://github.com/NotRexed/Hookshot/issues/999) ## Checklist ### For everyone - [x] One topic. - [x] The title is a Conventional Commit (`type(scope): description`). - [x] Rebased on current `main`. (on fbd42024; measured on 9a186f71 before the rebase) - [ ] `python -I -B tests/run_all.py` run and passing (any failure that also fails on `main` is named above). (run on 7569238a: 448 passed, 10 failed, 72 skipped; main fbd42024 fails 9 on this Windows PC the same way: test_bake_portability, test_bug_report, test_check_third_party, test_fpx_sticky, test_portable_builder_home, test_portable_builder_startup, test_prepare_at_build, test_release, test_spawn_emit (all but test_prepare_at_build fail here too); plus test_frame_wait_bounds and test_jobs_wait_runs_queued, timing checks that ran out of time while a 12-job game build ran beside them, in code this PR does not touch: to run again on an idle PC before ticking) - [x] Tested in the game, or what wasn't is named under Testing. - [ ] Testing filled in: what was tested, what wasn't, and what needs other testers (with test requests). (the test requests are still to open) - [ ] Measured: main and this PR at named commits, on the same PC, scene and settings, at least 2 interleaved runs each, the median reported. (fewer than 2 runs that count: main Death Mountain Trail: 1 run; and the review's fix not measured yet) - [x] No game files, dumps, build output or generated files. - [x] `third_party/` untouched (a new folder with a `PROVENANCE.md` instead). - [x] The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled. ### If applicable - [x] AI assistance recorded with `Assisted-by:` / `Co-Authored-By:` trailers. - [x] Flags noted, and the switch reference updated (docs/development.md, the README). - [ ] A row in `docs/tested-configurations.md` for each measured run. (the row there is the earlier 300 s series; the route series above still needs one) - [x] A game-free test added for the fix, failing on `main` and passing here. - [x] A check under `tests/` that reads changed source updated in this PR, not weakened. - [ ] Host code builds on both Windows (llvm-mingw) and Linux, with UTF-8 paths. (built on Windows only) - [x] Docs updated.
Assisted-by: Claude Code
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Assisted-by: Claude Code
Co-Authored-By: Claude Opus 5.5 <[email protected]>
fix(vk): the fetch lock's comment points at Hookshot's issue
Some checks failed
PR description / Description follows the template (pull_request_target) Successful in 3s
Website / Website on Cloudflare (pull_request_target) Has been skipped
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been cancelled
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been cancelled
CI / Workflow lint (actionlint) (pull_request) Has been cancelled
Third-party parts / As their notes say (pull_request) Has been cancelled
Third-party parts / The same as upstream (pull_request) Has been cancelled
308edb68b6
Assisted-by: Claude Code
Co-Authored-By: Claude Opus 5.5 <[email protected]>
HumanBean added this to the v3.0.0 milestone 2026-11-13 16:18:53 +00:00
HumanBean left a comment

Good approach overall: the lock is Intel-on-Windows only, RX_VK_PCACHE_FETCH_LOCK switches it either way, the cost when off is a static bool check per build, and the fetch sites I found all take it (pipeline_cache_save's worker, play_file_write, pcache_saved_now, pipeline_cache_close, pipeline_cache_save_play, pcache_init). On the build side, every vkCreate*Pipelines that passes a cache goes through guarded_*_held: create_cached, graphics_with's other caches (predict's scratch cache too), and the warm pass's computes. The others in gpu/vk pass VK_NULL_HANDLE. FSR 4's hook is AMD only. vkMergePipelineCaches only runs in pcache_init, before any builder exists. Putting the background save off instead of blocking, while keeping its count, is the right idea.

Must change

1. The lock deadlocks against the play generation's users wait. create_cached counts itself into g->users before its first create() and keeps that count across up to three create() calls. PcacheUse is taken and released around each call separately. So a builder can have users > 0 while it holds no shared lock: before its first call, or between two calls. A fetcher can take the exclusive lock at that moment. The try_lock poll in the background save is in fact most likely to win right there, in a gap between calls. Every fetcher here then calls play_cache_save() with the lock still held, and play_cache_save() waits while (g->users.load()) Sleep(1). The builder is blocked in lock_shared() and never decrements users. Both threads stop for good, and so does every later build: the replay thread's too, because it builds through create_cached.

  • Background save (pipeline_cache_save(false), every 300 presents once 32 pipelines are built): the game freezes in play.
  • pipeline_cache_close (window close) and pipeline_cache_save_play: the close hangs. pipeline_cache_close runs before prepare_stop(), so a background prepare's workers in create_cached are enough to cause it.
  • pcache_saved_now / pipeline_cache_save(true) (Restart now, the prepare): the same.

Two things need it: play_cache must be in use (dev.vk13.pipelineCreationCacheControl, a required feature in Vulkan 1.3, and f13 is the queried struct), and the lock must be on (Intel on Windows, or =1 anywhere). The description says this GPU writes no play files, which may be why 12 runs didn't hit it. Please check vk: pipeline cache: what play makes goes into play files in those logs, because on a Vulkan 1.3 Intel driver I'd expect play files to be on. Possible fixes:

  • Hold PcacheUse across the whole of create_cached, from before users.fetch_add to after fetch_sub.
  • Or don't hold the fetch lock across play_cache_save()'s users wait: swap the generation and wait for users first, then take the lock only in play_file_write.

The new test cuts only the lock out of vk_draw.cpp, so it can't see this. Please add a case that runs create_cached against play_cache_save with the lock on.

Should change

  1. "The background save never makes a build wait" (Risk, and the comment on the poll) isn't true. Once try_lock wins, the exclusive lock lasts the whole lambda. That covers both fetches of pcache (up to its full size once it has grown 64 MB), PipelineCacheFile::writable/publish (disk I/O) and the play file's fetch and write. Meanwhile every build waits, the replay thread's draw-time asks included. Please hold the lock only around the vkGetPipelineCacheData calls, and do the file work outside it. Then fix the description.

  2. Shutdown with a stuck build. pipeline_cache_close now waits in lock() for every build in flight before it does anything, and it runs before prepare_stop(). A driver stuck inside a build (the case prepare_left_workers and the watchdog exist for) used to cost only the save. Now it hangs the window close for good. Consider a bounded wait at close, as the background save does, and skip the save if the wait runs out.

  3. Starvation during heavy building. On Intel, with several prepare or GPL workers compiling back to back, a gap with no shared holder may not show up within 2 s at any save. The pipelines go unsaved until a forced save, and with point 3 that save may never come. Log how often it's put off (the told < 4 cap hides that), or fall back to one blocking attempt after N put-offs.

  4. The evidence. The tested-configurations row's build is "this PR and two other Intel changes (the pipeline cache fetch lock or the split-submission fix, …)". It doesn't say which build ran which of the 12 runs, so the crash fix can't be credited to this change from it. The PR's own route series didn't reproduce the crash on main. The test failing on main only shows that the symbols are missing. The GPU-time differences in the table (−17 to −34 %) can't come from this change: please call them run-to-run noise rather than a result.

  5. Nits:

Not checked: whether Intel's Windows driver really exposes pipelineCreationCacheControl (no log here), and the program cache's and the GPL workers' locks beyond confirming that none is held across a guarded_*_held call. I didn't build or run anything.

Verdict: request changes (WIP).

Good approach overall: the lock is Intel-on-Windows only, `RX_VK_PCACHE_FETCH_LOCK` switches it either way, the cost when off is a static bool check per build, and the fetch sites I found all take it (`pipeline_cache_save`'s worker, `play_file_write`, `pcache_saved_now`, `pipeline_cache_close`, `pipeline_cache_save_play`, `pcache_init`). On the build side, every `vkCreate*Pipelines` that passes a cache goes through `guarded_*_held`: `create_cached`, `graphics_with`'s other caches (predict's scratch cache too), and the warm pass's computes. The others in `gpu/vk` pass `VK_NULL_HANDLE`. FSR 4's hook is AMD only. `vkMergePipelineCaches` only runs in `pcache_init`, before any builder exists. Putting the background save off instead of blocking, while keeping its count, is the right idea. ## Must change **1. The lock deadlocks against the play generation's `users` wait.** `create_cached` counts itself into `g->users` before its first `create()` and keeps that count across up to three `create()` calls. `PcacheUse` is taken and released around each call separately. So a builder can have `users > 0` while it holds no shared lock: before its first call, or between two calls. A fetcher can take the exclusive lock at that moment. The `try_lock` poll in the background save is in fact most likely to win right there, in a gap between calls. Every fetcher here then calls `play_cache_save()` with the lock still held, and `play_cache_save()` waits `while (g->users.load()) Sleep(1)`. The builder is blocked in `lock_shared()` and never decrements `users`. Both threads stop for good, and so does every later build: the replay thread's too, because it builds through `create_cached`. - Background save (`pipeline_cache_save(false)`, every 300 presents once 32 pipelines are built): the game freezes in play. - `pipeline_cache_close` (window close) and `pipeline_cache_save_play`: the close hangs. `pipeline_cache_close` runs before `prepare_stop()`, so a background prepare's workers in `create_cached` are enough to cause it. - `pcache_saved_now` / `pipeline_cache_save(true)` (Restart now, the prepare): the same. Two things need it: `play_cache` must be in use (`dev.vk13.pipelineCreationCacheControl`, a required feature in Vulkan 1.3, and `f13` is the queried struct), and the lock must be on (Intel on Windows, or `=1` anywhere). The description says this GPU writes no play files, which may be why 12 runs didn't hit it. Please check `vk: pipeline cache: what play makes goes into play files` in those logs, because on a Vulkan 1.3 Intel driver I'd expect play files to be on. Possible fixes: - Hold `PcacheUse` across the whole of `create_cached`, from before `users.fetch_add` to after `fetch_sub`. - Or don't hold the fetch lock across `play_cache_save()`'s users wait: swap the generation and wait for `users` first, then take the lock only in `play_file_write`. The new test cuts only the lock out of `vk_draw.cpp`, so it can't see this. Please add a case that runs `create_cached` against `play_cache_save` with the lock on. ## Should change 2. **"The background save never makes a build wait" (Risk, and the comment on the poll) isn't true.** Once `try_lock` wins, the exclusive lock lasts the whole lambda. That covers both fetches of `pcache` (up to its full size once it has grown 64 MB), `PipelineCacheFile::writable`/`publish` (disk I/O) and the play file's fetch and write. Meanwhile every build waits, the replay thread's draw-time asks included. Please hold the lock only around the `vkGetPipelineCacheData` calls, and do the file work outside it. Then fix the description. 3. **Shutdown with a stuck build.** `pipeline_cache_close` now waits in `lock()` for every build in flight before it does anything, and it runs before `prepare_stop()`. A driver stuck inside a build (the case `prepare_left_workers` and the watchdog exist for) used to cost only the save. Now it hangs the window close for good. Consider a bounded wait at close, as the background save does, and skip the save if the wait runs out. 4. **Starvation during heavy building.** On Intel, with several prepare or GPL workers compiling back to back, a gap with no shared holder may not show up within 2 s at any save. The pipelines go unsaved until a forced save, and with point 3 that save may never come. Log how often it's put off (the `told < 4` cap hides that), or fall back to one blocking attempt after N put-offs. 5. **The evidence.** The tested-configurations row's build is "this PR and two other Intel changes (the pipeline cache fetch lock *or* the split-submission fix, …)". It doesn't say which build ran which of the 12 runs, so the crash fix can't be credited to this change from it. The PR's own route series didn't reproduce the crash on main. The test failing on main only shows that the symbols are missing. The GPU-time differences in the table (−17 to −34 %) can't come from this change: please call them run-to-run noise rather than a result. 6. Nits: - GitHub references in the code comment (`NotRexed/Hookshot#999`) and in the new docs row (`#999`, `#2564`) should be full URLs (https://github.com/NotRexed/Hookshot/issues/999, https://github.com/NotRexed/Hookshot/pull/2564). - `pcache_saved_now` holds the exclusive lock while `pipeline_cache_save(true)` waits on `busy`. A background save polling at that moment adds up to 2 s to the forced save. Not checked: whether Intel's Windows driver really exposes `pipelineCreationCacheControl` (no log here), and the program cache's and the GPL workers' locks beyond confirming that none is held across a `guarded_*_held` call. I didn't build or run anything. Verdict: request changes (WIP).
@ -22,6 +22,7 @@ otherwise.
| 2026-11-12 | main 4dd972a8 + this change, `HOS_VIDEO_INPUT_LIMIT=0` (as main) against the default (16) | Linux (CachyOS, kernel 7.3.3), a private headless sway, scripted input, no sound device (`HOS_AUDIO=0`) | RTX 3090 24 GB, 620.70.06 | Ryzen 9 7900X, 30 GB (the PC was swapping in every run) | 2048x1152, default options, cap 60 | A new game from an empty data folder: the first movie (A after 11 s, about 820 pictures of it), then the second (9,254 pictures) to its end | Three runs each, interleaved. Pictures skipped, first movie: 244, 247, 251 without the limit; 8, 198, 1 with it. Second movie: 123, 700, 98 without; 0, 502, 0 with. In the second run of each side the PC swapped hard and frames were late (177 and 146 frames over 33 ms; 0 to 2 in the others): those skips are the late frames'. Waits of over 90 ms between two pictures in a movie's first 5 s: 44, 43, 43 and 20, 21, 18 without; 1, 0, 0 and 0, 9, 0 with. The second movie ends within 12 ms of its clock on both sides |
| 2026-11-13 | Hookshot fix/commit-limit 905cb5e1 (NotRexed/Hookshot#2972, ported here as fix/commit-limit), against Hookshot main e0e60920, alternated | Windows | RTX 4060 Ti, - | -, 31 GB (115 GB commit limit) | 3440x1440, muted, the commit rule idle (default `RX_VK_COMMIT_RESERVE_MB`) | Route run: Kokiri, Hyrule Field, Kakariko, 2 runs each | This change: 53 and 57 fps median, p99 40.5 and 36.1 ms, private commit at end 28.9 and 28.6 GB, no `commit limit near` line (the rule never fired). Main: 51 and 50 fps, p99 43.2 and 42.1 ms, 33.4 and 34.8 GB. Run-to-run variation, not the change: it costs nothing when idle |
| 2026-11-13 | as above, this change only | as above | as above | as above | as above, `RX_VK_COMMIT_RESERVE_MB=38397` (the rule forced, about 25 GB of the game's commit before it kicks in) | as above, 1 run | 54 fps median, p99 38.7 ms, max 881 ms (the run before, rule not firing: 58, 35.6, 881). 52 `commit limit near` lines, texture budget down by the shortfall each time (e.g. 3661 to 2812 MB), headroom 37.4 to 38.4 GB; private commit peak 26.7 GB (30.3 without the rule), VRAM blocks peak 9.0 GB (12.9). The 5 s wait at the limit itself not reached |
| 2026-11-13 | v2.1.0-rc.3-313-g9b6072c + this PR and two other Intel changes for NotRexed/Hookshot#999 (the pipeline cache fetch lock or the split-submission fix, and compute stages let go of the 32-lane pin as NotRexed/Hookshot#2564 does), the same patch rebased on main e0e6092 for the PR, built from source (llvm-mingw) | Windows 11 Pro (26300), 1920x1080, scripted input | Intel Arc Graphics (Core Ultra 7 155H iGPU, Meteor Lake), 32.0.101.9175 | Core Ultra 7 155H, 32 GB | Render resolution 4, handheld profile; `RX_VK_PASS_TIMING=2`, a screenshot every 60 frames; old/new alternated, the game closed normally | Kokiri Forest outside Link's house, standing, 300 s | No `igvk64.dll+0x79ddf3` crash in 12 runs (main 9b6072c without it: 4 of 6). The background save ran 3 times and was put off once. Device lost in compute programs in 4 of 12, as on plain main |
Owner

"the pipeline cache fetch lock or the split-submission fix": which build ran which of the 12 runs? As written, the crash-free runs can't be credited to this change. Also, please write the GitHub references as full URLs.

"the pipeline cache fetch lock or the split-submission fix": which build ran which of the 12 runs? As written, the crash-free runs can't be credited to this change. Also, please write the GitHub references as full URLs.
@ -744,0 +771,4 @@
return;
}
if (wait) pcache_fetch_mu.lock(), owns = true;
else // (polled: a writer waiting in the lock would stall the builds behind it)
Owner

The poll keeps the background save from queueing as a writer, but once it wins, the lock lasts the whole save: both pcache fetches, the file's write and publish, and the play file. Every build waits meanwhile, the replay thread's included. Please hold it only around the vkGetPipelineCacheData calls. The PR's Risk section ('never makes a build wait') needs fixing too.

The poll keeps the background save from queueing as a writer, but once it wins, the lock lasts the whole save: both `pcache` fetches, the file's write and publish, and the play file. Every build waits meanwhile, the replay thread's included. Please hold it only around the `vkGetPipelineCacheData` calls. The PR's Risk section ('never makes a build wait') needs fixing too.
@ -1239,2 +1302,3 @@
}
VkResult cached_graphics(const VkGraphicsPipelineCreateInfo* ci, VkPipeline* pl) {
return create_cached(guarded_graphics, ci, pl);
return create_cached(guarded_graphics_held, ci, pl);
Owner

PcacheUse is scoped to one create() call, but create_cached holds g->users across up to three. Taking the shared lock once around the whole create_cached (users in, through users out) would remove the users/lock cycle with play_cache_save.

`PcacheUse` is scoped to one `create()` call, but `create_cached` holds `g->users` across up to three. Taking the shared lock once around the whole `create_cached` (users in, through users out) would remove the users/lock cycle with `play_cache_save`.
@ -9831,6 +9896,7 @@ std::string load_fallback_path() {
void program_layout(Program& p) { ensure_layout(p); }
std::vector<std::pair<uint64_t, uint64_t>> compute_variants_learned() { return compute_variants_read(); }
void pipeline_cache_close() {
PcacheFetch fetch(true);
Owner

Same deadlock at window close: the lock is held into play_cache_save()'s users wait. This also runs before prepare_stop(), so a background prepare's worker in create_cached is enough. And with a driver stuck inside a build, lock() never returns, where before only the save waited.

Same deadlock at window close: the lock is held into `play_cache_save()`'s users wait. This also runs before `prepare_stop()`, so a background prepare's worker in `create_cached` is enough. And with a driver stuck inside a build, `lock()` never returns, where before only the save waited.
@ -9861,2 +9928,3 @@
auto save = [force] {
auto save = [force, built] {
struct Done { ~Done() { busy = false; } } done;
PcacheFetch fetch(force); // (Intel: no build under way while the caches' data is fetched)
Owner

Deadlock: this keeps the exclusive lock through play_cache_save() below, which waits while (g->users.load()) Sleep(1). A builder in create_cached has already counted itself into users and is blocked in PcacheUse's lock_shared() (before its first create() or between two of them), so it never gets to decrement. The try_lock poll tends to win exactly in that gap. The result is a frozen game whenever the play caches are on (pipelineCreationCacheControl, Vulkan 1.3).

Deadlock: this keeps the exclusive lock through `play_cache_save()` below, which waits `while (g->users.load()) Sleep(1)`. A builder in `create_cached` has already counted itself into `users` and is blocked in `PcacheUse`'s `lock_shared()` (before its first `create()` or between two of them), so it never gets to decrement. The `try_lock` poll tends to win exactly in that gap. The result is a frozen game whenever the play caches are on (pipelineCreationCacheControl, Vulkan 1.3).
@ -0,0 +105,4 @@
lock = cut(draw, "std::shared_mutex pcache_fetch_mu;", "// ---- (longshot) What play makes goes into a cache of its own")
# (every fetch of a cache's data is a PcacheFetch, every build with a cache a PcacheUse)
save = cut(draw, "void pipeline_cache_save(bool force) {", "\n}\n")
assert "PcacheFetch fetch(force);" in save and "if (!fetch.held())" in save
Owner

The check cuts the lock alone, so it can't see the interaction with create_cached's users count and play_cache_save's wait, where the deadlock is. Please add a case that runs those two against each other with the lock on.

The check cuts the lock alone, so it can't see the interaction with `create_cached`'s `users` count and `play_cache_save`'s wait, where the deadlock is. Please add a case that runs those two against each other with the lock on.
Guep force-pushed fix-intel-pcache-fetch from 308edb68b6
Some checks failed
PR description / Description follows the template (pull_request_target) Successful in 3s
Website / Website on Cloudflare (pull_request_target) Has been skipped
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been cancelled
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been cancelled
CI / Workflow lint (actionlint) (pull_request) Has been cancelled
Third-party parts / As their notes say (pull_request) Has been cancelled
Third-party parts / The same as upstream (pull_request) Has been cancelled
to 7569238aec
Some checks are pending
CI / Conventional Commit title (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Blocked by required conditions
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Blocked by required conditions
CI / Workflow lint (actionlint) (pull_request) Blocked by required conditions
Third-party parts / As their notes say (pull_request) Blocked by required conditions
Third-party parts / The same as upstream (pull_request) Blocked by required conditions
Website / Website on Cloudflare (pull_request) Blocked by required conditions
PR description / Description follows the template (pull_request_target) Successful in 5s
2026-11-13 18:40:50 +00:00
Compare
Author
First-time contributor

Thank you, the deadlock was real: this driver does write play files (vk: pipeline cache: what play makes goes into play files is in every run's log here), so the description's "this GPU writes no play generation files" was wrong, and the runs were lucky. Rebased on fbd42024; the fix is 7569238a.

1. Deadlock. I took your second option, since it removes the cycle for every caller rather than for create_cached alone: the fetch lock is now held around each vkGetPipelineCacheData call only, never across play_cache_save()'s wait for a generation's users (nor across a file's write). pipeline_cache_close, pipeline_cache_save_play, pcache_saved_now and the save's worker no longer hold it around play_cache_save(). play_file_write takes it twice, for the size and for the data; a cache that grew in between does not fit, is not written and is tried at the next save (a generation taken away no longer grows, so that only concerns pcache). tests/test_pipeline_play_cache.py now compiles the real lock with the play caches, and a new case, lock-users, runs a save while a build sits between two of create_cached's builds (counted in, holding nothing): it checks the save holds no lock while it waits and that both finish. tests/test_pcache_fetch_lock.py checks the source: no PcacheFetch in pipeline_cache_close, pipeline_cache_save_play or play_cache_save.

2. "Never makes a build wait". Done by the same change: the background save holds the lock for the fetches alone, the files are written outside it. The Risk section now says what a build can wait for.

3. Shutdown with a stuck build. The close waits at most 10 s, for the lock (queued, try_lock_for) and for a generation's users, and leaves that play file unwritten rather than hang. Note that main's own while (g->users.load()) Sleep(1) hung the close in that case too, lock or not; it is bounded now at the close. lock-users's second half checks it: a bounded save with a build stuck in the generation returns within its bound, keeps the generation, and a later save writes it.

4. Starvation. Every put-off is logged with its count (the first 4, then every 16th), and after 3 in a row the next background save queues for up to 10 s instead of polling, so back-to-back builds cannot put it off for good.

5. Evidence. All 12 runs of the 300 s series ran one build with this change, #189 and the 32-lane pin change together: the row says so now, and the description says it cannot credit the crash fix to this change alone. The GPU-time differences are called noise.

6. Nits. Full URLs everywhere. pcache_saved_now no longer holds the lock across pipeline_cache_save(true).

Still to do before pr ready: the review's fix run in the game (it is checked game-free only), the test request, a row for the route series, a Linux build.

tests/run_all.py on 7569238a: the same failures as main on this PC, plus two timing checks that timed out beside a game build (named in the checklist), to run again on an idle PC.

Thank you, the deadlock was real: this driver does write play files (`vk: pipeline cache: what play makes goes into play files` is in every run's log here), so the description's "this GPU writes no play generation files" was wrong, and the runs were lucky. Rebased on fbd42024; the fix is 7569238a. **1. Deadlock.** I took your second option, since it removes the cycle for every caller rather than for `create_cached` alone: the fetch lock is now held around each `vkGetPipelineCacheData` call only, never across `play_cache_save()`'s wait for a generation's users (nor across a file's write). `pipeline_cache_close`, `pipeline_cache_save_play`, `pcache_saved_now` and the save's worker no longer hold it around `play_cache_save()`. `play_file_write` takes it twice, for the size and for the data; a cache that grew in between does not fit, is not written and is tried at the next save (a generation taken away no longer grows, so that only concerns `pcache`). `tests/test_pipeline_play_cache.py` now compiles the real lock with the play caches, and a new case, `lock-users`, runs a save while a build sits between two of `create_cached`'s builds (counted in, holding nothing): it checks the save holds no lock while it waits and that both finish. `tests/test_pcache_fetch_lock.py` checks the source: no `PcacheFetch` in `pipeline_cache_close`, `pipeline_cache_save_play` or `play_cache_save`. **2. "Never makes a build wait".** Done by the same change: the background save holds the lock for the fetches alone, the files are written outside it. The Risk section now says what a build can wait for. **3. Shutdown with a stuck build.** The close waits at most 10 s, for the lock (queued, `try_lock_for`) and for a generation's users, and leaves that play file unwritten rather than hang. Note that main's own `while (g->users.load()) Sleep(1)` hung the close in that case too, lock or not; it is bounded now at the close. `lock-users`'s second half checks it: a bounded save with a build stuck in the generation returns within its bound, keeps the generation, and a later save writes it. **4. Starvation.** Every put-off is logged with its count (the first 4, then every 16th), and after 3 in a row the next background save queues for up to 10 s instead of polling, so back-to-back builds cannot put it off for good. **5. Evidence.** All 12 runs of the 300 s series ran one build with this change, #189 and the 32-lane pin change together: the row says so now, and the description says it cannot credit the crash fix to this change alone. The GPU-time differences are called noise. **6. Nits.** Full URLs everywhere. `pcache_saved_now` no longer holds the lock across `pipeline_cache_save(true)`. Still to do before `pr ready`: the review's fix run in the game (it is checked game-free only), the test request, a row for the route series, a Linux build. `tests/run_all.py` on 7569238a: the same failures as main on this PC, plus two timing checks that timed out beside a game build (named in the checklist), to run again on an idle PC.
HumanBean left a comment

Re-review at 7569238a. Code only: I didn't build it, run the tests or run the game.

Earlier points

  1. Deadlock: addressed. I went through every path, and the lock is now a leaf on both sides. The exclusive side (PcacheFetch) is held around vkGetPipelineCacheData alone in play_file_write (x2), the save's fetch lambda, pcache_saved_now and pcache_init. Nothing else is taken inside it. The shared side (PcacheUse) covers only guarded_*, which is the driver call plus logf on a fault. The driver-fault gate, the program locks and create_cached's users count are all taken outside it. So no thread waits on anything while it holds the lock, and no thread holds the lock while it waits on users. play_cache_save()'s users wait now runs before play_file_write and holds no fetch lock. pipeline_cache_close, pipeline_cache_save_play and pcache_saved_now no longer hold it across a save. No PcacheUse is nested on one thread, so the writer-preferring shared_timed_mutex can't block a reader against itself. lock-users tests exactly the gap I described.
  2. "Never makes a build wait": addressed. The file work is outside the lock, and the Risk section now says what a build waits for.
  3. Close with a stuck build: the regression is gone, but see new point A. The close no longer waits in lock().
  4. Starvation: addressed. Every put-off is counted and logged (first 4, then every 16th), the count is kept, and after 3 put-offs the save queues for up to 10 s.
  5. Evidence: addressed. The row says all three changes ran in each of the 12 runs, and the description no longer credits the fix to this change alone. The GPU-time differences are called noise.
  6. Nits: addressed. Full GitHub URLs are in the code and the docs row. pcache_saved_now releases the lock before pipeline_cache_save(true).

The fetch still can't overlap a build. Every vkCreate*Pipelines that passes a cache goes through guarded_*_held: create_cached and graphics_with for every cache, plus the warm pass's guarded_compute_held(pcache, ...). The others in gpu/vk pass VK_NULL_HANDLE. This also holds on current main: the quick pipelines of #234 ask the caches through graphics_with, and their quick builds use no cache. RX_VK_TEST_SLOW_MS's sleep is outside graphics_with, so it doesn't hold the lock. Main's changes since fbd42024 don't touch the PR's hunks in vk_draw.cpp (the closest one is 3 lines away), so I expect a clean rebase.

New

A. The close's 10 s bound applies on every GPU, not only with the lock. pipeline_cache_close passes play_cache_save(kFetchQueueMs), and bounded = queue && wait_ms >= 0 doesn't look at pcache_fetch_lock_on(). So on NVIDIA, AMD and Linux too, the close now stops waiting for a generation's builder after 10 s. When that happens, it drops everything play made since the last save, where main waited. On AMD's proprietary driver one build can take 5-13 s (the reason for #234). A builder counted into the generation can do up to three, and a background prepare keeps building until prepare_stop(), which runs after this. So that's a real loss on hardware the PR doesn't target. The description's "0: as before" isn't true for the close either. Please bound the users wait only when the lock is on, or else say so in the Flags table and in Risk.

B. Should change: the bound doesn't cover a background save that's already waiting. A background save in poll mode calls play_cache_save(kFetchPollMs, false). With queue == false its users wait is unbounded, and it holds play_cache_save's static mu the whole time. If a build that is counted into the taken generation then sticks in the driver, that save never returns, and the close's bounded call blocks on mu. Main has the same hole, so this isn't a regression. But "the window's close waits at most 10 s" (docs/development.md, Risk) isn't true in that case. bounded = wait_ms >= 0 (the poll's 2 s as the bound) would close it: the generation is kept for the next save anyway. Note that the close's worst case is also 10 s for the users wait plus up to 10 s for each of play_file_write's two fetches, not 10 s in all.

C. Nit. In the background save, if the size fetch gets the lock but the data fetch (line 10071) doesn't, put_off is already reset to 0 and built isn't added back. That save is lost without a log line, and the next one waits for 32 more builds.

Left before ready (from the description)

  • The review's fix run in the game, with a row for the route series.
  • The test request opened.
  • run_all.py passing on an idle PC: the two timing checks rerun.
  • A Linux build.
  • A rebase on current main. The box says "current main", but it's on fbd42024 and main has moved 9 commits, including #234 in vk_draw.cpp.

Verdict: request changes, for A (a behaviour change on every GPU that isn't behind the switch) and B. The deadlock fix itself is right.

Re-review at 7569238a. Code only: I didn't build it, run the tests or run the game. ## Earlier points 1. **Deadlock: addressed.** I went through every path, and the lock is now a leaf on both sides. The exclusive side (`PcacheFetch`) is held around `vkGetPipelineCacheData` alone in `play_file_write` (x2), the save's `fetch` lambda, `pcache_saved_now` and `pcache_init`. Nothing else is taken inside it. The shared side (`PcacheUse`) covers only `guarded_*`, which is the driver call plus `logf` on a fault. The driver-fault gate, the program locks and `create_cached`'s `users` count are all taken outside it. So no thread waits on anything while it holds the lock, and no thread holds the lock while it waits on `users`. `play_cache_save()`'s `users` wait now runs before `play_file_write` and holds no fetch lock. `pipeline_cache_close`, `pipeline_cache_save_play` and `pcache_saved_now` no longer hold it across a save. No `PcacheUse` is nested on one thread, so the writer-preferring `shared_timed_mutex` can't block a reader against itself. `lock-users` tests exactly the gap I described. 2. **"Never makes a build wait": addressed.** The file work is outside the lock, and the Risk section now says what a build waits for. 3. **Close with a stuck build: the regression is gone, but see new point A.** The close no longer waits in `lock()`. 4. **Starvation: addressed.** Every put-off is counted and logged (first 4, then every 16th), the count is kept, and after 3 put-offs the save queues for up to 10 s. 5. **Evidence: addressed.** The row says all three changes ran in each of the 12 runs, and the description no longer credits the fix to this change alone. The GPU-time differences are called noise. 6. **Nits: addressed.** Full GitHub URLs are in the code and the docs row. `pcache_saved_now` releases the lock before `pipeline_cache_save(true)`. The fetch still can't overlap a build. Every `vkCreate*Pipelines` that passes a cache goes through `guarded_*_held`: `create_cached` and `graphics_with` for every cache, plus the warm pass's `guarded_compute_held(pcache, ...)`. The others in `gpu/vk` pass `VK_NULL_HANDLE`. This also holds on current main: the quick pipelines of [#234](https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/234) ask the caches through `graphics_with`, and their quick builds use no cache. `RX_VK_TEST_SLOW_MS`'s sleep is outside `graphics_with`, so it doesn't hold the lock. Main's changes since fbd42024 don't touch the PR's hunks in `vk_draw.cpp` (the closest one is 3 lines away), so I expect a clean rebase. ## New **A. The close's 10 s bound applies on every GPU, not only with the lock.** `pipeline_cache_close` passes `play_cache_save(kFetchQueueMs)`, and `bounded = queue && wait_ms >= 0` doesn't look at `pcache_fetch_lock_on()`. So on NVIDIA, AMD and Linux too, the close now stops waiting for a generation's builder after 10 s. When that happens, it drops everything play made since the last save, where main waited. On AMD's proprietary driver one build can take 5-13 s (the reason for [#234](https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/234)). A builder counted into the generation can do up to three, and a background prepare keeps building until `prepare_stop()`, which runs after this. So that's a real loss on hardware the PR doesn't target. The description's "`0`: as before" isn't true for the close either. Please bound the users wait only when the lock is on, or else say so in the Flags table and in Risk. **B. Should change: the bound doesn't cover a background save that's already waiting.** A background save in poll mode calls `play_cache_save(kFetchPollMs, false)`. With `queue == false` its users wait is unbounded, and it holds `play_cache_save`'s static `mu` the whole time. If a build that is counted into the taken generation then sticks in the driver, that save never returns, and the close's bounded call blocks on `mu`. Main has the same hole, so this isn't a regression. But "the window's close waits at most 10 s" (docs/development.md, Risk) isn't true in that case. `bounded = wait_ms >= 0` (the poll's 2 s as the bound) would close it: the generation is kept for the next save anyway. Note that the close's worst case is also 10 s for the users wait plus up to 10 s for each of `play_file_write`'s two fetches, not 10 s in all. **C. Nit.** In the background save, if the size fetch gets the lock but the data fetch (line 10071) doesn't, `put_off` is already reset to 0 and `built` isn't added back. That save is lost without a log line, and the next one waits for 32 more builds. ## Left before ready (from the description) - The review's fix run in the game, with a row for the route series. - The test request opened. - `run_all.py` passing on an idle PC: the two timing checks rerun. - A Linux build. - A rebase on current main. The box says "current `main`", but it's on fbd42024 and main has moved 9 commits, including [#234](https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/234) in `vk_draw.cpp`. Verdict: request changes, for A (a behaviour change on every GPU that isn't behind the switch) and B. The deadlock fix itself is right.
@ -843,3 +916,4 @@
unsaved.push_back(g);
}
}
const bool bounded = queue && wait_ms >= 0;
Owner

With queue == false (the background save's poll mode) the users wait stays unbounded, and it holds mu. If a build counted into the taken generation sticks in the driver, that save never returns, and the close's bounded call then blocks on mu. It's the same on main, but the docs promise a 10 s close. bounded = wait_ms >= 0 would bound it to the poll's 2 s, and the generation is kept for the next save anyway.

With `queue == false` (the background save's poll mode) the users wait stays unbounded, and it holds `mu`. If a build counted into the taken generation sticks in the driver, that save never returns, and the close's bounded call then blocks on `mu`. It's the same on main, but the docs promise a 10 s close. `bounded = wait_ms >= 0` would bound it to the poll's 2 s, and the generation is kept for the next save anyway.
@ -9906,2 +10003,2 @@
try {
if (play_cache.load()) play_cache_save();
try { // (a bounded wait: a build stuck in the driver costs the play file, not the close)
if (play_cache.load()) play_cache_save(kFetchQueueMs);
Owner

This bound applies on every GPU: play_cache_save's bounded doesn't check pcache_fetch_lock_on(). On AMD's proprietary driver one build takes 5-13 s, and a builder counted in can do three. A background prepare is still building here too, since prepare_stop() runs after this. So a close there now drops what play made since the last save, where main waited. Please bound it only with the lock on, or say so in the Flags table ("0: as before") and in Risk.

This bound applies on every GPU: `play_cache_save`'s `bounded` doesn't check `pcache_fetch_lock_on()`. On AMD's proprietary driver one build takes 5-13 s, and a builder counted in can do three. A background prepare is still building here too, since `prepare_stop()` runs after this. So a close there now drops what play made since the last save, where main waited. Please bound it only with the lock on, or say so in the Flags table ("0: as before") and in Risk.
@ -9952,3 +10069,3 @@
if (write) {
auto data = PipelineCacheFile::writable(pcache_path, n);
if (!data.empty() && vkGetPipelineCacheData(dev.device, pcache, &n, data.data()) == VK_SUCCESS && data.publish(n)) {
if (!data.empty() && fetch(&n, data.data()) && data.publish(n)) {
Owner

Nit: if this second fetch doesn't get the lock in poll mode, put_off was already reset and built isn't added back. The save is lost without a log line, and the next one waits for 32 more builds.

Nit: if this second fetch doesn't get the lock in poll mode, `put_off` was already reset and `built` isn't added back. The save is lost without a log line, and the next one waits for 32 more builds.
Some checks are pending
CI / Conventional Commit title (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Blocked by required conditions
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Blocked by required conditions
CI / Workflow lint (actionlint) (pull_request) Blocked by required conditions
Third-party parts / As their notes say (pull_request) Blocked by required conditions
Third-party parts / The same as upstream (pull_request) Blocked by required conditions
Website / Website on Cloudflare (pull_request) Blocked by required conditions
PR description / Description follows the template (pull_request_target) Successful in 5s
This pull request is marked as a work in progress.
Some workflows are waiting to be reviewed.
This branch is out-of-date with the base branch
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u 'fix-intel-pcache-fetch:Guep-fix-intel-pcache-fetch'
git switch 'Guep-fix-intel-pcache-fetch'
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
MagicBeanDev/Longshot!190
No description provided.