LeetLLM
My PlanLearnGlossaryTracksPracticeBlog
LeetLLM

Your go-to resource for mastering AI & LLM systems.

Product

  • Learn
  • Glossary
  • Tracks
  • Practice
  • Blog
  • RSS

Legal

  • Terms of Service
  • Privacy Policy

© 2026 LeetLLM. All rights reserved.

All Topics
Your Progress
0%

0 of 178 articles completed

🛠️Computing Foundations0/9
Git, Shell, Linux for AIDocker for Reproducible AIPython for AI EngineeringNumPy and Tensor ShapesCUDA for ML TrainingMPS & Metal for ML on MacData Structures for AISQL and Data ModelingAlgorithms for ML Engineers
📊Math & Statistics0/8
Gradients and BackpropVectors, Matrices & TensorsLinear Algebra for MLAdam, Momentum, SchedulersProbability for Machine LearningStatistics and UncertaintyDistributions and SamplingHypothesis Tests, Intervals, and pass@k
📚Preparation & Prerequisites0/13
Neural Networks from ScratchCNNs from ScratchTraining & BackpropagationSoftmax, Cross-Entropy & OptimizationRNNs, LSTMs, GRUs, and Sequence ModelingAutoencoders and VAEsThe Transformer Architecture End-to-EndLanguage Modeling & Next TokensFrom GPT to Modern LLMsPrompt Engineering FundamentalsCalling LLM APIs in ProductionFirst AI App End-to-EndThe LLM Lifecycle
🧮ML Algorithms & Evaluation0/11
Linear Regression from ScratchLogistic Regression and MetricsDecision Trees, Forests, and BoostingReinforcement Learning BasicsValidation and LeakageClustering and PCACore Retrieval AlgorithmsDecoding AlgorithmsExperiment Design and A/B TestingPyTorch Training LoopsDataset Pipelines and Data Quality
📦Production ML Systems0/6
Feature Engineering for Production MLBatch and Streaming Feature PipelinesGradient Boosted Trees in ProductionRanking and Recommendation SystemsForecasting and Anomaly DetectionMonitoring Predictive Models
🧪Core LLM Foundations0/8
The Bitter Lesson & ComputeBPE, WordPiece, and SentencePieceStatic to Contextual EmbeddingsPerplexity & Model EvaluationFile Ingestion for AIChunking StrategiesLLM Benchmarks & LimitationsInstruction Tuning & Chat Templates
🧰Applied LLM Engineering0/24
Dimensionality Reduction for EmbeddingsCoT, ToT & Self-Consistency PromptingFunction Calling & Tool UseMCP & Tool Protocol StandardsContext EngineeringPrompt Injection DefenseResponsible AI GovernanceData Labeling and Human FeedbackEvaluating AI AgentsProduction RAG PipelinesHybrid Search: Dense + SparseReranking and Cross-Encoders for RAGRAG Evaluation for Reliable AnswersLLM-as-a-Judge EvaluationBias & Fairness in LLMsHallucination Detection & MitigationLLM Observability & MonitoringExperiment Tracking with MLflow and W&BPrompt Optimization with DSPyModel Versioning & DeploymentSemantic Caching & Cost OptimizationLLM Cost Engineering & Token EconomicsModel Gateways, Routing, and FallbacksDesign an Automated Support Agent
🎓Portfolio Capstones0/9
Capstone: Delivery ETA PredictionCapstone: Product RankingCapstone: Demand ForecastingCapstone: Image Damage ClassifierCapstone: Production ML PipelineCapstone: Document QACapstone: Eval DashboardCapstone: Fine-Tuned ClassifierCapstone: Reproducible ML Study
🧠Transformer Deep Dives0/8
Sentence Embeddings & Contrastive LossEmbedding Similarity & QuantizationScaled Dot-Product AttentionVision Transformers and Image EncodersPositional Encoding: RoPE & ALiBiLayer Normalization: Pre-LN vs Post-LNMechanistic InterpretabilityDecoding Strategies: Greedy to Nucleus
🧬Advanced Training & Adaptation0/17
Scaling Laws & Compute-Optimal TrainingPre-training Data at ScaleBuild GPT from Scratch LabJAX for PyTorch ResearchersContinued Pretraining for Domain ShiftSynthetic Data PipelinesSupervised Fine-Tuning PipelineMixed Precision TrainingDistributed Training: FSDP & ZeROLoRA & Parameter-Efficient TuningTraining Run OperationsReward Modeling from Preference DataRLHF & DPO AlignmentConstitutional AI & Red TeamingRLVR & Verifiable RewardsKnowledge Distillation for LLMsModel Merging and Weight Interpolation
🤖Advanced Agents & Retrieval0/16
Vector DB Internals: HNSW & IVFAdvanced RAG: HyDE & Self-RAGGraphRAG & Knowledge GraphsRAG Security & Access ControlStructured Output GenerationReAct & Plan-and-ExecuteGuardrails & Safety FiltersCode Generation & SandboxingComputer-Use / GUI / Browser AgentsHuman-in-the-Loop Agent ArchitectureAI Coding Workflow with AgentsAgent Memory & PersistenceAgent Failure & RecoveryRecursive Language Models (RLM)Multi-Agent OrchestrationCapstone: Production Agent
⚡Inference & Production Scale0/19
Inference: TTFT, TPS & KV CacheMulti-Query & Grouped-Query AttentionKV Cache & PagedAttentionPrefix Caching and Prompt CachingFlashAttention & Memory EfficiencyContinuous Batching & SchedulingScaling LLM InferenceModel Parallelism for LLM InferenceModel Quantization: GPTQ, AWQ & GGUFLocal LLM DeploymentSLM Specialization & Edge DeploymentSpeculative DecodingLong Context Window ManagementMixture of Experts ArchitectureMamba & State Space ModelsReasoning & Test-Time ComputeAdvanced MLOps & DevOps for AIGPU Serving & AutoscalingA/B Testing for LLMs
🏗️System Design Capstones0/9
Content Moderation SystemCode Completion SystemMulti-Tenant LLM PlatformLLM-Powered Search EngineVision-Language Models & CLIPMultimodal LLM ArchitectureDiffusion Models: Images & TextReal-Time Voice AI AgentReasoning Agent System Design
🎤AI Lab Interviewing0/4
AI Lab Coding Interview: Python SystemsAI Lab System Design InterviewAI Lab Behavioral InterviewAI Lab Technical Presentation
🔬Project Deep Dives0/17
Deep Dive - vLLMDeep Dive - SkyRLDeep Dive - FlashAttentionDeep Dive - FlashInferDeep Dive - DeepGEMMDeep Dive - NCCLDeep Dive - MegatronDeep Dive - DeepSpeedDeep Dive - RayDeep Dive - MLflowDeep Dive - PyTorchDeep Dive - TransformersDeep Dive - SGLangDeep Dive - slimeDeep Dive - DeepEPDeep Dive - TinkerDeep Dive - Light-PEFT
Back to Topics
LearnComputing FoundationsMPS & Metal for ML on Mac
⚡EasyFine-Tuning & Training

MPS & Metal for ML on Mac

Train the same PyTorch ticket classifier on Apple silicon: select the `mps` device, keep tensors together, handle unsupported ops, time queued work honestly, and respond to unified-memory pressure.

15 min read
Learning path
Step 6 of 178 in the full curriculum
CUDA for ML TrainingData Structures for AI

Personalize this lesson

Adapt explanations and teaching visuals to your background and preferred voice.

Platform path

If you train on Linux or Windows with an NVIDIA GPU, start with CUDA for ML Training. If you train on a Mac with Apple silicon, use this matching backend path after that lesson's placement and timing contracts.

This builds on the CUDA placement and timing contracts. Four ticket descriptions still need a device tag, a matching model, and a timer that waits for queued work. The teaching batch is (B, T, D) = (4, 8, 16): four tickets, eight token positions, sixteen features. On an Apple silicon Mac the tag is mps instead of cuda, and the memory hardware underneath is not a second VRAM pool.[1]Reference 1Accelerated PyTorch training on Mac.https://developer.apple.com/metal/pytorch/[2]Reference 2MPS backend.https://docs.pytorch.org/docs/stable/notes/mps

Python still runs on the CPU. PyTorch launches GPU kernels only for tensors that already live on the selected device. The CUDA lesson used an NVIDIA device string. Here the same placement and timing contracts apply to Metal through PyTorch's MPS backend.

Metal, MPS, and the mps device

Metal is Apple's graphics and compute API. PyTorch doesn't ask you to write Metal shaders for ordinary training. It uses a Metal Performance Shaders (MPS) backend that maps tensor ops onto MPS Graph and tuned MPS kernels.[1]Reference 1Accelerated PyTorch training on Mac.https://developer.apple.com/metal/pytorch/[2]Reference 2MPS backend.https://docs.pytorch.org/docs/stable/notes/mps

Three names sit on different layers:

  • Metal: Apple's GPU programming framework.
  • MPS: Metal Performance Shaders, the backend PyTorch talks to.
  • mps: the PyTorch device string you pass to tensors and modules.

So the Mac question isn't "CUDA or GPU?" It's "which backend does this machine expose to PyTorch?"

Backend status

Apple's PyTorch setup page still labels the MPS backend beta. Operator coverage changes across PyTorch releases. An available backend means the GPU is reachable, not that every model runs there or runs faster than CPU.[1]Reference 1Accelerated PyTorch training on Mac.https://developer.apple.com/metal/pytorch/[3]Reference 3MPS Environment Variables.https://docs.pytorch.org/docs/stable/mps_environment_variables.html

One memory pool, two PyTorch devices

CUDA's picture was two physical pools: host RAM and discrete GPU video memory, with copies across an interconnect. Apple silicon uses a unified memory architecture: CPU and GPU share system memory instead of splitting storage into CPU RAM plus a separate VRAM card.[4]Reference 4MLX: An array framework for Apple siliconhttps://github.com/ml-explore/mlx

Keep the contrast tight:

  • On a discrete NVIDIA GPU, tensor.to("cuda") places storage in the GPU's own device memory.
  • On Apple silicon, tensor.to("mps") doesn't cross that same RAM-to-VRAM boundary. It still returns an mps tensor and selects MPS-backed execution. Don't treat the framework move as free or as always zero-copy. Apple's MLX arrays are designed around shared memory; PyTorch still keeps cpu and mps as separate device targets.[4]Reference 4MLX: An array framework for Apple siliconhttps://github.com/ml-explore/mlx[2]Reference 2MPS backend.https://docs.pytorch.org/docs/stable/notes/mps
Side-by-side memory topology for the same (4, 8, 16) ticket batch. On a discrete NVIDIA GPU the batch starts in host RAM as a cpu tensor, then .to("cuda") copies it into a separate GPU VRAM pool as cuda:0. On Apple silicon the batch stays in one unified physical memory pool while .to("mps") still retags it as mps:0. Pool count changes; PyTorch still requires the model and batch to share one device tag.
The stacked bars are topology, not capacity: CUDA has two pools with a copy between them, Apple silicon has one shared pool. Either way the `(4, 8, 16)` batch and the classifier must share one PyTorch device tag before the forward pass.

Two practical consequences follow:

  1. There isn't a dedicated VRAM budget for the integrated GPU. The GPU shares physical memory with the CPU, macOS, and other apps. The pool is still limited. macOS and PyTorch enforce working-set ceilings (recommended_max_memory, high watermark), so unified memory doesn't remove GPU memory pressure.
  2. Placement is still explicit. You still write cpu and mps, keep model and batch on compatible devices, and avoid needless host-visible scalar reads in the hot path.

Unified memory changes the hardware boundary. It doesn't let you skip device discipline.

The CUDA lesson kept (4, 8, 16) small enough for any card. That tiny float32 batch is only 2 KiB of raw features. A more realistic classifier step, 32 tickets with 128 tokens and 768 features, is what makes the memory bill visible:

ticket_batch_bytes.py
1tiny = 4 * 8 * 16 * 4 2realistic = 32 * 128 * 768 * 4 3mib = 1024 ** 2 4 5print(f"CUDA teaching batch (4, 8, 16): {tiny / 1024:.1f} KiB") 6print(f"larger step (32, 128, 768): {realistic / mib:.1f} MiB") 7print("training also keeps weights, activations, gradients, and optimizer state")
Output
1CUDA teaching batch (4, 8, 16): 2.0 KiB 2larger step (32, 128, 768): 12.0 MiB 3training also keeps weights, activations, gradients, and optimizer state

The expected output is the KiB and MiB pair for those two shapes. The 12.0 MiB figure is only the input features. Training still stores weights, activations, gradients, optimizer state, and temporary workspaces. On a Mac those tensors compete for the same system memory as the rest of the laptop.

Install on a supported Mac

Apple's setup page documents a wheel path for an Apple silicon Mac with macOS 14.0 or later, Python 3.10 or later, and Xcode command-line tools.[1]Reference 1Accelerated PyTorch training on Mac.https://developer.apple.com/metal/pytorch/ That page names a specific PyTorch release. The PyTorch install selector names its own current stable build. Both numbers move, so treat Apple's page as the Mac requirements floor, then confirm the wheel for your Python and macOS versions.[5]Reference 5Get Started.https://pytorch.org/get-started/locally/

Check the machine before changing model code. If xcode-select -p reports that developer tools are missing, run xcode-select --install once.

terminal
1xcode-select -p 2python3 --version 3sw_vers

Create a virtual environment so this lesson doesn't replace packages used by another project. Then install the current torch wheel:

terminal
1python3 -m venv .venv 2source .venv/bin/activate 3python -m pip install --upgrade pip 4python -m pip install torch

Prove what the installed backend can do

Start with one tiny script that distinguishes three states:

  1. This PyTorch binary was not built with MPS support.
  2. The binary knows about MPS, but this machine or OS can't use it right now.
  3. MPS is available, so you can move model and tensors onto mps.

PyTorch's own MPS note uses that is_built() / is_available() split. If the backend is missing from the wheel, it says the install was not built with MPS. If the wheel has MPS but the runtime can't use it, the current docs point at macOS below 14.0 or a machine without an MPS-capable device.[2]Reference 2MPS backend.https://docs.pytorch.org/docs/stable/notes/mps

Diagram showing is_built()?, False, No MPS in this wheel, and True.
is_built()?, False, No MPS in this wheel, and True.
mps_sanity_check.py
1import torch 2 3has_mps_backend = hasattr(torch.backends, "mps") 4mps_built = bool(has_mps_backend and torch.backends.mps.is_built()) 5mps_available = bool(has_mps_backend and torch.backends.mps.is_available()) 6 7device = torch.device("mps") if mps_available else torch.device("cpu") 8 9x = torch.arange(6, dtype=torch.float32).reshape(2, 3).to(device) 10model = torch.nn.Linear(3, 2).to(device) 11y = model(x) 12 13print(f"mps built: {mps_built}") 14print(f"mps available: {mps_available}") 15print(f"selected device: {device}") 16print(f"output shape: {tuple(y.shape)}")
Output
1mps built: True 2mps available: True 3selected device: mps 4output shape: (2, 2)

The printed mps built / mps available lines above are an example from a compatible Apple silicon Mac. On non-Mac hosts, older macOS, or a CPU-only wheel, expect False and selected device: cpu. The output shape stays (2, 2) either way.

  • built = False means this PyTorch binary lacks MPS support. That's expected off macOS. On a supported Mac, check the wheel and Python architecture.
  • built = True, available = False usually means the backend exists in the package, but OS, hardware, or runtime access is missing.
  • available = True means you can use torch.device("mps").

is_built() answers "does this wheel even know about MPS?" is_available() answers "can this specific machine use it right now?" Keep those two questions separate.

The same four tickets on mps

Reuse the CUDA lesson's batch. Four tickets, eight token positions, sixteen features. Average those token positions into one vector per ticket, then produce three logits per ticket. Labels stay [0, 2, 1, 0].

On a Mac training run, one small step usually looks like this:

StepCPU sidemps sideWhy it matters
batch assemblytokenizer, collator, padding, labelsnothing yetdata still starts on host
device movePython asks for .to("mps")batch becomes an mps tensorplacement is explicit
forward passhost launches opsMetal kernels run the mathmost heavy arithmetic lives here
loss readmaybe host asks for a scalardevice may need to finish queued work firstlogging can stall the loop
backward passautograd schedules gradient workgradient kernels run on mpsmemory now includes activations and grads
optimizer stephost calls step()parameter updates happen on mpsmodel stays on device across steps

Use the table as a debugging trace: for each row, name tensor location, kernel location, synchronization point, and likely failure.

Unified memory tempts people into "one laptop, one pool, one device." The hardware shares memory. PyTorch still doesn't. cpu and mps are separate device targets, and the forward pass still fails if model and batch land on different devices.[2]Reference 2MPS backend.https://docs.pytorch.org/docs/stable/notes/mps

mps_device_placement.py
1import torch 2import torch.nn as nn 3 4device = torch.device("mps" if torch.backends.mps.is_available() else "cpu") 5model = nn.Linear(16, 3).to(device) 6ticket_batch = torch.randn(4, 8, 16).to(device) 7 8ticket_vectors = ticket_batch.mean(dim=1) 9logits = model(ticket_vectors) 10devices_match = next(model.parameters()).device == ticket_batch.device == logits.device 11print("model and batch agree:", devices_match) 12print("ticket vectors:", tuple(ticket_vectors.shape)) 13print("logits shape:", tuple(logits.shape))
Output
1model and batch agree: True 2ticket vectors: (4, 16) 3logits shape: (4, 3)

That's the CUDA shape contract with a different device string: ticket vectors (4, 16), logits (4, 3). Continue from the placed classifier with one optimizer step. It uses MPS when available and still runs on a non-Mac machine:

one_mps_ticket_step.py
1import math 2 3import torch.nn.functional as F 4 5optimizer = torch.optim.SGD(model.parameters(), lr=0.1) 6labels = torch.tensor([0, 2, 1, 0], device=device) 7weights_before = model.weight.detach().clone() 8 9optimizer.zero_grad() 10loss = F.cross_entropy(model(ticket_vectors), labels) 11loss.backward() 12optimizer.step() 13logged_loss = loss.detach().cpu().item() 14 15print("weights changed:", not torch.equal(weights_before, model.weight.detach())) 16print("finite loss:", math.isfinite(logged_loss))
Output
1weights changed: True 2finite loss: True

The .cpu().item() call sits outside gradient computation. It marks the boundary where a scalar returns to the host for reporting.

If model and batch devices don't agree, fix placement before hunting deeper bugs:

catch_mps_mismatch.py
1import torch 2 3def require_same_device(model_device: torch.device, batch: torch.Tensor) -> None: 4 if batch.device != model_device: 5 raise RuntimeError(f"batch device does not match model device {model_device}") 6 7batch = torch.randn(4, 8, 16) 8try: 9 require_same_device(torch.device("mps"), batch) 10except RuntimeError as error: 11 print("caught:", error)
Output
1caught: batch device does not match model device mps

Same rule as CUDA. The Mac path is not an exemption from device consistency.

One Mac GPU isn't a CUDA cluster

PyTorch exposes torch.mps.device_count() for the number of available MPS devices.[6]Reference 6torch.mps.https://docs.pytorch.org/docs/stable/mps.html This lesson uses the default mps device, which Apple's verification example prints as mps:0.[1]Reference 1Accelerated PyTorch training on Mac.https://developer.apple.com/metal/pytorch/ Don't translate an Apple GPU core count into mps:0, mps:1, and so on. Core count and PyTorch device count answer different questions.

That differs from a CUDA server with several discrete cards. If a job needs multi-device or multi-node training, verify the target framework and communication backend on that cluster instead of translating CUDA distributed settings onto the Mac. The local MPS path here is for one-device development and smaller training runs.

Unsupported operations and CPU fallback

An operation without an MPS implementation can stop an otherwise valid training loop. PyTorch exposes PYTORCH_ENABLE_MPS_FALLBACK=1 so unsupported MPS operations can run on CPU instead of failing immediately.[3]Reference 3MPS Environment Variables.https://docs.pytorch.org/docs/stable/mps_environment_variables.html Treat that flag as a temporary compatibility aid, not a promise that every unsupported operation will work.

terminal
1PYTORCH_ENABLE_MPS_FALLBACK=1 python train.py

Set the variable in the process environment before PyTorch loads. Putting os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1" after import torch is often a no-op, because the C++ backend reads that state at initialization.

Fallback is a debugging tool

Fallback keeps debugging unblocked, but it can hide a CPU detour inside the accelerator path. That detour may add synchronization, device conversion, and copy work even though CPU and GPU share physical memory. If one hot operation keeps falling back, throughput can collapse while the script still looks correct.

CPU work isn't automatically fallback. Tokenization and batch assembly normally happen on CPU before .to("mps"). MPS fallback means a PyTorch operation on the accelerator path has no MPS implementation and runs on CPU instead.

One training-step bounce: the (4, 8, 16) ticket batch is tokenized on CPU, tagged mps, and mean runs as an MPS kernel. An unsupported operator then drops to CPU with a host sync before Linear(16, 3) continues on mps. Tokenization before .to("mps") is ordinary host prep, not this mid-step fallback.
Follow the dip: host prep on CPU is expected, then `.to("mps")` tags the batch. A missing MPS kernel is the expensive bounce. Linear continues on `mps` only after that sync.

Your tokenizer runs on CPU before ticket_batch.to("mps"). Is that MPS fallback?

Answer

No. CPU-side batch assembly before the device move is normal. MPS fallback happens when an unsupported PyTorch operation on the accelerator path runs on CPU instead of mps.

Use fallback to identify the blocking operation. Then remove the flag and choose deliberately: rewrite that operation, try a newer supported PyTorch release, keep the full workload on CPU, or accept the measured detour.

Keep precision changes measurable

The examples so far use float32. Keep that as the correctness baseline before trying lower precision. A blanket model.half() changes every floating-point parameter, including operations that may need more range, and makes numerical failures harder to localize.

PyTorch's automatic mixed precision (AMP) API chooses dtypes per operation. Its documentation recommends leaving model and inputs in their normal dtype, wrapping the forward pass and loss with torch.autocast, and using gradient scaling when float16 training needs it.[7]Reference 7Automatic Mixed Precision package - torch.amphttps://docs.pytorch.org/docs/stable/amp.html The official AMP examples are written for CUDA and CPU. On Mac, ask the installed build whether autocast is available for mps before copying a CUDA AMP snippet:

mps_precision_probe.py
1from contextlib import nullcontext 2 3import torch 4 5device = torch.device("mps" if torch.backends.mps.is_available() else "cpu") 6model = torch.nn.Linear(8, 3).to(device) 7features = torch.randn(4, 8, device=device) 8 9use_mps_amp = ( 10 device.type == "mps" 11 and torch.amp.autocast_mode.is_autocast_available("mps") 12) 13precision_context = ( 14 torch.autocast(device_type="mps", dtype=torch.float16) 15 if use_mps_amp 16 else nullcontext() 17) 18 19with precision_context: 20 logits = model(features) 21 loss = logits.square().mean() 22 23print("device:", device.type) 24print("MPS autocast enabled:", use_mps_amp) 25print("logits dtype:", logits.dtype) 26print("finite loss:", bool(torch.isfinite(loss).item()))

On a compatible Mac, this probe tells you whether the installed release enables MPS autocast and which dtype reached the logits. It doesn't prove the full training job is stable. Compare loss curves or evaluation metrics with the float32 baseline, then compare synchronized step time and peak memory. If results become non-finite or accuracy shifts, return to float32 before changing anything else. The later Mixed Precision Training lesson develops autocast and gradient scaling in depth.

Time and profile MPS work

The Mac timing trap looks like the CUDA timing trap. Kernel launches and queued device work can make naive timers lie. Warm up the operation first, then synchronize before and after the measured block. PyTorch exposes torch.mps.synchronize() for that boundary.[6]Reference 6torch.mps.https://docs.pytorch.org/docs/stable/mps.html

mps_timing.py
1import time 2import torch 3 4device = torch.device("mps" if torch.backends.mps.is_available() else "cpu") 5x = torch.randn(256, 256, device=device) 6w = torch.randn(256, 256, device=device) 7 8for _ in range(3): 9 y = x @ w 10if device.type == "mps": 11 torch.mps.synchronize() 12 13start = time.perf_counter() 14for _ in range(10): 15 y = x @ w 16if device.type == "mps": 17 torch.mps.synchronize() 18elapsed_ms = (time.perf_counter() - start) * 1000 19 20print("timed matmuls:", 10) 21print("result shape:", tuple(y.shape)) 22print("elapsed is nonnegative:", elapsed_ms >= 0)
Output
1timed matmuls: 10 2result shape: (256, 256) 3elapsed is nonnegative: True

Same hidden sync points still matter:

  • loss.item() when loss is an MPS tensor
  • tensor.cpu(), including tensor.cpu().numpy() for NumPy analysis
  • printing values that must come back to host memory

Calling .numpy() directly on an MPS tensor isn't the route back to NumPy: move the data to CPU first.

mps_logging_boundary.py
1import torch 2 3device = torch.device("mps" if torch.backends.mps.is_available() else "cpu") 4loss = torch.tensor(2.5, device=device) 5reported_loss = loss.detach().cpu().item() 6 7print(f"reported loss: {reported_loss:.1f}")
Output
1reported loss: 2.5

Don't trust timing claims until you know whether the host waited for the device. Also benchmark CPU and MPS on the same Mac, with the same shapes, dtype, warmup, and number of steps. An available GPU isn't proof that a tiny (4, 8, 16) step is faster there.

When a timer says the step is slow

A synchronized timer tells you whether the step is slow. A trace helps explain where time went. PyTorch's MPS profiler emits operating-system signposts that Xcode Instruments can record and display.[6]Reference 6torch.mps.https://docs.pytorch.org/docs/stable/mps.html

After opening Instruments with an OS Signpost or Logging trace, wrap only the steady-state region you want to inspect:

profile_mps_step.py
1import torch 2 3if not torch.backends.mps.is_available(): 4 raise SystemExit("Run this profiler example on an MPS-capable Mac") 5 6x = torch.randn(256, 256, device="mps") 7w = torch.randn(256, 256, device="mps") 8 9for _ in range(3): 10 y = x @ w 11torch.mps.synchronize() 12 13with torch.mps.profiler.profile( 14 mode="interval", 15 wait_until_completed=False, 16): 17 for _ in range(10): 18 y = x @ w 19torch.mps.synchronize() 20print("profiled matmuls:", 10)
Output
1profiled matmuls: 10

Keep wait_until_completed=False for a representative trace. Setting it to True waits after each encoded operation, which can make individual dispatches easier to inspect but changes the performance you're trying to understand. Look for long gaps between operations, many tiny dispatches, and CPU detours before guessing at a fix.[6]Reference 6torch.mps.https://docs.pytorch.org/docs/stable/mps.html

Memory pressure on Apple GPUs

The first memory lesson is the same as CUDA: weights aren't the whole bill. Activations, gradients, optimizer state, and temporary workspaces matter too.

Unified memory adds one twist. There isn't a separate video-memory pool: a large run competes for system memory with macOS and every other app. PyTorch exposes current_allocated_memory() for bytes occupied by live tensors, driver_allocated_memory() for total memory allocated by Metal for the process (including cached allocator blocks and MPS/MPSGraph allocations), and empty_cache() to release unoccupied cached memory.[6]Reference 6torch.mps.https://docs.pytorch.org/docs/stable/mps.html empty_cache() doesn't free tensors that are still alive, so it can't repair a workload whose real peak exceeds the limit.

Inspect those counters only after MPS is available:

mps_allocator_check.py
1import torch 2 3if torch.backends.mps.is_available(): 4 before = torch.mps.current_allocated_memory() 5 tensor = torch.ones(1024, 1024, device="mps") 6 after = torch.mps.current_allocated_memory() 7 del tensor 8 torch.mps.empty_cache() 9 print("live tensor allocation increased:", after > before) 10 print("recommended limit reported:", torch.mps.recommended_max_memory() > 0) 11else: 12 print("MPS allocator counters need an available mps device")

When memory gets tight, fix order should stay boring:

SymptomFirst questionFirst fix
OOM on first real batchIs batch or sequence length too large?shrink batch size first
Step time swings wildlyAre unsupported ops or sync points bouncing work back to CPU?check fallback and logging paths
MPS allocator errorsAre you near working-set limits?reduce workload before touching allocator env vars
macOS starts swappingIs training competing with other memory-heavy apps?stop the run, reduce workload, then close unneeded apps

PyTorch also exposes MPS-specific allocator controls such as PYTORCH_MPS_HIGH_WATERMARK_RATIO and PYTORCH_MPS_LOW_WATERMARK_RATIO. The current docs default the high watermark to 1.7 and use different low-watermark defaults for unified versus discrete GPUs.[3]Reference 3MPS Environment Variables.https://docs.pytorch.org/docs/stable/mps_environment_variables.html Those are advanced tuning knobs, not first response. Disabling the high watermark (0.0) can cause system failure under system-wide out-of-memory conditions. Start by shrinking work.

For the larger (32, 128, 768) budget, common first fixes stay boring on purpose:

  1. lower per-step batch size before touching allocator ratios
  2. shorten sequence length if the task allows it
  3. remove needless .cpu() calls before blaming Metal
  4. confirm fallback isn't firing inside the hot path
mps_workload_reduction.py
1batch_size = 32 2sequence_length = 128 3baseline_positions = batch_size * sequence_length 4 5for label, batch, tokens in [ 6 ("baseline", 32, 128), 7 ("half batch", 16, 128), 8 ("half length", 32, 64), 9]: 10 share = (batch * tokens) / baseline_positions 11 print(f"{label:11s}: {share:.0%} of token positions")
Output
1baseline : 100% of token positions 2half batch : 50% of token positions 3half length: 50% of token positions

Memory lever: Both changes halve token positions and many activation tensors. Shorter sequences can reduce attention score tensors faster because attention has two sequence axes.

Diagnose by symptom, not by backend name

Use the smallest failing batch and preserve its shape, dtype, device, and PyTorch version. Those four facts separate placement, operator coverage, precision, timing, and capacity failures that otherwise all look like "MPS is slow."

Observed symptomMost likely boundary to check firstEvidence to collect
is_built() or is_available() is falsepackage, hardware, or macOSboth booleans, torch.__version__, sw_vers
forward pass reports mixed devicesplacementmodel parameter device and every batch tensor device
run needs the fallback flagoperator coverageexact unsupported operator and PyTorch version
loss becomes NaN after precision changenumerical rangedtype, first non-finite step, float32 baseline
host timer looks impossibly fastasynchronous executionwarmup plus synchronized timing
allocator OOMs or macOS swapslive workload and system pressurebatch shape, both MPS memory counters, open-app pressure

You are on an Apple silicon Mac. torch.backends.mps.is_built() is True, but torch.backends.mps.is_available() is False. What does that tell you first?

Answer

PyTorch knows how to speak to the MPS backend, but current machine state still blocks usage. Check macOS version, hardware support, runtime access, and install path before blaming your model code.

Your training loop "works" only with PYTORCH_ENABLE_MPS_FALLBACK=1, but step time is awful. What is most likely happening?

Answer

One or more operations are running through CPU fallback. The script survives, but repeated backend switches, synchronization, or unsupported hot-path ops are erasing GPU gains.

What to take onto the next lesson

  • Metal is Apple's GPU stack. mps is PyTorch's device name for using it.
  • Apple silicon uses unified memory: CPU and GPU share system memory, but .to("mps") still selects MPS execution and doesn't promise a cost-free move. Apple currently labels the MPS backend beta.[4]Reference 4MLX: An array framework for Apple siliconhttps://github.com/ml-explore/mlx[1]Reference 1Accelerated PyTorch training on Mac.https://developer.apple.com/metal/pytorch/
  • Mac training still needs explicit device placement. The CUDA (4, 8, 16) ticket step is the same contract with a different tag.
  • is_built() and is_available() answer different setup questions.
  • PYTORCH_ENABLE_MPS_FALLBACK=1 is useful, but it can hide slow CPU detours.
  • Keep float32 as the baseline; test autocast against accuracy, memory, and synchronized timing.
  • Honest timing needs synchronization, while MPS signpost traces explain slow dispatch paths.

If you also work on NVIDIA servers, keep CUDA for ML Training nearby. Same placement and timing habits, different backend details.

Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.You are porting a PyTorch training step to an Apple silicon Mac and write model.to("mps"). Which description of the stack is correct?

Correct answer: Metal is Apple’s GPU framework; PyTorch’s MPS backend exposes it through the mps device.

The names sit at different layers. Metal is Apple's GPU compute framework, MPS is the backend PyTorch uses through Metal Performance Shaders, and mps is the PyTorch device target for tensors and modules. You still write ordinary PyTorch code.

2.On Apple silicon, a tensor is moved with ticket_batch.to("mps"). Which statement matches the unified-memory model PyTorch uses?

Correct answer: CPU and GPU share system memory, but .to("mps") still creates an mps tensor and selects MPS execution.

Unified memory means CPU and GPU share system memory instead of separate CPU RAM and discrete VRAM. It doesn't erase PyTorch device targets: moving to mps still returns an mps tensor and selects MPS-backed execution.

3.Your check prints mps built: True, mps available: False, and selected device cpu. What does that state mean?

Correct answer: The PyTorch build includes MPS, but this machine or runtime can't use it right now.

is_built() asks whether the installed PyTorch build contains MPS support. is_available() asks whether the current hardware, OS, and runtime can actually use MPS now. Unsupported operators and allocator pressure are later runtime issues.

4.A model is on mps. A tokenizer produced ticket_batch = torch.randn(4, 8, 16) on CPU. The classifier averages tokens with mean(dim=1) and then applies nn.Linear(16, 3). What must happen before the forward pass succeeds on MPS, and what shape should the logits have?

Correct answer: Move ticket_batch to mps; the token average is (4, 16) and logits are (4, 3).

PyTorch still requires compatible devices for the model and batch. Averaging over the 8 token positions leaves one 16-feature vector for each of 4 tickets, and the linear classifier maps each vector to 3 class logits. That is the same shape contract as the CUDA lesson.

5.Your ticket classifier fails on mps unless you run PYTORCH_ENABLE_MPS_FALLBACK=1. With the flag, the script finishes but each step is much slower. Tokenization happened on CPU before ticket_batch.to("mps"). What is the most likely diagnosis?

Correct answer: An unsupported hot-path operation is falling back to CPU; identify it instead of blaming CPU tokenization.

MPS fallback is for unsupported PyTorch operations on the accelerator path. It can keep a run alive, but repeated CPU detours in the hot loop can destroy throughput. CPU tokenization before .to("mps") is normal preprocessing, not fallback.

6.A ticket-classifier forward pass enqueues about 35 ms of mps work, but Python finishes launching it in about 3 ms. A timer around only the Python call reports 3 ms. What should you change to measure the device work honestly?

Correct answer: Synchronize before and after a warmed-up MPS block when timing device work, avoiding a dispatch-only timer.

MPS work can be queued while Python continues, so a host timer can measure launch time instead of completed device work. Synchronizing around the measured block makes the host wait for queued kernels. A CPU scalar read is also a sync point, but putting logging inside the benchmark changes what you are measuring.

7.How much storage is used by the raw float32 feature values in one larger ticket batch shaped (32, 128, 768), and why is that not the whole training memory bill?

Correct answer: About 12.0 MiB; training also stores weights, activations, gradients, optimizer state, and temporary work.

The larger memory-budget batch has 32 * 128 * 768 float32 values. At 4 bytes each, that is 12,582,912 bytes, or about 12.0 MiB. The CUDA teaching batch (4, 8, 16) is only 2 KiB of features. Training uses more memory than the input batch because it also needs parameters, activations, gradients, optimizer state, and workspaces.

8.While investigating MPS memory pressure, which statement correctly uses the allocator tools and first-line fix?

Correct answer: current_allocated_memory() tracks live tensors; driver_allocated_memory() includes broader process allocations. Reduce workload first.

The MPS counters distinguish live tensor allocation from total memory allocated by Metal for the process, including cached blocks and graph allocations. empty_cache() releases unoccupied cached memory, not live tensors. For pressure or allocator errors, start by reducing batch size, sequence length, or other workload drivers before changing watermark ratios.

9.A float32 MPS training run is stable. You enable float16 autocast and memory use falls, but validation accuracy drops and loss becomes non-finite after several steps. What is the sound next move?

Correct answer: Restore float32, find the first non-finite value, and keep autocast only if accuracy and timing hold.

Lower precision is a measured optimization, not a correctness fix. The stable float32 run is the control. Restore it, find where values first become non-finite, and accept autocast only after accuracy, memory, and honest timing all meet the workload's requirements.

10.Synchronized timing proves an MPS step is slow, but it doesn't show which operations or gaps dominate. Which next tool fits that question?

Correct answer: Capture the steady-state region with torch.mps.profiler.profile() and inspect its OS Signposts in Xcode Instruments.

A trace shows operation intervals and gaps that a single elapsed-time number can't. Keep the profiled region steady-state and remember that forcing completion after every dispatch changes the timing you are trying to explain.

10 questions remaining.

Next Step
Continue to Data Structures for AI

You can now place a training batch on `cuda` or `mps`, time queued kernels honestly, and shrink work when memory pressure shows up. The next lesson leaves the accelerator and asks a host-side question: once those tensors feed real pipelines, which in-memory structures make lookups, queues, caches, and <span data-glossary="top-k">top-k</span> cheap enough to keep the GPU busy.

PreviousCUDA for ML Training
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
References

Accelerated PyTorch training on Mac.

Apple · 2026

https://developer.apple.com/metal/pytorch/

MPS backend.

PyTorch Contributors · 2026

https://docs.pytorch.org/docs/stable/notes/mps

MPS Environment Variables.

PyTorch Contributors · 2026

https://docs.pytorch.org/docs/stable/mps_environment_variables.html

MLX: An array framework for Apple silicon

Apple (ml-explore) · 2026

https://github.com/ml-explore/mlx

Get Started.

PyTorch Contributors · 2026

https://pytorch.org/get-started/locally/

torch.mps.

PyTorch Contributors · 2026

https://docs.pytorch.org/docs/stable/mps.html

Automatic Mixed Precision package - torch.amp

PyTorch Contributors · 2026

https://docs.pytorch.org/docs/stable/amp.html

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.