Nền tảng Mô hình Ngôn ngữ Lớn Học qua Thực hành

bởi David Louapre

Chương 2

Kiến trúc Transformer: Giải phẫu cỗ máy đằng sau mọi LLM

Từ cơ chế Scaled Dot-Product Attention năm 2017 đến kiến trúc Decoder hiện đại với Pre-RMSNorm, RoPE và SwiGLU.

Chương này là bài giảng thứ ba trong chuỗi học liệu tương tác “Nền tảng Mô hình Ngôn ngữ Lớn | Học qua Thực hành” (dựa trên giáo trình cao học MVA tại ENS Paris-Saclay).

Ở Chương 0, chúng ta đã chứng kiến hành trình từ đếm n-gram, mạng truy hồi RNN/LSTM cho đến khoảnh khắc cơ chế chú ý (Attention) xuất hiện để giải cứu bài toán dịch máy. Ở Chương 1, ta đã biến chuỗi văn bản thô thành một dãy các số nguyên (token IDs) nhờ thuật toán tách từ BPE. Bây giờ là lúc mở nắp ca-pô của cỗ máy trung tâm: kiến trúc Transformer.

Triết lý xuyên suốt chương này vẫn là học qua thực hành: thay vì gọi một hàm thư viện đóng gói sẵn như nn.Transformer, bạn sẽ tự tay cài đặt từng viên gạch toán học của một khối Transformer hiện đại (như Llama-3) chỉ bằng các phép nhân ma trận cơ bản trong PyTorch.

Chúc bạn thực hành vui vẻ!

Khi đọc tin tức công nghệ, bạn thấy hàng loạt tên gọi mới xuất hiện mỗi tháng: GPT-4, Llama-3, Claude, Qwen, Gemma, DeepSeek… Có vẻ như thế giới AI đang thay đổi chóng mặt. Nhưng nếu mở mã nguồn của tất cả các mô hình đó ra, bạn sẽ bắt gặp một sự thật đáng kinh ngạc: về mặt kiến trúc cốt lõi, hầu như tất cả chúng đều là cùng một cỗ máy — một chồng các khối Transformer Decoder lặp đi lặp lại.

Vậy chính xác chuyện gì diễn ra bên trong một khối đó? Trong chương này, chúng ta sẽ đi tìm lời giải cho hai câu hỏi cốt lõi:

Tập dữ liệu & Công cụ

Trong chương này, trọng tâm của chúng ta là giải phẫu kiến trúc và kiểm chứng toán học, nên mọi bài tập đều chạy tức thời trên CPU chỉ với thư viện torch (PyTorch) mà không cần tải các tập dữ liệu lớn:

  • PyTorch (torch, torch.nn, torch.nn.functional): dùng để cài đặt trực tiếp các phép biến đổi tensor 3 chiều (B, L, d_model) và 4 chiều (B, H, L, d_head).
  • Các cấu hình mô hình thực tế: chúng ta sẽ đối chiếu trực tiếp mã nguồn của mình với thông số kiến trúc của GPT-2 Small (124M), Llama-3 8B và Llama-3 70B (Dubey & others, 2024).

1. Cơ chế Chú ý Tích vô hướng có Tỷ lệ và Mặt nạ Nhân quả

Hãy nhớ lại điểm dừng ở cuối Chương 0: trong mạng RNN/LSTM, thông tin phải đi tuần tự qua từng từ t−1→tt-1 \to t, khiến GPU không thể tính toán song song trên toàn câu và đạo hàm bị suy giảm khi khoảng cách giữa hai từ quá xa. Tháng 6 năm 2017, nhóm nghiên cứu của Vaswani và cộng sự tại Google đã đặt một câu hỏi táo bạo: Điều gì xảy ra nếu ta vứt bỏ hoàn toàn cấu trúc truy hồi, và chỉ giữ lại cơ chế chú ý? (Vaswani et al., 2017).

1.1 Trực giác truy xuất mềm: Truy vấn (Query), Khóa (Key) và Giá trị (Value)

Hãy xét câu tiếng Việt: “Con mèo ngồi trên chiếu vì nó buồn ngủ”. Khi đọc đến đại từ “nó”, làm thế nào một mạng nơ-ron biết “nó” đang chỉ “con mèo” chứ không phải “chiếu”?

Trong cơ chế Tự chú ý (Self-Attention), mỗi token ở vị trí ii có vector biểu diễn đầu vào xi∈Rdmodelx_i \in \mathbb{R}^{d_{\text{model}}}. Để các từ giao tiếp với nhau, mỗi từ đóng đồng thời ba vai trò khác nhau thông qua ba phép chiếu tuyến tính học được:

  1. Truy vấn (Query — qi=xiWQq_i = x_i W_Q): Vector thể hiện “thông tin mà vị trí ii đang đi tìm” (ví dụ: đại từ “nó” phát ra một truy vấn tìm danh từ chỉ động vật đứng trước).
  2. Khóa (Key — kj=xjWKk_j = x_j W_K): Vector thể hiện “nhãn nhận diện của vị trí jj” để trả lời các truy vấn (từ “mèo” giơ cao tấm biển: “tôi là danh từ chỉ con vật”).
  3. Giá trị (Value — vj=xjWVv_j = x_j W_V): Vector chứa “nội dung ngữ nghĩa mà vị trí jj sẽ truyền đi” nếu được chú ý tới.

Khi xếp toàn bộ LL token của một chuỗi thành ma trận X∈RL×dmodelX \in \mathbb{R}^{L \times d_{\text{model}}}, ta tính được cả ba ma trận cho mọi vị trí cùng một lúc bằng ba phép nhân ma trận:

Q=XWQ,K=XWK,V=XWV,Q = X W_Q, \qquad K = X W_K, \qquad V = X W_V,

trong đó WQ,WK∈Rdmodel×dkW_Q, W_K \in \mathbb{R}^{d_{\text{model}} \times d_k} và WV∈Rdmodel×dvW_V \in \mathbb{R}^{d_{\text{model}} \times d_v}.

1.2 Tại sao phải chia cho dk\sqrt{d_k}? Bài toán phương sai và bão hòa Softmax

Để đo mức độ khớp nhau giữa truy vấn qiq_i và khóa kjk_j, cách tự nhiên nhất là lấy tích vô hướng qi⋅kj=∑r=1dkqi,rkj,rq_i \cdot k_j = \sum_{r=1}^{d_k} q_{i,r} k_{j,r}. Nhưng tại sao công thức của Vaswani et al. (Vaswani et al., 2017) lại bắt buộc phải chia tích vô hướng này cho dk\sqrt{d_k}?

Hãy làm một phép tính xác suất nhỏ. Giả sử các thành phần của qiq_i và kjk_j là các biến ngẫu nhiên độc lập có trung bình bằng 00 và phương sai bằng 11. Khi đó, mỗi tích qi,rkj,rq_{i,r} k_{j,r} có trung bình bằng 00 và phương sai bằng 11. Tổng của dkd_k số hạng độc lập như vậy sẽ có:

E[qi⋅kj]=0,Var(qi⋅kj)=dk.\mathbb{E}[q_i \cdot k_j] = 0, \qquad \mathrm{Var}(q_i \cdot k_j) = d_k.

Nếu dk=64d_k = 64 hoặc 128128, độ lệch chuẩn của tích vô hướng sẽ là dk=8\sqrt{d_k} = 8 hoặc 11.311.3. Điều này có nghĩa là một số phần tử trong tích QK⊤QK^\top sẽ vọt lên +20+20 hoặc tụt xuống −20-20. Khi đưa các con số chênh lệch lớn như vậy vào hàm softmax(z)j=ezj∑nezn\text{softmax}(z)_j = \frac{e^{z_j}}{\sum_{n} e^{z_n}}, phần tử lớn nhất sẽ nuốt trọn xác suất ≈1.0\approx 1.0, còn mọi phần tử khác nhận xác suất ≈0.0\approx 0.0. Trong vùng bão hòa (saturation) này, đạo hàm của hàm Softmax gần như bằng 00, làm đóng băng quá trình học! Chia cho dk\sqrt{d_k} đưa phương sai của điểm số trở lại bằng 11 bất kể số chiều dkd_k lớn đến đâu.

1.3 Mặt nạ Nhân quả (Causal Mask) cho mô hình sinh văn bản tự hồi quy

Trong một mô hình ngôn ngữ tự hồi quy (Decoder-only như GPT hay Llama), nhiệm vụ tại vị trí ii là dự đoán token i+1i+1. Do đó, khi tính biểu diễn tại vị trí ii, mô hình tuyệt đối không được phép nhìn thấy các token tương lai ở vị trí j>ij > i.

Làm thế nào để vừa tính toàn bộ ma trận QK⊤∈RL×LQK^\top \in \mathbb{R}^{L \times L} song song trong một bước duy nhất trên GPU, vừa ngăn thông tin rò rỉ từ tương lai về quá khứ? Ta cộng thêm một ma trận mặt nạ nhân quả (Causal Mask) M∈RL×LM \in \mathbb{R}^{L \times L} trước khi lấy hàm Softmax:

Attention(Q,K,V)=softmax ⁣(QK⊤dk+M)V,với Mi,j={0neˆˊu j≤i−∞neˆˊu j>i.\mathrm{Attention}(Q, K, V) = \mathrm{softmax}\!\left(\frac{Q K^\top}{\sqrt{d_k}} + M\right) V, \qquad \text{với } M_{i,j} = \begin{cases} 0 & \text{nếu } j \le i \\ -\infty & \text{nếu } j > i. \end{cases}

Vì e−∞=0e^{-\infty} = 0, sau khi đi qua hàm Softmax theo từng hàng, mọi trọng số chú ý αi,j\alpha_{i,j} ở vùng tam giác trên (j>ij > i) đều bằng 00 tuyệt đối, còn các trọng số hợp lệ ở quá khứ và hiện tại (j≤ij \le i) tự động được chuẩn hóa sao cho tổng bằng 11.

Mô phỏng tương tác: Scaled Dot-Product Attention & Mặt nạ Nhân quả
Khám phá ma trận điểm trước Softmax (trái) và trọng số chú ý sau Softmax (phải) trên một câu tiếng Việt. Hãy thử tắt nút 'Chia √d_k' để quan sát hiện tượng bão hòa Softmax (một từ chiếm gần 100% trọng số, entropy tụt giảm), hoặc tắt 'Causal Mask' để so sánh Attention một chiều (Decoder) với Attention hai chiều (Encoder).

Hãy kiểm chứng trực tiếp cơ chế này bằng PyTorch trong Bài tập 2.1.

Bài tập 2.1 Cài đặt Causal Self-Attention và kiểm chứng tính nhân quả
Dữ liệu: PyTorch Tensors · Thời gian chạy: < 1 phút

Viết hàm causal_attention(q, k, v) nhận vào ba tensor Q,K,VQ, K, V có kích thước (B, L, d_k) và trả về cặp (out, weights) theo đúng phương trình Scaled Dot-Product Causal Attention.

Sau đó, hãy thực hiện hai phép kiểm tra quan trọng:

  1. Kiểm tra tổng trọng số trên mỗi hàng của weights bằng 1.0, và tổng trọng số ở vùng tam giác trên (j>ij > i) bằng 0.0.
  2. Kiểm chứng tính nhân quả (Causality test): Tạo bản sao Q2, K2, V2 và thay toàn bộ các vector ở vị trí tương lai t∈{4,5}t \in \{4, 5\} bằng số ngẫu nhiên mới. Chạy lại hàm và chứng minh rằng các vector đầu ra ở vị trí quá khứ k∈{0,1,2}k \in \{0, 1, 2\} không thay đổi dù chỉ một bit (sai khác = 0.00e+00)!
Mã khởi tạo
import math
import torch
import torch.nn.functional as F

torch.manual_seed(42)
B, L, d_k = 2, 6, 16
Q = torch.randn(B, L, d_k)
K = torch.randn(B, L, d_k)
V = torch.randn(B, L, d_k)
print("Kích thước Q, K, V:", tuple(Q.shape))
Kích thước Q, K, V: (2, 6, 16)
Hint

Dùng q @ k.transpose(-2, -1) để nhân ma trận trên hai chiều cuối cùng, trả về kích thước (B, L, L), rồi chia cho math.sqrt(q.size(-1)).

Hint

Tạo mặt nạ tam giác trên bằng mask = torch.triu(torch.ones(L, L, dtype=torch.bool, device=q.device), diagonal=1). Dùng scores.masked_fill(mask, float("-inf")) trước khi gọi F.softmax(scores, dim=-1).

Điểm kiểm tra kết quả bạn cần đạt được

Đầu ra out giữ nguyên kích thước (2, 6, 16). Khi sửa đổi các token ở vị trí 4 và 5, sai số lớn nhất tại các vị trí k≤2k \le 2 bằng đúng 0.00e+00, trong khi đầu ra tại các vị trí k≥4k \ge 4 thay đổi rõ rệt.

Kích thước đầu ra out: (2, 6, 16)
Tổng trọng số mỗi hàng: [1.0, 1.0, 1.0, 1.0, 1.0, 1.0]
Tổng trọng số tam giác trên (j > i): 0.000000
Sai khác tại vị trí k <= 2 khi sửa token 4, 5: 0.00e+00
Sai khác tại vị trí k > 2 khi sửa token 4, 5:  6.41e-01
Lời giải 2 ô mã

Chạy các ô mã theo đúng thứ tự này trong một notebook mới: chúng đã bao gồm các lệnh import, bước tải dữ liệu và khởi tạo hạt giống ngẫu nhiên (random seed).

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

torch.manual_seed(42)
B, L, d_k = 2, 6, 16
Q = torch.randn(B, L, d_k)
K = torch.randn(B, L, d_k)
V = torch.randn(B, L, d_k)
print("Kích thước Q, K, V:", tuple(Q.shape))
Kích thước Q, K, V: (2, 6, 16)
# (1) Hàm tính Scaled Dot-Product Causal Self-Attention
def causal_attention(q, k, v):
    L_seq = q.size(-2)
    scores = (q @ k.transpose(-2, -1)) / math.sqrt(q.size(-1))
    mask = torch.triu(
        torch.ones(L_seq, L_seq, dtype=torch.bool, device=q.device),
        diagonal=1,
    )
    scores = scores.masked_fill(mask, float("-inf"))
    weights = F.softmax(scores, dim=-1)
    return weights @ v, weights

out1, w1 = causal_attention(Q, K, V)
row_sums = [round(x, 4) for x in w1[0].sum(dim=-1).tolist()]
upper_sum = torch.triu(w1, diagonal=1).sum().item()

print("Kích thước đầu ra out:", tuple(out1.shape))
print("Tổng trọng số mỗi hàng:", row_sums)
print(f"Tổng trọng số tam giác trên (j > i): {upper_sum:.6f}")

# (2) Kiểm chứng tính nhân quả: thay đổi các token ở vị trí t >= 4
Q2, K2, V2 = Q.clone(), K.clone(), V.clone()
Q2[:, 4:] = torch.randn_like(Q2[:, 4:])
K2[:, 4:] = torch.randn_like(K2[:, 4:])
V2[:, 4:] = torch.randn_like(V2[:, 4:])

out2, _ = causal_attention(Q2, K2, V2)
diff_past = (out1[:, :3] - out2[:, :3]).abs().max().item()
diff_future = (out1[:, 4:] - out2[:, 4:]).abs().max().item()
print(f"Sai khác tại vị trí k <= 2 khi sửa token 4, 5: {diff_past:.2e}")
print(f"Sai khác tại vị trí k > 2 khi sửa token 4, 5:  {diff_future:.2e}")
Kích thước đầu ra out: (2, 6, 16)
Tổng trọng số mỗi hàng: [1.0, 1.0, 1.0, 1.0, 1.0, 1.0]
Tổng trọng số tam giác trên (j > i): 0.000000
Sai khác tại vị trí k <= 2 khi sửa token 4, 5: 0.00e+00
Sai khác tại vị trí k > 2 khi sửa token 4, 5:  6.41e-01
Điều cần lưu ý

Phép thử nhân quả vừa rồi giải thích tại sao Transformer có thể huấn luyện dự đoán toàn bộ LL vị trí trong câu cùng một lúc mà không hề gian lận! Nhờ mặt nạ tam giác trên −∞-\infty, phép tính tại hàng thứ kk của ma trận hoàn toàn độc lập với mọi hàng và cột đứng sau kk. Khi huấn luyện, GPU thực hiện một phép nhân ma trận lớn cho cả câu; còn khi sinh văn bản từng từ lúc suy luận, kết quả tại bước kk khớp chính xác tuyệt đối với lúc huấn luyện.


2. Chú ý Đa đầu (Multi-Head Attention) và Luồng Thặng dư

Một phép tự chú ý đơn lẻ (Single-Head Attention) buộc mỗi token phải gộp mọi mối quan hệ ngữ pháp, ngữ nghĩa, quy chiếu vào chung một bộ trọng số trung bình αi,1v1+⋯+αi,ivi\alpha_{i,1} v_1 + \dots + \alpha_{i,i} v_i. Nhưng trong ngôn ngữ tự nhiên, tại cùng một thời điểm, một từ cần chú ý đến nhiều khía cạnh khác nhau: đầu chú ý này tìm chủ ngữ của động từ, đầu chú ý kia nối tính từ với danh từ, đầu thứ ba theo dõi dấu câu.

2.1 Tách không gian thành HH đầu chú ý song song

Thay vì thực hiện một phép chú ý duy nhất trên không gian dmodeld_{\text{model}} chiều, Vaswani et al. (Vaswani et al., 2017) chia dmodeld_{\text{model}} thành HH không gian con (subspaces) độc lập, mỗi không gian con có số chiều:

dhead=dmodelH.d_{\text{head}} = \frac{d_{\text{model}}}{H}.

Với mỗi đầu h∈{1,…,H}h \in \{1, \dots, H\}, ta có bộ ma trận chiếu riêng WQ(h),WK(h),WV(h)∈Rdmodel×dheadW_Q^{(h)}, W_K^{(h)}, W_V^{(h)} \in \mathbb{R}^{d_{\text{model}} \times d_{\text{head}}} để tính:

headh=Attention ⁣(XWQ(h), XWK(h), XWV(h))∈RL×dhead.\mathrm{head}_h = \mathrm{Attention}\!\left(X W_Q^{(h)},\, X W_K^{(h)},\, X W_V^{(h)}\right) \in \mathbb{R}^{L \times d_{\text{head}}}.

Sau đó, đầu ra của cả HH đầu được ghép nối (concatenate) lại dọc theo chiều đặc trưng và nhân với ma trận chiếu đầu ra WO∈Rdmodel×dmodelW_O \in \mathbb{R}^{d_{\text{model}} \times d_{\text{model}}}:

MHA(X)=Concat(head1,…,headH)WO.\mathrm{MHA}(X) = \mathrm{Concat}(\mathrm{head}_1, \dots, \mathrm{head}_H) W_O.

2.2 Góc nhìn Mạch Cơ học (Mechanistic Interpretability): Luồng Thặng dư là một Kênh Giao tiếp Chung

Năm 2021, nhóm nghiên cứu của Nelson Elhage và Chris Olah tại Anthropic đã đề xuất một cách viết lại phương trình Multi-Head Attention giúp làm sáng tỏ bản chất hoạt động của Transformer (Elhage & others, 2021).

Nếu ta tách ma trận đầu ra WO∈R(H⋅dhead)×dmodelW_O \in \mathbb{R}^{(H \cdot d_{\text{head}}) \times d_{\text{model}}} thành HH khối hàng WO(h)∈Rdhead×dmodelW_O^{(h)} \in \mathbb{R}^{d_{\text{head}} \times d_{\text{model}}}, thì phép ghép nối rồi nhân WOW_O hoàn toàn tương đương với tổng của HH đầu chú ý độc lập:

Concat(head1,…,headH)WO=∑h=1Hsoftmax ⁣(XWQ(h)(WK(h))⊤X⊤dhead+M)⏟Mạch QK (Đọc từ đaˆu?)XWV(h)WO(h)⏟Mạch OV (Ghi thoˆng tin gıˋ?).\mathrm{Concat}(\mathrm{head}_1, \dots, \mathrm{head}_H) W_O = \sum_{h=1}^{H} \underbrace{\mathrm{softmax}\!\left(\frac{X W_Q^{(h)} (W_K^{(h)})^\top X^\top}{\sqrt{d_{\text{head}}}} + M\right)}_{\text{Mạch QK (Đọc từ đâu?)}} \underbrace{X W_V^{(h)} W_O^{(h)}}_{\text{Mạch OV (Ghi thông tin gì?)}}.

Kết hợp với nhánh cộng thặng dư xℓ=xℓ−1+MHA(Norm(xℓ−1))x_\ell = x_{\ell-1} + \mathrm{MHA}(\mathrm{Norm}(x_{\ell-1})), ta nhận ra:

Bài tập 2.2 Cài đặt Causal Multi-Head Attention với Tensor 4 chiều
Dữ liệu: PyTorch Tensors · Thời gian chạy: < 1 phút

Xây dựng lớp CausalMultiHeadAttention(d_model, n_heads) kế thừa từ nn.Module (không dùng bias trong các lớp nn.Linear, theo đúng chuẩn Llama hiện đại).

Trong hàm forward(self, x):

  1. Chiếu x có kích thước (B, L, d_model) qua w_q, w_k, w_v và biến đổi hình dạng thành (B, n_heads, L, d_head).
  2. Tính ma trận chú ý nhân quả attn kích thước (B, n_heads, L, L).
  3. Nhân attn @ v, hoán vị chiều ngược lại về (B, L, d_model) và chiếu qua w_o.
  4. Kiểm tra tổng số tham số của lớp bằng đúng 4dmodel24 d_{\text{model}}^2.
Mã khởi tạo
import math
import torch
import torch.nn as nn
import torch.nn.functional as F

torch.manual_seed(7)
B, L, d_model, n_heads = 2, 8, 64, 4
x = torch.randn(B, L, d_model)
print(f"Đầu vào x: {tuple(x.shape)}, d_head = {d_model // n_heads}")
Đầu vào x: (2, 8, 64), d_head = 16
Hint

Sau khi gọi self.w_q(x), dùng .view(B, L, self.n_heads, self.d_head).transpose(1, 2) để đổi thứ tự chiều từ (B, L, H, d_head) sang (B, H, L, d_head).

Hint

Sau khi tính attn @ v (có kích thước (B, H, L, d_head)), đừng quên gọi .transpose(1, 2).contiguous().view(B, L, D) trước khi đưa vào self.w_o.

Điểm kiểm tra kết quả bạn cần đạt được

Với d_model = 64 và n_heads = 4, mỗi head làm việc trên vector d_head = 16 chiều. Tổng số tham số của cả 4 đầu chú ý vẫn bằng đúng 4×642=16,3844 \times 64^2 = 16{,}384 — không tốn thêm một tham số hay phép nhân nào so với Attention 1 đầu có kích thước 64!

Kích thước Q sau khi tách head: (2, 4, 8, 16)
Kích thước bản đồ chú ý attn:   (2, 4, 8, 8)
Kích thước đầu ra MHA:          (2, 8, 64)
Tổng số tham số (4 * d_model^2): 16384
Khoảng cách trung bình giữa các head: 0.2841
Lời giải 2 ô mã

Chạy các ô mã theo đúng thứ tự này trong một notebook mới: chúng đã bao gồm các lệnh import, bước tải dữ liệu và khởi tạo hạt giống ngẫu nhiên (random seed).

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

torch.manual_seed(7)
B, L, d_model, n_heads = 2, 8, 64, 4
x = torch.randn(B, L, d_model)
print(f"Đầu vào x: {tuple(x.shape)}, d_head = {d_model // n_heads}")
Đầu vào x: (2, 8, 64), d_head = 16
class CausalMultiHeadAttention(nn.Module):
    def __init__(self, d_model, n_heads):
        super().__init__()
        assert d_model % n_heads == 0
        self.n_heads = n_heads
        self.d_head = d_model // n_heads
        self.w_q = nn.Linear(d_model, d_model, bias=False)
        self.w_k = nn.Linear(d_model, d_model, bias=False)
        self.w_v = nn.Linear(d_model, d_model, bias=False)
        self.w_o = nn.Linear(d_model, d_model, bias=False)

    def forward(self, x):
        B, L, D = x.shape
        q = self.w_q(x).view(B, L, self.n_heads, self.d_head).transpose(1, 2)
        k = self.w_k(x).view(B, L, self.n_heads, self.d_head).transpose(1, 2)
        v = self.w_v(x).view(B, L, self.n_heads, self.d_head).transpose(1, 2)

        scores = (q @ k.transpose(-2, -1)) / math.sqrt(self.d_head)
        mask = torch.triu(
            torch.ones(L, L, dtype=torch.bool, device=x.device), diagonal=1
        )
        attn = F.softmax(scores.masked_fill(mask, float("-inf")), dim=-1)

        out = (attn @ v).transpose(1, 2).contiguous().view(B, L, D)
        return self.w_o(out), attn, q

mha = CausalMultiHeadAttention(d_model, n_heads)
out, attn, q = mha(x)
n_params = sum(p.numel() for p in mha.parameters())
head_diff = (attn[0, 0] - attn[0, 1]).abs().mean().item()

print("Kích thước Q sau khi tách head:", tuple(q.shape))
print("Kích thước bản đồ chú ý attn:  ", tuple(attn.shape))
print("Kích thước đầu ra MHA:         ", tuple(out.shape))
print("Tổng số tham số (4 * d_model^2):", n_params)
print(f"Khoảng cách trung bình giữa các head: {head_diff:.4f}")
Kích thước Q sau khi tách head: (2, 4, 8, 16)
Kích thước bản đồ chú ý attn:   (2, 4, 8, 8)
Kích thước đầu ra MHA:          (2, 8, 64)
Tổng số tham số (4 * d_model^2): 16384
Khoảng cách trung bình giữa các head: 0.2841
Điều cần lưu ý

Hãy chú ý con số 16384 tham số: việc chia 64 chiều thành 4 đầu 16 chiều hoàn toàn miễn phí về mặt số lượng tham số và FLOPs, nhưng lại cho phép mô hình tạo ra H=4H = 4 bản đồ chú ý (L, L) hoàn toàn khác nhau trong cùng một tầng (khoảng cách trung bình giữa head 0 và head 1 là 0.2841). Đó là lý do mọi mô hình Transformer từ năm 2017 đến nay đều dùng Multi-Head Attention.


3. Mã hóa Vị trí: Từ Sinusoidal đến Rotary Position Embedding (RoPE)

Hãy nhìn lại phương trình Self-Attention: nếu không có mặt nạ nhân quả và ta tráo đổi vị trí của hai từ trong câu đầu vào, các vector đầu ra cũng chỉ đổi chỗ tương ứng mà giá trị không hề thay đổi (tính chất đồng biến với phép hoán vị — permutation equivariance). Nói cách khác, bản thân phép chú ý không hề có khái niệm về thứ tự từ!

3.1 Hạn chế của Mã hóa Vị trí Tuyệt đối (Absolute Positional Encoding)

Trong bài báo gốc năm 2017 (Vaswani et al., 2017) và trong GPT-2, thông tin vị trí được đưa vào bằng cách cộng một vector vị trí tuyệt đối pm∈Rdmodelp_m \in \mathbb{R}^{d_{\text{model}}} vào vector nhúng của token ở vị trí mm ngay tại tầng đầu vào: xm=em+pmx_m = e_m + p_m.

Cách tiếp cận cộng trực tiếp này có hai nhược điểm lớn:

  1. Nhiễm bẩn ngữ nghĩa trên luồng thặng dư: Vector vị trí pmp_m bị trộn lẫn với vector ngữ nghĩa eme_m và bị biến đổi qua nhiều tầng phi tuyến.
  2. Kém linh hoạt với khoảng cách tương đối: Trong ngôn ngữ, điều quan trọng nhất giữa hai từ không phải là chúng đứng ở từ thứ 55 và từ thứ 22 kể từ đầu văn bản, mà là chúng cách nhau m−n=3m - n = 3 từ!

Làm thế nào để thiết kế một phép biến đổi vị trí f(q,m)f(q, m) và f(k,n)f(k, n) sao cho tích vô hướng chú ý ⟨f(q,m),f(k,n)⟩\langle f(q, m), f(k, n) \rangle chỉ phụ thuộc vào vector q,kq, k và hiệu vị trí tương đối m−nm - n?

3.2 Phép quay vị trí RoPE trên mặt phẳng phức

Năm 2021, Jianlin Su và cộng sự đã giải bài toán này một cách tuyệt đẹp bằng hình học thông qua Rotary Position Embedding (RoPE) (Su & others, 2024).

Xét trước hết trường hợp vector q,k∈R2q, k \in \mathbb{R}^2 có 2 chiều. Nếu tại vị trí mm, ta quay vector qq một góc mθm\theta trên mặt phẳng 2D bằng ma trận quay trực giao RmθR_{m\theta}:

Rmθ=(cos⁡(mθ)−sin⁡(mθ)sin⁡(mθ)cos⁡(mθ)),R_{m\theta} = \begin{pmatrix} \cos(m\theta) & -\sin(m\theta) \\ \sin(m\theta) & \cos(m\theta) \end{pmatrix},

thì khi tính tích vô hướng giữa RmθqR_{m\theta} q (ở vị trí mm) và RnθkR_{n\theta} k (ở vị trí nn), nhờ tính chất của ma trận quay Rmθ⊤Rnθ=R(n−m)θR_{m\theta}^\top R_{n\theta} = R_{(n-m)\theta}, ta có ngay:

⟨Rmθq, Rnθk⟩=q⊤Rmθ⊤Rnθk=q⊤R(n−m)θk=⟨R(m−n)θq, k⟩.\langle R_{m\theta} q,\, R_{n\theta} k \rangle = q^\top R_{m\theta}^\top R_{n\theta} k = q^\top R_{(n-m)\theta} k = \langle R_{(m-n)\theta} q,\, k \rangle.

Tích vô hướng này chỉ phụ thuộc vào góc lệch tương đối (m−n)θ(m - n)\theta! Dù hai từ nằm ở vị trí (5,2)(5, 2) hay bị dịch đi 10001000 token tới vị trí (1005,1002)(1005, 1002), góc giữa hai vector sau khi quay vẫn giữ nguyên không đổi!

Với vector nhiều chiều dheadd_{\text{head}} (là một số chẵn), RoPE đơn giản chia dheadd_{\text{head}} tọa độ thành dhead/2d_{\text{head}}/2 cặp 2 chiều độc lập (x2i,x2i+1)(x_{2i}, x_{2i+1}), và quay mỗi cặp i∈{0,1,…,dhead/2−1}i \in \{0, 1, \dots, d_{\text{head}}/2 - 1\} với tốc độ góc (tần số) giảm dần theo cấp số nhân:

θi=base−2i/dhead(thường chọn base=10,000 hoặc 500,000 trong Llama-3).\theta_i = \text{base}^{-2i / d_{\text{head}}} \qquad (\text{thường chọn } \text{base} = 10{,}000 \text{ hoặc } 500{,}000 \text{ trong Llama-3}).
Trực quan hóa Phép quay Vị trí Tương đối RoPE trên Mặt phẳng 2D
Kéo thanh trượt vị trí Query m và Key n để quan sát cách các cặp tọa độ quay với tần số θ_i khác nhau. Hãy nhấn nút 'Dịch cả hai vị trí (m+1, n+1)': cả hai mũi tên đều quay sang góc mới trên đường tròn, nhưng góc lệch tương đối Δφ và tổng tích vô hướng ⟨R_m q, R_n k⟩ hoàn toàn bất biến!
Đọc thêm Các giải pháp thay thế RoPE: ALiBi (Attention with Linear Biases)

Ngoài RoPE, một hướng tiếp cận thanh lịch khác là ALiBi của Press, Smith & Lewis (2021) (Press & Lewis, 2021): không hề quay hay cộng bất kỳ vector vị trí nào vào Q,KQ, K, mà chỉ trừ thẳng một hình phạt tuyến tính tỷ lệ với khoảng cách m⋅∣i−j∣m \cdot |i - j| vào điểm chú ý trước Softmax:

softmax ⁣(qikj⊤dk−m⋅(i−j)),\mathrm{softmax}\!\left(\frac{q_i k_j^\top}{\sqrt{d_k}} - m \cdot (i - j)\right),

trong đó hệ số góc mm cố định theo từng head. Mặc dù ALiBi giúp ngoại suy độ dài khá tốt, RoPE vẫn chiến thắng trong hầu hết các mô hình thế hệ mới vì khả năng biểu diễn quan hệ vị trí phong phú hơn (không bị ép phải giảm đơn điệu theo khoảng cách).

Hãy cài đặt trực tiếp phép quay RoPE và kiểm nghiệm tính bất biến dịch chuyển bằng thực nghiệm trong Bài tập 2.3.

Bài tập 2.3 Cài đặt RoPE và kiểm chứng bất biến dịch chuyển tương đối
Dữ liệu: PyTorch Tensors · Thời gian chạy: < 1 phút

Viết hàm apply_rope_1d(vec, pos, base=10000.0) nhận vào một vector vec có số chiều chẵn d_head = 16 và vị trí nguyên pos:

  1. Tính vector tần số θi=10000−2i/d\theta_i = 10000^{-2i/d} với 2i∈{0,2,…,d−2}2i \in \{0, 2, \dots, d-2\}.
  2. Tách vec thành các tọa độ chẵn x_even = vec[..., 0::2] và tọa độ lẻ x_odd = vec[..., 1::2], áp dụng phép quay 2D theo góc pos * theta, rồi ghép xen kẽ trở lại.
  3. Kiểm chứng rằng phép quay RoPE bảo toàn chuẩn độ dài L2 của vector (∥Rmq∥2=∥q∥2\|R_m q\|_2 = \|q\|_2), và điểm chú ý ⟨Rm+Δq,Rn+Δk⟩\langle R_{m+\Delta} q, R_{n+\Delta} k \rangle luôn bằng ⟨Rmq,Rnk⟩\langle R_m q, R_n k \rangle với mọi độ dịch Δ∈{1,…,20}\Delta \in \{1, \dots, 20\}.
Mã khởi tạo
import torch

torch.manual_seed(123)
d_head = 16
q = torch.randn(d_head)
k = torch.randn(d_head)
print("Vector q và k (16 chiều), chuẩn L2:",
      round(q.norm().item(), 4), round(k.norm().item(), 4))
Vector q và k (16 chiều), chuẩn L2: 3.7483 3.9112
Hint

Tạo chỉ số chẵn bằng i = torch.arange(0, d, 2, dtype=vec.dtype) rồi tính theta = base ** (-i / d) và angles = pos * theta.

Hint

Sau khi tính rot_even = x_even * cos - x_odd * sin và rot_odd = x_even * sin + x_odd * cos, dùng torch.stack([rot_even, rot_odd], dim=-1).flatten(-2) để ghép các cặp (chẵn, lẻ) liền kề nhau trở lại chiều ban đầu.

Điểm kiểm tra kết quả bạn cần đạt được

Chuẩn L2 của q trước và sau khi quay ở vị trí m=5m=5 đều bằng 3.7483. Tích vô hướng tại (m=5,n=2)(m=5, n=2) và tại (m=17,n=14)(m=17, n=14) trùng khớp tới chữ số thập phân thứ sáu (2.184937), với sai số dấu phẩy động cực đại chỉ ở mức 9.54e-07!

Chuẩn L2 của q sau khi quay ở m=5: 3.7483 (sai khác 0.00e+00)
Điểm chú ý <R_5 q, R_2 k>   (m-n = 3):  2.184937
Điểm chú ý <R_17 q, R_14 k> (m-n = 3):  2.184937
Sai số cực đại khi dịch chuyển Delta in [1..20]: 9.54e-07
Lời giải 2 ô mã

Chạy các ô mã theo đúng thứ tự này trong một notebook mới: chúng đã bao gồm các lệnh import, bước tải dữ liệu và khởi tạo hạt giống ngẫu nhiên (random seed).

import torch

torch.manual_seed(123)
d_head = 16
q = torch.randn(d_head)
k = torch.randn(d_head)
print("Vector q và k (16 chiều), chuẩn L2:",
      round(q.norm().item(), 4), round(k.norm().item(), 4))
Vector q và k (16 chiều), chuẩn L2: 3.7483 3.9112
# (1) Cài đặt phép quay vị trí phức RoPE (Rotary Position Embedding)
def apply_rope_1d(vec, pos, base=10000.0):
    d = vec.shape[-1]
    i = torch.arange(0, d, 2, dtype=vec.dtype, device=vec.device)
    theta = base ** (-i / d)
    angles = pos * theta
    cos_a, sin_a = torch.cos(angles), torch.sin(angles)

    x_even = vec[..., 0::2]
    x_odd = vec[..., 1::2]
    rot_even = x_even * cos_a - x_odd * sin_a
    rot_odd = x_even * sin_a + x_odd * cos_a
    return torch.stack([rot_even, rot_odd], dim=-1).flatten(-2)

q_rot5 = apply_rope_1d(q, pos=5)
norm_diff = (q.norm() - q_rot5.norm()).abs().item()
print(f"Chuẩn L2 của q sau khi quay ở m=5: {q_rot5.norm():.4f} "
      f"(sai khác {norm_diff:.2e})")

# (2) Kiểm chứng bất biến dịch chuyển <R_{m+d} q, R_{n+d} k> = <R_m q, R_n k>
m, n = 5, 2
base_score = torch.dot(apply_rope_1d(q, m), apply_rope_1d(k, n)).item()
shifted_score = torch.dot(
    apply_rope_1d(q, m + 12), apply_rope_1d(k, n + 12)
).item()

max_err = max(
    abs(torch.dot(apply_rope_1d(q, m + d),
                  apply_rope_1d(k, n + d)).item() - base_score)
    for d in range(1, 21)
)
print(f"Điểm chú ý <R_5 q, R_2 k>   (m-n = 3):  {base_score:.6f}")
print(f"Điểm chú ý <R_17 q, R_14 k> (m-n = 3):  {shifted_score:.6f}")
print(f"Sai số cực đại khi dịch chuyển Delta in [1..20]: {max_err:.2e}")
Chuẩn L2 của q sau khi quay ở m=5: 3.7483 (sai khác 0.00e+00)
Điểm chú ý <R_5 q, R_2 k>   (m-n = 3):  2.184937
Điểm chú ý <R_17 q, R_14 k> (m-n = 3):  2.184937
Sai số cực đại khi dịch chuyển Delta in [1..20]: 9.54e-07
Điều cần lưu ý

Chú ý hai tính chất toán học quý giá bạn vừa chứng minh bằng code:

  1. Bảo toàn năng lượng (Isometry): Khác với việc cộng thêm một vector vị trí làm thay đổi độ lớn của xx, phép nhân với ma trận trực giao RmR_m giữ nguyên độ dài ∥Rmq∥2=∥q∥2\|R_m q\|_2 = \|q\|_2, giúp tích vô hướng chú ý không bị bùng nổ.
  2. Chỉ tác động lên QQ và KK ở mỗi tầng: Trong kiến trúc hiện đại, RoPE được áp dụng trực tiếp lên QQ và KK ngay sau phép chiếu tuyến tính ở mọi tầng Transformer, trong khi vector VV không cần quay.

4. Mạng Feed-Forward, Chuẩn hóa và Công thức Llama Hiện đại

Nếu Self-Attention là nơi các token trao đổi thông tin với nhau across positions, thì nửa còn lại của mỗi khối Transformer — mạng Feed-Forward (FFN / MLP) — lại tác động độc lập lên từng token một để xử lý thông tin vừa thu thập được và tra cứu tri thức lưu trong trọng số.

Từ kiến trúc Transformer gốc năm 2017 (Vaswani et al., 2017) đến kiến trúc chuẩn của dòng mô hình LLaMA (Touvron & others, 2023) và Llama-3 (Dubey & others, 2024), cộng đồng nghiên cứu đã thực hiện ba cải tiến quyết định về chuẩn hóa và mạng FFN.

4.1 Từ Post-LayerNorm sang Pre-RMSNorm: Đường cao tốc cho Đạo hàm

Trong bài báo gốc năm 2017, lớp chuẩn hóa LayerNorm (Ba & Hinton, 2016) được đặt sau phép cộng thặng dư (Post-LN):

xℓPost-LN=LayerNorm ⁣(xℓ−1+Fℓ(xℓ−1)).x_\ell^{\text{Post-LN}} = \mathrm{LayerNorm}\!\left(x_{\ell-1} + F_\ell(x_{\ell-1})\right).

Năm 2020, Xiong và cộng sự (Xiong & others, 2020) đã chứng minh bằng giải tích rằng thiết kế Post-LN khiến đạo hàm truyền ngược qua mỗi lớp LayerNorm bị chia nhỏ theo hệ số 11+σ2\frac{1}{\sqrt{1 + \sigma^2}}, làm đạo hàm ở các tầng đầu tiên suy giảm theo hàm mũ theo độ sâu LL, còn đạo hàm ở tầng cuối lại quá lớn. Đó là lý do Transformer gốc bắt buộc phải có giai đoạn khởi động tốc độ học (learning-rate warmup) rất nhạy cảm, nếu không mô hình sẽ phân kỳ ngay từ những bước đầu.

Giải pháp hiện đại là chuyển lớp chuẩn hóa lên trước nhánh con (Pre-Norm), giữ cho luồng thặng dư hoàn toàn thông suốt từ đầu đến cuối:

xℓPre-Norm=xℓ−1+Fℓ(Norm(xℓ−1)).x_\ell^{\text{Pre-Norm}} = x_{\ell-1} + F_\ell\big(\mathrm{Norm}(x_{\ell-1})\big).

Khi lấy đạo hàm từ tầng cuối xL=x0+∑ℓ=1LFℓ(Norm(xℓ−1))x_L = x_0 + \sum_{\ell=1}^L F_\ell(\mathrm{Norm}(x_{\ell-1})) về bất kỳ tầng ℓ\ell nào, luôn có số hạng đạo hàm đơn vị II truyền thẳng không bị cản trở!

Hơn nữa, năm 2019, Biao Zhang và Rico Sennrich (Zhang & Sennrich, 2019) nhận thấy rằng phép trừ trung bình μ\mu trong LayerNorm không đóng góp gì cho hiệu năng của Transformer mà chỉ tốn thêm phép tính. Họ đề xuất RMSNorm (Root Mean Square Layer Normalization) — chỉ chuẩn hóa theo căn bậc hai của trung bình bình phương:

RMSNorm(x)=x1d∑i=1dxi2+ε⊙γ,\mathrm{RMSNorm}(x) = \frac{x}{\sqrt{\frac{1}{d}\sum_{i=1}^{d} x_i^2 + \varepsilon}} \odot \gamma,

trong đó γ∈Rd\gamma \in \mathbb{R}^d là vector hệ số tỷ lệ học được (không có hệ số dịch β\beta).

4.2 Từ ReLU và GELU sang Cổng Tuyến tính SwiGLU

Trong Transformer năm 2017, mạng FFN gồm hai phép chiếu tuyến tính kẹp giữa một hàm kích hoạt ReLU (hoặc GELU (Hendrycks & Gimpel, 2016) trong GPT-2):

FFNReLU(x)=max⁡(0, xW1+b1)W2+b2,với dff=4dmodel.\mathrm{FFN}_{\text{ReLU}}(x) = \max(0,\, x W_1 + b_1) W_2 + b_2, \qquad \text{với } d_{\text{ff}} = 4 d_{\text{model}}.

Năm 2020, Noam Shazeer (một trong các đồng tác giả của Transformer gốc) đã thử nghiệm thay thế hàm kích hoạt đơn lẻ bằng cơ chế Đơn vị Tuyến tính có Cổng (Gated Linear Units — GLU), cụ thể là biến thể SwiGLU sử dụng hàm SiLU(z)=z⋅σ(z)\mathrm{SiLU}(z) = z \cdot \sigma(z) (còn gọi là hàm Swish) (Shazeer, 2020):

SwiGLU(x)=(SiLU(xWgate)⊙(xWup))Wdown,\mathrm{SwiGLU}(x) = \Big(\mathrm{SiLU}(x W_{\text{gate}}) \odot (x W_{\text{up}})\Big) W_{\text{down}},

trong đó ⊙\odot là phép nhân từng phần tử (element-wise product), Wgate,Wup∈Rdmodel×dffW_{\text{gate}}, W_{\text{up}} \in \mathbb{R}^{d_{\text{model}} \times d_{\text{ff}}} và Wdown∈Rdff×dmodelW_{\text{down}} \in \mathbb{R}^{d_{\text{ff}} \times d_{\text{model}}}.

Tại sao SwiGLU cần tới 3 ma trận (Wgate,Wup,WdownW_{\text{gate}}, W_{\text{up}}, W_{\text{down}}) thay vì 2 ma trận? Vì nhánh xWgatex W_{\text{gate}} đi qua hàm kích hoạt mượt SiLU\mathrm{SiLU} đóng vai trò như một cánh cổng mềm quyết định cho bao nhiêu thông tin từ nhánh tuyến tính xWupx W_{\text{up}} đi qua, tạo thành tích bậc hai giữa các đặc trưng đầu vào. Để tổng số tham số 3⋅dmodel⋅dff3 \cdot d_{\text{model}} \cdot d_{\text{ff}} xấp xỉ bằng 2⋅dmodel⋅(4dmodel)=8dmodel22 \cdot d_{\text{model}} \cdot (4 d_{\text{model}}) = 8 d_{\text{model}}^2 của FFN cũ, chiều trung gian dffd_{\text{ff}} trong SwiGLU thường được chọn bằng:

dff≈83dmodel≈2.67 dmodel(laˋm troˋn thaˋnh bội soˆˊ của 256 để toˆˊi ưu nhaˆn Tensor Core treˆn GPU).d_{\text{ff}} \approx \frac{8}{3} d_{\text{model}} \approx 2.67\, d_{\text{model}} \qquad (\text{làm tròn thành bội số của } 256 \text{ để tối ưu nhân Tensor Core trên GPU}).
Tương tác 2 Tab: Hàm kích hoạt (ReLU, GELU, SiLU, SwiGLU) & Luồng Đạo hàm (Pre-RMSNorm vs Post-LN)
Ở Tab 1, chuyển đổi giữa đồ thị hàm số f(x) và đạo hàm f'(x) để thấy vùng đạo hàm âm mượt của GELU/SiLU so với vùng 'chết' của ReLU. Ở Tab 2, kéo thanh trượt độ sâu tầng L để xem Pre-RMSNorm giữ chuẩn đạo hàm ổn định ra sao so với Post-LN.

4.3 Bảng đối chiếu: Kiến trúc Transformer 2017 vs GPT-2 vs Llama-3

Bảng dưới đây tóm tắt sự tiến hóa từ kiến trúc Transformer gốc đến công thức chuẩn hiện đại (Dubey & others, 2024; Touvron & others, 2023):

Thành phầnTransformer gốc (2017)GPT-2 (2019)Công thức hiện đại (Llama-3, Qwen, Mistral)
Cấu trúc tổng thểEncoder–DecoderDecoder-onlyDecoder-only
Vị trí chuẩn hóaPost-Norm (sau nhánh cộng)Pre-Norm (trước nhánh con)Pre-Norm (luồng thặng dư thông suốt)
Công thức chuẩn hóaLayerNorm (trừ μ\mu, chia σ\sigma)LayerNorm (có γ,β\gamma, \beta)RMSNorm (chỉ chia RMS, không có β\beta)
Mã hóa vị tríSinusoidal cộng vào đầu vàoHọc được (Learned Absolute)RoPE (quay phức Q,KQ, K ở mọi tầng)
Hàm kích hoạt FFNReLU (22 ma trận, dff=4dd_{\text{ff}} = 4d)GELU (22 ma trận, dff=4dd_{\text{ff}} = 4d)SwiGLU (33 ma trận, dff≈83dd_{\text{ff}} \approx \frac{8}{3}d đến 3.5d3.5d)
Hệ số dịch (Bias)Có bias ở mọi lớp LinearCó biasBỏ toàn bộ bias (bias=False)

5. Giải phẫu Tham số và Độ phức tạp Tính toán (FLOPs)

Trước khi bắt tay lắp ráp một mô hình hoàn chỉnh, một kỹ năng không thể thiếu của kỹ sư LLM là nhẩm tính chính xác số lượng tham số (NN) và số phép tính dấu phẩy động (FLOPs) chỉ từ vài con số cấu hình.

5.1 Công thức đếm tham số của một mô hình ngôn ngữ

Giả sử mô hình có LlayersL_{\text{layers}} tầng, chiều mô hình dmodel=dd_{\text{model}} = d, chiều ẩn FFN là dffd_{\text{ff}}, và từ vựng có ∣V∣|V| token:

  1. Nhúng đầu vào (WEW_E) và Đầu dự đoán (WLMW_{\text{LM}}): Mỗi ma trận có kích thước ∣V∣×d|V| \times d. Nếu không chia sẻ trọng số (untied embeddings như Llama-3), chúng chiếm 2∣V∣d2 |V| d tham số.
  2. Mỗi khối Multi-Head Attention: Gồm 4 ma trận WQ,WK,WV,WOW_Q, W_K, W_V, W_O. Với MHA tiêu chuẩn, mỗi ma trận có kích thước d×dd \times d, tổng cộng là 4d24 d^2 tham số mỗi tầng (hoặc d(d+2dkv)+d2d(d + 2 d_{\text{kv}}) + d^2 nếu dùng Grouped-Query Attention như Llama-3, chúng ta sẽ học kỹ ở Chương 5).
  3. Mỗi khối FFN:
    • Với FFN truyền thống (dff=4dd_{\text{ff}} = 4d, 2 ma trận): 2⋅d⋅4d=8d22 \cdot d \cdot 4d = 8 d^2 tham số.
    • Với SwiGLU (33 ma trận Wgate,Wup,WdownW_{\text{gate}}, W_{\text{up}}, W_{\text{down}}): 3⋅d⋅dff3 \cdot d \cdot d_{\text{ff}} tham số (nếu dff≈83dd_{\text{ff}} \approx \frac{8}{3}d, con số này cũng bằng 8d28 d^2).
  4. Hai lớp RMSNorm mỗi tầng: Chỉ chiếm 2d2 d tham số mỗi tầng (rất nhỏ so với 12d212 d^2).

Như vậy, tổng số tham số truyền qua các tầng tuyến tính (không tính bảng tra cứu Embedding) của toàn mô hình là:

Nnon-emb=Llayers×(Tham soˆˊ Attention+3d⋅dff+2d)≈12Llayersd2.N_{\text{non-emb}} = L_{\text{layers}} \times \left(\text{Tham số Attention} + 3 d \cdot d_{\text{ff}} + 2d\right) \approx 12 L_{\text{layers}} d^2.

5.2 Tại sao quy tắc C≈6NDC \approx 6 N D lại đúng?

Khi nhân một vector 1×din1 \times d_{\text{in}} với một ma trận trọng số W∈Rdin×doutW \in \mathbb{R}^{d_{\text{in}} \times d_{\text{out}}} (chứa P=dindoutP = d_{\text{in}} d_{\text{out}} tham số), mỗi phần tử đầu ra cần dind_{\text{in}} phép nhân và dind_{\text{in}} phép cộng, tức là mỗi tham số trong ma trận đóng góp đúng 22 FLOPs cho mỗi token đi qua!

Cộng cả hai lượt lại, chi phí huấn luyện một mô hình có NN tham số trên một tập dữ liệu gồm DD token tuân theo công thức vàng mà chúng ta sẽ dùng xuyên suốt Chương 3:

Ctrain≈(2N+4N)×D=6ND(FLOPs).C_{\text{train}} \approx (2N + 4N) \times D = 6 N D \quad \text{(FLOPs)}.
Máy tính tương tác: Phân rã Tham số (N) và FLOPs của GPT-2, Llama-3 8B & Llama-3 70B
Chọn một mô hình thực tế để xem tỷ lệ tham số giữa Embedding, Attention và FFN. Hãy để ý khi mô hình phóng to từ 124M (GPT-2) lên 70B (Llama-3), tỷ trọng của lớp Embedding giảm từ 31% xuống chỉ còn 3%, trong khi khối SwiGLU FFN chiếm tới 80% tổng số tham số!

Bây giờ, hãy kết hợp toàn bộ kiến thức trong chương này để lắp ráp hoàn chỉnh một khối LlamaBlock từ đầu trong Bài tập 2.5!

Bài tập 2.5 Lắp ráp khối Transformer kiểu Llama (RMSNorm + Causal MHA + SwiGLU)
Dữ liệu: PyTorch Module · Thời gian chạy: < 1 phút

Hãy cài đặt ba lớp RMSNorm, SwiGLU và LlamaBlock để tạo thành một khối Transformer Decoder hiện đại hoàn chỉnh với cấu hình d_model = 64, n_heads = 4, d_ff = 172 (tất cả các lớp nn.Linear đều đặt bias=False):

  1. RMSNorm(d, eps=1e-6): chuẩn hóa đầu vào theo RMS(x)\text{RMS}(x) và nhân với tham số self.weight khởi tạo bằng torch.ones(d).
  2. SwiGLU(d_model, d_ff): cài đặt phép tính w_down(F.silu(w_gate(x)) * w_up(x)).
  3. LlamaBlock(d_model, n_heads, d_ff): thực hiện hai nhánh cộng thặng dư liên tiếp h = x + attn(norm1(x)) và out = h + ffn(norm2(h)).
  4. Tính số tham số lý thuyết 2d+4d2+3d⋅dff2d + 4d^2 + 3 d \cdot d_{\text{ff}} và đối chiếu với sum(p.numel() for p in block.parameters()).
Mã khởi tạo
import math
import torch
import torch.nn as nn
import torch.nn.functional as F

torch.manual_seed(0)
cfg = dict(d_model=64, n_heads=4, d_ff=172)
x = torch.randn(2, 12, cfg["d_model"])
print("Cấu hình khối Mini-Llama:", cfg)
Cấu hình khối Mini-Llama: {'d_model': 64, 'n_heads': 4, 'd_ff': 172}
Hint

Trong RMSNorm.forward, dùng torch.rsqrt(x.pow(2).mean(dim=-1, keepdim=True) + self.eps) để tính nghịch đảo căn bậc hai trung bình bình phương một cách nhanh và ổn định số học.

Hint

Trong SwiGLU.forward, hàm SiLU(z)=zσ(z)\mathrm{SiLU}(z) = z \sigma(z) đã có sẵn trong PyTorch dưới tên F.silu(...). Nhân kết quả của F.silu(self.w_gate(x)) với self.w_up(x) rồi đưa qua self.w_down.

Điểm kiểm tra kết quả bạn cần đạt được

Đầu ra của LlamaBlock giữ nguyên kích thước luồng thặng dư (2, 12, 64). Tổng số tham số thực tế khớp chính xác 100% với công thức lý thuyết: 128 (RMSNorm) + 16,384 (Attention) + 33,024 (SwiGLU) = 49,536 tham số!

Kích thước đầu ra TransformerBlock: (2, 12, 64)
Tham số RMSNorm (2 * d_model):       128
Tham số Attention (4 * d_model^2):   16384
Tham số SwiGLU FFN (3 * d * d_ff):   33024
Tổng tham số lý thuyết vs thực tế:   49536 == 49536 (Khớp 100%!)
Lời giải 2 ô mã

Chạy các ô mã theo đúng thứ tự này trong một notebook mới: chúng đã bao gồm các lệnh import, bước tải dữ liệu và khởi tạo hạt giống ngẫu nhiên (random seed).

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

torch.manual_seed(0)
cfg = dict(d_model=64, n_heads=4, d_ff=172)
x = torch.randn(2, 12, cfg["d_model"])
print("Cấu hình khối Mini-Llama:", cfg)
Cấu hình khối Mini-Llama: {'d_model': 64, 'n_heads': 4, 'd_ff': 172}
class RMSNorm(nn.Module):
    def __init__(self, d, eps=1e-6):
        super().__init__()
        self.eps = eps
        self.weight = nn.Parameter(torch.ones(d))

    def forward(self, x):
        rms = torch.rsqrt(x.pow(2).mean(dim=-1, keepdim=True) + self.eps)
        return x * rms * self.weight

class SwiGLU(nn.Module):
    def __init__(self, d_model, d_ff):
        super().__init__()
        self.w_gate = nn.Linear(d_model, d_ff, bias=False)
        self.w_up = nn.Linear(d_model, d_ff, bias=False)
        self.w_down = nn.Linear(d_ff, d_model, bias=False)

    def forward(self, x):
        return self.w_down(F.silu(self.w_gate(x)) * self.w_up(x))

class LlamaBlock(nn.Module):
    def __init__(self, d_model, n_heads, d_ff):
        super().__init__()
        self.norm1 = RMSNorm(d_model)
        self.attn_qkv = nn.Linear(d_model, 3 * d_model, bias=False)
        self.attn_out = nn.Linear(d_model, d_model, bias=False)
        self.norm2 = RMSNorm(d_model)
        self.ffn = SwiGLU(d_model, d_ff)
        self.n_heads = n_heads
        self.d_head = d_model // n_heads

    def forward(self, x):
        B, L, D = x.shape
        q, k, v = self.attn_qkv(self.norm1(x)).chunk(3, dim=-1)
        q = q.view(B, L, self.n_heads, self.d_head).transpose(1, 2)
        k = k.view(B, L, self.n_heads, self.d_head).transpose(1, 2)
        v = v.view(B, L, self.n_heads, self.d_head).transpose(1, 2)
        scores = (q @ k.transpose(-2, -1)) / math.sqrt(self.d_head)
        mask = torch.triu(torch.ones(L, L, dtype=torch.bool), diagonal=1)
        attn = F.softmax(scores.masked_fill(mask, float("-inf")), dim=-1)
        h = x + self.attn_out((attn @ v).transpose(1, 2).reshape(B, L, D))
        return h + self.ffn(self.norm2(h))

block = LlamaBlock(**cfg)
out = block(x)
d, d_ff = cfg["d_model"], cfg["d_ff"]
theo = 2 * d + 4 * d * d + 3 * d * d_ff
actual = sum(p.numel() for p in block.parameters())

print("Kích thước đầu ra TransformerBlock:", tuple(out.shape))
print(f"Tham số RMSNorm (2 * d_model):       {2 * d}")
print(f"Tham số Attention (4 * d_model^2):   {4 * d * d}")
print(f"Tham số SwiGLU FFN (3 * d * d_ff):   {3 * d * d_ff}")
print(f"Tổng tham số lý thuyết vs thực tế:   {theo} == {actual} (Khớp 100%!)")
Kích thước đầu ra TransformerBlock: (2, 12, 64)
Tham số RMSNorm (2 * d_model):       128
Tham số Attention (4 * d_model^2):   16384
Tham số SwiGLU FFN (3 * d * d_ff):   33024
Tổng tham số lý thuyết vs thực tế:   49536 == 49536 (Khớp 100%!)
Điều cần lưu ý

Chúc mừng bạn! Chỉ với chưa đầy 45 dòng mã PyTorch thuần túy, bạn đã viết xong khối kiến trúc cốt lõi của Llama-3, Mistral và Qwen. Một mô hình ngôn ngữ 8 tỷ hay 70 tỷ tham số thực chất chỉ là một lớp nn.Embedding, nối tiếp bởi một danh sách nn.ModuleList([LlamaBlock(...) for _ in range(L)]), kết thúc bằng một lớp RMSNorm và một lớp nn.Linear(d_model, vocab_size). Khi đã nắm vững từng tensor trong 45 dòng mã này, không còn bất kỳ “hộp đen” nào trong kiến trúc LLM đối với bạn nữa!


Nếu muốn đào sâu hơn nữa vào cơ học bên trong khối Transformer, bạn có thể thử sức với các bài tập mở rộng dưới đây trước khi bước sang Chương 3: Tiền huấn luyện (Pre-training).

Mở rộng

Đầu chú ý Quy nạp (Induction Heads). Trong bài báo về mạch Transformer (Elhage & others, 2021), các tác giả phát hiện một cơ chế hai tầng rất thú vị gọi là Induction Head: khi mô hình có ít nhất 2 tầng Attention, tầng 1 dùng RoPE để chép thông tin của token AA sang token đứng ngay sau nó là BB; sau đó ở tầng 2, khi gặp lại token AA ở cuối văn bản, một đầu chú ý sẽ tìm vị trí nào trong quá khứ có “dấu vết đứng sau AA” và chép token BB ra làm dự đoán tiếp theo ([A][B]…[A]→[B][A][B] \dots [A] \to [B]). Hãy khởi tạo một mô hình gồm 2 tầng LlamaBlock và huấn luyện nó trên các chuỗi lặp ngẫu nhiên để quan sát bản đồ chú ý của tầng 2 hội tụ vào đúng vị trí BB!

Mở rộng

Mở rộng ngữ cảnh với RoPE Scaling (NTK-Aware / YaRN). Trong Bài tập 2.3, chúng ta đã dùng cơ số chuẩn base = 10000.0. Điều gì xảy ra với các tần số θi=base−2i/d\theta_i = \text{base}^{-2i/d} khi ta tăng base lên 500000.0 như trong Llama-3 (Dubey & others, 2024)? Hãy vẽ biểu đồ bước sóng λi=2πθi\lambda_i = \frac{2\pi}{\theta_i} theo chỉ số chiều ii cho cả hai giá trị base để giải thích tại sao tăng cơ số base cho phép mô hình mở rộng cửa sổ ngữ cảnh từ 4K4\text{K} lên 128K128\text{K} token.

Mở rộng

Khi độ dài ngữ cảnh LL vượt xa dmodeld_{\text{model}}. Trong Mục 5.2, ta đã bỏ qua chi phí của phép tính ma trận chú ý QK⊤VQK^\top V vì khi LL nhỏ, nó nhỏ hơn rất nhiều so với 6ND6ND. Hãy viết công thức chính xác cho số FLOPs của phép nhân QK⊤QK^\top và Attn⋅V\text{Attn} \cdot V trên một chuỗi dài LL token, và tìm ngưỡng độ dài L∗L^* mà tại đó chi phí bậc hai của Attention bắt đầu bằng đúng chi phí của toàn bộ các lớp tuyến tính trong một tầng Llama-3 8B (dmodel=4096,dff=14336d_{\text{model}} = 4096, d_{\text{ff}} = 14336).


Tài liệu tham khảo

  1. 2016 Ba, Kiros & Hinton. Layer Normalization. arXiv. 4
  2. 2016 Hendrycks & Gimpel. Gaussian Error Linear Units (GELUs). arXiv. 4
  3. 2017 Vaswani et al. Attention Is All You Need. NeurIPS. 1
  4. 2019 Zhang & Sennrich. Root Mean Square Layer Normalization. NeurIPS. 4
  5. 2020 Shazeer. GLU Variants Improve Transformer. arXiv. 4
  6. 2020 Xiong et al. On Layer Normalization in the Transformer Architecture. ICML. 4
  7. 2021 Elhage et al. A Mathematical Framework for Transformer Circuits. Transformer Circuits Thread. 2
  8. 2021 Press, Smith & Lewis. Train Short, Test Long: Attention with Linear Biases Enables Input Length Extrapolation. ICLR 2022. 3
  9. 2023 Touvron et al. LLaMA: Open and Efficient Foundation Language Models. arXiv. 4, 5
  10. 2024 Dubey et al. The Llama 3 Herd of Models. arXiv. 5
  11. 2024 Su et al. RoFormer: Enhanced Transformer with Rotary Position Embedding. Neurocomputing (arXiv 2021). 3