Skip to content

fix(mac): serialise all Apple-MPS torch work behind one device lock - #87

Open
CSSFrancis wants to merge 1 commit into
mainfrom
fix/mps-device-serialisation
Open

fix(mac): serialise all Apple-MPS torch work behind one device lock#87
CSSFrancis wants to merge 1 commit into
mainfrom
fix/mps-device-serialisation

Conversation

@CSSFrancis

Copy link
Copy Markdown
Owner

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 null means 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:

at::native::relu_mps_ -> MetalShaderLibrary::exec_unary_kernel
at::native::zero_     -> fill_mps_kernel
                      -> [AGXG13GFamilyComputeContext setComputePipelineState:]

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/except sees 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_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. registry.get_model deliberately calls load_model outside its cache lock (so a slow download doesn't block the cache), 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 — the orientation-mapping path that crashed

The fix

spyde/device_lock.py becomes the single owner of the process-wide accelerator lock (a reentrant RLock, since nesting on one thread is normal — the neural batch takes the device lock and then the _gpu_slots semaphore). Every torch entry point now goes 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) 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

  • Full 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 fake mps-typed device so it runs GPU-free on any machine (CI included).
  • Real Metal, real SpyDE code paths: 3 preview threads + the batched orientation fit concurrently on device=mps — completes clean.
  • Real Electron app: fv_neural_calibration.spec.ts passes (calibration + compute; "Found 703 diffraction vectors", spots correctly circled) and vector_om_lazy.spec.ts passes (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.md so 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 spyde against the repo, so this fix applies immediately. A packaged app uses the bundled python-env (a non-editable copy of spyde), which needs a rebuild to pick it up.

🤖 Generated with Claude Code

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant