fix(mac): serialise all Apple-MPS torch work behind one device lock - #87
Open
CSSFrancis wants to merge 1 commit into
Open
fix(mac): serialise all Apple-MPS torch work behind one device lock#87CSSFrancis wants to merge 1 commit into
CSSFrancis wants to merge 1 commit into
Conversation
Orientation mapping on a Mac killed the backend process outright
("Analysis backend stopped", exit code null). Two macOS crash reports from
the failing runs show a SIGSEGV inside Metal kernel dispatch on a Python
worker thread:
at::native::relu_mps_ -> MetalShaderLibrary::exec_unary_kernel
at::native::zero_ -> fill_mps_kernel
-> [AGXG13GFamilyComputeContext setComputePipelineState:]
and in both reports a SECOND thread was concurrently inside torch (in one,
two threads were in relu_mps_ at the same instant). torch's MPS backend is
not thread-safe: concurrent submission corrupts the Metal command encoder,
and the resulting fault is an uncatchable native crash — no try/except sees
it, so the whole backend dies. Reproduced standalone: 4 threads running one
small conv/ReLU net on MPS segfaults (or hangs) within a few dozen
iterations; the same loop behind a single lock runs clean.
There WAS a lock for this, but it was private to find_vectors_torch and
only the neural BATCH and the NXCORR paths took it. Everything else
submitted to Metal unserialised:
- the neural single-frame preview (fires on navigator moves, on worker
threads)
- the neural calibration sweep
- the cold model load — .to(device) plus the MPS smoke-test forward, which
registry.get_model deliberately runs OUTSIDE its cache lock, so several
find-vectors chunk threads can land in it at once
- the entire batched vector-orientation fit (dispatched to a worker by
vom_run), which is the orientation-mapping path that crashed
Introduce spyde/device_lock.py as the single owner of THE process-wide
accelerator lock (a reentrant RLock, since nesting on one thread is normal)
and route every torch entry point through accelerator_lock(device). Off MPS
it is a null context: CUDA is thread-safe and its stream concurrency is a
deliberate throughput win, so that path is byte-for-byte unchanged.
The orientation fit would otherwise hold the device for the whole anneal, so
it hands it back at the yield points it already has: compute_vector_orientation_gpu
wraps the caller's on_yield to sync, release, yield, re-acquire. A concurrent
preview waits one yield window (~12 refine steps) instead of the whole fit.
The device is always synchronised before a hand-off — releasing with kernels
still in flight would let the next thread submit into a live encoder, which
is the race itself.
test_device_lock.py pins the contract that actually broke: shared-object
identity across every holder, null-context off MPS, serialisation under
concurrency, the yield hand-off, and that the protocol cannot deadlock. It
uses a fake mps-typed device so it runs GPU-free on any machine.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The crash
Orientation mapping on the SPED Ag sample on a Mac kills the Python backend outright — the app shows "Analysis backend stopped" with
[SpyDE exited with code null]. The leaked-semaphore warnings in that dialog are just shutdown noise;code nullmeans the process was killed by a signal.Two macOS crash reports from the failing runs both show a SIGSEGV inside Metal kernel dispatch, on a Python worker thread:
In both reports a second thread was concurrently inside torch — in one, threads 29 and 30 were in
relu_mps_at the same instant.torch's MPS backend is not thread-safe: concurrent submission corrupts the Metal command encoder, and the fault is an uncatchable native crash. No
try/exceptsees it, so the whole backend process dies.Reproduced standalone in ~30 lines: 4 threads running one small conv/ReLU net on MPS segfaults (exit 139) or hangs within a few dozen iterations. The identical loop behind a single lock: 3/3 clean.
Root cause: a lock only some callers took
There was a lock for exactly this, but it lived privately in
find_vectors_torchand only the neural batch and the NXCORR paths took it. Everything else submitted to Metal unserialised:.to(device)plus the MPS smoke-test forward.registry.get_modeldeliberately callsload_modeloutside its cache lock (so a slow download doesn't block the cache), so several find-vectors chunk threads can land in it at oncevom_run— the orientation-mapping path that crashedThe fix
spyde/device_lock.pybecomes the single owner of the process-wide accelerator lock (a reentrantRLock, since nesting on one thread is normal — the neural batch takes the device lock and then the_gpu_slotssemaphore). Every torch entry point now goes throughaccelerator_lock(device).Off MPS it is a null context. CUDA is thread-safe and its stream concurrency is a deliberate throughput win, so that path is byte-for-byte unchanged.
The orientation fit would otherwise hold the device for the whole anneal, so it hands it back at the yield points it already has:
compute_vector_orientation_gpuwraps the caller'son_yieldto sync → release → yield → re-acquire. A concurrent preview waits one yield window (~12 refine steps) rather than the whole fit. The device is always synchronised before a hand-off — releasing with kernels still in flight would let the next thread submit into a live encoder, which is the race itself.Verification
spyde/tests/migrated/suite: 1556 passed, 2 skipped, 1 xfailed, 0 failures.test_device_lock.py(new, 14 tests) pins the contract that actually broke: shared-object identity across every holder, null-context off MPS, serialisation under concurrency, the yield hand-off, and that the release/re-acquire protocol cannot deadlock. Uses a fakemps-typed device so it runs GPU-free on any machine (CI included).device=mps— completes clean.fv_neural_calibration.spec.tspasses (calibration + compute; "Found 703 diffraction vectors", spots correctly circled) andvector_om_lazy.spec.tspasses (Generate → Compute → IPF + strain windows). The OM strain-window screenshot is identical with and without this change — its empty panels are pre-existing screenshot timing in that spec, not a regression here.Honest caveat on the stress harness
The crash is probabilistic per submission. SpyDE's own preview path does heavy GIL-holding numpy work between short MPS bursts, so with the lock disabled, 6 threads × 40 frames still completed cleanly here — the SpyDE-level stress script does not discriminate and is not a regression gate. The mechanism evidence is the two crash reports plus the minimal standalone repro; the durable gate is the lock-contract unit tests. This is noted in
CLAUDE.mdso nobody mistakes a clean stress run for proof.Note for anyone hitting this on a packaged build
Dev/unpackaged runs resolve Python via
uv run python -m spydeagainst the repo, so this fix applies immediately. A packaged app uses the bundledpython-env(a non-editable copy ofspyde), which needs a rebuild to pick it up.🤖 Generated with Claude Code