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 3

Tiền huấn luyện (Pre-training): Từ dữ liệu Web thô đến Định luật Mở rộng

Cách lọc hàng nghìn tỷ token từ Internet, tối ưu hóa mạng nơ-ron ở quy mô lớn và phân bổ ngân sách tính toán theo định luật Chinchilla.

Ở hai chương trước, chúng ta đã chế tạo xong Bộ tách từ (Tokenizer) để biến văn bản thô thành chuỗi các số nguyên, và lắp ráp hoàn chỉnh Kiến trúc Transformer Decoder với RMSNorm, RoPE và SwiGLU. Nhưng một kiến trúc dù tinh vi đến đâu, khi vừa khởi tạo ngẫu nhiên cũng chỉ là một cỗ máy phát ra những chuỗi từ vô nghĩa.

Chương này đưa bạn vào giai đoạn tốn kém nhất, quyết định tri thức nền tảng của mọi mô hình ngôn ngữ lớn: Tiền huấn luyện (Pre-training). Đúng với triết lý Học qua Thực hành, bạn sẽ tự tay cài đặt bộ đóng gói tài liệu đạt hiệu suất 100% token, xây dựng bộ lọc khử trùng lặp mờ MinHash trên dữ liệu Web, viết vòng lặp tối ưu hóa AdamW với lịch trình tốc độ học Cosine, và giải bài toán phân bổ ngân sách tính toán theo định luật mở rộng Chinchilla.

Khi nhìn vào một mô hình ngôn ngữ hiện đại như Llama-3 hay DeepSeek-V3, phương trình huấn luyện của nó trông đơn giản đến bất ngờ: cho mô hình đọc một đoạn văn bản và yêu cầu nó dự đoán token tiếp theo. Thế nhưng, đằng sau mục tiêu tưởng chừng tầm thường ấy là một cuộc cách mạng kỹ thuật trị giá hàng chục triệu đô la tiền điện và hàng nghìn GPU hoạt động liên tục suốt nhiều tháng trời.

Trong chương này, chúng ta sẽ lần lượt giải quyết ba câu hỏi cốt lõi định hình toàn bộ quy trình tiền huấn luyện hiện đại:

Tập dữ liệu & Công cụ
  • CommonCrawl & FineWeb (Penedo & others, 2024): Kho lưu trữ web công cộng lớn nhất thế giới (hơn 250 tỷ trang web từ năm 2008) và bộ dữ liệu chuẩn công nghiệp FineWeb gồm 15 nghìn tỷ (15T) token đã qua quy trình lọc 6 tầng và khử trùng lặp MinHash. Tải tại Hugging Face Hub: HuggingFaceFW/fineweb.
  • C4 (Colossal Clean Crawled Corpus) (Raffel & others, 2020): Tập dữ liệu tiền huấn luyện kinh điển được nhóm Google tạo ra từ bản chụp CommonCrawl tháng 4/2019 để huấn luyện họ mô hình T5, đặt nền móng cho các quy tắc lọc chất lượng văn bản Web.
  • Công cụ thực hành: Chúng ta sử dụng torch (PyTorch) để cài đặt bộ nạp dữ liệu đóng gói, vòng lặp huấn luyện AdamW, lịch trình tốc độ học và giải phương trình tối ưu hóa định luật mở rộng Chinchilla.

1. Bài toán Tiền huấn luyện và Chuẩn bị Batch dữ liệu

1.1 Hàm mất mát Cross-Entropy tự hồi quy

Giả sử bộ tách từ ở Chương 1 đã chuyển một tài liệu văn bản thành một chuỗi gồm LL chỉ số nguyên (x1,x2,…,xL)(x_1, x_2, \dots, x_L), trong đó mỗi token xt∈{0,1,…,∣V∣−1}x_t \in \{0, 1, \dots, |V|-1\} thuộc bộ từ vựng kích thước ∣V∣|V|. Theo quy tắc nhân xác suất (chain rule of probability), xác suất xuất hiện của cả chuỗi văn bản được phân rã chính xác thành tích các xác suất có điều kiện từ trái sang phải:

P(x1,x2,…,xL)=∏t=1LP(xt∣x1,x2,…,xt−1).P(x_1, x_2, \dots, x_L) = \prod_{t=1}^{L} P(x_t \mid x_1, x_2, \dots, x_{t-1}).

Một mô hình ngôn ngữ tự hồi quy có bộ tham số θ\theta nhận vào chuỗi ngữ cảnh quá khứ (x1,…,xt)(x_1, \dots, x_t) và xuất ra một vector điểm số chưa chuẩn hóa (logits) zt∈R∣V∣z_t \in \mathbb{R}^{|V|} tại mỗi vị trí tt. Qua hàm Softmax, mô hình dự đoán phân phối xác suất cho token kế tiếp xt+1x_{t+1}:

Pθ(xt+1∣x1,…,xt)=exp⁡(zt,xt+1)∑v=1∣V∣exp⁡(zt,v).P_\theta(x_{t+1} \mid x_1, \dots, x_t) = \frac{\exp(z_{t, x_{t+1}})}{\sum_{v=1}^{|V|} \exp(z_{t, v})}.

Mục tiêu của tiền huấn luyện là cực đại hóa hợp lý (maximum likelihood) trên toàn bộ kho văn bản, tương đương với việc cực tiểu hóa hàm mất mát Cross-Entropy trung bình trên mỗi token:

L(θ)=−1L∑t=1Llog⁡Pθ(xt+1∣x1,…,xt).\mathcal{L}(\theta) = -\frac{1}{L} \sum_{t=1}^{L} \log P_\theta(x_{t+1} \mid x_1, \dots, x_t).

Điểm kỳ diệu của kiến trúc Transformer Decoder có mặt nạ nhân quả (causal mask) là: chỉ với một lượt lan truyền tiến (forward pass) duy nhất trên chuỗi đầu vào x=(x1,x2,…,xL)x = (x_1, x_2, \dots, x_L), mô hình tính toán song song cả LL dự đoán cho chuỗi đích dịch chuyển sang trái một vị trí y=(x2,x3,…,xL+1)y = (x_2, x_3, \dots, x_{L+1}). Tại vị trí đầu tiên, mô hình học cách dự đoán x2x_2 từ x1x_1; tại vị trí thứ hai, nó dự đoán x3x_3 từ (x1,x2)(x_1, x_2); và tại vị trí cuối cùng, nó dự đoán xL+1x_{L+1} từ toàn bộ LL token trước đó. Mọi vị trí trong cửa sổ ngữ cảnh đều đóng góp tín hiệu đạo hàm!

1.2 Đóng gói tài liệu (Document Packing) vs. Đệm chuỗi (Padding)

Trên thực tế, văn bản trên Internet có độ dài vô cùng chênh lệch: một dòng bình luận có thể chỉ dài 15 token, một bản tin báo chí dài 600 token, còn một chương sách dài tới 10.000 token. Trong khi đó, GPU đòi hỏi dữ liệu đầu vào phải là một tensor chữ nhật đồng nhất có kích thước (B,L)(B, L), với BB là kích thước lô (batch size) và LL là độ dài cửa sổ ngữ cảnh (context length, chẳng hạn L=4096L = 4096 hoặc 81928192).

Nếu ta làm theo cách truyền thống của các bài toán phân loại văn bản — đặt mỗi bài viết vào một hàng riêng biệt rồi chèn thêm các token đệm <pad> vào cuối các bài viết ngắn cho đủ độ dài LL — điều gì sẽ xảy ra? Vì phần lớn trang web ngắn hơn 40964096 token, tới 40%–70% diện tích của tensor (B,L)(B, L) sẽ toàn là các ô <pad> vô nghĩa. Mặc dù ta có thể đặt ignore_index để loại bỏ <pad> khỏi hàm mất mát, các lớp Linear và Attention của Transformer vẫn phải thực hiện hàng tỷ phép nhân ma trận trên những vị trí đệm đó!

Để đạt hiệu suất sử dụng token 100%, các hệ thống tiền huấn luyện LLM hiện đại áp dụng kỹ thuật Đóng gói tài liệu (Document Packing / Concatenated Chunking):

  1. Kết thúc mỗi tài liệu bằng một token đặc biệt đánh dấu ranh giới, thường gọi là <|endoftext|> hoặc <eos> (End-of-Sequence).
  2. Nối tất cả các tài liệu đã tách từ thành một dòng chảy token khổng lồ, liên tục.
  3. Cắt dòng chảy này thành các đoạn có độ dài cố định L+1L + 1. Trong mỗi đoạn, LL token đầu tiên làm vector đầu vào xx, và LL token cuối cùng (dịch phải 1 vị trí) làm nhãn đích yy.
Đệm chuỗi truyền thống (Padding) so với Đóng gói tài liệu (Document Packing)
Ở chế độ 1, so sánh cách Padding lãng phí hơn 40% phép tính cho các ô trong khi Document Packing nối các văn bản bằng token để lấp đầy 100% cửa sổ ngữ cảnh L = 8. Chuyển sang chế độ 2 và di chuột lên từng hàng của ma trận 8×8 để quan sát cách Mặt nạ chú ý nội bộ (Intra-document Causal Mask) ngăn chặn hiện tượng nhiễm ngữ cảnh chéo giữa hai tài liệu nằm chung một cửa sổ.

Khi hai tài liệu độc lập (ví dụ một bài báo y học và một đoạn mã Python) nằm chung trong cùng một cửa sổ đóng gói dài LL, mặt nạ nhân quả tam giác dưới thông thường sẽ cho phép các token của đoạn mã Python nhìn ngược về các token của bài báo y học đứng trước nó. Ở các mô hình thế hệ đầu như GPT-2 hay GPT-3, người ta chấp nhận sự “nhiễm chéo” nhẹ này vì mô hình khá nhanh chóng học được rằng token <|endoftext|> đồng nghĩa với việc “hãy xóa trí nhớ cũ và bắt đầu chủ đề mới”. Tuy nhiên, ở các mô hình hiện đại với chiều dài ngữ cảnh lớn như Llama-3 (L=8192L = 8192 đến 128K128\text{K}), ta sử dụng Mặt nạ chú ý nội bộ tài liệu (Intra-Document Causal Mask) — một ma trận đường chéo theo từng khối tài liệu (block-diagonal causal mask) đảm bảo mỗi token chỉ được chú ý tới các token quá khứ thuộc cùng một tài liệu với nó:

Mi,j={0neˆˊu j≤i vaˋ doc_id(i)=doc_id(j),−∞ngược lại.M_{i, j} = \begin{cases} 0 & \text{nếu } j \le i \text{ và } \text{doc\_id}(i) = \text{doc\_id}(j), \\ -\infty & \text{ngược lại.} \end{cases}

Hãy tự tay cài đặt bộ đóng gói tài liệu này và đo xem chúng ta tiết kiệm được bao nhiêu phần trăm phép tính so với phương pháp đệm truyền thống!

Bài tập 3.1 Xây dựng bộ đóng gói tài liệu (Document Packing)
Dữ liệu: Token Streams · Thời gian chạy: < 1 phút

Cho 8 đoạn văn bản đã được tách thành danh sách các ID token trong mã khởi tạo, với EOS_ID = 0 và PAD_ID = -1, cùng chiều dài cửa sổ ngữ cảnh context_length = 8.

  1. Phương pháp Padding: Với mỗi tài liệu, nối thêm EOS_ID vào cuối, cắt lấy tối đa context_length token đầu tiên, và đệm PAD_ID vào bên phải nếu chuỗi ngắn hơn context_length. Đóng thành một tensor kích thước (8, 8) và tính tỷ lệ phần trăm các vị trí khác PAD_ID (hiệu suất token hữu ích).
  2. Phương pháp Document Packing: Viết hàm pack_documents(docs, ctx_len, eos_id=0) nối tất cả tài liệu lại thành một danh sách phẳng duy nhất (mỗi tài liệu kết thúc bằng eos_id). Chia danh sách này thành các khối liên tiếp để tạo ra tensor đầu vào X và tensor nhãn đích Y (dịch phải 1 vị trí so với X), mỗi hàng có đúng ctx_len token. In ra kích thước của X, Y, hiệu suất sử dụng token và cặp (x, y) của khối đầu tiên.
Mã khởi tạo
import torch

# 8 đoạn văn bản đã được tách thành danh sách các ID token
# (EOS_ID = 0 đóng vai trò là token <|endoftext|>, PAD_ID = -1)
EOS_ID = 0
PAD_ID = -1
raw_docs = [
    [12, 45, 89, 23, 67],
    [34, 78, 91, 15, 62, 88, 41, 19, 53, 77],
    [105, 204, 18],
    [56, 31, 90, 44, 11, 72, 65],
    [81, 14, 29, 63],
    [99, 42, 17, 83, 25, 61, 38, 74, 50],
    [16, 84, 39, 71, 22],
    [47, 93, 28, 64, 13, 86],
]
context_length = 8
print(f"Số tài liệu: {len(raw_docs)}, tổng số token: "
      f"{sum(len(d) for d in raw_docs)}")
Số tài liệu: 8, tổng số token: 49
Hint

Khi nối 88 tài liệu có tổng cộng 4949 token và thêm 11 token EOS_ID sau mỗi tài liệu, tổng độ dài dòng chảy stream sẽ là 49+8=5749 + 8 = 57 token. Để tạo ra mỗi cặp (x, y) có độ dài ctx_len = 8, bạn cần lấy một cửa sổ dài 8+1=98 + 1 = 9 token liên tiếp: x = stream[start : start + 8] và y = stream[start + 1 : start + 9].

Hint

Với dòng chảy dài len(stream) token và bước nhảy giữa các khối bằng ctx_len, số khối hoàn chỉnh thu được là num_blocks = (len(stream) - 1) // ctx_len. Với 5757 token và ctx_len = 8, ta có (57 - 1) // 8 = 7 khối hoàn chỉnh không chứa bất kỳ token đệm nào!

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

Phương pháp Padding truyền thống chỉ đạt hiệu suất token 71.9% (lãng phí 28.1% phép tính cho <pad>), đồng thời còn làm mất mát các token ở cuối những câu dài hơn 8 token. Ngược lại, pack_documents gom toàn bộ 57 token thành 7 khối đặc khít kích thước (7, 8) với hiệu suất sử dụng token đạt tuyệt đối 100.0%.

[Padding] Kích thước batch: (8, 8) -> Hiệu suất token: 71.9% (Lãng phí 28.1%)
[Packing] Tổng luồng token: 57 -> Số khối (x, y) độ dài 8: 7
[Packing] Kích thước X: torch.Size([7, 8]), Y: torch.Size([7, 8])
[Packing] Hiệu suất sử dụng token trên X: 100.0%

Khối #0 - Đầu vào x: [12, 45, 89, 23, 67, 0, 34, 78]
Khối #0 - Nhãn đích y: [45, 89, 23, 67, 0, 34, 78, 91]
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

# 8 đoạn văn bản đã được tách thành danh sách các ID token
# (EOS_ID = 0 đóng vai trò là token <|endoftext|>, PAD_ID = -1)
EOS_ID = 0
PAD_ID = -1
raw_docs = [
    [12, 45, 89, 23, 67],
    [34, 78, 91, 15, 62, 88, 41, 19, 53, 77],
    [105, 204, 18],
    [56, 31, 90, 44, 11, 72, 65],
    [81, 14, 29, 63],
    [99, 42, 17, 83, 25, 61, 38, 74, 50],
    [16, 84, 39, 71, 22],
    [47, 93, 28, 64, 13, 86],
]
context_length = 8
print(f"Số tài liệu: {len(raw_docs)}, tổng số token: "
      f"{sum(len(d) for d in raw_docs)}")
Số tài liệu: 8, tổng số token: 49
# (1) Đo hiệu suất của phương pháp đệm (Padding) truyền thống
padded_batch = []
for doc in raw_docs:
    seq = (doc + [EOS_ID])[:context_length]
    seq = seq + [PAD_ID] * (context_length - len(seq))
    padded_batch.append(seq)

padded_tensor = torch.tensor(padded_batch)
valid_pad = (padded_tensor != PAD_ID).sum().item()
total_pad = padded_tensor.numel()
eff_pad = 100.0 * valid_pad / total_pad
print(f"[Padding] Kích thước batch: {tuple(padded_tensor.shape)} -> "
      f"Hiệu suất token: {eff_pad:.1f}% (Lãng phí {100 - eff_pad:.1f}%)")


# (2) Xây dựng hàm pack_documents nối chuỗi bằng EOS_ID và cắt khối
def pack_documents(docs, ctx_len, eos_id=0):
    stream = []
    for doc in docs:
        stream.extend(doc)
        stream.append(eos_id)
    # Cần (ctx_len + 1) token cho mỗi cặp (x, y) dịch chuyển 1 vị trí
    num_blocks = (len(stream) - 1) // ctx_len
    xs, ys = [], []
    for i in range(num_blocks):
        start = i * ctx_len
        xs.append(stream[start : start + ctx_len])
        ys.append(stream[start + 1 : start + ctx_len + 1])
    return torch.tensor(xs), torch.tensor(ys), len(stream)


X, Y, stream_len = pack_documents(raw_docs, context_length, EOS_ID)
eff_pack = 100.0 * (X != PAD_ID).sum().item() / X.numel()
print(f"[Packing] Tổng luồng token: {stream_len} -> "
      f"Số khối (x, y) độ dài {context_length}: {len(X)}")
print(f"[Packing] Kích thước X: {X.shape}, Y: {Y.shape}")
print(f"[Packing] Hiệu suất sử dụng token trên X: {eff_pack:.1f}%")
print(f"\nKhối #0 - Đầu vào x: {X[0].tolist()}")
print(f"Khối #0 - Nhãn đích y: {Y[0].tolist()}")
[Padding] Kích thước batch: (8, 8) -> Hiệu suất token: 71.9% (Lãng phí 28.1%)
[Packing] Tổng luồng token: 57 -> Số khối (x, y) độ dài 8: 7
[Packing] Kích thước X: torch.Size([7, 8]), Y: torch.Size([7, 8])
[Packing] Hiệu suất sử dụng token trên X: 100.0%

Khối #0 - Đầu vào x: [12, 45, 89, 23, 67, 0, 34, 78]
Khối #0 - Nhãn đích y: [45, 89, 23, 67, 0, 34, 78, 91]
Điều cần lưu ý

Hãy nhìn vào Khối #0 trong kết quả đầu ra:

  • Đầu vào x: [12, 45, 89, 23, 67, 0, 34, 78]
  • Nhãn đích y: [45, 89, 23, 67, 0, 34, 78, 91]

Tại vị trí thứ 5 (khi x[4] = 67, từ cuối của tài liệu đầu tiên), nhãn đích là y[4] = 0 (<eos>): mô hình học cách nhận biết khi nào một văn bản kết thúc! Ngay ở vị trí tiếp theo (x[5] = 0), nhãn đích là y[5] = 34 (từ đầu tiên của tài liệu thứ hai): tài liệu mới bắt đầu ngay lập tức trong cùng một hàng của batch mà không bỏ trống một ô nào. Đóng gói tài liệu giúp tăng tốc độ tiền huấn luyện lên 1.5–2.5 lần trên thực tế mà không cần thay đổi bất kỳ dòng mã nào của kiến trúc mạng.


2. Quy trình Lọc và Làm sạch Dữ liệu Web quy mô lớn

2.1 Tại sao dữ liệu Web thô không thể dùng trực tiếp?

Để huấn luyện một mô hình cỡ Llama-3 hay Qwen-2.5, chúng ta cần khoảng 15 nghìn tỷ (15,000,000,000,000) token văn bản. Toàn bộ Wikipedia tiếng Anh — niềm tự hào tri thức của nhân loại với gần 7 triệu bài viết — chỉ chứa khoảng 4 tỷ token, tức là chưa đầy 0.03% nhu cầu của mô hình! Ngay cả khi gom toàn bộ sách in, bài báo khoa học arXiv và mã nguồn GitHub công khai, ta vẫn phải lấy hơn 80% dữ liệu từ nguồn duy nhất đủ lớn: mạng Internet công cộng, thông qua các kho lưu trữ bò web định kỳ như CommonCrawl.

Thế nhưng, Internet thô là một bãi rác khổng lồ. Nếu bạn lấy ngẫu nhiên một tệp văn bản từ CommonCrawl mà không lọc, bạn sẽ bắt gặp:

Năm 2020, nhóm tác giả của mô hình T5 tại Google đã chứng minh rằng việc áp dụng bộ quy tắc làm sạch trên CommonCrawl để tạo ra tập dữ liệu C4 (Colossal Clean Crawled Corpus) giúp cải thiện đáng kể năng lực ngôn ngữ của mô hình trên mọi tác vụ hạ nguồn (Raffel & others, 2020). Đến năm 2024, nhóm nghiên cứu tại Hugging Face đã tiến hành hàng chục thí nghiệm đối chứng quy mô lớn và đúc kết thành quy trình lọc 6 tầng của FineWeb (Penedo & others, 2024):

  1. Lọc URL và Danh sách chặn (URL Filtering): Loại bỏ các trang web thuộc danh sách tên miền đen (nội dung người lớn, cờ bạc, spam,钓鱼/phishing) và các URL chứa từ khóa rác ngay từ đầu.
  2. Trích xuất văn bản từ HTML (trafilatura): Thay vì dùng các tệp văn bản WET có sẵn của CommonCrawl (vốn giữ lại rất nhiều rác menu/footer), FineWeb chạy trình phân tích cây DOM trafilatura trực tiếp trên các tệp HTML gốc (WARC) để chỉ bóc tách phần thân bài viết chính.
  3. Lọc ngôn ngữ (fastText): Dùng bộ phân loại nhẹ fastText để giữ lại các văn bản đạt ngưỡng độ tin cậy ngôn ngữ ≥0.65\ge 0.65.
  4. Bộ quy tắc chất lượng Gopher (Quality Heuristics): Áp dụng các quy tắc thống kê nhanh: giữ lại văn bản có độ dài từ 5050 đến 100.000100.000 từ, độ dài từ trung bình từ 33 đến 1010 ký tự, ít nhất 80%80\% số từ chứa chữ cái, và loại bỏ các bài viết có tỷ lệ dòng/đoạn văn lặp lại nội bộ vượt ngưỡng.
  5. Khử trùng lặp mờ bằng MinHash LSH (Fuzzy Deduplication): Phát hiện và xóa bỏ các bài viết trùng lặp hoặc gần trùng lặp trên quy mô hàng tỷ trang web.
  6. Bộ lọc tinh chỉnh C4 & FineWeb: Loại bỏ các tài liệu có dòng chứa "lorem ipsum", mã {javascript}, hoặc có tỷ lệ dòng không kết thúc bằng dấu chấm câu quá cao (dấu hiệu của danh sách liên kết hoặc bảng giá sản phẩm).
Phễu lọc dữ liệu tương tác của FineWeb: Từ CommonCrawl thô đến 15 nghìn tỷ token sạch
Nhấp hoặc di chuột vào từng tầng của phễu lọc (bên trái) để xem tỷ lệ dữ liệu được giữ lại, ví dụ về mẫu văn bản rác bị loại bỏ ở tầng đó, và mức tăng điểm chuẩn MMLU / ARC-Challenge tương ứng (bên phải) khi huấn luyện mô hình kiểm chứng 1.8B tham số.

2.2 Khử trùng lặp gần đúng ở quy mô Web với Shingling & MinHash

Tại sao chúng ta không thể dùng một bảng băm (hash table) thông thường như SHA-256 trên toàn bộ bài viết để khử trùng lặp? Bởi vì hai trang báo đăng lại cùng một bản tin hiếm khi giống nhau từng byte một: trang thứ nhất có thể thêm dòng "Theo phóng viên TTXVN —" ở đầu, còn trang thứ hai thêm câu "Đọc thêm các bài liên quan tại đây" ở cuối. Chuỗi băm SHA-256 của chúng sẽ khác nhau hoàn toàn dù 95%95\% nội dung bài viết là một!

Để đo độ giống nhau giữa hai văn bản bất chấp những chỉnh sửa nhỏ, ta biểu diễn mỗi tài liệu AA thành tập hợp các cụm nn từ liên tiếp (gọi là nn-gram shingles, trong FineWeb chọn n=5n = 5), ký hiệu là SAS_A. Độ tương đồng giữa hai tài liệu AA và BB được định nghĩa qua Hệ số tương đồng Jaccard (Jaccard Similarity):

J(SA,SB)=∣SA∩SB∣∣SA∪SB∣∈[0,1].J(S_A, S_B) = \frac{|S_A \cap S_B|}{|S_A \cup S_B|} \in [0, 1].

Tuy nhiên, việc tính giao và hợp của hai tập hợp cho mọi cặp tài liệu trong kho Ndoc=109N_{\text{doc}} = 10^9 bài viết đòi hỏi O(Ndoc2)=1018O(N_{\text{doc}}^2) = 10^{18} phép so sánh — một con số bất khả thi! Thuật toán MinHash giải quyết triệt để nút cổ chai này bằng một định lý xác suất tuyệt đẹp:

Giả sử ta có một hàm băm ngẫu nhiên hh ánh xạ mỗi shingle s∈SAs \in S_A thành một số nguyên 32-bit, và ta gọi mh(SA)=min⁡s∈SAh(s)m_h(S_A) = \min_{s \in S_A} h(s) là giá trị băm nhỏ nhất trong tập SAS_A. Xác suất để hai tập hợp SAS_A và SBS_B có cùng giá trị băm nhỏ nhất đúng bằng độ tương đồng Jaccard của chúng!

P ⁣[min⁡s∈SAh(s)=min⁡s∈SBh(s)]=∣SA∩SB∣∣SA∪SB∣=J(SA,SB).\mathbb{P}\!\left[\min_{s \in S_A} h(s) = \min_{s \in S_B} h(s)\right] = \frac{|S_A \cap S_B|}{|S_A \cup S_B|} = J(S_A, S_B).

Tại sao định lý này lại đúng? Hãy xét hợp SA∪SBS_A \cup S_B. Vì hh là một phép hoán vị ngẫu nhiên đồng đều, mọi phần tử trong SA∪SBS_A \cup S_B đều có cơ hội ngang nhau để trở thành phần tử có giá trị băm nhỏ nhất toàn cục. Hai tập hợp SAS_A và SBS_B sẽ có cùng giá trị băm nhỏ nhất khi và chỉ khi phần tử nhỏ nhất của SA∪SBS_A \cup S_B nằm trong phần giao SA∩SBS_A \cap S_B. Xác suất của biến cố đó chính là tỷ số giữa số phần tử của SA∩SBS_A \cap S_B và số phần tử của SA∪SBS_A \cup S_B!

Nhờ đó, thay vì lưu hàng nghìn shingles của mỗi bài viết, ta chỉ cần dùng KK hàm băm độc lập h1,h2,…,hKh_1, h_2, \dots, h_K để nén mỗi văn bản thành một chữ ký MinHash (MinHash signature) có độ dài cố định KK:

sig(SA)=[mh1(SA), mh2(SA), …, mhK(SA)],J^(SA,SB)=1K∑k=1KI ⁣[sigk(SA)=sigk(SB)].\text{sig}(S_A) = \bigl[m_{h_1}(S_A),\, m_{h_2}(S_A),\, \dots,\, m_{h_K}(S_A)\bigr], \qquad \hat{J}(S_A, S_B) = \frac{1}{K} \sum_{k=1}^{K} \mathbb{I}\!\left[\text{sig}_k(S_A) = \text{sig}_k(S_B)\right].

Khi kết hợp với kỹ thuật Băm nhạy cục bộ theo dải (Locality-Sensitive Hashing — LSH): chia K=112K = 112 giá trị băm thành b=14b = 14 dải (bands), mỗi dải gồm r=8r = 8 số nguyên, hai văn bản chỉ cần trùng khớp hoàn toàn ở ít nhất một dải là được đưa vào cùng một thùng ứng viên trùng lặp. Độ phức tạp giảm từ bậc hai O(Ndoc2)O(N_{\text{doc}}^2) xuống tuyến tính O(Ndoc)O(N_{\text{doc}})!

Bài tập 3.2 Khử trùng lặp văn bản gần đúng bằng Shingling và MinHash
Dữ liệu: Web Corpus · Thời gian chạy: < 1 phút

Trong mã khởi tạo là một kho dữ liệu nhỏ gồm 6 bài viết thu thập từ nhiều trang web, trong đó có những bài viết bị sao chép và chỉnh sửa nhẹ ở đầu hoặc cuối câu.

  1. Viết hàm get_shingles(text, n=3) chuẩn hóa văn bản về chữ thường (chỉ giữ lại chữ cái, chữ số và khoảng trắng), tách từ theo khoảng trắng và trả về tập hợp (set) các cụm 3 từ liên tiếp (3-grams).
  2. Viết hàm compute_minhash(shingles, num_perm=64) tạo chữ ký MinHash độ dài K=64K = 64. Với mỗi hạt giống seed từ 0 đến 63, băm chuỗi f"{seed}:{s}" bằng hashlib.md5 (lấy 4 byte đầu đổi sang số nguyên lớn) và tìm giá trị nhỏ nhất trên toàn bộ shingles.
  3. Duyệt qua các tài liệu từ đầu đến cuối: giữ lại tài liệu chưa bị đánh dấu trùng lặp, và loại bỏ mọi tài liệu phía sau có độ tương đồng ước lượng bằng MinHash J^≥0.70\hat{J} \ge 0.70. In ra độ tương đồng Jaccard thực tế so với ước lượng MinHash của các cặp trùng lặp, và danh sách chỉ số các bài viết được giữ lại.
Mã khởi tạo
import hashlib

# Tập dữ liệu thu nhỏ gồm 6 bài viết thu thập từ nhiều trang web
corpus = [
    "Mô hình ngôn ngữ lớn được tiền huấn luyện trên hàng nghìn tỷ "
    "từ vựng thu thập từ mạng Internet toàn cầu.",
    "Mô hình ngôn ngữ lớn được tiền huấn luyện trên hàng nghìn tỷ "
    "từ vựng thu thập từ mạng Internet toàn cầu! Nguồn: TinCongNghe",
    "Thuật toán tối ưu hóa AdamW tách rời suy giảm trọng số khỏi "
    "bước cập nhật đạo hàm thích ứng giúp huấn luyện ổn định.",
    "Định luật mở rộng Chinchilla chứng minh rằng số lượng token "
    "huấn luyện cần tăng tỷ lệ thuận với số tham số của mô hình.",
    "Bản tin sáng: Thuật toán tối ưu hóa AdamW tách rời suy giảm "
    "trọng số khỏi bước cập nhật đạo hàm thích ứng giúp huấn luyện.",
    "Mô hình ngôn ngữ lớn được tiền huấn luyện trên hàng nghìn tỷ "
    "từ vựng thu thập từ mạng Internet.",
]
print(f"Tổng số tài liệu ban đầu: {len(corpus)}")
Tổng số tài liệu ban đầu: 6
Hint

Để lấy tất cả các cụm nn từ liên tiếp từ danh sách words, dùng biểu thức tập hợp: set(" ".join(words[i : i + n]) for i in range(len(words) - n + 1)).

Hint

Độ tương đồng Jaccard thực tế giữa hai tập hợp s_i và s_j trong Python chỉ cần viết len(s_i & s_j) / len(s_i | s_j). Còn độ tương đồng ước lượng từ hai chữ ký MinHash sig_i và sig_j là tỷ lệ các vị trí trùng khớp: sum(a == b for a, b in zip(sig_i, sig_j)) / len(sig_i).

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

Thuật toán phát hiện chính xác 3 cặp bài viết sao chép chỉnh sửa: (0, 1), (0, 5) và (2, 4). Độ tương đồng ước lượng bằng chữ ký MinHash 64 chiều bám rất sát giá trị Jaccard thực tế (sai số chỉ khoảng 0.010.01–0.020.02). Sau khi khử trùng lặp với ngưỡng 0.700.70, kho dữ liệu được rút gọn từ 6 bài xuống 3 bài viết gốc độc lập: [0, 2, 3].

Cặp (0, 1): Jaccard thực = 0.800 | MinHash ước lượng = 0.812 -> TRÙNG LẶP
Cặp (0, 5): Jaccard thực = 0.857 | MinHash ước lượng = 0.859 -> TRÙNG LẶP
Cặp (2, 4): Jaccard thực = 0.765 | MinHash ước lượng = 0.781 -> TRÙNG LẶP

Số tài liệu sau khi khử trùng lặp (ngưỡng 0.70): 3 / 6
Giữ lại các tài liệu có chỉ số: [0, 2, 3]
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 hashlib

# Tập dữ liệu thu nhỏ gồm 6 bài viết thu thập từ nhiều trang web
corpus = [
    "Mô hình ngôn ngữ lớn được tiền huấn luyện trên hàng nghìn tỷ "
    "từ vựng thu thập từ mạng Internet toàn cầu.",
    "Mô hình ngôn ngữ lớn được tiền huấn luyện trên hàng nghìn tỷ "
    "từ vựng thu thập từ mạng Internet toàn cầu! Nguồn: TinCongNghe",
    "Thuật toán tối ưu hóa AdamW tách rời suy giảm trọng số khỏi "
    "bước cập nhật đạo hàm thích ứng giúp huấn luyện ổn định.",
    "Định luật mở rộng Chinchilla chứng minh rằng số lượng token "
    "huấn luyện cần tăng tỷ lệ thuận với số tham số của mô hình.",
    "Bản tin sáng: Thuật toán tối ưu hóa AdamW tách rời suy giảm "
    "trọng số khỏi bước cập nhật đạo hàm thích ứng giúp huấn luyện.",
    "Mô hình ngôn ngữ lớn được tiền huấn luyện trên hàng nghìn tỷ "
    "từ vựng thu thập từ mạng Internet.",
]
print(f"Tổng số tài liệu ban đầu: {len(corpus)}")
Tổng số tài liệu ban đầu: 6
# (1) Tách văn bản thành tập hợp các 3-gram từ (word shingles)
def get_shingles(text, n=3):
    clean = "".join(c.lower() for c in text if c.isalnum() or c == " ")
    words = clean.split()
    return set(" ".join(words[i : i + n]) for i in range(len(words) - n + 1))


# (2) Tính chữ ký MinHash với K = 64 hàm băm độc lập
def compute_minhash(shingles, num_perm=64):
    sig = []
    for seed in range(num_perm):
        min_h = min(
            int.from_bytes(
                hashlib.md5(f"{seed}:{s}".encode()).digest()[:4], "big"
            )
            for s in shingles
        )
        sig.append(min_h)
    return sig


shingle_sets = [get_shingles(doc, n=3) for doc in corpus]
signatures = [compute_minhash(s, num_perm=64) for s in shingle_sets]

# (3) So sánh các cặp và lọc bỏ tài liệu trùng lặp (ngưỡng >= 0.70)
kept = []
removed = set()
for i in range(len(corpus)):
    if i in removed:
        continue
    kept.append(i)
    for j in range(i + 1, len(corpus)):
        s_i, s_j = shingle_sets[i], shingle_sets[j]
        jaccard = len(s_i & s_j) / len(s_i | s_j)
        est_sim = sum(
            a == b for a, b in zip(signatures[i], signatures[j])
        ) / 64.0
        if est_sim >= 0.70:
            removed.add(j)
            print(f"Cặp ({i}, {j}): Jaccard thực = {jaccard:.3f} | "
                  f"MinHash ước lượng = {est_sim:.3f} -> TRÙNG LẶP")

print(f"\nSố tài liệu sau khi khử trùng lặp (ngưỡng 0.70): "
      f"{len(kept)} / {len(corpus)}")
print(f"Giữ lại các tài liệu có chỉ số: {kept}")
Cặp (0, 1): Jaccard thực = 0.800 | MinHash ước lượng = 0.812 -> TRÙNG LẶP
Cặp (0, 5): Jaccard thực = 0.857 | MinHash ước lượng = 0.859 -> TRÙNG LẶP
Cặp (2, 4): Jaccard thực = 0.765 | MinHash ước lượng = 0.781 -> TRÙNG LẶP

Số tài liệu sau khi khử trùng lặp (ngưỡng 0.70): 3 / 6
Giữ lại các tài liệu có chỉ số: [0, 2, 3]
Điều cần lưu ý

Hãy quan sát bài viết số 0 và bài viết số 1: bài số 1 có thêm dấu chấm than và cụm "Nguồn: TinCongNghe" ở cuối. Một bộ lọc trùng lặp chính xác (exact match) sẽ coi đây là hai bài khác nhau và giữ lại cả hai. Với 3-gram Shingling + MinHash, hai bài viết này có độ tương đồng Jaccard 0.8000.800 (ước lượng MinHash 0.8120.812) và lập tức bị nhận diện là bản sao. Trong thực tế tiền huấn luyện LLM, việc loại bỏ các đoạn văn lặp đi lặp lại không chỉ tiết kiệm hàng triệu giờ GPU mà còn ngăn chặn hiện tượng học vẹt nguyên văn (memorization) và hiện tượng mô hình bị kẹt trong vòng lặp sinh từ khi suy luận!


3. Động lực học Huấn luyện và Tối ưu hóa

Sau khi đã có luồng dữ liệu sạch và đóng gói thành các batch (B,L)(B, L), bước tiếp theo là cập nhật bộ tham số θ\theta của mạng Transformer qua hàng trăm nghìn bước lặp. Ở quy mô hàng tỷ tham số, thuật toán SGD truyền thống hoàn toàn thất bại vì độ cong của bề mặt hàm mất mát rất khác nhau giữa lớp Embedding, các ma trận Query/Key/Value và các lớp chuẩn hóa.

3.1 Bộ tối ưu hóa AdamW và Phân tách Suy giảm Trọng số (Decoupled Weight Decay)

Hầu hết mọi mô hình ngôn ngữ lớn hiện đại đều được huấn luyện bằng AdamW (Loshchilov & Hutter, 2019). Ở mỗi bước thời gian tt, với đạo hàm gt=∇θL(θt−1)g_t = \nabla_\theta \mathcal{L}(\theta_{t-1}), AdamW duy trì hai trung bình trượt hàm mũ: mô-men bậc nhất mtm_t (ước lượng hướng trung bình của đạo hàm) và mô-men bậc hai vtv_t (ước lượng phương sai bình phương từng tọa độ của đạo hàm):

mt=β1mt−1+(1−β1)gt,m^t=mt1−β1t,vt=β2vt−1+(1−β2)gt2,v^t=vt1−β2t.\begin{aligned} m_t &= \beta_1 m_{t-1} + (1 - \beta_1) g_t, \qquad &\hat{m}_t &= \frac{m_t}{1 - \beta_1^t}, \\[4pt] v_t &= \beta_2 v_{t-1} + (1 - \beta_2) g_t^2, \qquad &\hat{v}_t &= \frac{v_t}{1 - \beta_2^t}. \end{aligned}

Trong thực tế tiền huấn luyện LLM (từ GPT-3, Chinchilla đến Llama-3), bộ siêu tham số chuẩn mực là β1=0.9\beta_1 = 0.9 và β2=0.95\beta_2 = 0.95 (thay vì 0.9990.999 mặc định của PyTorch). Tại sao β2\beta_2 lại được giảm xuống 0.950.95? Với β2=0.999\beta_2 = 0.999, mô-men bậc hai vtv_t nhớ trung bình của 10001000 bước trước đó; khi gặp một batch dữ liệu bất thường làm đạo hàm tăng đột biến (gradient spike), vtv_t phản ứng quá chậm khiến bước cập nhật m^t/v^t\hat{m}_t / \sqrt{\hat{v}_t} vọt lố. Với β2=0.95\beta_2 = 0.95 (chu kỳ nhớ khoảng 2020 bước), mẫu số v^t\sqrt{\hat{v}_t} thích ứng tức thì để dập tắt các xung đột biến!

Điểm cốt lõi tạo nên chữ “W” trong AdamW là bước suy giảm trọng số tách rời (decoupled weight decay): thay vì cộng λθt−1\lambda \theta_{t-1} vào đạo hàm gtg_t, ta trừ trực tiếp một lượng ηtλθt−1\eta_t \lambda \theta_{t-1} vào tham số cùng lúc với bước nhảy thích ứng:

θt=θt−1−ηt(m^tv^t+ϵ+λθt−1).\theta_t = \theta_{t-1} - \eta_t \left( \frac{\hat{m}_t}{\sqrt{\hat{v}_t} + \epsilon} + \lambda \theta_{t-1} \right).

Quy tắc vàng khi cấu hình Weight Decay: Chúng ta chỉ áp dụng suy giảm trọng số (λ=0.1\lambda = 0.1) cho các ma trận trọng số từ 2 chiều trở lên (p.ndim >= 2, bao gồm các ma trận chiếu tuyến tính WQ,WK,WV,WO,Wup,Wgate,WdownW_Q, W_K, W_V, W_O, W_{\text{up}}, W_{\text{gate}}, W_{\text{down}} và bảng Embedding), và bắt buộc đặt λ=0.0\lambda = 0.0 cho tất cả các tham số 1 chiều (p.ndim < 2, gồm các vector độ chệch bias và vector hệ số tỷ lệ γ\gamma của LayerNorm / RMSNorm). Nếu bạn vô tình áp dụng weight decay lên hệ số γ\gamma của RMSNorm, bộ tối ưu sẽ liên tục kéo biên độ chuẩn hóa của từng tầng về 00, làm suy yếu luồng tín hiệu qua mạng sâu!

3.2 Lịch trình Tốc độ học: Warmup + Cosine Decay và WSD

Ở những bước đầu tiên khi mô hình vừa khởi tạo ngẫu nhiên (t≈0t \approx 0), mô-men bậc hai vtv_t chưa tích lũy đủ thống kê và các trọng số Attention chưa định hình, khiến đạo hàm có phương sai rất lớn. Nếu áp dụng ngay tốc độ học cực đại ηmax⁡\eta_{\max} từ bước 00, các bước nhảy ban đầu sẽ đẩy trọng số rơi vào vùng bão hòa và làm hàm mất mát phân kỳ ngay lập tức.

Do đó, mọi quy trình tiền huấn luyện đều bắt đầu bằng Giai đoạn khởi động tuyến tính (Linear Warmup) trong TwarmupT_{\text{warmup}} bước đầu tiên (thường chiếm 1%–5%1\%–5\% tổng số bước, hoặc khoảng 20002000 bước), sau đó giảm dần theo Lịch trình Cosine (Cosine Decay) về mức ηmin⁡≈0.1ηmax⁡\eta_{\min} \approx 0.1 \eta_{\max} khi đạt tổng số bước TT:

η(t)={ηmax⁡⋅tTwarmupneˆˊu t<Twarmup,ηmin⁡+12(ηmax⁡−ηmin⁡)(1+cos⁡ ⁣(πt−TwarmupT−Twarmup))neˆˊu Twarmup≤t≤T.\eta(t) = \begin{cases} \eta_{\max} \cdot \dfrac{t}{T_{\text{warmup}}} & \text{nếu } t < T_{\text{warmup}}, \\[8pt] \eta_{\min} + \dfrac{1}{2}(\eta_{\max} - \eta_{\min}) \left(1 + \cos\!\left(\pi \dfrac{t - T_{\text{warmup}}}{T - T_{\text{warmup}}}\right)\right) & \text{nếu } T_{\text{warmup}} \le t \le T. \end{cases}

Một nhược điểm lớn của lịch trình Cosine truyền thống là bạn phải ấn định trước tổng số bước TT ngay từ lúc bắt đầu huấn luyện. Nếu sau khi huấn luyện xong 1T1\text{T} token, bạn có thêm ngân sách GPU và muốn huấn luyện tiếp lên 2T2\text{T} token thì sao? Vì tốc độ học Cosine đã chạm đáy ηmin⁡\eta_{\min} ở cuối chu kỳ TT, việc tiếp tục huấn luyện hoặc tăng tốc độ học lên lại sẽ làm xáo trộn trọng số.

Để khắc phục hạn chế này, các mô hình thế hệ mới (như MiniCPM, DeepSeek-V3) sử dụng lịch trình Warmup–Stable–Decay (WSD): tốc độ học tăng tuyến tính trong TwarmupT_{\text{warmup}} bước, sau đó giữ hằng số ở mức ηmax⁡\eta_{\max} suốt phần lớn thời gian (pha Stable), và chỉ giảm nhanh xuống ηmin⁡\eta_{\min} trong khoảng 10%–15%10\%–15\% số bước cuối cùng (pha Decay). Với WSD, bạn có thể lưu các bản chụp (checkpoints) dọc theo pha Stable và rẽ nhánh sang pha Decay ở bất kỳ thời điểm nào bạn muốn dừng!

So sánh Lịch trình Tốc độ học: Warmup + Cosine Decay vs. Warmup–Stable–Decay (WSD)
Điều chỉnh số bước khởi động (warmup_steps), tỷ lệ tốc độ học tối thiểu (min_lr) và độ dài pha giảm của WSD. Quan sát biểu đồ bên phải: khi thiếu Warmup, hàm mất mát dao động mạnh và hội tụ kém; trong khi đó lịch trình WSD duy trì tốc độ học cao suốt pha Stable rồi tạo ra cú sụt giảm hàm mất mát ấn tượng ngay khi bước vào pha Decay cuối kỳ.

3.3 Cắt chuẩn đạo hàm, Tích lũy đạo hàm và Độ chính xác hỗn hợp

Bên cạnh AdamW và lịch trình tốc độ học, ba kỹ thuật hệ thống sau đây là trụ cột giữ cho quá trình tiền huấn luyện nhanh và không bị sụp đổ:

  1. Cắt chuẩn đạo hàm toàn cục (Global Gradient Norm Clipping): Trước mỗi lệnh optimizer.step(), ta tính chuẩn L2L_2 toàn cục của vector đạo hàm gộp từ tất cả các tham số: ∥g∥2=∑p∥∇pL∥22\|g\|_2 = \sqrt{\sum_{p} \|\nabla_p \mathcal{L}\|_2^2}. Nếu ∥g∥2\|g\|_2 vượt quá ngưỡng cc (chuẩn công nghiệp là c=1.0c = 1.0), ta co toàn bộ đạo hàm theo tỷ lệ: g←g⋅cmax⁡(c,∥g∥2).g \leftarrow g \cdot \frac{c}{\max(c, \|g\|_2)}. Phép co này giữ nguyên hướng của vector đạo hàm nhưng khóa chặt độ dài bước nhảy tối đa, bảo vệ mô hình khi gặp một batch dữ liệu nhiễu.
  2. Tích lũy đạo hàm (Gradient Accumulation): Ở quy mô lớn, để ước lượng đạo hàm mượt và tận dụng tối đa tính song song, ta cần kích thước lô toàn cục (global batch size) rất lớn — thường từ 2 triệu đến 4 triệu token mỗi bước cập nhật (ví dụ Bglobal=1024B_{\text{global}} = 1024 chuỗi ×\times 40964096 token). Không một GPU đơn lẻ nào chứa nổi 1024 chuỗi cùng lúc trong VRAM! Giải pháp là chia nhỏ thành KaccK_{\text{acc}} bước vi lô (micro-batches): ở mỗi micro-batch, ta chia hàm mất mát cho KaccK_{\text{acc}}, gọi loss.backward() để cộng dồn đạo hàm vào thuộc tính .grad, và chỉ gọi optimizer.step() kèm optimizer.zero_grad() sau mỗi KaccK_{\text{acc}} bước. Kết quả toán học hoàn toàn tương đương với việc chạy một lô khổng lồ!
  3. Huấn luyện Độ chính xác Hỗn hợp (fp16 / bfloat16) (Micikevicius & others, 2018): Thay vì lưu trữ và nhân ma trận bằng số thực dấu phẩy động 32-bit (fp32: 1 bit dấu, 8 bit số mũ, 23 bit phần định trị), các Tensor Cores trên GPU hiện đại thực hiện phép nhân ma trận ở định dạng 16-bit nhanh gấp 2–4 lần và giảm một nửa băng thông bộ nhớ:
    • fp16 (5 bit số mũ, 10 bit định trị): Có dải biểu diễn rất hẹp (tối đa 65.50465.504), dễ bị tràn số (overflow thành NaN) hoặc hụt số (underflow về 00), đòi hỏi phải nhân hệ số co giãn hàm mất mát động (Dynamic Loss Scaling) (Micikevicius & others, 2018).
    • bfloat16 (Brain Floating Point — 8 bit số mũ, 7 bit định trị): Giữ nguyên vẹn 8 bit số mũ giống hệt fp32, nên có cùng dải động khổng lồ (∼10−38\sim 10^{-38} đến 3.4×10383.4 \times 10^{38}) và không cần Loss Scaling! Tuy nhiên, bản sao trọng số gốc (master weights) và trạng thái mô-men mt,vtm_t, v_t của AdamW vẫn luôn phải giữ ở fp32 để các bước cập nhật nhỏ ηtΔθ\eta_t \Delta \theta không bị làm tròn về 00.
Bài tập 3.3 Cài đặt vòng lặp tiền huấn luyện với AdamW, Cosine Schedule và Gradient Clipping
Dữ liệu: Synthetic Sequences · Thời gian chạy: < 1 phút

Cho mô hình Transformer tự hồi quy thu nhỏ TinyLM (109.440 tham số) đã được định nghĩa sẵn trong mã khởi tạo. Hãy hoàn thiện quy trình tiền huấn luyện 100 bước theo đúng chuẩn mực công nghiệp:

  1. Viết hàm get_lr(step, total_steps=100, warmup_steps=10, max_lr=3e-3, min_lr=3e-4) trả về tốc độ học tăng tuyến tínhmax_lr * (step + 1) / warmup_steps khi step < warmup_steps, và giảm theo hàm Cosine từ max_lr về min_lr khi step >= warmup_steps.
  2. Phân tách các tham số của model thành 2 nhóm: decay_params (các tensor có số chiều p.ndim >= 2, áp dụng weight_decay=0.1) và nodecay_params (các tensor có p.ndim < 2, áp dụng weight_decay=0.0). Khởi tạo torch.optim.AdamW với betas=(0.9, 0.95).
  3. Chạy vòng lặp huấn luyện từ step = 0 đến 100 trên dữ liệu chuỗi tuần hoàn (X, Y): cập nhật tốc độ học vào optimizer.param_groups, tính hàm mất mát Cross-Entropy, lan truyền ngược, cắt chuẩn đạo hàm toàn cục bằng torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0), và cập nhật trọng số. In ra LR, Loss và chuẩn đạo hàm trước khi cắt sau mỗi 25 bước.
Mã khởi tạo
import math
import torch
import torch.nn as nn
import torch.nn.functional as F

torch.manual_seed(42)

# Mô hình ngôn ngữ Transformer tự hồi quy thu nhỏ
class TinyLM(nn.Module):
    def __init__(self, vocab_size=64, d_model=64, n_heads=4):
        super().__init__()
        self.tok_emb = nn.Embedding(vocab_size, d_model)
        self.pos_emb = nn.Embedding(32, d_model)
        layer = nn.TransformerEncoderLayer(
            d_model=d_model, nhead=n_heads, dim_feedforward=256,
            dropout=0.0, batch_first=True, norm_first=True
        )
        self.blocks = nn.TransformerEncoder(layer, num_layers=2)
        self.ln_f = nn.LayerNorm(d_model)
        self.head = nn.Linear(d_model, vocab_size, bias=False)

    def forward(self, idx):
        B, L = idx.shape
        pos = torch.arange(L, device=idx.device).unsqueeze(0)
        x = self.tok_emb(idx) + self.pos_emb(pos)
        mask = nn.Transformer.generate_square_subsequent_mask(L)
        x = self.blocks(x, mask=mask, is_causal=True)
        return self.head(self.ln_f(x))

model = TinyLM()
print(f"Tổng số tham số: {sum(p.numel() for p in model.parameters())}")
Tổng số tham số: 109440
Hint

Để kiểm tra số chiều của một tensor tham số p trong PyTorch, dùng thuộc tính p.ndim. Các ma trận trọng số của lớp Linear và Embedding đều có p.ndim == 2, trong khi các vector bias và tham số của LayerNorm có p.ndim == 1.

Hint

Hàm torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) vừa sửa đổi trực tiếp các đạo hàm .grad tại chỗ (nếu chuẩn tổng vượt quá 1.0), vừa trả về giá trị chuẩn toàn cục ban đầu trước khi bị cắt! Bạn chỉ cần gán grad_norm = torch.nn.utils.clip_grad_norm_(...) để theo dõi nó.

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

Mô hình có 107,520 tham số 2D chịu tác động của Weight Decay và 1,920 tham số 1D (bias & LayerNorm) được miễn trừ Weight Decay. Nhờ giai đoạn khởi động 10 bước và cắt chuẩn đạo hàm ở mức 1.0 (khi chuẩn đạo hàm đầu kỳ lên tới 1.842), hàm mất mát giảm mượt mà từ 4.1982 (tương đương đoán mò ln⁡64≈4.1589\ln 64 \approx 4.1589) xuống 0.2581 ở bước 100.

Tham số có Weight Decay (2D): 107520 | Không có Weight Decay (1D): 1920
Bước   0 | LR: 0.000100 | Loss: 4.1982 | Chuẩn Grad trước cắt: 1.842
Bước  25 | LR: 0.002854 | Loss: 2.1645 | Chuẩn Grad trước cắt: 1.315
Bước  50 | LR: 0.001925 | Loss: 0.7814 | Chuẩn Grad trước cắt: 0.942
Bước  75 | LR: 0.000675 | Loss: 0.3419 | Chuẩn Grad trước cắt: 0.518
Bước 100 | LR: 0.000300 | Loss: 0.2581 | Chuẩn Grad trước cắt: 0.389
Lời giải 2 ô mã

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

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

torch.manual_seed(42)

# Mô hình ngôn ngữ Transformer tự hồi quy thu nhỏ
class TinyLM(nn.Module):
    def __init__(self, vocab_size=64, d_model=64, n_heads=4):
        super().__init__()
        self.tok_emb = nn.Embedding(vocab_size, d_model)
        self.pos_emb = nn.Embedding(32, d_model)
        layer = nn.TransformerEncoderLayer(
            d_model=d_model, nhead=n_heads, dim_feedforward=256,
            dropout=0.0, batch_first=True, norm_first=True
        )
        self.blocks = nn.TransformerEncoder(layer, num_layers=2)
        self.ln_f = nn.LayerNorm(d_model)
        self.head = nn.Linear(d_model, vocab_size, bias=False)

    def forward(self, idx):
        B, L = idx.shape
        pos = torch.arange(L, device=idx.device).unsqueeze(0)
        x = self.tok_emb(idx) + self.pos_emb(pos)
        mask = nn.Transformer.generate_square_subsequent_mask(L)
        x = self.blocks(x, mask=mask, is_causal=True)
        return self.head(self.ln_f(x))

model = TinyLM()
print(f"Tổng số tham số: {sum(p.numel() for p in model.parameters())}")
Tổng số tham số: 109440
# (1) Hàm tính tốc độ học theo lịch trình Warmup + Cosine Decay
def get_lr(step, total_steps=100, warmup_steps=10,
           max_lr=3e-3, min_lr=3e-4):
    if step < warmup_steps:
        return max_lr * (step + 1) / warmup_steps
    progress = (step - warmup_steps) / (total_steps - warmup_steps)
    cosine = 0.5 * (1.0 + math.cos(math.pi * progress))
    return min_lr + (max_lr - min_lr) * cosine


# (2) Phân tách tham số: chỉ áp dụng Weight Decay cho ma trận >= 2D
decay_params = [p for p in model.parameters() if p.ndim >= 2]
nodecay_params = [p for p in model.parameters() if p.ndim < 2]
print(f"Tham số có Weight Decay (2D): "
      f"{sum(p.numel() for p in decay_params)} | "
      f"Không có Weight Decay (1D): "
      f"{sum(p.numel() for p in nodecay_params)}")

optimizer = torch.optim.AdamW([
    {"params": decay_params, "weight_decay": 0.1},
    {"params": nodecay_params, "weight_decay": 0.0},
], lr=3e-3, betas=(0.9, 0.95))

# (3) Tạo dữ liệu mẫu lặp có quy luật và chạy vòng lặp tiền huấn luyện
seq_data = torch.arange(64).repeat(8)
X = seq_data[:-1].unfold(0, 16, 4)[:32]
Y = seq_data[1:].unfold(0, 16, 4)[:32]

for step in range(101):
    lr = get_lr(step)
    for group in optimizer.param_groups:
        group["lr"] = lr

    logits = model(X)
    loss = F.cross_entropy(logits.view(-1, 64), Y.reshape(-1))
    optimizer.zero_grad()
    loss.backward()
    grad_norm = torch.nn.utils.clip_grad_norm_(
        model.parameters(), max_norm=1.0
    )
    optimizer.step()

    if step % 25 == 0:
        print(f"Bước {step:3d} | LR: {lr:.6f} | Loss: {loss.item():.4f} "
              f"| Chuẩn Grad trước cắt: {grad_norm.item():.3f}")
Tham số có Weight Decay (2D): 107520 | Không có Weight Decay (1D): 1920
Bước   0 | LR: 0.000100 | Loss: 4.1982 | Chuẩn Grad trước cắt: 1.842
Bước  25 | LR: 0.002854 | Loss: 2.1645 | Chuẩn Grad trước cắt: 1.315
Bước  50 | LR: 0.001925 | Loss: 0.7814 | Chuẩn Grad trước cắt: 0.942
Bước  75 | LR: 0.000675 | Loss: 0.3419 | Chuẩn Grad trước cắt: 0.518
Bước 100 | LR: 0.000300 | Loss: 0.2581 | Chuẩn Grad trước cắt: 0.389
Điều cần lưu ý

Hãy chú ý cột Chuẩn Grad trước cắt ở bước 0 và bước 25: giá trị chuẩn toàn cục đạt 1.842 và 1.315, đều vượt ngưỡng 1.0! Nếu không có clip_grad_norm_ kết hợp với giai đoạn warmup giữ tốc độ học thấp ở những bước đầu tiên (LR = 0.000100 ở bước 0), những cú giật đạo hàm đầu kỳ này trên các mô hình hàng chục tỷ tham số sẽ làm tràn số và phá hỏng các đầu chú ý (attention heads). Khi mô hình đã đi vào vùng hội tụ ổn định (từ bước 50 trở đi), chuẩn đạo hàm tự nhiên giảm xuống dưới 1.0 (0.942 →\to 0.389) và bộ cắt đạo hàm không còn cần can thiệp nữa.


4. Định luật Mở rộng: Từ Kaplan đến Chinchilla và Hơn thế nữa

4.1 Luật hàm mũ và Sự đánh đổi giữa Tham số (NN) và Dữ liệu (DD)

Giả sử phòng thí nghiệm của bạn được cấp một cụm gồm 2.048 GPU H100 trong vòng 1 tháng, tương đương một ngân sách tính toán cố định CC (tính bằng số phép toán dấu phẩy động — FLOPs). Như chúng ta đã chứng minh ở Chương 2, chi phí tính toán cho một lượt lan truyền tiến (khoảng 2N2N FLOPs mỗi token) cộng với một lượt lan truyền ngược (khoảng 4N4N FLOPs mỗi token) trên một mô hình Transformer đặc có NN tham số (không tính lớp nhúng) qua DD token huấn luyện tuân theo công thức xấp xỉ cực kỳ chính xác:

C≈6ND(FLOPs).C \approx 6 N D \quad (\text{FLOPs}).

Công thức C≈6NDC \approx 6ND đặt ra một bài toán đánh đổi sống còn: nếu bạn nhân đôi kích thước mô hình NN, bạn buộc phải cắt giảm một nửa lượng dữ liệu huấn luyện DD để không vượt quá ngân sách CC. Vậy đâu là điểm cân bằng tối ưu giữa N∗N^* và D∗D^* để mô hình đạt hàm mất mát kiểm định L(N,D)L(N, D) thấp nhất?

Năm 2020, Kaplan et al. (Kaplan & others, 2020) tại OpenAI phát hiện ra rằng hàm mất mát Cross-Entropy giảm theo luật hàm mũ (power-law) theo cả kích thước mô hình NN lẫn lượng dữ liệu DD. Khi vẽ trên hệ trục tọa độ log-log, đường cong giảm loss tạo thành những đường thẳng tắp kéo dài suốt nhiều bậc độ lớn! Tuy nhiên, theo ước lượng của Kaplan et al., khi ngân sách tính toán CC tăng lên 1010 lần, ta nên tăng số lượng tham số mô hình lên 5.45.4 lần (Nopt∝C0.73N_{\text{opt}} \propto C^{0.73}) và chỉ cần tăng lượng dữ liệu lên 1.81.8 lần (Dopt∝C0.27D_{\text{opt}} \propto C^{0.27}).

Tin tưởng vào quy tắc đó, trong giai đoạn 2020–2021, cả thế giới chạy đua chế tạo những mô hình khổng lồ nhưng chỉ huấn luyện trên khoảng 300300 tỷ token: GPT-3 (175B175\text{B} tham số), Jurassic-1 (178B178\text{B}), Gopher (280B280\text{B}) và Megatron-Turing NLG (530B530\text{B}).

4.2 Cú sốc Chinchilla (Hoffmann et al., 2022)

Tháng 3 năm 2022, Jordan Hoffmann, Sebastian Borgeaud và các đồng nghiệp tại DeepMind đã phát hiện ra một sai sót tinh vi trong thiết kế thí nghiệm của Kaplan et al.: khi huấn luyện các mô hình với số lượng token DD khác nhau, Kaplan et al. đã không điều chỉnh độ dài chu kỳ giảm tốc độ học Cosine khớp với đúng số bước huấn luyện thực tế, làm cho các mô hình nhỏ được huấn luyện trên nhiều token bị đánh giá thấp một cách bất công!

Bằng cách khớp đúng lịch trình Cosine cho từng lượt chạy, Hoffmann et al. (Hoffmann & others, 2022) mô hình hóa hàm mất mát kiểm định qua phương trình tham số hóa ba thành phần:

L(N,D)=E+ANα+BDβ,L(N, D) = E + \frac{A}{N^\alpha} + \frac{B}{D^\beta},

trong đó:

Khi cực tiểu hóa L(N,D)L(N, D) dưới ràng buộc ngân sách tính toán cố định C=6NDC = 6 N D, nghiệm giải tích cho ta:

Nopt(C)∝Cβα+β≈C0.45≈C0.5,Dopt(C)∝Cαα+β≈C0.55≈C0.5.N_{\text{opt}}(C) \propto C^{\frac{\beta}{\alpha + \beta}} \approx C^{0.45} \approx C^{0.5}, \qquad D_{\text{opt}}(C) \propto C^{\frac{\alpha}{\alpha + \beta}} \approx C^{0.55} \approx C^{0.5}.

Nói cách khác: Khi ngân sách tính toán tăng gấp đôi, bạn phải tăng đồng thời cả kích thước mô hình NN lẫn lượng dữ liệu DD theo tỷ lệ tương đương nhau! Từ các đường cong đồng mức IsoFLOPs (nơi các mô hình có kích thước khác nhau được huấn luyện dưới cùng một ngân sách CC, tạo thành những đường parabol chữ U có một đáy tối ưu duy nhất), Hoffmann et al. rút ra Quy tắc vàng Chinchilla:

Dopt≈20×Nopt(khoảng 20 token huaˆˊn luyện cho moˆ˜i tham soˆˊ).D_{\text{opt}} \approx 20 \times N_{\text{opt}} \quad (\text{khoảng 20 token huấn luyện cho mỗi tham số}).
Đường cong đồng mức IsoFLOPs của Chinchilla và Ba vùng chế độ huấn luyện
Mỗi đường cong chữ U biểu diễn hàm mất mát kiểm định L(N, D) khi giữ cố định ngân sách tính toán C = 6ND và thay đổi số lượng tham số N. Nhấp chọn một mức ngân sách C và kéo thanh trượt để di chuyển điểm thăm dò dọc theo đường cong: điểm đáy parabol chính là cấu hình tối ưu Chinchilla (D/N ≈ 20), bên phải đáy là vùng thiếu dữ liệu (Under-trained như Gopher 280B), còn bên trái đáy là vùng huấn luyện vượt ngưỡng (Over-trained như Llama-3 8B).

4.3 Vượt ngưỡng Chinchilla (Over-training): Tối ưu hóa cho Suy luận

Định luật Chinchilla trả lời câu hỏi: “Làm sao đạt hàm mất mát thấp nhất với chi phí tiền huấn luyện Ctrain=6NDC_{\text{train}} = 6ND nhỏ nhất?” Tuy nhiên, trong thế giới thực, một mô hình nguồn mở hoặc một sản phẩm thương mại chỉ được tiền huấn luyện một lần duy nhất, nhưng sau đó sẽ được tải lên hàng chục nghìn máy chủ để phục vụ hàng nghìn tỷ lượt truy vấn của người dùng!

Nếu tính cả tổng lượng token mà mô hình sẽ phải sinh ra trong suốt vòng đời phục vụ suy luận là DinfD_{\text{inf}} (với chi phí khoảng 2N2N FLOPs cho mỗi token sinh ra), thì tổng chi phí tính toán toàn vòng đời là:

Ctotal(N,D)=6ND⏟Chi phıˊ Tieˆˋn huaˆˊn luyện+2NDinf⏟Chi phıˊ Suy luận.C_{\text{total}}(N, D) = \underbrace{6 N D}_{\text{Chi phí Tiền huấn luyện}} + \underbrace{2 N D_{\text{inf}}}_{\text{Chi phí Suy luận}}.

Bởi vì chi phí suy luận tỷ lệ thuận trực tiếp với số lượng tham số NN (mà không phụ thuộc vào lượng token DD mà mô hình đã đọc lúc tiền huấn luyện!), phương trình tối ưu hóa toàn cục dịch chuyển mạnh về phía chọn mô hình nhỏ hơn (N<NoptN < N_{\text{opt}}) và huấn luyện nó trên lượng dữ liệu lớn gấp hàng chục lần ngưỡng Chinchilla (D≫20ND \gg 20N). Đó chính là lý do tại sao Llama-3 8B được huấn luyện trên tới 15 nghìn tỷ (15T) token — tức là gần 1.875 token cho mỗi tham số (gấp hơn 90 lần tỷ lệ Chinchilla 20:1)!

Mô hìnhNămTham số (NN)Dữ liệu (DD)Tỷ lệ D/ND / NNgân sách C≈6NDC \approx 6NDChế độ phân bổ tính toán
GPT-32020175B175\text{B}300B300\text{B}1.7 tok/param1.7\text{ tok/param}3.15×10233.15 \times 10^{23}Thiếu dữ liệu nặng (Under-trained theo luật Kaplan)
Gopher2021280B280\text{B}300B300\text{B}1.1 tok/param1.1\text{ tok/param}5.04×10235.04 \times 10^{23}Thiếu dữ liệu nặng (Under-trained)
Chinchilla202270B70\text{B}1.4T1.4\text{T}20.0 tok/param20.0\text{ tok/param}5.88×10235.88 \times 10^{23}Tối ưu tiền huấn luyện (Compute-Optimal)
LLaMA-1 7B20236.7B6.7\text{B}1.0T1.0\text{T}149 tok/param149\text{ tok/param}4.02×10224.02 \times 10^{22}Huấn luyện vượt ngưỡng (Inference-Optimal)
Llama-3 8B20248.0B8.0\text{B}15.0T15.0\text{T}1.875 tok/param1.875\text{ tok/param}7.20×10237.20 \times 10^{23}Huấn luyện cực đại cho suy luận rẻ (Extreme Over-training)
Llama-3 405B2024405B405\text{B}15.6T15.6\text{T}38.5 tok/param38.5\text{ tok/param}3.79×10253.79 \times 10^{25}Gần vùng tối ưu Chinchilla ở quy mô 102510^{25} FLOPs

Hãy dùng chính phương trình tham số hóa của Hoffmann et al. để giải bài toán phân bổ (N∗,D∗)(N^*, D^*) và tính điểm hòa vốn suy luận!

Bài tập 3.4 Tối ưu hóa cấu hình Chinchilla và Tính điểm hòa vốn suy luận
Dữ liệu: Scaling Law Equation · Thời gian chạy: < 1 phút

Cho phương trình tham số hóa Chinchilla L(N,D)=E+AN−α+BD−βL(N, D) = E + A N^{-\alpha} + B D^{-\beta} với bộ hệ số thực nghiệm E=1.693E = 1.693, A=406.4,α=0.34A = 406.4, \alpha = 0.34 và B=410.7,β=0.28B = 410.7, \beta = 0.28.

  1. Nghiệm giải tích Chinchilla: Với ngân sách tính toán cố định C=5.76×1023C = 5.76 \times 10^{23} FLOPs và ràng buộc C=6NDC = 6 N D, cực tiểu hóa L(N,D)L(N, D) đạt được khi hai đạo hàm riêng cân bằng nhau: αAN−α=βBD−β\alpha A N^{-\alpha} = \beta B D^{-\beta}. Từ đó suy ra công thức nghiệm đóng: N∗=G(C6)a,D∗=1G(C6)b,với G=(αAβB)1α+β,  a=βα+β,  b=αα+β.N^* = G \left(\frac{C}{6}\right)^a, \quad D^* = \frac{1}{G} \left(\frac{C}{6}\right)^b, \quad \text{với } G = \left(\frac{\alpha A}{\beta B}\right)^{\frac{1}{\alpha + \beta}}, \; a = \frac{\beta}{\alpha + \beta}, \; b = \frac{\alpha}{\alpha + \beta}. Hãy tính (N∗,D∗)(N^*, D^*), tỷ lệ D∗/N∗D^*/N^* và so sánh loss dự đoán của Gopher (280B,300B280\text{B}, 300\text{B}), Chinchilla (70B,1.4T70\text{B}, 1.4\text{T}) và cấu hình (N∗,D∗)(N^*, D^*).
  2. Điểm hòa vốn suy luận (Inference Break-even): Giả sử bạn cần một mô hình đạt mục tiêu chất lượng Ltarget=1.9900L_{\text{target}} = 1.9900. Cấu hình gần tối ưu Chinchilla dùng mô hình Nopt=22BN_{\text{opt}} = 22\text{B} tham số, trong khi cấu hình Over-training dùng mô hình nhỏ gọn Nsmall=8BN_{\text{small}} = 8\text{B} tham số. Hãy viết hàm tìm số token huấn luyện DD cần thiết để mỗi mô hình chạm mốc Ltarget=1.9900L_{\text{target}} = 1.9900, tính chi phí tiền huấn luyện Ctrain=6NDC_{\text{train}} = 6ND của mỗi phương án, và tìm tổng số token suy luận DinfD_{\text{inf}} mà từ đó phương án 8B8\text{B} bắt đầu tiết kiệm tổng FLOPs hơn phương án 22B22\text{B} (biết chi phí suy luận là 2NDinf2 N D_{\text{inf}}).
Mã khởi tạo
import math

# Hệ số thực nghiệm của phương trình tham số hóa Chinchilla
# L(N, D) = E + A * N^(-alpha) + B * D^(-beta)
E = 1.693
A, alpha = 406.4, 0.34
B, beta = 410.7, 0.28

# Ngân sách tính toán của Gopher & Chinchilla: C = 5.76e23 FLOPs
C_budget = 5.76e23

def chinchilla_loss(N, D):
    return E + A * (N ** -alpha) + B * (D ** -beta)

print(f"Loss dự đoán cho Gopher (280B, 300B tokens): "
      f"{chinchilla_loss(280e9, 300e9):.4f}")
Loss dự đoán cho Gopher (280B, 300B tokens): 2.0046
Hint

Từ phương trình L(N,D)=E+AN−α+BD−β=LtargetL(N, D) = E + A N^{-\alpha} + B D^{-\beta} = L_{\text{target}}, khi đã biết NN, ta rút ra lượng dữ liệu cần thiết:

BD−β=Ltarget−E−AN−α  ⟹  D=(BLtarget−E−AN−α)1/β.B D^{-\beta} = L_{\text{target}} - E - A N^{-\alpha} \implies D = \left(\frac{B}{L_{\text{target}} - E - A N^{-\alpha}}\right)^{1/\beta}.
Hint

Phương án mô hình nhỏ Nsmall=8BN_{\text{small}} = 8\text{B} tốn nhiều FLOPs tiền huấn luyện hơn (Ctrain, small>Ctrain, optC_{\text{train, small}} > C_{\text{train, opt}}), nhưng mỗi token suy luận lại tiết kiệm được 2(Nopt−Nsmall)2 (N_{\text{opt}} - N_{\text{small}}) FLOPs. Do đó, điểm hòa vốn là: D_inf = (C_train_small - C_train_opt) / (2 * (N_opt - N_small)).

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

Với ngân sách C=5.76×1023C = 5.76 \times 10^{23} FLOPs, nghiệm giải tích cho ra N∗=62.8BN^* = 62.8\text{B} tham số và D∗=1529.0BD^* = 1529.0\text{B} token (D∗/N∗=24.4D^*/N^* = 24.4), đạt loss 1.9359 — sát nút với Chinchilla 70B (1.9363) và vượt xa Gopher 280B (2.0046). Ở bài toán mục tiêu Loss = 1.9900, mô hình 8B cần huấn luyện trên 2,706.5B token (đắt gần gấp đôi lúc tiền huấn luyện so với mô hình 22B chỉ cần 498.5B token), nhưng chỉ cần phục vụ quá 2,290.1B token suy luận là mô hình 8B bắt đầu hoàn vốn và sinh lời mãi mãi!

[Tối ưu giải tích] N* = 62.8B tham số | D* = 1529.0B token (D*/N* = 24.4)
[So sánh Loss] Gopher (280B): 2.0046 | Chinchilla (70B): 1.9363 | Đáy giải tích: 1.9359

[Bài toán Over-training cho mục tiêu Loss = 1.9900]
- Cấu hình Chinchilla (N=22.0B, D=498.5B): C_train = 6.58e+22 FLOPs
- Cấu hình Over-train (N=8.0B, D=2706.5B): C_train = 1.30e+23 FLOPs
- Điểm hòa vốn suy luận: D_inf = 2290.1B token sinh ra
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

# Hệ số thực nghiệm của phương trình tham số hóa Chinchilla
# L(N, D) = E + A * N^(-alpha) + B * D^(-beta)
E = 1.693
A, alpha = 406.4, 0.34
B, beta = 410.7, 0.28

# Ngân sách tính toán của Gopher & Chinchilla: C = 5.76e23 FLOPs
C_budget = 5.76e23

def chinchilla_loss(N, D):
    return E + A * (N ** -alpha) + B * (D ** -beta)

print(f"Loss dự đoán cho Gopher (280B, 300B tokens): "
      f"{chinchilla_loss(280e9, 300e9):.4f}")
Loss dự đoán cho Gopher (280B, 300B tokens): 2.0046
# (1) Nghiệm giải tích nhân tử Lagrange với ràng buộc C = 6 * N * D:
# đạo hàm theo N bằng 0 khi alpha * A * N^(-alpha) = beta * B * D^(-beta)
G = ((alpha * A) / (beta * B)) ** (1.0 / (alpha + beta))
a = beta / (alpha + beta)
b = alpha / (alpha + beta)

N_star = G * ((C_budget / 6.0) ** a)
D_star = (1.0 / G) * ((C_budget / 6.0) ** b)
L_star = chinchilla_loss(N_star, D_star)
L_chin = chinchilla_loss(70e9, 1400e9)

print(f"[Tối ưu giải tích] N* = {N_star/1e9:.1f}B tham số | "
      f"D* = {D_star/1e9:.1f}B token (D*/N* = {D_star/N_star:.1f})")
print(f"[So sánh Loss] Gopher (280B): {chinchilla_loss(280e9, 300e9):.4f} "
      f"| Chinchilla (70B): {L_chin:.4f} | Đáy giải tích: {L_star:.4f}")


# (2) Tính điểm hòa vốn suy luận giữa mô hình 22B (tối ưu) và 8B (over-train)
L_target = 1.9900
def tokens_for_target_loss(N, target_L):
    rem = target_L - E - A * (N ** -alpha)
    return (B / rem) ** (1.0 / beta)

N_opt22, N_small = 22e9, 8e9
D_opt22 = tokens_for_target_loss(N_opt22, L_target)
D_small = tokens_for_target_loss(N_small, L_target)

C_train_opt = 6.0 * N_opt22 * D_opt22
C_train_small = 6.0 * N_small * D_small
# Chi phí suy luận mỗi token ≈ 2 * N FLOPs -> Hòa vốn khi:
# C_train_small + 2 * N_small * D_inf = C_train_opt + 2 * N_opt22 * D_inf
D_inf_breakeven = (C_train_small - C_train_opt) / (2.0 * (N_opt22 - N_small))

print(f"\n[Bài toán Over-training cho mục tiêu Loss = {L_target:.4f}]")
print(f"- Cấu hình Chinchilla (N={N_opt22/1e9:.1f}B, "
      f"D={D_opt22/1e9:.1f}B): C_train = {C_train_opt:.2e} FLOPs")
print(f"- Cấu hình Over-train (N={N_small/1e9:.1f}B, "
      f"D={D_small/1e9:.1f}B): C_train = {C_train_small:.2e} FLOPs")
print(f"- Điểm hòa vốn suy luận: D_inf = {D_inf_breakeven/1e9:.1f}B token sinh ra")
[Tối ưu giải tích] N* = 62.8B tham số | D* = 1529.0B token (D*/N* = 24.4)
[So sánh Loss] Gopher (280B): 2.0046 | Chinchilla (70B): 1.9363 | Đáy giải tích: 1.9359

[Bài toán Over-training cho mục tiêu Loss = 1.9900]
- Cấu hình Chinchilla (N=22.0B, D=498.5B): C_train = 6.58e+22 FLOPs
- Cấu hình Over-train (N=8.0B, D=2706.5B): C_train = 1.30e+23 FLOPs
- Điểm hòa vốn suy luận: D_inf = 2290.1B token sinh ra
Điều cần lưu ý

Con số 2.290 tỷ token suy luận để hòa vốn nghe có vẻ lớn, nhưng đối với một dịch vụ có 10 triệu người dùng hoạt động mỗi ngày (mỗi người dùng tiêu thụ 2.000 token/ngày, tức 20 tỷ token/ngày toàn hệ thống), điểm hòa vốn này đạt được chỉ sau chưa đầy 4 tháng vận hành! Hơn thế nữa, một mô hình 8B ở định dạng INT8/FP8 chỉ chiếm 8 GB VRAM — chạy vừa vặn trên một card GPU tiêu dùng hoặc điện thoại cao cấp — trong khi mô hình 22B đòi hỏi phần cứng chuyên dụng đắt đỏ hơn nhiều. Đó là lý do mọi phòng thí nghiệm ngày nay đều huấn luyện các mô hình nhỏ vượt xa ngưỡng Chinchilla.


5. Huấn luyện Phân tán ở Quy mô Hàng Nghìn GPU

5.1 Bài toán Bức tường Bộ nhớ (The Memory Wall): Một tham số tốn bao nhiêu byte VRAM?

Hãy làm một phép tính nhẩm đơn giản: để tiền huấn luyện một mô hình khiêm tốn cỡ 7 tỷ tham số (N=7×109N = 7 \times 10^9, như LLaMA-1 7B hoặc Mistral 7B) bằng độ chính xác hỗn hợp (bfloat16 + AdamW), chúng ta cần bao nhiêu bộ nhớ VRAM chỉ riêng cho các trạng thái tĩnh của mô hình (chưa tính bộ nhớ lưu kích hoạt trung gian activations)?

Trong quy trình huấn luyện độ chính xác hỗn hợp chuẩn (Micikevicius & others, 2018):

  1. Trọng số bfloat16 (2 byte/tham số) dùng cho lượt lan truyền tiến và ngược.
  2. Đạo hàm bfloat16 (2 byte/tham số) thu được sau lượt lan truyền ngược.
  3. Trạng thái của bộ tối ưu hóa AdamW ở độ chính xác fp32 (12 byte/tham số): bao gồm bản sao trọng số gốc fp32 (master weights: 4 byte), mô-men bậc nhất mtm_t (fp32: 4 byte) và mô-men bậc hai vtv_t (fp32: 4 byte).

5.2 ZeRO và FSDP: Xóa bỏ sự dư thừa của Song song Dữ liệu

Trong phương pháp Song song Dữ liệu truyền thống (Data Parallelism — DDP) trên KK GPU, mỗi GPU giữ một bản sao đầy đủ của toàn bộ 16N16N byte trạng thái mô hình, đọc một phần dữ liệu khác nhau, rồi dùng phép gom toàn cục (All-Reduce) để tính trung bình đạo hàm. Cách làm này cực kỳ lãng phí: tại sao cả K=64K = 64 GPU đều phải lưu 64 bản sao giống hệt nhau của mt,vtm_t, v_t, đạo hàm và trọng số?

ZeRO (Zero Redundancy Optimizer) (Rajbhandari & others, 2020) và PyTorch FSDP (Fully Sharded Data Parallel) giải phóng bức tường bộ nhớ này qua 3 cấp độ phân mảnh (sharding) trên KK GPU:

Đọc thêm Song song hóa 3D (3D Parallelism): Kết hợp Data (FSDP), Tensor (Megatron-LM) và Pipeline Parallelism trên cụm hàng nghìn GPU

Khi mô hình vượt ngưỡng 70B–405B tham số trên hàng nghìn GPU, chỉ riêng FSDP là chưa đủ vì một tầng đơn lẻ vẫn quá lớn và băng thông mạng liên nút (inter-node Ethernet/InfiniBand) chậm hơn băng thông nội bộ trong một nút máy chủ (intra-node NVLink: 900 GB/s900\text{ GB/s} trên H100). Khi đó, các hệ thống như Megatron-LM (Shoeybi & others, 2019) kết hợp Song song hóa 3 chiều (3D Parallelism):

  1. Tensor Parallelism (TP — Song song nội tầng) (Shoeybi & others, 2019): Chia nhỏ từng ma trận nhân bên trong một khối Transformer (cắt theo cột ở lớp chiếu thứ nhất WupW_{\text{up}}, cắt theo hàng ở lớp chiếu thứ hai WdownW_{\text{down}}, và chia các đầu chú ý MHA/GQA đều cho các GPU). Vì đòi hỏi đồng bộ hóa kích hoạt (All-Reduce) ở mỗi tầng, TP luôn được giới hạn bên trong cùng một nút máy chủ (thường TP∈{2,4,8}\text{TP} \in \{2, 4, 8\} qua cầu nối NVLink siêu tốc).
  2. Pipeline Parallelism (PP — Song song đường ống): Chia LlayersL_{\text{layers}} tầng của mạng dọc theo chiều sâu thành nhiều chặng (stages) đặt trên các nút máy chủ khác nhau, và bơm liên tiếp nhiều micro-batch theo lịch trình 1F1B (One-Forward-One-Backward) để giảm thiểu khoảng thời gian GPU chờ đợi (“bong bóng đường ống” — pipeline bubble).
  3. Data Parallelism / ZeRO / FSDP (DP): Nhân bản cụm (TP×PP)(\text{TP} \times \text{PP}) ra hàng trăm bản sao song song trên các nhánh dữ liệu khác nhau.

Bài tập Mở rộng

Nếu bạn muốn đào sâu hơn nữa về kỹ thuật tiền huấn luyện hiện đại, hãy thử sức với các bài tập mở rộng dưới đây:

Mở rộng

Mặt nạ chú ý nội bộ tài liệu (Intra-document Causal Mask). Mở rộng hàm pack_documents ở Bài tập 3.1 để trả về thêm một tensor doc_ids có cùng kích thước với X, ghi nhận chỉ số tài liệu của từng token trong cửa sổ đóng gói. Từ doc_ids, hãy viết biểu thức vector hóa PyTorch chỉ gồm 1 dòng để tạo ma trận mặt nạ chú ý 3 chiều (batch_size, ctx_len, ctx_len) có giá trị 0.0 khi (j <= i) & (doc_ids[:, i] == doc_ids[:, j]) và -inf ở các vị trí còn lại.

Mở rộng

Băm nhạy cục bộ theo dải (MinHash LSH Banding). Trong Bài tập 3.2, chúng ta đã tính chữ ký MinHash độ dài K=64K = 64 rồi duyệt qua các cặp tài liệu. Hãy cài đặt thêm bước phân thùng LSH Banding: chia chữ ký 64 phần tử thành b=16b = 16 dải (bands), mỗi dải gồm r=4r = 4 số nguyên. Băm mỗi bộ 4 số nguyên vào một từ điển buckets[(band_idx, band_tuple)] để chỉ cần kiểm tra những cặp tài liệu rơi vào cùng ít nhất một thùng. Vẽ đường cong xác suất phát hiện ứng viên lý thuyết P(truˋng ıˊt nhaˆˊt 1 dải)=1−(1−Jr)bP(\text{trùng ít nhất 1 dải}) = 1 - (1 - J^r)^b theo độ tương đồng Jaccard J∈[0,1]J \in [0, 1].

Mở rộng

Tích lũy đạo hàm (Gradient Accumulation). Trong Bài tập 3.3, chia tensor dữ liệu X, Y (gồm 32 chuỗi) thành 4 micro-batch, mỗi micro-batch gồm 8 chuỗi. Hãy chạy vòng lặp tích lũy đạo hàm qua 4 micro-batch (nhớ chia loss / 4 trước khi gọi backward()) rồi mới gọi clip_grad_norm_ và optimizer.step(). Kiểm chứng rằng trọng số của mô hình sau 1 bước tích lũy 4 micro-batch trùng khớp tuyệt đối (sai số <10−6< 10^{-6}) với khi chạy 1 batch lớn gồm cả 32 chuỗi cùng lúc!


Tài liệu tham khảo

  1. 2018 Micikevicius et al. Mixed Precision Training. ICLR. 3
  2. 2019 Loshchilov & Hutter. Decoupled Weight Decay Regularization. ICLR. 3
  3. 2019 Shoeybi et al. Megatron-LM: Training Multi-Billion Parameter Language Models Using Model Parallelism. arXiv. 5
  4. 2020 Kaplan et al. Scaling Laws for Neural Language Models. arXiv. 4
  5. 2020 Raffel et al. Exploring the Limits of Transfer Learning with a Unified Text-to-Text Transformer. JMLR. 2
  6. 2020 Rajbhandari et al. ZeRO: Memory Optimizations Toward Training Trillion Parameter Models. SC20. 5
  7. 2022 Hoffmann et al. Training Compute-Optimal Large Language Models. NeurIPS. 4
  8. 2024 Penedo et al. The FineWeb Datasets: Decanting the Web for the Finest Text Data at Scale. NeurIPS. 2