Implementation Preview소스 코드 분석·실행 전 계획

minGPT를 시작하기 전에 정리한 Shakespeare GPT의 전체 흐름

작은 Shakespeare GPT를 직접 학습하기 전에 필요한 개념과 minGPT의 데이터·forward·loss·학습·생성 흐름을 소스 순서대로 정리했다.

논문과 그림을 읽은 다음 단계로 minGPT를 시작하려 한다. 목표는 “Shakespeare처럼 보이는 문장을 생성했다”가 아니다.

입력 문자열이 token id가 되고, loss가 계산되고, gradient가 각 weight를 바꾸고, 다시 한 글자가 생성되기까지 모든 tensor shape를 설명하는 것.

DB 실행 계획을 볼 때도 SQL 결과만 확인하면 병목을 찾기 어렵다. Scan, join, sort가 어떤 순서와 비용으로 실행되는지 쪼개야 한다. GPT도 비슷하게 접근해 보려 한다. Prompt와 출력 사이를 black box로 두지 않고 데이터 준비, embedding, attention, MLP, logits, loss, optimizer, sampling으로 나눠 본다.

이 글은 실행 후기처럼 쓰지 않았다. 아직 학습을 돌리지 않았고 결과도 없다. 시작 전에 코드에서 확인한 사실, 내가 먼저 이해해야 할 개념, 실행하면서 채울 항목을 구분해 둔다.

왜 minGPT부터 보고 nanoGPT로 갈까

minGPT는 GPT 학습과 추론을 작고 읽기 쉬운 PyTorch 코드로 다시 구현한 저장소다. 핵심 모델이 mingpt/model.py 한 파일에 모여 있고, 학습 루프는 mingpt/trainer.py, 문자 단위 예제는 projects/chargpt/chargpt.py에 있다.

저장소 README에는 2023년 1월 기준으로 minGPT가 semi-archived 상태이며, 후속 구현으로 nanoGPT를 보라고 적혀 있다. nanoGPT도 2025년 11월 README에서 오래되고 deprecated된 코드라고 밝히며 nanochat을 안내한다. 둘을 현재 제품의 기반으로 선택하려는 것이 아니라 GPT 구조와 학습 루프를 해부하는 교육용 기준점으로 사용하려 한다. 그렇다고 minGPT를 건너뛰지는 않으려 한다.

  • minGPT는 교육 목적이 강해 attention 계산을 직접 펼쳐 놓았다.
  • nanoGPT는 같은 뼈대 위에 mixed precision, gradient accumulation, DDP, torch.compile, PyTorch SDPA 경로, checkpoint 같은 학습 시스템 요소가 붙는다.
  • 처음부터 최적화 코드까지 보면 “왜 필요한가”보다 “어떻게 켜는가”만 기억할 가능성이 높다.

내 순서는 구조를 minGPT에서 확인하고, 같은 지점을 nanoGPT에서 다시 찾아 차이를 설명하는 것이다.

시작 전에 알아야 할 최소 개념

모든 딥러닝 이론을 끝내고 코드를 볼 필요는 없다. 반대로 아래 개념을 모른 채 줄만 따라가면 shape가 바뀌는 이유를 놓친다.

1. Tensor와 shape

PyTorch tensor는 숫자 배열이다. GPT 코드에서 축의 의미가 계속 유지된다.

B: batch size
T: sequence length
C: embedding dimension
H: number of heads
D: head size = C / H
V: vocabulary size

view, transpose, contiguous는 값을 학습시키는 연산이 아니라 attention에 맞게 축과 메모리 배치를 바꾸는 연산이다. Shape를 적지 않으면 QKV 코드가 갑자기 복잡해진다.

2. Embedding

Token id는 정수라서 그 자체로 의미 있는 거리나 방향을 갖지 않는다. nn.Embedding(V, C)는 id를 길이 C인 학습 가능한 벡터로 조회한다.

idx:     [B, T]
wte(idx):[B, T, C]

minGPT에는 token embedding wte와 position embedding wpe가 있다. 둘을 더한다. RoPE를 사용하는 코드가 아니다.

3. Linear layer와 행렬 곱

nn.Linear(C, 3C)는 마지막 차원을 C에서 3C로 바꾼다. minGPT는 이 한 번의 projection 결과를 Q, K, V 세 덩어리로 나눈다.

[B, T, C] → [B, T, 3C] → q, k, v each [B, T, C]

Bias와 weight의 shape, 마지막 축에 선형 변환이 적용된다는 점을 먼저 확인해야 한다.

4. Softmax와 logits

Logit은 정규화 전 점수다. Attention에서는 QKᵀ / sqrt(D)가 logit이고, vocabulary projection에서는 다음 token 후보마다 logit이 나온다.

Softmax는 지정한 축의 값을 합이 1인 분포로 바꾼다. Attention은 Key 위치 축, 생성은 vocabulary 축에 적용한다.

5. Cross entropy

학습에서 모델은 [B,T,V] logits를 내고 정답은 [B,T] token id다. Cross entropy는 정답 token의 확률을 높이도록 loss를 만든다. PyTorch의 F.cross_entropy에는 softmax 결과가 아니라 raw logits를 넘긴다. 함수 내부가 log_softmax와 negative log-likelihood를 수치적으로 안정된 방식으로 결합하기 때문이다.

6. Autograd와 optimizer

Forward는 loss를 계산한다. loss.backward()는 계산 그래프를 역으로 따라 각 parameter의 gradient를 채운다. optimizer.step()이 그 gradient를 사용해 weight를 갱신한다.

forward → loss → zero_grad → backward → gradient clip → optimizer.step

minGPT Trainer는 AdamW를 사용한다. 모든 parameter에 같은 weight decay를 적용하지 않고 linear weight와 bias·LayerNorm·Embedding을 나눠 설정한다.

7. train mode와 eval mode

model.train()model.eval()은 단순한 상태 이름이 아니다. Dropout처럼 학습과 추론에서 동작이 달라지는 module을 바꾼다. 생성할 때는 eval()torch.no_grad()를 함께 사용하는 이유를 설명할 수 있어야 한다.

Tiny Shakespeare 데이터가 학습 쌍이 되는 과정

minGPT의 projects/chargpt/chargpt.py는 문자 단위 language model 예제다. 문자열에 등장하는 고유 문자를 정렬하고 stoi, itos 사전을 만든다.

stoi: character → integer id
itos: integer id → character

block_size=128이면 dataset은 연속된 129개 문자를 읽는다. 앞 128개가 x, 한 칸 뒤로 민 128개가 y다.

chunk: [c0, c1, c2, ..., c128]
x:     [c0, c1, c2, ..., c127]
y:     [c1, c2, c3, ..., c128]

각 위치가 다음 문자를 정답으로 갖는다.

x = "Hell"
y = "ello"

한 시퀀스에서 마지막 문자 하나만 학습하는 것이 아니다. Causal mask가 미래를 가리므로 T개 위치의 next-character loss를 동시에 계산한다. 이것이 language model 학습에서 sequence 축을 batch처럼 활용하는 부분이다.

minGPT 기본 예제의 shape 지도

chargpt 기본 설정은 gpt-mini다. 소스에서 확인한 값은 다음과 같다.

B = 64       # Trainer 기본 batch size
T = 128      # CharDataset block size
C = 192      # gpt-mini n_embd
H = 6        # gpt-mini n_head
D = 32       # C / H
L = 6        # gpt-mini n_layer
V = dataset의 고유 문자 수

Tiny Shakespeare 데이터가 nanoGPT 예제와 같은 65개 문자 집합이라고 가정하면 V=65다. minGPT에서 사용하는 input.txt가 다르면 V도 달라지므로 실행 로그에서 다시 확인해야 한다.

지점코드 의미Shape
idx입력 문자 id[64,128]
targets한 칸 이동한 정답 id[64,128]
tok_embtoken embedding[64,128,192]
pos_embposition embedding[1,128,192]
x두 embedding의 합[64,128,192]
c_attn(x)QKV 통합 projection[64,128,576]
q, k, vsplit 직후각각 [64,128,192]
head 분리 후attention 입력각각 [64,6,128,32]
q @ kᵀattention logits[64,6,128,128]
softmax(att) @ vhead별 결과[64,6,128,32]
head 결합 후attention 출력[64,128,192]
block 출력residual stream[64,128,192]
logits다음 문자 점수[64,128,V]
flatten logitscross entropy 입력[8192,V]
flatten targetscross entropy 정답[8192]

이 표를 print 결과로 검증하는 것이 첫 번째 완료 기준이다.

Forward pass를 A부터 Z까지 읽기

A. 입력 길이를 검사한다

GPT.forward(idx, targets)idx[b,t]를 읽고 t <= block_size인지 확인한다. Position id [0,1,...,t-1][1,t] shape로 만든다.

B. Token과 position embedding을 더한다

tok_emb = wte(idx)   # [B,T,C]
pos_emb = wpe(pos)   # [1,T,C]
x = dropout(tok_emb + pos_emb)

[1,T,C] position embedding은 batch 축으로 broadcast된다. Batch의 모든 문장이 같은 position index를 쓰기 때문이다.

C. Transformer block을 반복한다

각 block은 pre-LN 구조다.

x = x + attention(layer_norm_1(x))
x = x + mlp(layer_norm_2(x))

첫 번째 글에서 본 원 Transformer의 post-LN 순서와 다르다. minGPT는 GPT-2 계열에 맞춘 구현이다.

D. QKV를 한 번에 투영하고 head를 나눈다

CausalSelfAttentionLinear(C,3C) 결과를 세 개로 split한다. 이어서 view(B,T,H,D).transpose(1,2)로 바꾼다.

[B,T,C] → [B,H,T,D]

C % H == 0 assertion이 있는 이유도 head별 차원을 정수로 나누기 위해서다.

E. Scaled dot-product attention을 계산한다

att = (q @ k.transpose(-2, -1)) / sqrt(D)

Shape은 [B,H,T,T]다. Query 위치마다 모든 Key 위치의 score가 있다.

F. Causal mask를 적용한다

minGPT는 block_size × block_size lower-triangular matrix를 buffer로 등록한다. 현재 T만큼 잘라 0인 위치를 -inf로 바꾼다.

Buffer는 optimizer가 갱신하는 parameter는 아니지만 model과 함께 device를 이동하고 state에 포함될 수 있는 tensor다. 단순 전역 상수와 구분해서 볼 부분이다.

G. Softmax weight로 V를 합친다

att = softmax(att, dim=-1)
y = att @ v

y[B,H,T,D]다. Head 축을 다시 token 축 뒤로 옮기고 [B,T,C]로 합친다. Output projection과 dropout을 지나 residual stream에 더해진다.

H. MLP가 feature를 넓혔다 줄인다

minGPT MLP는 다음 shape를 따른다.

[B,T,C] → Linear(C,4C) → GELU → Linear(4C,C) → [B,T,C]

Attention이 위치 사이 정보를 섞고, MLP는 각 위치의 feature를 변환한다.

I. 마지막 LayerNorm과 vocabulary projection

모든 block을 지난 뒤 ln_f를 적용하고 lm_headCV로 바꾼다.

[B,T,C] → [B,T,V]

각 위치마다 “다음 문자가 vocabulary의 각 후보일 점수”가 나온다.

J. 모든 위치의 loss를 한꺼번에 계산한다

minGPT는 logits를 [B×T,V], targets를 [B×T]로 펴서 cross entropy를 계산한다.

[64,128,V] → [8192,V]
[64,128]   → [8192]

Loss 하나가 8,192개의 next-character prediction을 평균한 값이 된다.

학습 루프에서 실제로 일어나는 일

Trainer.run()의 핵심은 길지 않다.

1. AdamW optimizer 구성
2. RandomSampler와 DataLoader로 (x,y) batch 생성
3. model.train()
4. logits, loss = model(x,y)
5. 이전 gradient 초기화
6. loss.backward()
7. gradient norm clip
8. optimizer.step()
9. callback과 iteration 갱신

여기서 확인하고 싶은 것은 loss 숫자만이 아니다.

  • 첫 iteration 전후 같은 parameter의 값이 실제로 달라지는가?
  • loss.backward() 뒤 QKV projection weight의 gradient가 None이 아닌가?
  • Gradient norm clip 전후 값은 어떻게 달라지는가?
  • model.eval()에서 dropout이 꺼졌을 때 같은 입력의 logits가 재현되는가?

이 질문을 작은 assertion과 로그로 확인하면 autograd와 optimizer가 추상어로 남지 않는다.

생성은 학습과 어떻게 다른가

generate()는 현재 context를 forward하고 마지막 위치 logits만 꺼낸다.

logits:          [B,T,V]
last logits:     [B,V]
temperature 적용
top-k 밖의 logit 제거
softmax → 확률
multinomial 또는 argmax로 다음 id 선택
context 뒤에 붙이기
반복

Temperature가 1보다 작으면 logit 차이가 상대적으로 커져 분포가 뾰족해지고, 1보다 크면 평평해진다. Top-k는 확률이 높은 k개 후보만 남긴다. 둘 다 모델 weight를 바꾸지 않고 sampling 분포를 조절한다.

minGPT의 기본 generate()는 새 token마다 현재 context 전체를 다시 forward한다. 이전 token의 K와 V를 저장해 재사용하는 KV cache가 없다. 이 코드를 이해하면 KV cache가 무엇을 없애는지도 구체적으로 보일 것이다.

코드를 읽을 순서

파일 처음부터 끝까지 읽는 것보다 데이터에서 출력 방향으로 따라갈 생각이다.

  1. projects/chargpt/chargpt.pyCharDataset.__getitem__
  2. 같은 파일의 dataset → model → trainer 조립
  3. GPT.__init__wte, wpe, block, ln_f, lm_head
  4. GPT.forward
  5. Block.forward
  6. CausalSelfAttention.forward
  7. 다시 GPT.forward의 logits와 loss
  8. Trainer.run의 backward와 optimizer
  9. GPT.generate

이 순서면 “클래스 정의를 읽었다”가 아니라 한 batch가 지나가는 경로를 이어서 볼 수 있다.

첫 실행에서 넣을 관측 코드

원본을 크게 고치지 않고 첫 batch에서만 shape를 출력한다.

def shape(name, tensor):
    print(f"{name:>16}: {tuple(tensor.shape)} {tensor.dtype} {tensor.device}")

확인 대상은 다음으로 제한한다.

idx / targets
tok_emb / pos_emb
q / k / v
attention logits / weights
attention output
block output
logits / loss

추가로 attention weight 한 행의 합이 1인지, causal mask 위쪽 삼각형이 0인지 assertion으로 확인한다.

assert torch.allclose(att.sum(dim=-1), torch.ones_like(att.sum(dim=-1)), atol=1e-5)

실험용 출력은 첫 iteration에서만 켜야 한다. 모든 batch마다 출력하면 학습 속도와 로그 가독성을 동시에 망친다.

학습 완료 기준

생성 문장이 그럴듯하다는 것만으로 완료하지 않는다.

구조

  • [B,T,C] → [B,H,T,D] → [B,H,T,T]를 코드 없이 적을 수 있다.
  • causal mask가 softmax 전에 적용되는 이유를 설명할 수 있다.
  • Attention과 MLP가 residual stream에 무엇을 더하는지 설명할 수 있다.
  • minGPT가 learned position embedding과 pre-LN을 쓴다는 것을 찾을 수 있다.

학습

  • xy가 한 칸 이동한 이유를 설명할 수 있다.
  • [B,T,V] logits와 [B,T] target이 cross entropy에서 어떻게 만나는지 설명할 수 있다.
  • zero_grad, backward, gradient clipping, step 순서를 설명할 수 있다.
  • Train loss만 보지 않고 별도 validation이 필요한 이유를 말할 수 있다.

생성

  • 마지막 위치 logits만 사용하는 이유를 설명할 수 있다.
  • Temperature와 top-k가 logits 분포에 주는 영향을 비교할 수 있다.
  • 매 token마다 전체 context를 다시 계산하는 지점을 찾아 KV cache와 연결할 수 있다.

실행하면서 채울 기록

아래 표는 아직 빈칸이다. 수치를 만들지 않고 실제 실행 뒤에 채운다.

항목실행 후 기록
환경CPU / MPS / CUDA, PyTorch 버전
데이터문자 수, vocabulary size, train/validation split
모델layer, head, embedding, block size, parameter 수
학습batch size, iteration, learning rate, 소요 시간
결과train loss, validation loss, sample
실패OOM, dtype, device, overfitting, 재현성 문제
해석shape를 예상과 다르게 이해했던 지점

nanoGPT로 넘어가면 비교할 것

minGPT를 설명할 수 있게 되면 nanoGPT에서 같은 지점을 찾는다.

관점minGPTnanoGPT에서 볼 것
목적교육적이고 작은 구현단순함을 유지한 실전형 학습
데이터PyTorch Dataset/DataLoader 예제memmap 기반 binary token stream
Attention수동 scaled dot-product attentionPyTorch SDPA 또는 수동 fallback 경로
학습기본 단일 장치 loopmixed precision, accumulation, DDP, compile
평가예제 callback 중심train/val loss 평가와 checkpoint
성능 관측iteration timetokens/iteration, MFU 추정

nanoGPT의 Shakespeare character config는 block_size=256, n_layer=6, n_head=6, n_embd=384를 기본값으로 둔다. Apple Silicon에서는 README가 --device=mps 사용을 안내한다. 그대로 복사하기 전에 현재 장치와 PyTorch 지원을 확인할 것이다.

지금 내 생각

LLM을 사용하면서 가장 쉽게 빠지는 함정은 입력과 출력만 보고 중간을 “모델이 알아서 처리한다”고 묶는 것이다. 서비스 개발에서는 API 호출만으로도 기능을 만들 수 있지만, latency나 memory, context length, hallucination, KV cache를 파고들려면 내부 계산을 피할 수 없다.

minGPT가 약 300줄이라는 사실보다 중요한 점은 그 300줄 안에 언어 모델의 데이터 계약과 계산 계약이 모두 드러나 있다는 것이다.

  • 데이터 계약: 현재 token sequence로 다음 token sequence를 예측한다.
  • 계산 계약: causal attention으로 미래를 차단한다.
  • 출력 계약: 각 위치에서 vocabulary 전체의 logits를 낸다.
  • 학습 계약: 정답 token의 cross entropy를 줄인다.
  • 추론 계약: 마지막 분포에서 하나를 뽑아 입력 뒤에 붙인다.

이 다섯 계약을 코드와 tensor로 설명할 수 있다면 모델 크기가 커지고 최적화가 붙어도 길을 잃지 않을 것 같다. 반대로 실행 명령만 성공시키고 넘어가면 nanoGPT에서 옵션만 늘어난다.

다음 TODO

  1. minGPT 저장소의 현재 commit을 고정한다.
  2. Tiny Shakespeare 원문과 train/validation 기준을 기록한다.
  3. 첫 batch의 모든 핵심 shape를 출력한다.
  4. Forward 결과와 cross entropy 입력을 손으로 대조한다.
  5. 1회 backward 전후 parameter와 gradient를 확인한다.
  6. 작은 모델을 학습하고 loss curve와 생성 sample을 저장한다.
  7. 생성 loop의 중복 계산을 표시하고 KV cache 공부로 연결한다.
  8. 같은 실험을 nanoGPT로 옮겨 코드와 성능 차이를 비교한다.

완료 후에는 이 글의 빈 표를 실제 실행 결과로 교체하고, 실패 로그도 함께 남길 예정이다.

직접 확인한 소스

대화

댓글

0
댓글을 불러오는 중입니다.