GPT và huấn luyện phân tán
Mục tiêu
Phần tiêu đề “Mục tiêu”Sau bài này, bạn sẽ:
- Hiểu GPT là mô hình ngôn ngữ nhân quả (causal language model): Transformer chỉ có decoder (decoder-only), học , và nhờ mặt nạ mà một lần chạy cho loss ở mọi vị trí. Biết GPT-1, GPT-2, GPT-3 khác nhau ở đâu, và scaling laws nói gì.
- Tính được bộ nhớ và thời gian huấn luyện một mô hình lớn, để thấy vì sao phải huấn luyện phân tán (distributed training).
- Tự dẫn được vì sao trong song song dữ liệu (data parallelism), trung bình gradient của các phần lô bằng gradient của cả lô, và khi nào điều đó sai. Hiểu ring all-reduce và chi phí của nó.
- Dùng được tích lũy gradient (gradient accumulation), độ chính xác hỗn hợp (mixed precision) và
DistributedDataParallelcủa PyTorch. Nhận ra khi nào phải chuyển sang tensor parallel, pipeline parallel hay ZeRO/FSDP.
Cần biết trước
Phần tiêu đề “Cần biết trước”- Transformers, thuộc phần Học sâu (Đã học)
Trực giác
Phần tiêu đề “Trực giác”Một mô hình, một việc: viết tiếp
Phần tiêu đề “Một mô hình, một việc: viết tiếp”Che chữ cuối của câu “Trăm năm trong cõi người …” rồi đoán. Đó là toàn bộ việc GPT học khi pre-train: cho đoạn văn phía trước, đoán token kế tiếp. Không có nhãn nào do người gán. Văn bản tự làm nhãn cho chính nó, nên bạn có bao nhiêu văn bản thì có bấy nhiêu dữ liệu huấn luyện.
Điều bất ngờ của các năm 2018–2020 là chỉ cần làm việc đó to hơn: nhiều tham số hơn, nhiều văn bản hơn. GPT-2 có 1.5 tỉ tham số. GPT-3 có 175 tỉ, học trên 300 tỉ token.
Một GPU không đủ
Phần tiêu đề “Một GPU không đủ”Thử tính cho GPT-2 bản lớn nhất, 1.5 tỉ tham số. Khi huấn luyện bằng Adam với độ chính xác hỗn hợp, mỗi tham số cần khoảng 16 byte (mục Lý thuyết sẽ đếm). Vậy chỉ riêng tham số, gradient và trạng thái bộ tối ưu đã là byte, tức 24 GB (tự tính). Một GPU 24 GB vừa khít, không còn chỗ cho kích hoạt (activation: giá trị trung gian của các lớp, phải giữ lại cho lan truyền ngược).
Với GPT-3, bài báo ghi tổng sức tính khoảng phép tính dấu phẩy động. Một GPU A100 tính tối đa khoảng phép mỗi giây ở 16 bit. Chạy liên tục ở mức tối đa lý thuyết đó, nó cần khoảng giây, tức gần 32 năm (tự tính). Thực tế còn lâu hơn, vì không GPU nào chạy hết công suất.
Chia việc như chia bài khảo sát
Phần tiêu đề “Chia việc như chia bài khảo sát”Bạn muốn biết điểm hài lòng trung bình của 100 khách hàng. Một người hỏi cả 100 thì lâu. Bốn người, mỗi người hỏi 25 khách, tính trung bình phần mình, rồi gặp nhau lấy trung bình của bốn con số. Kết quả đúng bằng trung bình của 100 người, vì mỗi người hỏi số khách bằng nhau. Nếu một người hỏi 70 khách còn ba người kia mỗi người 10, thì trung bình của bốn con số sẽ lệch: nhóm 10 khách được tính nặng ngang nhóm 70.
Song song dữ liệu làm đúng như vậy với gradient. Mỗi GPU giữ một bản mô hình, tính gradient trên phần lô của mình, rồi các GPU “gặp nhau” để lấy trung bình. Câu hỏi là gặp nhau thế nào cho nhanh, khi mỗi gradient nặng hàng GB.
Bài toán của bài này là: GPT học gì, và vì sao một lần chạy học được ở mọi vị trí? Khi mô hình và dữ liệu quá lớn cho một GPU, làm sao chia việc huấn luyện ra nhiều GPU mà kết quả vẫn đúng như khi học trên một máy?
Lý thuyết
Phần tiêu đề “Lý thuyết”GPT: mô hình ngôn ngữ nhân quả
Phần tiêu đề “GPT: mô hình ngôn ngữ nhân quả”Gọi một đoạn văn là dãy token . Theo quy tắc nhân xác suất, . GPT ước lượng từng thừa số bằng một chồng khối decoder. Vì mỗi dự đoán chỉ dựa vào quá khứ, ta gọi đó là mô hình ngôn ngữ nhân quả.
Loss là cross-entropy trung bình trên các vị trí:
Ở đây đầu vào là và nhãn là : lấy một đoạn token, nhãn chính là đầu vào dịch đi một vị trí. Đầu ra ở vị trí dự đoán token .
Vì sao một lần chạy cho cả số hạng? Mặt nạ nhân quả bảo đảm biểu diễn ở vị trí chỉ phụ thuộc . Vậy dự đoán ở vị trí không “nhìn trộm” được đáp án , và cả dự đoán đều là bài kiểm tra hợp lệ. Một đoạn 1 024 token cho 1 024 tín hiệu học chỉ với một lần lan truyền xuôi và một lần lan truyền ngược. Khi sinh văn bản thì khác: chỉ dùng logit ở vị trí cuối, rồi nối token vừa chọn vào và chạy tiếp. Cách cài đặt từng dòng có ở bài Xây dựng LLM đầu tiên.
Từ GPT-1 tới GPT-3
Phần tiêu đề “Từ GPT-1 tới GPT-3”| Mô hình | Năm | Cỡ | Dữ liệu | Ý chính |
|---|---|---|---|---|
| GPT-1 (Radford và cộng sự) | 2018 | 12 khối, , ngữ cảnh 512 | BooksCorpus, hơn 7 000 cuốn sách | Pre-train rồi fine-tune riêng từng tác vụ |
| GPT-2 (Radford và cộng sự) | 2019 | tới 1.5 tỉ tham số, ngữ cảnh 1 024 | WebText, khoảng 40 GB | Ghi tác vụ ngay trong văn bản, đo zero-shot (không ví dụ) |
| GPT-3 (Brown và cộng sự) | 2020 | 175 tỉ tham số, ngữ cảnh 2 048 | 300 tỉ token | Học trong ngữ cảnh: zero-shot, one-shot, few-shot, không cập nhật tham số |
Kiến trúc gần như giữ nguyên qua ba đời. Thứ thay đổi là quy mô, và cách dùng mô hình: từ fine-tune từng tác vụ sang chỉ viết yêu cầu vào prompt. Bài Họ GPT và fine-tune đa nhiệm kể chi tiết con đường này và bước instruction tuning sau đó. Riêng GPT-3, paper ghi rằng để mô hình lớn không tràn bộ nhớ, họ dùng kết hợp song song bên trong từng phép nhân ma trận và song song giữa các lớp (mục 2.3), tức hai kiểu song song ở cuối mục này.
Scaling laws. Kaplan và cộng sự (2020) đo hàng loạt mô hình và thấy loss giảm theo luật lũy thừa (power law) khi tăng số tham số, lượng dữ liệu hay sức tính, đều đặn qua nhiều bậc độ lớn. Họ cũng dùng một phép ước lượng hay gặp: huấn luyện tốn khoảng phép tính cho mỗi token, với là số tham số. Kiểm lại với GPT-3: , khớp con số của bài báo (tự tính).
Hoffmann và cộng sự (2022) hỏi tiếp: với một ngân sách tính cố định, nên chia cho mô hình to hơn hay cho nhiều token hơn? Câu trả lời của họ là tăng đều cả hai. Mô hình Chinchilla 70 tỉ tham số học 1.4 nghìn tỉ token, tức khoảng 20 token cho mỗi tham số (tự tính), và vượt Gopher 280 tỉ tham số được huấn luyện với cùng sức tính. Theo thước đo đó, GPT-3 với khoảng 1.7 token mỗi tham số (tự tính) là mô hình quá to so với lượng dữ liệu nó học.
GPT và BERT. BERT (bài BERT) là Transformer chỉ có encoder, attention nhìn hai chiều, học bằng cách đoán các token bị che. Mỗi vị trí nhìn thấy cả hai phía nên biểu diễn giàu ngữ cảnh hơn, hợp với phân loại, gán nhãn, trích câu trả lời. Đổi lại, BERT chỉ tính loss ở khoảng 15% vị trí bị che, và không sinh văn bản từ trái sang phải một cách tự nhiên. GPT tính loss ở mọi vị trí, và sinh văn bản chính là việc nó được huấn luyện để làm. Bản đồ đầy đủ các họ Transformer ở bài Transformers nâng cao.
Bộ nhớ đi đâu
Phần tiêu đề “Bộ nhớ đi đâu”Bảng này đã được đếm ở bài Họ LLaMA; nhắc lại ở đây. Theo cách đếm của bài báo ZeRO (Rajbhandari và cộng sự, 2020), khi huấn luyện bằng Adam với độ chính xác hỗn hợp, mỗi tham số cần:
| Thứ cần lưu | Byte mỗi tham số |
|---|---|
| Tham số 16 bit | 2 |
| Gradient 16 bit | 2 |
| Bản FP32 của tham số | 4 |
| Momentum của Adam (FP32) | 4 |
| Phương sai của Adam (FP32) | 4 |
| Tổng | 16 |
Ba dòng FP32 là trạng thái bộ tối ưu (optimizer state). Mô hình tham số cần byte, chưa kể kích hoạt. Với GPT-2 1.5 tỉ là 24 GB, với GPT-3 175 tỉ là khoảng 2.8 TB (tự tính), tức ít nhất 35 GPU 80 GB chỉ để chứa.
Có hai vấn đề tách biệt: chậm (một GPU tính không kịp) và không vừa (một GPU không chứa nổi). Song song dữ liệu giải quyết vấn đề thứ nhất. Vấn đề thứ hai cần chia chính mô hình, ở cuối mục.
Song song dữ liệu: trung bình gradient bằng gradient cả lô
Phần tiêu đề “Song song dữ liệu: trung bình gradient bằng gradient cả lô”Có GPU, ta gọi chung là worker, đánh số . Mỗi worker giữ một bản đầy đủ của tham số , giống hệt nhau. Một lô mẫu được chia thành phần (gọi là shard), mỗi phần mẫu. Với GPT, một mẫu là một đoạn văn bản.
Gọi là loss của mẫu . Loss của cả lô là trung bình:
Worker chỉ thấy phần của mình. Nó tính loss trung bình trên phần đó và gradient tương ứng:
Ta muốn chứng minh rằng trung bình của các chính là .
Bước 1: gradient của tổng là tổng các gradient. Đạo hàm là phép tuyến tính, nên
Bước 2: nhóm tổng theo shard. Mỗi mẫu nằm trong đúng một shard, nên tổng trên mẫu bằng tổng của tổng con:
Bước 3: thay mỗi tổng con bằng gradient của worker. Từ định nghĩa của , tổng con bằng . Thay vào, rồi dùng :
Vậy chỉ cần các worker trao đổi để mỗi worker có trung bình của gradient. Sau đó mỗi worker chạy cùng một bước Adam, từ cùng tham số, với cùng gradient, nên tham số vẫn giống hệt nhau. Kết quả đúng như một máy học cả lô mẫu, chỉ khác sai số làm tròn.
Khi các shard không bằng nhau, gọi là số mẫu của shard . Bước 3 cho , nên
Đây là trung bình có trọng số, trọng số là tỉ lệ mẫu của từng shard. Trung bình đơn giản chỉ đúng khi mọi bằng nhau, như bài khảo sát ở mục Trực giác. DistributedSampler của PyTorch mặc định thêm vài chỉ số lặp lại để mọi worker có cùng số mẫu (và cùng số bước). Nhờ vậy các shard bằng nhau, đổi lại vài mẫu được tính hai lần. Với mô hình ngôn ngữ còn một chỗ tinh tế: nếu mỗi mẫu là loss trung bình trên các token, mà các shard có số token thật (không tính phần đệm) khác nhau, thì “bằng nhau” phải tính theo token chứ không theo số chuỗi.
All-reduce và cách làm vòng
Phần tiêu đề “All-reduce và cách làm vòng”Phép “mỗi worker có tổng (hay trung bình) của mọi gradient” gọi là all-reduce. Cách ngây thơ là gửi hết về một máy trung tâm, cộng lại, rồi gửi về. Gọi là kích thước gradient. Máy trung tâm phải nhận rồi gửi đi : càng nhiều worker, máy đó càng nghẽn.
Ring all-reduce xếp worker thành vòng, mỗi worker chỉ gửi cho hàng xóm bên phải. Gradient của mỗi worker được cắt thành mảnh, mỗi mảnh .
- Reduce-scatter, bước. Ở mỗi bước, mọi worker cùng lúc gửi một mảnh sang phải, và bên nhận cộng mảnh đó vào mảnh cùng số của mình. Các mảnh đi vòng và tích dần tổng. Sau bước, mỗi worker giữ tổng đầy đủ của đúng một mảnh.
- All-gather, bước nữa. Mỗi worker chuyền mảnh đã đủ tổng sang phải, bên nhận chép đè. Sau bước, ai cũng có đủ mảnh.
Với 3 GPU và ba mảnh A, B, C, viết A0 là mảnh A của GPU 0, A01 là A0 + A1, A012 là tổng đủ:
| Bước | GPU 0 gửi | GPU 1 gửi | GPU 2 gửi | Sau bước này |
|---|---|---|---|---|
| Reduce-scatter 1 | A0 | B1 | C2 | GPU 1 có A01, GPU 2 có B12, GPU 0 có C20 |
| Reduce-scatter 2 | C20 | A01 | B12 | GPU 0 có B012, GPU 1 có C012, GPU 2 có A012 |
| All-gather 1 | B012 | C012 | A012 | Mỗi GPU có hai mảnh đủ tổng |
| All-gather 2 | A012 | B012 | C012 | Mỗi GPU có cả ba mảnh đủ tổng |
Đếm lượng gửi. Có bước, mỗi bước một worker gửi một mảnh . Tổng lượng mỗi worker gửi là
Con số này nhỏ hơn và gần như không tăng khi thêm worker: với , với . Mọi worker cùng gửi và cùng nhận ở mọi bước, nên không có máy nào nghẽn. Theo Horovod, Patarasuk và Yuan (2009) chỉ ra cách này tối ưu về băng thông khi gradient đủ lớn. Baidu (Gibiansky, 2017) đưa ring all-reduce vào học sâu, và Horovod (Sergeev và Del Balso, 2018) phổ biến nó cho TensorFlow.
Thử với GPT-2 1.5 tỉ tham số, gradient 16 bit là 3 GB. Trên 64 GPU nối mạng 100 Gb/s (12.5 GB/s), mỗi GPU gửi khoảng GB, mất khoảng 0.47 giây mỗi bước. Cách máy trung tâm phải nhận GB qua cùng đường truyền, khoảng 15 giây (tự tính, bỏ qua độ trễ). Số bước thì tăng theo , nên với rất nhiều GPU, độ trễ của từng bước bắt đầu đáng kể; thư viện như NCCL có thêm các cách xếp khác (dạng cây) cho trường hợp đó.
DistributedDataParallel (DDP) của PyTorch còn giấu thời gian trao đổi: nó gom gradient thành các nhóm (bucket), và ngay khi lan truyền ngược tính xong một nhóm thì all-reduce nhóm đó, trong lúc các lớp phía trước còn đang tính (Li và cộng sự, 2020).
Tích lũy gradient
Phần tiêu đề “Tích lũy gradient”Lô lớn có thể không vừa bộ nhớ của một GPU, vì kích hoạt tăng theo số mẫu. Phép dẫn ở trên cho một lối ra mà không cần thêm GPU: chia lô thành micro-batch chạy nối tiếp trên cùng một GPU, cộng dồn của từng micro-batch, rồi mới cập nhật một lần. Đó là đúng công thức trung bình của song song dữ liệu, chỉ thay “các worker cùng lúc” bằng “các micro-batch lần lượt”. Đổi lại là thời gian: lần chạy thay vì một.
Ghép hai cách, lô hiệu dụng (effective batch size) của một bước cập nhật là
Trong PyTorch, loss.backward() cộng dồn vào .grad, nên tích lũy chỉ là gọi backward() lần trước một lần step(), với loss đã chia cho . Dùng cùng DDP thì bọc micro-batch đầu trong model.no_sync() để chỉ all-reduce một lần, ở micro-batch cuối.
Độ chính xác hỗn hợp
Phần tiêu đề “Độ chính xác hỗn hợp”Tính bằng số 16 bit nhanh hơn và tốn một nửa bộ nhớ so với 32 bit, trên GPU có lõi tính 16 bit. Nhưng số 16 bit có hai giới hạn:
- FP16 (5 bit mũ, 10 bit phần định trị) chỉ biểu diễn được số dương nhỏ nhất khoảng và lớn nhất 65 504. Gradient bị làm tròn thành 0.
- Khoảng cách giữa 1 và số FP16 kế tiếp là khoảng . Cộng một bước cập nhật vào trọng số bằng 1 thì kết quả vẫn là 1: bước cập nhật biến mất (đã thử bằng NumPy).
Micikevicius và cộng sự (2017; ICLR 2018) vẫn tính lan truyền xuôi và ngược ở 16 bit, và đưa ra ba kỹ thuật để giữ độ chính xác, nay là chuẩn:
- Bản trọng số chính (master weights) ở FP32: phép cập nhật làm trên bản này, nên bước nhỏ không mất. Bản 16 bit dùng để tính được chép lại từ bản chính sau mỗi bước.
- Loss scaling: nhân loss với một hệ số , ví dụ , trước khi lan truyền ngược. Theo quy tắc dây chuyền, mọi gradient cũng được nhân , nên thành khoảng , nằm trong vùng FP16 biểu diễn được. Trước khi cập nhật thì chia lại cho ở FP32.
- Cộng dồn ở FP32: tích các số FP16 (trong phép nhân ma trận, tích vô hướng) được cộng dồn vào một biến FP32, rồi mới làm tròn về 16 bit.
Bài báo dùng hệ số cố định, và ở phần kết luận gợi ý chọn tự động. Cách các thư viện làm ngày nay là loss scaling động: thấy gradient bị tràn (inf, nan) thì bỏ bước đó và giảm , lâu không tràn thì tăng .
BF16 (8 bit mũ, 7 bit phần định trị) có cùng số bit mũ với FP32, nên phạm vi số gần như FP32 và thường không cần loss scaling. Đổi lại, nó chỉ giữ khoảng 2 đến 3 chữ số có nghĩa, nên bản chính FP32 vẫn cần. Trong PyTorch, phần tính 16 bit là torch.autocast, phần loss scaling là torch.amp.GradScaler.
Khi mô hình không vừa một GPU
Phần tiêu đề “Khi mô hình không vừa một GPU”Song song dữ liệu chép cả mô hình lên mỗi GPU, nên không giúp gì khi byte lớn hơn bộ nhớ một GPU. Khi đó phải chia chính mô hình, hoặc chia trạng thái. Ba cách dưới đây được bài Họ LLaMA trình bày chi tiết, có công thức và ví dụ số; ở đây chỉ nêu ý chính.
- Song song tensor (tensor parallelism; Megatron-LM, Shoeybi và cộng sự, 2019): chia các ma trận trong một lớp ra nhiều GPU, ví dụ mỗi GPU giữ một nửa số cột của ma trận đầu tiên trong FFN. Phải trao đổi ở mọi lớp, nên cần đường truyền rất nhanh, thường là các GPU trong cùng một máy.
- Song song pipeline (pipeline parallelism; GPipe, Huang và cộng sự, 2019): chia mô hình theo chiều sâu, mỗi GPU một nhóm lớp liên tiếp. Lô được cắt thành micro-batch nối đuôi nhau để các GPU cùng bận, nhưng vẫn còn bong bóng (pipeline bubble): lúc đầu GPU sau chờ, lúc cuối GPU trước chờ.
- ZeRO (Rajbhandari và cộng sự, 2020) và FSDP (Fully Sharded Data Parallel, cách cài đặt của PyTorch): vẫn là song song dữ liệu, nhưng thay vì bản giống nhau, chia trạng thái bộ tối ưu, rồi gradient, rồi cả tham số ra phần. Ở mức chia nhiều nhất, mỗi GPU còn khoảng byte; GPU cần tham số của lớp nào thì gom các mảnh về, dùng xong thì bỏ.
Các hệ thống lớn ghép nhiều cách: tensor trong một máy, pipeline giữa vài máy, và song song dữ liệu hay FSDP ở ngoài cùng.
Giới hạn
Phần tiêu đề “Giới hạn”- Lô càng lớn chưa chắc càng tốt. Song song dữ liệu bảo toàn gradient của một lô, nhưng thêm GPU là tăng lô hiệu dụng. Lô lớn thường cần chỉnh tốc độ học và khởi động tốc độ học (warmup), tức tăng dần trong các bước đầu (hộp đầu tiên ở mục Lỗi hay gặp).
- Ô code chỉ chạy trên CPU, 2 tiến trình. Nó cho thấy cơ chế, không cho thấy tốc độ. Chạy mô hình lớn trên nhiều GPU khi phục vụ người dùng thuộc bài Deploy LLM: nhiều GPU, phân tán.
- Bài không bàn kiến trúc mới. Các cải tiến sau GPT (RoPE, attention hiệu quả, KV cache) ở bài Transformers nâng cao và Họ LLaMA.
Minh họa
Phần tiêu đề “Minh họa”Hình dưới đặt ba kiểu song song cạnh nhau, trên cùng một mô hình 4 lớp và 2 GPU. Bài này đi sâu vào hàng đầu tiên, song song dữ liệu: hãy để ý mỗi GPU giữ đủ 4 lớp và chỉ trao đổi gradient, còn hai hàng dưới thì chính các lớp bị cắt ra.
Bảng dưới gom lại thứ mỗi cách chia và thứ phải trao đổi:
| Cách | Mỗi GPU giữ | Trao đổi gì, khi nào | Giải quyết |
|---|---|---|---|
| Song song dữ liệu (DDP) | Cả mô hình, một phần lô | All-reduce gradient, một lần mỗi bước | Chậm |
| Tích lũy gradient | Cả mô hình, một micro-batch mỗi lần | Không trao đổi (một GPU) | Lô không vừa bộ nhớ |
| ZeRO / FSDP | Một phần tham số, gradient, trạng thái | Gom tham số từng lớp, chia gradient | Mô hình không vừa, vẫn chia lô |
| Song song tensor | Một phần ma trận của mọi lớp | Kết quả của từng lớp, mọi lớp | Lớp quá lớn |
| Song song pipeline | Một nhóm lớp liên tiếp | Kích hoạt và gradient giữa các nhóm | Mô hình không vừa (chia theo chiều sâu) |
Ví dụ code
Phần tiêu đề “Ví dụ code”Code trong bài chạy thử lần cuối tháng 11/2026: hai ô NumPy với Python 3.14 và NumPy 2.4 (bản có trong Pyodide), ô DDP với Python 3.14 và PyTorch 2.14 trên CPU, Windows 11. Hai ô NumPy chạy ngay trên trang, độc lập với nhau. Ô DDP cần PyTorch và nhiều tiến trình nên chạy trên máy bạn.
Trung bình gradient của các shard
Phần tiêu đề “Trung bình gradient của các shard”Ô dưới đây dựng một bài hồi quy tuyến tính nhỏ: lô 32 mẫu, 3 đặc trưng, loss MSE trung bình như bài Hồi quy tuyến tính. Nó so gradient cả lô với ba cách: 4 worker mỗi worker 8 mẫu rồi lấy trung bình; một máy chạy 4 micro-batch nối tiếp và cộng dồn ; và 4 shard không đều (20, 4, 4, 4 mẫu).
Gradient của các shard so với gradient cả lô
# Data parallel bằng NumPy: trung bình gradient các shard = gradient cả lôimport numpy as np
rng = np.random.default_rng(0)m, n, N = 32, 3, 4 # số mẫu trong lô, số đặc trưng, số workerX = rng.normal(size=(m, n))y = X @ np.array([2.0, -1.0, 0.5]) + 0.1 * rng.normal(size=m)w = np.zeros(n) # mọi worker giữ cùng một bản w
def grad(Xs, ys): # gradient của MSE trung bình trên các mẫu được đưa vào return 2 / len(ys) * Xs.T @ (Xs @ w - ys)
g_full = grad(X, y) # một máy, cả lô 32 mẫu
# 1. Data parallel: mỗi worker 8 mẫu, rồi all-reduce lấy trung bìnhshards = np.array_split(np.arange(m), N)g_dp = np.mean([grad(X[i], y[i]) for i in shards], axis=0)print(f"data parallel: max diff {np.abs(g_dp - g_full).max():.1e}")
# 2. Tích lũy gradient: một máy, 4 micro-batch nối nhau, cộng dồn g / Nacc = np.zeros(n)for i in shards: acc += grad(X[i], y[i]) / Nprint(f"accumulation: max diff {np.abs(acc - g_full).max():.1e}")
# 3. Shard không đều (20, 4, 4, 4 mẫu): trung bình đơn giản bị lệchuneven = np.split(np.arange(m), [20, 24, 28])g_plain = np.mean([grad(X[i], y[i]) for i in uneven], axis=0)g_weighted = sum(len(i) * grad(X[i], y[i]) for i in uneven) / mprint(f"uneven, plain mean: max diff {np.abs(g_plain - g_full).max():.3f}")print(f"uneven, weighted: max diff {np.abs(g_weighted - g_full).max():.1e}")print(f"|g_full| max {np.abs(g_full).max():.3f}")Lần chạy đầu cần tải Python, khoảng 10 MB.
Đọc kết quả:
- Data parallel và tích lũy gradient đều lệch gradient cả lô khoảng , tức chỉ sai số làm tròn của số
float64. Đúng như ba bước dẫn ở mục Lý thuyết. - Shard không đều, trung bình đơn giản: lệch tới 0.629, trong khi thành phần lớn nhất của gradient (dòng cuối) chỉ khoảng 4.940. Sai cỡ 13% độ lớn đó không phải sai số làm tròn: gradient descent sẽ đi theo một hướng khác.
- Shard không đều, trung bình có trọng số : lại về sai số làm tròn, .
Ring all-reduce
Phần tiêu đề “Ring all-reduce”Ô này mô phỏng 4 worker, mỗi worker một gradient 12 số nguyên, cắt thành 4 mảnh 3 số. Hàm ring_step cho mọi worker cùng gửi một mảnh sang phải; pha 1 cộng, pha 2 chép đè. Ô đếm số phần tử worker 0 đã gửi.
Ring all-reduce với 4 worker
# Ring all-reduce: N worker cộng gradient, chỉ gửi cho hàng xóm bên phảiimport numpy as np
rng = np.random.default_rng(0)N, size = 4, 12 # số worker, số phần tử của mỗi gradientgrads = [rng.integers(0, 10, size) for _ in range(N)]target = np.sum(grads, axis=0) # kết quả all-reduce cần cóbuf = [np.array_split(g.copy(), N) for g in grads] # mỗi worker: N mảnh
def ring_step(chunk_of, add): # mọi worker i gửi một mảnh sang i + 1 msgs = [(i, chunk_of(i), buf[i][chunk_of(i)].copy()) for i in range(N)] for i, c, data in msgs: # gửi cùng lúc, nhận xong mới cộng j = (i + 1) % N buf[j][c] = buf[j][c] + data if add else data return len(msgs[0][2]) # số phần tử worker 0 vừa gửi
sent = 0for s in range(N - 1): # pha 1, reduce-scatter: cộng dồn quanh vòng sent += ring_step(lambda i: (i - s) % N, add=True)print("worker 0, chunk 1:", buf[0][1], "target:", target[3:6])for s in range(N - 1): # pha 2, all-gather: chuyền các mảnh đã đủ tổng sent += ring_step(lambda i: (i + 1 - s) % N, add=False)
ok = all(np.array_equal(np.concatenate(b), target) for b in buf)print(f"every worker has the sum: {ok}")print(f"worker 0 sent {sent} numbers = {sent / size:.2f} x size")for k in [2, 4, 8, 64]: # tỉ lệ dữ liệu gửi đi khi có k worker print(f"N = {k:2d}: 2(N-1)/N = {2 * (k - 1) / k:.2f}")Lần chạy đầu cần tải Python, khoảng 10 MB.
Sau pha reduce-scatter, worker 0 giữ mảnh số 1 (đếm từ 0) là [13 20 12], đúng bằng phần tương ứng của tổng cần có. Sau pha all-gather, cả 4 worker đều có đủ tổng. Worker 0 đã gửi 18 số, bằng 1.50 lần kích thước gradient, khớp . Bốn dòng cuối cho thấy tỉ lệ này chỉ tăng từ 1.00 lên 1.97 khi số worker tăng từ 2 lên 64.
DDP với hai tiến trình
Phần tiêu đề “DDP với hai tiến trình”File dưới đây chạy song song dữ liệu thật bằng torch.distributed, trên CPU, không cần GPU:
mp.spawntạo 2 tiến trình, mỗi tiến trình đóng vai một GPU và chạy hàmworker(rank)vớiranklà 0 hoặc 1. Lời gọimp.spawnphải nằm trongif __name__ == "__main__":, vì mỗi tiến trình con nạp lại file từ đầu; thiếu dòng đó, tiến trình con lại gọimp.spawnvà Python báo lỗiRuntimeError.init_process_group("gloo", init_method="tcp://127.0.0.1:29500", ...)nối hai tiến trình qua cổng 29500 trên chính máy bạn. Backendgloochạy trên CPU và có trên cả Windows; với GPU NVIDIA thì người ta dùngnccl.- Dữ liệu là 4 câu đầu Truyện Kiều, 130 ký tự, cắt thành 114 đoạn dài 17 ký tự. Mô hình là một GPT ký tự tí hon: embedding, một khối Transformer có mặt nạ nhân quả, lớp đầu ra. Hàm
forwardnhận đoạn 17 ký tự, lấy 16 ký tự đầu làm đầu vào, 16 ký tự sau làm nhãn, và trả về luôn loss. DistributedSamplerchia 114 đoạn thành hai nửa, mỗi tiến trình 57 đoạn, xáo trộn lại mỗi epoch nhờset_epoch.- Mỗi tiến trình cố ý khởi tạo bằng seed khác nhau (
torch.manual_seed(rank)). Khi bọc mô hình,DDP(...)chép tham số của rank 0 sang các rank khác, nên hai bên bắt đầu giống nhau. Trongloss.backward(), DDP all-reduce và lấy trung bình gradient. - Sau 30 epoch,
all_gathergom vectơ tham số của hai tiến trình về để so.
Chạy trên máy bạn
DDP: 2 tiến trình CPU cùng huấn luyện một GPT tí hon
- Cần cài
pip install torch- Phần cứng
- CPU là đủ, không cần GPU. Khoảng 1 phút trên máy phát triển, phần lớn là thời gian mỗi tiến trình nạp PyTorch
# DDP: 2 tiến trình CPU cùng huấn luyện một GPT tí hon, backend glooimport torchimport torch.distributed as distimport torch.multiprocessing as mpfrom torch import nnfrom torch.nn.parallel import DistributedDataParallel as DDPfrom torch.utils.data import DataLoader, DistributedSampler, TensorDataset
# 4 câu đầu Truyện Kiều làm dữ liệuTEXT = ("trăm năm trong cõi người ta,\nchữ tài chữ mệnh khéo là ghét nhau.\n" "trải qua một cuộc bể dâu,\nnhững điều trông thấy mà đau đớn lòng.\n")L, D, WORLD = 16, 32, 2 # độ dài ngữ cảnh, số chiều, số tiến trìnhADDR = "tcp://127.0.0.1:29500" # nơi các tiến trình hẹn gặp nhau
class TinyGPT(nn.Module): def __init__(self, vocab): super().__init__() self.tok, self.pos = nn.Embedding(vocab, D), nn.Embedding(L, D) self.block = nn.TransformerEncoderLayer( # một khối Transformer D, 2, 4 * D, dropout=0.0, batch_first=True, norm_first=True) self.head = nn.Linear(D, vocab)
def forward(self, w): # w: (B, L + 1). Đoán ký tự 2..L+1 từ 1..L h = self.tok(w[:, :-1]) + self.pos(torch.arange(L)) mask = nn.Transformer.generate_square_subsequent_mask(L) z = self.head(self.block(h, src_mask=mask, is_causal=True)) return nn.functional.cross_entropy(z.mT, w[:, 1:]) # loss
def worker(rank): dist.init_process_group("gloo", init_method=ADDR, rank=rank, world_size=WORLD) chars = sorted(set(TEXT)) ids = torch.tensor([chars.index(c) for c in TEXT]) data = TensorDataset(ids.unfold(0, L + 1, 1)) # mọi đoạn L + 1 ký tự sampler = DistributedSampler(data, WORLD, rank, seed=0) # chia dữ liệu loader = DataLoader(data, batch_size=16, sampler=sampler) torch.manual_seed(rank) # cố ý khởi tạo khác nhau ở mỗi rank model = DDP(TinyGPT(len(chars))) # DDP chép tham số của rank 0 sang opt = torch.optim.AdamW(model.parameters(), lr=3e-3) for epoch in range(1, 31): sampler.set_epoch(epoch) # mỗi epoch xáo trộn một kiểu for (w,) in loader: loss = model(w) opt.zero_grad() loss.backward() # DDP all-reduce gradient ngay trong bước này opt.step() if epoch % 10 == 0: print(f"rank {rank} epoch {epoch} loss {loss.item():.3f}") flat = torch.cat([p.detach().flatten() for p in model.parameters()]) both = [torch.zeros_like(flat) for _ in range(WORLD)] dist.all_gather(both, flat) # gom tham số của mọi rank về if rank == 0: print(f"{len(sampler)} windows/rank, {flat.numel()} params") print(f"max |rank0 - rank1| = {(both[0] - both[1]).abs().max():.1e}") dist.destroy_process_group()
if __name__ == "__main__": mp.spawn(worker, nprocs=WORLD) # chạy worker(0) và worker(1)Kết quả mẫu
Lấy từ một lần chạy thật.
rank 1 epoch 10 loss 1.615rank 1 epoch 20 loss 0.649rank 1 epoch 30 loss 0.261rank 0 epoch 10 loss 1.692rank 0 epoch 20 loss 0.611rank 0 epoch 30 loss 0.27657 windows/rank, 15751 paramsmax |rank0 - rank1| = 0.0e+00Đọc kết quả:
- Loss của hai rank khác nhau (1.692 và 1.615 ở epoch 10), vì mỗi rank in loss trên micro-batch cuối của phần dữ liệu riêng của nó. Lúc khởi tạo, loss kỳ vọng ở khoảng , mức đoán đều trên 39 ký tự; tới epoch 30, cả hai xuống dưới 0.3: với 130 ký tự, mô hình gần như học thuộc.
- Tham số của hai rank giống hệt nhau, sai khác lớn nhất đúng 0, sau 30 epoch và 120 bước cập nhật, dù chúng khởi tạo khác nhau và không bao giờ thấy cùng một dữ liệu. Đó là hai việc của DDP: phát tham số của rank 0 lúc đầu, và all-reduce gradient ở mỗi bước.
- Thứ tự các dòng có thể khác mỗi lần chạy, vì hai tiến trình in song song. Trên Windows, bạn có thể thấy 2 dòng cảnh báo
[c10d] The client socket has failed to connect to [tên-máy]:29500; kết nối sau đó vẫn thành công nên các dòng này vô hại. Nếu cổng 29500 đang bận, đổi sang một số cổng khác trongADDR.
Thử đổi dòng tạo mô hình thành model = TinyGPT(len(chars)), tức bỏ DDP, giữ nguyên phần còn lại. Hai tiến trình khi đó học độc lập, mỗi bên trên nửa dữ liệu của mình. Ở lần chạy thử của bài, loss ở epoch 30 là 0.466 và 0.509, còn sai khác tham số lớn nhất lên tới 5.3: cuối cùng bạn có hai mô hình khác hẳn nhau, không phải một.
Lỗi hay gặp
Phần tiêu đề “Lỗi hay gặp”Kiểm tra nhanh
Phần tiêu đề “Kiểm tra nhanh”Tóm tắt và đọc thêm
Phần tiêu đề “Tóm tắt và đọc thêm”Ý chính:
- GPT là Transformer chỉ có decoder, học . Nhãn là đầu vào dịch một vị trí, và mặt nạ nhân quả cho loss ở mọi vị trí trong một lần chạy. GPT-1, 2, 3 giữ kiến trúc, tăng quy mô; scaling laws cho thấy loss giảm đều theo luật lũy thừa, và Chinchilla khuyên khoảng 20 token cho mỗi tham số.
- Huấn luyện bằng Adam với độ chính xác hỗn hợp tốn khoảng byte, chưa kể kích hoạt. Hai vấn đề riêng: chậm, và không vừa.
- Song song dữ liệu: mỗi GPU một bản mô hình, một phần lô; , bằng trung bình đơn giản khi các shard bằng nhau. Ring all-reduce cho mỗi GPU gửi , với là kích thước gradient. Tích lũy gradient là cùng phép trung bình làm lần lượt.
- Mixed precision tính ở 16 bit, giữ bản chính FP32, nhân loss với khi dùng FP16. Mô hình không vừa thì chia ma trận (tensor), chia lớp (pipeline) hoặc chia trạng thái (ZeRO/FSDP).
Đọc thêm:
- Brown, T. B., Mann, B., Ryder, N., và cộng sự (2020). Language Models are Few-Shot Learners, arXiv:2005.14165, mục 2.1–2.3 và phụ lục D. GPT-3: cỡ các mô hình, dữ liệu, cách huấn luyện song song, và sức tính đã dùng.
- Kaplan, J., McCandlish, S., Henighan, T., và cộng sự (2020). Scaling Laws for Neural Language Models, arXiv:2001.08361; Hoffmann, J., Borgeaud, S., Mensch, A., và cộng sự (2022). Training Compute-Optimal Large Language Models, arXiv:2203.15556. Hai bài về scaling laws; bài thứ hai là Chinchilla. Kaplan viết cho số tham số, chỗ bài này viết .
- Sergeev, A., Del Balso, M. (2018). Horovod: fast and easy distributed deep learning in TensorFlow, arXiv:1802.05799, mục 3. Ring all-reduce giải thích ngắn gọn, có hình.
- Li, S., Zhao, Y., Varma, R., và cộng sự (2020). PyTorch Distributed: Experiences on Accelerating Data Parallel Training, arXiv:2006.15704, VLDB 2020. Thiết kế của DDP: phát tham số lúc đầu, gom gradient thành nhóm, chồng trao đổi lên lan truyền ngược,
no_sync. - Micikevicius, P., Narang, S., Alben, J., và cộng sự (2017). Mixed Precision Training, arXiv:1710.03740, ICLR 2018, mục 3. Bản trọng số chính FP32, loss scaling, cộng dồn ở FP32, và vì sao cần chúng.
- PyTorch. Distributed Data Parallel và torch.utils.data, mục
DistributedSampler(PyTorch 2.14), tài liệu chính thức, đọc tháng 11/2026. Cách DDP hoạt động bên trong, và cách sampler chia dữ liệu.
Trình duyệt đang chặn lưu dữ liệu. Tiến độ sẽ mất khi bạn tải lại trang.
Nội dung theo giấy phép CC BY-NC-SA 4.0. Mã nguồn theo giấy phép MIT.Giới thiệu