~16 min · journey, pytorch, mps, dispatch-overhead, op-coverage, measured
Level 0스펙 시트 훑는 사람
0 XP0/91 lessons0/19 achievements
0/100 XP to next level100 XP to go0% complete
"MPS 경로는 연산이 크면 대역폭을 끌어오고 연산이 작으면 디스패처 값을 치러. 토큰 하나는 작은 연산 천 개야."
장치가 없는 기계에 붙은 장치라는 말
PyTorch는 mps 백엔드로 애플 GPU에 닿아. 자체 문서는 그걸 "계산 그래프와 프리미티브를 고효율 Metal Performance Shaders Graph 프레임워크와 튜닝된 커널에 매핑"하는 것으로 설명하고, 들어가는 방법은 문서가 말하는 그대로야. "텐서와 모듈을 mps 장치로 옮기기만 하면 된다." 그건 CUDA의 어휘야. 장치 하나, 옮기기 하나. 옮겨 갈 두 번째 자리가 없는 풀 위에 얹힌. GPU 트랙이 그 동사를 쟀어. office에서 1기가바이트에 .to('mps')는 66 ms야. 풀 하나 안에서의 복사. 프레임워크의 기계 모델엔 메모리가 둘이고 하드웨어엔 하나니까. 이 경로의 나머지 전부가 그 불일치에서 양방향으로 따라 나와. 프레임워크의 모델이 그저 군더더기인 곳에선 경로가 빠르고, 그 모델이 하중을 받치는 곳에선 경로가 부서져.
빠른 자리: 큰 연산
office에서 torch 2.12로 실측. 8192² 행렬곱은 fp32로 18.1 TFLOP/s, bf16로 21.9. CPU 트랙에서 MLX가 n = 4096 fp32로 19.5까지 몰았던 같은 실리콘이니까 커널이 문제는 아니야. 2.15 GB 행렬에 bf16 행렬-벡터 곱은 3.55 ms. 605 GB/s, 이 퀘스트가 이 기계에서 잰 스트리밍 대역폭의 95%. 연산 하나가 크면 MPS 경로는 풀을 풀의 속도로 읽어. 음악 학습 엔진의 음원 분리기 Demucs가 큰 연산의 사례야. torch 2.12에서 30초 오디오를 GPU로 1.18 s에 분리해. CPU 스레드 24개로는 12.6 s. 출력은 상대 L2 1.7% 안에서 일치. 같은 맥에서 열 배 빨라. 이미지 엔진의 경로 전체가 이 사례야. server의 MPS 위의 diffusers, 프로세스 하나, 체크포인트와 업스케일러와 인페인팅이 전부 같은 문으로. 그리고 그게 이 집의 유일한 이미지 생성 호스트야.
부서지는 자리: 작은 연산, 없는 연산, 낡은 믿음
같은 맥, 같은 bf16 Llama-3.2-1B 체크포인트(토큰당 2.47 GB, 묶인 임베딩, 638 GB/s에서 상한 초당 258토큰)를 두 방법으로 디코드했어. mlx-lm: 초당 190토큰, 토큰당 5.3 ms, 상한의 74%. transformers를 지나 MPS 위의 PyTorch, 그리디, 이거(eager): 초당 32–42토큰, 토큰당 24–31 ms, 12–16%. 바이트 값은 방금 행렬-벡터 곱이 보여 준 대역폭에서 4 ms야. 나머지 20여 밀리초가 디코드 상한 레슨의 고정 항이 최악인 모습이야. 각각 열두 개쯤의 연산인 레이어 열여섯 개, 모든 연산이 프레임워크를 지나 따로따로 디스패치되고, 풀이 빠르게 못 만드는 파이썬 루프에 동기화돼. 같은 가중치, 같은 버스, 속도는 5분의 1, 그 차이 안에 칩에 대한 건 하나도 없어.
그다음, 거기 없는 연산들. torch 2.12는 MPS에서 float64를 아예 거부하고("the MPS framework doesn't support float64"), linalg.eigh는 MPS 사용자라면 다 읽어 본 그 오류를 던져. "not currently implemented for the MPS device … you can set the environment variable PYTORCH_ENABLE_MPS_FALLBACK=1 to use the CPU as a fallback." 폴백을 켜고 실측하면 2048² eigh가 350 ms, CPU에서 직접은 371. 폴백은 비용이 없었어. 'CPU로의 복사'가 풀 하나 안이었으니까. 별도 GPU에선 같은 폴백이 버스 횡단 둘을 치러. 통합 메모리가 프레임워크의 기계 모델을 조용히 덮어 주는 거야.
그리고 낡은 믿음. 음악 엔진의 코드는 MPS가 Demucs에 고장 났다는 주석과 함께 Demucs를 CPU에 고정하고, 자체 문서는 엔진이 MPS에서 돈다고 말해. 엔진이 실제로 도는 torch에서 MPS는 고장 나지 않았어. 열 배 빨라. 한 번 부서진 경로는 런타임이 고친 뒤에도 오래 코드 안에서 부서진 채로 남아. 이 퀘스트는 이 발견을 고치지 않고 가족에게 보고해. 엔진은 퀘스트가 고칠 게 아니니까. 네 맥 카드엔 일반 규칙이 들어가. MPS '안 됨'은 torch 버전을 옆에 적어서 카드에 올리고, 만료돼.
same_bytes_two_paths.py — bf16 체크포인트 하나를 torch MPS와 mlx-lm으로 디코드·python
#!/usr/bin/env python3
"""The same bf16 checkpoint decoded two ways on one Mac: PyTorch on MPS through
transformers, and mlx-lm. Same bytes per token, same pool, two paths."""
import sys, time
MODEL = "unsloth/Llama-3.2-1B-Instruct" # bf16 safetensors, tied embeddings
PROMPT = "Explain, in about 300 words, why unified memory matters for language models."
N = 128
def torch_mps():
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
tok = AutoTokenizer.from_pretrained(MODEL)
model = AutoModelForCausalLM.from_pretrained(MODEL, dtype=torch.bfloat16).to("mps").eval()
msgs = [{"role": "user", "content": PROMPT}]
ids = tok.apply_chat_template(msgs, add_generation_prompt=True, return_tensors="pt", return_dict=True)["input_ids"].to("mps")
nbytes = sum(p.numel() * p.element_size() for p in model.parameters())
with torch.no_grad():
model.generate(ids, max_new_tokens=8, do_sample=False) # warm-up
torch.mps.synchronize(); t0 = time.perf_counter()
out = model.generate(ids, max_new_tokens=1, do_sample=False)
torch.mps.synchronize(); ttft = time.perf_counter() - t0
t0 = time.perf_counter()
out = model.generate(ids, max_new_tokens=N, do_sample=False, min_new_tokens=N)
torch.mps.synchronize(); dt = time.perf_counter() - t0
gen = out.shape[1] - ids.shape[1]
print(f"torch {torch.__version__} MPS via transformers: {nbytes/1e9:.2f} GB of bf16 weights; prompt {ids.shape[1]} tokens; "
f"TTFT {ttft:.3f}s; {gen} tokens in {dt:.2f}s = {gen/(dt-ttft):.1f} tok/s (decode, TTFT removed)")
def mlx_path():
import mlx.core as mx
from mlx_lm import load, stream_generate
from mlx_lm.sample_utils import make_sampler
model, tok = load(MODEL) # HF bf16 loaded as-is
msgs = [{"role": "user", "content": PROMPT}]
prompt = tok.apply_chat_template(msgs, add_generation_prompt=True)
sampler = make_sampler(temp=0.0)
for _ in stream_generate(model, tok, prompt, max_tokens=8, sampler=sampler): pass # warm-up
last = None
for r in stream_generate(model, tok, prompt, max_tokens=N, sampler=sampler): last = r
print(f"mlx {mx.__version__} mlx-lm: prompt {len(prompt)} tokens; prompt {last.prompt_tps:.0f} tok/s; "
f"decode {last.generation_tps:.1f} tok/s; peak {last.peak_memory:.2f} GB")
if __name__ == "__main__":
{"torch": torch_mps, "mlx": mlx_path}[sys.argv[1]]()
# office, M3 Ultra, 2026-09-15:
# torch 2.11.0 MPS via transformers 5.14.1: 2.47 GB bf16; prompt 52; TTFT 0.024s; 42.1 tok/s, then 31.8 on a second run
# mlx 0.32.2 / mlx-lm 0.31.3: prompt 2350 tok/s; decode 190.0 tok/s; peak 2.56 GB
# ceiling for 2.47 GB per token at 638 GB/s: 258 tok/s
mps_breaks.py — 큰 연산, 없는 연산, 그리고 풀 하나 안에서 폴백이 치르는 값·python
#!/usr/bin/env python3
"""Run with PYTORCH_ENABLE_MPS_FALLBACK=1 so the missing op falls back instead of raising."""
import time, torch
def timeit(fn, n=3):
fn(); torch.mps.synchronize()
t = time.perf_counter()
for _ in range(n):
fn()
torch.mps.synchronize()
return (time.perf_counter() - t) / n
x = torch.randn(8192, 8192, device="mps")
for name, a in (("fp32", x), ("fp16", x.half()), ("bf16", x.bfloat16())):
dt = timeit(lambda: a @ a, 5)
print(f"matmul 8192^2 {name} on mps: {2*8192**3/dt/1e12:.2f} TFLOP/s")
W = torch.randn(32768, 32768, device="mps", dtype=torch.bfloat16); v = torch.randn(32768, device="mps", dtype=torch.bfloat16)
dt = timeit(lambda: W @ v, 10)
print(f"matvec over {W.numel()*2/1e9:.2f} GB bf16 on mps: {dt*1e3:.2f} ms = {W.numel()*2/dt/1e9:.0f} GB/s")
s = torch.randn(2048, 2048); s = s @ s.T
s_mps = s.to("mps")
print(f"eigh 2048^2 on cpu: {timeit(lambda: torch.linalg.eigh(s))*1e3:6.1f} ms")
print(f"eigh 2048^2 on mps (CPU fallback, env): {timeit(lambda: torch.linalg.eigh(s_mps))*1e3:6.1f} ms -> result on {torch.linalg.eigh(s_mps)[0].device}")
try:
torch.randn(64, 64, device="mps", dtype=torch.float64)
except TypeError as e:
print("float64:", e)
# office, torch 2.12.0, 2026-09-15:
# matmul 8192^2 fp32 18.05 / fp16 21.09 / bf16 21.88 TFLOP/s
# matvec over 2.15 GB bf16: 3.55 ms = 605 GB/s
# eigh 2048^2 on cpu 371.3 ms; on mps with fallback 350.2 ms -> result on mps:0
# float64: Cannot convert a MPS Tensor to float64 dtype as the MPS framework doesn't support float64. Please use float32 instead.
# without the env var, eigh raises: "The operator 'aten::_linalg_eigh.eigenvalues' is not currently implemented for the MPS device ..."
들어가는 bf16 체크포인트로 네 맥에서 same_bytes_two_paths.py를 두 방법 다 돌려. 각각의 토큰당 밀리초를 구하고, 바이트 항(바이트 ÷ 네가 잰 대역폭)을 빼서 고정 항 둘을 카드에 적어. 그다음 mps_breaks.py를 돌려서 네 torch가 거부하는 연산을 기록하고, 옆에 torch 버전을 적어. 그 줄은 만료될 테니까.
Hint
torch 고정 항이 MLX 것의 열 배면 파이썬 프레임워크를 지나는 이거 디코드에선 정상이지, 설치가 망가진 게 아니야. 행렬-벡터 곱 대역폭이 네 스트림 수치보다 한참 낮으면 행렬이 디스패치를 숨길 만큼 크지 않았던 거야. 최소 1기가바이트짜리 행렬을 써.
Progress
Progress is local-only — sign in to sync across devices.