GPT를 밑바닥부터: microgpt.py 완전 해설

Andrej Karpathy의 microgpt.py (2026.02) — 순수 파이썬(os, math, random) 약 243줄, 외부 ML 라이브러리 0개. 저자 소개, 데이터 파이프라인, 토크나이저, Autograd 엔진, GPT 아키텍처, 학습 루프, Adam 옵티마이저, 추론, 현대 LLM 비교까지 한 페이지에서 완전 해설합니다.

개요

microgpt.py는 Andrej Karpathy가 2026년 2월 공개한 "가장 원자적인 GPT 구현"입니다. os, math, random 세 표준 모듈만으로 GPT를 완성합니다.

핵심 철학: "This file contains the full algorithmic content of what is needed: dataset · tokenizer · autograd engine · GPT-2-like architecture · Adam optimizer · training loop · inference loop. Everything else is just efficiency. I cannot simplify this any further." — Andrej Karpathy, 2026.02.12
항목의미
파라미터4,192개학습 가능한 숫자 전체
코드 줄 수약 243줄데이터부터 추론까지 한 파일
학습 문서(이름)32,033개names.txt 영어 이름 데이터셋
외부 ML 라이브러리0개os, math, random만 사용
학습 결과loss 3.30 → 2.37Perplexity 27 → 10.7 (약 2.5배 향상)

이 페이지는 다음 순서로 해설합니다:

저자 — Andrej Karpathy

학력
Stanford PhD — Fei-Fei Li 지도 하에 Computer Vision · Deep Learning 전공. 박사 논문 주제: "Connecting Images and Natural Language" (이미지-언어 연결). 재학 중 Google Brain · DeepMind에서 인턴.
커리어
OpenAI 창립 멤버(2015) — GPT 시리즈 초기 연구에 직접 참여.
Tesla AI 디렉터(2017~2022) — Autopilot의 신경망 인식 시스템 총괄. "Data Engine" 개념(모델이 스스로 학습 데이터를 생성)을 실제 자율주행에 적용.
OpenAI 복귀(2023) — AGI 안전성 연구.
독립(2024~) — AI 교육 콘텐츠에 집중.
교육 철학
"Everything else is just efficiency" — 복잡한 AI를 의존성 없는 가장 원자적 형태로 단순화함으로써, 블랙박스를 투명하게 만드는 교육자. "이해하려면 직접 만들어봐야 한다"는 원칙으로 수만 명에게 딥러닝의 문을 열었습니다.

프로젝트 계보 — 10년의 단순화 여정

각 프로젝트는 "한 가지를 더 단순하게"라는 목표로 이어집니다.

프로젝트연도핵심 아이디어microgpt와의 관계
micrograd 2020 스칼라 Autograd — Value 클래스로 역전파 구현 microgpt Value 클래스의 직접적 원형
makemore 2022 문자 수준 언어 모델, names.txt 데이터셋 데이터셋·토크나이저 구조 계승
nanoGPT 2022 PyTorch+GPU 기반 실용 GPT 훈련 코드베이스 — GPT-2(124M) 완전 재현, Shakespeare 예제, DDP 분산학습, Flash Attention GPT 아키텍처의 "Production 최소 구현" — microgpt가 배운 원리를 실제 규모로 확장
llm.c 2024 C/CUDA로 LLM 훈련 — NumPy·PyTorch 없이 의존성 제거 철학 계승
microgpt 2026 순수 파이썬 약 243줄 — 모든 것을 하나의 파일에 집대성

전체 코드 흐름

【입력】 names.txt (32,033개 이름)
    │
    ▼
【Phase 1 — 데이터 파이프라인】 (~20줄)
    ├─ import os/math/random
    ├─ random.shuffle(docs)          ← 학습 순서 무작위화
    ├─ uchars = sorted(set(''.join(docs)))  ← 고유 문자 추출
    └─ vocab_size = 27 (a~z + BOS)

    │  각 이름 "emma" → [26, 4, 12, 12, 0, 26]
    ▼
【Phase 2 — Autograd 엔진】 (~40줄)
    ├─ class Value: data, grad, _children, _local_grads
    ├─ 6가지 연산: add, mul, pow, log, exp, relu
    └─ backward(): 위상 정렬 → 연쇄 법칙 적용

    │  모든 숫자가 미분 가능한 Value 객체가 됨
    ▼
【Phase 3 — 신경망 정의】 (~50줄)
    ├─ state_dict: 4,192개 Value 파라미터
    ├─ linear(), softmax(), rmsnorm()
    └─ gpt(token_id, pos_id, keys, values) → logits[27]

    │  토큰 → 다음 토큰 확률 분포
    ▼
【Phase 4 — 학습 루프 (1,000 스텝)】 (~30줄)
    ├─ 순전파: loss = CrossEntropy(gpt(tokens), targets)
    ├─ 역전파: loss.backward()
    ├─ Adam: p.data -= lr * m_hat / (v_hat**0.5 + ε)
    └─ lr 선형 감쇠: 0.01 → 0.00001

    │  loss: 3.30 → 2.37
    ▼
【Phase 5 — 추론 (이름 생성)】
    BOS → 't' → 'o' → 'm' → BOS  →  "tom"
Phase 1 데이터·토크나이저 names.txt → 토큰 vocab_size=27 Phase 2 Autograd 엔진 class Value add·mul·log·exp Phase 3 신경망 정의 gpt() 함수 4,192 파라미터 Phase 4 학습 루프 1000 스텝 · Adam loss 3.30 → 2.37 Phase 5 추론 (이름 생성) BOS → 't'→'o'→'m' → BOS → "tom"

microgpt.py 5단계 파이프라인: 데이터·토크나이저 → Autograd → 신경망 정의 → 학습 → 추론

왜 이 순서인가? 데이터 없이는 토크나이저가 없고, Autograd 없이는 학습이 없고, 모델 없이는 추론이 없습니다. 각 블록이 다음 블록의 토대가 되는 완벽한 의존성 체인입니다. GPT-4도 이 4단계 구조를 따릅니다 — 규모만 다를 뿐입니다.

임포트 & 데이터 로딩

microgpt.py가 사용하는 임포트는 단 세 줄입니다. ML 라이브러리가 없다는 것 자체가 이 프로젝트의 핵심 철학입니다.

# 수학적 원리를 라이브러리 뒤에 숨기지 않고 직접 보여주기 위함
import os        # os.path.exists — 파일 존재 여부 확인
import math      # math.log, math.exp — 수학 함수
import random    # random.seed, random.gauss, random.choices

random.seed(42)  # 재현 가능한 결과 — 동일한 random.seed → 동일한 학습 결과

데이터셋 — makemore 저장소

데이터 출처: names.txt는 Karpathy의 makemore 프로젝트의 영어 이름 데이터셋입니다. 약 32,000개의 고유 이름이 포함되어 있습니다.

if not os.path.exists('input.txt'):
    import urllib.request
    names_url = 'https://raw.githubusercontent.com/karpathy/makemore/988aa59/names.txt'
    urllib.request.urlretrieve(names_url, 'input.txt')

docs = [line.strip() for line in open('input.txt') if line.strip()]
random.shuffle(docs)  # 순서 섞기 — 학습 편향 방지
print(f"num docs: {len(docs)}")  # → num docs: 32033

input.txt — 각 줄이 하나의 이름(= 하나의 훈련 문서):

emma        ← 4글자
olivia      ← 6글자
ava         ← 3글자
isabella    ← 8글자
sophia      ← 6글자
...
(총 32,033줄)
특성이유
총 이름 수32,033개미국 신생아 이름 통계 기반
문자 종류소문자 a~z (26종)대소문자·숫자·특수문자 없음
최단 이름2글자 (예: "jo")
최장 이름15글자block_size=16 설정 근거
평균 길이약 5~6글자

random.shuffle — 왜 섞는가?

학습 루프는 docs[step % len(docs)]로 순환합니다. 섞지 않으면 처음 1,000 스텝 동안 항상 같은 이름 순서로 학습하게 됩니다. 데이터셋의 앞부분에 특정 패턴(예: 짧은 이름, 특정 알파벳으로 시작하는 이름)이 몰려 있으면 모델이 그 패턴에 편향됩니다.

random.seed(42)       # ← 먼저 시드를 설정한 뒤
random.shuffle(docs)  # ← 섞어야 재현 가능한 순서가 됩니다

배치 크기 1 — 왜 이름 하나씩 학습하는가?

microgpt는 한 번에 이름 하나(batch_size=1)를 학습합니다. 배치 크기가 크면 GPU 메모리와 병렬 연산이 필요하지만, microgpt는 CPU에서 스칼라 연산으로 작동하므로 배치 크기 1이 가장 단순합니다.

학습 vs 추론의 차이: 학습 시에는 이름 전체 토큰을 한 번에 순전파합니다(각 위치에서 다음 토큰 예측). 추론 시에는 BOS부터 시작해서 한 번에 토큰 하나씩 생성합니다.

왜 이름 데이터인가?

  • 검증이 쉽다: 생성된 이름이 발음 가능한지 사람이 즉시 판단 가능.
  • 규모가 적당하다: 32,033개 × 평균 5글자 = 약 160,000 토큰 — 노트북으로 1분 내 학습.
  • 패턴이 명확하다: 이름은 모음-자음 교대, 특정 접미사(-a, -on, -er) 등 학습 가능한 통계 패턴이 있음.
  • 범용성: "이름"이나 "대화"나 GPT 입장에서는 모두 토큰 시퀀스. 원리는 동일.

토크나이저 구축

텍스트를 숫자로, 숫자를 다시 텍스트로 변환하는 번역기를 만듭니다. microgpt.py는 문자 수준(character-level) 토크나이저를 사용합니다.

# 모든 이름을 합쳐 고유한 문자만 추출 (정렬)
uchars = sorted(set(''.join(docs)))
# 결과: ['a', 'b', 'c', ..., 'z'] → 26개 소문자

BOS = len(uchars)            # = 26, Beginning of Sequence 특수 토큰 ID
vocab_size = len(uchars) + 1  # = 27 (a~z 26개 + BOS 1개)

# 인코딩: uchars.index(ch)  디코딩: uchars[token_id]

# 예시: "emma" → [BOS, e, m, m, a, BOS]
name = "emma"
tokens = [BOS] + [uchars.index(ch) for ch in name] + [BOS]
# → [26, 4, 12, 12, 0, 26]

# 디코딩: 정수 → 문자 (BOS 제외)
decoded = ''.join(uchars[t] for t in tokens if t != BOS)
# → "emma"

BOS 래핑 — 문서 경계 표현

"emma" → tokens = [26, 4, 12, 12, 0, 26]

【학습 입력/타겟 쌍】
pos 0: 입력=BOS(26)  → 타겟='e'(4)   ← "이름의 시작엔 무엇이 오는가?"
pos 1: 입력='e'(4)   → 타겟='m'(12)  ← "e 다음엔 무엇이 오는가?"
pos 2: 입력='m'(12)  → 타겟='m'(12)  ← "em 다음엔?"
pos 3: 입력='m'(12)  → 타겟='a'(0)   ← "emm 다음엔?"
pos 4: 입력='a'(0)   → 타겟=BOS(26)  ← "emma 다음엔? → 끝!"
BOS를 양쪽에 붙이는 이유: 앞에 BOS → 모델이 "이름이 시작된다"는 신호를 받고 첫 글자를 예측. 뒤에 BOS → 모델이 "이름이 끝났다"는 것을 학습(추론 시 생성 종료 조건). BOS 하나가 시작 신호이자 종료 신호 두 역할을 모두 합니다.
문자열 "emma" 4글자 문자 분해 e m m a uchars.index(ch) 인덱스 변환 4 12 12 0 a=0 … z=25 BOS 래핑 [26]+ids+[26] BOS=26 토큰 시퀀스 [26, 4, 12, 12, 0, 26] BOS e m m a BOS 길이 6 (4글자 + BOS×2)

"emma" 토크나이저 변환 파이프라인: 문자열 → 문자 분해 → 인덱스 조회 → BOS 래핑 → 토큰 시퀀스

토큰 ID 구조 — 27개 심볼

ID심볼ID심볼ID심볼
0'a'9'j'18's'
1'b'10'k'19't'
2'c'11'l'20'u'
3'd'12'm'21'v'
4'e'13'n'22'w'
5'f'14'o'23'x'
6'g'15'p'24'y'
7'h'16'q'25'z'
8'i'17'r'26BOS
항목microgpt (문자 수준)tiktoken / BPE (프로덕션)
어휘집 크기27개 (a~z + BOS)~100,000개
특징구현 단순, 이해 쉬움위치당 더 많은 의미 압축
인코딩 방식uchars.index(ch) 사용자주 쓰이는 문자열 병합(BPE)
인코딩 복잡도O(n) 선형 탐색O(1) 딕셔너리 룩업
용도소규모 교육용에 적합GPT-4 등 실제 LLM에서 사용

흔한 실수들

  • Off-by-One 오류: 어휘집 크기를 26으로 잘못 지정하면 BOS(ID=26)를 임베딩 테이블에서 찾지 못해 IndexError 발생. wte = matrix(vocab_size, n_embd)에서 vocab_size=27이어야 합니다.
  • 일관성 없는 어휘집: 소문자로 훈련된 모델에 대문자("Emma")를 입력하면 uchars.index('E')가 ValueError를 냅니다. 모든 이름은 소문자 전처리 후 토크나이저에 입력해야 합니다.
  • uchars.index() 시간 복잡도: list.index()는 O(n) 선형 탐색입니다. 어휘 크기가 27개로 작기 때문에 문제없지만, vocab_size가 50,000이라면 반드시 dict 기반 룩업테이블로 바꿔야 합니다.

Autograd 엔진 — 학습의 심장

PyTorch의 핵심 기능인 자동 미분(Automatic Differentiation)을 밑바닥부터 직접 구현합니다. 모든 숫자가 그냥 숫자가 아니라, 자신이 어디서 왔는지를 기억하는 Value 객체가 됩니다.

핵심 아이디어: 순전파(forward pass) 중에 계산 그래프를 동적으로 구성하고, 역전파(backward pass) 시 연쇄 법칙(Chain Rule)을 적용해 모든 파라미터의 기울기를 자동으로 계산합니다.

연쇄 법칙 (Chain Rule)

dy/dx = (dy/du) · (du/dx) — 미적분학의 핵심 — 복합 함수의 미분

self.grad — 전역 미분값
d(Loss)/d(self) — 역전파 시 계산됨
_local_grads — 지역 미분값
d(self)/d(child) — 순전파 시 계산됨

6가지 연산의 지역 기울기 — 한눈에 보기

연산순전파 f(a, b)지역 기울기 ∂f/∂a직관
adda + b1 (∂f/∂b = 1)덧셈은 기울기를 그대로 흘림
mula × bb (∂f/∂b = a)상대방 값이 기울기 배율
powann · an−1지수 법칙
logln(a)1 / a로그의 도함수
expeaea (자기 자신!)e^x의 도함수 = e^x
relumax(0, a)1 if a > 0 else 0양수만 기울기 통과
연쇄 법칙 직관 — 속도 비유: 자동차가 자전거보다 2배 빠르고, 자전거가 사람보다 4배 빠르다면 → 자동차는 사람보다 2 × 4 = 8배 빠릅니다. dL/dx = dL/dy · dy/dx — 각 단계의 기울기를 곱하면 전체 기울기가 됩니다.

Value 클래스 전체 코드

class Value:
    __slots__ = ('data', 'grad', '_children', '_local_grads')

    def __init__(self, data, children=(), local_grads=()):
        self.data = data          # float: 순전파 결과값
        self.grad = 0             # float: d(Loss)/d(self), 역전파 시 채워짐
        self._children = children         # tuple[Value]: 이 노드를 만든 입력들
        self._local_grads = local_grads   # tuple[float]: d(self)/d(child)

    # ── 핵심 이진 연산 두 개 (나머지는 모두 이 두 개의 조합) ──
    def __add__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        # f = a + b  →  ∂f/∂a = 1,  ∂f/∂b = 1
        return Value(self.data + other.data, (self, other), (1, 1))

    def __mul__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        # f = a × b  →  ∂f/∂a = b,  ∂f/∂b = a
        return Value(self.data * other.data, (self, other), (other.data, self.data))

    # ── 나머지 기본 연산 ──
    def __pow__(self, other):
        # f = a^n  →  ∂f/∂a = n·a^(n-1)
        return Value(self.data**other, (self,), (other * self.data**(other-1),))

    def log(self):
        # f = ln(a)  →  ∂f/∂a = 1/a
        return Value(math.log(self.data), (self,), (1/self.data,))

    def exp(self):
        # f = e^a  →  ∂f/∂a = e^a  (도함수가 자기 자신!)
        return Value(math.exp(self.data), (self,), (math.exp(self.data),))

    def relu(self):
        # f = max(0, a)  →  ∂f/∂a = 1 if a>0 else 0
        return Value(max(0, self.data), (self,), (float(self.data > 0),))

    # ── 파생 연산 (위 연산들의 조합으로 자동 처리) ──
    def __neg__(self):   return self * -1           # -a = a × (-1)
    def __sub__(self, other):  return self + (-other)    # a - b = a + (-b)
    def __rsub__(self, other): return other + (-self)    # b - a
    def __radd__(self, other): return self + other       # b + a (sum()에서 필요)
    def __rmul__(self, other): return self * other       # b × a
    def __truediv__(self, other): return self * other**-1  # a/b = a × b^(-1)
    def __rtruediv__(self, other): return other * self**-1 # b/a

__slots__ — 메모리 최적화

파이썬 객체는 기본적으로 __dict__라는 딕셔너리로 속성을 저장합니다. __slots__를 선언하면 이 딕셔너리 대신 고정된 슬롯(C 배열)을 사용합니다.

방식인스턴스당 메모리4,192개 기준
__dict__ (기본)~200~400 bytes~1.5 MB
__slots__ (microgpt)~100~150 bytes~0.6 MB

학습 중에는 순전파 시 파라미터 수의 수십~수백 배의 중간 Value 노드가 생성됩니다. 메모리 절약 외에도 속성 접근 속도도 향상됩니다.

__radd__ 가 필요한 이유

# Python의 sum()은 내부적으로 0 + first_element 를 실행합니다
# → 0.__add__(Value) → int는 Value를 모르므로 NotImplemented 반환
# → Python이 Value.__radd__(0) 를 대신 호출
# → self + other = Value + 0 → 정상 작동

total = sum(losses)   # sum([Value, Value, ...]) → 내부적으로 0 + Value(...) 실행
# __radd__ 없으면 TypeError!

역전파 — backward()

def backward(self):
    # 1) 위상 정렬(Topological Sort)로 그래프 순회
    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)

    # 2) 역순으로 기울기 전파
    self.grad = 1  # d(Loss)/d(Loss) = 1 (역전파 출발점)
    for v in reversed(topo):
        for child, local_grad in zip(v._children, v._local_grads):
            child.grad += local_grad * v.grad  # += 누적! (다변수 연쇄 법칙)
위상 정렬(Topological Sort)의 비밀: 계산 그래프를 역순으로 순회함으로써, 모든 child 노드의 기울기를 정확히 계산할 수 있습니다. 이것이 PyTorch의 torch.autograd가 내부적으로 수행하는 작업과 동일한 원리입니다.

역전파 단계별 추적 — L = (a * b) + a

a = 2.0, b = 3.0인 경우. 수학적으로 dL/da = b + 1 = 4.0, dL/db = a = 2.0

# ── 순전파 ──
a = Value(2.0)   # 파라미터
b = Value(3.0)   # 파라미터
c = a * b        # c.data=6.0, c._children=(a,b), c._local_grads=(3.0, 2.0)
                 #   ∂c/∂a = b.data = 3.0,  ∂c/∂b = a.data = 2.0
L = c + a        # L.data=8.0, L._children=(c,a), L._local_grads=(1, 1)
                 #   ∂L/∂c = 1,  ∂L/∂a = 1

# ── 위상 정렬 결과 (DFS 후위 순회) ──
# topo = [a, b, c, L]  ← 의존성 순서
# reversed(topo) = [L, c, b, a]  ← 역전파 순서

# ── 역전파 ──
L.grad = 1   # d(L)/d(L) = 1 (출발점)

# v=L: children=(c,a), local_grads=(1, 1)
# c.grad += 1 * L.grad  = 1 * 1 = 1.0
# a.grad += 1 * L.grad  = 1 * 1 = 1.0   (L = c + a 에서 a 경로)

# v=c: children=(a,b), local_grads=(3.0, 2.0)
# a.grad += 3.0 * c.grad = 3.0 * 1.0    → a.grad = 1.0 + 3.0 = 4.0
# b.grad += 2.0 * c.grad = 2.0 * 1.0    → b.grad = 0.0 + 2.0 = 2.0

# 최종: a.grad=4.0 ✓, b.grad=2.0 ✓ (수학 계산과 일치)

계산 그래프 시각화

       a(2.0)    b(3.0)
        │  \      │
        │   ×─────┘  ← __mul__: local_grads=(b, a)=(3.0, 2.0)
        │    \
        │     c(6.0)
        │      │
        └──────+      ← __add__: local_grads=(1, 1)
               │
              L(8.0)   ← loss.backward() 시작점, L.grad=1

역전파 방향: L → c,a → a,b  (화살표 반대 방향으로 grad 전파)
a=2.0 grad=4.0 b=3.0 grad=2.0 c=a×b=6.0 grad=1.0 L=c+a=8.0 grad=1.0 (시작) ∂c/∂a=3.0 ∂c/∂b=2.0 ∂L/∂c=1 ∂L/∂a=1

L = (a×b) + a 의 계산 그래프와 역전파. a는 두 경로에서 기울기를 받아 grad=4.0(누적)

재귀 위상 정렬의 동작 원리

def build_topo(v):
    if v not in visited:
        visited.add(v)
        for child in v._children:
            build_topo(child)  # 자식들을 먼저 처리
        topo.append(v)         # 자식 처리 후 자신을 추가 (후위 순회)

# L의 경우:
# build_topo(L)
#   build_topo(c)   ← L의 child
#     build_topo(a) ← c의 child → visited, topo=[a]
#     build_topo(b) ← c의 child → visited, topo=[a,b]
#   topo=[a,b,c]
#   build_topo(a)   ← L의 child → already visited, skip
# topo=[a, b, c, L]
# reversed → [L, c, b, a] ← 역전파 순서
+= 누적의 핵심 이유: 변수 a가 두 연산(a*bc+a)에서 사용될 때, 각 경로에서 오는 기울기를 더해야(+=) 합니다. 이것이 미적분의 "다변수 연쇄 법칙(multivariable chain rule)"입니다. PyTorch에서도 동일하게 .grad는 누적됩니다 (이 때문에 매 스텝마다 optimizer.zero_grad()를 호출해야 합니다).

모델 설계도 (Model Blueprint)

microgpt의 하이퍼파라미터를 정의합니다. 이 숫자들이 모델의 크기와 능력을 결정합니다.

GPT-2 아키텍처 따르기: "Follow GPT-2, blessed among the GPTs, with minor differences: layernorm → rmsnorm, no biases, GeLU → ReLU"
# Model Blueprint
n_embd = 16      # 임베딩 차원 (각 토큰을 16차원 벡터로 표현)
n_head = 4       # 어텐션 헤드 수 (16 / 4 = 헤드당 4차원)
n_layer = 1      # 트랜스포머 블록 수
block_size = 16  # 최대 시퀀스 길이 (컨텍스트 윈도우)
head_dim = n_embd // n_head  # = 4
파라미터선택 이유더 크게 하면?
n_embd1627개 토큰을 표현하기에 충분표현력↑ 속도↓ 메모리↑
n_head4head_dim = 16/4 = 4 (정수 분할)다양한 어텐션 패턴 가능↑
n_layer1단순성 최대화, 이름 생성엔 충분더 깊은 추론 가능↑
block_size16최장 이름이 15글자 + BOS더 긴 시퀀스 처리 가능
head_dim4= n_embd / n_head (파생값)어텐션 해상도↑
block_size=16의 근거: names.txt에서 가장 긴 이름이 15글자입니다. BOS + 15글자 = 16 위치면 충분합니다. 16이 넘는 이름은 n = min(block_size, len(tokens) - 1)으로 잘립니다.

파라미터 계산 — 4,192개의 숫자

레이어Shape파라미터 수
wte (토큰 임베딩)27 × 16432
wpe (위치 임베딩)16 × 16256
lm_head (언임베딩)27 × 16432 ※
attn_wq (Query)16 × 16256
attn_wk (Key)16 × 16256
attn_wv (Value)16 × 16256
attn_wo (출력 프로젝션)16 × 16256
mlp_fc1 (확장)64 × 161,024
mlp_fc2 (축소)16 × 641,024
총합4,192
※ Weight Tying (가중치 공유): 실제 코드에서 wtelm_head는 각각 별도로 초기화되어 있지만, 개념적으로 공유 가능합니다. nanoGPT에서는 명시적으로 lm_head.weight = transformer.wte.weight로 공유하여 432개 파라미터를 절약합니다. 이 아이디어는 Press & Wolf (2016)에서 제안된 것으로, 입력 임베딩과 출력 임베딩이 같은 공간에 있다는 직관에 기반합니다.
RMSNorm 파라미터 없음: microgpt의 rmsnorm(x)는 학습 가능한 γ(scale) 파라미터 없이 정규화만 수행합니다. LLaMA 등 프로덕션 모델의 RMSNorm과의 차이입니다.

state_dict — 가중치 초기화

# N(0, 0.08²) 가우시안으로 초기화된 행렬 생성 함수
matrix = lambda nout, nin, std=0.08: \
    [[Value(random.gauss(0, std)) for _ in range(nin)] for _ in range(nout)]

state_dict = {
    'wte':     matrix(vocab_size, n_embd),  # 27×16 = 432
    'wpe':     matrix(block_size, n_embd),  # 16×16 = 256
    'lm_head': matrix(vocab_size, n_embd),  # 27×16 = 432
}
for i in range(n_layer):
    state_dict[f'layer{i}.attn_wq'] = matrix(n_embd, n_embd)    # 16×16
    state_dict[f'layer{i}.attn_wk'] = matrix(n_embd, n_embd)
    state_dict[f'layer{i}.attn_wv'] = matrix(n_embd, n_embd)
    state_dict[f'layer{i}.attn_wo'] = matrix(n_embd, n_embd)
    state_dict[f'layer{i}.mlp_fc1'] = matrix(4*n_embd, n_embd)  # 64×16
    state_dict[f'layer{i}.mlp_fc2'] = matrix(n_embd, 4*n_embd)  # 16×64

params = [p for mat in state_dict.values() for row in mat for p in row]
# len(params) == 4192

std=0.08 선택 이유 — Xavier vs He vs microgpt

# 표준 초기화 비교 (n_in=16, n_out=16인 경우)

# Xavier (Glorot) 초기화: 순전파/역전파 분산 균형
# std = sqrt(2 / (n_in + n_out)) = sqrt(2/32) ≈ 0.25

# He (Kaiming) 초기화: ReLU 네트워크에 최적화
# std = sqrt(2 / n_in) = sqrt(2/16) ≈ 0.35

# microgpt 선택: std = 0.08 (더 작게 시작)
# 이유: bias 없는 네트워크는 초기값이 너무 크면
#       softmax가 포화(saturation)되기 쉬움.
#       작은 초기값으로 시작해 부드럽게 학습.
#       또한 약 243줄의 단순 모델에서 안정적 수렴을 위해.
초기화 방법std 값대상 활성화단점
Xavier≈ 0.25sigmoid, tanhReLU에는 부적합
He (Kaiming)≈ 0.35ReLU 계열bias 없으면 초기 포화 위험
microgpt0.08 (고정)ReLU단순하고 작은 모델에 최적
왜 0으로 초기화하면 안 되는가? 모든 파라미터가 0이면 순전파 결과가 전부 0이 되고, 역전파 시 모든 뉴런이 동일한 기울기를 받아 영원히 0에서 벗어나지 못합니다(대칭성 문제). 가우시안으로 초기화하면 각 파라미터가 다른 값을 가져 각자 다른 특징을 학습합니다.

임베딩 & 핵심 헬퍼 함수

출력 레이어 구조 — lm_head

최종 출력은 두 단계입니다: lm_head linear 변환 후 Softmax로 확률로 변환.

# hidden state x (16차원) → linear(lm_head) → softmax → 확률
# 16차원 → 27개 logit (어휘집 크기)
# logit → 합이 1인 확률 분포

logits = linear(x, state_dict['lm_head'])  # 16 → 27
probs  = softmax(logits)                   # 27개 확률, 합=1

# Softmax 강화 효과:
# z = [10, 12, 8] → Softmax(z) ≈ [12%, 87%, 2%]
# 입력 차이가 작아도 Softmax는 승자를 확실히 가림
# 10과 12의 차이는 작지만, 87%라는 압도적 확률로 12가 선택됨

미니 PyTorch — 3가지 헬퍼 함수

# ① linear: 행렬-벡터 곱셈 (bias 없음)
# x: [n_in]  →  return: [n_out]
def linear(x, w):
    return [sum(wi * xi for wi, xi in zip(wo, x)) for wo in w]

# ② softmax: 로짓 → 확률 분포 (수치 안정성을 위해 max 차감)
def softmax(logits):
    max_val = max(val.data for val in logits)   # overflow 방지
    exps = [(val - max_val).exp() for val in logits]
    total = sum(exps)
    return [e / total for e in exps]

# ③ rmsnorm: 평균 없이 RMS로만 정규화 (학습 가능 파라미터 없음)
def rmsnorm(x):
    ms = sum(xi * xi for xi in x) / len(x)    # 평균 제곱
    scale = (ms + 1e-5) ** -0.5               # 역수 RMS
    return [xi * scale for xi in x]
linear() — Wx (bias 없음)
행·열 내적으로 차원 변환. PyTorch의 nn.Linear(bias=False)에 해당.
softmax() — ex / Σex
로짓을 확률로 변환. max_val 차감은 수치 안정성을 위한 필수 트릭 — e^(큰 수)의 overflow를 방지합니다.
rmsnorm() — x / √(mean(x²) + ε)
RMS로만 정규화. 학습 가능한 γ(scale) 파라미터가 없습니다. LLaMA 등 프로덕션 모델의 RMSNorm과 달리, microgpt는 정규화만 수행합니다. ε=1e-5로 수치 안정성 확보.

토큰 + 위치 임베딩

wte (토큰 임베딩): "이 문자가 무엇인가" — 문자의 정체성. 같은 문자는 항상 같은 임베딩 행을 가져옵니다. 학습 후 모음('a','e','i','o','u')들은 벡터 공간에서 서로 가까워질 것입니다.

wpe (위치 임베딩): "이 문자가 몇 번째 위치에 있는가" — 어텐션은 위치를 모르므로(내적 연산은 순서가 없음) 위치 임베딩을 더함으로써 모델이 "첫 번째 글자", "마지막 글자" 같은 위치 패턴을 학습합니다.

# "emma"의 BOS 처리 예시 (token_id=26, pos_id=0)
tok_emb = state_dict['wte'][26]  # BOS 임베딩 → [0.03, -0.07, 0.11, ...] (16차원)
pos_emb = state_dict['wpe'][0]   # 위치 0 임베딩 → [0.05, 0.02, -0.08, ...]
x = [t + p for t, p in zip(tok_emb, pos_emb)]  # 원소별 덧셈 → 16차원
x = rmsnorm(x)  # 크기 정규화

# 왜 concatenate가 아닌 add인가?
# concat → 32차원 → 파라미터 2배 (wq, wk, wv도 32×32 필요)
# add   → 16차원 유지, 두 정보를 같은 공간에서 합산, 파라미터 절약
# 이론: 두 임베딩이 같은 공간(16차원)에서 "어떤 문자" + "어느 위치" 정보를 합산

Attention = 내부 검색 엔진

어텐션은 "현재 내가 처리 중인 토큰과 관련이 있는 이전 토큰들을 찾아라"내부 검색 엔진입니다.

Query (Q) — 검색어
"나는 어떤 정보를 찾고 있나?" — 현재 처리 중인 토큰이 묻는 질문
Key (K) — 색인
"나는 어떤 정보를 가지고 있나?" — 각 토큰이 제공하는 레이블
Value (V) — 내용물
"매칭 시 실제로 전달할 정보" — 검색 결과
# Q, K, V 행렬을 각각 가중치로 선형 변환
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'])  # 값:   "매칭 시 무엇을 줄까?"

Scaled Dot-Product — 왜 √head_dim으로 나누나?

# score = Q · K / √head_dim
# head_dim = 4인 경우
# Q = [0.1, -0.3, 0.5, 0.2]   (현재 토큰의 Query 벡터)
# K = [0.4, 0.2, -0.1, 0.3]   (과거 토큰의 Key 벡터)
# 내적 = 0.1×0.4 + (-0.3)×0.2 + 0.5×(-0.1) + 0.2×0.3
#      = 0.04 - 0.06 - 0.05 + 0.06 = -0.01
# 스케일: -0.01 / sqrt(4) = -0.01 / 2 = -0.005
스케일링의 수학적 이유: head_dim 차원의 벡터를 랜덤 초기화(N(0,1))하면 내적의 분산은 head_dim에 비례합니다. head_dim=4이면 분산≈4 (표준편차≈2), head_dim=64이면 분산≈64 (표준편차≈8). Softmax에 큰 값이 들어가면 한 토큰에 100% 집중되는 "포화(saturation)"가 발생합니다. √head_dim으로 나누면 내적의 분산을 1로 정규화하여 softmax가 균형 있는 가중치를 생성합니다.

Multi-head Attention — 왜 여러 헤드가 필요한가?

# head_dim = n_embd // n_head = 16 // 4 = 4
for h in range(n_head):        # h = 0, 1, 2, 3
    hs = h * head_dim          # 헤드 시작 인덱스: 0, 4, 8, 12
    q_h = q[hs:hs+head_dim]    # 각 헤드의 Query 4차원 슬라이스
    k_h = [ki[hs:hs+head_dim] for ki in keys[li]]   # 각 헤드의 Key [T, 4]
    v_h = [vi[hs:hs+head_dim] for vi in values[li]]  # 각 헤드의 Value [T, 4]

각 헤드가 서로 다른 패턴을 학습합니다:

  • 헤드 0: "첫 글자 패턴" — BOS 다음에 어떤 자음이 자주 오는가
  • 헤드 1: "모음 연속 패턴" — 모음 뒤에 모음이 오는지 자음이 오는지
  • 헤드 2: "어미 패턴" — 이름 끝부분의 통계적 규칙
  • 헤드 3: "위치 정보" — 지금 시퀀스의 어느 위치인가
실제로 어떤 패턴을 학습하는지는 학습 후 어텐션 가중치를 시각화해야 확인할 수 있습니다. "각 헤드가 다른 것을 학습한다"는 것이 Multi-head Attention의 핵심 아이디어입니다.
임베딩 x 16차원 n_embd=16 헤드 0 (dim 0~3) Q·K·V 각 4차원 → head_out[0:4] 헤드 1 (dim 4~7) Q·K·V 각 4차원 → head_out[4:8] 헤드 2 (dim 8~11) Q·K·V 각 4차원 → head_out[8:12] 헤드 3 (dim 12~15) Q·K·V 각 4차원 → head_out[12:16] Concat x_attn 16차원 4헤드 × 4차원 attn_proj 16차원 출력 linear(x_attn)

Multi-head Attention: n_embd=16을 4개 헤드가 4차원씩 분담 → 각각 독립적 패턴 학습 → Concat → 선형 변환

"emma" — 어텐션 동작 단계별 추적 (pos=4)

# 입력 토큰 시퀀스: [BOS(26), e(4), m(12), m(12), a(0)]
# pos_id:           [ 0,      1,    2,      3,      4  ]

# pos=4, token='a' 처리 시:
#   keys[0]   = [K_BOS, K_e, K_m, K_m, K_a]  (5개 누적)
#   values[0] = [V_BOS, V_e, V_m, V_m, V_a]
#
#   헤드 h=0 (4차원):
#     Q = q[0:4]      ← 'a'의 Query 벡터
#
#     scores = [
#       dot(Q, K_BOS) / 2,   # BOS와의 연관성
#       dot(Q, K_e)   / 2,   # 'e'와의 연관성
#       dot(Q, K_m)   / 2,   # 첫 번째 'm'과의 연관성
#       dot(Q, K_m)   / 2,   # 두 번째 'm'과의 연관성
#       dot(Q, K_a)   / 2,   # 'a' 자신과의 연관성 (현재 위치도 포함!)
#     ]
#
#     weights = softmax(scores)
#             = [0.05, 0.20, 0.35, 0.30, 0.10]  ← 가중치 합=1.0
#             (두 'm'에 0.65 집중 → 'emma'에서 'mm' 패턴 주목)
#
#     head_out = 0.05×V_BOS + 0.20×V_e + 0.35×V_m + 0.30×V_m + 0.10×V_a
#              = 가중 평균 벡터 (4차원)

# 4개 헤드 각각 head_out 4차원 계산 → x_attn.extend(head_out)
# 결과: x_attn = [h0의 4차원, h1의 4차원, h2의 4차원, h3의 4차원] = 16차원

인과적 마스킹 (Causal Masking) — 미래를 못 보게 하기

GPT는 자기회귀(autoregressive) 모델 — 미래 토큰을 보면 안 됩니다. GPT-2 원본에서는 어텐션 행렬에 "-∞" 마스크를 명시적으로 적용합니다. microgpt에서는 이것이 자동으로 처리됩니다:

# 학습 시: pos_id=2를 처리할 때
# keys[li]에는 이미 [K_pos0, K_pos1, K_pos2]만 있습니다.
# K_pos3, K_pos4는 아직 append되지 않았습니다.
# → 명시적 마스킹 없이도 미래 정보를 볼 수 없습니다!
for pos_id in range(n):
    logits = gpt(token_id, pos_id, keys, values)
    # 내부에서 keys[li].append(k)가 실행됨
    # 다음 pos_id 처리 시 현재 K도 포함됨
이것이 microgpt가 Transformer 원본보다 단순한 이유 중 하나입니다. KV 캐시의 순차적 축적이 자동으로 인과적 마스킹을 구현합니다.

KV Cache — 추론 속도의 비밀

# 추론/학습 시작 시 빈 KV 캐시 초기화
keys   = [[] for _ in range(n_layer)]   # 레이어별 Key 리스트
values = [[] for _ in range(n_layer)]   # 레이어별 Value 리스트

# gpt() 내부: 이번 토큰의 K,V를 캐시에 누적
keys[li].append(k)    # k = linear(x, attn_wk), 모든 헤드 포함 [n_embd]
values[li].append(v)  # v = linear(x, attn_wv)

# 어텐션 계산: 과거 모든 K,V를 헤드별로 분리해서 사용
k_h = [ki[hs:hs+head_dim] for ki in keys[li]]    # T개 과거 Key 헤드
v_h = [vi[hs:hs+head_dim] for vi in values[li]]   # T개 과거 Value 헤드
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))   # 현재까지 누적된 토큰 수만큼
]
KV Cache의 효과: 각 추론 스텝마다 이미 계산한 K, V를 다시 계산하지 않고 재사용합니다. O(T²) 반복 계산 → O(T) 증가분 계산. GPT-4 같은 대형 모델에서는 이 캐시가 추론 속도를 수십 배 높여줍니다.
KV 캐시 순차 누적 (추론 시, "kamon" 생성) Step 0 (pos=0) 입력: BOS(26) keys = [K_BOS] → Attention 범위: BOS만 → 샘플링: 'k'(10) 미래 정보 없음 (자동 마스킹) Step 1 (pos=1) 입력: 'k'(10) keys = [K_BOS, K_k] → Attention 범위: BOS + k → 샘플링: 'a'(0) K_BOS 재계산 없이 재사용 Step 2 (pos=2) 입력: 'a'(0) keys = [K_BOS, K_k, K_a] → Attention 범위: BOS+k+a → 다음: 'm'(12) K_BOS, K_k 모두 재사용

KV 캐시 누적: 각 스텝마다 새 K·V만 추가 — 기존 K·V 재계산 없이 재사용. 인과적 마스킹도 자동 구현.

잔차 연결 (Residual Connection) — 잊어버리지 않기

# 잔차 연결 패턴 (Attention 블록 예시)
x_residual = x               # ① 원본 x 저장
x = rmsnorm(x)              # ② 정규화
x = attention(x)  # ...   # ③ Attention 처리
x = [a + b for a, b in zip(x, x_residual)]  # ④ 원본 + 결과

잔차 연결을 빠뜨리면?

모델이 "나는 B라는 글자를 읽고 있었지"라는 사실을 잊어버리고, "나는 A와 관련이 있어"라는 어텐션 출력만 기억하게 됩니다. 우리는 원본 정보(Content)와 새로운 컨텍스트(Context) 둘 다 필요합니다.
x = x + attention_output — 이 한 줄이 핵심입니다.

잔차 연결의 또 다른 효과:

  • 기울기 소실 방지 — 깊은 네트워크에서 역전파 시 기울기가 층마다 곱해져 소실되는 문제를 해결합니다. 잔차 경로를 통해 기울기가 직접 흐릅니다.
  • 항등 함수 학습 용이 — 레이어가 아무것도 하지 않아도 되면(정보 변환 불필요), 출력 = 0 + x_residual이 됩니다.
  • 모든 현대 트랜스포머의 표준 — GPT-2 이후 모든 대형 언어 모델이 채택
입력 임베딩 Q (쿼리) K (키) V (값) Q·Kᵀ / √d → softmax Σ weights × V → 출력

Scaled Dot-Product Attention 흐름: Q·Kᵀ/√d → softmax → 가중합 × V

트랜스포머 블록

트랜스포머의 핵심 반복 단위입니다. 각 레이어는 Attention → MLP 순서로 처리합니다.

입력 x → Attention(컨텍스트 수집) → + → MLP(의미 처리) → + → 출력

입력 x (16차원) Pre-LN RMSNorm(x) Multi-head Attention Q·K·V → 컨텍스트 수집 + Pre-LN RMSNorm(x) MLP (FC1→ReLU→FC2) 16 → 64 → 16차원 + 출력 x (16차원) 잔차 경로 잔차 경로

트랜스포머 블록: Pre-LN → Multi-head Attention → 잔차합 → Pre-LN → MLP → 잔차합. 점선은 잔차(bypass) 경로.

MLP — 비선형성과 4× 확장의 이유

# fc1: 16차원 → 64차원 (4× 확장)
x = linear(x, state_dict[f'layer{li}.mlp_fc1'])  # [n_embd] → [4*n_embd]
x = [xi.relu() for xi in x]                       # 비선형성: max(0, x)
# fc2: 64차원 → 16차원 (원상복구)
x = linear(x, state_dict[f'layer{li}.mlp_fc2'])  # [4*n_embd] → [n_embd]

왜 4× 확장인가? 이 "bottleneck" 구조(좁게 → 넓게 → 좁게)는 GPT-2 원논문에서 정의된 표준입니다. 중간 64차원에서 더 많은 특징 조합을 만든 뒤, 다시 16차원으로 압축합니다. 4× 비율은 실험적으로 좋은 성능을 보인 경험적 수치이며, GPT-4도 동일한 비율을 씁니다.

Attention vs MLP의 역할 구분: Attention은 다른 토큰의 정보를 "수집(Gather)"합니다. MLP는 수집된 정보를 "처리(Process)"합니다. ReLU 없이 Attention + Linear만 있다면, 아무리 쌓아도 단 하나의 선형 변환과 수학적으로 동일합니다. ReLU가 추가하는 비선형성이 복잡한 패턴을 학습 가능하게 합니다.

GELU → ReLU — 의도적 단순화

GELU (표준, GPT-2/3) — x · Φ(x)
정규분포의 누적분포함수 사용. 수식 복잡 (0.5 × x × (1 + tanh(√(2/π) × (x + 0.044715x³)))). 확률론적 해석으로 더 부드러운 활성화. 하지만 순수 파이썬으로 구현하면 느리고 복잡합니다.
ReLU (microgpt) — max(0, x)
단순하지만 충분히 효과적. xi.relu() 단 한 줄. 순수 파이썬 구현에 최적. 이름 생성이라는 단순한 태스크에는 ReLU도 충분합니다.

Pre-LN vs Post-LN — microgpt의 선택

구분Post-LN (GPT-2 원본)Pre-LN (microgpt)
순서x → Attention → x+residual → Normx → Norm → Attention → x+residual
잔차 경로정규화된 값이 잔차로 흐름원본 x가 그대로 잔차로 흐름 ✓
학습 안정성레이어 수 많으면 불안정더 안정적 (LLaMA, PaLM 채택)
초기화 의존높음낮음
# microgpt의 Pre-LN 패턴:
x_residual = x         # ← 정규화 전 원본 저장
x = rmsnorm(x)         # ← 정규화 후 Attention/MLP에 입력
x = attention(x)  # ...
x = [a + b for a, b in zip(x, x_residual)]  # ← 원본 + 결과

RMSNorm vs LayerNorm

항목LayerNorm (기존)RMSNorm (LLaMA 등)RMSNorm (microgpt)
수식(x − μ) / √(σ² + ε)x / √(RMS² + ε)x / √(RMS² + ε)
학습 파라미터γ + β (2개)γ만 (1개)없음 (정규화만)
계산 비용평균·분산 계산분산만 계산분산만 계산 (γ 없음)

RMSNorm은 평균 계산이 필요 없어 LayerNorm 대비 7~64% 더 빠르고, 성능은 거의 동일합니다.

완전한 gpt() 함수 — GPT 파이프라인

def gpt(token_id, pos_id, keys, values):

    # ─── ① 임베딩: 토큰 + 위치 합산 ─────────────────────
    tok_emb = state_dict['wte'][token_id]   # 16차원 벡터
    pos_emb = state_dict['wpe'][pos_id]     # 16차원 벡터
    x = [t + p for t, p in zip(tok_emb, pos_emb)]
    x = rmsnorm(x)  # ← 입력 정규화 (잔차 경로 때문에 필요)

    # ─── ② 트랜스포머 레이어 × n_layer ──────────────────
    for li in range(n_layer):

        # Attention Block: Pre-LN 방식
        x_residual = x
        x = rmsnorm(x)
        q = linear(x, state_dict[f'layer{li}.attn_wq'])    # Q: [n_embd]
        k = linear(x, state_dict[f'layer{li}.attn_wk'])    # K: [n_embd]
        v = linear(x, state_dict[f'layer{li}.attn_wv'])    # V: [n_embd]
        keys[li].append(k)    # KV 캐시에 현재 토큰 저장
        values[li].append(v)

        # 멀티헤드 어텐션 (헤드별로 분리 계산)
        x_attn = []
        for h in range(n_head):
            hs = h * head_dim
            q_h = q[hs:hs+head_dim]                                   # [head_dim]
            k_h = [ki[hs:hs+head_dim] for ki in keys[li]]             # [T, head_dim]
            v_h = [vi[hs:hs+head_dim] for vi in values[li]]           # [T, head_dim]
            # Scaled Dot-Product: Q·Kᵀ / √head_dim
            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)                        # [T]
            # Weighted sum of Values
            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)]   # x = attn_out + x_residual

        # MLP Block: Pre-LN 방식
        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]                      # 비선형성
        x = linear(x, state_dict[f'layer{li}.mlp_fc2'])  # 64 → 16
        x = [a + b for a, b in zip(x, x_residual)]         # x = mlp_out + x_residual

    # ─── ③ 언임베딩: 16차원 → 27개 로짓 ─────────────────
    logits = linear(x, state_dict['lm_head'])
    return logits
입력 rmsnorm의 비밀: x = rmsnorm(x)가 임베딩 직후 한 번 더 있습니다. 이것은 Karpathy가 주석에서 언급한 부분 — "잔차 경로(residual stream) 덕분에 불필요하지 않다." 잔차 연결은 원본 임베딩을 그대로 더하므로, 첫 번째 rmsnorm이 없으면 비정규화된 임베딩이 그대로 잔차로 흘러들어갑니다. 임베딩의 크기가 제각각이면 잔차 경로 전체가 불안정해집니다.

이 함수 하나가 토큰 ID와 위치 ID를 받아 27개 어휘에 대한 확률 분포(로짓)를 반환합니다.

트랜스포머 4대 구성요소

  1. Attention — 컨텍스트 수집 (다른 토큰의 정보를 Gather)
  2. MLP — 의미 처리 (수집된 정보를 Process)
  3. 잔차 연결(Residuals) — 안정적인 학습 + 기울기 소실 방지
  4. RMSNorm — 연산 안정성 확보 (LayerNorm 대비 단순화)
이 네 가지 구성요소가 현대 모든 트랜스포머 모델(GPT-4, LLaMA, Gemini 등)의 기본 빌딩 블록입니다. 규모(Scale)만 다를 뿐, 원리는 microgpt와 완전히 동일합니다.

Cross-Entropy 손실 계산

Cross-Entropy는 정보 이론에서 나온 개념입니다. 모델의 예측 분포 P가 정답에서 얼마나 멀리 있는지를 측정합니다.

# 수식: L = -log(P(정답 토큰))
# 코드: loss_t = -probs[target_id].log()

# 직관: 정답 토큰에 높은 확률 부여 → log(높은 값) → 음수 작음 → loss 작음
# P(target) = 0.9  → loss = -log(0.9) ≈ 0.105  (잘 맞춤)
# P(target) = 0.5  → loss = -log(0.5) ≈ 0.693
# P(target) = 0.1  → loss = -log(0.1) ≈ 2.303  (많이 틀림)
# P(target) = 0.01 → loss = -log(0.01) ≈ 4.605 (매우 틀림)

# 전체 이름에 대한 평균:
# loss = (1/n) * Σ -log(P(tokens[pos+1] | tokens[:pos+1]))

위치별 손실 계산 예시 — "ab" 이름

이름 "ab"를 BOS로 감싸면 토큰 시퀀스: [BOS, a, b, BOS]

PosInputTargetP(target) 예시Loss ≈
0BOS'a'0.102.30
1'a''b'0.500.69
2'b'BOS0.201.61
평균L₀ = (2.30 + 0.69 + 1.61) / 3 ≈ 1.53
학습 = 각 위치에서 다음 토큰을 예측하도록 훈련 BOS → 첫 글자 예측. 각 글자 → 다음 글자 예측. 마지막 글자 → BOS(종료) 예측. 위치별 cross-entropy를 전부 합산하고 평균을 냅니다.

랜덤 베이스라인 — 학습 시작점 3.30

# 완전 랜덤 모델: 27개 토큰에 균등 확률 1/27 ≈ 0.037
# 랜덤 베이스라인 손실 = -log(1/27) = log(27) ≈ 3.296

# 정보 이론적 해석: 27개 심볼의 엔트로피 = log₂(27) ≈ 4.75 bits
# (완전히 무작위인 시스템의 불확실성)

# Perplexity = exp(loss)
# 랜덤 Perplexity = exp(3.30) = 27  (= 어휘 크기, 직관적!)
# 학습 후 Perplexity = exp(2.37) ≈ 10.7
# → 모델이 평균적으로 약 11개 후보 중 정답을 고르는 수준

손실 궤적 — 3.30 → 2.37 (1000 스텝)

스텝LossPerplexity의미
03.3027.1완전 랜덤 (초기화 직후)
100~2.90~18기본 통계 습득 시작
500~2.60~13모음-자음 패턴 학습 중
10002.3710.7이름 구조 패턴 학습 완료
3.30 2.90 2.60 2.37 Loss 0 100 500 1000 Step 랜덤 3.30 3.30 ~2.90 ~2.60 2.37

손실 궤적: 1,000 스텝 학습 — 3.30(랜덤 초기화) → 2.37(학습 완료). 점선은 랜덤 베이스라인.

손실 3.30→2.37, 개선폭 0.93 — Perplexity 27→10.7으로 약 2.5배 향상. 4,192개 파라미터만으로 이름의 통계적 패턴을 학습한 결과입니다.

왜 loss=0이 되지 않는가? 이름 데이터 자체에 불확실성이 있습니다. 예: "k" 다음에 "a", "e", "i" 등 여러 글자가 올 수 있습니다. 모델이 할 수 있는 최선은 이 확률 분포를 정확히 학습하는 것이며, 완벽해도 엔트로피(최솟값) 이하로는 내려갈 수 없습니다.

완전한 학습 루프

learning_rate, beta1, beta2, eps_adam = 0.01, 0.85, 0.99, 1e-8
m = [0.0] * len(params)   # 1차 모멘텀 (방향)
v = [0.0] * len(params)   # 2차 모멘텀 (크기)

num_steps = 1000
for step in range(num_steps):

    # ① 문서 선택: 셔플된 docs를 순환 (step % len(docs))
    doc = docs[step % len(docs)]
    tokens = [BOS] + [uchars.index(ch) for ch in doc] + [BOS]
    n = min(block_size, len(tokens) - 1)   # 최대 block_size까지만

    # ② 순전파: 각 위치에서 다음 토큰 예측 + loss 누적
    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)
        losses.append(-probs[target_id].log())   # cross-entropy (스칼라)
    loss = (1 / n) * sum(losses)   # 위치별 평균 loss

    # ③ 역전파: 모든 파라미터의 기울기 계산
    loss.backward()

    # ④ Adam 파라미터 업데이트 (기울기 초기화 포함)
    lr_t = learning_rate * (1 - step / num_steps)   # 선형 감쇠
    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 = 0   # ← 기울기 초기화 (업데이트 직후)
학습 루프 4단계: ①문서 순환 → ②순전파(loss 누적) → ③역전파(grad 계산) → ④Adam 업데이트 + grad 초기화. 이 1,000번 반복이 microgpt의 학습 전부입니다. p.grad = 0은 업데이트 에 수행됩니다 (인덱스 기반 for 루프이므로 별도의 초기화 루프가 필요 없습니다).

경사 하강법 관점으로 다시 보기

위 루프는 구현이 길어 보이지만, 수학적으로는 경사 하강법 한 줄을 반복하는 과정입니다.

# 경사 하강법 핵심식
θ_(t+1) = θ_t - η_t * ∇L(θ_t)

# microgpt에서의 대응 관계
# θ_t      -> params 리스트의 각 p.data
# ∇L(θ_t)  -> loss.backward() 이후의 p.grad
# η_t      -> lr_t (선형 감쇠 학습률)
# 업데이트 -> Adam 식으로 보정 후 p.data 감소
microgpt 학습 루프와 경사 하강법 매핑 순전파 loss 계산 역전파 p.grad 계산 Adam 보정 m, v, bias correction 업데이트 p.data 감소 수식 관점 θ_(t+1) = θ_t - η_t * ∇L(θ_t) microgpt는 ∇L을 Adam으로 보정해 안정적이고 빠르게 수렴

microgpt 학습 루프는 경사 하강법을 Adam 방식으로 안정화한 구현입니다.

핵심 포인트: 학습의 본질은 항상 동일합니다. 손실이 줄어드는 방향(음의 기울기)으로 조금씩 이동하고, Adam은 그 이동 폭을 파라미터별로 자동 조절합니다.

학습률 감쇠 (Linear Learning Rate Decay)

# 선형 학습률 감쇠: step이 진행될수록 lr 감소
lr_t = learning_rate * (1 - step / num_steps)
# step=0:    lr_t = 0.01 × (1 - 0/1000)   = 0.01000  ← 최대
# step=500:  lr_t = 0.01 × (1 - 500/1000) = 0.00500  ← 중간
# step=999:  lr_t = 0.01 × (1 - 999/1000) = 0.00001  ← 최소

# 주의: step=1000 (num_steps)에 도달하면 lr_t=0이 되므로
# 실제로는 step 0~999 (총 1000번)만 실행됩니다.
왜 학습률을 줄이는가? 학습 초반에는 파라미터가 최적값에서 많이 떨어져 있으므로 큰 보폭(높은 lr)으로 빠르게 이동합니다. 후반에는 최적값 근처에서 세밀하게 조정해야 수렴 포인트를 지나치지 않도록 작은 보폭을 씁니다. nanoGPT는 더 정교한 "Cosine decay with linear warmup" (GPT-3 방식)을 사용합니다.

Adam 옵티마이저

단순한 SGD(확률적 경사 하강법) 대신, 적응적 학습률을 갖는 Adam을 구현합니다. Adam = Adaptive Moment estimation.

옵티마이저업데이트 규칙단점
SGDp -= lr × grad학습률 수동 조정, 느린 수렴
Momentum SGDv = β×v + grad; p -= lr×v학습률 여전히 전역 고정
RMSPropv = β×v + (1-β)×grad²; p -= lr×grad/√v모멘텀 없음
Adam ✓m, v 둘 다 추적; 바이어스 보정메모리 2배 (m, v 버퍼)

Adam 단계별 코드 해설

# ── 초기화 (학습 루프 전) ──
m = [0.0] * len(params)   # 1차 모멘텀 버퍼 (지수 이동 평균 of grad)
v = [0.0] * len(params)   # 2차 모멘텀 버퍼 (지수 이동 평균 of grad²)
beta1, beta2 = 0.85, 0.99

# ── 각 스텝의 업데이트 ──
for i, p in enumerate(params):
    g = p.grad

    # ─ 1단계: 1차 모멘텀 (방향) ─
    # 현재 기울기 g와 이전 방향 m[i]의 지수 가중 평균
    m[i] = beta1 * m[i] + (1 - beta1) * g
    # beta1=0.85 → 현재 기울기에 15% 가중, 이전 방향에 85% 가중
    # (기본값 0.9보다 작아 더 빠르게 현재 기울기에 반응)

    # ─ 2단계: 2차 모멘텀 (크기) ─
    # 기울기 제곱의 지수 이동 평균 → "이 파라미터의 기울기가 얼마나 크게 변하는가"
    v[i] = beta2 * v[i] + (1 - beta2) * g**2
    # beta2=0.99 → 기울기 크기의 장기 기억

    # ─ 3단계: 바이어스 보정 ─
    # 초기(step 작을 때) m,v가 0으로 초기화되어 있어 편향됨을 보정
    t = step + 1
    m_hat = m[i] / (1 - beta1**t)   # step=1: 1/(1-0.85)=6.67배 증폭
    v_hat = v[i] / (1 - beta2**t)   # step=1: 1/(1-0.99)=100배 증폭

    # ─ 4단계: 파라미터 업데이트 ─
    # 효과적 학습률 = lr_t / √v_hat → 기울기가 크게 변하는 파라미터는 작게 업데이트
    p.data -= lr_t * m_hat / (v_hat**0.5 + eps_adam)
    p.grad = 0   # 초기화

바이어스 보정 상세 — 왜 필요한가?

# step=1, beta1=0.85, grad=1.0 인 경우:
m[i] = 0.85 * 0.0 + 0.15 * 1.0 = 0.15   # 실제 gradient=1.0인데 m=0.15만 저장됨
m_hat = 0.15 / (1 - 0.85**1) = 0.15 / 0.15 = 1.0  # 보정 후 1.0 ← 올바른 값

# step=100, beta1=0.85:
# 1 - 0.85**100 ≈ 1.0 (완전 포화)
# → 보정 인자가 거의 1이 되어 바이어스 보정이 자동으로 무력화됨
# → 초반 스텝에서만 중요하고, 충분한 스텝 후엔 영향 없음
파라미터기본값 (논문)microgpt효과
beta10.90.85 현재 기울기에 15% (기본 10%) → 더 빠른 방향 전환
beta20.9990.99 기울기 크기 기억 더 짧게 → 더 빠른 적응

1,000 스텝 밖에 없는 짧은 학습에서는 빠른 적응이 중요합니다. 기본값(β1=0.9, β2=0.999)은 수십만 스텝의 장기 학습을 위한 값입니다.

Adam의 적응적 학습률 직관: 평탄한 길(기울기 작음) → 큰 보폭으로 빠르게. 가파른 경사(기울기 큼) → 작은 보폭으로 조심스럽게. lr / √v_hat에서 v_hat(기울기 제곱 평균)이 크면 분모가 커져 실제 학습률이 줄어듭니다. 파라미터마다 각자에게 맞는 학습률을 자동 조절합니다.

추론 (Inference) & 이름 생성

# may the model babble back to us
temperature = 0.5  # (0, 1] — 낮을수록 보수적, 높을수록 다양한 출력
for sample_idx in range(20):   # 총 20개의 이름 생성
    keys, values = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)]
    token_id = BOS   # 시작 토큰
    sample = []
    # BOS → 첫 글자 → 두 번째 글자 → ... → BOS(종료)
    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)}")

자기회귀 생성 — "kamon" 단계별 추적

# "kamon" 생성 과정 (temperature=0.5 적용)
keys = [[] for _ in range(n_layer)]
values = [[] for _ in range(n_layer)]
token_id = BOS  # 26

# ── step 0 (pos=0) ──
logits = gpt(26, 0, keys, values)           # BOS 입력
probs  = softmax([l/0.5 for l in logits])   # temperature=0.5 적용
# probs 중 'k'(10)의 확률이 가장 높다고 가정
token_id = random.choices(range(27), weights=[p.data for p in probs])[0]
# → token_id = 10 ('k')

# ── step 1 (pos=1) ──
logits = gpt(10, 1, keys, values)   # 'k' 입력, pos=1
# keys[0]에는 이제 K_BOS, K_k 두 개가 있음
# → 'a'(0)가 높은 확률로 샘플링됨 → token_id = 0

# ── step 2 (pos=2) ── 'm'(12) 샘플링
# keys[0] = [K_BOS, K_k, K_a] (3개 누적)

# ── step 3 (pos=3) ── 'o'(14) 샘플링
# ── step 4 (pos=4) ── 'n'(13) 샘플링
# ── step 5 (pos=5) ── BOS(26)가 샘플링되면 break → "kamon" 완성
# → sample = ['k', 'a', 'm', 'o', 'n'] → "kamon"
BOS token=26 gpt() token, pos keys, values 확률 분포 softmax(logits/T) T=0.5 샘플링 random.choices() → 토큰 ID == BOS? break → 완성! ≠ BOS sample.append(ch) 반복 (pos+1, token=새 token_id)

자기회귀 생성 루프: BOS → gpt() → softmax/temperature → 샘플링 → BOS이면 종료, 아니면 반복

block_size=16 제한의 의미

for pos_id in range(block_size):   # 0~15, 최대 16 스텝
    logits = gpt(token_id, pos_id, keys, values)
    # ...
    if token_id == BOS: break   # 보통 여기서 멈춤

# 만약 BOS가 16 스텝 안에 생성되지 않으면:
# pos_id가 block_size에 도달해 루프 종료 → 이름이 잘림
# names.txt의 최장 이름이 15글자이므로, 정상 이름은 항상 16 이내에 BOS 생성
# 반면 wpe(위치 임베딩)는 0~15만 정의됨
# → pos_id=16 이상을 입력하면 IndexError 발생

Temperature — 창의성 조절

# 원본 logits (학습된 모델 출력, 일부):
# 'a'=2.1, 'k'=1.8, 'e'=1.5, 나머지≈0

# Temperature T=1.0 (원본 유지):
# softmax([2.1, 1.8, 1.5, ...]) ≈ [0.30, 0.22, 0.17, ...]
#   → 적당히 다양한 선택

# Temperature T=0.5 (낮게, 로짓 2배 증폭):
# softmax([4.2, 3.6, 3.0, ...]) ≈ [0.52, 0.28, 0.14, ...]
#   → 'a'에 집중, 더 안정적

# Temperature T=0.1 (매우 낮게, 로짓 10배 증폭):
# softmax([21, 18, 15, ...]) ≈ [0.95, 0.05, ~0, ...]
#   → 거의 항상 'a' 선택 (greedy에 가까움)

# Temperature T=2.0 (높게, 로짓 절반):
# softmax([1.05, 0.9, 0.75, ...]) ≈ [0.20, 0.18, 0.16, ...]
#   → 거의 균등 → 무작위에 가까움
Temperature = 0.1 — 보수적 (Greedy에 가까움)
로짓에 10을 곱한 효과 → 가장 높은 확률 토큰에 95%+ 집중. 반복적이지만 안정적. 동일한 BOS에서 항상 같은 이름이 나올 수 있음.
Temperature = 0.5 (microgpt.py 기본값) — 품질과 다양성의 균형
로짓에 2를 곱한 효과 → 상위 토큰들 간의 차이를 2배로 강조. 발음 가능하고 다양한 이름 생성.
Temperature = 1.0 — 원본 확률 그대로
모델이 학습한 분포를 그대로 사용. 이론적으로 "올바른" 샘플링. 가끔 발음 어려운 이름도 생성.
Temperature > 1.0 — 창의적 (과도한 무작위)
로짓 차이를 줄여 균등 분포에 가까워짐. 이상한 결과 빈도 증가.
⚠️ Temperature = 0 → ZeroDivisionError: logits / 0은 Python에서 ZeroDivisionError를 냅니다. Greedy Decoding을 원한다면 temperature 나눗셈을 제거하고 token_id = max(range(vocab_size), key=lambda i: logits[i].data)를 사용하세요.

실제 생성 결과 — 1000 스텝 학습 후 샘플 20개

샘플이름특징
1~5kamon, ann, karai, jaire, vialan모음-자음 교대 패턴
6~10karia, yeran, anna, areli, kaina"ar-", "an-" 흔한 이름 조각
11~15konna, keylen, liole, alerin, earan2~6글자 적절한 길이
16~20lenne, kana, lara, alela, anton발음 가능한 이름 구조
4,192개 파라미터가 학습한 통계적 패턴: "k-", "l-", "a-" 로 시작하는 이름이 많음. 모음(a, e, i, o) 뒤에 자음이 자주 옴. "-an", "-ra", "-la", "-on" 같은 접미사 패턴. 평균 4~5글자 (훈련 데이터 분포 반영).

학습 vs 추론의 차이점

측면학습 (Training)추론 (Inference)
입력 방식이름 전체 토큰을 위치별로 순전파BOS부터 시작, 토큰 하나씩 생성
역전파O (loss.backward())X (grad 불필요)
KV 캐시매 이름마다 초기화매 샘플마다 초기화
종료 조건모든 위치 처리 완료BOS 샘플링 or block_size 도달
결정성결정적 (seed 고정)확률적 (temperature 샘플링)

MicroGPT vs 현대 LLM

항목microgpt.pyModern LLMs (GPT-4 등)
파라미터4,192개수조(Trillions)개
데이터32,033개의 이름인터넷 전체 텍스트 (수조 토큰)
하드웨어노트북 CPU, 수 분수천 개의 H100 GPU
코드약 243줄 순수 파이썬수백만 줄, 수천억 원 학습 비용
목적영어 이름 생성범용 언어 이해 & 생성
하지만 원리는 완전히 동일합니다. MicroGPT와 GPT-4의 아키텍처적 차이는 규모(scale)뿐입니다. 토큰 임베딩, 트랜스포머 레이어, 어텐션, MLP, 잔차 연결, RMSNorm, 소프트맥스, Adam 옵티마이저 — 모두 같은 원리입니다.

구성요소별 상세 비교

구성요소microgpt.pyGPT-4 / LLaMA 등
데이터32,033개의 이름 (names.txt)인터넷 전체 텍스트 (수조 토큰)
토크나이저26 알파벳 + BOS = 27개 토큰BPE/SentencePiece, 50K~256K 토큰
Autograd스칼라 Value 클래스, 순수 PythonPyTorch/JAX 텐서 자동미분 (GPU 병렬)
아키텍처n_embd=16, n_head=4, n_layer=1n_embd=12288, n_head=96, n_layer=96+
파라미터4,192개수천억~수조 개
학습1000 스텝, 노트북 CPU, 수 분수천억 스텝, H100 수천 개, 수개월
정규화RMSNorm (학습 파라미터 없음)LayerNorm or RMSNorm (γ 파라미터 있음)
활성화ReLUGELU (GPT-2/3), SwiGLU (LLaMA)
사후 학습없음 (순수 사전학습만)SFT + RLHF/DPO (인간 피드백)
추론 최적화KV캐시 + temperature 샘플링Flash Attention, 양자화, 분산 추론
Karpathy의 말: "The most atomic way to train and run inference for a GPT in pure, dependency-free Python. This file is the complete algorithm. Everything else is just efficiency."

nanoGPT — microgpt의 Production 확장판

nanoGPT는 Karpathy가 2022년 12월 공개한 "가장 간결하고 빠른 GPT 훈련 코드베이스"입니다. OpenAI GPT-2(124M 파라미터)를 완전히 재현하고, 단일 GPU에서 실용적인 언어 모델을 훈련할 수 있게 설계되었습니다. microgpt가 "이해를 위한 원자적 구현"이라면, nanoGPT는 "실제 사용을 위한 최소 구현"입니다.

Token Embedding (vocab_size=50257) + Positional Embedding (block_size=1024) Transformer Blocks (n_layer=12) Block 1 ... Block 11 Block 12 LayerNorm CausalSelfAttention Residual LayerNorm → MLP Final LayerNorm Linear (lm_head) Vocabulary
Softmax (50257)

그림 1: nanoGPT 아키텍처 — GPT-2 (124M) 구조

핵심 숫자: 124M 파라미터 = 12레이어 × 12헤드 × 768임베딩. Attention O(N²) → Flash Attention으로 O(N) 메모리 절약.

nanoGPT 설치 및 실행

nanoGPT는 약 300줄의 모델 정의와 300줄의 훈련 루프로 구성되어 있어 코드를 읽고 이해하기 매우 좋습니다.

# 1. 저장소 클론
git clone https://github.com/karpathy/nanoGPT.git
cd nanoGPT

# 2. 의존성 설치
pip install torch numpy tiktoken tqdm transformers datasets

# 3. Shakespeare 데이터로 훈련 (문자 수준, 입문용)
python train.py config/train_shakespeare_char.py

# 4. 텍스트 생성
python sample.py --out_dir=out-shakespeare-char

# 5. GPT-2 가중치로 시작 (파인튜닝)
python train.py --init_from=gpt2

# 6. 분산학습 (4 GPU)
torchrun --standalone --nproc_per_node=4 train.py

# 7. torch.compile으로 최적화 (PyTorch 2.0+)
python train.py --compile=True

핵심 커맨드라인 옵션

옵션기본값설명
--init_fromscratchscratch(처음부터), resume(계속), gpt2/GPT-2 가중치
--batch_size12배치 크기 (마이크로 배치)
--max_iters600000총 훈련 반복 횟수
--learning_rate6e-4최대 학습률
--compileFalsetorch.compile으로 그래프 최적화
--dtypebfloat16float16/bfloat16/float32
--wandb_logFalseWeights & Biases 로깅 활성화

microgpt vs nanoGPT 핵심 차이점

항목microgpt.pynanoGPT
연산 단위스칼라 Value 객체PyTorch 텐서 (GPU 병렬 연산)
자동미분수동 구현 (_backward, 위상 정렬)PyTorch .backward() 완전 자동화
토크나이저26 알파벳 + BOS = vocab_size 27 (문자 수준)tiktoken BPE — vocab_size 50,257 (서브워드)
아키텍처n_layer=1, n_head=4, n_embd=16n_layer=12, n_head=12, n_embd=768 (GPT-2 small)
파라미터 수4,192개124,439,808개 (GPT-2 small) ~ 1.5B (XL)
배치 크기1 (이름 1개/스텝)32~512+ (gradient accumulation)
컨텍스트 길이최대 15 토큰 (가장 긴 이름)1,024 토큰 (GPT-2 기본)
Attention 구현스칼라 루프 기반 수동 계산Flash Attention (F.scaled_dot_product_attention)
정규화RMSNorm (수동 구현)LayerNorm (nn.LayerNorm) + Dropout
활성화 함수ReLUGELU (F.gelu) — GPT-2/3 표준
옵티마이저Adam 수동 구현 (β1=0.85, β2=0.99)PyTorch optim.AdamW + weight decay (β1=0.9, β2=0.95)
LR 스케줄선형 감쇠Cosine decay with linear warmup (GPT-3 방식)
Gradient Clipping없음clip_grad_norm_ (max_norm=1.0)
분산학습없음 (단일 프로세스)PyTorch DDP — 다중 GPU/노드
Checkpoint없음자동 저장/복원, best val loss 기준
Weight Tyingwte/lm_head 별도 초기화lm_head.weight = transformer.wte.weight 명시적 공유
외부 의존성os, math, random (표준 라이브러리만)torch, tiktoken, numpy, datasets 등

nanoGPT 파일 구조

파일역할microgpt 대응
model.py GPT 모델 정의 — CausalSelfAttention, MLP, Block, GPT 클래스, Flash Attention 지원 def gpt() 함수 전체
train.py 훈련 루프 — DDP 분산학습, gradient accumulation, cosine LR, checkpoint 저장/복원 for step in range(num_steps): 루프
data/ 데이터 준비 스크립트 — Shakespeare, OpenWebText 다운로드·토크나이징·바이너리 저장 코드 내 names.txt 직접 로딩 3줄
config/ 사전 정의 훈련 설정 — GPT-2 small/medium/large/XL, Shakespeare 등 하이퍼파라미터 세트 코드 내 하드코딩된 n_embd=16
sample.py 텍스트 샘플링 — checkpoint 로드 후 temperature·top_k 기반 텍스트 생성 for sample_idx in range(20): 루프
bench.py 성능 벤치마크 — throughput(token/s) 측정, Flash Attention 효과 비교 해당 없음

nanoGPT model.py — 클래스 구조

microgpt의 함수형 구현과 달리 nanoGPT는 PyTorch nn.Module 클래스 계층 구조를 사용합니다. 아키텍처는 동일하지만, 텐서화·자동미분·GPU 최적화가 추가됩니다.

# nanoGPT model.py 클래스 구조 (요약)

class CausalSelfAttention(nn.Module):
    # Q, K, V 프로젝션을 단일 c_attn(3*n_embd)로 통합 — 효율적 행렬 곱
    # Flash Attention: F.scaled_dot_product_attention() 자동 사용
    # microgpt: 스칼라 루프로 직접 qkv 계산
    # attn_dropout + resid_dropout 정규화

class MLP(nn.Module):
    # Linear(n_embd → 4*n_embd) → GELU → Linear(4*n_embd → n_embd)
    # microgpt: ReLU 사용, nanoGPT: GELU (GPT-2/3 표준)
    # dropout으로 과적합 방지

class Block(nn.Module):
    # Pre-LayerNorm 구조: ln_1 → attention → residual
    #                      ln_2 → mlp       → residual
    # microgpt: RMSNorm 사용, nanoGPT: LayerNorm

class GPT(nn.Module):
    # transformer.wte : 토큰 임베딩  [vocab_size, n_embd]
    # transformer.wpe : 위치 임베딩  [block_size, n_embd]  ← 학습 가능
    # transformer.drop: Dropout
    # transformer.h   : ModuleList([Block(...)] * n_layer)
    # transformer.ln_f: 최종 LayerNorm
    # lm_head         : Linear(n_embd → vocab_size, bias=False)
    #                   → wte와 가중치 공유(weight tying)
    #
    # generate(): top_k 샘플링 + temperature 스케일링
    # from_pretrained(): HuggingFace 없이 GPT-2 가중치 직접 로드
Weight Tying (가중치 공유)
nanoGPT는 토큰 임베딩(wte)과 LM 헤드(lm_head)가 동일한 가중치를 공유합니다.
lm_head.weight = transformer.wte.weight — 124M 모델의 경우 약 100M 파라미터 절약 (vocab_size × n_embd = 50257 × 768 ≈ 38M).

수학적 의미: 임베딩 층은 "단어를 벡터로 변환"하고, LM 헤드는 "벡터를 단어로 변환"하는 역연산입니다. 두 작업이 유사한 구조이므로 가중치를 공유하면:
• 파라미터 30% 절약 • 학습 안정성 향상 • 사전학습-미세조정 일관성 유지

microgpt → nanoGPT 확장 경로 — 6단계 로드맵

  1. ① 스칼라 → NumPy 벡터화
    Value 스칼라 루프 → NumPy 배열 연산 → 속도 10~100배 향상.
    역전파도 NumPy 브로드캐스팅으로 표현 가능. 텐서 차원 개념 도입.
  2. ② 배치(Batch) 처리 도입
    이름 1개/스텝 → 32개/스텝 미니배치 → 그래디언트 분산 감소, 학습 안정화.
    텐서 차원이 [T]에서 [B, T]로 확장됨.
  3. ③ PyTorch 텐서 교체
    NumPy → PyTorch Tensor → .backward() 자동미분, GPU 가속.
    수동으로 작성한 _backward 체인이 완전히 자동화됨.
  4. ④ 표준 모듈 & 정규화 적용
    수동 구현한 linear, rmsnorm, Adamnn.Linear, nn.LayerNorm, optim.AdamW.
    Dropout, gradient clipping, weight decay 정규화 기법 추가.
  5. ⑤ 아키텍처 확장
    n_layer=1 → 12+, n_embd=16 → 768+, vocab_size=27 → 50,257 (BPE).
    Flash Attention, 컨텍스트 길이 1024+ 지원, top-k 샘플링 추가.
  6. ⑥ 분산학습 & 생산화
    DDP로 다중 GPU 학습, gradient accumulation, cosine LR with warmup,
    자동 checkpoint 저장/복원 → 실제 GPT-2 수준 모델 훈련 가능.

nanoGPT 대표 사용 예시

Shakespeare 문자 수준 언어 모델
셰익스피어 전집(~1MB)으로 훈련 → 셰익스피어 스타일 문장 생성. 단일 GPU로 수 분~수 시간 안에 완료. GPT의 원리를 실감하는 입문 예제. microgpt의 names.txt → 셰익스피어 텍스트로 도메인만 교체한 확장 버전.
GPT-2 (124M) 완전 재현
OpenWebText(~38GB) 데이터셋으로 훈련 → OpenAI GPT-2와 동등한 검증 손실(val loss ≈ 2.85) 달성. A100 GPU 8개로 약 4일 소요. Karpathy가 직접 재현 결과를 공개함.
GPT-2 공식 가중치 파인튜닝
OpenAI 공개 GPT-2 가중치를 HuggingFace 없이 직접 로드. --init_from=gpt2 플래그 하나로 gpt2/gpt2-medium/gpt2-large/gpt2-xl 중 선택. 특정 도메인 텍스트로 파인튜닝하여 도메인 특화 모델 제작 가능.
FineWeb-Edu (10B 토큰) 대규모 훈련
Karpathy가 공개한 고품질 교육용 웹 데이터셋. fineweb.py 스크립트로 HuggingFace에서 직접 다운로드 및 토크나이징. 100M 토큰 × 100개Shard로 분할 저장. GPT-2 재현보다 10배 많은 토큰으로 훈련.
OpenWebText 재현 훈련
GPT-2 학습에 사용된 OpenWebText 데이터셋을 직접 수집·전처리. CC-BY-SA 라이선스. 약 38GB 규모의 웹 텍스트. 다운로드 후 tiktoken으로 BPE 토크나이징, 바이너리 형태로 저장.
💡 데이터셋 선택 가이드:
• Shakespeare (1MB) — 수분~수시간, 디버깅/입문용
• FineWeb-Edu (10B) — 고품질 교육용, GPT-2 재현 이상
• OpenWebText (38GB) — GPT-2 완전 재현을 위한 정식 데이터

build-nanogpt — 2024년 새로운 비디오 강의

2024년 6월, Karpathy는 build-nanogpt 저장소를 공개하며 "Zero to Hero" 시리즈의 후속 강의를 시작했습니다. 이 저장소는 빈 파일에서 시작해 단계별로 GPT-2 (124M)를 재현하는 전체 과정을 담고 있습니다.

핵심 특징:
• Git 커밋 단위로 진행되는 단계별 튜토리얼
• YouTube 비디오 강의와 1:1 매핑
• 빈 디렉토리에서 시작해 완전한 GPT-2 훈련까지
• GPT-2 (~1시간, 약 $10) 재현 가능
• 더 많은 토큰/시간 투자시 GPT-3 규모로 확장 가능

build-nanogpt 커밋 로그 예시

# 저장소 클론 후 커밋 히스토리로 학습 진행
git clone https://github.com/karpathy/build-nanogpt.git
cd build-nanogpt
git log --oneline

# 각 커밋에서 특정 기능 확인 가능
# 1. Character-level BigramLanguageModel
# 2. Add batch sampling (get_batch)
# 3. Add the GPT model itself
# 4. Add data preprocessing (encode/decode)
# 5. Add train/val split
# 6. Add self-attention (Q, K, V)
# ... 최종: Full GPT-2 (124M)

훈련 결과 예시 (build-nanogpt)

10B 토큰 훈련 후 "Hello, I'm a language model" 프롬프트로 생성된 텍스트:

Hello, I'm a language model, and my goal is to make English as easy 
and fun as possible for everyone, and to find out what we can do 
to make the world a better place.

40B 토큰 훈련 후 동일 프롬프트:

Hello, I'm a language model, a model of computer science, 
and this is a way to program computer programs using a formal 
language that is based on mathematics and logic.

다양한 실행 환경

환경특징권장 사용처
로컬 MacBook/GPUCPU 또는 CUDA, 소규모 데이터디버깅, Shakespeare 예제
Lambda CloudA100 80GB, 시간당 약 $1GPT-2 수준 훈련
Modal서버리스 GPU, 자동 스케일링간헐적 훈련, 프로토타입
CodeanywhereNVIDIA T4 16GB, 즉시 사용교육, 빠른 실습
Google Colab ProV100/A100, 종량제개인 실험
GPU 메모리 요구량 계산기 (GPT-2 124M 기준)
항목계산식예상 값 (BF16)
모델 가중치124M × 2 bytes~250 MB
옵티마이저 상태 (AdamW)124M × 2 × 2 (m+v) × 2 bytes~1 GB
활성화 (Forward)batch × seq × layers × hidden² × 4~2-4 GB
그래디언트124M × 2 bytes~250 MB
총계 (단일 GPU)~4-6 GB
필요 메모리 예측:
RTX 3060 (12GB) 가능, RTX 3080 (10GB) 빠듯함, RTX 3090/4090 (24GB) 여유.
gradient_accumulation으로 Effective Batch Size 512+ 시뮬레이션 가능.

하이퍼파라미터 상세 설명

GPT-2 (124M) 재현을 위한 기본 설정:

파라미터설명
n_layer12Transformer 블록 수 ( depth )
n_head12어텐션 헤드 수 (각각 n_embd/12)
n_embd768임베딩 차원
block_size1024최대 컨텍스트 길이
vocab_size50257GPT-2 BPE 어휘 크기
dropout0.0pretraining 시 0 권장
biasFalseLayerNorm/Linear bias (GPT-2와 동일)
learning_rate6e-4최대 학습률
beta20.95AdamW 2차 모멘텀 감쇠
weight_decay0.1가중치 감쇠 (L2 정규화)
warmup_iters2000워밍업 스텝 수
lr_decay_iters600000학습률 감쇠 시작점
min_lr6e-5최소 학습률 (lr/10)
grad_clip1.0그래디언트 클리핑 임계값

성능 최적화 기법

  • torch.compile: PyTorch 2.0+ 그래프 최적화로 2~3배 속도 향상. CUDA 커널 분리·병합으로 GPU 활용도 극대화.
  • Flash Attention: F.scaled_dot_product_attention - O(N²) → O(N) 메모리 절약. IO-aware 알고리즘으로 HBM 접근 최소화.
    • 표준 Attention: QKᵀ 계산 시 N×N 행렬 HBM에 저장 → 1024컨텍스트시 4GB 메모리
    • Flash Attention: Online softmax 방식으로 HBM 저장 없이 레지스터에서 연산
    • 자동 사용: PyTorch 2.0+는 F.scaled_dot_product_attention 호출 시 자동 적용
  • Mixed Precision (BF16/FP16): GPU 메모리 50% 절약, 연산 속도 향상. bf16은 fp32 대비 정밀도 유지하면서 메모리 절약.
  • Gradient Accumulation: 작은 GPU로 큰 배치 시뮬레이션. effective_batch_size = batch_size × grad_accum_steps.
  • DDP (Distributed Data Parallel): 다중 GPU 병렬 훈련. 각 GPU는 모델 복사본 보유, 그래디언트 동기로 평균.
  • Gradient Checkpointing: 메모리 절약 대용량 모델용. 순전파 시 중간 활성화 저장 대신 역전파 시 재계산.
⚠️ Flash Attention 사용 조건:
• NVIDIA GPU (Ampere 이상 — A100, RTX 30xx, 40xx)
• PyTorch 2.0+ 또는 flash-attn 패키지 설치
• HeadDim ≤ 128 (그 이상은 표준 구현 사용)
• fp16/bf16 권장 (fp32는 자동 비활성화)

흔한 오류 및 해결 방법

오류원인해결 방법
uint16 → long 변환 오류이전 PyTorch 버전npt = npt.astype(np.int32) 후 변환
OOM (Out of Memory)배치 크기 과대batch_size 감소 또는 gradient_accumulation_steps 증가
loss NaN학습률 과고/수치 불안정학습률 감소, grad_clip 적용
GPU 미인식CUDA 설치 문제torch.cuda.is_available() 확인
tokenizer 경고tiktoken 버전pip install tiktoken --upgrade

관련 Karpathy 프로젝트

micrograd — autograd 전신
스칼라 autograd 엔진 (Value 클래스) — microgpt Value 클래스의 직접적 원형. 역전파 원리를 이해하기 위한 가장 작은 구현. 약 150줄.
makemore — 데이터셋·토크나이저 전신
names.txt 데이터셋 출처, 문자 수준 언어 모델. Bigram부터 Transformer까지 단계적으로 발전시키는 강의 시리즈. microgpt 토크나이저 구조 계승.
nanoGPT — 실용 확장 버전
microgpt에서 배운 원리를 실제 규모로 구현. PyTorch + GPU 기반, GPT-2 완전 재현, 분산학습 지원. 연구/실험용 GPT 훈련의 현실적 최소 기준점.
llm.c — 의존성 최소화 극한
C/CUDA로만 LLM 훈련 — PyTorch조차 제거. microgpt의 "의존성 없이 직접 구현" 철학을 시스템 레벨까지 밀고 나간 프로젝트.

243줄 코드로 얻은 것들

구성요소microgpt 코드현대 프레임워크 등가
Autograd 엔진class ValuePyTorch torch.autograd
신경망 레이어linear, softmax, rmsnormPyTorch nn.Linear, nn.Softmax, nn.RMSNorm
GPT 아키텍처def gpt(...)HuggingFace GPT2Model
Adam 옵티마이저m, v 리스트 + 4줄 업데이트PyTorch optim.Adam
KARPATHY의 핵심 메시지:
"우리는 실행 규칙이 아니라, 학습 규칙을 정의한 것입니다."
GPT-4도, LLaMA도, 우리의 microgpt도 — 모두 손실을 줄이는 숫자들을 조정합니다. 4,192개(또는 수조 개)의 차이일 뿐, 원리는 완전히 동일합니다. 블랙박스는 열렸습니다.

실험 아이디어 — 파라미터를 바꿔보자

실험변경 내용예상 효과
더 깊게n_layer = 4파라미터 4배↑ 더 자연스러운 이름
더 넓게n_embd = 64, n_head = 8파라미터 ~16배↑ 표현력↑
더 오래num_steps = 10000더 낮은 loss, 더 정확한 이름 패턴
다른 데이터셰익스피어 텍스트문장 수준 언어 생성
온도 실험temperature = 0.1, 0.5, 1.0, 2.0창의성 vs 안정성 트레이드오프
단일 헤드n_head = 1멀티헤드 이점 체감 — loss 소폭 상승 예상
bias 추가linear: +b 항 추가파라미터 +16개, 수렴 속도 변화 관찰
std 비교std=0.01 vs 0.08 vs 0.3초기화 크기와 수렴 안정성의 상관관계
Adam → SGDAdam 업데이트를 p.data -= lr * p.grad로 대체적응적 학습률의 중요성 실감

수치 안정성 — 미묘한 함정들

순수 파이썬으로 부동소수점 연산을 구현할 때 마주치는 세 가지 핵심 문제를 다룹니다. 실제 ML 라이브러리들도 동일한 문제를 내부에서 처리합니다.

log(0) 문제 — 교차 엔트로피의 약점

Cross-Entropy 손실 -log(P(target))에서 P(target) = 0이면 math.log(0)은 수학적으로 -∞이고 Python에서는 ValueError를 냅니다.

# 언제 발생하는가?
# 학습 초기에는 거의 없지만, 극단적 상황에서 발생 가능:
#
# 1. Softmax 포화(saturation): logit 차이가 매우 크면
#    softmax([100, 0, 0, ...]) → [1.0, 0.0, 0.0, ...]
#    target이 index 1이라면: probs[1] = 0.0 → log(0) → ValueError!
#
# 2. Temperature가 매우 작으면:
#    softmax([10, 1, 0] / 0.001) = softmax([10000, 1000, 0])
#    → probs ≈ [1.0, 0.0, 0.0] → 같은 문제 발생

# microgpt의 자연스러운 방어:
# 1) softmax 내 max 차감 → overflow 방지 (exp(큰 수) 방지)
# 2) std=0.08 작은 초기값 → 초기 logit 포화 방지
# 3) Adam 학습률 감쇠 → 학습 후반 포화 방지

# 명시적 방어가 필요한 경우 (microgpt에는 없지만 추가 가능):
def safe_log(x, eps=1e-8):
    return (x + eps).log()   # p가 0이어도 log(ε) ≈ -18 (유한)

# 또는 log-softmax 직접 계산 (수치적으로 더 안정적):
def log_softmax(logits):
    max_val = max(val.data for val in logits)
    log_sum_exp = (sum([(v - max_val).exp() for v in logits])).log()
    return [(v - max_val) - log_sum_exp for v in logits]
# log_softmax(logits)[target] 를 직접 손실로 사용하면 log(prob) 보다 안정적

Softmax Max 차감 — 수학적 동치 증명

microgpt의 softmax()max_val을 먼저 뺍니다. 왜 결과가 같은지 수학적으로 보입니다.

# 원본 Softmax: σ(z)ᵢ = exp(zᵢ) / Σⱼ exp(zⱼ)
#
# 임의의 상수 c를 빼도 결과가 같음을 증명:
# σ(z - c)ᵢ = exp(zᵢ - c) / Σⱼ exp(zⱼ - c)
#            = exp(zᵢ) × exp(-c) / [Σⱼ exp(zⱼ) × exp(-c)]
#            = exp(zᵢ) / Σⱼ exp(zⱼ)   ← exp(-c)가 약분됨!
#            = σ(z)ᵢ  ✓
#
# 실용적 의미:
# z = [1000, 999, 998]  (큰 수)
# 직접 계산: exp(1000) = 5×10^434 → Python float overflow → inf
#            inf / inf = nan → 완전히 망가짐
#
# max 차감: c = 1000
# z - c = [0, -1, -2]
# exp([0, -1, -2]) = [1.0, 0.368, 0.135] → 완전 안전!
언제 오버플로가 발생하는가? Python float은 IEEE 754 배정밀도 (64비트). 최대 표현 값은 약 1.8 × 10^308. math.exp(710) 이상이면 OverflowError 발생. logit이 710을 넘으면 위험합니다 — max 차감이 이를 방어합니다.

Python 재귀 깊이 한계 — build_topo 주의

# Python 기본 재귀 한계: sys.getrecursionlimit() = 1000
#
# microgpt에서 build_topo 재귀 깊이:
# loss = (1/n) × sum(losses)
# losses = n개 cross-entropy 값
# 각 loss = softmax + log 연산 체인
#
# 한 스텝 처리 시 생성되는 Value 노드 수:
# - 임베딩: 16 nodes
# - Attention (n_head=4, T 토큰): T × (4×head_dim + softmax) nodes
# - MLP: 16→64→16, relu 포함 → ~200 nodes
# - loss: n개 토큰의 chain
#
# "isabella" (8글자) 처리 시:
# n = 9 위치, 각 위치당 ~500 nodes → 총 ~4,500 nodes
# 재귀 깊이는 그래프 최대 깊이 (체인 길이) ≈ 수백
# → 1,000 한계에 걸릴 가능성 있음
#
# 더 깊은 네트워크(n_layer=4+) 사용 시 재귀 한계 초과 가능:
import sys
sys.setrecursionlimit(10000)  # 안전하게 늘리기

부동소수점 누적 오차

# 스칼라 연산은 텐서 연산보다 누적 오차가 클 수 있음
# 예: 1000번의 덧셈에서 오차 누적
#
# sum([0.1] * 10) == 1.0  → False (≈ 0.9999999999999999)
# Python의 부동소수점 특성 — 이진수로 표현할 수 없는 소수들
#
# microgpt에서는 이 오차가 학습에 무시할 수 있는 수준:
# - 4,192개 파라미터 — 매우 작은 모델
# - 1,000 스텝 — 짧은 학습
# - Adam이 자동으로 보상: v_hat 분모가 적응적으로 조정
#
# 대규모 모델에서는 bfloat16 또는 float32 + gradient scaling 사용
문제원인microgpt 방어책프로덕션 방어책
exp() 오버플로logit 값이 710 초과softmax max 차감동일 + bfloat16
log(0) 오류softmax 포화 후 타겟 확률 0작은 초기값 + LR 감쇠log-softmax + label smoothing
재귀 한계 초과build_topo 재귀 깊이단순 구조(n_layer=1)iterative DFS 또는 PyTorch
기울기 소실역전파 시 기울기 < 0.01잔차 연결gradient clipping + warmup
기울기 폭발역전파 시 기울기 > 1e6작은 lr + 감쇠clip_grad_norm_(max=1.0)

순전파 수치 추적 — "ab" 이름의 첫 토큰 처리

학습 완료 후 "ab" 이름을 학습하는 한 스텝의 순전파를 개념적으로 추적합니다. 실제 수치는 가중치 초기화에 따라 다르지만, 각 단계에서 무슨 일이 일어나는지를 구체적으로 이해합니다.

Step 0 — BOS(26) 입력, pos=0, 타겟='a'(0)

# ── 임베딩 ──────────────────────────────────────────────
token_id = 26  # BOS
pos_id   = 0

tok_emb = state_dict['wte'][26]
# → [0.031, -0.072, 0.085, -0.041, 0.097, -0.063, ...]  (16개 Value)
#    각 값은 N(0, 0.08²)로 초기화된 후 학습으로 조정됨

pos_emb = state_dict['wpe'][0]
# → [0.055, 0.021, -0.088, 0.067, -0.034, 0.092, ...]  (16개 Value)
#    위치 0: 이름의 시작 위치 특성 인코딩

x = [t + p for t, p in zip(tok_emb, pos_emb)]
# → [0.086, -0.051, -0.003, 0.026, ...]  (BOS + 위치0 합산)

x = rmsnorm(x)
# RMS = sqrt(mean(x²)) ≈ sqrt(0.086²+0.051²+... / 16)
# scale = 1/RMS → 각 원소가 단위 RMS로 정규화됨
# → [-0.89, 0.52, 0.03, -0.27, ...]  (크기 정규화, 방향 유지)

# ── Attention (pos=0: keys가 비어있어 자기 자신만 봄) ──
q = linear(x, state_dict['layer0.attn_wq'])  # 16×16 행렬 곱 → 16차원
k = linear(x, state_dict['layer0.attn_wk'])  # 16차원
v = linear(x, state_dict['layer0.attn_wv'])  # 16차원

keys[0].append(k)    # KV 캐시: 첫 번째 항목 추가
values[0].append(v)

# 각 헤드 (head_dim=4):
# 헤드 0: q[0:4] 와 keys[0][0][0:4] 내적 → attn_logit 1개
# → attn_logit / 2.0 (= /√4)
# → softmax([single_value]) = [1.0]  ← 자기 자신 100% 집중
# → head_out = 1.0 × v[0:4]  (value 그대로)
#
# pos=0에서는 어텐션이 자기 자신에 100% 집중 (다른 선택지 없음!)

x_attn = q  # 개념적으로: pos=0에서 attention은 거의 항등 함수

# 출력 프로젝션 + 잔차 연결
x = linear(x_attn, state_dict['layer0.attn_wo'])
x = [a + b for a, b in zip(x, x_residual)]  # 잔차 더하기

# ── MLP ────────────────────────────────────────────────
x_residual = x
x = rmsnorm(x)
x = linear(x, state_dict['layer0.mlp_fc1'])   # 16 → 64
# 64개 중 약 절반이 ReLU로 0이 됨 (죽은 뉴런)
x = [xi.relu() for xi in x]                   # 음수 → 0
x = linear(x, state_dict['layer0.mlp_fc2'])   # 64 → 16
x = [a + b for a, b in zip(x, x_residual)]

# ── 언임베딩 ────────────────────────────────────────────
logits = linear(x, state_dict['lm_head'])     # 16 → 27
# 학습 후: 'a','e','i','m','l' 등의 logit이 높아야 함
# (이름은 모음이나 흔한 자음으로 시작하는 경우 많음)
#
# 예시 (학습 완료 후):
# logits ≈ {'a': 2.3, 'l': 2.1, 'k': 1.9, 'e': 1.8, ..., 'x': 0.2, 'z': 0.1}
# BOS 타겟이 아닌 이름 시작 글자들이 높은 점수

probs = softmax(logits)
# 'a' 확률: exp(2.3) / Σ exp(logits) ≈ 0.12  (12% 확률)
# 타겟이 'a'(0)이므로: loss = -log(0.12) ≈ 2.12
pos=0의 특수성: 첫 번째 토큰(BOS)을 처리할 때 KV 캐시가 비어있습니다. 어텐션은 자기 자신에게만 100% 집중할 수밖에 없습니다 — softmax([x]) = [1.0]. 이 때의 어텐션 연산은 사실상 항등 함수에 가깝습니다. 실질적인 어텐션의 힘은 pos ≥ 1 부터 발휘됩니다.

Step 1 — 'a'(0) 입력, pos=1, 타겟='b'(1)

# keys[0]에는 이제 [K_BOS] 1개 → pos=1에서는 2개를 봄
# 'a' + 위치1 임베딩 → x 계산 (이전과 동일 과정)
# ...
# Attention에서:
# 각 헤드: q_h[현재] × K_BOS / 2  AND  q_h[현재] × K_a / 2
# attn_logits = [score_BOS, score_a]  (2개)
# weights = softmax([score_BOS, score_a])  → 합이 1
#
# 예: weights ≈ [0.3, 0.7]  ("a가 BOS보다 더 관련있다")
# head_out = 0.3 × V_BOS[0:4] + 0.7 × V_a[0:4]
#
# 이 어텐션이 "a 다음엔 무엇이 오는가"를 학습합니다.
# names.txt에서 'a' 다음에 자주 오는 글자들이 높은 확률로 학습됨
keys[0].append(k)   # K_a 추가, 이제 [K_BOS, K_a]
values[0].append(v)

# 최종 logits:
# 학습 후 'b','d','l','n','r','s' 등이 높은 점수
# (영어 이름에서 'a' 다음에 자주 오는 글자들)
# 타겟='b'(1): loss = -log(P('b'))

차원 변환 요약표

단계입력 형태출력 형태연산파라미터
토큰 임베딩scalar (token_id)[16]table lookupwte[27×16]
위치 임베딩scalar (pos_id)[16]table lookupwpe[16×16]
합산 + RMSNorm[16] + [16][16]add + scale없음
Q/K/V 프로젝션[16][16] × 3linear3 × [16×16]
어텐션 (T토큰)[16] + T×[16][16]scaled dot-product없음(캐시 사용)
출력 프로젝션[16][16]linear[16×16]
MLP fc1[16][64]linear + ReLU[64×16]
MLP fc2[64][16]linear[16×64]
lm_head[16][27]linear[27×16]
softmax[27][27]exp / sum없음
cross-entropy[27] probsscalar-log(probs[target])없음

AdamW & 최신 옵티마이저 변형

microgpt의 Adam 구현을 기준으로 프로덕션 옵티마이저들과의 차이를 비교합니다.

Adam vs AdamW — Weight Decay의 위치가 결정적

# ── Adam + L2 Regularization (잘못된 방식) ──
# L2: loss에 λ×‖W‖² 추가 → gradient에 λ×W 추가
g_l2 = p.grad + lambda_wd * p.data   # L2 포함 gradient
m[i] = beta1 * m[i] + (1 - beta1) * g_l2
v[i] = beta2 * v[i] + (1 - beta2) * g_l2**2
# 문제: Adam이 g_l2의 크기를 정규화해버림
# v_hat에 의한 나눗셈이 weight decay 효과를 희석시킴
# → weight decay가 실제로는 잘 작동하지 않음!

# ── AdamW (올바른 방식, Loshchilov & Hutter 2017) ──
m[i] = beta1 * m[i] + (1 - beta1) * p.grad     # 순수 gradient만
v[i] = beta2 * v[i] + (1 - beta2) * p.grad**2  # 순수 gradient만
m_hat = m[i] / (1 - beta1**t)
v_hat = v[i] / (1 - beta2**t)
p.data -= lr_t * m_hat / (v_hat**0.5 + eps_adam)  # Adam 업데이트
p.data -= lr_t * lambda_wd * p.data                # weight decay 별도 적용!
# → weight decay가 Adam 적응 학습률에 희석되지 않고 독립적으로 작용
# → GPT-3, LLaMA, nanoGPT가 사용하는 방식
microgpt에 weight decay가 없는 이유: 4,192개 파라미터, 32,033개 훈련 예제 — 모델이 너무 작아 과적합 위험이 낮습니다. weight decay는 파라미터가 많고 데이터가 적을 때 (과적합 방지) 중요합니다. nanoGPTweight_decay=0.1을 사용하며 임베딩/bias에는 적용하지 않습니다.

Lion 옵티마이저 (Evolved Sign Momentum, 2023)

# Google Brain의 프로그래밍으로 진화된 옵티마이저
# Adam보다 메모리 효율적: m 버퍼 1개만 필요 (v 버퍼 불필요)
#
# Lion 업데이트 규칙:
for i, p in enumerate(params):
    # 업데이트 방향: m과 gradient의 선형 조합
    update = sign(beta1 * m[i] + (1 - beta1) * p.grad)
    p.data -= lr_t * (update + lambda_wd * p.data)
    # 모멘텀 업데이트 (업데이트 후)
    m[i] = beta2 * m[i] + (1 - beta2) * p.grad

# 핵심 차이: sign() 사용 → 업데이트 크기가 항상 ±lr로 고정
# Adam: lr × m_hat / √v_hat  (크기 가변)
# Lion: lr × sign(...)       (크기 고정)
#
# 장점: v 버퍼 불필요 → 메모리 33% 절약 (m+v → m만)
# 단점: 적절한 lr 조정 필요 (보통 Adam의 10배 작게)

학습률 스케줄러 완전 비교

스케줄러수식사용처특징
Constant lr_t = lr 단순 실험 후반 수렴 불안정
Linear Decay
(microgpt)
lr_t = lr × (1 - t/T) 짧은 학습 (1K 스텝) 구현 단순, 후반 급격히 감소
Step Decay lr_t = lr × γ^⌊t/s⌋ 전통적 CNN 학습 단계적 감소, 불연속
Cosine Decay lr_t = lr_min + ½(lr-lr_min)(1 + cos(πt/T)) GPT-2/3, nanoGPT 부드러운 감소, 최솟값 보장
Warmup + Cosine
(GPT-3 방식)
선형 warmup → cosine decay 대규모 LLM 학습 표준 초반 불안정 방지 + 부드러운 수렴
1-Cycle lr 상승 → 감소 FastAI 방식 빠른 수렴, 소규모 실험
# Cosine Decay with Linear Warmup (nanoGPT 방식)
warmup_steps = 100   # 전체 스텝의 10%
lr_min = 0.0001      # 최솟값 (lr의 10%)

def get_lr(t):
    if t < warmup_steps:
        return learning_rate * t / warmup_steps  # 선형 증가
    # Cosine decay
    progress = (t - warmup_steps) / (num_steps - warmup_steps)
    return lr_min + 0.5 * (learning_rate - lr_min) * (1 + math.cos(math.pi * progress))

# microgpt 선형 감쇠와 비교:
# step=0:    linear=0.01000, cosine=0.0000 (warmup 시작)
# step=100:  linear=0.00900, cosine=0.0100 (warmup 완료)
# step=500:  linear=0.00500, cosine=0.0055 (cosine 중반)
# step=999:  linear=0.00001, cosine=0.0001 (cosine 끝)
왜 Warmup이 필요한가? 학습 초반에는 m, v 버퍼가 0으로 초기화되어 있어 바이어스 보정 후에도 불안정합니다. Warmup 동안 lr을 서서히 높이면 파라미터들이 먼저 "적응"한 뒤 큰 lr로 빠르게 학습합니다. microgpt처럼 1,000 스텝의 짧은 학습에는 warmup이 불필요하지만, 수십만 스텝의 LLM 학습에서는 필수입니다.

고급 샘플링 전략

microgpt의 temperature 샘플링 외에, 프로덕션 LLM에서 사용하는 다양한 샘플링 전략을 구현합니다.

Greedy Decoding — 항상 가장 확률 높은 토큰

# Temperature 없이 argmax 선택
# 장점: 결정적(deterministic), 항상 같은 출력
# 단점: 반복 루프에 빠질 수 있음, 다양성 없음

def greedy_decode(logits):
    return max(range(len(logits)), key=lambda i: logits[i].data)

# microgpt 추론에서 사용 예:
# token_id = greedy_decode(logits)  # temperature 없이

# 주의: temperature=0은 ZeroDivisionError → greedy_decode()를 대신 사용

Top-K 샘플링

# 확률 상위 K개 토큰만 남기고 나머지는 -∞ 처리 후 샘플링
# 장점: 낮은 확률의 이상한 토큰을 완전 차단
# 단점: K 선택이 어려움 (K=1은 greedy, K=vocab_size는 pure sampling)

def top_k_sample(logits, k=10, temperature=1.0):
    # 1. 상위 K개 인덱스 찾기
    sorted_indices = sorted(range(len(logits)),
                              key=lambda i: logits[i].data, reverse=True)
    top_k_indices = sorted_indices[:k]

    # 2. 상위 K개 logit만 추출
    top_k_logits = [logits[i] / temperature for i in top_k_indices]

    # 3. softmax → 샘플링
    top_k_probs = softmax(top_k_logits)
    local_idx = random.choices(range(k), weights=[p.data for p in top_k_probs])[0]
    return top_k_indices[local_idx]

# k=5 예시: 상위 5개 ['a', 'e', 'l', 'k', 'm'] 만 고려
# 나머지 22개 글자는 아무리 작은 확률이어도 완전히 제외

Top-P (Nucleus) 샘플링

# 확률의 누적 합이 p를 넘을 때까지 상위 토큰을 포함
# 장점: 확률 분포가 넓을 때(불확실할 때)는 더 많은 토큰 포함
#       확률 분포가 좁을 때(확실할 때)는 적은 토큰만 포함
# Top-K보다 적응적 → 더 자연스러운 텍스트 생성

def top_p_sample(logits, p=0.9, temperature=1.0):
    # 1. 내림차순 정렬
    sorted_indices = sorted(range(len(logits)),
                              key=lambda i: logits[i].data, reverse=True)
    sorted_logits = [logits[i] / temperature for i in sorted_indices]
    sorted_probs = softmax(sorted_logits)

    # 2. 누적 확률이 p를 초과하는 최소 집합 찾기
    nucleus = []
    cumulative = 0.0
    for idx, prob in zip(sorted_indices, sorted_probs):
        nucleus.append((idx, prob))
        cumulative += prob.data
        if cumulative >= p:
            break

    # 3. nucleus에서 샘플링
    indices = [item[0] for item in nucleus]
    weights = [item[1].data for item in nucleus]
    return random.choices(indices, weights=weights)[0]

# p=0.9 예시:
# 확률이 고른 경우: [0.1, 0.09, 0.08, ...] → 누적 0.9 위해 10개+ 포함
# 확률이 집중된 경우: [0.85, 0.08, ...] → 누적 0.9 위해 2개만 포함

Repetition Penalty

# 이미 생성된 토큰의 logit을 낮춰 반복 방지
# GPT-2 공식 생성에서 사용

def apply_repetition_penalty(logits, generated, penalty=1.3):
    for token_id in set(generated):   # 중복 처리 방지
        if logits[token_id].data > 0:
            logits[token_id] = logits[token_id] / Value(penalty)  # 양수 logit 낮추기
        else:
            logits[token_id] = logits[token_id] * Value(penalty)  # 음수 logit 더 낮추기
    return logits

# penalty=1.0: 효과 없음
# penalty=1.3: 이미 나온 글자의 확률 약 23% 감소
# penalty=2.0: 이미 나온 글자의 확률 50% 감소 (과도하면 이상한 이름)
전략적합 상황파라미터특징
Greedy테스트, 재현성 필요없음항상 같은 결과, 반복 위험
Temperature
(microgpt 기본)
일반 이름 생성T=0.5~1.0균형 잡힌 다양성
Top-K특정 품질 기준 유지K=10~50이상한 토큰 완전 차단
Top-P (Nucleus)LLM 프로덕션 표준p=0.9~0.95적응적 후보 집합
Temperature + Top-PGPT-4 기본값T=1.0, p=0.9두 방법의 조합
Repetition Penalty반복 방지 필요 시penalty=1.2~1.5이미 생성된 토큰 억제

PyTorch 마이그레이션 가이드 — microgpt → nanoGPT

microgpt의 각 구성요소를 PyTorch로 1:1 변환하는 방법을 코드와 함께 보여줍니다. 이 과정에서 수동 구현의 의미가 더욱 명확해집니다.

① Value 클래스 → torch.Tensor

# ── microgpt ──────────────────────────────────────────
a = Value(2.0)
b = Value(3.0)
c = a * b + a   # 계산 그래프 자동 구성
c.backward()   # 위상 정렬 + 연쇄 법칙
print(a.grad)   # 4.0
print(b.grad)   # 2.0

# ── PyTorch 동치 ──────────────────────────────────────
import torch
a = torch.tensor(2.0, requires_grad=True)  # requires_grad=True ← 역전파 대상
b = torch.tensor(3.0, requires_grad=True)
c = a * b + a
c.backward()   # PyTorch 내부에서 동일한 위상 정렬 + 연쇄 법칙
print(a.grad)   # tensor(4.)
print(b.grad)   # tensor(2.)

# 차이점:
# - Value: 스칼라 하나 = 객체 1개  (느림, 파이썬 오버헤드)
# - Tensor: 배열 전체 = 객체 1개  (빠름, C++/CUDA 연산)

② 헬퍼 함수 → torch.nn 모듈

import torch
import torch.nn as nn
import torch.nn.functional as F

# ── linear(x, w) → nn.Linear ──
# microgpt:
linear(x, state_dict['layer0.attn_wq'])  # list 내포, O(n³) 스칼라 루프

# PyTorch:
attn_wq = nn.Linear(n_embd, n_embd, bias=False)
attn_wq(x_tensor)  # BLAS/cuBLAS 행렬 곱 → GPU에서 수천 배 빠름

# ── softmax(logits) → F.softmax ──
# microgpt:
softmax(logits)  # max 차감 + exp + sum + div, 스칼라

# PyTorch:
F.softmax(logits_tensor, dim=-1)  # 내부에서 동일한 max 차감 적용, 텐서 연산

# ── rmsnorm(x) → nn.RMSNorm ──
# microgpt:
rmsnorm(x)  # 학습 가능 γ 없음

# PyTorch (≥2.4):
rms_norm = nn.RMSNorm(n_embd, eps=1e-5)  # 학습 가능 γ 있음 (기본 1로 초기화)
rms_norm(x_tensor)

# 또는 직접 구현:
def rmsnorm_torch(x, eps=1e-5):
    ms = (x ** 2).mean(dim=-1, keepdim=True)
    return x * (ms + eps) ** -0.5

③ gpt() 함수 → nn.Module

import torch
import torch.nn as nn
import torch.nn.functional as F

class MicroGPT(nn.Module):
    def __init__(self, vocab_size=27, n_embd=16, n_head=4, n_layer=1, block_size=16):
        super().__init__()
        self.wte = nn.Embedding(vocab_size, n_embd)   # microgpt: state_dict['wte']
        self.wpe = nn.Embedding(block_size, n_embd)   # microgpt: state_dict['wpe']
        self.layers = nn.ModuleList([Block(n_embd, n_head) for _ in range(n_layer)])
        self.lm_head = nn.Linear(n_embd, vocab_size, bias=False)
        # Weight Tying: wte와 lm_head 공유 (nanoGPT 방식)
        self.lm_head.weight = self.wte.weight           # 파라미터 432개 절약!

    def forward(self, idx):  # idx: [B, T] 토큰 인덱스
        B, T = idx.shape
        pos = torch.arange(0, T, device=idx.device)    # [T]

        x = self.wte(idx) + self.wpe(pos)             # [B, T, n_embd]
        x = F.rms_norm(x, [n_embd])                    # RMSNorm

        for layer in self.layers:
            x = layer(x)                                # [B, T, n_embd]

        logits = self.lm_head(x)                       # [B, T, vocab_size]
        return logits

# 학습 루프 (PyTorch 버전)
model = MicroGPT()
optimizer = torch.optim.Adam(model.parameters(), lr=0.01,
                              betas=(0.85, 0.99), eps=1e-8)

for step in range(num_steps):
    # 토큰 텐서: [1, T] → [T-1] input, [T-1] target
    tokens = torch.tensor(token_sequence).unsqueeze(0)  # [1, T]
    logits = model(tokens[:, :-1])                      # [1, T-1, 27]
    loss = F.cross_entropy(logits.view(-1, 27),
                            tokens[:, 1:].view(-1))       # scalar

    optimizer.zero_grad()   # grad 초기화 (microgpt: p.grad = 0)
    loss.backward()         # 역전파
    optimizer.step()         # Adam 업데이트

예상 속도 향상

구현 방식1000 스텝 소요 시간속도 비율특징
microgpt (순수 파이썬)~5~10분의존성 0, 교육 목적
NumPy 벡터화~10~30초~20×스칼라 → 배열 연산
PyTorch CPU~1~5초~100×자동 최적화, BLAS 활용
PyTorch GPU (RTX 3090)~0.1초~1000×병렬 텐서 연산, CUDA
어떤 것을 먼저 배워야 하는가? microgpt → NumPy → PyTorch CPU → PyTorch GPU 순서로 발전하면, 각 단계에서 왜 더 빠른지를 이해하면서 배울 수 있습니다. PyTorch만 배우면 loss.backward()가 마법처럼 느껴지지만, microgpt를 먼저 구현하면 "아, 이게 위상 정렬 + 연쇄 법칙이구나"를 알게 됩니다.

디버깅 가이드 & 흔한 실수

microgpt.py를 직접 실행하거나 변형할 때 마주치는 오류들을 유형별로 정리합니다.

런타임 오류 — 즉시 크래시

오류원인해결
ValueError: math domain error math.log(0) 또는 음수 입력 softmax 결과 확인, eps=1e-8 추가, 또는 log-softmax 사용
ValueError: 'E' is not in list uchars.index('E') — 대문자 입력 입력 텍스트 소문자 변환: doc.lower()
ZeroDivisionError temperature = 0 설정 temperature > 0 유지, 또는 greedy_decode() 사용
RecursionError: maximum recursion depth exceeded build_topo 재귀가 Python 기본 한계 1000 초과 sys.setrecursionlimit(10000) 또는 iterative DFS 구현
IndexError: list index out of range vocab_size=26으로 잘못 지정, BOS(26)가 범위 밖 vocab_size = len(uchars) + 1 = 27 확인
OverflowError: math range error math.exp()에 710 이상 입력 softmax max 차감 확인, 또는 lr 줄이기 (logit 폭발 가능성)

학습 수렴 문제

❌ Loss가 3.30 (랜덤 베이스라인)에서 전혀 내려가지 않는 경우
  • loss.backward()가 실행되지 않거나, p.grad가 모두 0인지 확인
  • 파라미터 업데이트 후 p.grad = 0이 아닌 p.grad = 0.0으로 해야 함 (실수형)
  • params 리스트에 일부 파라미터가 빠졌는지 확인
  • Adam 버퍼 m, v가 params와 길이가 같은지 확인
❌ Loss가 발산하는 경우 (NaN 또는 무한대)
  • 학습률이 너무 큼 → learning_rate = 0.001로 줄이기
  • 초기화 std가 너무 큼 → std = 0.02로 줄이기
  • softmax 오버플로 → max 차감 확인
  • eps_adam = 1e-8이 너무 작은 경우 드물게 발생 → 1e-6으로 늘리기
❌ Loss가 2.7~2.8에서 멈추는 경우
  • 정상 범위: 1,000 스텝으로는 2.37이 목표 (데이터와 모델 크기 한계)
  • num_steps = 5000으로 늘리면 더 낮아질 수 있음
  • n_layer = 2, n_embd = 32로 모델 크기 증가 → 더 빠른 수렴
❌ 생성된 이름이 모두 동일한 경우
  • temperature가 너무 낮음 → temperature = 0.8~1.0으로 높이기
  • random.seed()가 반복 설정되어 있는지 확인 (추론 루프 안에 있으면 안 됨)
❌ 생성된 이름이 모두 단 1~2글자인 경우
  • 모델이 BOS를 너무 쉽게 생성 → 학습이 부족하거나 temperature가 너무 낮음
  • 더 많은 학습 스텝 또는 높은 temperature 시도

조용한 버그 — 오류 없이 잘못된 결과

# 버그 1: grad 초기화를 업데이트 전에 하는 경우
for i, p in enumerate(params):
    p.grad = 0              # ❌ 잘못됨: grad를 먼저 초기화하면 m, v 계산이 0 기반
    m[i] = beta1 * m[i] + (1-beta1) * p.grad  # ← 항상 0!
    # ...

# 올바른 순서: 업데이트 후 초기화
for i, p in enumerate(params):
    g = p.grad              # ✓ 먼저 grad 읽기
    m[i] = beta1 * m[i] + (1-beta1) * g
    v[i] = beta2 * v[i] + (1-beta2) * g**2
    # ... 업데이트 ...
    p.grad = 0              # ✓ 마지막에 초기화

# 버그 2: keys/values를 초기화하지 않고 재사용
for step in range(num_steps):
    # ❌ 잘못됨: 이전 이름의 K, V가 누적되어 있음
    for pos_id in range(n):
        logits = gpt(token_id, pos_id, keys, values)

# ✓ 올바름: 각 이름 처리 전 KV 캐시 초기화
for step in range(num_steps):
    keys, values = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)]
    for pos_id in range(n):
        logits = gpt(token_id, pos_id, keys, values)

# 버그 3: state_dict를 함수 내부에서 선언하는 경우
def gpt(token_id, pos_id, keys, values):
    state_dict = { ... }  # ❌ 매 호출마다 재초기화! 학습이 전혀 안 됨
# ✓ state_dict는 함수 밖 전역 스코프에 선언해야 함

디버깅 체크리스트

  1. 학습 시작 전 loss.datalog(27) ≈ 3.296 근처인지 확인 (랜덤 베이스라인)
  2. 10 스텝 후 loss가 감소하고 있는지 확인 (그래프나 print)
  3. len(params)가 정확히 4,192인지 확인
  4. 추론 전 keys, values가 새로 초기화되었는지 확인
  5. vocab_size가 len(uchars) + 1 = 27인지 확인
  6. 모든 입력 문자가 소문자인지 확인

성능 분석 & 시간 복잡도

microgpt의 각 단계별 계산 비용을 분석하고, 왜 느린지, 어디서 병목이 발생하는지를 이해합니다.

시간 복잡도 — 기호 정의

  • B = batch size (microgpt: 1)
  • T = 시퀀스 길이 (이름 길이, 최대 16)
  • d = n_embd = 16 (임베딩 차원)
  • H = n_head = 4 (어텐션 헤드 수)
  • d_h = head_dim = d/H = 4 (헤드당 차원)
  • V = vocab_size = 27
  • 4d = MLP 확장 차원 = 64
연산순전파 복잡도실제 연산 수 (T=5, d=16)
임베딩 조회 (wte + wpe)O(d)32 덧셈
RMSNormO(d)~64 연산
Q, K, V 프로젝션 (각각)O(d²)16×16 = 256 (×3)
어텐션 점수 (T 토큰, H 헤드)O(T × d)5 × 4 × 4 = 80 내적
Softmax (T개)O(T)~50 연산
Value 가중합O(T × d)5 × 4 × 4 = 80
출력 프로젝션O(d²)16×16 = 256
MLP (fc1 + relu + fc2)O(d × 4d)16×64 + 64×16 = 2,048
lm_headO(V × d)27×16 = 432
총 순전파O(T×d² + T²×d)~4,000 스칼라 연산 (T=5 기준)
# 전체 학습 1000 스텝 연산 추정:
#
# 이름당 평균 길이 ≈ 5.5글자 → 평균 T ≈ 7 (BOS 포함)
# 순전파당 스칼라 곱셈: ~6,000개
# 역전파: 순전파의 약 2배 → ~12,000개
# Value 객체당 Python 오버헤드: ~1μs
#
# 한 스텝: ~18,000 스칼라 연산 × 1μs = 18ms
# 1,000 스텝: ~18초  (실제: ~5~10분 — Python 객체 오버헤드가 큼)
#
# NumPy 벡터화 시:
# 18,000 스칼라 곱 → 1번의 16×16 행렬 곱 (BLAS: ~1μs)
# 예상 속도 향상: 18ms → 0.1ms = 180배

Python 구현 병목 분석

병목microgpt 속도NumPy 등가속도 차이
행렬-벡터 곱 (linear) O(n²) 중첩 for 루프
스칼라 Value 곱셈
np.dot(W, x)
BLAS 최적화
100~1000×
softmax 분모 합산 sum(exps) Python 리스트
__radd__ 체인
np.sum(exps)
벡터 연산
50~200×
역전파 위상 정렬 DFS 재귀
Python 함수 호출 오버헤드
PyTorch autograd
C++ 연산 그래프
1000×+
Value 객체 생성 한 스텝당 ~수만 개
Python 힙 할당
텐서 뷰 (zero-copy) 100×+

어텐션의 O(T²) 복잡도 — GPT의 근본 한계

# Attention 행렬: T × T (모든 토큰 쌍의 점수)
# T=16  → 16×16 = 256 점수
# T=512 → 512×512 = 262,144 점수 (1,024배!)
# T=128K (Claude 3.5) → 128K×128K ≈ 16.4억 점수
#
# 이 O(T²) 문제를 해결하는 기술들:
#
# 1. Flash Attention (Dao et al., 2022):
#    동일한 O(T²) 연산이지만 메모리 접근 패턴 최적화
#    GPU 메모리 O(T²) → O(T) (행렬 저장 없이 on-the-fly 계산)
#    nanoGPT: F.scaled_dot_product_attention() 으로 자동 적용
#
# 2. Sliding Window Attention (Mistral):
#    각 토큰이 이전 W개만 봄 → O(T × W)
#    W=4096이면 긴 시퀀스에서도 효율적
#
# 3. GQA (Grouped Query Attention, LLaMA-2/3):
#    여러 Q 헤드가 K, V를 공유 → KV 캐시 크기 감소
#    n_head=32, n_kv_head=8 → K, V 메모리 4배 절약

KV 캐시 메모리 사용량

# microgpt KV 캐시 분석:
# 레이어당: T개 키 + T개 값, 각각 n_embd=16 차원
# T=16 (최대), n_layer=1:
# 메모리 = 2 × 16 × 16 = 512 Value 객체
#        × ~150 bytes/객체 ≈ 76.8 KB (매우 작음)
#
# GPT-4 KV 캐시 비교 (n_embd=12288, n_layer=96, T=128K):
# 2 × 128K × 12288 × 96 × 4 bytes (float32)
# ≈ 2 × 128K × 12288 × 96 × 4 ≈ 300 GB (!)
# 실제로는 fp16 + GQA 로 대폭 줄임
성능 개선 로드맵 요약:
microgpt (5~10분) → NumPy 벡터화 (~30초) → PyTorch CPU (~5초) → PyTorch GPU (0.1초) → Flash Attention + 배치 처리 (0.01초). 각 단계마다 10~100배 개선이 가능하며, 원리는 모두 동일합니다.

참고 자료

원본 소스

관련 Karpathy 프로젝트

프로젝트설명관계
micrograd 스칼라 autograd 엔진 — Value 클래스, 위상 정렬 역전파 (~150줄) microgpt Value 클래스의 직접적 원형
makemore 문자 수준 언어 모델 — Bigram → MLP → Transformer 단계별 강의 names.txt 데이터셋 출처, 토크나이저 구조 계승
nanoGPT 실용적 GPT 훈련 — PyTorch, Flash Attention, DDP, GPT-2 재현, Shakespeare 예제 microgpt의 "Production 확장" — 같은 원리, 실용적 규모
llm.c C/CUDA로 LLM 훈련 — PyTorch조차 없이 구현 의존성 제거 철학의 극한 — microgpt 방향의 시스템 레벨 확장

학습 영상 (Karpathy YouTube)

핵심 논문

관련 vibecoding 문서