Three different things hide inside the question “MPS vs CUDA”: a framework device backend, a compute API, and — confusingly — an unrelated NVIDIA feature that shares the acronym. Sorting them out takes about five minutes and saves the rework that comes from treating device="mps" as a drop-in replacement for device="cuda".
Which MPS is being discussed?
In an Apple-silicon context, MPS means Metal Performance Shaders — Apple’s GPU compute library, which PyTorch exposes as the mps device backend. In an NVIDIA context, MPS means Multi-Process Service — a mechanism that lets several processes share one GPU’s contexts concurrently. The two have nothing in common beyond three letters. A quick tell: if the surrounding text mentions macOS, Metal, or Apple GPUs, it is the backend; if it mentions concurrent inference processes, context switching, or per-device utilisation, it is Multi-Process Service.
Why the device flag is not the whole story
Switching device="cuda" to device="mps" in PyTorch changes far more than the silicon underneath. Two boundaries do the damage.
Operator coverage. The Metal backend implements a narrower set of operators than the CUDA backend, and unimplemented ones either raise or fall back to CPU. Silent CPU fallback is the expensive case: the code runs, the numbers look plausible, and the throughput collapses without an obvious error to chase.
Memory model. Apple silicon uses unified memory shared between CPU and GPU; CUDA assumes discrete device memory with explicit host-to-device transfers. Code written around pinned buffers, overlapped copies, and stream-ordered transfers does not port performantly across that boundary even where the API surface looks equivalent. The transfer optimisations that earn real speedups on an NVIDIA GPU are, on Apple silicon, optimising something that does not exist in the same form.
Quick answer
| Term | What it actually is | The practical question it raises |
|---|---|---|
| MPS (Apple) | Metal Performance Shaders — GPU compute library, exposed as PyTorch’s mps device |
Are all my operators implemented, or am I silently falling back to CPU? |
| CUDA | NVIDIA’s compute API and kernel/memory model over discrete device memory | Are my host-device transfer patterns actually the bottleneck? |
| MPS (NVIDIA) | Multi-Process Service — concurrent GPU sharing across processes | What is per-device utilisation when several inference processes share one GPU? |
The honest position on Apple silicon: MPS is a strong local development target and a reasonable one for small-scale on-device inference. Treating it as a training or serving platform equivalent to a CUDA deployment is a different claim, and it needs operator-coverage evidence for the specific model before anyone commits to it. We see teams reach that conclusion late — after code has been written against one memory model — which is precisely when it costs the most.
Where NVIDIA’s Multi-Process Service is the real subject, the question is not portability at all. It is utilisation per device under concurrent load, which is a measurable figure and belongs alongside the rest of the backend-selection work we describe in our GPU engineering practice.
What to check before committing
- Run the model’s full operator set on the
mpsdevice and log every fallback, not just failures. - Compare a short training or inference run against the CUDA baseline for numerical drift, not only wall-clock time.
- Strip CUDA-specific transfer optimisations before measuring — otherwise you are benchmarking dead code.
- If concurrency is the goal, confirm which MPS the documentation you are reading actually means.
The open question worth naming: operator coverage on the Metal backend moves with each PyTorch release, so a portability assessment has a shelf life. What did you verify, and against which version?