feat(inference): run local models on the Apple Silicon GPU - #580
Merged
Conversation
`mps` joins the closed device vocabulary a local connection draws from, so a connection on an M-series Mac runs on the GPU instead of on the CPU cores. It was refused before because nothing in the adapters could honour it, which is the rule `DEVICE_PATTERN` states and which this keeps: `gpu` and `auto` still name nothing that could be resolved and stay out. Half precision remains CUDA-only. Metal has no float64 and its bfloat16 varies between releases, so `precisions_for` answers `fp32` alone for `mps` and the existing cross-field rule refuses the pairing at creation. The device-resolution rule was two identical private methods, one per adapter, and neither had a test — nothing reached the fallback branch, the warning or the half-precision decision. It is promoted into `inference/_device.py` and covered there, with availability injected so both answers are exercised on a machine that has neither GPU. `PYTORCH_ENABLE_MPS_FALLBACK` is set as `visionset.inference` is imported, not where the device is resolved: the array library reads it while it initialises, so by then it is already too late. It is a `setdefault`, so an operator who turned it off keeps that answer. `openapi.json` and the generated client are byte-identical — `device` travels as a plain string and no schema shape moved.
`is_available` answers 'is there a Metal device', which is not 'can I put a tensor on it'. On an Intel Mac with a discrete GPU it answers true and every allocation then raises 'MPS backend is only supported on devices with unified memory' — so `_present` waved `mps` through, the documented CPU fallback never ran, no warning was logged, and the failure surfaced as a 500 out of the first suggestion. Asking `is_built` as well would not have helped; it is true there too. The second question is asked by doing it: one cached one-element allocation, which is the run-time shape this module already argues for. Reproduced and fixed against an i9 MacBook Pro, macOS 26.6, torch 2.13. cf. #573
A mutation widening it to `except Exception` came back green — the docstring's claim that a non-RuntimeError surprise propagates rather than being read as absent hardware was a description, not a rule. cf. #573
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.
Closes #573.
What changed
mpsjoins the device vocabulary, so a local connection can run on the Apple Silicon GPU. The kernel gains the value, the adapters honour it, and the catalog screen offers it — withfp32as the only precision, because half precision applies on CUDA only.The two local adapters each carried a private
_resolved_deviceand an identicalCPU_FALLBACK_WARNING. Adding a third device to two copies is how two copies become two answers, so the rule is promoted intoinference/_device.py— the moverequire_moveandrequire_draftalready made in the kernel.The part worth reviewing: available is not usable
Caught by running the branch on real hardware rather than by a test.
_presentoriginally askedtorch.backends.mps.is_available()alone, and argued in its own docstring that askingis_builttoo would be "asking one question twice". That is right aboutis_builtand wrong about the question.is_availableanswers is there a Metal device; what the adapter needs is can I put a tensor on it.On an Intel Mac with a discrete GPU those diverge:
So
mpswas waved through, the documented CPU fallback never ran, zero fallback warnings were logged, and the model died moving its weights to the device —POST /inference/suggest → 500with an incident id, surfacing in the editor as "That suggestion could not be made". Measured on an i9 MacBook Pro, macOS 26.6, torch 2.13.0 (conda-forge osx-64).The fix asks the second question by doing it — one one-element allocation,
RuntimeErrormeans absent — which is the run-time shape this module already argues for: "a device this particular machine does not have is answered at the moment of the call."After the fix, on the same machine:
It costs the other paths nothing, and that was measured
resolved()runs inside_load(), which is memoised — once per model load, never per suggestion. The probe is additionally cached on the array library it was handed. Probe allocations across 50resolved()calls:cpucudamps, Apple Silicon('mps', False)— unchangedmps, Intel Mac('cpu', False)+ warningTimings on the failing path: first
resolved()25.6 ms including the probe, every call after it 0.0007 ms,cpu0.0003 ms with no probe at all. On Apple Silicon that one allocation initialises the Metal context — work the first real inference does moments later regardless, so it is moved earlier rather than added.Caching on the torch object rather than in a module global is also what keeps the probe as injectable as the two availability flags beside it: a test handing in a different stub gets a different answer without clearing anything.
Found, not fixed
Nothing unrelated surfaced.
Test plan
bash scripts/check.shrun in stages (the harness kills a command at ~10 minutes), each verdict recorded, then re-run in full after rebasing onto69dbcc8because that brought in four commits from other work:check.sh python frontend generated docsPASSED— 3339 passed, 13 skipped; ruff, mypy, import-linter, every drift gate, docs buildcheck.sh browserPASSED— annotator e2e and browser cycleNew coverage in
tests/inference/test_device.py, all on the existing stub so it runs on a machine with neither GPU:is_availablealone gets wrong.zerosraisesAssertionErrorrather than described in a comment.RuntimeErrorsurprise from the probe propagates instead of being read as absent hardware.StubTorchgainsmps_usableas a third axis, separate frommps, because on a real machine the two genuinely disagree.Three mutations, each reddening a named test. One came back green first — widening
except RuntimeErrortoexcept Exceptionwas invisible, so the docstring's claim that a half-installed runtime propagates rather than becoming a silent CPU fallback was a description and not a rule. That is the third test above.What still needs an M-series Mac
The fallback is verified on hardware that cannot run MPS.
mpsactually accelerating anything is not verified by this PR — no runner in CI is an Apple Silicon Mac and neither is the machine this was written on. The path is exercised by stub, and the real thing wants one manual run on an M-series machine before anybody relies on the speedup.