From 7dcfafb29abf089668a45a735e1e45b8cf9bb022 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 7 Aug 2026 18:13:25 -0500 Subject: [PATCH] fix(ebsd): BandSimulator uploads take the device lock; wizard tests pin CPU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three identical macOS-CI SIGABRTs landed at the first test_ebsd_wizard test: the only file in the suite that ever resolves MPS (every sibling pins cpu), and BandSimulator.__init__ uploaded the detector geometry OUTSIDE accelerator_lock — constructed by simulate_dictionary and refine_orientations BEFORE their per-chunk locks, so it submitted to Metal concurrent with the band-overlay engine's locked matching. One unlocked participant re-opens the crash, exactly as the device_lock contract documents; also a real-app crash on a Mac wizard rebuild (old overlay matches while the new build's ctor submits). Roster tests pin simulate_dictionary, the ctor and refine_orientations so the next unlocked call site fails on every platform. test_ebsd_wizard gets an autouse SPYDE_EBSD_DEVICE=cpu pin (wiring tests; accelerator work under pytest belongs in a subprocess) with an env escape hatch for a Mac repro: SPYDE_EBSD_DEVICE=mps uv run pytest spyde/tests/migrated/test_ebsd_refine.py spyde/tests/migrated/test_ebsd_wizard.py -v -x --- spyde/ebsd/refine.py | 17 ++++++++--- spyde/tests/migrated/test_device_lock.py | 39 ++++++++++++++++++++++++ spyde/tests/migrated/test_ebsd_wizard.py | 23 ++++++++++++++ 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/spyde/ebsd/refine.py b/spyde/ebsd/refine.py index 8a43dbab..612f893b 100644 --- a/spyde/ebsd/refine.py +++ b/spyde/ebsd/refine.py @@ -105,10 +105,19 @@ def __init__(self, detector=(60, 60), pc=(0.5, 0.5, 0.55), *, refl = reflectors if reflectors is not None else cubic_reflectors() r = detector_directions(detector, pc).reshape(-1, 3) self.reflectors = refl - self.r = torch.as_tensor(r, dtype=dtype, device=device) - self.normals = torch.as_tensor(refl.normals, dtype=dtype, device=device) - self.weights = torch.as_tensor(refl.weights, dtype=dtype, device=device) - self.widths = torch.as_tensor(refl.widths, dtype=dtype, device=device) + # Under the lock like every other device submission (device_lock.py): + # these uploads are Metal blits, and BOTH simulate_dictionary and + # refine_orientations construct the simulator BEFORE their per-chunk + # locks — so on MPS this was the one EBSD call site that submitted + # unserialised, concurrent with any live band-overlay match. + with accelerator_lock(device): + self.r = torch.as_tensor(r, dtype=dtype, device=device) + self.normals = torch.as_tensor(refl.normals, dtype=dtype, + device=device) + self.weights = torch.as_tensor(refl.weights, dtype=dtype, + device=device) + self.widths = torch.as_tensor(refl.widths, dtype=dtype, + device=device) self.shape = tuple(detector) self.device = device self.dtype = dtype diff --git a/spyde/tests/migrated/test_device_lock.py b/spyde/tests/migrated/test_device_lock.py index 4524b260..557834d4 100644 --- a/spyde/tests/migrated/test_device_lock.py +++ b/spyde/tests/migrated/test_device_lock.py @@ -365,6 +365,45 @@ def test_average_dot_product_map_locks(self, monkeypatch): rng.normal(size=(3, 3, 6, 6)).astype("float32"), device="cpu") assert seen, "ADP ran unserialised" + def test_the_band_simulator_upload_locks(self, monkeypatch): + """``BandSimulator.__init__`` uploads the detector geometry to the + device — and both ``simulate_dictionary`` and ``refine_orientations`` + construct it BEFORE their per-chunk locks, so it was the one EBSD + call site that submitted to Metal unserialised (the macOS-CI SIGABRT + at the ebsd_wizard tests).""" + pytest.importorskip("torch") + from spyde.ebsd import refine + + seen = self._spy(monkeypatch, refine) + refine.BandSimulator((8, 8), device="cpu") + assert seen, "BandSimulator uploaded to the device unserialised" + + def test_simulate_dictionary_locks(self, monkeypatch): + """The dictionary build runs on the ebsd-build-dictionary worker + thread while the previous wizard's band overlay may still be matching + — every one of its submissions must serialise.""" + pytest.importorskip("torch") + from spyde.ebsd import indexing, refine + + seen = self._spy(monkeypatch, indexing) + seen_ctor = self._spy(monkeypatch, refine) + out = indexing.simulate_dictionary( + np.zeros((3, 3)), detector=(8, 8), device="cpu") + assert out.shape == (3, 8, 8) + assert seen, "dictionary simulation submitted unserialised" + assert seen_ctor, "its BandSimulator construction submitted unserialised" + + def test_refine_orientations_locks(self, monkeypatch): + pytest.importorskip("torch") + from spyde.ebsd import refine + + seen = self._spy(monkeypatch, refine) + rng = np.random.default_rng(0) + refine.refine_orientations( + rng.normal(size=(2, 8, 8)).astype("float32"), np.zeros((2, 3)), + detector=(8, 8), device="cpu", steps=2) + assert seen, "refinement submitted unserialised" + def _fit_a_tiny_model(**kw): """A 4-position fit, small enough to be instant on the CPU.""" diff --git a/spyde/tests/migrated/test_ebsd_wizard.py b/spyde/tests/migrated/test_ebsd_wizard.py index cf446f12..2072a806 100644 --- a/spyde/tests/migrated/test_ebsd_wizard.py +++ b/spyde/tests/migrated/test_ebsd_wizard.py @@ -10,6 +10,7 @@ """ from __future__ import annotations +import os import time import numpy as np @@ -23,6 +24,28 @@ from spyde.data import ebsd_patterns, ground_truth +@pytest.fixture(autouse=True) +def _cpu_device(monkeypatch): + """Force the EBSD device to CPU for this file — WIRING tests, like every + sibling (test_ebsd_indexing / test_ebsd_refine pin ``device="cpu"`` on + every call; kernel accuracy is theirs, not this file's). + + This file never pinned a device, so on an Apple-Silicon runner every + handler resolved ``default_device()`` -> "mps" — the only place in the + whole migrated suite that touched Metal. Under pytest that means the + build worker, the band-overlay engine thread, up to 8 dask threads and + per-test teardown of MPS-resident tensors all churn the device across 7 + Session lifecycles — the multi-threaded Metal profile CLAUDE.md documents + as fatally racy — and macOS CI died with SIGABRT here when the + macos-latest image rolled. Accelerator work belongs in a subprocess (the + test_vector_orientation_gpu.py pattern), not under the pytest harness. + + Overridable: a maintainer reproducing on a Mac sets SPYDE_EBSD_DEVICE=mps. + """ + if not os.environ.get("SPYDE_EBSD_DEVICE"): + monkeypatch.setenv("SPYDE_EBSD_DEVICE", "cpu") + + def _wait(pred, timeout=120.0, interval=0.05): end = time.time() + timeout while time.time() < end: