~15 min · journey, mlx, decode-loop, kv-cache, lazy-evaluation, measured
Level 0스펙 시트 훑는 사람
0 XP0/91 lessons0/19 achievements
0/100 XP to next level100 XP to go0% complete
"프리필 한 번. 그다음 같은 가중치로 토큰 하나씩, 자라나는 캐시와 함께. 루프를 직접 써 보면 믿어야 할 게 하나도 안 남아."
루프를 풀어 쓰면
mlx 퀘스트는 MLX를 프레임워크로 가르쳐. 배열, 지연 평가, 스트림, 양자화, 모델 동물원. 이 레슨은 그걸 한 가지 목적에만 써. 라이브러리 루프가 가리는 것 없이 모델을 지나는 토큰의 경로를 보여 주는 것. 코드 블록은 모델을 로드하고(매핑됨, 지연됨. 앞 레슨), make_prompt_cache로 레이어마다 KVCache 객체를 하나씩 만들고, 프롬프트 전체를 model(prompt, cache=cache)에 한 번 통과시켜. 그게 프리필이야. 그다음 루프야. 토큰 하나 들어가고, 같은 가중치를 지나는 순전파 한 번, 토큰 하나 나오고, 캐시는 한 자리 길어져. 그게 디코드고, 물리 트랙의 모든 주장이 코드에 보여. 가중치는 매 반복마다 같은 객체야. 바뀌는 건 캐시야. 샘플링된 토큰에 거는 mx.eval이 지연 평가가 강제되고 GPU가 실제로 도는 자리야.
office에서 손으로 쓴 루프는 9B를 실행마다 초당 68–79토큰으로 디코드했어. 같은 모델에서 mlx-lm의 stream_generate는 95. 그 격차가 디코드 상한의 고정 항이야. 손 루프는 토큰마다 .item()을 불러 파이썬으로 읽어 오고, 그게 매 단계 GPU를 동기화해. 그리고 매 단계를 인터프리터에서 몰아. 라이브러리는 파이프라인을 꽉 채운 채로 유지해. 같은 가중치, 같은 버스, 같은 캐시. 속도의 5분의 1에서 4분의 1이 루프의 모양에 남아 있었어(95에 대해 79와 68.5, 두 번 실행).
순전파 한 번 안에 있는 것
model(...) 호출 하나하나는 MLX가 지연해서 만들고 Metal 스트림에서 돌리는 그래프야. 모델의 레이어마다 정규화 하나, 어텐션 블록 하나(하이브리드 배치에 따라 풀 또는 리니어), 피드포워드 블록 하나. 각각은 4비트 가중치와 그 스케일을 읽는 양자화 행렬-벡터 커널 몇 개야. 물리 트랙의 토큰당 바이트를 한 번 읽는 거야. 풀 어텐션 레이어에선 커널이 이전 위치 전부의 캐시도 읽고, 그게 컨텍스트 기울기야. 출력 헤드가 어휘 전체에 대한 로짓을 내고, argmax가 하나를 고르고(실험은 실행이 정확히 반복되도록 그리디 샘플링을 써), 그 정수가 다음 입력이야. 단계 사이에 경계를 건너는 건 없어. 호스트 쪽 사건은 토큰 id를 읽어 오는 것 하나뿐이야.
이 집이 이 경로를 도는 이유
세 가지고, 각각이 트랙 하나야. 가중치 하나하나를 한 번, 전폭으로 읽어. 디코드 상한 레슨이야. 그리고 27B에서 상한의 74%에 닿았고, 이 퀘스트가 잰 어떤 런타임보다 좋은 비율이야. 통합 메모리 네이티브야. GPU 트랙이야. 그래서 옮기는 동사가 없고 프레임워크 복사도 없어. 그리고 애플이 유지하는 문이라 M5 Neural Accelerator가 여기에 먼저 왔어(릴리스 노트 기준 MLX 0.30.0, 2025-11-19. 이 퀘스트가 잰 게 아니라 벤더 날짜야). 이 집의 추론 허브는 MLX 서버고 이 퀘스트의 실험 전체는 mlx-lm에서 돌아. 코딩 에이전트의 로컬 다리는 다음 레슨의 문을 지나는데, 그게 대부분의 사람이 대신 쓰는 문이야.
Code
decode_loop.py — 프리필 한 번, 그다음 같은 가중치를 지나는 패스마다 토큰 하나·python
#!/usr/bin/env python3
"""The MLX path, written out: load (lazy, mmap), prefill the prompt through
the model once, then one token per step through the same weights with a
growing KV cache. No stream_generate; the loop is the lesson."""
import time
import mlx.core as mx
from mlx_lm import load
from mlx_lm.models.cache import make_prompt_cache
model, tok = load("mlx-community/Qwen3.5-9B-4bit")
prompt = mx.array(tok.apply_chat_template([{"role": "user", "content": "Name three uses of unified memory."}],
add_generation_prompt=True, enable_thinking=False))[None]
cache = make_prompt_cache(model) # one cache object per layer
t0 = time.perf_counter()
logits = model(prompt, cache=cache) # PREFILL: the whole prompt, one pass
token = mx.argmax(logits[:, -1, :], axis=-1)
mx.eval(token) # lazy until here; the GPU runs now
ttft = time.perf_counter() - t0
out = [int(token.item())]
t1 = time.perf_counter()
for _ in range(63): # DECODE: one token per pass
logits = model(token[None], cache=cache) # same weights; cache grows by one
token = mx.argmax(logits[:, -1, :], axis=-1)
mx.eval(token)
out.append(int(token.item())) # a GPU sync per token — the hand loop's cost
if out[-1] == tok.eos_token_id:
break
dt = time.perf_counter() - t1
print(tok.decode(out).strip()[:300])
print(f"\nprefill {prompt.shape[1]} tokens: TTFT {ttft:.3f}s; decode {len(out)-1} tokens at {(len(out)-1)/dt:.1f} tok/s")
# office, 2026-09-15, mlx 0.32.2 / mlx-lm 0.31.3: prefill 19 tokens, TTFT 0.30s; decode 68-79 tok/s across runs
# (stream_generate on the same model: 95 tok/s — the library keeps the pipeline full)