Karpathy의 microgpt.py 완전 해부: 150줄로 이해하는 GPT의 본질

Karpathy의 microgpt.py 완전 해부: 150줄로 이해하는 GPT의 본질
Andrej Karpathy가 새로운 코드를 공개했습니다. 이번에는 nanoGPT보다 더 극단적입니다. 외부 라이브러리 없이, 순수 Python만으로 GPT를 학습하고 추론하는 150줄짜리 코드입니다.
PyTorch 없음. NumPy 없음. import는 os, math, random 세 개뿐.
코드 상단의 주석이 모든 것을 요약합니다:
"This file is the complete algorithm. Everything else is just efficiency."
이 글에서는 microgpt.py를 한 줄 한 줄 해부합니다. 코드를 따라가다 보면, GPT라는 알고리즘이 실제로는 놀라울 정도로 단순한 수학 연산의 조합이라는 사실을 체감하게 됩니다.
전체 구조
microgpt.py는 크게 6개 파트로 나뉩니다:
총 파라미터: 4,192개. GPT-2 Small의 124M과 비교하면 약 30,000배 작습니다. 하지만 알고리즘은 동일합니다.
1. 데이터와 토크나이저
import os
import math
import random
random.seed(42)
if not os.path.exists('input.txt'):
import urllib.request
names_url = 'https://raw.githubusercontent.com/karpathy/makemore/refs/heads/master/names.txt'
urllib.request.urlretrieve(names_url, 'input.txt')
docs = [l.strip() for l in open('input.txt').read().strip().split('\n') if l.strip()]
random.shuffle(docs)데이터셋은 Karpathy의 makemore 프로젝트에서 사용한 names.txt입니다. 약 32,000개의 영어 이름이 들어있습니다.
uchars = sorted(set(''.join(docs))) # ['a', 'b', ..., 'z'] -> 26글자
BOS = len(uchars) # BOS = 26
vocab_size = len(uchars) + 1 # 27토크나이저는 문자 단위입니다. a=0, b=1, ..., z=25로 매핑하고, 26번은 BOS(Beginning of Sequence) 토큰입니다.
여기서 재밌는 설계가 있습니다. BOS 토큰이 EOS(End of Sequence) 역할도 겸합니다:
tokens = [BOS] + [uchars.index(ch) for ch in doc] + [BOS]
# "emma" -> [26, 4, 12, 12, 0, 26]
# BOS e m m a EOS하나의 특수 토큰이 시작과 끝 모두를 담당합니다. 모델은 context(위치, 이전 글자)를 보고 두 역할을 구분합니다. 이것은 사실 GPT-2의 <|endoftext|> 토큰과 같은 방식입니다.
2. Autograd 엔진: Value 클래스
microgpt.py의 심장부입니다. PyTorch의 autograd를 순수 Python으로 재구현합니다.
class Value:
__slots__ = ('data', 'grad', '_children', '_local_grads')
def __init__(self, data, children=(), local_grads=()):
self.data = data # 순방향 계산 값 (스칼라)
self.grad = 0 # 손실에 대한 이 노드의 기울기
self._children = children # 계산 그래프에서의 자식 노드들
self._local_grads = local_grads # 자식에 대한 국소 기울기각 Value 객체는 하나의 스칼라 값을 감싸며, 연산을 수행할 때마다 계산 그래프를 자동으로 구성합니다.
__slots__는 Python 최적화입니다. 일반적인 __dict__ 대신 고정 크기 배열에 속성을 저장해서 객체당 50~80바이트를 절약합니다. 학습 중 한 스텝에서 수만 개의 Value 객체가 생성되므로, 이 절약이 의미 있습니다.
지원하는 연산들:
def __add__(self, other): # a + b -> 국소 기울기: (1, 1)
other = other if isinstance(other, Value) else Value(other)
return Value(self.data + other.data, (self, other), (1, 1))
def __mul__(self, other): # a * b -> 국소 기울기: (b, a)
other = other if isinstance(other, Value) else Value(other)
return Value(self.data * other.data, (self, other), (other.data, self.data))
def __pow__(self, other): # a^k -> 국소 기울기: k * a^(k-1)
return Value(self.data**other, (self,), (other * self.data**(other-1),))
def log(self): # log(a) -> 국소 기울기: 1/a
return Value(math.log(self.data), (self,), (1/self.data,))
def exp(self): # exp(a) -> 국소 기울기: exp(a)
return Value(math.exp(self.data), (self,), (math.exp(self.data),))
def relu(self): # max(0, a) -> 국소 기울기: 1 if a > 0 else 0
return Value(max(0, self.data), (self,), (float(self.data > 0),))이 6가지 기본 연산만으로 GPT의 모든 계산을 수행할 수 있습니다:
- linear layer: 곱셈(mul) + 덧셈(add)
- RMSNorm: 제곱(pow) + 평균(add, mul) + 역수(pow)
- softmax: exp + 나눗셈(mul, pow)
- cross-entropy loss: log + 부정(mul)
- ReLU: relu
나머지 연산자(__neg__, __sub__, __truediv__ 등)는 모두 이 기본 연산의 조합입니다.
역전파:
def backward(self):
topo = []
visited = set()
def build_topo(v):
if v not in visited:
visited.add(v)
for child in v._children:
build_topo(child)
topo.append(v)
build_topo(self)
self.grad = 1
for v in reversed(topo):
for child, local_grad in zip(v._children, v._local_grads):
child.grad += local_grad * v.grad역전파는 두 단계입니다:
- 계산 그래프를 DFS로 순회해 위상 정렬(topological sort)을 만듭니다.
- 역순으로 순회하며 chain rule을 적용합니다:
child.grad += local_grad * v.grad
"위상 정렬이 뭐고 왜 필요한가?", "chain rule이 정확히 뭔가?"가 궁금하다면 -- 이 15줄의 수학적 기초를 처음부터 풀어쓴 별도의 글이 있습니다: 역전파를 처음부터: Chain Rule, 계산 그래프, 위상 정렬.
핵심: local_grad는 순방향 계산 시 미리 저장한 일반 float입니다. 역전파 자체는 새로운 Value 객체를 생성하지 않습니다. 이것은 2차 미분(Hessian)이 불가능하다는 뜻이지만, 메모리를 크게 절약합니다. PyTorch의 기본 동작도 동일합니다.
3. 파라미터 초기화
n_embd = 16 # 임베딩 차원
n_head = 4 # 어텐션 헤드 수
n_layer = 1 # 레이어 수
block_size = 16 # 최대 시퀀스 길이
head_dim = n_embd // n_head # 헤드당 차원 = 4
matrix = lambda nout, nin, std=0.08: [
[Value(random.gauss(0, std)) for _ in range(nin)]
for _ in range(nout)
]모든 가중치는 matrix 함수로 생성합니다. 2차원 리스트의 각 원소가 하나의 Value 객체입니다. 초기값은 평균 0, 표준편차 0.08의 가우시안 분포에서 샘플링합니다.
state_dict = {
'wte': matrix(vocab_size, n_embd), # 토큰 임베딩: 27 x 16
'wpe': matrix(block_size, n_embd), # 위치 임베딩: 16 x 16
'lm_head': matrix(vocab_size, n_embd), # 출력 헤드: 27 x 16
}
for i in range(n_layer): # 1개 레이어
state_dict[f'layer{i}.attn_wq'] = matrix(n_embd, n_embd) # 16 x 16
state_dict[f'layer{i}.attn_wk'] = matrix(n_embd, n_embd) # 16 x 16
state_dict[f'layer{i}.attn_wv'] = matrix(n_embd, n_embd) # 16 x 16
state_dict[f'layer{i}.attn_wo'] = matrix(n_embd, n_embd) # 16 x 16
state_dict[f'layer{i}.mlp_fc1'] = matrix(4 * n_embd, n_embd) # 64 x 16
state_dict[f'layer{i}.mlp_fc2'] = matrix(n_embd, 4 * n_embd) # 16 x 64파라미터 수 분해:
4,192개의 스칼라 Value 객체. 각각이 독립적인 Python 객체로 힙 메모리에 존재합니다. GPT-2 Small의 124M 파라미터는 연속된 GPU 메모리에 float16/float32 텐서로 저장됩니다. 같은 알고리즘, 극적으로 다른 구현.
4. 모델 아키텍처
먼저 세 가지 유틸리티 함수:
def linear(x, w):
# 행렬-벡터 곱. x: list[Value], w: list[list[Value]]
return [sum(wi * xi for wi, xi in zip(wo, x)) for wo in w]
def softmax(logits):
max_val = max(val.data for val in logits) # 수치 안정성을 위한 max 빼기
exps = [(val - max_val).exp() for val in logits]
total = sum(exps)
return [e / total for e in exps]
def rmsnorm(x):
ms = sum(xi * xi for xi in x) / len(x) # 제곱 평균
scale = (ms + 1e-5) ** -0.5 # 1 / sqrt(ms + eps)
return [xi * scale for xi in x]linear은 행렬-벡터 곱입니다. PyTorch의 F.linear(x, W)와 동일하지만, 스칼라 연산으로 풀어쓴 것입니다. 16차원 입력에 대해 16차원 출력을 만들려면 16 x (16 곱셈 + 15 덧셈) = 496개의 Value 객체가 생성됩니다.
rmsnorm은 Zhang & Sennrich (2019)의 RMSNorm입니다. LayerNorm과 달리 평균을 빼지 않고(re-centering 없음), 학습 가능한 파라미터(gamma, beta)도 없습니다. LLaMA, Gemma 등 최신 모델이 RMSNorm을 사용하지만, 그들은 학습 가능한 gain 파라미터를 포함합니다. microgpt.py는 그마저도 생략합니다.
GPT 함수:
def gpt(token_id, pos_id, keys, values):
# 1. 임베딩
tok_emb = state_dict['wte'][token_id]
pos_emb = state_dict['wpe'][pos_id]
x = [t + p for t, p in zip(tok_emb, pos_emb)]
x = rmsnorm(x) # 임베딩 후 정규화 (GPT-2에는 없음)
for li in range(n_layer):
# 2. Multi-Head Attention
x_residual = x
x = rmsnorm(x) # Pre-norm
q = linear(x, state_dict[f'layer{li}.attn_wq'])
k = linear(x, state_dict[f'layer{li}.attn_wk'])
v = linear(x, state_dict[f'layer{li}.attn_wv'])
# KV-cache: 현재 토큰의 K, V를 기록
keys[li].append(k)
values[li].append(v)
x_attn = []
for h in range(n_head): # 4개 헤드, 각 4차원
hs = h * head_dim
q_h = q[hs:hs+head_dim]
k_h = [ki[hs:hs+head_dim] for ki in keys[li]] # 모든 과거 K
v_h = [vi[hs:hs+head_dim] for vi in values[li]] # 모든 과거 V
# Scaled dot-product attention
attn_logits = [
sum(q_h[j] * k_h[t][j] for j in range(head_dim)) / head_dim**0.5
for t in range(len(k_h))
]
attn_weights = softmax(attn_logits)
head_out = [
sum(attn_weights[t] * v_h[t][j] for t in range(len(v_h)))
for j in range(head_dim)
]
x_attn.extend(head_out)
x = linear(x_attn, state_dict[f'layer{li}.attn_wo'])
x = [a + b for a, b in zip(x, x_residual)] # Residual connection
# 3. MLP
x_residual = x
x = rmsnorm(x)
x = linear(x, state_dict[f'layer{li}.mlp_fc1']) # 16 -> 64
x = [xi.relu() for xi in x] # ReLU (GPT-2는 GELU)
x = linear(x, state_dict[f'layer{li}.mlp_fc2']) # 64 -> 16
x = [a + b for a, b in zip(x, x_residual)] # Residual connection
# 4. 출력 logits
logits = linear(x, state_dict['lm_head'])
return logits # 27차원 벡터이 함수는 토큰 하나를 받아 다음 토큰의 확률 분포(logits)를 반환합니다. GPT-2의 forward pass와 구조적으로 동일합니다.
주목할 점:
KV-cache가 자연스럽게 구현됩니다. gpt() 함수가 토큰 하나씩 처리하면서 K, V를 리스트에 추가합니다. 어텐션 계산 시 이전의 모든 K, V를 참조합니다. 이것이 프로덕션 LLM 추론에서 사용하는 KV-cache와 정확히 같은 방식입니다.
Causal masking이 암묵적입니다. 위치 t에서 keys 리스트에는 위치 0, 1, ..., t의 항목만 있습니다. 별도의 마스크 행렬이 필요 없습니다.
학습 중에도 이 KV-cache를 사용합니다. 모든 K, V가 Value 객체이므로, loss.backward()가 캐시된 값들을 통해서도 올바르게 역전파합니다. 수학적으로는 전체 시퀀스를 병렬 처리하는 것과 동일합니다.
5. GPT-2와의 비교
최종 norm이 없다는 것이 실용적으로 가장 큰 차이입니다. GPT-2와 LLaMA 모두 출력 projection 직전에 정규화를 적용합니다. 이 규모에서는 문제없지만, 더 큰 모델에서는 학습 안정성에 영향을 줄 수 있습니다.
Weight tying이 없다는 것도 주목할 점입니다. wte(432 파라미터)와 lm_head(432 파라미터)가 별도입니다. GPT-2에서는 이 둘이 같은 가중치를 공유합니다. microgpt.py에서는 단순함을 위해 분리했습니다.
6. 학습 루프
learning_rate, beta1, beta2, eps_adam = 0.01, 0.85, 0.99, 1e-8
m = [0.0] * len(params) # 1차 모멘트 (momentum)
v = [0.0] * len(params) # 2차 모멘트 (adaptive learning rate)
num_steps = 1000
for step in range(num_steps):
doc = docs[step % len(docs)]
tokens = [BOS] + [uchars.index(ch) for ch in doc] + [BOS]
n = min(block_size, len(tokens) - 1)
keys, values = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)]
losses = []
for pos_id in range(n):
token_id, target_id = tokens[pos_id], tokens[pos_id + 1]
logits = gpt(token_id, pos_id, keys, values)
probs = softmax(logits)
loss_t = -probs[target_id].log()
losses.append(loss_t)
loss = (1 / n) * sum(losses)
loss.backward()
lr_t = learning_rate * (1 - step / num_steps) # Linear decay
for i, p in enumerate(params):
m[i] = beta1 * m[i] + (1 - beta1) * p.grad
v[i] = beta2 * v[i] + (1 - beta2) * p.grad ** 2
m_hat = m[i] / (1 - beta1 ** (step + 1))
v_hat = v[i] / (1 - beta2 ** (step + 1))
p.data -= lr_t * m_hat / (v_hat ** 0.5 + eps_adam)
p.grad = 0Batch size 1입니다. 매 스텝마다 이름 하나를 처리합니다. 미니배치도, gradient accumulation도 없습니다.
Cross-entropy loss는 수동으로 구현됩니다: softmax(logits) 후 -log(p[target]). 프로덕션 코드의 fused log_softmax(exp 후 log를 별도로 하는 대신 합쳐서 수치 안정성을 높이는 방식)보다 덜 안정적이지만, 이 규모에서는 문제없습니다.
Adam optimizer는 Kingma & Ba (2015) 논문의 구현 그대로입니다. bias correction(m_hat, v_hat)이 포함되어 있습니다. beta1=0.85는 표준값 0.9보다 약간 낮습니다. batch size 1의 노이즈가 큰 환경에서 최근 gradient에 더 빠르게 반응하도록 한 것입니다.
Learning rate schedule은 linear decay입니다. 0.01에서 시작해 1000 스텝에 걸쳐 0으로 선형 감소합니다. 프로덕션 모델은 보통 warmup + cosine decay를 사용합니다.
실제로 500 스텝 학습한 결과입니다. Loss는 3.2에서 시작해 ~2.4 근처로 수렴합니다. 스텝당 약 0.23초 소요됩니다 -- 순수 Python이라 느리지만, 학습은 확실히 진행됩니다.

7. 추론
temperature = 0.5
for sample_idx in range(20):
keys, values = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)]
token_id = BOS
sample = []
for pos_id in range(block_size):
logits = gpt(token_id, pos_id, keys, values)
probs = softmax([l / temperature for l in logits])
token_id = random.choices(range(vocab_size), weights=[p.data for p in probs])[0]
if token_id == BOS:
break
sample.append(uchars[token_id])
print(f"sample {sample_idx+1:2d}: {''.join(sample)}")Temperature 0.5는 분포를 더 뾰족하게 만듭니다 (1.0보다 "보수적"). logits를 temperature로 나눈 뒤 softmax를 적용하면, 높은 확률의 토큰이 더욱 강조됩니다.
random.choices는 가중치 기반 랜덤 샘플링입니다. PyTorch의 torch.multinomial과 동일한 역할입니다.
BOS 토큰(=26)이 생성되면 시퀀스를 종료합니다. 여기서 BOS가 EOS 역할을 하는 것이 드러납니다.
다양한 temperature에서 생성한 이름들:
- T=0.3: jamel, aneya, jailen, raryen, adara, kayri, alya, maya, arire, ara
- T=0.5: kolla, liylen, mavan, aikili, eara, karer, shane, mara, alema, amora
- T=0.8: maimel, risonen, faxuela, elyna, jaielev, coelenaimeea, harir
- T=1.0: taisar, luus, vasol, yynuev, fhazel, majazh, buryn
- T=1.5: rusonnodra, wnienln, ravelo, nnh, siclka, raarlr
Temperature가 낮을수록 "안전한" 이름(짧고 규칙적), 높을수록 "실험적" 이름(길고 불규칙)이 생성됩니다.
8. 숨겨진 인사이트
microgpt.py를 깊이 들여다보면, 코드 너머의 교훈들이 보입니다.
"모든 것이 스칼라 연산이다"
GPT의 forward pass에서 일어나는 모든 일 -- 임베딩 조회, 어텐션 계산, MLP 변환 -- 은 결국 스칼라 덧셈과 곱셈입니다. PyTorch의 텐서 연산은 이 스칼라 연산 수만 개를 병렬로 묶어 실행하는 것일 뿐입니다.
이름 "emma"(5개 위치)를 학습할 때, forward pass에서 약 39,700개의 Value 객체가 생성됩니다. "christopher"(12개 위치)면 약 99,500개입니다. 각 객체는 72~120바이트이므로, 한 스텝에 ~9MB의 Python 객체가 힙에 올라갑니다. PyTorch는 같은 계산을 1.89ms에 처리합니다. microgpt.py는 211.7ms. 약 112배 차이 -- CPU에서만 이 정도이고, GPU를 쓰면 만 배 이상 벌어집니다.
학습 후 각 가중치 행렬의 분포를 보면, 초기화(std=0.08)에서 벗어나 의미 있는 구조가 형성된 것을 확인할 수 있습니다. 특히 lm_head(출력 헤드)의 분포가 가장 넓게 퍼져있는데, 각 토큰을 구분하기 위해 더 다양한 값이 필요하기 때문입니다.

"KV-cache는 발명이 아니라 발견이다"
microgpt.py에서 KV-cache는 의도적으로 추가된 최적화가 아닙니다. 토큰을 하나씩 처리하다 보니, 이전 토큰의 K와 V를 저장해두는 것이 자연스럽게 나타납니다. 프로덕션 LLM의 KV-cache도 결국 같은 원리입니다 -- 이미 계산한 것을 다시 계산하지 않는 것.
"Causal masking은 제약이 아니라 결과다"
별도의 마스크 행렬 없이, KV-cache에 과거 토큰만 쌓이므로 자연스럽게 causal attention이 됩니다. 마스킹은 autoregressive 생성의 구조적 결과이지, 인위적으로 부과하는 제약이 아닙니다.
실제로 학습 후 어텐션 패턴을 시각화하면, 4개 헤드가 각각 다른 패턴을 학습한 것을 볼 수 있습니다. 어떤 헤드는 직전 문자에 집중하고, 어떤 헤드는 BOS 토큰(시퀀스 시작)을 참조합니다. 단 4차원 헤드로도 의미 있는 어텐션 패턴이 형성됩니다.

"Autograd는 생각보다 단순하다"
chain rule을 계산 그래프에 적용하는 것. 순방향에서 국소 기울기를 저장하고, 역방향에서 곱해나가는 것. 이것이 PyTorch, JAX, TensorFlow 모두의 핵심입니다. microgpt.py의 backward()는 35줄이면 충분합니다.
9. "Everything else is just efficiency"
이 코드에 들어있는 것 (= 알고리즘):
- 토큰 임베딩 + 위치 임베딩
- Multi-head causal self-attention (QKV projection + scaled dot-product)
- Feedforward network (확장 + 비선형 + 축소)
- Residual connection
- 정규화 (RMSNorm)
- Autoregressive next-token prediction + cross-entropy loss
- Adam optimizer
이 코드에 없는 것 (= 효율화):
이 중 어떤 것도 알고리즘을 바꾸지 않습니다. microgpt.py에서 LLaMA-3 405B까지의 개념적 도약은 0입니다. 엔지니어링의 도약은 거대합니다.
이것이 이 코드의 진짜 메시지입니다. GPT는 미분 가능한 산술 연산의 특정 연결 패턴(attention + MLP + residual)을 경사 하강법으로 학습하는 것입니다. 150줄의 Python으로 표현할 수 있습니다. 나머지는 전부 규모의 문제입니다.
micrograd에서 microgpt까지: Karpathy의 교육 철학
이 코드는 Karpathy의 교육 프로젝트 계보에서 최종 결론입니다:
- micrograd (2020): Autograd 엔진만. 신경망의 역전파를 처음부터 구현.
- makemore (2022): 문자 수준 언어 모델. Bigram에서 Transformer까지 점진적 확장.
- nanoGPT (2023): 학습 가능한 GPT-2. PyTorch 의존, 하지만 최소한의 코드.
- microgpt (2025): 전부를 하나로. Autograd + 모델 + 학습 + 추론을 순수 Python으로.
microgpt.py는 "만약 PyTorch가 없었다면?"이라는 질문에 대한 답입니다. 답은 "같은 알고리즘을 30,000배 느리게 실행할 뿐"입니다.
핵심 정리
참고 자료
- Karpathy, "microgpt.py." GitHub Gist, 2025.
- Karpathy, "micrograd: A tiny scalar-valued autograd engine." GitHub, 2020.
- Karpathy, "nanoGPT: The simplest, fastest repository for training/finetuning medium-sized GPTs." GitHub, 2023.
- Radford et al., "Language Models are Unsupervised Multitask Learners." OpenAI, 2019.
- Kingma & Ba, "Adam: A Method for Stochastic Optimization." ICLR, 2015.
- Zhang & Sennrich, "Root Mean Square Layer Normalization." NeurIPS, 2019.