본문 바로가기
C.W.K.
Stream
Lesson 06 of 06 · published

CUDA 위의 같은 토큰

~14 min · journey, cuda, pcie, vram, mlx-cuda-backend, physics

Level 0스펙 시트 훑는 사람
0 XP0/91 lessons0/19 achievements
0/100 XP to next level100 XP to go0% complete
"카드에선 여정에 단계가 하나 더 있어. 한 번만 밟는. 나머지는 전부 위에 더 큰 숫자를 얹은 같은 나눗셈이야. 가중치가 안 들어가기 시작하기 전까지는. 그다음부턴 그 추가 단계를 토큰마다 밟아."

별도 GPU 위의 여정, 단계별로

이 퀘스트는 CUDA 기계를 하나도 안 쟀어. 아래의 모든 숫자는 벤더 수치거나 그걸 나눈 값이고, 그렇게 라벨이 붙어 있어. 그래도 이 경로는 걸어 볼 가치가 있어. 이 트랙의 다른 다섯 레슨이 말없이 비교되는 경로니까. 1단계, 파일에서 호스트 페이지로: 똑같아. 체크포인트가 CPU 메모리에 메모리 매핑되고, 페이지 캐시나 SSD에서 폴트로 들어와. 2단계, 추가 단계: 가중치가 PCI Express를 건너 카드 자체 메모리로 가. 로드할 때 한 번. 페이지 고정된 준비 버퍼에서의 cudaMemcpy, Gen 5 열여섯 레인에서 63 GB/s쯤이니 27B의 16 GB는 4분의 1초. 3단계, 디코드: 정확히 물리 트랙의 루프야. 풀 대신 카드의 메모리에 대해서. 같은 토큰당 14.42 GB를 RTX 5090의 1,792 GB/s나 H100의 3,350으로 나눠. 인터페이스 레슨이 카드에 들어가는 모델은 거기서 더 빨리 디코드한다고 인정한 이유야. 4단계, 토큰이 집에 오기: 샘플링된 id 하나하나가 버스를 건너 돌아오는 몇 바이트, 마이크로초. 런타임은 루프를 장치에 붙들어 둬서 그것마저 숨겨. 5단계, 캐시: KV 캐시도 카드 메모리에 살아. 그래서 컨텍스트는 호스트 메모리가 아니라 VRAM을 써. 27B에서 토큰당 64 KB, 10만 토큰에 6.4 GB, 32 중에서.

단계맥, MLX맥, PyTorch MPS카드, CUDA증거
파일 → 메모리mmap, 지연. 첫 순전파에서 페이지 wiredmmap, 지연호스트 메모리로 mmap실측(맥) / 물리
연산 유닛의 메모리로없음 — 풀 하나풀 안의 CPU memcpy: 2.47 GB에 110–450 ms(따뜻하면 22 GB/s)PCIe 복사 한 번: ~63 GB/s, 16 GB에 0.25 s실측(맥) / 벤더 주장(버스)
디코드, 토큰당14.42 GB ÷ 실측 638 → 상한 44 tok/s같은 바이트, 같은 버스, 더 큰 고정 항14.42 GB ÷ 1,792 → 124(RTX 5090). ÷ 3,350 → 232(H100)실측 / 벤더에서 유도
토큰 id가 호스트로동기화 하나, 같은 풀동기화 하나, 같은 풀몇 바이트의 버스 횡단. CUDA 그래프가 숨김물리
KV 캐시풀 안에, wired풀 안에VRAM에: 27B에서 10만 토큰당 6.4 GB실측(크기) / 물리

가중치가 안 들어가면 반복되는 단계

카드의 메모리가 인터페이스 레슨이 값을 매긴 그 벽이야. 32 GB 카드는 4비트 27B를 긴 컨텍스트 여유까지 담아. 40 GB쯤인 4비트 70B는 못 담고, 런타임의 답은 모델 일부를 호스트에 두고 토큰마다 버스 너머로 흘려보내는 거야. 그러면 2단계는 한 번 밟는 게 아니게 돼. 나눗셈이 63 GB/s 위의 토큰당 바이트가 되고, 27B는 그걸 옮길 수 있는 버스에서 초당 4.4토큰으로 디코드돼. 풀에서의 44에 대해서. 그 숫자가 카드를 살 수도 있었던 집에 512 GB Mac Studio가 있는 이유의 전부고, 라이벌 트랙이 멀티 GPU 세금에 레슨 하나를 쓰는 이유의 전부야. 카드 둘은 벽을 더 높이고 그 사이에 두 번째 버스를 더해. 같은 표의 GB10은 그 벽에 대한 NVIDIA 자체의 답이야. 카드의 소프트웨어와 노트북의 대역폭을 가진 128 GB 풀. 273 GB/s, 27B에서 상한 초당 19토큰. 스펙 기준 Studio의 57에 대해 3분의 1이고, 커널이 실제로 스트림하는 44에 대해선 43%야. 라이벌 트랙이 그 거래를 양방향으로 재.

역전

이 트랙의 마지막 사실은 이상한 쪽이야. MLX, 커뮤니티가 먼저 닿은 지 263일 뒤에 애플이 풀 하나짜리 기계를 위해 만든 프레임워크가 이제 CUDA 백엔드를 실어. 리눅스에서 pip install mlx[cuda12], 아키텍처 SM 7.5 이상의 카드에. 통합 메모리에 대고 쓴 코드가 모듈러 기계에서 돌아. 맥에선 전혀 필요 없던 복사 단계를 프레임워크 안에 숨긴 채로. 20년 동안 이식은 반대 방향으로 흘렀어. CUDA에서 나머지 전부로. mlx 퀘스트의 프로덕션 트랙이 그 백엔드를 공학으로 다뤄. 여기선 쥐의 마지막 수야. 사고에서 태어난 프레임워크가 그 사고를 재는 기준이었던 기계에 닿는 것. 이 레슨의 맥 카드 항목은 한 줄이야. 다섯 단계, 그리고 네가 실제로 가진 기계에 대해 2단계를 한 번인지 토큰마다인지 표시한 것.

Code

journey_any_device.py — 네가 가진 어떤 가속기에서든 같은 루프. 2단계가 다른 줄이야·python
#!/usr/bin/env python3
"""The same token loop on whatever accelerator this machine has. The one line that
differs between a Mac and a CUDA box is the .to(device): on a Mac it is a copy inside
one pool; on a discrete GPU it is the PCIe crossing, once, at load. Everything after it
is bytes per token against that device's own memory bandwidth."""
import time, torch
from transformers import AutoModelForCausalLM, AutoTokenizer

MODEL = "unsloth/Llama-3.2-1B-Instruct"
dev = "cuda" if torch.cuda.is_available() else ("mps" if torch.backends.mps.is_available() else "cpu")
sync = {"cuda": lambda: torch.cuda.synchronize(), "mps": lambda: torch.mps.synchronize()}.get(dev, lambda: None)

tok = AutoTokenizer.from_pretrained(MODEL)
model = AutoModelForCausalLM.from_pretrained(MODEL, dtype=torch.bfloat16).eval()      # step 1: file -> host pages (mmap, lazy)
nbytes = sum(p.numel() * p.element_size() for p in model.parameters())

t0 = time.perf_counter(); model.to(dev); sync(); t_move = time.perf_counter() - t0     # step 2: THE extra step
ids = tok.apply_chat_template([{"role": "user", "content": "Name three uses of unified memory."}],
                              add_generation_prompt=True, return_tensors="pt", return_dict=True)["input_ids"].to(dev)
with torch.no_grad():
    model.generate(ids, max_new_tokens=4, do_sample=False)                             # warm-up
    sync(); t0 = time.perf_counter(); model.generate(ids, max_new_tokens=1, do_sample=False); sync(); ttft = time.perf_counter() - t0
    sync(); t0 = time.perf_counter(); out = model.generate(ids, max_new_tokens=128, min_new_tokens=128, do_sample=False); sync(); dt = time.perf_counter() - t0
n = out.shape[1] - ids.shape[1]
print(f"device {dev}: {nbytes/1e9:.2f} GB bf16")
print(f"  step 2, .to({dev}): {t_move*1e3:.0f} ms = {nbytes/t_move/1e9:.1f} GB/s  <- one pool on a Mac; PCIe on a card (x16 Gen5 ~63 GB/s)")
print(f"  step 3, decode:     {n/(dt-ttft):.1f} tok/s = {nbytes*n/(dt-ttft)/1e9:.0f} GB/s effective; TTFT {ttft*1e3:.0f} ms on {ids.shape[1]} prompt tokens")

# office, M3 Ultra, torch 2.11.0, 2026-09-15 (a Mac: the step exists in the framework, not in the hardware):
#   step 2, .to(mps): 1133 ms in a cold process; 451 ms in a warm one; 110-115 ms on a repeat (22 GB/s) -- a CPU memcpy inside one pool
#   step 3, decode:   44.2 tok/s = 109 GB/s effective; TTFT 26 ms on 42 prompt tokens
#   for comparison, mlx-lm on the same bytes copies nothing at step 2 and decodes at 190 tok/s (previous lessons)
cuda_derive.py — 다섯 메모리 시스템 위의 27B, 벤더 숫자 넣고 나눗셈 꺼내기·python
#!/usr/bin/env python3
"""The same 27B 4-bit checkpoint (14.42 GB per token, 64 KB of KV per token) on
five memory systems: vendor bandwidth figures in, derived ceilings and copy times out.
Nothing here is measured -- every input is a vendor number, every output a division."""
BYTES_PER_TOKEN = 14.42e9          # Qwen3.5-27B-4bit, read per token (measurements file)
KV_PER_TOKEN = 64e3                # 2 x 16 full-attention layers x 4 KV heads x 256 x 2 bytes
PCIE5_X16 = 63e9                   # ~63 GB/s per direction (T2 interface lesson)

systems = [  # name, memory GB, bandwidth GB/s, weights cross a bus at load?
    ("M3 Ultra (Mac Studio)",        512, 819,  False),
    ("M2 Ultra (Mac Studio)",        192, 800,  False),
    ("RTX 5090 (32 GB GDDR7)",        32, 1792, True),
    ("RTX PRO 6000 Blackwell (96 GB)", 96, 1792, True),
    ("H100 SXM (80 GB HBM3)",         80, 3350, True),
    ("DGX Spark GB10 (128 GB LPDDR5X)", 128, 273, False),
]
print(f"{'system':34} {'fits 27B+100k ctx?':20} {'ceiling tok/s':>14} {'load copy over PCIe5':>22}")
for name, mem, bw, bus in systems:
    need = (BYTES_PER_TOKEN + 1.6e9 + KV_PER_TOKEN * 100_000) / 1e9      # weights + vision/embed + 100k-token cache
    fits = need <= mem * 0.9
    ceiling = bw * 1e9 / BYTES_PER_TOKEN
    copy = f"{16.05e9 / PCIE5_X16:.2f} s" if bus else "none (one pool)"
    print(f"{name:34} {('yes' if fits else 'NO') + f' ({need:.1f} GB)':20} {ceiling:14.0f} {copy:>22}")

print("\nIf the weights do not fit the card, every token re-crosses the bus:")
print(f"  27B at 4-bit over PCIe 5 x16: {PCIE5_X16 / BYTES_PER_TOKEN:.1f} tok/s ceiling -- the bus becomes the memory")

# system                             fits 27B+100k ctx?    ceiling tok/s   load copy over PCIe5
# M3 Ultra (Mac Studio)              yes (22.4 GB)                    57        none (one pool)
# M2 Ultra (Mac Studio)              yes (22.4 GB)                    55        none (one pool)
# RTX 5090 (32 GB GDDR7)             yes (22.4 GB)                   124                 0.25 s
# RTX PRO 6000 Blackwell (96 GB)     yes (22.4 GB)                   124                 0.25 s
# H100 SXM (80 GB HBM3)              yes (22.4 GB)                   232                 0.25 s
# DGX Spark GB10 (128 GB LPDDR5X)    yes (22.4 GB)                    19        none (one pool)
# If the weights do not fit the card: 4.4 tok/s over PCIe 5 x16

External links

Exercise

네 맥에서 journey_any_device.py를 돌리고, CUDA 기계에 접근할 수 있으면 거기서도 돌려. 각각의 2단계 시간과 디코드 속도를 카드에 적고, cuda_derive.py에 네 카드의 메모리와 대역폭을 행으로 추가해서 돌려. 카드에 답해. 네가 실제로 돌리는 가장 큰 모델에 대해, 각 기계에서 2단계는 한 번이야, 토큰마다야?
Hint
4비트 가중치에 네가 쓰는 캐시를 더한 게 카드 메모리에 여유 있게 들어가면 한 번, 아니면 토큰마다. 맥에선 작업 집합 안의 무엇에든 답은 항상 한 번이고, MLX 경로에선 아예 없어. 들어가는 모델인데 네 카드의 디코드가 상한의 절반 아래면 고정 항이 프레임워크 것이야. MPS 레슨에서처럼.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.