PyTorch는 GPU가 별도의 대륙인 세상에서 태어났어. NVIDIA 카드는 시스템 RAM과 물리적으로 떨어진 자체 VRAM을 쓰고, 둘 사이는 PCIe로 이어져. GPU를 쓰는 연산마다 tensor.to('cuda')로 목적지를 밝혀야 하고, 데이터도 복사해야 해. 국경을 넘을 때마다 신고서와 통행료가 붙는 셈이지.
Apple Silicon에는 그 국경이 없어. M 시리즈 칩은 CPU, GPU, Neural Engine을 한 실리콘에 넣고 같은 물리 RAM을 함께 써. 따로 떨어진 VRAM도, 프로세서 사이를 잇는 PCIe 버스도 없어. 메모리 하나가 있고, 다음 연산을 어느 장치가 맡을지만 정하면 돼.
MLX는 이 물리 구조를 정직하게 반영한 배열 라이브러리야. "통합 메모리"는 소프트웨어 묘기가 아니야. 하드웨어가 실제로 어떻게 생겼는지 프레임워크가 모른 척하지 않은 결과야.
코드에서는 무엇이 사라지나
두 코드를 나란히 보면 바로 보여. CUDA 위의 PyTorch 코드는 통관 신고를 차례로 적어야 해.
(이 PyTorch 코드는 비교용이야. mlx 환경에서 실행하지 말고 흐름만 읽어.)
이제 같은 일을 하는 MLX 코드를 봐. 무엇이 없는지가 핵심이야.
변화는 그게 전부야. .to(device)가 없고, 되돌아올 때 쓰는 .cpu()도 없고, 비동기 복사 흐름을 따로 관리하지도 않아. 배열은 처음부터 GPU와 CPU가 함께 보는 통합 메모리에 있고, MLX는 알맞은 연산 장치에서 알맞은 커널을 실행해.
직접 증명해보자 — 복사 없이 장치를 넘는 계산
이 실험을 해보면 감이 확 와. 배열 하나는 CPU에, 다른 하나는 GPU에 지정하고 둘을 더해. CUDA 위의 PyTorch라면 먼저 .to()로 한쪽을 옮기지 않았다고 오류가 나. MLX에서는 그냥 계산돼.
MLX에서 두 "장치"는 서로 다른 메모리 웅덩이가 아니야. 다음 연산을 어느 장치가 실행할지 붙이는 표지일 뿐이야. 데이터는 시작부터 끝까지 같은 곳에 있어.
기능 하나가 아니라 설계의 중심인 이유
MLX의 선택 대부분은 이 사실에서 나와. 지연 실행이 필요한 건 호출 하나씩 보지 않고 전체 그래프를 본 뒤, 각 연산을 어느 장치에서 실행할지 정해야 하기 때문이야. 똑똑하게 배치하려면 그래프를 먼저 확보해야 하지. mx.grad, mx.vmap 같은 함수 변환도 장치 사이 전송 비용을 따로 짤 필요가 없으니 다루기 쉬워져. 학습 반복문도 계산과 복사를 번갈아 끼워 넣을 필요 없이 그대로 돌아가.
이 퀘스트에서 그림 하나만 기억한다면 이거면 돼. Apple Silicon의 배열은 모든 연산 장치가 읽을 수 있는 하나의 바이트 풀에 산다. MLX는 그 사실을 끝까지 밀어붙인 프레임워크야.
Code
CUDA 위의 PyTorch — 통과 의례(대비용으로만 읽고 MLX 환경에서는 돌리지 마)·python
# Don't run this in the mlx env — it's a contrast, not a recipe.
import torch
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
x = torch.randn(1024, 1024).to(device) # ← copy bytes from RAM to VRAM
y = torch.randn(1024, 1024).to(device) # ← copy bytes from RAM to VRAM
z = (x @ y).cpu() # ← copy result back from VRAM to RAM
# Forget any one of these .to() / .cpu() calls and you get a runtime error
# or a silent slowdown.
MLX의 동등한 코드 — 뭐가 없는지 봐·python
import mlx.core as mx
x = mx.random.normal((1024, 1024))
y = mx.random.normal((1024, 1024))
z = x @ y
mx.eval(z) # materialize the lazy graph (more on this in core.lesson4)
print("shape :", z.shape)
print("dtype :", z.dtype)
print("device:", mx.default_device()) # → Device(gpu, 0) on Apple Silicon
# No .to('cuda'). No .cpu(). The unified-memory pool is the storage,
# always, and 'device' is just a label about which compute unit ran it.
장치 사이 덧셈 — 'device'가 메모리 풀이 아니라 표지라는 증명·python
import mlx.core as mx
mx.set_default_device(mx.cpu)
a_cpu = mx.array([1.0, 2.0, 3.0, 4.0])
print("default device:", mx.default_device()) # Device(cpu, 0)
print("a_cpu :", a_cpu) # array([1, 2, 3, 4], dtype=float32)
mx.set_default_device(mx.gpu)
a_gpu = mx.array([1.0, 2.0, 3.0, 4.0])
print("default device:", mx.default_device()) # Device(gpu, 0)
print("a_gpu :", a_gpu) # array([1, 2, 3, 4], dtype=float32)
# In PyTorch on CUDA, this next line is a runtime error (cross-device op).
# In MLX, it just works. There is one pool of bytes; the labels were a hint
# about scheduling, not a partition of memory.
b = a_cpu + a_gpu
mx.eval(b)
print("a_cpu + a_gpu :", b) # array([2, 4, 6, 8], dtype=float32)
mlx 환경에서 장치 사이 덧셈 코드 블록을 실행하고 array([2, 4, 6, 8])이 나오는지 확인해. 다음에는 a_cpu를 먼저 만든 뒤 b = a_cpu + a_cpu로 같은 장치의 배열끼리 더하고 b의 장치를 봐. 이어서 mx.set_default_device(mx.gpu)를 실행한 뒤 a_cpu + a_cpu를 다시 계산해. 무엇이 바뀌고 무엇이 그대로인지 두 문장으로 설명해봐. 이 연습의 목적은 장치 표지가 언제 중요하고 언제 중요하지 않은지 손으로 느끼는 거야.
Progress
Progress is local-only — sign in to sync across devices.