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:
- Bên trong một khối Transformer Decoder hiện đại thực sự gồm những phép toán ma trận nào, và mỗi phép toán đóng vai trò gì trong việc xử lý ngôn ngữ?
- Tại sao kiến trúc Transformer gốc năm 2017 của Vaswani và cộng sự lại được tinh chỉnh thành “công thức chuẩn hiện đại” (Pre-RMSNorm, RoPE, SwiGLU) dùng trong Llama-3 và hầu hết các LLM ngày nay?
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ừ , 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í có vector biểu diễn đầu vào . Để 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:
- Truy vấn (Query — ): Vector thể hiện “thông tin mà vị trí đ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).
- Khóa (Key — ): Vector thể hiện “nhãn nhận diện của vị trí ” để 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”).
- Giá trị (Value — ): Vector chứa “nội dung ngữ nghĩa mà vị trí sẽ truyền đi” nếu được chú ý tới.
Khi xếp toàn bộ token của một chuỗi thành ma trận , 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:
trong đó và .
1.2 Tại sao phải chia cho ? Bài toán phương sai và bão hòa Softmax
Để đo mức độ khớp nhau giữa truy vấn và khóa , cách tự nhiên nhất là lấy tích vô hướng . 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 ?
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 và là các biến ngẫu nhiên độc lập có trung bình bằng và phương sai bằng . Khi đó, mỗi tích có trung bình bằng và phương sai bằng . Tổng của số hạng độc lập như vậy sẽ có:
Nếu hoặc , độ lệch chuẩn của tích vô hướng sẽ là hoặc . Điều này có nghĩa là một số phần tử trong tích sẽ vọt lên hoặc tụt xuống . Khi đưa các con số chênh lệch lớn như vậy vào hàm , phần tử lớn nhất sẽ nuốt trọn xác suất , còn mọi phần tử khác nhận xác suất . Trong vùng bão hòa (saturation) này, đạo hàm của hàm Softmax gần như bằng , làm đóng băng quá trình học! Chia cho đưa phương sai của điểm số trở lại bằng bất kể số chiều 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í là dự đoán token . Do đó, khi tính biểu diễn tại vị trí , mô hình tuyệt đối không được phép nhìn thấy các token tương lai ở vị trí .
Làm thế nào để vừa tính toàn bộ ma trận 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) trước khi lấy hàm Softmax:
Vì , sau khi đi qua hàm Softmax theo từng hàng, mọi trọng số chú ý ở vùng tam giác trên () đều bằng tuyệt đối, còn các trọng số hợp lệ ở quá khứ và hiện tại () tự động được chuẩn hóa sao cho tổng bằng .
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ả
Viết hàm causal_attention(q, k, v) nhận vào ba tensor 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:
- Kiểm tra tổng trọng số trên mỗi hàng của
weightsbằng1.0, và tổng trọng số ở vùng tam giác trên () bằng0.0. - Kiểm chứng tính nhân quả (Causality test): Tạo bản sao
Q2, K2, V2và thay toàn bộ các vector ở vị trí tương lai 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ứ không thay đổi dù chỉ một bit (sai khác = 0.00e+00)!
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í bằng đúng 0.00e+00, trong khi đầu ra tại các vị trí 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
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ộ 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 , phép tính tại hàng thứ của ma trận hoàn toàn độc lập với mọi hàng và cột đứng sau . 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 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 . 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 đầu chú ý song song
Thay vì thực hiện một phép chú ý duy nhất trên không gian chiều, Vaswani et al. (Vaswani et al., 2017) chia thành không gian con (subspaces) độc lập, mỗi không gian con có số chiều:
Với mỗi đầu , ta có bộ ma trận chiếu riêng để tính:
Sau đó, đầu ra của cả đầ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 :
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 thành khối hàng , thì phép ghép nối rồi nhân hoàn toàn tương đương với tổng của đầu chú ý độc lập:
Kết hợp với nhánh cộng thặng dư , ta nhận ra:
- Luồng Thặng dư (Residual Stream) là một dải băng chuyền có kích thước cố định chạy xuyên suốt từ tầng đầu tiên đến tầng cuối cùng.
- Mỗi đầu chú ý đọc một không gian con từ luồng thặng dư, di chuyển thông tin giữa các vị trí (nhờ mạch QK ), biến đổi nội dung (nhờ mạch OV ), rồi cộng đè kết quả của nó trở lại vào luồng thặng dư!
Bài tập 2.2 Cài đặt Causal Multi-Head Attention với Tensor 4 chiều
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):
- Chiếu
xcó kích thước(B, L, d_model)quaw_q, w_k, w_vvà biến đổi hình dạng thành(B, n_heads, L, d_head). - Tính ma trận chú ý nhân quả
attnkích thước(B, n_heads, L, L). - Nhân
attn @ v, hoán vị chiều ngược lại về(B, L, d_model)và chiếu quaw_o. - Kiểm tra tổng số tham số của lớp bằng đúng .
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 — 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
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 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 vào vector nhúng của token ở vị trí ngay tại tầng đầu vào: .
Cách tiếp cận cộng trực tiếp này có hai nhược điểm lớn:
- Nhiễm bẩn ngữ nghĩa trên luồng thặng dư: Vector vị trí bị trộn lẫn với vector ngữ nghĩa và bị biến đổi qua nhiều tầng phi tuyến.
- 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ứ và từ thứ kể từ đầu văn bản, mà là chúng cách nhau từ!
Làm thế nào để thiết kế một phép biến đổi vị trí và sao cho tích vô hướng chú ý chỉ phụ thuộc vào vector và hiệu vị trí tương đối ?
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 có 2 chiều. Nếu tại vị trí , ta quay vector một góc trên mặt phẳng 2D bằng ma trận quay trực giao :
thì khi tính tích vô hướng giữa (ở vị trí ) và (ở vị trí ), nhờ tính chất của ma trận quay , ta có ngay:
Tích vô hướng này chỉ phụ thuộc vào góc lệch tương đối ! Dù hai từ nằm ở vị trí hay bị dịch đi token tới vị trí , góc giữa hai vector sau khi quay vẫn giữ nguyên không đổi!
Với vector nhiều chiều (là một số chẵn), RoPE đơn giản chia tọa độ thành cặp 2 chiều độc lập , và quay mỗi cặp với tốc độ góc (tần số) giảm dần theo cấp số nhân:
- Các cặp chiều đầu tiên ( nhỏ, ) quay rất nhanh — đóng vai trò như kim giây đồng hồ, giúp phân biệt sắc nét các từ đứng sát cạnh nhau.
- Các cặp chiều cuối cùng ( lớn, ) quay cực chậm — đóng vai trò như kim giờ, giúp mô hình nắm bắt cấu trúc ngữ cảnh dài hàng nghìn token.
Đọ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 , mà chỉ trừ thẳng một hình phạt tuyến tính tỷ lệ với khoảng cách vào điểm chú ý trước Softmax:
trong đó hệ số góc 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
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:
- Tính vector tần số với .
- Tách
vecthành các tọa độ chẵnx_even = vec[..., 0::2]và tọa độ lẻx_odd = vec[..., 1::2], áp dụng phép quay 2D theo gócpos * theta, rồi ghép xen kẽ trở lại. - Kiểm chứng rằng phép quay RoPE bảo toàn chuẩn độ dài L2 của vector (), và điểm chú ý luôn bằng với mọi độ dịch .
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í đều bằng 3.7483. Tích vô hướng tại và tại 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
Chú ý hai tính chất toán học quý giá bạn vừa chứng minh bằng code:
- 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 , phép nhân với ma trận trực giao giữ nguyên độ dài , giúp tích vô hướng chú ý không bị bùng nổ.
- Chỉ tác động lên và ở mỗi tầng: Trong kiến trúc hiện đại, RoPE được áp dụng trực tiếp lên và ngay sau phép chiếu tuyến tính ở mọi tầng Transformer, trong khi vector 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):
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ố , làm đạo hàm ở các tầng đầu tiên suy giảm theo hàm mũ theo độ sâu , 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:
Khi lấy đạo hàm từ tầng cuối về bất kỳ tầng nào, luôn có số hạng đạo hàm đơn vị 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 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:
trong đó là vector hệ số tỷ lệ học được (không có hệ số dịch ).
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):
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 (còn gọi là hàm Swish) (Shazeer, 2020):
trong đó là phép nhân từng phần tử (element-wise product), và .
Tại sao SwiGLU cần tới 3 ma trận () thay vì 2 ma trận? Vì nhánh đi qua hàm kích hoạt mượt đó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 đ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ố xấp xỉ bằng của FFN cũ, chiều trung gian trong SwiGLU thường được chọn bằng:
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ần | Transformer 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–Decoder | Decoder-only | Decoder-only |
| Vị trí chuẩn hóa | Post-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óa | LayerNorm (trừ , chia ) | LayerNorm (có ) | RMSNorm (chỉ chia RMS, không có ) |
| Mã hóa vị trí | Sinusoidal cộng vào đầu vào | Học được (Learned Absolute) | RoPE (quay phức ở mọi tầng) |
| Hàm kích hoạt FFN | ReLU ( ma trận, ) | GELU ( ma trận, ) | SwiGLU ( ma trận, đến ) |
| Hệ số dịch (Bias) | Có bias ở mọi lớp Linear | Có bias | Bỏ 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ố () 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ó tầng, chiều mô hình , chiều ẩn FFN là , và từ vựng có token:
- Nhúng đầu vào () và Đầu dự đoán (): Mỗi ma trận có kích thước . Nếu không chia sẻ trọng số (untied embeddings như Llama-3), chúng chiếm tham số.
- Mỗi khối Multi-Head Attention: Gồm 4 ma trận . Với MHA tiêu chuẩn, mỗi ma trận có kích thước , tổng cộng là tham số mỗi tầng (hoặc nếu dùng Grouped-Query Attention như Llama-3, chúng ta sẽ học kỹ ở Chương 5).
- Mỗi khối FFN:
- Với FFN truyền thống (, 2 ma trận): tham số.
- Với SwiGLU ( ma trận ): tham số (nếu , con số này cũng bằng ).
- Hai lớp RMSNorm mỗi tầng: Chỉ chiếm tham số mỗi tầng (rất nhỏ so với ).
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à:
5.2 Tại sao quy tắc lại đúng?
Khi nhân một vector với một ma trận trọng số (chứa tham số), mỗi phần tử đầu ra cần phép nhân và phép cộng, tức là mỗi tham số trong ma trận đóng góp đúng FLOPs cho mỗi token đi qua!
- Lượt truyền tiến (Forward pass): Đi qua toàn bộ tham số của mô hình một lần cho mỗi token tốn FLOPs/token.
- Lượt truyền ngược (Backward pass): Để tính đạo hàm theo kích hoạt đầu vào (, tốn FLOPs) và đạo hàm theo trọng số (, tốn thêm FLOPs), lượt truyền ngược tốn gấp đôi lượt truyền tiến, tức là FLOPs/token.
Cộng cả hai lượt lại, chi phí huấn luyện một mô hình có tham số trên một tập dữ liệu gồm token tuân theo công thức vàng mà chúng ta sẽ dùng xuyên suốt Chương 3:
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)
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):
RMSNorm(d, eps=1e-6): chuẩn hóa đầu vào theo và nhân với tham sốself.weightkhởi tạo bằngtorch.ones(d).SwiGLU(d_model, d_ff): cài đặt phép tínhw_down(F.silu(w_gate(x)) * w_up(x)).LlamaBlock(d_model, n_heads, d_ff): thực hiện hai nhánh cộng thặng dư liên tiếph = x + attn(norm1(x))vàout = h + ffn(norm2(h)).- Tính số tham số lý thuyết và đối chiếu với
sum(p.numel() for p in block.parameters()).
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 đã 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%!)
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).
Đầ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 sang token đứng ngay sau nó là ; sau đó ở tầng 2, khi gặp lại token ở 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 ” và chép token ra làm dự đoán tiếp theo (). 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í !
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ố 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 theo chỉ số chiều 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ừ lên token.
Khi độ dài ngữ cảnh vượt xa . Trong Mục 5.2, ta đã bỏ qua chi phí của phép tính ma trận chú ý vì khi nhỏ, nó nhỏ hơn rất nhiều so với . Hãy viết công thức chính xác cho số FLOPs của phép nhân và trên một chuỗi dài token, và tìm ngưỡng độ dài 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 ().
Tài liệu tham khảo
- 2016 Layer Normalization. arXiv. 4
- 2016 Gaussian Error Linear Units (GELUs). arXiv. 4
- 2017 Attention Is All You Need. NeurIPS. 1
- 2019 Root Mean Square Layer Normalization. NeurIPS. 4
- 2020 GLU Variants Improve Transformer. arXiv. 4
- 2020 On Layer Normalization in the Transformer Architecture. ICML. 4
- 2021 A Mathematical Framework for Transformer Circuits. Transformer Circuits Thread. 2
- 2021 Train Short, Test Long: Attention with Linear Biases Enables Input Length Extrapolation. ICLR 2022. 3
- 2023 LLaMA: Open and Efficient Foundation Language Models. arXiv. 4, 5
- 2024 The Llama 3 Herd of Models. arXiv. 5
- 2024 RoFormer: Enhanced Transformer with Rotary Position Embedding. Neurocomputing (arXiv 2021). 3