Mở đầu: bỏ hẳn recurrence — kiến trúc đứng sau mọi LLM hiện nay
Bài 13 đã verify: attention không "quên" theo khoảng cách như RNN (chuỗi $80$ bước, RNN sập về $\approx50\%$, attention vẫn $100\%$). Bài báo "Attention Is All You Need" (Vaswani et al. 2017) đẩy ý tưởng đó tới tận cùng: bỏ hẳn phần hồi quy (recurrence) — không còn $h_t$ phụ thuộc $h_{t-1}$ nào cả, TOÀN BỘ mô hình chỉ xây từ attention + vài phép biến đổi đơn giản. Đây là kiến trúc nền của GPT, BERT, và gần như mọi LLM hiện nay.
Bài này ghép transposeGrad() + layerNorm() (2 op mới) với
matmul/add/mul/softmax đã có thành $1$ transformer block hoàn chỉnh, verify
từng mảnh bằng số thật: vì sao phải scale attention score, vì sao thiếu positional encoding thì model "mù
vị trí", và vì sao residual connection một mình KHÔNG đủ ổn định — phải đi cùng layer norm.
1. "Attention Is All You Need": vì sao bỏ hẳn recurrence
Bỏ RNN, chỉ dùng attention — $2$ lý do cụ thể, cả $2$ đã có bằng chứng số từ các bài trước:
1. Song song hoá toàn chuỗi trên GPU. RNN (Bài 13) buộc phải tính $h_1$ xong mới tính $h_2$ — phụ thuộc tuần tự, không chia việc ra được. Attention tính điểm số cho MỌI cặp vị trí ĐỘC LẬP nhau — GPU xử lý hàng nghìn phép tính song song cùng lúc, tận dụng triệt để phần cứng hiện đại.
2. Đường gradient ngắn, không xuyên thời gian. Verify ở Bài 13: RNN sập về đoán bừa ở chuỗi $80$ bước vì gradient phải xuyên qua $80$ phép nhân liên tiếp; attention giữ nguyên $100\%$ vì gradient từ bất kỳ vị trí nào chỉ đi qua đúng $1$ bước (softmax + nhân trọng số).
Kiến trúc gốc (Vaswani 2017) có $2$ nửa: encoder (đọc toàn bộ input, dùng cho dịch máy — nhìn được cả 2 chiều) và decoder (sinh output từng token, chỉ nhìn được QUÁ KHỨ của chính nó). Series này tập trung $100\%$ vào decoder-only (kiểu GPT) — vì đích cuối (Bài 16–Bài 19) là sinh văn bản, đúng việc decoder làm.
2. Self-attention Q/K/V: tính tay trước, tổng quát sau
Mỗi vị trí trong chuỗi tạo ra $3$ vector qua $3$ phép chiếu tuyến tính học được: Query ($Q$ — "tôi đang tìm gì"), Key ($K$ — "tôi có gì để được tìm thấy"), Value ($V$ — "nội dung thực sự mang đi"). Ví dụ TÍ HON verify tay trước: $Q=K=$ ma trận đơn vị $3\times3$ (mỗi token chỉ "khớp" với chính nó), $V=\begin{bmatrix}1&2&3\\4&5&6\\7&8&9\end{bmatrix}$:
$$QK^T = I$$
(ma trận đơn vị — mỗi hàng chỉ có $1$ số $=1$, còn lại đều $=0$)
Softmax hàng $0$ ($[1,0,0]$): $\frac{e^1}{e^1+e^0+e^0} = 0{,}5761$ cho vị trí $0$, $\frac{e^0}{e^1+2} = 0{,}2119$ cho $2$ vị trí còn lại. Context hàng $0$ = tổng có trọng số của $V$: $0{,}5761\times1 + 0{,}2119\times4 + 0{,}2119\times7 = 2{,}907$ — thiên hẳn về hàng $0$ của $V$ (đúng trực giác: token $0$ "khớp" mạnh nhất với chính nó nên context của nó chủ yếu lấy từ $V$ hàng $0$).
Tổng quát hoá ($T$ vị trí, $d_k$ chiều mỗi đầu):
$$\text{Attention}(Q,K,V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V$$
Nếu $Q, K$ có các thành phần độc lập, phương sai $1$ (thường đúng sau khởi tạo He/Xavier — Bài 9), tích vô hướng $Q\cdot K$ của $2$ vector $d_k$ chiều có phương sai $= d_k$ (mỗi số hạng đóng góp phương sai $1$, cộng $d_k$ số hạng độc lập). Verify bằng số thật (đo trên $20.000$ mẫu ngẫu nhiên):
| $d_k$ | Phương sai $Q\cdot K$ đo được |
|---|---|
| $8$ | $8{,}09$ |
| $16$ | $15{,}91$ |
| $32$ | $31{,}74$ |
| $64$ | $63{,}41$ |
| $128$ | $128{,}54$ |
Phương sai tăng ĐÚNG theo $d_k$ — độ lớn điểm số ($\sqrt{d_k}$ độ lệch chuẩn) làm softmax bão hoà (gần như toàn bộ xác suất dồn vào $1$ vị trí), và gradient qua softmax bão hoà gần như bằng $0$ (giống hệt cạm bẫy sigmoid bão hoà đã học ở Bài 7). Verify trực tiếp ($T=5$ vị trí, $d_k=64$):
| Cách tính score | Xác suất lớn nhất (softmax) | Chuẩn gradient qua softmax |
|---|---|---|
| KHÔNG scale | $1{,}0000$ (bão hoà hoàn toàn) | $5{,}14\times10^{-10}$ (gần như chết) |
| CÓ scale $1/\sqrt{d_k}$ | $0{,}8282$ (mềm, hợp lý) | $0{,}161$ (khoẻ mạnh) |
Chênh nhau $8$ bậc độ lớn ($10^{-10}$ so với $0{,}16$) — không scale, model KHÔNG học được gì ở $d_k$ lớn (mọi mô hình thật đều dùng $d_k\ge64$) vì gradient chết trước khi kịp cập nhật $Q,K$.
Causal mask — decoder không được "nhìn tương lai": trước softmax, cộng thêm $1$ ma trận tam giác toàn $-\infty$ (thực tế dùng $-10^9$, đủ lớn để softmax cho ra $0$ tuyệt đối) vào các vị trí $j>i$ (cột tương lai so với hàng đang xét):
$$\text{mask}_{ij} = \begin{cases} 0 & j\le i \\ -10^9 & j>i \end{cases}$$
- Hàng $0$: $(1{,}0,\ 0{,}0,\ 0{,}0)$ — chỉ được nhìn chính nó.
- Hàng $1$: $(0{,}3318,\ 0{,}6682,\ 0{,}0)$ — nhìn được vị trí $0$ và $1$, vị trí $2$ bị chặn tuyệt đối.
- Hàng $2$: $(0{,}2237,\ 0{,}3688,\ 0{,}4076)$ — không đổi so với không mask, vì đây là vị trí CUỐI, vốn dĩ đã không có vị trí nào ở tương lai để cần chặn.
3. Multi-head: nhiều "góc nhìn" song song
Thay vì $1$ phép attention trên toàn bộ $d_{model}$ chiều, chia thành $h$ "đầu" (head) song song, mỗi đầu attention trên $1$ không gian con nhỏ hơn ($d_k = d_{model}/h$) — mỗi đầu có thể học "chú ý" theo $1$ khía cạnh khác nhau (đầu này theo dõi quan hệ ngữ pháp, đầu kia theo dõi quan hệ ngữ nghĩa, v.v.). Cách ghép chuẩn: tính context của mỗi đầu riêng, GHÉP (concat) lại, rồi qua $1$ phép chiếu $W_O$ chung. NeuroJS ghép theo cách tương đương về đại số nhưng đơn giản hơn: mỗi đầu có $W_O$ RIÊNG, CỘNG tổng các đầu ra đã chiếu — tương đương chính xác "ghép rồi nhân $W_O$ chung" (phép nhân ma trận phân phối qua phép ghép cột), nhưng không cần thêm op "ghép cột" mới vào engine.
Đếm tham số ĐẦY ĐỦ $1$ block (ví dụ $d_{model}=32$, $4$ đầu, $d_{ff}=64$ — dùng trong demo Mục 5):
| Thành phần | Công thức | Số tham số |
|---|---|---|
| Attention ($4$ đầu) | $4h\times(d_{model}\times d_k) = 4\times d_{model}^2$ | $4.096$ |
| Layer norm ($\times2$) | $2\times(2\times d_{model})$ | $128$ |
| FFN | $2\times d_{model}\times d_{ff} + d_{ff} + d_{model}$ | $4.192$ |
| Tổng 1 block | $8.416$ |
Điểm thú vị verify được từ công thức: tham số attention LUÔN bằng $4\times d_{model}^2$, KHÔNG PHỤ THUỘC số đầu $h$ — chia càng nhiều đầu thì $d_k$ càng nhỏ, nhưng tổng tham số không đổi (mỗi đầu nhỏ hơn nhưng nhiều đầu hơn, triệt tiêu nhau đúng bằng đại số). Dùng công thức trên với số của GPT-2 nhỏ ($d_{model}=768$, $d_{ff}=3.072$, $12$ đầu, $12$ block): mỗi block $\approx7$ triệu tham số, nhân $12$ cộng embedding — tự tính ra sẽ thấy con số quen thuộc $\approx124$ triệu.
- $h=1$ ($d_k=32$): $4.096$ tham số/đầu $\times 1$ đầu $= 4.096$.
- $h=2$ ($d_k=16$): $2.048$ tham số/đầu $\times 2$ đầu $= 4.096$.
- $h=4$ ($d_k=8$): $1.024$ tham số/đầu $\times 4$ đầu $= 4.096$.
- $h=8$ ($d_k=4$): $512$ tham số/đầu $\times 8$ đầu $= 4.096$.
- $h=32$ ($d_k=1$): $128$ tham số/đầu $\times 32$ đầu $= 4.096$.
4. Phần còn lại của block: positional encoding, residual, layer norm, FFN
Positional encoding — self-attention THUẦN (không tiêm vị trí) có tính chất toán học nghiêm ngặt: permutation-equivariant (đổi thứ tự input, output đổi thứ tự Y HỆT theo, giá trị mỗi output không đổi). Verify trực tiếp: đổi chỗ $2$ token đầu trong chuỗi $3$ token, output của token đó (dù giờ đứng ở vị trí khác) không đổi MỘT CHỮ SỐ nào (sai lệch đo được $\approx10^{-8}$, thuần là sai số làm tròn float32):
const outOriginal = selfAttnNoPE([x1, x2, x3]); // thu tu goc
const outSwapped = selfAttnNoPE([x2, x1, x3]); // doi cho x1,x2
// outOriginal[0] (output cua x1 o vi tri 0) va outSwapped[1] (output
// cua x1 o vi tri 1 sau khi doi cho) - VERIFIED giong het nhau (~1e-8)
// => model KHONG the phan biet "x1 dung truoc" hay "x1 dung sau" neu
// khong co positional encoding
Đây chính là cạm bẫy "chó cắn người" ≠ "người cắn chó" đã nêu ở
Bài 13 Mục 1 — nhưng lần này với bằng chứng số học nghiêm ngặt: nếu
quên tiêm vị trí, self-attention xử lý CHÍNH XÁC như túi từ (bag of words), không hề biết từ nào đứng
trước từ nào. Cách chữa (kiểu GPT — series này dùng): positional embedding học được —
$1$ bảng tra $T_{max}\times d_{model}$ y hệt embeddingLookup() Bài 12, tra theo VỊ TRÍ
($0,1,2,\ldots$) thay vì theo từ, CỘNG vào embedding từ trước khi vào block đầu tiên.
Residual connection ($x + \text{sublayer}(x)$) thường được gọi là "đường cao tốc gradient" — nhưng verify kỹ hơn cho thấy sự thật tinh tế hơn khẩu hiệu đó:
| Cách xếp $N$ lớp | Chuẩn gradient tại input, $N=40$ | Kết luận |
|---|---|---|
| KHÔNG residual | $1{,}48\times10^{-16}$ | Tiêu biến gần như hoàn toàn |
| Residual MỘT MÌNH (không layer norm) | $1{,}27\times10^{12}$ | VẪN bùng nổ — không hề ổn định |
| Residual + Layer Norm ("Add & Norm", đúng Transformer thật) | $9{,}34\times10^{-5}$ (và $1{,}07\times10^{-3}$ ở $N=80$) | Ổn định qua MỌI độ sâu đã thử ($1$–$80$ lớp) |
Bài học verify được: residual một mình KHÔNG đủ — chuẩn gradient vẫn có thể bùng nổ theo cấp số nhân nếu không có gì "ghìm" độ lớn activation lại. Layer norm (chuẩn hoá TỪNG VỊ TRÍ về mean=$0$/var=$1$ rồi co giãn bằng $\gamma,\beta$ học được — khác BatchNorm chuẩn hoá theo batch, đã nhắc ở Bài 9) chính là mảnh ghép giữ activation không phình to qua từng lớp, làm cho "đường cao tốc gradient" của residual thực sự chạy êm — cặp đôi Add & Norm luôn đi cùng nhau trong mọi Transformer thật, không phải ngẫu nhiên.
FFN (feed-forward network) áp dụng ĐỘC LẬP cho từng vị trí (không trộn thông tin giữa các vị trí — việc đó là nhiệm vụ của attention): $2$ lớp tuyến tính với ReLU ở giữa, $d_{model}\to d_{ff}\to d_{model}$ ($d_{ff}$ thường gấp $4$ lần $d_{model}$ trong kiến trúc thật, demo Mục 5 dùng tỉ lệ $2$ lần để nhẹ hơn).
5. Thực hành: transformer block chạy từng bước
Xem $1$ block hoàn chỉnh chạy từng giai đoạn trên câu thơ ngắn thật — ma trận $QK^T$, mask, softmax, context, và output cuối cùng sau Add&Norm + FFN:
Đếm tham số của chính bạn — thử số của GPT-2 nhỏ ($d_{model}=768$, $12$ đầu, $d_{ff}=3.072$):
Đối chiếu công nghiệp: PyTorch nn.MultiheadAttention đóng gói toàn bộ Mục 2-3 — nhưng thực tế
phần lớn code base LLM (nanoGPT, llama.cpp...) tự viết attention thủ công như bài này, vì cần kiểm soát
chi tiết (KV cache, custom mask) mà module đóng gói không cho phép:
# Doi chieu 1-1 voi Muc 2-4 - cai dat: pytorch.org/get-started/locally
import torch.nn as nn
class TransformerBlock(nn.Module):
def __init__(self, d_model, n_heads, d_ff):
super().__init__()
self.attn = nn.MultiheadAttention(d_model, n_heads, batch_first=True) # Muc 2-3
self.ln1 = nn.LayerNorm(d_model) # Muc 4
self.ffn = nn.Sequential(nn.Linear(d_model, d_ff), nn.ReLU(), nn.Linear(d_ff, d_model))
self.ln2 = nn.LayerNorm(d_model)
def forward(self, x, causal_mask):
attn_out, _ = self.attn(x, x, x, attn_mask=causal_mask)
x = self.ln1(x + attn_out) # Add & Norm - Muc 4
x = self.ln2(x + self.ffn(x)) # Add & Norm lan 2
return x
# Thuc te: nanoGPT/llama.cpp tu viet Q/K/V/softmax thu cong (nhu Muc 2-3
# bai nay) de kiem soat KV-cache va custom mask - khong dung nn.MultiheadAttention
Tóm lược
- ✅ Bỏ hẳn recurrence: song song hoá được (không phụ thuộc tuần tự như RNN), gradient không xuyên thời gian — cả 2 verify từ Bài 13.
- ✅ Self-attention Q/K/V tính tay trên ma trận $3\times3$ trước khi tổng quát; scale $1/\sqrt{d_k}$ KHÔNG phải tuỳ chọn — verified: không scale làm gradient softmax chết ($5{,}14\times10^{-10}$) so với có scale khoẻ mạnh ($0{,}161$), do phương sai $QK^T$ tăng đúng theo $d_k$.
- ✅ Multi-head = nhiều góc nhìn song song trong không gian con nhỏ hơn; tổng tham số attention LUÔN $=4d_{model}^2$ bất kể số đầu — verified $1$ block $8.416$ tham số.
- ✅ Positional encoding bắt buộc: verified không có PE thì self-attention permutation-equivariant (đổi vị trí, output không đổi). Residual một mình chưa đủ — verified vẫn bùng nổ ($10^{12}$); chỉ Add & Norm (residual + layer norm) mới ổn định thật sự qua $80$ lớp.
- ✅ Ghép đủ $1$ transformer block decoder-only: self-attention (có mask) → Add&Norm → FFN → Add&Norm — nền tảng của mọi LLM hiện đại.
Tải file code thực hành minh họa bài học
File JavaScript ghép multi-head self-attention + Add&Norm + FFN thành transformer block hoàn chỉnh,
verify scale/positional-encoding/residual bằng số đo được (chạy
node transformer_demo.js, mất dưới $1$ giây):
Bình luận