Trong các chương trước, chúng ta đã đi trọn hành trình tạo ra một trợ lý ngôn ngữ: tách từ thành chuỗi token (Chương 1), lắp ráp kiến trúc Transformer (Chương 2), tiền huấn luyện trên hàng nghìn tỷ từ (Chương 3) và căn chỉnh theo sở thích con người (Chương 4).
Nhưng khi đưa mô hình đó vào vận hành thực tế cho hàng triệu người dùng — giai đoạn suy luận (inference) — một nghịch lý hoàn toàn mới xuất hiện. Một cụm GPU có thể xử lý hàng chục nghìn token mỗi giây lúc huấn luyện, nhưng lại chỉ nhỏ giọt vài chục token mỗi giây khi trò chuyện trực tiếp. Hơn thế nữa, trong suốt vòng đời của một mô hình phổ biến, tổng chi phí điện năng và phần cứng cho suy luận thường vượt xa chi phí huấn luyện ban đầu gấp nhiều lần.
Chương này sẽ mổ xẻ các nguyên lý toán học và giới hạn vật lý của phần cứng đứng sau quá trình sinh văn bản. Như thường lệ, bạn sẽ tự tay cài đặt từng thuật toán cốt lõi — từ bộ chọn mẫu hạt nhân (Nucleus Sampling), bộ nhớ đệm kv_cache, lượng tử hóa trọng số nhóm (Group-wise Quantization) cho đến kiểm chứng mẫu suy đoán (Speculative Decoding) — chỉ với vài chục dòng mã PyTorch thuần túy.
Khi một mô hình ngôn ngữ đã được huấn luyện xong và đóng băng toàn bộ trọng số , công việc duy nhất của nó tại mỗi bước là nhận vào chuỗi ngữ cảnh và xuất ra một vector điểm số chưa chuẩn hóa (gọi là logits) trên toàn bộ từ vựng .
Tưởng chừng mọi thứ đến đây đã xong, nhưng để biến vector logits đó thành một hệ thống sinh văn bản vừa thông minh, tự nhiên, vừa đạt tốc độ phản hồi tức thì, chúng ta phải giải quyết hai câu hỏi lớn định hình toàn bộ kỹ thuật suy luận LLM hiện đại:
- Làm thế nào để chọn ra token tiếp theo từ phân phối xác suất sao cho văn bản sinh ra không bị rơi vào vòng lặp vô hồn của tìm kiếm tham lam (greedy decoding), cũng không bị trượt sang những từ vô nghĩa ở phần đuôi dài của phân phối?
- Tại sao khi sinh từng token tự hồi quy, các siêu chip GPU hàng trăm TFLOPS lại dành tới hơn 95% thời gian chỉ để… đứng chờ bộ nhớ RAM, và làm thế nào để phá vỡ bức tường băng thông bộ nhớ (memory wall) này mà không làm giảm chất lượng mô hình?
Trong toàn bộ các bài tập thực hành của chương này, chúng ta sử dụng PyTorch tiêu chuẩn (chạy tức thì trên CPU trong vài giây) để mô phỏng chính xác các phép toán bên trong các động cơ suy luận hiện đại:
- Kiến trúc tham chiếu: Llama-3 8B ( lớp, , đầu Query, đầu Key/Value) và Llama-3 70B ( lớp, , đầu Query, đầu Key/Value).
- Phần cứng tham chiếu: GPU NVIDIA A100 SXM 80GB (Đỉnh tính toán FP16: , Băng thông bộ nhớ HBM2e: ) và H100 SXM 80GB (Đỉnh FP16: , Băng thông HBM3: ).
- Động cơ suy luận công nghiệp (Inference Engines): Các thuật toán bạn cài đặt trong chương này chính là nền tảng cốt lõi bên trong
vLLM,TensorRT-LLM,SGLangvàllama.cpp.
1. Chiến lược giải mã và chọn mẫu (Decoding & Sampling)
Giả sử mô hình vừa đọc xong cụm từ “Cánh cửa gỗ bất ngờ mở ra, bên trong là một” và trả về vector logits ở lớp tuyến tính cuối cùng. Hàm Softmax biến vector này thành phân phối xác suất của token tiếp theo:
Có phân phối xác suất rồi, ta rút ra từ tiếp theo bằng cách nào? Quyết định tưởng như nhỏ nhặt này lại có ảnh hưởng sống còn đến chất lượng câu trả lời của mô hình.
1.1 Giải mã cực đại xác suất và căn bệnh thoái hóa văn bản
Nếu mục tiêu là tìm chuỗi từ có xác suất hợp lý cao nhất theo mô hình, phản xạ tự nhiên nhất là giải mã tham lam (Greedy Decoding): tại mỗi bước , ta luôn chọn từ có xác suất lớn nhất:
Giải mã tham lam hoàn toàn tất định (deterministic) và rất phù hợp cho các tác vụ có đáp án duy nhất (như phân loại hay trích xuất số liệu). Tuy nhiên, việc chọn cực đại cục bộ ở từng bước không đảm bảo chuỗi tổng thể có xác suất cao nhất: một từ hơi kém phổ biến hơn ở bước có thể mở ra một nhánh câu xuất sắc ở bước .
Để khắc phục, các hệ thống dịch máy cổ điển (Chương 0, mục 9) sử dụng tìm kiếm chùm (Beam Search): duy trì song song ứng viên chuỗi tốt nhất (gọi là độ rộng chùm beam width) ở mỗi bước và chọn chuỗi tối đa hóa .
Thế nhưng, khi áp dụng Greedy Decoding hoặc Beam Search vào sinh văn bản mở (open-ended generation) trên các mô hình ngôn ngữ lớn, một hiện tượng kỳ lạ xảy ra: chỉ sau vài câu, mô hình bắt đầu viết nhạt nhẽo, an toàn quá mức, và nhanh chóng rơi vào những vòng lặp vô tận kiểu: “Tôi không biết. Tôi không biết. Tôi không biết…”
Năm 2020, Ari Holtzman và cộng sự đã đặt tên cho hiện tượng này là sự thoái hóa văn bản nơ-ron (neural text degeneration) (Holtzman & others, 2020). Vậy nếu không lấy cực đại (argmax), tại sao ta không lấy mẫu ngẫu nhiên trực tiếp từ phân phối như cách chúng ta từng làm với mô hình xích Markov ở Chương 0?
Câu trả lời nằm ở cái đuôi không đáng tin cậy (unreliable tail) của hàm Softmax. Với bộ từ vựng token, luôn có hàng chục nghìn từ hoàn toàn lạc đề hoặc sai ngữ pháp nhưng vẫn nhận một xác suất nhỏ khác không (ví dụ ). Khi cộng dồn từ như vậy lại, cái đuôi dài này có thể chiếm tới tổng khối lượng xác suất! Nếu lấy mẫu thuần túy trên toàn bộ từ vựng, cứ sau khoảng 5 đến 10 từ, mô hình sẽ trượt chân rút phải một từ hoàn toàn vô nghĩa ở phần đuôi, kéo toàn bộ các bước tự hồi quy phía sau chệch hướng.
1.2 Nhiệt độ (), Top- và Chọn mẫu Hạt nhân Top- (Nucleus Sampling)
Để vừa giữ được sự ngẫu nhiên sáng tạo, vừa cắt bỏ hoàn toàn cái đuôi nhiễu loạn, các hệ thống LLM hiện đại kết hợp một đường ống gồm ba phép biến đổi liên tiếp trên vector logits trước khi rút mẫu:
-
Nhiệt độ (Temperature Scaling — ): Trước khi đưa vào Softmax, ta chia toàn bộ logits cho hằng số nhiệt độ :
Khi , chênh lệch giữa các logit lớn và nhỏ được khuếch đại, làm phân phối nhọn hơn (tập trung vào các từ an toàn; khi , nó hội tụ về giải mã tham lam). Khi , phân phối bị san phẳng, trao nhiều cơ hội hơn cho các từ ít phổ biến (nhưng cũng làm phình to cái đuôi nhiễu).
-
Lọc Top- (Top- Sampling): Sắp xếp các token theo thứ tự xác suất giảm dần , chỉ giữ lại đúng token đầu tiên (ví dụ ) và gán logit của mọi token còn lại bằng . Nhược điểm của Top- là số lượng cố định không thích ứng được với ngữ cảnh:
- Khi phân phối rất nhọn (ví dụ: “Thủ đô của Pháp là…”, từ
"Paris"chiếm ), việc giữ từ sẽ vô tình đưa 49 từ sai vào tập chọn mẫu. - Khi phân phối rất phẳng (ví dụ: “Hôm nay tôi muốn kể câu chuyện về một…”, có hàng trăm danh từ hợp lý mỗi từ chiếm ), việc cắt ở hay lại chặt cụt mất phần lớn các ý tưởng hay.
- Khi phân phối rất nhọn (ví dụ: “Thủ đô của Pháp là…”, từ
-
Chọn mẫu Hạt nhân Top- (Nucleus Sampling [@holtzman2020]): Thay vì cố định số lượng ứng viên , ta cố định khối lượng xác suất tích lũy (thường chọn hoặc ). Ta tìm tập con nhỏ nhất các token đứng đầu sao cho tổng xác suất của chúng vừa chạm hoặc vượt ngưỡng :
Mọi token nằm ngoài đều bị loại bỏ, và các token trong hạt nhân được chuẩn hóa lại thành phân phối cuối cùng :
Nhờ định nghĩa theo tổng tích lũy, kích thước hạt nhân tự động co giãn linh hoạt: thu nhỏ về chỉ từ khi mô hình chắc chắn, và mở rộng ra hàng chục từ khi ngữ cảnh có nhiều nhánh rẽ hợp lý! Hãy thử nghiệm trực tiếp cơ chế này trên biểu đồ tương tác dưới đây.
Hãy tự tay cài đặt toàn bộ đường ống chọn mẫu này trong bài tập thực hành đầu tiên.
Bài tập 5.1 Cài đặt bộ chọn mẫu kết hợp Temperature, Top-k và Top-p
Viết hàm filter_and_sample(logits, temp, top_k, top_p) nhận vào một vector logits 1 chiều, thực hiện lần lượt: (1) chia cho nhiệt độ temp và sắp xếp giảm dần; (2) gán -inf cho các vị trí vượt quá top_k; (3) tính xác suất tích lũy torch.cumsum và gán -inf cho các token mà tổng tích lũy của các token đứng trước nó đã lớn hơn hoặc bằng top_p (luôn giữ lại ít nhất token đứng đầu); (4) chuẩn hóa lại bằng Softmax và trả về phân phối xác suất trên thứ tự từ vựng ban đầu.
Sau đó, tính Entropy Shannon (đơn vị: bits) để đo độ đa dạng của phân phối ở 3 cấu hình khác nhau và rút thử 10 mẫu ngẫu nhiên.
import torch
torch.manual_seed(42)
vocab = [
"căn", "thế", "kho", "hành", "bóng",
"khu", "người", "chiếc", "con", "trái",
]
logits = torch.tensor(
[3.1, 2.7, 2.4, 2.0, 1.7, 1.3, 0.9, 0.5, 0.1, -0.6]
)
print("Từ vựng:", len(vocab), "tokens | Logit cao nhất:", logits[0].item())
Từ vựng: 10 tokens | Logit cao nhất: 3.0999999046325684
Hint
Sử dụng sorted_logits, sorted_idx = torch.sort( logits / temp, descending=True ) để sắp xếp các ứng viên từ cao xuống thấp. Với Top-, bạn chỉ cần gán sorted_logits[top_k:] = -float("inf").
Hint
Để tìm ranh giới cắt Top- chuẩn xác (bao gồm cả token đầu tiên làm tổng tích lũy vượt ngưỡng top_p), hãy tính probs = torch.softmax(sorted_logits, dim=-1) và cum_probs = torch.cumsum(probs, dim=-1). Một token ở vị trí sẽ bị loại nếu tổng xác suất trước nó đã đạt top_p, tức là (cum_probs - probs) >= top_p. Đừng quên đặt remove_mask[0] = False để đảm bảo luôn giữ lại ít nhất 1 token.
Hint
Sau khi tính final_sorted = torch.softmax(sorted_logits, dim=-1), dùng phương thức final_probs.scatter_(0, sorted_idx, final_sorted) để đưa các giá trị xác suất về lại đúng vị trí chỉ số từ vựng ban đầu.
Điểm kiểm tra kết quả bạn cần đạt được
Ở chế độ tham lam (T=0.1, k=1), chỉ 1 token được giữ lại với xác suất và entropy bằng . Với Top-p (T=0.8, p=0.9), hạt nhân giữ lại token đầu tiên, nâng entropy lên .
[Greedy T=0.1, k=1] Giữ: 1/10 | Entropy: 0.00 bits | Top-1: căn (100.0%) [Top-p T=0.8, p=0.9] Giữ: 6/10 | Entropy: 2.26 bits | Top-1: căn (35.1%) [Top-k T=1.2, k=4] Giữ: 4/10 | Entropy: 1.93 bits | Top-1: căn (37.1%) Mẫu rút thử (T=0.8, k=8, p=0.90): căn, thế, căn, kho, hành, căn, thế, bóng, căn, kho
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(42)
vocab = [
"căn", "thế", "kho", "hành", "bóng",
"khu", "người", "chiếc", "con", "trái",
]
logits = torch.tensor(
[3.1, 2.7, 2.4, 2.0, 1.7, 1.3, 0.9, 0.5, 0.1, -0.6]
)
print("Từ vựng:", len(vocab), "tokens | Logit cao nhất:", logits[0].item())
Từ vựng: 10 tokens | Logit cao nhất: 3.0999999046325684
def filter_and_sample(logits, temp=1.0, top_k=10, top_p=1.0):
# (1) Chia nhiệt độ T và sắp xếp logits giảm dần
scaled = logits / max(temp, 1e-5)
sorted_logits, sorted_idx = torch.sort(scaled, descending=True)
# (2) Áp dụng mặt nạ Top-k
k = min(top_k, sorted_logits.size(-1))
sorted_logits[k:] = -float("inf")
# (3) Tính xác suất tích lũy và áp dụng mặt nạ Top-p (Nucleus)
probs = torch.softmax(sorted_logits, dim=-1)
cum_probs = torch.cumsum(probs, dim=-1)
remove_mask = (cum_probs - probs) >= top_p
remove_mask[0] = False
sorted_logits[remove_mask] = -float("inf")
# (4) Chuẩn hóa lại xác suất trên thứ tự từ vựng gốc
final_Sorted = torch.softmax(sorted_logits, dim=-1)
final_probs = torch.zeros_like(logits)
final_probs.scatter_(0, sorted_idx, final_Sorted)
return final_probs
def calc_entropy(probs):
nz = probs[probs > 0]
return -(nz * torch.log2(nz)).sum().item()
configs = [
("Greedy T=0.1, k=1", 0.1, 1, 1.0),
("Top-p T=0.8, p=0.9", 0.8, 10, 0.9),
("Top-k T=1.2, k=4", 1.2, 4, 1.0),
]
for label, t, k, p in configs:
dist = filter_and_sample(logits, temp=t, top_k=k, top_p=p)
kept = int((dist > 0).sum().item())
h = calc_entropy(dist)
top_i = int(torch.argmax(dist).item())
print(
f"[{label:19s}] Giữ: {kept}/10 | Entropy: {h:.2f} bits"
f" | Top-1: {vocab[top_i]} ({dist[top_i]*100:.1f}%)"
)
torch.manual_seed(0)
dist_p = filter_and_sample(logits, temp=0.8, top_k=8, top_p=0.90)
draws = torch.multinomial(dist_p, num_samples=10, replacement=True)
words = ", ".join(vocab[i] for i in draws.tolist())
print(f"\nMẫu rút thử (T=0.8, k=8, p=0.90): {words}")
[Greedy T=0.1, k=1] Giữ: 1/10 | Entropy: 0.00 bits | Top-1: căn (100.0%) [Top-p T=0.8, p=0.9] Giữ: 6/10 | Entropy: 2.26 bits | Top-1: căn (35.1%) [Top-k T=1.2, k=4] Giữ: 4/10 | Entropy: 1.93 bits | Top-1: căn (37.1%) Mẫu rút thử (T=0.8, k=8, p=0.90): căn, thế, căn, kho, hành, căn, thế, bóng, căn, kho
Hãy chú ý cách công thức dịch chuyển (cum_probs - probs) >= top_p hoạt động: nếu token đầu tiên "căn" đã có xác suất trong khi top_p = 0.90, thì tại vị trí , cum_probs - probs bằng nên "căn" được giữ lại; còn từ vị trí trở đi, tổng tích lũy phía trước đã là nên toàn bộ các từ còn lại bị cắt sạch! Top- tự động biến thành giải mã tham lam khi mô hình hoàn toàn chắc chắn, và tự động mở rộng khi mô hình phân vân.
Đọc thêm Các chiến lược chọn mẫu hiện đại khác: Min-p và Phạt lặp (Repetition Penalty)
Bên cạnh Top- và Top-, các động cơ suy luận mã nguồn mở gần đây (như llama.cpp và vLLM) rất ưa chuộng thêm hai kỹ thuật:
- Min- Sampling: Thay vì cắt theo tổng tích lũy, Min- đặt ngưỡng cắt tương đối theo xác suất của token đứng đầu : mọi token có (ví dụ ) sẽ bị loại bỏ. Cách này cực kỳ đơn giản và tránh được việc Top- đôi khi giữ lại quá nhiều từ vụn vặt khi nằm lấp lửng ở mức (chưa đủ ).
- Frequency & Presence Penalty: Giảm trực tiếp logit của các token đã xuất hiện lần trong văn bản trước đó, giúp triệt tiêu tận gốc hiện tượng lặp từ.
2. Bộ nhớ đệm KV (KV Cache) và giới hạn phần cứng Roofline
Sau khi đã biết cách rút ra token tiếp theo , ta nối nó vào cuối chuỗi đầu vào để thành rồi lại đưa vào mô hình để sinh tiếp . Nhưng nếu thực hiện một cách ngây thơ — mỗi bước lại đưa toàn bộ chuỗi từ đầu vào mạng Transformer — tốc độ suy luận sẽ sụp đổ rất nhanh khi câu trả lời dài ra. Tại sao lại như vậy?
2.1 Tại sao tính lại toàn bộ chuỗi tốn và cách KV Cache giải cứu
Hãy nhìn vào một lớp Causal Self-Attention (Chương 2, mục 1) khi xử lý chuỗi có độ dài với ma trận biểu diễn đầu vào . Lớp này nhân với ba ma trận trọng số để tạo ra:
Có hai sự thật toán học vô cùng quan trọng ở đây:
- Nhờ mặt nạ nhân quả (causal mask), biểu diễn của các token ở vị trí hoàn toàn không phụ thuộc vào token mới . Do đó, các hàng và ở bước giống hệt 100% với những gì chúng ta đã tính ở bước !
- Để dự đoán token tiếp theo , lớp đầu ra cuối cùng của mô hình chỉ đọc duy nhất vector ẩn ở vị trí cuối cùng . Mà để tính , phép chú ý chỉ cần lấy tích vô hướng giữa truy vấn mới nhất với tất cả các khóa :
Nếu không lưu lại gì từ bước trước, ở mỗi bước ta phải tính lại toàn bộ hàng của ; tổng chi phí sinh token sẽ tỷ lệ với .
Giải pháp rất tự nhiên: chúng ta cấp phát một vùng nhớ trên RAM của GPU gọi là Bộ nhớ đệm Khóa–Giá trị (KV Cache) để lưu lại các ma trận và ở mọi lớp Transformer. Khi sinh token thứ , ta chỉ đưa duy nhất 1 token mới nhất đi qua mạng:
- Chỉ tính , , (chi phí theo độ dài chuỗi).
- Nối (append) và vào cuối bộ đệm để thu được và .
- Nhân vector () với () và ().
2.2 Hai pha suy luận (Prefill vs Decode) và Mô hình Roofline
KV Cache chia quá trình suy luận của một yêu cầu người dùng thành hai pha có bản chất phần cứng đối lập nhau hoàn toàn:
- Pha 1 — Prefill (Khởi tạo ngữ cảnh): Khi người dùng vừa gửi một câu hỏi (prompt) dài token (ví dụ token), toàn bộ token này đã có sẵn cùng lúc. GPU xử lý tất cả chúng song song trong một lượt chạy duy nhất bằng phép nhân Ma trận – Ma trận (kích thước ), vừa sinh ra token trả lời đầu tiên, vừa điền đầy KV Cache ban đầu.
- Pha 2 — Decode (Giải mã tự hồi quy): Từ token thứ hai trở đi, vì token sau phụ thuộc vào token trước nên mô hình buộc phải chạy tuần tự từng bước một. Nhờ KV Cache, đầu vào của mỗi bước chỉ có độ dài bằng (hoặc bằng kích thước batch ). Mọi phép nhân trọng số giờ đây thu nhỏ thành phép nhân Vector – Ma trận (kích thước ).
Tại sao phép nhân Vector – Ma trận ở pha Decode lại là “cơn ác mộng” đối với GPU hiện đại? Để hiểu điều này, các kỹ sư hệ thống sử dụng Mô hình Roofline (Roofline Model).
Một GPU có hai thông số vật lý giới hạn:
- Đỉnh tính toán (): Số phép tính dấu phẩy động tối đa mỗi giây của các nhân Tensor Cores (ví dụ NVIDIA A100 đạt ở chuẩn FP16).
- Băng thông bộ nhớ (): Tốc độ truyền dữ liệu tối đa từ bộ nhớ chính HBM (VRAM) vào bộ nhớ đệm siêu tốc trên chip SRAM (ví dụ A100 đạt ).
Tỷ số giữa số phép toán số học thực hiện được trên mỗi byte dữ liệu phải đọc từ HBM được gọi là Cường độ số học (Arithmetic Intensity — ):
Trên GPU A100, để các nhân tính toán không phải ngồi chơi chờ dữ liệu, một tác vụ phải có cường độ số học vượt qua điểm gãy (Ridge Point):
Hãy tính cường độ số học của một lớp tuyến với ma trận trọng số lưu ở chuẩn FP16 ( mỗi tham số, tổng dung lượng ) khi xử lý token cùng lúc ():
- Số phép tính nhân–cộng của phép nhân ma trận là .
- Khi , lượng byte cần đọc từ HBM bị chi phối bởi kích thước ma trận trọng số .
- Suy ra cường độ số học xấp xỉ bằng đúng số lượng token xử lý song song:
| Pha suy luận | Số token xử lý cùng lúc () | Bản chất toán học | Cường độ số học (FP16) | So với ngưỡng A100 () | Trạng thái phần cứng |
|---|---|---|---|---|---|
| Pha Prefill (Prompt ) | Ma trận Ma trận (GEMM) | Giới hạn bởi tính toán (Compute-bound): Tensor Cores chạy hết công suất. | |||
| Pha Decode đơn lẻ () | Vector Ma trận (GEMV) | Giới hạn bởi bộ nhớ (Memory-bound): GPU nhàn rỗi , tốc độ chỉ phụ thuộc thời gian đọc trọng số từ HBM. | |||
| Pha Decode theo Batch () | Ma trận nhỏ Ma trận | Tận dụng băng thông tốt gấp , tiến gần lên đỉnh Roofline. |
Đây chính là kết luận quan trọng nhất về phần cứng suy luận: Ở pha Decode với kích thước batch nhỏ, tốc độ sinh token (tokens/giây) hoàn toàn không phụ thuộc vào việc GPU có bao nhiêu TFLOPS, mà bằng Băng thông bộ nhớ chia cho Dung lượng mô hình! Ví dụ, mô hình Llama-3 8B ở chuẩn FP16 nặng ; để sinh 1 token, GPU A100 () bắt buộc phải đọc hết trọng số này từ HBM lên chip, nên tốc độ tối đa lý thuyết khi là:
Hãy quan sát cả cơ chế ma trận của KV Cache lẫn vị trí của hai pha Prefill / Decode trên biểu đồ Roofline dưới đây.
Hãy kiểm chứng sự tương đương toán học tuyệt đối và mức giảm số phép tính của KV Cache trong Bài tập 5.2.
Bài tập 5.2 Cài đặt Causal Self-Attention có bộ nhớ đệm KV Cache
Cài đặt hàm causal_attn_step(x, kv_cache=None) thực hiện một bước chú ý nhân quả:
- Chiếu đầu vào
x(kích thước(B, L_in, d_model)) quaW_q,W_k,W_v. - Nếu
kv_cacheđược truyền vào dưới dạng tuple(k_prev, v_prev), hãy nối (torch.cat)kvàvmới vào sau bộ đệm theo chiều chuỗi (dim=1) để tạo thànhnew_cache = (k, v). - Tính ma trận điểm chú ý (nếu
L_in > 1, tức pha Prefill, hãy áp dụng mặt nạ tam giác trêntorch.triu(..., diagonal=1)gán-inf), qua Softmax, nhân với , chiếu quaW_ovà trả về vector đầu ra ở vị trí cuối cùngout[:, -1:, :]cùngnew_cache.
So sánh sai số tuyệt đối lớn nhất giữa 16 bước sinh không có KV Cache (luôn truyền toàn bộ tiền tố seq_tokens[:, :cur_len, :]) và có KV Cache (1 bước Prefill 8 token + 16 bước Decode từng token một), đồng thời đếm tổng số lượt token phải đi qua phép chiếu tuyến tính QKV.
import math
import torch
import torch.nn as nn
torch.manual_seed(7)
B, L_prompt, gen_steps, d_model = 1, 8, 16, 64
W_q = nn.Linear(d_model, d_model, bias=False)
W_k = nn.Linear(d_model, d_model, bias=False)
W_v = nn.Linear(d_model, d_model, bias=False)
W_o = nn.Linear(d_model, d_model, bias=False)
seq_tokens = torch.randn(B, L_prompt + gen_steps, d_model)
print("Kích thước chuỗi mô phỏng:", tuple(seq_tokens.shape))
Kích thước chuỗi mô phỏng: (1, 24, 64)
Hint
Ở pha Prefill (kv_cache is None), x có chiều dài L_q = L_k = 8, nên bạn cần mặt nạ nhân quả mask = torch.triu(torch.ones(L_q, L_k, dtype=torch.bool), diagonal=1) để không cho vị trí trước nhìn thấy vị trí sau.
Hint
Ở pha Decode (kv_cache is not None), x chỉ chứa đúng 1 token mới nhất (L_q = 1), trong khi k và v sau khi nối có chiều dài L_k = t. Vì token mới nhất ở vị trí thứ được phép nhìn thấy toàn bộ các vị trí , bạn không cần áp dụng mặt nạ nhân quả khi L_q == 1!
Điểm kiểm tra kết quả bạn cần đạt được
Sai số tuyệt đối lớn nhất giữa hai phương pháp chỉ là 2.98e-08 (sai số làm tròn dấu phẩy động 32-bit). Số lượt chiếu QKV tuyến tính giảm từ 268 lượt xuống đúng 24 lượt (bằng tổng độ dài chuỗi ), tiết kiệm gấp 11.2 lần chỉ trên chuỗi ngắn 24 token!
Sai số tuyệt đối lớn nhất (No-Cache vs KV-Cache): 2.98e-08 Kích thước bộ đệm K, V sau cùng: (1, 24, 64) Số lượt chiếu QKV tuyến tính (Không Cache): 268 lượt Số lượt chiếu QKV tuyến tính (Có KV Cache): 24 lượt (Giảm 11.2x)
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
torch.manual_seed(7)
B, L_prompt, gen_steps, d_model = 1, 8, 16, 64
W_q = nn.Linear(d_model, d_model, bias=False)
W_k = nn.Linear(d_model, d_model, bias=False)
W_v = nn.Linear(d_model, d_model, bias=False)
W_o = nn.Linear(d_model, d_model, bias=False)
seq_tokens = torch.randn(B, L_prompt + gen_steps, d_model)
print("Kích thước chuỗi mô phỏng:", tuple(seq_tokens.shape))
Kích thước chuỗi mô phỏng: (1, 24, 64)
def causal_attn_step(x, kv_cache=None):
# (1) Chiếu tuyến tính Q, K, V cho các token đầu vào x
q, k, v = W_q(x), W_k(x), W_v(x)
qkv_calls = x.size(1)
# (2) Nối k, v mới vào bộ nhớ đệm kv_cache nếu có
if kv_cache is not None:
k_prev, v_prev = kv_cache
k = torch.cat([k_prev, k], dim=1)
v = torch.cat([v_prev, v], dim=1)
new_cache = (k, v)
# (3) Tính điểm chú ý nhân quả và đầu ra cho vị trí cuối cùng
scores = (q @ k.transpose(-2, -1)) / math.sqrt(d_model)
L_q, L_k = q.size(1), k.size(1)
if L_q > 1:
mask = torch.triu(
torch.ones(L_q, L_k, dtype=torch.bool), diagonal=1
)
scores = scores.masked_fill(mask, -float("inf"))
weights = torch.softmax(scores, dim=-1)
out = W_o(weights @ v)
return out[:, -1:, :], new_cache, qkv_calls
with torch.no_grad():
# (A) Không dùng KV Cache: mỗi bước truyền lại toàn bộ tiền tố
outs_nocache, calls_nocache = [], 0
for step in range(gen_steps + 1):
cur_len = L_prompt + step
out_t, _, c = causal_attn_step(seq_tokens[:, :cur_len, :])
outs_nocache.append(out_t)
calls_nocache += c
# (B) Có KV Cache: pha Prefill (8 token) + 16 bước Decode (1 token)
outs_cached, calls_cached = [], 0
out_0, cache, c = causal_attn_step(seq_tokens[:, :L_prompt, :])
outs_cached.append(out_0)
calls_cached += c
for step in range(1, gen_steps + 1):
tok_t = seq_tokens[:, L_prompt + step - 1 : L_prompt + step, :]
out_t, cache, c = causal_attn_step(tok_t, kv_cache=cache)
outs_cached.append(out_t)
calls_cached += c
diff = (torch.cat(outs_nocache, 1) - torch.cat(outs_cached, 1)).abs()
print(f"Sai số tuyệt đối lớn nhất (No-Cache vs KV-Cache): {diff.max():.2e}")
print("Kích thước bộ đệm K, V sau cùng:", tuple(cache[0].shape))
print(f"Số lượt chiếu QKV tuyến tính (Không Cache): {calls_nocache} lượt")
ratio = calls_nocache / calls_cached
print(
f"Số lượt chiếu QKV tuyến tính (Có KV Cache): {calls_cached} lượt"
f" (Giảm {ratio:.1f}x)"
)
Sai số tuyệt đối lớn nhất (No-Cache vs KV-Cache): 2.98e-08 Kích thước bộ đệm K, V sau cùng: (1, 24, 64) Số lượt chiếu QKV tuyến tính (Không Cache): 268 lượt Số lượt chiếu QKV tuyến tính (Có KV Cache): 24 lượt (Giảm 11.2x)
Khi không có KV Cache, mỗi token ở đầu câu bị đem đi nhân với lặp đi lặp lại ở mọi bước sinh tiếp theo ( lượt). Khi bật KV Cache, mỗi token trong câu — dù thuộc phần câu hỏi hay phần trả lời — chỉ đi qua các ma trận trọng số đúng một lần duy nhất ( lượt). Với chuỗi dài token, KV Cache giảm số phép tính chiếu tuyến tính đi hơn 2.000 lần!
3. Thu gọn KV Cache: MQA, GQA và PagedAttention
KV Cache giúp chúng ta thoát khỏi độ phức tạp tính toán bậc hai ở mỗi bước Decode, nhưng nó lại đánh đổi bằng một cái giá rất đắt: dung lượng bộ nhớ VRAM tăng tuyến tính theo cả độ dài ngữ cảnh lẫn kích thước batch .
Hãy làm một phép tính nhẩm cho mô hình Llama-2 70B nếu dùng cơ chế Multi-Head Attention (MHA) tiêu chuẩn ( lớp, đầu chú ý, chiều mỗi đầu , lưu ở chuẩn FP16 tốn cho mỗi số thực):
- Với mỗi token, ở mỗi lớp và mỗi đầu chú ý, ta phải lưu 1 vector khóa ( số) và 1 vector giá trị ( số).
- Dung lượng KV Cache cho 1 token duy nhất trên toàn mạng là:
- Nếu hệ thống phục vụ đồng thời một batch gồm người dùng với độ dài ngữ cảnh token, tổng số token cần lưu trong KV Cache là token. Dung lượng VRAM riêng cho KV Cache sẽ là:
Con số này còn lớn gấp đôi dung lượng của bản thân trọng số mô hình 70B ()! Hơn thế nữa, ở mỗi bước Decode, GPU phải đọc toàn bộ KV Cache này từ HBM lên chip để tính tích vô hướng , khiến nút cổ chai băng thông bộ nhớ càng trở nên trầm trọng.
3.1 Từ Multi-Query Attention (MQA) đến Grouped-Query Attention (GQA)
Làm thế nào để cắt giảm kích thước KV Cache đi nhiều lần mà không làm giảm số lượng đầu truy vấn (Query heads) của mô hình?
Năm 2019, Noam Shazeer (một trong các đồng tác giả của Transformer gốc) nhận ra một sự bất đối xứng thú vị (Shazeer, 2019): trong Multi-Head Attention, chúng ta rất cần nhiều đầu truy vấn để mỗi đầu đặt một “câu hỏi” khác nhau ở mỗi bước. Nhưng các bảng tra cứu và thì hoàn toàn có thể dùng chung! Ông đề xuất Multi-Query Attention (MQA): giữ nguyên toàn bộ đầu Query, nhưng chỉ sử dụng duy nhất 1 đầu Key và 1 đầu Value chia sẻ cho tất cả đầu Query đó. Với , MQA lập tức làm giảm dung lượng và băng thông đọc của KV Cache đi đúng 64 lần!
Tuy nhiên, việc ép cả 64 đầu Query phải nhìn vào cùng 1 không gian Key/Value duy nhất đôi khi làm giảm độ chính xác trên các bài toán suy luận phức tạp. Năm 2023, Ainslie và cộng sự tại Google đã đưa ra điểm cân bằng vàng mang tên Grouped-Query Attention (GQA) (Ainslie & others, 2023):
- Ta chia đầu Query thành nhóm bằng nhau (với , tức có đầu Key/Value).
- Mỗi nhóm gồm đầu Query sẽ chia sẻ chung 1 đầu Key và 1 đầu Value.
- Công thức tổng quát tính dung lượng bộ nhớ KV Cache (tính bằng Bytes) cho batch size và độ dài chuỗi giờ đây là:
Ngày nay, GQA với đầu Key/Value đã trở thành tiêu chuẩn mặc định của hầu hết mọi mô hình ngôn ngữ lớn hiện đại (bao gồm Llama-3 8B/70B, Qwen-2.5, Mistral và Gemma-2), giúp giảm dung lượng KV Cache đi đến hoàn toàn miễn phí về mặt chất lượng!
3.2 Quản lý bộ nhớ phân trang PagedAttention (vLLM)
Ngay cả khi đã dùng GQA để thu gọn kích thước mỗi token trong KV Cache, một vấn đề hệ thống cực lớn khác vẫn tồn tại khi vận hành máy chủ suy luận cho nhiều người dùng cùng lúc: sự phân mảnh bộ nhớ (memory fragmentation).
Trong PyTorch thông thường, một tensor kv_cache của một yêu cầu phải được cấp phát dưới dạng một khối bộ nhớ liên tục (contiguous memory) trên VRAM. Nhưng khi người dùng vừa gửi câu hỏi vào, hệ thống không thể biết trước câu trả lời sẽ dài token hay token! Do đó, các hệ thống thế hệ đầu buộc phải đặt chỗ trước (pre-allocate) một vùng nhớ liên tục bằng đúng độ dài tối đa (ví dụ token) cho mỗi yêu cầu.
Cách cấp phát liên tục này gây ra sự lãng phí khủng khiếp (Kwon & others, 2023):
- Phân mảnh nội (Internal fragmentation): Vùng nhớ đã đặt chỗ cho token nhưng thực tế câu trả lời chỉ dài token; hơn phần bộ nhớ còn lại bị bỏ không mà không yêu cầu nào khác được chạm vào.
- Phân mảnh ngoại (External fragmentation): Khi các yêu cầu có độ dài khác nhau kết thúc và giải phóng bộ nhớ, VRAM bị băm nhỏ thành những khoảng trống rời rạc không đủ lớn để chứa một khối liên tục mới.
Năm 2023, nhóm nghiên cứu tại UC Berkeley đã giải quyết triệt để bài toán này bằng PagedAttention (vLLM) (Kwon & others, 2023), mượn trực tiếp nguyên lý bộ nhớ ảo phân trang (virtual memory paging) của hệ điều hành Linux/Windows:
- Thay vì cấp phát một mảng lớn liên tục cho mỗi yêu cầu, toàn bộ VRAM dành cho KV Cache được chia thành các khối (KV blocks / pages) có kích thước nhỏ cố định (ví dụ mỗi khối chứa đúng token).
- Một yêu cầu sinh đến đâu mới được cấp phát thêm khối mới đến đó (on-demand allocation). Các khối của cùng một cuộc hội thoại không cần nằm cạnh nhau trong bộ nhớ vật lý HBM, mà được ánh xạ thông qua một Bảng khối (Block Table).
- Nhân tính toán CUDA của PagedAttention đọc trực tiếp bảng khối này để gom các vector từ các trang vật lý rời rạc ngay trong lúc tính điểm chú ý.
Đặc biệt, PagedAttention còn mở ra khả năng chia sẻ tiền tố (Prefix Caching / Copy-on-Write): khi hàng nghìn yêu cầu cùng dùng chung một đoạn System Prompt dài (hoặc tài liệu RAG chung), tất cả chúng chỉ cần trỏ chung vào cùng các trang KV vật lý của đoạn mở đầu đó mà không tốn thêm một byte VRAM nào!
4. FlashAttention và lượng tử hóa trọng số (Quantization)
Ở Mục 2 và Mục 3, chúng ta đã thấy bức tường băng thông bộ nhớ (Memory Wall) kìm hãm cả hai thành phần lớn nhất của mô hình:
- Ở pha Prefill (hoặc khi ngữ cảnh rất dài), việc đọc/ghi ma trận chú ý trung gian ra vào bộ nhớ HBM làm tắc nghẽn cơ chế Self-Attention.
- Ở pha Decode, việc phải đọc toàn bộ ma trận trọng số từ HBM cho mỗi token làm tắc nghẽn các lớp tuyến tính (Linear / FFN).
Chúng ta sẽ lần lượt phá vỡ hai nút cổ chai này bằng FlashAttention và Lượng tử hóa Trọng số (Weight Quantization).
4.1 FlashAttention: Tránh ghi ma trận ra HBM nhờ Online Softmax
Trên một GPU như A100, bộ nhớ được tổ chức theo cấu trúc phân cấp với sự chênh lệch tốc độ rất lớn:
- Bộ nhớ chính HBM (VRAM): Dung lượng rất lớn (), nhưng băng thông chậm ().
- Bộ nhớ đệm trên chip SRAM (Shared Memory / L1 Cache): Băng thông cực nhanh (, nhanh gấp HBM), nhưng dung lượng cực nhỏ (chỉ khoảng trên mỗi Streaming Multiprocessor, tổng cộng trên toàn chip).
Cách cài đặt Self-Attention tiêu chuẩn trong PyTorch () thực hiện 3 bước tách rời, buộc GPU phải ghi các ma trận trung gian khổng lồ kích thước ra HBM rồi lại đọc ngược vào SRAM:
- Đọc từ HBM lên SRAM, tính , rồi ghi toàn bộ ra HBM.
- Đọc từ HBM lên SRAM, tính , rồi ghi toàn bộ ra HBM.
- Đọc và từ HBM lên SRAM, tính , rồi ghi ra HBM.
Khi độ dài chuỗi , ma trận có tới hơn 1 tỷ phần tử cho mỗi đầu chú ý! Thời gian di chuyển ma trận này qua lại giữa HBM và SRAM lâu gấp nhiều lần thời gian thực hiện phép nhân ma trận thực sự.
Năm 2022, Tri Dao và cộng sự giới thiệu FlashAttention (Dao & others, 2022) với ý tưởng cốt lõi: Không bao giờ tạo ra và ghi ma trận xuống HBM! Thay vào đó, ta chia (tile) các ma trận thành từng khối nhỏ nạp vừa vào SRAM, tính điểm chú ý của từng khối rồi cộng dồn trực tiếp vào kết quả đầu ra ngay bên trong SRAM trước khi ghi về HBM.
Nhưng có một rào cản toán học: hàm (với để tránh tràn số) có mẫu số phụ thuộc vào toàn bộ hàng . Làm sao ta có thể nhân với khi mới chỉ đọc được khối thứ nhất của và ?
Bí quyết nằm ở kỹ thuật Online Softmax (chuẩn hóa lại trực tuyến). Giả sử khi đọc xong khối thứ nhất , ta lưu giá trị lớn nhất cục bộ , tổng mẫu số cục bộ , và vector đầu ra tạm thời . Khi nạp tiếp khối thứ hai vào SRAM, ta cập nhật thống kê toàn cục mới chỉ bằng một phép nhân hiệu chỉnh hệ số mũ:
Nhờ quy tắc cập nhật đệ quy tuyệt đẹp này, GPU có thể quét qua từng mảnh của trong SRAM và cuối cùng thu được chính xác tuyệt đối ma trận mà không hề tốn một byte HBM nào cho ma trận !
4.2 Lượng tử hóa trọng số (Weight Quantization): Nén FP16 xuống INT8 và INT4
Nếu FlashAttention giải quyết nút cổ chai của cơ chế Attention, thì ở pha Decode (với độ dài chuỗi vừa phải), hơn thời gian lại nằm ở việc đọc các ma trận trọng số từ HBM vào SRAM.
Vì tốc độ pha Decode tỷ lệ nghịch với dung lượng byte của trọng số:
- Nếu ta nén mỗi trọng số từ chuẩn 16-bit (
FP16/BF16, ) xuống số nguyên 8-bit (INT8, ), dung lượng mô hình giảm và tốc độ sinh token lý thuyết tăng gấp . - Nếu nén xuống số nguyên 4-bit (
INT4, ), dung lượng mô hình 70B giảm từ xuống chỉ còn (chạy vừa trên một card GPU tiêu dùng hoặc Mac Studio!), và tốc độ đọc bộ nhớ tăng gần .
Cơ chế lượng tử hóa hậu huấn luyện (Post-Training Quantization — PTQ) phổ biến nhất là lượng tử hóa đối xứng Absmax (Symmetric Absmax Quantization). Để ánh xạ một vector thực sang lưới số nguyên -bit nằm trong đoạn với (ví dụ cho INT8 và cho INT4), ta tìm giá trị tuyệt đối lớn nhất và định nghĩa hệ số tỷ lệ (scale factor) :
Khi thực hiện phép nhân ma trận trong lúc suy luận, các trọng số nguyên được nạp từ HBM lên SRAM với băng thông cực nhỏ, sau đó được giải lượng tử hóa (dequantize) ngay trên chip về lại số thực xấp xỉ để nhân với vector kích hoạt đầu vào .
Tuy nhiên, nếu dùng chung một hệ số tỷ lệ cho toàn bộ ma trận (Per-Tensor Quantization), một rắc rối lớn sẽ xuất hiện trên các LLM có quy mô từ 6.7 tỷ tham số trở lên (Dettmers & others, 2022): các đặc trưng ngoại lai (emergent outlier features). Một số kênh ẩn có độ lớn vượt trội gấp các kênh bình thường. Khi một phần tử ngoại lai kéo lên rất cao, hệ số tỷ lệ trở nên quá lớn, khiến các trọng số nhỏ còn lại trong ma trận bị làm tròn hết về con số !
Để khắc phục hiện tượng này, bốn phương pháp kinh điển đã lần lượt ra đời:
- Chia nhóm (Group-wise Quantization): Thay vì lấy một hệ số cho cả ma trận hay cả hàng, ta chia mỗi hàng trọng số thành các nhóm nhỏ liên tiếp có kích thước phần tử. Mỗi nhóm có một hệ số (FP16) riêng biệt. Một phần tử ngoại lai giờ đây chỉ ảnh hưởng tới 64 phần tử trong nhóm của nó thay vì làm hỏng cả ma trận, trong khi chi phí lưu thêm các hệ số chỉ tốn thêm mỗi tham số!
- LLM.int8() [@dettmers2022llmint8]: Phát hiện ra rằng các giá trị ngoại lai luôn tập trung ở một vài chiều kênh cố định (outlier dimensions).
LLM.int8()tách riêng các cột ngoại lai này ra để nhân ở chuẩnFP16, còn ma trận còn lại được nhân bằng nhân phần cứngINT8. - GPTQ [@frantar2023gptq]: Lượng tử hóa từng cột trọng số xuống 4-bit và sử dụng thông tin đạo hàm bậc hai (nghịch đảo ma trận Hessian tính trên một tập dữ liệu hiệu chuẩn nhỏ) để cập nhật bù trừ sai số làm tròn của cột vừa lượng tử hóa sang các cột chưa lượng tử hóa phía sau.
- AWQ (Activation-aware Weight Quantization) [@lin2024awq]: Nhận thấy rằng không phải mọi trọng số đều quan trọng như nhau — những kênh trọng số đi kèm với đầu vào kích hoạt có biên độ lớn mới là nơi quyết định sai số của tích . AWQ nhân phóng đại (per-channel scaling) các kênh trọng số nhạy cảm nhất trước khi lượng tử hóa 4-bit để bảo vệ chúng khỏi sai số làm tròn mà không cần dùng độ chính xác hỗn hợp.
Hãy tự tay cài đặt lượng tử hóa theo nhóm (Group-wise INT8 & INT4) và kiểm chứng tác động của các kênh ngoại lai trong Bài tập 5.4 dưới đây.
Bài tập 5.4 Cài đặt lượng tử hóa đối xứng theo nhóm (Group-wise INT8 & INT4)
Cho ma trận trọng số W kích thước (256, 512) có chứa hai kênh ngoại lai (cột 17 và cột 203 có biên độ lớn gấp bình thường). Hãy viết hàm quantize_dequantize(W, bits=8, group_size=None):
- Nếu
group_size is None(lượng tử hóa toàn ma trận Per-Tensor), trải phẳngWthành 1 nhóm duy nhất(1, -1); ngược lại, định hình lạiWthành các nhóm có kích thước(-1, group_size). - Với mỗi nhóm (mỗi hàng của tensor đã reshape), tính giá trị lớn nhất tuyệt đối
abs_max, suy ra hệ số tỷ lệscales = abs_max / q_max(vớiq_max = (1 << (bits - 1)) - 1), và làm tròntorch.round(W_g / scales)kẹp trong đoạn[-q_max, q_max]. - Giải lượng tử hóa
W_hat = (W_q * scales).reshape(W.shape)và tính tổng dung lượng bộ nhớ (KB) gồm phần trọng số -bit cộng với phần lưu trữ mảngscalesở chuẩn FP16 ( mỗi hệ số tỷ lệ).
So sánh tỷ lệ nén bộ nhớ và sai số chuẩn Frobenius tương đối giữa chế độ Per-Tensor và Group-wise (g=64) cho cả INT8 và INT4.
import torch
torch.manual_seed(123)
d_out, d_in = 256, 512
W = torch.randn(d_out, d_in, dtype=torch.float32) * 0.08
# Chèn một vài kênh ngoại lai (outlier channels) như trong LLM thực tế
W[:, 17] *= 8.5
W[:, 203] *= 6.0
X = torch.randn(16, d_in, dtype=torch.float32)
Y_ref = X @ W.T
print(f"Ma trận W: {tuple(W.shape)} | Dung lượng FP16 gốc: {W.numel()*2/1024:.1f} KB")
Ma trận W: (256, 512) | Dung lượng FP16 gốc: 256.0 KB
Hint
Trong bước (2), dùng abs_max = W_g.abs().amax(dim=1, keepdim=True).clamp(min=1e-8) để tìm biên độ cực đại của từng nhóm riêng biệt và giữ nguyên số chiều (num_groups, 1) khi chia W_g / scales.
Hint
Dung lượng bộ nhớ (tính theo byte) của trọng số sau khi nén là W.numel() * (bits / 8.0), và dung lượng của các hệ số tỷ lệ FP16 là scales.numel() * 2.0. Chia tổng hai con số này cho 1024.0 để đổi sang KB.
Điểm kiểm tra kết quả bạn cần đạt được
Ở chuẩn INT4 Per-Tensor, hai kênh ngoại lai làm sai số đầu ra vọt lên tới 31.25% (mô hình hỏng hoàn toàn). Khi chia nhóm INT4 Group-wise (g=64), sai số giảm mạnh gần 4 lần xuống còn 8.42% trong khi chỉ tốn thêm 4.0 KB cho các hệ số tỷ lệ (vẫn nén 3.76x so với FP16). Với INT8 Group-wise (g=64), sai số chỉ còn 0.49%!
[INT8 Per-Tensor ] Bộ nhớ: 128.0 KB (Nén 2.00x) | Sai số Y: 1.84% [INT8 Group-wise g=64 ] Bộ nhớ: 132.0 KB (Nén 1.94x) | Sai số Y: 0.49% [INT4 Per-Tensor ] Bộ nhớ: 64.0 KB (Nén 4.00x) | Sai số Y: 31.25% [INT4 Group-wise g=64 ] Bộ nhớ: 68.0 KB (Nén 3.76x) | Sai số Y: 8.42%
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_out, d_in = 256, 512
W = torch.randn(d_out, d_in, dtype=torch.float32) * 0.08
# Chèn một vài kênh ngoại lai (outlier channels) như trong LLM thực tế
W[:, 17] *= 8.5
W[:, 203] *= 6.0
X = torch.randn(16, d_in, dtype=torch.float32)
Y_ref = X @ W.T
print(f"Ma trận W: {tuple(W.shape)} | Dung lượng FP16 gốc: {W.numel()*2/1024:.1f} KB")
Ma trận W: (256, 512) | Dung lượng FP16 gốc: 256.0 KB
def quantize_dequantize(W, bits=8, group_size=None):
q_max = (1 << (bits - 1)) - 1
orig_shape = W.shape
# (1) Chia ma trận thành từng nhóm kích thước group_size
if group_size is None:
W_g = W.reshape(1, -1)
else:
W_g = W.reshape(-1, group_size)
# (2) Tính hệ số tỷ lệ Absmax cho từng nhóm và làm tròn
abs_max = W_g.abs().amax(dim=1, keepdim=True).clamp(min=1e-8)
scales = abs_max / q_max
W_q = torch.clamp(torch.round(W_g / scales), -q_max, q_max)
# (3) Giải lượng tử hóa (Dequantize) và tính dung lượng bộ nhớ
W_hat = (W_q * scales).reshape(orig_shape)
weight_bytes = W.numel() * (bits / 8.0)
scale_bytes = scales.numel() * 2.0 # Lưu scale ở dạng FP16
total_kb = (weight_bytes + scale_bytes) / 1024.0
return W_hat, total_kb
fp16_kb = (W.numel() * 2.0) / 1024.0
configs = [
("INT8 Per-Tensor", 8, None),
("INT8 Group-wise g=64", 8, 64),
("INT4 Per-Tensor", 4, None),
("INT4 Group-wise g=64", 4, 64),
]
for name, bits, g_size in configs:
W_hat, mem_kb = quantize_dequantize(W, bits=bits, group_size=g_size)
Y_hat = X @ W_hat.T
rel_err = (torch.norm(Y_ref - Y_hat) / torch.norm(Y_ref)).item() * 100
ratio = fp16_kb / mem_kb
print(
f"[{name:21s}] Bộ nhớ: {mem_kb:5.1f} KB (Nén {ratio:.2f}x)"
f" | Sai số Y: {rel_err:.2f}%"
)
[INT8 Per-Tensor ] Bộ nhớ: 128.0 KB (Nén 2.00x) | Sai số Y: 1.84% [INT8 Group-wise g=64 ] Bộ nhớ: 132.0 KB (Nén 1.94x) | Sai số Y: 0.49% [INT4 Per-Tensor ] Bộ nhớ: 64.0 KB (Nén 4.00x) | Sai số Y: 31.25% [INT4 Group-wise g=64 ] Bộ nhớ: 68.0 KB (Nén 3.76x) | Sai số Y: 8.42%
Bài tập này minh họa trực tiếp lý do tại sao mọi định dạng lượng tử hóa 4-bit hiện đại (như GGUF Q4_K_M trong llama.cpp, GPTQ, hay AWQ) đều bắt buộc phải dùng lượng tử hóa theo nhóm nhỏ (). Chỉ với chi phí phụ trội trên (tức thêm cho mỗi trọng số), chúng ta cô lập được ảnh hưởng của các kênh ngoại lai trong phạm vi từng nhóm 64 phần tử thay vì để chúng phá hủy độ phân giải của toàn bộ tham số trong ma trận!
5. Giải mã suy đoán (Speculative Decoding)
Ngay cả khi chúng ta đã trang bị KV Cache, GQA, FlashAttention và lượng tử hóa trọng số, bản chất tự hồi quy của pha Decode vẫn giữ nguyên một định luật khắc nghiệt: muốn sinh ra token từ một mô hình đích khổng lồ (Target Model, ví dụ 70B), ta bắt buộc phải đọc toàn bộ trọng số của mô hình 70B đó từ HBM lên chip đúng lần tuần tự.
Trong khi đó, như ta đã thấy trên biểu đồ Roofline ở Mục 2.2, khi chạy với kích thước batch nhỏ (), cường độ số học của GPU cực thấp: thời gian để mô hình 70B xử lý 1 token và thời gian để nó xử lý song song 5 token cùng lúc là gần như bằng nhau (vì cả hai đều chỉ đọc trọng số 70B từ HBM đúng 1 lần!).
Câu hỏi táo bạo được đặt ra là: Liệu ta có thể kiểm tra song song 5 token cùng lúc trong 1 lần chạy mô hình lớn, khi mà ta còn chưa biết 5 token đó là gì?
5.1 Nguyên lý Đề xuất và Kiểm chứng (Draft-and-Verify)
Năm 2023, Yaniv Leviathan và các cộng sự tại Google Research đã đưa ra câu trả lời thông qua thuật toán Giải mã Suy đoán (Speculative Decoding) (Leviathan & Matias, 2023). Ý tưởng xuất phát từ một quan sát rất thực tế: trong bất kỳ đoạn văn bản nào, phần lớn các token tiếp theo đều là những từ nối, dấu câu hoặc cụm từ cố định rất dễ đoán (như "Hà" "Nội", "Việt" "Nam", "import" "torch"); chỉ có một số ít vị trí mới thực sự cần đến trí thông minh của mô hình 70B.
Thay vì dùng mô hình đích lớn (phân phối ) để mò mẫm từng bước, ta đặt cạnh nó một mô hình nháp nhỏ (Draft Model — ) có phân phối (ví dụ mô hình 1B cùng họ, nhẹ hơn và nhanh hơn gấp ). Mỗi chu kỳ suy đoán gồm hai bước:
- Bước Đề xuất (Drafting): Chạy tuần tự mô hình nháp nhỏ trong bước (thường chọn hoặc ) để sinh nhanh một chuỗi token ứng viên cùng các xác suất nháp tương ứng .
- Bước Kiểm chứng song song (Parallel Verification): Nối cả token nháp này vào sau ngữ cảnh hiện tại và đưa qua mô hình đích lớn chỉ trong 1 lượt chạy tiến (forward pass) duy nhất. Nhờ tính chất song song của Transformer (giống hệt pha Prefill!), chỉ với 1 lần đọc trọng số từ HBM, mô hình lớn trả về đồng thời tất cả phân phối xác suất đích: và cả phân phối ở vị trí tiếp theo !
5.2 Thuật toán Lấy mẫu Bác bỏ Suy đoán (Speculative Rejection Sampling)
Nếu chúng ta đang dùng giải mã tham lam (), việc kiểm chứng rất đơn giản: ta duyệt từ đến , giữ lại các token nháp chừng nào , và dừng lại ngay tại vị trí đầu tiên mà mô hình nháp đoán khác mô hình đích.
Nhưng khi chọn mẫu ngẫu nhiên (), làm thế nào để chấp nhận hoặc bác bỏ các token nháp sao cho token đầu ra cuối cùng tuân theo đúng 100% phân phối đích mà không hề bị thiên lệch theo mô hình nháp?
Leviathan et al. (Leviathan & Matias, 2023) giải quyết bài toán này bằng Lấy mẫu Bác bỏ Suy đoán (Speculative Rejection Sampling). Duyệt tuần tự từng vị trí :
- Rút một số ngẫu nhiên phân phối đều và so sánh với tỷ số xác suất giữa mô hình đích và mô hình nháp tại token ứng viên :
- Trường hợp 1 (Chấp nhận — ): Token được giữ lại chính thức! Ta chuyển sang kiểm tra vị trí . (Lưu ý: nếu mô hình đích đánh giá cao hơn hoặc bằng mô hình nháp, tức , thì token đó luôn luôn được chấp nhận ).
- Trường hợp 2 (Bác bỏ — ): Ta bác bỏ cùng toàn bộ các token nháp phía sau nó (). Tuy nhiên, ta không phải chạy lại mô hình lớn! Ta lập tức rút một token thay thế tại vị trí từ phân phối phần dư đã chuẩn hóa (normalized residual distribution) chỉ chứa những token mà mô hình nháp đã đánh giá thấp hơn mô hình đích: rồi kết thúc chu kỳ hiện tại.
- Trường hợp Thưởng đặc biệt: Nếu toàn bộ token nháp đều được chấp nhận, ta lấy mẫu thêm 1 token thứ hoàn toàn miễn phí từ phân phối mà mô hình lớn đã tính sẵn ở đầu ra cuối cùng! Như vậy, mỗi chu kỳ luôn sinh ra ít nhất token và tối đa token mới.
Nếu gọi là tỷ lệ chấp nhận trung bình mỗi bước và là tỷ số thời gian chạy 1 bước của mô hình nháp so với mô hình đích, thì kỳ vọng số token sinh ra trong một chu kỳ là , và hệ số tăng tốc thực tế là:
Hãy khám phá trực quan từng bước chấp nhận/bác bỏ và đường cong tăng tốc trên biểu đồ tương tác dưới đây.
Hãy kết thúc chương học bằng việc tự tay cài đặt bước kiểm chứng suy đoán và xác nhận định lý bảo toàn phân phối bằng mô phỏng Monte Carlo trong Bài tập 5.5.
Bài tập 5.5 Cài đặt và kiểm chứng Speculative Rejection Sampling
Cho hai phân phối xác suất rời rạc trên 6 token: phân phối của mô hình đích p_target và phân phối của mô hình nháp q_draft (khác biệt nhau đáng kể). Hãy viết hàm speculative_Verify_samples(p, q, num_trials=200_000) mô phỏng song song 200,000 lượt rút mẫu suy đoán:
- Rút
num_trialsứng viên nhápx_drafttừ phân phốiq. - Tính xác suất chấp nhận
torch.clamp(p[x_draft] / q[x_draft], max=1.0)cho từng mẫu và so sánh với biến ngẫu nhiên phân phối đềuu = torch.rand(num_trials). - Với các mẫu bị bác bỏ (
~accepted), rút mẫu thay thế từ phân phối phần dư chuẩn hóap_res = clamp(p - q, min=0) / sum(clamp(p - q, min=0)).
Tính tỷ lệ chấp nhận lý thuyết , so sánh với tỷ lệ chấp nhận thực nghiệm và kiểm tra xem phân phối tần suất thu được p_spec(x) có hội tụ chính xác về p_target(x) hay không!
import torch
torch.manual_seed(2026)
tokens = ["Hà", "Nội", "Sài", "Gòn", "Đà", "Nẵng"]
# Phân phối của mô hình đích lớn p(x) và mô hình nháp nhỏ q(x)
p_target = torch.tensor([0.42, 0.25, 0.15, 0.10, 0.05, 0.03])
q_draft = torch.tensor([0.22, 0.38, 0.18, 0.08, 0.09, 0.05])
print("Tổng p(x):", p_target.sum().item(), "| Tổng q(x):", q_draft.sum().item())
Tổng p(x): 1.0 | Tổng q(x): 1.0
Hint
Sử dụng torch.multinomial(q, num_samples=num_trials, replacement=True) để rút đồng thời toàn bộ 200,000 mẫu nháp mà không cần dùng vòng lặp for của Python.
Hint
Để tính phân phối phần dư, viết residual = torch.clamp(p - q, min=0.0) rồi chia cho residual.sum(). Sau đó thay thế các vị trí bị bác bỏ bằng x_final[~accepted] = torch.multinomial(p_res, num_samples=num_rej, replacement=True).
Điểm kiểm tra kết quả bạn cần đạt được
Tỷ lệ chấp nhận thực nghiệm đạt 77.96%, khớp sát sao với giá trị lý thuyết . Mặc dù mô hình nháp q_draft đánh giá lệch hẳn (ví dụ gán cho "Hà" chỉ 0.22 thay vì 0.42, và gán cho "Nội" tới 0.38 thay vì 0.25), phân phối đầu ra sau bước sửa phần dư đạt 0.4191 cho "Hà" và 0.2514 cho "Nội" — sai lệch cực đại so với p_target chỉ là 0.0014!
Tỷ lệ chấp nhận lý thuyết α = Σ min(p, q): 78.00% Tỷ lệ chấp nhận thực nghiệm (200,000 mẫu): 77.96% Sai lệch cực đại |p_spec(x) - p(x)|: 0.0014 Token | p_target(x) | q_draft(x) | p_spec(x) ------------------------------------------------- Hà | 0.4200 | 0.2200 | 0.4191 Nội | 0.2500 | 0.3800 | 0.2514 Sài | 0.1500 | 0.1800 | 0.1498 Gòn | 0.1000 | 0.0800 | 0.0999 Đà | 0.0500 | 0.0900 | 0.0497 Nẵng | 0.0300 | 0.0500 | 0.0301
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(2026)
tokens = ["Hà", "Nội", "Sài", "Gòn", "Đà", "Nẵng"]
# Phân phối của mô hình đích lớn p(x) và mô hình nháp nhỏ q(x)
p_target = torch.tensor([0.42, 0.25, 0.15, 0.10, 0.05, 0.03])
q_draft = torch.tensor([0.22, 0.38, 0.18, 0.08, 0.09, 0.05])
print("Tổng p(x):", p_target.sum().item(), "| Tổng q(x):", q_draft.sum().item())
Tổng p(x): 1.0 | Tổng q(x): 1.0
def speculative_Verify_samples(p, q, num_trials=200_000):
# (1) Mô hình nháp đề xuất x ~ q(x)
x_draft = torch.multinomial(q, num_samples=num_trials, replacement=True)
# (2) Chấp nhận x với xác suất min(1, p(x) / q(x))
accept_prob = torch.clamp(p[x_draft] / q[x_draft], max=1.0)
u = torch.rand(num_trials)
accepted = u < accept_prob
# (3) Nếu bị bác bỏ, rút mẫu bù từ p'(x) = norm(max(0, p(x) - q(x)))
residual = torch.clamp(p - q, min=0.0)
p_res = residual / residual.sum()
num_rej = int((~accepted).sum().item())
x_final = x_draft.clone()
x_final[~accepted] = torch.multinomial(
p_res, num_samples=num_rej, replacement=True
)
return x_final, accepted.float().mean().item()
alpha_theory = torch.minimum(p_target, q_draft).sum().item()
samples, alpha_emp = speculative_Verify_samples(p_target, q_draft)
counts = torch.bincount(samples, minlength=len(tokens)).float()
p_emp = counts / len(samples)
max_err = (p_emp - p_target).abs().max().item()
print(f"Tỷ lệ chấp nhận lý thuyết α = Σ min(p, q): {alpha_theory*100:.2f}%")
print(f"Tỷ lệ chấp nhận thực nghiệm (200,000 mẫu): {alpha_emp*100:.2f}%")
print(f"Sai lệch cực đại |p_spec(x) - p(x)|: {max_err:.4f}\n")
print("Token | p_target(x) | q_draft(x) | p_spec(x)")
print("-" * 49)
for i, tok in enumerate(tokens):
print(
f"{tok:6s} | {p_target[i]:.4f} |"
f" {q_draft[i]:.4f} | {p_emp[i]:.4f}"
)
Tỷ lệ chấp nhận lý thuyết α = Σ min(p, q): 78.00% Tỷ lệ chấp nhận thực nghiệm (200,000 mẫu): 77.96% Sai lệch cực đại |p_spec(x) - p(x)|: 0.0014 Token | p_target(x) | q_draft(x) | p_spec(x) ------------------------------------------------- Hà | 0.4200 | 0.2200 | 0.4191 Nội | 0.2500 | 0.3800 | 0.2514 Sài | 0.1500 | 0.1800 | 0.1498 Gòn | 0.1000 | 0.0800 | 0.0999 Đà | 0.0500 | 0.0900 | 0.0497 Nẵng | 0.0300 | 0.0500 | 0.0301
Hãy nhìn vào bảng kết quả ở hai dòng đầu tiên:
- Với token
"Nội", mô hình nháp quá tự tin (), nên bước chấp nhận đã tự động cắt giảm tần suất của"Nội"từ về đúng . - Với token
"Hà", mô hình nháp đánh giá quá thấp (), nên mọi lần nháp đề xuất"Hà"đều được chấp nhận , và phần thiếu hụt được bù đắp chính xác từ các lần bác bỏ thông qua phân phối phần dư !
Đó là vẻ đẹp của Giải mã Suy đoán: bạn nhận được tốc độ của mô hình nhỏ, nhưng chất lượng và phân phối xác suất lại là của mô hình lớn 100%.
Đọc thêm Giải mã suy đoán không cần mô hình nháp riêng: Medusa và EAGLE
Trong thực tế triển khai, việc phải nạp và quản lý hai mô hình độc lập ( và ) có cùng bộ từ vựng đôi khi gây phức tạp cho hệ thống phân tán. Gần đây, các kỹ thuật như Medusa (Cai et al., 2024) và EAGLE (Li et al., 2024) đã cải tiến Speculative Decoding bằng cách gắn thêm vài đầu dự đoán nhẹ (drafting heads) trực tiếp lên trên lớp ẩn cuối cùng của chính mô hình đích để dự đoán song song các token ở vị trí , sau đó dùng mặt nạ chú ý hình cây (tree attention) để kiểm chứng nhiều nhánh ứng viên cùng lúc trong một lượt chạy duy nhất.
Nếu muốn đào sâu hơn nữa vào kỹ thuật tối ưu hóa suy luận, bạn có thể thử sức với các bài tập mở rộng dưới đây.
Đo băng thông bộ nhớ thực tế trên máy của bạn. Viết một đoạn mã PyTorch tạo một ma trận lớn ở chuẩn float16 () và đo thời gian thực hiện phép nhân Vector – Ma trận () so với phép nhân Ma trận – Ma trận (). Tính cường độ số học và băng thông bộ nhớ thực tế (GB/s) mà CPU hoặc GPU của bạn đạt được ở hai chế độ này.
Lượng tử hóa bất đối xứng (Asymmetric Zero-Point Quantization). Trong Bài tập 5.4, ta đã dùng lượng tử hóa đối xứng quanh điểm . Nếu một nhóm trọng số có phân bố lệch (ví dụ nằm trong đoạn ), lượng tử hóa đối xứng sẽ lãng phí một nửa dải số nguyên âm. Hãy cài đặt lượng tử hóa bất đối xứng ánh xạ vào thông qua hệ số tỷ lệ và điểm không , rồi so sánh sai số Frobenius với phương pháp đối xứng.
Cài đặt thuật toán Online Softmax một lượt duyệt. Viết hàm online_softmax_attention(q, K_blocks, V_blocks) nhận vào một truy vấn và danh sách các khối , cập nhật trực tiếp qua từng khối trong một vòng lặp duy nhất theo công thức ở Mục 4.1, và kiểm chứng kết quả trùng khớp với torch.softmax(q @ K.T / sqrt(d)) @ V.
Tìm độ dài suy đoán tối ưu. Viết một hàm Python vẽ đồ thị theo với các cặp khác nhau. Khi và , giá trị nào cho hệ số tăng tốc cao nhất? Tại sao khi tăng quá lớn, hệ số tăng tốc lại bắt đầu giảm xuống?
Tài liệu tham khảo
- 2019 Fast Transformer Decoding: One Write-Head is All You Need. arXiv. 3
- 2020 The Curious Case of Neural Text Degeneration. ICLR. 1
- 2022 FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness. NeurIPS. 4
- 2022 LLM.int8(): 8-bit Matrix Multiplication for Transformers at Scale. NeurIPS. 4
- 2023 GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints. EMNLP. 3
- 2023 GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers. ICLR. 4
- 2023 Efficient Memory Management for Large Language Model Serving with PagedAttention. SOSP. 3
- 2023 Fast Inference from Transformers via Speculative Decoding. ICML. 5
- 2024 AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration. MLSys. 4