12 bài vừa rồi xây từng mảnh ghép riêng lẻ. Bài 1: mental model tổng quát — tokenize, embed, attention, sample. Bài 2-4: math foundation — linear algebra, calculus, probability. Bài 5: neural network từ zero. Bài 6-8: tokenization — BPE, byte-level, tiếng Việt. Bài 9-11: attention — scaled dot-product, multi-head, causal mask. Bài 12: Transformer block — Layer Norm, residual connection, FFN.

Giờ ghép lại.

Karpathy nanoGPT là một file Python, khoảng 300 dòng, implement GPT-2 architecture đầy đủ. Không dùng HuggingFace, không abstraction ẩn. Chỉ PyTorch thuần. Bài này walk-through từng phần code đó, train trên tinyshakespeare (chạy được trên CPU, khoảng 15-20 phút), rồi generate text Shakespeare-like từ model vừa train.

Sau bài này, bạn biết chính xác GPT làm gì ở từng dòng code.

Mental model: full stack nanoGPT

Trước khi code, cần nắm rõ bức tranh tổng:

text input
    |
    v
[ char-level tokenize ]   ->   dãy integer ID
    |
    v
[ token embedding ]   +   [ position embedding ]   ->   matrix [B, T, C]
    |
    v
[ Transformer Block x N ]     (attention + FFN + residual + LayerNorm)
    |
    v
[ LayerNorm final ]
    |
    v
[ Linear projection ]         matrix [B, T, vocab_size]
    |
    v
[ logits ]   ->   cross-entropy với targets   ->   loss
                  softmax + sample            ->   next token

Training:  minimize loss qua hàng nghìn steps
Generate:  sample next token, append vào sequence, lặp lại

Hai mode: training dùng loss để cập nhật weights, generate dùng model để predict token kế tiếp. Đây là vòng lặp autoregressive đã giải thích ở bài 1 — giờ implement thật sự.

Phần 1: Setup — environment và data

Yêu cầu: Python 3.9+, PyTorch 2.x, numpy, requests. Cài qua pip:

pip install torch numpy requests

Nếu có GPU (NVIDIA), PyTorch tự nhận CUDA. Không có GPU thì CPU vẫn chạy được — chỉ chậm hơn khoảng 5-10x, nhưng với config nhỏ bài này dùng thì vẫn xong trong 15-20 phút.

Dataset: tinyshakespeare — toàn bộ tác phẩm Shakespeare, khoảng 1MB text. Đủ để demo nhưng không quá lớn.

Tokenization: bài này dùng char-level thay vì BPE (đã học bài 7). Vocab nhỏ hơn nhiều (~65 ký tự), dễ implement, đủ để thấy model học được cấu trúc ngôn ngữ. GPT-2 thật dùng BPE 50,257 tokens — sau bài này bạn có thể swap vào.

import torch
import torch.nn as nn
from torch.nn import functional as F
import requests

# Download tinyshakespeare
url = "https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt"
text = requests.get(url).text
print(f"Dataset: {len(text):,} ký tự")  # ~1,115,394

# Char-level vocabulary
chars = sorted(set(text))
vocab_size = len(chars)  # 65
print(f"Vocab size: {vocab_size}")
print(f"Chars: {''.join(chars)}")

# Encoder / decoder
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for i, c in enumerate(chars)}
encode = lambda s: [stoi[c] for c in s]
decode = lambda ids: ''.join(itos[i] for i in ids)

# Train/val split 90/10
data = torch.tensor(encode(text), dtype=torch.long)
n = int(0.9 * len(data))
train_data = data[:n]
val_data   = data[n:]

print(f"Train: {len(train_data):,} tokens")
print(f"Val:   {len(val_data):,} tokens")

Một đoạn test nhanh:

sample = "To be, or not to be"
encoded = encode(sample)
decoded = decode(encoded)
print(encoded)   # [45, 53, 1, 40, 43, 6, 1, 53, 56, 1, 52, 53, 58, 1, 58, 53, 1, 40, 43]
print(decoded)   # "To be, or not to be"

Round-trip hoạt động. Dataset sẵn sàng.

Phần 2: Hyperparameters

Config cho model “nhỏ” — chạy được trên CPU:

# Training
batch_size    = 64       # số sequences mỗi batch
block_size    = 256      # context length (max tokens model nhìn được)
max_iters     = 5000     # tổng số training steps
eval_iters    = 200      # số batches dùng để estimate loss khi đánh giá
learning_rate = 3e-4     # AdamW learning rate

# Architecture
n_embd   = 384           # embedding dimension (C)
n_head   = 6             # số attention heads mỗi layer
n_layer  = 6             # số Transformer blocks xếp chồng
dropout  = 0.2           # dropout rate (regularization)

device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f"Using: {device}")

Tổng tham số model này khoảng 10 triệu — nhỏ hơn GPT-2 Small (124M) 12 lần nhưng đủ để sinh text có cấu trúc.

Tại sao n_embd = 384n_head = 6? Vì 384 / 6 = 64 — mỗi head xử lý 64 chiều. Đây là head size. Phải chia đều.

Phần 3: Attention head

Đây là đơn vị nhỏ nhất của attention — một head duy nhất. Bài 9-10 đã giải thích cơ chế, giờ code:

class Head(nn.Module):
    """Single self-attention head."""

    def __init__(self, head_size):
        super().__init__()
        self.key   = nn.Linear(n_embd, head_size, bias=False)
        self.query = nn.Linear(n_embd, head_size, bias=False)
        self.value = nn.Linear(n_embd, head_size, bias=False)
        # causal mask: token i chỉ attend đến token 0..i (không nhìn tương lai)
        self.register_buffer('tril', torch.tril(torch.ones(block_size, block_size)))
        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        B, T, C = x.shape                           # batch, time (seq len), channels

        k = self.key(x)                             # (B, T, head_size)
        q = self.query(x)                           # (B, T, head_size)

        # Attention scores: Q * K^T / sqrt(head_size)
        wei = q @ k.transpose(-2, -1) * C**-0.5    # (B, T, T)

        # Causal mask: đặt -inf vào các vị trí tương lai
        wei = wei.masked_fill(self.tril[:T, :T] == 0, float('-inf'))

        # Softmax -> attention weights (tổng mỗi hàng = 1)
        wei = F.softmax(wei, dim=-1)                # (B, T, T)
        wei = self.dropout(wei)

        # Weighted sum của values
        v = self.value(x)                           # (B, T, head_size)
        return wei @ v                              # (B, T, head_size)

Lưu ý register_buffer: tril không phải parameter (không được update khi training), nhưng cần được move sang CUDA cùng model. register_buffer đảm bảo điều đó.

C**-0.51/sqrt(head_size) — scale factor bài 9 đã giải thích: nếu không scale, dot product quá lớn khiến softmax bị saturate, gradient biến mất.

Phần 4: MultiHead Attention, FeedForward, Block

Ba class này lắp lại thành một Transformer Block — cấu trúc được stack N lần:

class MultiHeadAttention(nn.Module):
    """N heads chạy song song, concat output."""

    def __init__(self, num_heads, head_size):
        super().__init__()
        self.heads   = nn.ModuleList([Head(head_size) for _ in range(num_heads)])
        self.proj    = nn.Linear(num_heads * head_size, n_embd)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        # Chạy tất cả heads, concat theo dim cuối
        out = torch.cat([h(x) for h in self.heads], dim=-1)  # (B, T, n_embd)
        return self.dropout(self.proj(out))


class FeedForward(nn.Module):
    """FFN: 2 Linear + ReLU, expand 4x rồi project về."""

    def __init__(self, n_embd):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(n_embd, 4 * n_embd),
            nn.ReLU(),
            nn.Linear(4 * n_embd, n_embd),
            nn.Dropout(dropout),
        )

    def forward(self, x):
        return self.net(x)


class Block(nn.Module):
    """Transformer block: communication (attention) rồi computation (FFN)."""

    def __init__(self, n_embd, n_head):
        super().__init__()
        head_size  = n_embd // n_head
        self.sa    = MultiHeadAttention(n_head, head_size)
        self.ffwd  = FeedForward(n_embd)
        self.ln1   = nn.LayerNorm(n_embd)
        self.ln2   = nn.LayerNorm(n_embd)

    def forward(self, x):
        # Pre-norm + residual connection (bài 12 đã giải thích)
        x = x + self.sa(self.ln1(x))
        x = x + self.ffwd(self.ln2(x))
        return x

Hai điểm quan trọng trong Block.forward:

  1. Pre-norm: LayerNorm đặt trước attention và FFN (không phải sau như paper gốc). Đây là cải tiến của GPT-2 — giúp training ổn định hơn ở nhiều layer.

  2. Residual connection: x = x + ... — bài 12 đã giải thích tại sao: gradient có đường đi thẳng về đầu mạng, tránh vanishing gradient khi stack sâu.

FeedForward expand 4x (n_embd -> 4*n_embd) rồi project về. Con số 4x là convention từ paper “Attention is All You Need” — thực nghiệm cho thấy tỉ lệ này hoạt động tốt. Không có lý thuyết toán học đặc biệt đằng sau.

Phần 5: GPT model

Lắp tất cả vào class GPT chính:

class GPT(nn.Module):

    def __init__(self):
        super().__init__()
        # Token và position embedding
        self.token_embedding    = nn.Embedding(vocab_size, n_embd)
        self.position_embedding = nn.Embedding(block_size, n_embd)
        # Stack N transformer blocks
        self.blocks = nn.Sequential(*[Block(n_embd, n_head) for _ in range(n_layer)])
        # Final LayerNorm + projection về vocab
        self.ln_f    = nn.LayerNorm(n_embd)
        self.lm_head = nn.Linear(n_embd, vocab_size)

    def forward(self, idx, targets=None):
        B, T = idx.shape

        # Token + position embedding, cộng lại (bài 9 đã giải thích)
        tok_emb = self.token_embedding(idx)                                        # (B, T, C)
        pos_emb = self.position_embedding(torch.arange(T, device=idx.device))     # (T, C)
        x = tok_emb + pos_emb                                                      # broadcast: (B, T, C)

        # Qua N transformer blocks
        x = self.blocks(x)                                                         # (B, T, C)
        x = self.ln_f(x)                                                           # (B, T, C)

        # Project về vocab size -> logits
        logits = self.lm_head(x)                                                   # (B, T, vocab_size)

        # Nếu có targets, tính cross-entropy loss
        loss = None
        if targets is not None:
            B, T, C = logits.shape
            logits  = logits.view(B * T, C)     # flatten batch và time
            targets = targets.view(B * T)
            loss    = F.cross_entropy(logits, targets)

        return logits, loss

    def generate(self, idx, max_new_tokens):
        for _ in range(max_new_tokens):
            # Crop context về block_size nếu quá dài
            idx_cond = idx[:, -block_size:]
            # Forward pass (không cần loss)
            logits, _ = self(idx_cond)
            # Lấy logits của token cuối cùng
            logits = logits[:, -1, :]              # (B, vocab_size)
            # Softmax -> probabilities
            probs  = F.softmax(logits, dim=-1)     # (B, vocab_size)
            # Sample một token
            next_id = torch.multinomial(probs, num_samples=1)   # (B, 1)
            # Append vào sequence, lặp lại
            idx = torch.cat([idx, next_id], dim=1)              # (B, T+1)
        return idx

Phần generate là vòng lặp autoregressive: mỗi lần sinh đúng một token, append vào sequence, feed lại vào model. Bài 1 đã mô tả bằng words, đây là code thật.

torch.multinomial sample theo phân phối xác suất — không phải luôn lấy token cao nhất. Đây là lý do output mỗi lần chạy khác nhau.

Đếm params:

model = GPT().to(device)
total = sum(p.numel() for p in model.parameters())
print(f"Params: {total:,}")  # ~10,788,929 (~10.8M)

Phần 6: Training loop

def get_batch(split):
    """Lấy một batch random từ train hoặc val data."""
    data = train_data if split == 'train' else val_data
    # Random start positions
    ix = torch.randint(len(data) - block_size, (batch_size,))
    x  = torch.stack([data[i     : i + block_size    ] for i in ix])
    y  = torch.stack([data[i + 1 : i + block_size + 1] for i in ix])
    return x.to(device), y.to(device)

@torch.no_grad()
def estimate_loss():
    """Ước tính loss trên train và val (dùng nhiều batches để ổn định)."""
    out = {}
    model.train(False)   # inference mode: tắt dropout
    for split in ['train', 'val']:
        losses = torch.zeros(eval_iters)
        for k in range(eval_iters):
            X, Y = get_batch(split)
            _, loss = model(X, Y)
            losses[k] = loss.item()
        out[split] = losses.mean()
    model.train(True)    # trở về training mode
    return out

# Khởi tạo model và optimizer
model     = GPT().to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate)

# Training loop
for it in range(max_iters):

    # In loss mỗi 500 steps
    if it % 500 == 0:
        losses = estimate_loss()
        print(f"step {it:5d}: train loss {losses['train']:.4f}, val loss {losses['val']:.4f}")

    # Lấy batch
    xb, yb = get_batch('train')

    # Forward + backward + update
    logits, loss = model(xb, yb)
    optimizer.zero_grad(set_to_none=True)
    loss.backward()
    optimizer.step()

print("Training xong.")

optimizer.zero_grad(set_to_none=True) thay vì .zero_grad() — nhanh hơn một chút vì set gradient về None thay vì tensor zeros.

model.train(False)model.train(True) là cách tường minh để switch giữa inference mode và training mode — tương đương với cặp model.eval() / model.train() nhưng rõ ý định hơn: tắt dropout khi đánh giá, bật lại khi train.

Nếu đang chạy CPU và muốn theo dõi tiến độ, có thể giảm max_iters = 2000 để xong trong 5-6 phút, loss vẫn xuống đủ để generate text có cấu trúc.

Phần 7: Generate và kết quả

Sau khi training xong:

# Context ban đầu: một newline character (token index 0)
context = torch.zeros((1, 1), dtype=torch.long, device=device)

# Generate 500 tokens
output = model.generate(context, max_new_tokens=500)
print(decode(output[0].tolist()))

Progression của loss và output:

StepTrain lossVal lossText output
04.174.17kXJ!sMq?Z;hTp... hoàn toàn random
5002.462.50tha the the the... học được bigrams
10002.072.14thate the hath... bắt đầu có từ
20001.771.92ROMEO: The king... nhận ra names
50001.481.65Đoạn văn có cấu trúc drama rõ ràng

Sau 5000 steps, output trông như thế này:

DUKE VINCENTIO:
The nature of our people,
Our city's institutions, and the terms
For common justice, you're as pregnant in
As art and practice hath enriched any
That we remember. There is our commission,
From which we would not have you warp. Call hither,
I say, bid come before us Angelo.

ESCALUS:
I shall desire you, sir, to give me leave
To have free speech with you; and it concerns me
To look into the bottom of my place:
A power I have, but of what strength and nature
I am not yet instructed.

Không phải Shakespeare thật. Nhưng đúng cấu trúc: có tên nhân vật viết hoa, có dấu phẩy đúng chỗ, câu cú có ngữ nghĩa tạm ổn. Model 10M params, train 15 phút, từ đống ký tự random.

Loss 1.48 nghĩa là gì? Cross-entropy loss 1.48 ~ perplexity e^1.48 ≈ 4.4. Tức là trung bình model bối rối giữa 4-5 lựa chọn mỗi token — không tệ với vocab 65 ký tự và model nhỏ.

Phần 8: Từ nanoGPT đến GPT-3

Cùng architecture, cùng code — chỉ scale config:

Modeln_layern_embdn_headParamsTrain time
nanoGPT toy6384610M15 phút CPU
GPT-2 Small1276812124MVài ngày, 1 GPU
GPT-2 Medium24102416345MVài tuần, 4 GPU
GPT-2 XL481600251.5BVài tuần, 8 GPU
GPT-3961228896175BTháng, 1000+ GPU

Bốn thứ thay đổi khi scale: số layer (n_layer), embedding dim (n_embd), số head (n_head), và dataset. Code không thay đổi đáng kể. Đây là điều thú vị nhất của Transformer: cùng architecture, chỉ cần nhiều tài nguyên hơn.

Tại sao scale lại hoạt động? Đây là nội dung của scaling laws (bài 14 sẽ đào sâu) — Kaplan et al. 2020 và Chinchilla 2022 tìm ra công thức: loss giảm theo power law khi tăng params và data. Không có “điểm vỡ” hay ngưỡng — cứ thêm tài nguyên, model tốt hơn.

Và nếu bạn muốn load GPT-2 Small pretrained weights vào nanoGPT? Karpathy đã implement sẵn trong repo gốc — tên method GPT.from_pretrained('gpt2'). Vì architecture match, weights có thể load thẳng. Fine-tuning từ đó dễ hơn nhiều so với train from scratch.

Cheatsheet

Config nanoGPT toy (CPU-friendly):

ParamGiá trịÝ nghĩa
block_size256Context window
n_embd384Embedding dim C
n_head6Attention heads
n_layer6Transformer blocks
dropout0.2Regularization
learning_rate3e-4AdamW LR
max_iters5000Training steps
batch_size64Sequences/batch

6 điểm phải nhớ:

  1. Tokenization — char-level đủ để demo, swap sang BPE khi cần vocab lớn hơn
  2. Token + Position embedding cộng lại — model học vị trí tương đối từ đây
  3. Pre-norm (LayerNorm trước attention) — ổn định hơn post-norm gốc
  4. Residual connectionx = x + sublayer(x), gradient có đường đi thẳng
  5. Causal mask — token i chỉ attend đến token 0..i, không nhìn tương lai
  6. Cross-entropy loss flatten — logits reshape thành [B*T, vocab_size] trước khi tính

Từ model đến generation:

context = torch.zeros((1, 1), dtype=torch.long)  # batch=1, length=1
output  = model.generate(context, max_new_tokens=500)
text    = decode(output[0].tolist())

Loss progression reference (tinyshakespeare, char-level):

init:      ~4.17  (random, log(65) ≈ 4.17)
step 1000: ~2.0
step 3000: ~1.6
step 5000: ~1.5   (cân bằng với model size này)

Lời kết

Part 3 hoàn thành. Bắt đầu từ bài 9 với attention từng head, qua multi-head, qua Transformer block, giờ đến GPT hoàn chỉnh chạy được.

Bước tiếp theo thực hành — đừng chỉ đọc, clone về và chạy:

git clone https://github.com/karpathy/nanoGPT
# Đọc toàn bộ train.py và model.py
# Thử train trên dataset khác: code Python, lyrics nhạc, Reddit comments

Thay text = requests.get(url).text bằng bất kỳ corpus nào bạn có. Model sẽ học style của corpus đó. Đây là cách nhanh nhất để cảm được intuition về “model học gì từ data”.

Video recommend: Karpathy “Let’s build GPT: from scratch, in code, spelled out” — 2 tiếng, walk-through toàn bộ code theo thời gian thực. Đây là video bài này dựa vào. Xem lại sau khi đã code một lần — sẽ hiểu rõ hơn nhiều.

Part 4: Training deep dive (bài 14-17) — scaling laws, mixed precision training, gradient clipping, distributed training. Câu hỏi lớn: làm thế nào để train model 10 tỷ params mà không cần 10 tỷ GPU memory? Và tại sao data nhiều hơn đôi khi tốt hơn model to hơn?