fix(vk): read and publish a compute program's pipeline atomically #621
No reviewers
Labels
No labels
area/accessibility
area/audio
area/input
area/install
area/menu
area/renderer
area/saves
area/tooling
area/upscaling
beanstalk: approved @ 1697603
beanstalk: approved @ 2c5a62e
beanstalk: approved @ 4fd82b7
beanstalk: approved @ 64d6d09
beanstalk: approved @ 6f91fae
beanstalk: approved @ 70a78f9
beanstalk: approved @ 8015c9a
beanstalk: approved @ 8d361dc
beanstalk: approved @ 9d8356e
beanstalk: approved @ abf2227
beanstalk: approved @ d82f202
beanstalk: approved @ e5f1e40
beanstalk: approved @ ee8ab5f
ci: approved
good first issue
gpu/amd
gpu/igpu
gpu/intel
gpu/nvidia
help wanted
kind
bug
kind
chore
kind
docs
kind
feature
kind
performance
kind
question
needs measurements
platform/linux
platform/macos
platform/steam-deck
platform/windows
preview
priority
critical
priority
high
priority
low
regression
risk
high
risk
low
risk
medium
status
blocked
status
confirmed
status
duplicate
status
invalid
status
needs-info
status
wontfix
testing wanted
ci: approved
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Longshot!621
Loading…
Reference in a new issue
No description provided.
Delete branch "jcabarca/Longshot:fix/vk-compute-pipeline-race"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Type: fix · Risk: low · Tested on: Windows, AMD Radeon RX 9070 XT · Needs: none
Why
A compute shader's pipeline is built on worker threads while other threads record dispatches and read it, with plain reads and writes and no lock around the first read. That is a data race (undefined behaviour in C++): a dispatch can see a half-published pipeline, or one written in place before it was made, and bind a handle that is not ready.
How to reproduce
Seen on: not seen in the game: found by reading
dispatch()ingpu/vk/vk_draw.cpp; a race like this shows only now and then, with no fixed stepsSettings and switches: defaults
Steps: game-free:
python -I -B tests/test_compute_pipeline_atomic.pyon main (copy the test from this PR); it lists every plain use ofcompute_pipelineinvk_draw.cppExpected: every read and write of a compute program's pipeline is atomic, and the pipeline is published only once made
Actual: on main 14 lines read or write it plainly, and the synchronous build passes
&p.compute_pipelinestraight tocached_compute(); the test failsWhat changed
Every read and write of
Program::compute_pipelineinvk_draw.cppnow goes throughstd::atomic_ref<VkPipeline>, with release stores and acquire loads.dispatch()reads it once for the bind. The synchronous build makes the pipeline into a local and publishes it only once it is made.dispatch()ingpu/vk/vk_draw.cpp: acpipe()lambda loads the pipeline with acquire; the checks beforeParLock, the synchronous build's check and the bind use it (optimized ? optimized : pipe, keeping main's Intel quick compute path).precompile_run()publish with a release store.cached_compute(&ci, &built)on a local and storesbuiltonly on success.tests/test_compute_pipeline_atomic.py(new) fails if any use ofcompute_pipelineinvk_draw.cppis not throughstd::atomic_ref<VkPipeline>(, or if the synchronous build writes it in place.tests/test_intel_quick_compute.pylooks for the two lines this PR changes in their atomic spelling (the publish in the worker build and the bind); what it checks is unchanged.tests/test_jobs_failure.py's fixture gets theVkPipelinealias the failure callback now names.The bug, step by step:
dispatch()readsp.compute_pipeline(if (!compute_wait && !p.compute_pipeline ...),ParLock plk(!p.compute_pipeline)) before taking any lock.q.compute_pipeline = made;(or= c;from the cache,= VK_NULL_HANDLE;on failure), andprecompile_run()writesp.compute_pipeline = made;.&p.compute_pipelinetocached_compute(), so the field holds a value while the driver is still creating the pipeline, and other threads can read it.Before (
dispatch()):After:
Start reading at
cpipe()indispatch(), then the worker'sauto build = [job, on_worker, made_how]andprecompile_run().Measurements
Verdict: it fixes the bug:
tests/test_compute_pipeline_atomic.pyfails on main (14 plain uses) and passes with this PR (every use atomic), in both interleaved runs. No speed change is expected or measured: an acquire load or release store of a pointer is a plain load or store on x86-64 and ARM64.Hardware: AMD Radeon RX 9070 XT, Windows; the test itself is game-free (it reads source, it does not use the GPU); Python 3.12
Scene: none, game-free
Method:
tests/test_compute_pipeline_atomic.pyfrom this PR against each side'sgpu/vk/vk_draw.cpp(a checkout of each commit, the PR's test copied into main's), 2 runs each, interleaved (main, PR, main, PR); the result is pass/fail, so the median is the result both runs gaveHow to reproduce:
python -I -B tests/test_compute_pipeline_atomic.pyin a checkout of each side (copy the test into main's)82886e89e37f3aa0tests/test_compute_pipeline_atomic.py, run 1tests/test_compute_pipeline_atomic.py, run 2What the test checks:
compute_pipelineinvk_draw.cpp(comments and longer names likecompute_pipeline_infoleft out) hasstd::atomic_ref<VkPipeline>(right before it.cached_compute(&ci, &p.compute_pipeline)is gone: the synchronous build no longer writes the field in place.Flags and settings
None.
Testing
Tested: on this branch, Windows, Python 3.12:
test_compute_pipeline_atomicpasses (fails on main), and the other checks that read the changed code pass:test_intel_quick_compute,test_jobs_failure,test_driver_faults,test_fallback_loaded,test_fallback_size_policy,test_intel_compiler_gate,test_mesh_fallback,test_mesh_fallback_failed_once,test_module_ids,test_pipeline_state_computes,test_precompile_compute_guard,test_program_cache_one_variant,test_warm_bounded.Built and played: this commit builds on Windows (llvm-mingw back end) and on Linux (
longshot_vk.so, without and with AMD FSR 4). Played on Windows, AMD Radeon RX 9070 XT: the game runs normally.Not tested: the race itself: it shows only now and then, with no steps that reach it on purpose.
test_fsr4_tunewas not run to the end: it needsspirv-val, which this PC does not have (it stops the same way on main). Linux was built, not run.Must be tested: None: the change is the same on every GPU and OS (atomic loads and stores of a pointer), and the game-free test covers it.
Risks
What could break: a missed
compute_pipelineuse would keep a plain access, but the test fails on any.vk_programs.cppstill writes the field plainly while it loads programs; this PR does not touch it.Turning it off: revert the commit.
Compatibility: Nothing changes.
Checklist
Everyone
type(scope): description) of the status line's type.main.python -I -B tests/run_all.pypasses (any failure that also fails onmainis named). (the checks that read the changed code, on this PR, Windows: all pass buttest_fsr4_tune, which also fails on main here: nospirv-val)third_party/untouched (a new folder with aPROVENANCE.mdinstead).When it applies
Assisted-by:/Co-Authored-By:trailers.mainand passing here.tests/that reads changed source updated in this PR, not weakened. (tests/test_compute_pipeline_atomic.pynew;tests/test_intel_quick_compute.pyfollows the atomic spelling, same checks)longshot_vk.sowithout and with AMD FSR 4;std::atomic_refis C++20, which the build already uses (-std=c++20), andvk_memory.cppalready uses it; no OS-specific code or paths)WIP: fix(vk): read and publish a compute program's pipeline atomicallyto fix(vk): read and publish a compute program's pipeline atomicallyRead the diff, the surrounding
dispatch()/precompile_run()code, and the other users ofProgram::compute_pipeline. Invk_draw.cppevery access now goes throughstd::atomic_refwith release stores and acquire loads. The synchronous build also makes the pipeline into a local and publishes it only on success, which fixes the in-place write bycached_compute(&ci, &p.compute_pipeline). The bind reads the pipeline once intopipe, so the check and the bind can't disagree, and the Intel quick-compute path is kept. The two plain writes invk_programs.cpp(lines 214 and 239) set it on a program that is not yet published, so they are not a race. The test edits only follow the new spelling and don't weaken the checks. I did not run anything. Approving.