One framework, three (or four) backends
PyTorch supports CPU, NVIDIA CUDA, and Apple Silicon MPS as first-class devices. ROCm (AMD) and XPU (Intel) exist but are less commonly seen in indie work. The mental model: a tensor lives on exactly one device; ops require their inputs to live on the same device; .to(device) moves data.
The device-string idiom
Almost every PyTorch project starts with a single line picking the best available device, then reuses that string everywhere:
device = (
"cuda" if torch.cuda.is_available()
else "mps" if torch.backends.mps.is_available()
else "cpu"
)
That string then gets passed to .to(device) on the model and every batch. The "expected cuda:0 got cpu" error always traces to one tensor that didn't get moved.
MPS — the Apple Silicon backend
MPS works for the vast majority of standard ops. There are still a handful of operators that fall back to CPU (a warning is emitted by default unless you set PYTORCH_ENABLE_MPS_FALLBACK=1). torch.compile support on MPS is improving but still less mature than CUDA. For peak Apple Silicon performance, MLX is often a better fit because it was designed for the unified memory architecture from day one.