C.W.K.
Stream
Lesson 05 of 06 · published

MLX vs PyTorch MPS vs CoreML — The Three-Way Honest Comparison

~16 min · comparison, pytorch-mps, coreml

Level 0Curious
0 XP0/51 lessons0/15 achievements
0/100 XP to next level100 XP to go0% complete

Three frameworks, three different jobs

This is the lesson where we calibrate. Most of MLX Quest is MLX-positive by necessity — we're teaching it. But MLX exists in a real ecosystem with two other ML stacks that also live on Apple Silicon, and conflating them costs you time. Here is the honest stack-up.

  • MLX — Apple's research-oriented array framework, Python-first, native to unified memory.
  • PyTorch with the MPS backend — PyTorch's CUDA-shaped API translated into Metal calls so existing PyTorch code can run on Mac.
  • CoreML — Apple's app-shipping ML runtime, Swift-first, designed to embed pre-trained models inside iOS / macOS / watchOS / tvOS apps.

The shape of each one

PyTorch MPS is a translator. Its goal is "PyTorch code, written for CUDA, runs on Mac with as few changes as possible." It uses the same torch.tensor objects you know and accepts device='mps' instead of device='cuda:0'. The MPS backend translates each PyTorch operation into Metal Performance Shaders calls. This is great when you have an existing PyTorch codebase you want to iterate on locally. It's not great when you want native Apple-Silicon-shaped APIs — because it inherits the CUDA worldview where the GPU is a separate device with its own memory.

MLX is the native speaker. The API is shaped around the unified-memory hardware (lesson 2). The mental model assumes you can do CPU and GPU work without copying, that lazy graphs are normal, that function transforms are first-class. This is great when you're starting fresh on Apple Silicon. It's not great when you need to run an existing PyTorch model that uses fifty CUDA-specific ops with no MLX equivalent.

CoreML is the runtime. You convert a trained model (from PyTorch, TensorFlow, or anything) into a .mlpackage and embed it in your app. The runtime handles model loading, inference, and (importantly) on-device deployment guarantees — App Store distribution, on-device privacy, integration with Vision and Speech frameworks. This is great when you're shipping a feature inside an app. It's not great when you're doing research, or when you want to update the model independently from app releases.

The same matrix multiply, three ways

Below are the equivalents in MLX and PyTorch MPS. Both were verified in the same mlx env (which happens to also have torch installed, so MPS works). CoreML doesn't have a Python-level matmul snippet that's worth showing — its Python side is for model conversion, not array math.

The decision tree

  • Doing research / new project on Apple Silicon → MLX. The native speaker beats the translator.
  • Existing PyTorch codebase, want to develop on Mac → PyTorch MPS. Less rewriting; you'll discover gaps in MPS coverage as you go.
  • Shipping a model inside an iOS / macOS / watchOS / tvOS app → CoreML. App Store distribution, privacy guarantees, and OS-level integration matter.
  • Serving an LLM locally over an HTTP endpoint → MLX (specifically mlx-lm; see Track 2).
  • Just want "hot model, no fuss" without writing Python → Ollama (which is built on MLX since v0.19; see compare.lesson1).

What this lesson is not saying

I'm not telling you MLX is faster than PyTorch MPS in every benchmark — sometimes MPS wins, sometimes MLX wins, and the leader changes between MLX releases. I'm telling you the frameworks are shaped for different jobs. Pick by job, not by tribe.

Code

Same matmul in MLX·python
import mlx.core as mx

x = mx.random.normal((1024, 1024))
y = x @ x.T
mx.eval(y)
print("MLX     :", tuple(y.shape), y.dtype, mx.default_device())

# Verified output (2026-05-03):
#   MLX     : (1024, 1024) mlx.core.float32 Device(gpu, 0)
Same matmul in PyTorch with MPS backend·python
# Requires PyTorch ≥ 2.0 with MPS support and macOS ≥ 12.3.
import torch

device = torch.device("mps" if torch.backends.mps.is_available() else "cpu")
x = torch.randn(1024, 1024, device=device)
y = x @ x.T
print("PyTorch :", tuple(y.shape), y.dtype, y.device)

# Verified output (2026-05-03, torch 2.11.0):
#   PyTorch : (1024, 1024) torch.float32 mps:0
CoreML model conversion (sketch — Swift is the deployment side)·python
# CoreML's Python surface is the conversion side, not array math.
# A typical workflow is: train in PyTorch, export to ONNX, convert to .mlpackage,
# then embed in a Swift app. Sketch only — not for execution here.
import coremltools as ct
import torch

# Suppose you have a trained PyTorch model `model` ...
# example_input = torch.rand(1, 3, 224, 224)
# traced = torch.jit.trace(model.eval(), example_input)
# mlmodel = ct.convert(
#     traced,
#     inputs=[ct.TensorType(shape=example_input.shape)],
#     compute_units=ct.ComputeUnit.ALL,   # CPU + GPU + Neural Engine
# )
# mlmodel.save("MyModel.mlpackage")

External links

Exercise

Run both the MLX and PyTorch MPS matmul code blocks (the second only works if torch is installed in your env — pip install torch if not, but in a separate env if you want to keep mlx clean). Time both with python -c 'import time; ...' for a 4096×4096 matmul, three runs each, take the median. The point is *not* to declare a winner — it's to feel the timing variance and notice that the answer depends on shape, dtype, and which framework released last. Two sentences on what you noticed.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.