Ở 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:
- Làm thế nào để biến hàng trăm terabyte mã HTML hỗn độn, đầy spam và trùng lặp trên Internet thành hàng nghìn tỷ token sạch, được đóng gói khít 100% vào bộ nhớ GPU?
- Làm thế nào để dẫn dắt hàng chục tỷ tham số hội tụ ổn định qua hàng trăm nghìn bước cập nhật đạo hàm ở độ chính xác giảm (
bfloat16) mà không bị nổ đạo hàm hay phân kỳ? - Với một ngân sách tính toán cố định (tính bằng FLOPs), ta nên xây dựng một mô hình thật lớn hay huấn luyện một mô hình vừa phải trên nhiều dữ liệu hơn?
- 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ệnAdamW, 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 chỉ số nguyên , trong đó mỗi token thuộc bộ từ vựng kích thước . 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:
Một mô hình ngôn ngữ tự hồi quy có bộ tham số nhận vào chuỗi ngữ cảnh quá khứ và xuất ra một vector điểm số chưa chuẩn hóa (logits) tại mỗi vị trí . Qua hàm Softmax, mô hình dự đoán phân phối xác suất cho token kế tiếp :
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:
Đ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 , mô hình tính toán song song cả dự đoán cho chuỗi đích dịch chuyển sang trái một vị trí . Tại vị trí đầu tiên, mô hình học cách dự đoán từ ; tại vị trí thứ hai, nó dự đoán từ ; và tại vị trí cuối cùng, nó dự đoán từ toàn bộ 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 , với là kích thước lô (batch size) và là độ dài cửa sổ ngữ cảnh (context length, chẳng hạn hoặc ).
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 — điều gì sẽ xảy ra? Vì phần lớn trang web ngắn hơn token, tới 40%–70% diện tích của tensor 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):
- 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). - 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.
- Cắt dòng chảy này thành các đoạn có độ dài cố định . Trong mỗi đoạn, token đầu tiên làm vector đầu vào , và token cuối cùng (dịch phải 1 vị trí) làm nhãn đích .
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 , 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 ( đến ), 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ó:
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)
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.
- Phương pháp Padding: Với mỗi tài liệu, nối thêm
EOS_IDvào cuối, cắt lấy tối đacontext_lengthtoken đầu tiên, và đệmPAD_IDvào bên phải nếu chuỗi ngắn hơncontext_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ácPAD_ID(hiệu suất token hữu ích). - 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ằngeos_id). Chia danh sách này thành các khối liên tiếp để tạo ra tensor đầu vàoXvà tensor nhãn đíchY(dịch phải 1 vị trí so vớiX), mỗi hàng có đúngctx_lentoken. In ra kích thước củaX,Y, hiệu suất sử dụng token và cặp(x, y)của khối đầu tiên.
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 tài liệu có tổng cộng token và thêm token EOS_ID sau mỗi tài liệu, tổng độ dài dòng chảy stream sẽ là 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 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 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]
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:
- Các thanh menu điều hướng lặp đi lặp lại (
"Trang chủ | Đăng nhập | Giỏ hàng (0) | Chính sách bảo mật"), - Các trang web nhồi nhét từ khóa SEO tự động để đánh lừa công cụ tìm kiếm,
- Hàng triệu bản tin, thông cáo báo chí hoặc điều khoản sử dụng được sao chép y hệt nhau trên hàng chục nghìn tên miền khác nhau,
- Nội dung độc hại, lừa đảo, văn bản lỗi bảng mã hoặc dữ liệu cá nhân nhạy cảm (PII).
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):
- 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.
- 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 DOMtrafilaturatrự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. - 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ữ . - 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ừ đến từ, độ dài từ trung bình từ đến ký tự, ít nhất 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.
- 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.
- 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).
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ù 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 thành tập hợp các cụm từ liên tiếp (gọi là -gram shingles, trong FineWeb chọn ), ký hiệu là . Độ tương đồng giữa hai tài liệu và được định nghĩa qua Hệ số tương đồng Jaccard (Jaccard Similarity):
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 bài viết đòi hỏi 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 ánh xạ mỗi shingle thành một số nguyên 32-bit, và ta gọi là giá trị băm nhỏ nhất trong tập . Xác suất để hai tập hợp và có cùng giá trị băm nhỏ nhất đúng bằng độ tương đồng Jaccard của chúng!
Tại sao định lý này lại đúng? Hãy xét hợp . Vì là một phép hoán vị ngẫu nhiên đồng đều, mọi phần tử trong đề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 và sẽ có cùng giá trị băm nhỏ nhất khi và chỉ khi phần tử nhỏ nhất của nằm trong phần giao . Xác suất của biến cố đó chính là tỷ số giữa số phần tử của và số phần tử của !
Nhờ đó, thay vì lưu hàng nghìn shingles của mỗi bài viết, ta chỉ cần dùng hàm băm độc lập để nén mỗi văn bản thành một chữ ký MinHash (MinHash signature) có độ dài cố định :
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 giá trị băm thành dải (bands), mỗi dải gồm 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 xuống tuyến tính !
Bài tập 3.2 Khử trùng lặp văn bản gần đúng bằng Shingling và MinHash
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.
- 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). - Viết hàm
compute_minhash(shingles, num_perm=64)tạo chữ ký MinHash độ dài . Với mỗi hạt giốngseedtừ0đến63, băm chuỗif"{seed}:{s}"bằnghashlib.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. - 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 . 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.
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 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 –). Sau khi khử trùng lặp với ngưỡng , 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]
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 (ước lượng MinHash ) 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ước tiếp theo là cập nhật bộ tham số 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 , với đạo hàm , AdamW duy trì hai trung bình trượt hàm mũ: mô-men bậc nhất (ước lượng hướng trung bình của đạo hàm) và mô-men bậc hai (ước lượng phương sai bình phương từng tọa độ của đạo hàm):
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à và (thay vì mặc định của PyTorch). Tại sao lại được giảm xuống ? Với , mô-men bậc hai nhớ trung bình của 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), phản ứng quá chậm khiến bước cập nhật vọt lố. Với (chu kỳ nhớ khoảng bước), mẫu số 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 vào đạo hàm , ta trừ trực tiếp một lượng vào tham số cùng lúc với bước nhảy thích ứng:
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ố () 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 và bảng Embedding), và bắt buộc đặt 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ệ của LayerNorm / RMSNorm). Nếu bạn vô tình áp dụng weight decay lên hệ số 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ề , 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 (), mô-men bậc hai 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 từ bước , 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 bước đầu tiên (thường chiếm tổng số bước, hoặc khoảng bước), sau đó giảm dần theo Lịch trình Cosine (Cosine Decay) về mức khi đạt tổng số bước :
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 ngay từ lúc bắt đầu huấn luyện. Nếu sau khi huấn luyện xong token, bạn có thêm ngân sách GPU và muốn huấn luyện tiếp lên token thì sao? Vì tốc độ học Cosine đã chạm đáy ở cuối chu kỳ , 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 bước, sau đó giữ hằng số ở mức suốt phần lớn thời gian (pha Stable), và chỉ giảm nhanh xuống trong khoảng 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!
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 đổ:
- 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 toàn cục của vector đạo hàm gộp từ tất cả các tham số: . Nếu vượt quá ngưỡng (chuẩn công nghiệp là ), ta co toàn bộ đạo hàm theo tỷ lệ: 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. - 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ụ chuỗi 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 bước vi lô (micro-batches): ở mỗi micro-batch, ta chia hàm mất mát cho , gọi
loss.backward()để cộng dồn đạo hàm vào thuộc tính.grad, và chỉ gọioptimizer.step()kèmoptimizer.zero_grad()sau mỗi 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ồ! - 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 ), dễ bị tràn số (overflow thànhNaN) hoặc hụt số (underflow về ), đò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ệtfp32, nên có cùng dải động khổng lồ ( đến ) 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 của AdamW vẫn luôn phải giữ ởfp32để các bước cập nhật nhỏ không bị làm tròn về .
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
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:
- 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_stepskhistep < warmup_steps, và giảm theo hàm Cosine từmax_lrvềmin_lrkhistep >= warmup_steps. - Phân tách các tham số của
modelthành 2 nhóm:decay_params(các tensor có số chiềup.ndim >= 2, áp dụngweight_decay=0.1) vànodecay_params(các tensor cóp.ndim < 2, áp dụngweight_decay=0.0). Khởi tạotorch.optim.AdamWvớibetas=(0.9, 0.95). - Chạy vòng lặp huấn luyện từ
step = 0đến100trên dữ liệu chuỗi tuần hoàn(X, Y): cập nhật tốc độ học vàooptimizer.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ằngtorch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0), và cập nhật trọng số. In raLR,Lossvà chuẩn đạo hàm trước khi cắt sau mỗi 25 bước.
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ò ) 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
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 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ố () và Dữ liệu ()
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 (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 FLOPs mỗi token) cộng với một lượt lan truyền ngược (khoảng FLOPs mỗi token) trên một mô hình Transformer đặc có tham số (không tính lớp nhúng) qua token huấn luyện tuân theo công thức xấp xỉ cực kỳ chính xác:
Công thức đặ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 , bạn buộc phải cắt giảm một nửa lượng dữ liệu huấn luyện để không vượt quá ngân sách . Vậy đâu là điểm cân bằng tối ưu giữa và để mô hình đạt hàm mất mát kiểm định 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 lẫn lượng dữ liệu . 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 tăng lên lần, ta nên tăng số lượng tham số mô hình lên lần () và chỉ cần tăng lượng dữ liệu lên lần ().
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 tỷ token: GPT-3 ( tham số), Jurassic-1 (), Gopher () và Megatron-Turing NLG ().
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 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:
trong đó:
- (nats/token) là entropy tối thiểu bất khả quy của ngôn ngữ tự nhiên (ngay cả một mô hình hoàn hảo vô hạn tham số cũng không thể dự đoán chính xác tuyệt đối từ tiếp theo do tính ngẫu nhiên nội tại của văn bản),
- (với ) là sai số xấp xỉ hàm do mô hình có dung lượng hữu hạn ,
- (với ) là sai số thống kê do mô hình chỉ được nhìn thấy một lượng hữu hạn token huấn luyện.
Khi cực tiểu hóa dưới ràng buộc ngân sách tính toán cố định , nghiệm giải tích cho ta:
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 lẫn lượng dữ liệu 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 , 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:
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 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à (với chi phí khoảng FLOPs cho mỗi token sinh ra), thì tổng chi phí tính toán toàn vòng đời là:
Bởi vì chi phí suy luận tỷ lệ thuận trực tiếp với số lượng tham số (mà không phụ thuộc vào lượng token 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 () 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 (). Đó 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ình | Năm | Tham số () | Dữ liệu () | Tỷ lệ | Ngân sách | Chế độ phân bổ tính toán |
|---|---|---|---|---|---|---|
| GPT-3 | 2020 | Thiếu dữ liệu nặng (Under-trained theo luật Kaplan) | ||||
| Gopher | 2021 | Thiếu dữ liệu nặng (Under-trained) | ||||
| Chinchilla | 2022 | Tối ưu tiền huấn luyện (Compute-Optimal) | ||||
| LLaMA-1 7B | 2023 | Huấn luyện vượt ngưỡng (Inference-Optimal) | ||||
| Llama-3 8B | 2024 | Huấn luyện cực đại cho suy luận rẻ (Extreme Over-training) | ||||
| Llama-3 405B | 2024 | Gần vùng tối ưu Chinchilla ở quy mô 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ổ 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
Cho phương trình tham số hóa Chinchilla với bộ hệ số thực nghiệm , và .
- Nghiệm giải tích Chinchilla: Với ngân sách tính toán cố định FLOPs và ràng buộc , cực tiểu hóa đạt được khi hai đạo hàm riêng cân bằng nhau: . Từ đó suy ra công thức nghiệm đóng: Hãy tính , tỷ lệ và so sánh loss dự đoán của Gopher (), Chinchilla () và cấu hình .
- Đ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 . Cấu hình gần tối ưu Chinchilla dùng mô hình tham số, trong khi cấu hình Over-training dùng mô hình nhỏ gọn tham số. Hãy viết hàm tìm số token huấn luyện cần thiết để mỗi mô hình chạm mốc , tính chi phí tiền huấn luyện của mỗi phương án, và tìm tổng số token suy luận mà từ đó phương án bắt đầu tiết kiệm tổng FLOPs hơn phương án (biết chi phí suy luận là ).
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 , khi đã biết , ta rút ra lượng dữ liệu cần thiết:
Hint
Phương án mô hình nhỏ tốn nhiều FLOPs tiền huấn luyện hơn (), nhưng mỗi token suy luận lại tiết kiệm được 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 FLOPs, nghiệm giải tích cho ra tham số và token (), đạ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
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ố (, 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):
- Trọng số
bfloat16(2 byte/tham số) dùng cho lượt lan truyền tiến và ngược. - Đạo hàm
bfloat16(2 byte/tham số) thu được sau lượt lan truyền ngược. - Trạng thái của bộ tối ưu hóa
AdamWở độ chính xácfp32(12 byte/tham số): bao gồm bản sao trọng số gốcfp32(master weights: 4 byte), mô-men bậc nhất (fp32: 4 byte) và mô-men bậc hai (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 GPU, mỗi GPU giữ một bản sao đầy đủ của toàn bộ 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ả GPU đều phải lưu 64 bản sao giống hệt nhau của , đạ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 GPU:
- ZeRO-1 (Optimizer State Partitioning): Mỗi GPU chỉ lưu giữ và cập nhật trạng thái
fp32của AdamW. Bộ nhớ mỗi GPU giảm từ xuống byte (giảm gần mà không tốn thêm bất kỳ chi phí truyền thông nào!). - ZeRO-2 (Gradient + Optimizer Partitioning): Phân mảnh thêm cả đạo hàm
bfloat16bằng phép toánReduce-Scatter. Bộ nhớ mỗi GPU giảm xuống byte. - ZeRO-3 / FSDP (Full Parameter Sharding): Phân mảnh cả trọng số
bfloat16! Trước khi tính toán một tầng Transformer, các GPU gọi lệnhAll-Gatherđể gom đủ trọng số của riêng tầng đó, tính xong lập tức giải phóng trọng số của các phân mảnh khác. Bộ nhớ tĩnh trên mỗi GPU giảm tuyến tính theo số lượng 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: 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):
- 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 , cắt theo hàng ở lớp chiếu thứ hai , 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 qua cầu nối NVLink siêu tốc). - Pipeline Parallelism (PP — Song song đường ống): Chia 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).
- Data Parallelism / ZeRO / FSDP (DP): Nhân bản cụm 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ặ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.
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 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 dải (bands), mỗi dải gồm 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 theo độ tương đồng Jaccard .
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ố ) 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
- 2018 Mixed Precision Training. ICLR. 3
- 2019 Decoupled Weight Decay Regularization. ICLR. 3
- 2019 Megatron-LM: Training Multi-Billion Parameter Language Models Using Model Parallelism. arXiv. 5
- 2020 Scaling Laws for Neural Language Models. arXiv. 4
- 2020 Exploring the Limits of Transfer Learning with a Unified Text-to-Text Transformer. JMLR. 2
- 2020 ZeRO: Memory Optimizations Toward Training Trillion Parameter Models. SC20. 5
- 2022 Training Compute-Optimal Large Language Models. NeurIPS. 4
- 2024 The FineWeb Datasets: Decanting the Web for the Finest Text Data at Scale. NeurIPS. 2