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 5

Suy luận & Tối ưu hóa (Inference): Giải mã nhanh, bộ nhớ đệm KV và giới hạn phần cứng

Từ chiến lược chọn mẫu Top-p đến KV Cache, GQA, FlashAttention, lượng tử hóa trọng số và giải mã suy đoán (Speculative Decoding).

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ố θ\theta, 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 x1,x2,…,xtx_1, x_2, \dots, x_t và xuất ra một vector điểm số chưa chuẩn hóa z∈R∣V∣\mathbf{z} \in \mathbb{R}^{|V|} (gọi là logits) trên toàn bộ từ vựng VV.

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:

Công cụ & Mô hình Tham chiếu

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 (3232 lớp, dmodel=4096d_{\text{model}} = 4096, 3232 đầu Query, 88 đầu Key/Value) và Llama-3 70B (8080 lớp, dmodel=8192d_{\text{model}} = 8192, 6464 đầu Query, 88 đầu Key/Value).
  • Phần cứng tham chiếu: GPU NVIDIA A100 SXM 80GB (Đỉnh tính toán FP16: 312 TFLOPS312\text{ TFLOPS}, Băng thông bộ nhớ HBM2e: 2.0 TB/s2.0\text{ TB/s}) và H100 SXM 80GB (Đỉnh FP16: 989 TFLOPS989\text{ TFLOPS}, Băng thông HBM3: 3.35 TB/s3.35\text{ TB/s}).
  • Độ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, SGLang và 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 z=(z1,z2,…,z∣V∣)\mathbf{z} = (z_1, z_2, \dots, z_{|V|}) ở 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:

p(xt+1=wi∣x1:t)=exp⁡(zi)∑j=1∣V∣exp⁡(zj).p(x_{t+1} = w_i \mid x_{1:t}) = \frac{\exp(z_i)}{\sum_{j=1}^{|V|} \exp(z_j)}.

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ừ y1,…,yMy_1, \dots, y_M 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 tt, ta luôn chọn từ có xác suất lớn nhất:

xt+1=arg⁡max⁡w∈V p(w∣x1:t).x_{t+1} = \arg\max_{w \in V} \, p(w \mid x_{1: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 tt có thể mở ra một nhánh câu xuất sắc ở bước t+1t+1.

Để 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 BB ứ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 ∏tp(xt∣x<t)\prod_{t} p(x_t \mid x_{<t}).

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 p(w∣x1:t)p(w \mid x_{1:t}) 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 ∣V∣≈128,000|V| \approx 128{,}000 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ụ 10−510^{-5}). Khi cộng dồn 100,000100{,}000 từ như vậy lại, cái đuôi dài này có thể chiếm tới 10%−20%10\% - 20\% 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 độ (TT), Top-kk và Chọn mẫu Hạt nhân Top-pp (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 z\mathbf{z} trước khi rút mẫu:

  1. Nhiệt độ (Temperature Scaling — T>0T > 0): Trước khi đưa vào Softmax, ta chia toàn bộ logits cho hằng số nhiệt độ TT:

    pi(T)=exp⁡(zi/T)∑j=1∣V∣exp⁡(zj/T).p_i(T) = \frac{\exp(z_i / T)}{\sum_{j=1}^{|V|} \exp(z_j / T)}.

    Khi T<1T < 1, 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 T→0T \to 0, nó hội tụ về giải mã tham lam). Khi T>1T > 1, 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).

  2. Lọc Top-kk (Top-kk Sampling): Sắp xếp các token theo thứ tự xác suất giảm dần p(1)≥p(2)≥⋯≥p(∣V∣)p_{(1)} \ge p_{(2)} \ge \dots \ge p_{(|V|)}, chỉ giữ lại đúng kk token đầu tiên (ví dụ k=50k = 50) và gán logit của mọi token còn lại bằng −∞-\infty. Nhược điểm của Top-kk là số lượng kk 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 98%98\%), việc giữ k=50k=50 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 0.5%0.5\%), việc cắt ở k=10k=10 hay k=20k=20 lại chặt cụt mất phần lớn các ý tưởng hay.
  3. Chọn mẫu Hạt nhân Top-pp (Nucleus Sampling [@holtzman2020]): Thay vì cố định số lượng ứng viên kk, ta cố định khối lượng xác suất tích lũy p∈(0,1]p \in (0, 1] (thường chọn p=0.9p = 0.9 hoặc 0.950.95). Ta tìm tập con nhỏ nhất các token đứng đầu V(p)V^{(p)} sao cho tổng xác suất của chúng vừa chạm hoặc vượt ngưỡng pp:

    V(p)={w(1),…,w(m)  |  m=min⁡{j:∑i=1jp(i)(T)≥p}}.V^{(p)} = \left\{ w_{(1)}, \dots, w_{(m)} \;\middle|\; m = \min \left\{ j : \sum_{i=1}^{j} p_{(i)}(T) \ge p \right\} \right\}.

    Mọi token nằm ngoài V(p)V^{(p)} đề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 p~\tilde{p}:

    p~(w)={p(w)∑v∈V(p)p(v)neˆˊu w∈V(p),0ngược lại.\tilde{p}(w) = \begin{cases} \dfrac{p(w)}{\sum_{v \in V^{(p)}} p(v)} & \text{nếu } w \in V^{(p)}, \\[8pt] 0 & \text{ngược lại.} \end{cases}

Nhờ định nghĩa theo tổng tích lũy, kích thước hạt nhân ∣V(p)∣|V^{(p)}| tự động co giãn linh hoạt: thu nhỏ về chỉ 1−21-2 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.

Sân chơi tương tác: Nhiệt độ (T), Top-k và Hạt nhân Top-p
Chọn ngữ cảnh đầu vào (sáng tạo phẳng hoặc sự kiện nhọn) và kéo các thanh trượt Nhiệt độ T, Top-k, Top-p. Thanh mờ biểu diễn xác suất sau khi chia nhiệt độ p_i(T); thanh đậm biểu diễn xác suất cuối cùng p̃_i sau khi cắt bỏ các từ ngoài hạt nhân và chuẩn hóa lại.

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
Dữ liệu: Logits mô phỏng · Thời gian chạy: < 1 phút

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 H(p~)=−∑ip~ilog⁡2p~iH(\tilde{p}) = -\sum_i \tilde{p}_i \log_2 \tilde{p}_i (đơ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.

Mã khởi tạo
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-kk, bạn chỉ cần gán sorted_logits[top_k:] = -float("inf").

Hint

Để tìm ranh giới cắt Top-pp 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í jj 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 100%100\% và entropy bằng 0.00 bits0.00\text{ bits}. Với Top-p (T=0.8, p=0.9), hạt nhân giữ lại 6/106/10 token đầu tiên, nâng entropy lên 2.26 bits2.26\text{ bits}.

[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
Điều cần lưu ý

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 95%95\% trong khi top_p = 0.90, thì tại vị trí 00, cum_probs - probs bằng 0<0.900 < 0.90 nên "căn" được giữ lại; còn từ vị trí 11 trở đi, tổng tích lũy phía trước đã là 0.95≥0.900.95 \ge 0.90 nên toàn bộ các từ còn lại bị cắt sạch! Top-pp 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-kk và Top-pp, 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-pp Sampling: Thay vì cắt theo tổng tích lũy, Min-pp đặt ngưỡng cắt tương đối theo xác suất của token đứng đầu pmax⁡=max⁡ipip_{\max} = \max_i p_i: mọi token có pi<min_p×pmax⁡p_i < \text{min\_p} \times p_{\max} (ví dụ min_p=0.05\text{min\_p} = 0.05) sẽ bị loại bỏ. Cách này cực kỳ đơn giản và tránh được việc Top-pp đôi khi giữ lại quá nhiều từ vụn vặt khi pmax⁡p_{\max} nằm lấp lửng ở mức 85%85\% (chưa đủ 90%90\%).
  • Frequency & Presence Penalty: Giảm trực tiếp logit zi←zi−α⋅ci−β⋅I[ci>0]z_i \leftarrow z_i - \alpha \cdot c_i - \beta \cdot \mathbb{I}[c_i > 0] của các token đã xuất hiện cic_i 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 xt+1x_{t+1}, ta nối nó vào cuối chuỗi đầu vào để thành x1,…,xt,xt+1x_1, \dots, x_t, x_{t+1} rồi lại đưa vào mô hình để sinh tiếp xt+2x_{t+2}. 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 O(L2)O(L^2) 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 tt với ma trận biểu diễn đầu vào X1:t∈Rt×dX_{1:t} \in \mathbb{R}^{t \times d}. Lớp này nhân X1:tX_{1:t} với ba ma trận trọng số WQ,WK,WV∈Rd×dW_Q, W_K, W_V \in \mathbb{R}^{d \times d} để tạo ra:

Q=X1:tWQ=[q1⋮qt−1qt],K=X1:tWK=[k1⋮kt−1kt],V=X1:tWV=[v1⋮vt−1vt].Q = X_{1:t} W_Q = \begin{bmatrix} q_1 \\ \vdots \\ q_{t-1} \\ q_t \end{bmatrix}, \quad K = X_{1:t} W_K = \begin{bmatrix} k_1 \\ \vdots \\ k_{t-1} \\ k_t \end{bmatrix}, \quad V = X_{1:t} W_V = \begin{bmatrix} v_1 \\ \vdots \\ v_{t-1} \\ v_t \end{bmatrix}.

Có hai sự thật toán học vô cùng quan trọng ở đây:

  1. Nhờ mặt nạ nhân quả (causal mask), biểu diễn của các token ở vị trí 1,2,…,t−11, 2, \dots, t-1 hoàn toàn không phụ thuộc vào token mới xtx_t. Do đó, các hàng k1…kt−1k_1 \dots k_{t-1} và v1…vt−1v_1 \dots v_{t-1} ở bước tt giống hệt 100% với những gì chúng ta đã tính ở bước t−1t-1!
  2. Để dự đoán token tiếp theo xt+1x_{t+1}, 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 hth_t. Mà để tính hth_t, phép chú ý chỉ cần lấy tích vô hướng giữa truy vấn mới nhất qt∈R1×dq_t \in \mathbb{R}^{1 \times d} với tất cả các khóa k1…ktk_1 \dots k_t:
Attentiont(qt,K1:t,V1:t)=softmax ⁣(qtK1:t⊤dk)V1:t.\text{Attention}_t(q_t, K_{1:t}, V_{1:t}) = \text{softmax}\!\left(\frac{q_t K_{1:t}^\top}{\sqrt{d_k}}\right) V_{1:t}.

Nếu không lưu lại gì từ bước trước, ở mỗi bước tt ta phải tính lại toàn bộ tt hàng của Q,K,VQ, K, V; tổng chi phí sinh LL token sẽ tỷ lệ với ∑t=1Lt=L(L+1)2=O(L2)\sum_{t=1}^{L} t = \frac{L(L+1)}{2} = O(L^2).

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 K1:t−1K_{1:t-1} và V1:t−1V_{1:t-1} ở mọi lớp Transformer. Khi sinh token thứ tt, ta chỉ đưa duy nhất 1 token mới nhất xtx_t đi qua mạng:

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:

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:

  1. Đỉnh tính toán (Peak FLOP/s\text{Peak FLOP/s}): 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 312 TFLOPS=312×1012 FLOP/s312\text{ TFLOPS} = 312 \times 10^{12}\text{ FLOP/s} ở chuẩn FP16).
  2. Băng thông bộ nhớ (Memory Bandwidth\text{Memory Bandwidth}): 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 2.0 TB/s=2×1012 Bytes/s2.0\text{ TB/s} = 2 \times 10^{12}\text{ Bytes/s}).

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 — II):

I=Tổng soˆˊ pheˊp tıˊnh (FLOPs)Tổng soˆˊ Byte đọc/ghi bộ nhớ (Bytes)[FLOPs/Byte].I = \frac{\text{Tổng số phép tính (FLOPs)}}{\text{Tổng số Byte đọc/ghi bộ nhớ (Bytes)}} \quad [\text{FLOPs/Byte}].

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):

Iridge=312 TFLOPS2.0 TB/s=156 FLOPs/Byte.I_{\text{ridge}} = \frac{312\text{ TFLOPS}}{2.0\text{ TB/s}} = 156\text{ FLOPs/Byte}.

Hãy tính cường độ số học của một lớp tuyến Y=XWY = X W với ma trận trọng số W∈Rd×dW \in \mathbb{R}^{d \times d} lưu ở chuẩn FP16 (2 bytes2\text{ bytes} mỗi tham số, tổng dung lượng 2d2 bytes2 d^2\text{ bytes}) khi xử lý NN token cùng lúc (X∈RN×dX \in \mathbb{R}^{N \times d}):

I≈2Nd2 FLOPs2d2 Bytes=N FLOPs/Byte!I \approx \frac{2 N d^2\text{ FLOPs}}{2 d^2\text{ Bytes}} = N\text{ FLOPs/Byte!}
Pha suy luậnSố token xử lý cùng lúc (NN)Bản chất toán họcCường độ số học II (FP16)So với ngưỡng A100 (156 FLOPs/B156\text{ FLOPs/B})Trạng thái phần cứng
Pha Prefill (Prompt L=512,B=1L=512, B=1)N=512N = 512Ma trận ×\times Ma trận (GEMM)≈512 FLOPs/Byte\approx 512\text{ FLOPs/Byte}512>156512 > 156Giới hạn bởi tính toán (Compute-bound): Tensor Cores chạy hết 100%100\% công suất.
Pha Decode đơn lẻ (1 token,B=11\text{ token}, B=1)N=1N = 1Vector ×\times Ma trận (GEMV)≈1 FLOP/Byte\approx 1\text{ FLOP/Byte}1≪1561 \ll 156Giới hạn bởi bộ nhớ (Memory-bound): GPU nhàn rỗi >99%>99\%, tốc độ chỉ phụ thuộc thời gian đọc trọng số từ HBM.
Pha Decode theo Batch (1 token,B=641\text{ token}, B=64)N=64N = 64Ma trận nhỏ ×\times Ma trận≈64 FLOPs/Byte\approx 64\text{ FLOPs/Byte}64<15664 < 156Tận dụng băng thông tốt gấp 64×64\times, 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 16 GB16\text{ GB}; để sinh 1 token, GPU A100 (2000 GB/s2000\text{ GB/s}) bắt buộc phải đọc hết 16 GB16\text{ GB} trọng số này từ HBM lên chip, nên tốc độ tối đa lý thuyết khi B=1B=1 là:

Toˆˊc độ toˆˊi đa (Decode, B=1)≈Ba˘ng thoˆng HBMDung lượng trọng soˆˊ + KV Cache≈2000 GB/s16 GB=125 tokens/giaˆy.\text{Tốc độ tối đa (Decode, } B=1\text{)} \approx \frac{\text{Băng thông HBM}}{\text{Dung lượng trọng số + KV Cache}} \approx \frac{2000\text{ GB/s}}{16\text{ GB}} = 125\text{ tokens/giây}.

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.

Biểu đồ kép: Ma trận KV Cache ở bước t và Mô hình Roofline trên GPU
Trái: So sánh các hàng Q, K, V phải tính ở bước t khi không có và khi có KV Cache. Phải: Biểu đồ Roofline (thang log-log) trên A100/H100. Kéo thanh trượt Kích thước Batch B để thấy điểm hoạt động của pha Decode trượt dọc theo sườn nghiêng băng thông bộ nhớ tiến về phía trần tính toán.

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
Dữ liệu: Tensor ngẫu nhiên · Thời gian chạy: < 1 phút

Cài đặt hàm causal_attn_step(x, kv_cache=None) thực hiện một bước chú ý nhân quả:

  1. Chiếu đầu vào x (kích thước (B, L_in, d_model)) qua W_q, W_k, W_v.
  2. Nếu kv_cache được truyền vào dưới dạng tuple (k_prev, v_prev), hãy nối (torch.cat) k và v mới vào sau bộ đệm theo chiều chuỗi (dim=1) để tạo thành new_cache = (k, v).
  3. Tính ma trận điểm chú ý QK⊤/dmodelQ K^\top / \sqrt{d_{\text{model}}} (nếu L_in > 1, tức pha Prefill, hãy áp dụng mặt nạ tam giác trên torch.triu(..., diagonal=1) gán -inf), qua Softmax, nhân với VV, chiếu qua W_o và trả về vector đầu ra ở vị trí cuối cùng out[:, -1:, :] cùng new_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.

Mã khởi tạo
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ứ tt được phép nhìn thấy toàn bộ các vị trí 1…t1 \dots t, 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 8+16=248 + 16 = 24), 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)
Điều cần lưu ý

Khi không có KV Cache, mỗi token ở đầu câu bị đem đi nhân với WQ,WK,WVW_Q, W_K, W_V lặp đi lặp lại ở mọi bước sinh tiếp theo (∑step=016(8+step)=268\sum_{step=0}^{16} (8 + step) = 268 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 (8+16×1=248 + 16 \times 1 = 24 lượt). Với chuỗi dài L=4096L = 4096 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 O(L2)O(L^2) ở 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 LL lẫn kích thước batch BB.

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 (nlayers=80n_{\text{layers}} = 80 lớp, nheads=64n_{\text{heads}} = 64 đầu chú ý, chiều mỗi đầu dh=128d_h = 128, lưu ở chuẩn FP16 tốn 2 bytes2\text{ bytes} cho mỗi số thực):

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 (140 GB140\text{ GB})! Hơn thế nữa, ở mỗi bước Decode, GPU phải đọc toàn bộ 320 GB320\text{ GB} KV Cache này từ HBM lên chip để tính tích vô hướng qtK⊤q_t K^\top, 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 Q(1),…,Q(H)Q^{(1)}, \dots, Q^{(H)} để 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 KK và VV thì hoàn toàn có thể dùng chung! Ông đề xuất Multi-Query Attention (MQA): giữ nguyên toàn bộ HH đầu Query, nhưng chỉ sử dụng duy nhất 1 đầu Key và 1 đầu Value chia sẻ cho tất cả HH đầu Query đó. Với H=64H = 64, 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):

VRAMKV=2×bytes_per_param×nlayers×nkv×dh×L×B.\text{VRAM}_{\text{KV}} = 2 \times \text{bytes\_per\_param} \times n_{\text{layers}} \times n_{\text{kv}} \times d_h \times L \times B.

Ngày nay, GQA với nkv=8n_{\text{kv}} = 8 đầ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 4×4\times đến 8×8\times hoàn toàn miễn phí về mặt chất lượng!

Trực quan hóa MHA, GQA, MQA và Máy tính bộ nhớ KV Cache
Chọn chế độ MHA (8 đầu KV), GQA-4 (4 đầu KV), GQA-2 (chuẩn nén 8× như Llama-3) hoặc MQA (1 đầu KV), đồng thời kéo thanh trượt Độ dài ngữ cảnh L và Batch Size B để xem dung lượng VRAM tiết kiệm được so với giới hạn 80 GB của một GPU A100.

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 5050 token hay 2,0482{,}048 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 LmaxL_{\text{max}} (ví dụ 4,0964{,}096 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):

  1. Phân mảnh nội (Internal fragmentation): Vùng nhớ đã đặt chỗ cho 4,0964{,}096 token nhưng thực tế câu trả lời chỉ dài 300300 token; hơn 90%90\% 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.
  2. 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:

Đặ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:

  1. Ở pha Prefill (hoặc khi ngữ cảnh LL rất dài), việc đọc/ghi ma trận chú ý trung gian S,P∈RL×LS, P \in \mathbb{R}^{L \times L} ra vào bộ nhớ HBM làm tắc nghẽn cơ chế Self-Attention.
  2. Ở pha Decode, việc phải đọc toàn bộ ma trận trọng số WW 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 L×LL \times L 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:

Cách cài đặt Self-Attention tiêu chuẩn trong PyTorch (softmax(QK⊤/dk)V\text{softmax}(QK^\top / \sqrt{d_k}) V) 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 L×LL \times L ra HBM rồi lại đọc ngược vào SRAM:

  1. Đọc Q,K∈RL×dQ, K \in \mathbb{R}^{L \times d} từ HBM lên SRAM, tính S=QK⊤/dk∈RL×LS = QK^\top / \sqrt{d_k} \in \mathbb{R}^{L \times L}, rồi ghi toàn bộ SS ra HBM.
  2. Đọc SS từ HBM lên SRAM, tính P=softmax(S)∈RL×LP = \text{softmax}(S) \in \mathbb{R}^{L \times L}, rồi ghi toàn bộ PP ra HBM.
  3. Đọc PP và VV từ HBM lên SRAM, tính O=PV∈RL×dO = PV \in \mathbb{R}^{L \times d}, rồi ghi OO ra HBM.

Khi độ dài chuỗi L=32,768L = 32{,}768, ma trận L×LL \times L có tới hơn 1 tỷ phần tử cho mỗi đầu chú ý! Thời gian di chuyển ma trận L×LL \times L 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 L×LL \times L xuống HBM! Thay vào đó, ta chia (tile) các ma trận Q,K,VQ, K, V 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 OO ngay bên trong SRAM trước khi ghi OO về HBM.

Nhưng có một rào cản toán học: hàm softmax(si)=esi−m∑j=1Lesj−m\text{softmax}(s_i) = \frac{e^{s_i - m}}{\sum_{j=1}^{L} e^{s_j - m}} (với m=max⁡jsjm = \max_j s_j để tránh tràn số) có mẫu số phụ thuộc vào toàn bộ hàng 1…L1 \dots L. Làm sao ta có thể nhân PP với VV khi mới chỉ đọc được khối thứ nhất của KK và VV?

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 S(1)S^{(1)}, ta lưu giá trị lớn nhất cục bộ m(1)=max⁡(S(1))m^{(1)} = \max(S^{(1)}), tổng mẫu số cục bộ ℓ(1)=∑eS(1)−m(1)\ell^{(1)} = \sum e^{S^{(1)} - m^{(1)}}, và vector đầu ra tạm thời O(1)=eS(1)−m(1)ℓ(1)V(1)O^{(1)} = \frac{e^{S^{(1)} - m^{(1)}}}{\ell^{(1)}} V^{(1)}. Khi nạp tiếp khối thứ hai S(2),V(2)S^{(2)}, V^{(2)} 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ũ:

mnew=max⁡ ⁣(m(1), max⁡(S(2))),ℓnew=em(1)−mnewℓ(1)+∑eS(2)−mnew,Onew=ℓ(1)em(1)−mnewℓnewO(1)+eS(2)−mnewℓnewV(2).\begin{aligned} m_{\text{new}} &= \max\!\big(m^{(1)},\, \max(S^{(2)})\big), \\[4pt] \ell_{\text{new}} &= e^{m^{(1)} - m_{\text{new}}} \ell^{(1)} + \sum e^{S^{(2)} - m_{\text{new}}}, \\[4pt] O_{\text{new}} &= \frac{\ell^{(1)} e^{m^{(1)} - m_{\text{new}}}}{\ell_{\text{new}}} O^{(1)} + \frac{e^{S^{(2)} - m_{\text{new}}}}{\ell_{\text{new}}} V^{(2)}. \end{aligned}

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 K,VK, V trong SRAM và cuối cùng thu được chính xác tuyệt đối ma trận OO mà không hề tốn một byte HBM nào cho ma trận L×LL \times L!

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 L×LL \times L của cơ chế Attention, thì ở pha Decode (với độ dài chuỗi vừa phải), hơn 80%80\% thời gian lại nằm ở việc đọc các ma trận trọng số W∈Rdout×dinW \in \mathbb{R}^{d_{\text{out}} \times d_{\text{in}}} 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ố:

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 w∈Rg\mathbf{w} \in \mathbb{R}^g sang lưới số nguyên bb-bit nằm trong đoạn [−qmax⁡,qmax⁡][-q_{\max}, q_{\max}] với qmax⁡=2b−1−1q_{\max} = 2^{b-1} - 1 (ví dụ qmax⁡=127q_{\max} = 127 cho INT8 và qmax⁡=7q_{\max} = 7 cho INT4), ta tìm giá trị tuyệt đối lớn nhất và định nghĩa hệ số tỷ lệ (scale factor) ss:

s=max⁡i∣wi∣qmax⁡,wi(q)=clip ⁣(round ⁣(wis), −qmax⁡, qmax⁡).s = \frac{\max_i |w_i|}{q_{\max}}, \qquad w_i^{(q)} = \text{clip}\!\left(\text{round}\!\left(\frac{w_i}{s}\right),\, -q_{\max},\, q_{\max}\right).

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 wi(q)w_i^{(q)} đượ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ỉ w^i=s⋅wi(q)\hat{w}_i = s \cdot w_i^{(q)} để nhân với vector kích hoạt đầu vào xx.

Tuy nhiên, nếu dùng chung một hệ số tỷ lệ ss 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 10×−20×10\times - 20\times các kênh bình thường. Khi một phần tử ngoại lai kéo max⁡∣wi∣\max |w_i| lên rất cao, hệ số tỷ lệ ss trở nên quá lớn, khiến 99.9%99.9\% các trọng số nhỏ còn lại trong ma trận bị làm tròn hết về con số 00!

Để 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:

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)
Dữ liệu: Ma trận có kênh ngoại lai · Thời gian chạy: < 1 phút

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 6×−8.5×6\times - 8.5\times bình thường). Hãy viết hàm quantize_dequantize(W, bits=8, group_size=None):

  1. Nếu group_size is None (lượng tử hóa toàn ma trận Per-Tensor), trải phẳng W thành 1 nhóm duy nhất (1, -1); ngược lại, định hình lại W thành các nhóm có kích thước (-1, group_size).
  2. 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ới q_max = (1 << (bits - 1)) - 1), và làm tròn torch.round(W_g / scales) kẹp trong đoạn [-q_max, q_max].
  3. 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ố bb-bit cộng với phần lưu trữ mảng scales ở chuẩn FP16 (2 bytes2\text{ bytes} mỗi hệ số tỷ lệ).

So sánh tỷ lệ nén bộ nhớ và sai số chuẩn Frobenius tương đối ∥XW⊤−XW^⊤∥F∥XW⊤∥F\frac{\|X W^\top - X \hat{W}^\top\|_F}{\|X W^\top\|_F} giữa chế độ Per-Tensor và Group-wise (g=64) cho cả INT8 và INT4.

Mã khởi tạo
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%
Điều cần lưu ý

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ỏ (g=32,64,128g = 32, 64, 128). Chỉ với chi phí phụ trội 4 KB4\text{ KB} trên 256 KB256\text{ KB} (tức thêm 0.25 bit0.25\text{ bit} 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ộ 131,072131{,}072 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 KK 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 KK 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ỏ (B=1B=1), 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à" →\to "Nội", "Việt" →\to "Nam", "import" →\to "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 MpM_p (phân phối p(x∣x<t)p(x \mid x_{<t})) để mò mẫm từng bước, ta đặt cạnh nó một mô hình nháp nhỏ (Draft Model — MqM_q) có phân phối q(x∣x<t)q(x \mid x_{<t}) (ví dụ mô hình 1B cùng họ, nhẹ hơn và nhanh hơn gấp 15×−20×15\times - 20\times). Mỗi chu kỳ suy đoán gồm hai bước:

  1. Bước Đề xuất (Drafting): Chạy tuần tự mô hình nháp nhỏ MqM_q trong γ\gamma bước (thường chọn γ=4\gamma = 4 hoặc 55) để sinh nhanh một chuỗi γ\gamma token ứng viên x~1,x~2,…,x~γ\tilde{x}_1, \tilde{x}_2, \dots, \tilde{x}_\gamma cùng các xác suất nháp tương ứng q1(x~1),…,qγ(x~γ)q_1(\tilde{x}_1), \dots, q_\gamma(\tilde{x}_\gamma).
  2. Bước Kiểm chứng song song (Parallel Verification): Nối cả γ\gamma token nháp này vào sau ngữ cảnh hiện tại và đưa qua mô hình đích lớn MpM_p 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 MpM_p trả về đồng thời tất cả γ+1\gamma + 1 phân phối xác suất đích: p1(x),p2(x),…,pγ(x)p_1(x), p_2(x), \dots, p_\gamma(x) và cả phân phối ở vị trí tiếp theo pγ+1(x)p_{\gamma+1}(x)!

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 (T=0T = 0), việc kiểm chứng rất đơn giản: ta duyệt từ i=1i = 1 đến γ\gamma, giữ lại các token nháp chừng nào x~i==arg⁡max⁡pi(x)\tilde{x}_i == \arg\max p_i(x), 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 (T>0T > 0), làm thế nào để chấp nhận hoặc bác bỏ các token nháp x~i∼qi(x)\tilde{x}_i \sim q_i(x) sao cho token đầu ra cuối cùng tuân theo đúng 100% phân phối đích pi(x)p_i(x) 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í i=1,…,γi = 1, \dots, \gamma:

Nếu gọi α∈(0,1)\alpha \in (0, 1) là tỷ lệ chấp nhận trung bình mỗi bước và c=Tdraft/Ttargetc = T_{\text{draft}} / T_{\text{target}} 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à E[tokens]=1−αγ+11−α\mathbb{E}[\text{tokens}] = \frac{1 - \alpha^{\gamma + 1}}{1 - \alpha}, và hệ số tăng tốc thực tế là:

Speedup(α,γ,c)=1−αγ+1(1−α)(γc+1).\text{Speedup}(\alpha, \gamma, c) = \frac{1 - \alpha^{\gamma + 1}}{(1 - \alpha)(\gamma c + 1)}.

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.

Mô phỏng tương tác Giải mã Suy đoán (Speculative Decoding)
Chuyển đổi giữa Kịch bản A (bác bỏ và sửa token thứ 4 bằng phân phối phần dư) và Kịch bản B (chấp nhận cả 4 token + nhận thêm token thưởng thứ 5). Điều chỉnh thanh trượt tỷ lệ chấp nhận α, độ dài nháp γ và chi phí mô hình nháp c để xem hệ số tăng tốc thực tế.

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
Dữ liệu: Phân phối đích p(x) và nháp q(x) · Thời gian chạy: < 1 phút

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:

  1. Rút num_trials ứng viên nháp x_draft từ phân phối q.
  2. 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 đều u = torch.rand(num_trials).
  3. 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óa p_res = clamp(p - q, min=0) / sum(clamp(p - q, min=0)).

Tính tỷ lệ chấp nhận lý thuyết α=∑xmin⁡(p(x),q(x))\alpha = \sum_x \min(p(x), q(x)), 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!

Mã khởi tạo
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 α=∑min⁡(p,q)=78.00%\alpha = \sum \min(p, q) = 78.00\%. 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
Điều cần lưu ý

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 (q=0.38>p=0.25q = 0.38 > p = 0.25), nên bước chấp nhận min⁡(1,p/q)=0.25/0.38\min(1, p/q) = 0.25 / 0.38 đã tự động cắt giảm tần suất của "Nội" từ 38%38\% về đúng 25%25\%.
  • Với token "Hà", mô hình nháp đánh giá quá thấp (q=0.22<p=0.42q = 0.22 < p = 0.42), nên mọi lần nháp đề xuất "Hà" đều được chấp nhận 100%100\%, và phần thiếu hụt 0.42−0.22=0.200.42 - 0.22 = 0.20 đượ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ư p′(x)p'(x)!

Đó 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 (MpM_p và MqM_q) 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í t+1,t+2,t+3,t+4t+1, t+2, t+3, t+4, 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.

Mở rộng

Đ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 W∈R8192×8192W \in \mathbb{R}^{8192 \times 8192} ở chuẩn float16 (128 MB128\text{ MB}) và đo thời gian thực hiện phép nhân Vector – Ma trận xWx W (B=1B=1) so với phép nhân Ma trận – Ma trận XWX W (B=256B=256). 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.

Mở rộng

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 00. Nếu một nhóm trọng số có phân bố lệch (ví dụ nằm trong đoạn [−0.1,0.8][-0.1, 0.8]), 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ạ [wmin⁡,wmax⁡][w_{\min}, w_{\max}] vào [0,2b−1][0, 2^b - 1] thông qua hệ số tỷ lệ s=wmax⁡−wmin⁡2b−1s = \frac{w_{\max} - w_{\min}}{2^b - 1} và điểm không z=round(−wmin⁡/s)z = \text{round}(-w_{\min} / s), rồi so sánh sai số Frobenius với phương pháp đối xứng.

Mở rộ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 q∈R1×dq \in \mathbb{R}^{1 \times d} và danh sách các khối K(b),V(b)∈RBtile×dK^{(b)}, V^{(b)} \in \mathbb{R}^{B_{\text{tile}} \times d}, cập nhật trực tiếp (m,ℓ,O)(m, \ell, O) 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ưởng

Tìm độ dài suy đoán γ∗\gamma^* tối ưu. Viết một hàm Python vẽ đồ thị Speedup(γ)\text{Speedup}(\gamma) theo γ∈{1,…,16}\gamma \in \{1, \dots, 16\} với các cặp (α,c)(\alpha, c) khác nhau. Khi c=0.05c = 0.05 và α=0.75\alpha = 0.75, giá trị γ∗\gamma^* nào cho hệ số tăng tốc cao nhất? Tại sao khi tăng γ\gamma 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

  1. 2019 Shazeer. Fast Transformer Decoding: One Write-Head is All You Need. arXiv. 3
  2. 2020 Holtzman et al. The Curious Case of Neural Text Degeneration. ICLR. 1
  3. 2022 Dao et al. FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness. NeurIPS. 4
  4. 2022 Dettmers et al. LLM.int8(): 8-bit Matrix Multiplication for Transformers at Scale. NeurIPS. 4
  5. 2023 Ainslie et al. GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints. EMNLP. 3
  6. 2023 Frantar et al. GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers. ICLR. 4
  7. 2023 Kwon et al. Efficient Memory Management for Large Language Model Serving with PagedAttention. SOSP. 3
  8. 2023 Leviathan, Kalman & Matias. Fast Inference from Transformers via Speculative Decoding. ICML. 5
  9. 2024 Lin et al. AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration. MLSys. 4