Skip to content

GPT và huấn luyện phân tán

This content is not available in your language yet.

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 p(xt∣x<t)p(x_t \mid x_{<t}), 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í 2(N−1)N\frac{2(N-1)}{N} của nó.
  • Dùng được tích lũy gradient (gradient accumulation), độ chính xác hỗn hợp (mixed precision) và DistributedDataParallel của PyTorch. Nhận ra khi nào phải chuyển sang tensor parallel, pipeline parallel hay ZeRO/FSDP.
  • Transformers, in Deep Learning(Not translated yet) (Learned)

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.

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à 16×1.5×109=24×10916 \times 1.5 \times 10^9 = 24 \times 10^9 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 3.14×10233.14 \times 10^{23} phép tính dấu phẩy động. Một GPU A100 tính tối đa khoảng 312×1012312 \times 10^{12} 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 10910^9 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.

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?

Gọi một đoạn văn là dãy token x1,…,xLx_1, \dots, x_L. Theo quy tắc nhân xác suất, p(x1,…,xL)=∏t=1Lp(xt∣x<t)p(x_1, \dots, x_L) = \prod_{t=1}^{L} p(x_t \mid x_{<t}). GPT ước lượng từng thừa số pθ(xt∣x<t)p_{\boldsymbol{\theta}}(x_t \mid x_{<t}) 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í:

L(θ)=−1L∑t=1Lln⁡pθ(xt+1∣x1,…,xt)\mathcal{L}(\boldsymbol{\theta}) = -\frac{1}{L} \sum_{t=1}^{L} \ln p_{\boldsymbol{\theta}}(x_{t+1} \mid x_1, \dots, x_t)

Ở đây đầu vào là x1,…,xLx_1, \dots, x_L và nhãn là x2,…,xL+1x_2, \dots, x_{L+1}: lấy một đoạn L+1L + 1 token, nhãn chính là đầu vào dịch đi một vị trí. Đầu ra ở vị trí tt dự đoán token t+1t + 1.

Vì sao một lần chạy cho cả LL số hạng? Mặt nạ nhân quả bảo đảm biểu diễn ở vị trí tt chỉ phụ thuộc x1,…,xtx_1, \dots, x_t. Vậy dự đoán ở vị trí tt không “nhìn trộm” được đáp án xt+1x_{t+1}, và cả LL 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.

Mô hìnhNămCỡDữ liệuÝ chính
GPT-1 (Radford và cộng sự)201812 khối, D=768D = 768, ngữ cảnh 512BooksCorpus, hơn 7 000 cuốn sáchPre-train rồi fine-tune riêng từng tác vụ
GPT-2 (Radford và cộng sự)2019tới 1.5 tỉ tham số, ngữ cảnh 1 024WebText, khoảng 40 GBGhi tác vụ ngay trong văn bản, đo zero-shot (không ví dụ)
GPT-3 (Brown và cộng sự)2020175 tỉ tham số, ngữ cảnh 2 048300 tỉ tokenHọ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 6Ψ6\Psi phép tính cho mỗi token, với Ψ\Psi là số tham số. Kiểm lại với GPT-3: 6×175×109×300×109≈3.15×10236 \times 175 \times 10^9 \times 300 \times 10^9 \approx 3.15 \times 10^{23}, khớp con số 3.14×10233.14 \times 10^{23} 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ả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ưuByte mỗi tham số
Tham số 16 bit2
Gradient 16 bit2
Bản FP32 của tham số4
Momentum của Adam (FP32)4
Phương sai của Adam (FP32)4
Tổng16

Ba dòng FP32 là trạng thái bộ tối ưu (optimizer state). Mô hình Ψ\Psi tham số cần 16Ψ16\Psi 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ó NN GPU, ta gọi chung là NN worker, đánh số k=1,…,Nk = 1, \dots, N. Mỗi worker giữ một bản đầy đủ của tham số θ\boldsymbol{\theta}, giống hệt nhau. Một lô mm mẫu được chia thành NN phần S1,…,SNS_1, \dots, S_N (gọi là shard), mỗi phần B=m/NB = m/N mẫu. Với GPT, một mẫu là một đoạn văn bản.

Gọi ℓi(θ)\ell_i(\boldsymbol{\theta}) là loss của mẫu ii. Loss của cả lô là trung bình:

L(θ)=1m∑i=1mℓi(θ)\mathcal{L}(\boldsymbol{\theta}) = \frac{1}{m} \sum_{i=1}^{m} \ell_i(\boldsymbol{\theta})

Worker kk 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:

Lk=1B∑i∈Skℓi,gk=∇Lk=1B∑i∈Sk∇ℓi\mathcal{L}_k = \frac{1}{B} \sum_{i \in S_k} \ell_i, \qquad \mathbf{g}_k = \nabla \mathcal{L}_k = \frac{1}{B} \sum_{i \in S_k} \nabla \ell_i

Ta muốn chứng minh rằng trung bình của các gk\mathbf{g}_k chính là ∇L\nabla \mathcal{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

∇L=1m∑i=1m∇ℓi\nabla \mathcal{L} = \frac{1}{m} \sum_{i=1}^{m} \nabla \ell_i

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 mm mẫu bằng tổng của NN tổng con:

∇L=1m∑k=1N∑i∈Sk∇ℓi\nabla \mathcal{L} = \frac{1}{m} \sum_{k=1}^{N} \sum_{i \in S_k} \nabla \ell_i

Bước 3: thay mỗi tổng con bằng gradient của worker. Từ định nghĩa của gk\mathbf{g}_k, tổng con ∑i∈Sk∇ℓi\sum_{i \in S_k} \nabla \ell_i bằng B gkB\,\mathbf{g}_k. Thay vào, rồi dùng B/m=1/NB/m = 1/N:

∇L=1m∑k=1NB gk=Bm∑k=1Ngk=1N∑k=1Ngk\begin{aligned} \nabla \mathcal{L} &= \frac{1}{m} \sum_{k=1}^{N} B\,\mathbf{g}_k \\ &= \frac{B}{m} \sum_{k=1}^{N} \mathbf{g}_k \\ &= \frac{1}{N} \sum_{k=1}^{N} \mathbf{g}_k \end{aligned}

Vậy chỉ cần các worker trao đổi để mỗi worker có trung bình của NN 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ô mm mẫu, chỉ khác sai số làm tròn.

Khi các shard không bằng nhau, gọi BkB_k là số mẫu của shard kk. Bước 3 cho ∑i∈Sk∇ℓi=Bk gk\sum_{i \in S_k} \nabla \ell_i = B_k\,\mathbf{g}_k, nên

∇L=∑k=1NBkm gk\nabla \mathcal{L} = \sum_{k=1}^{N} \frac{B_k}{m}\,\mathbf{g}_k

Đâ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 1N∑kgk\frac{1}{N}\sum_k \mathbf{g}_k chỉ đúng khi mọi BkB_k 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.

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 GG là kích thước gradient. Máy trung tâm phải nhận (N−1)G(N-1)G rồi gửi đi (N−1)G(N-1)G: càng nhiều worker, máy đó càng nghẽn.

Ring all-reduce xếp NN 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 NN mảnh, mỗi mảnh G/NG/N.

  1. Reduce-scatter, N−1N - 1 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 N−1N - 1 bước, mỗi worker giữ tổng đầy đủ của đúng một mảnh.
  2. All-gather, N−1N - 1 bước nữa. Mỗi worker chuyền mảnh đã đủ tổng sang phải, bên nhận chép đè. Sau N−1N - 1 bước, ai cũng có đủ NN 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ướcGPU 0 gửiGPU 1 gửiGPU 2 gửiSau bước này
Reduce-scatter 1A0B1C2GPU 1 có A01, GPU 2 có B12, GPU 0 có C20
Reduce-scatter 2C20A01B12GPU 0 có B012, GPU 1 có C012, GPU 2 có A012
All-gather 1B012C012A012Mỗi GPU có hai mảnh đủ tổng
All-gather 2A012B012C012Mỗi GPU có cả ba mảnh đủ tổng

Đếm lượng gửi. Có 2(N−1)2(N-1) bước, mỗi bước một worker gửi một mảnh G/NG/N. Tổng lượng mỗi worker gửi là

2(N−1)⋅GN=2(N−1)N G2(N-1) \cdot \frac{G}{N} = \frac{2(N-1)}{N}\,G

Con số này nhỏ hơn 2G2G và gần như không tăng khi thêm worker: 1.5 G1.5\,G với N=4N = 4, 1.97 G1.97\,G với N=64N = 64. 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 1.97×3≈5.91.97 \times 3 \approx 5.9 GB, mất khoảng 0.47 giây mỗi bước. Cách máy trung tâm phải nhận 63×3=18963 \times 3 = 189 GB qua cùng đường truyền, khoảng 15 giây (tự tính, bỏ qua độ trễ). Số bước 2(N−1)2(N-1) thì tăng theo NN, 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).

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 AA micro-batch chạy nối tiếp trên cùng một GPU, cộng dồn 1Aga\frac{1}{A}\mathbf{g}_a 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: AA 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à

loˆ hiệu dụng=(micro-batch moˆ˜i GPU)×A×N\text{lô hiệu dụng} = (\text{micro-batch mỗi GPU}) \times A \times N

Trong PyTorch, loss.backward() cộng dồn vào .grad, nên tích lũy chỉ là gọi backward() AA lần trước một lần step(), với loss đã chia cho AA. Dùng cùng DDP thì bọc A−1A - 1 micro-batch đầu trong model.no_sync() để chỉ all-reduce một lần, ở micro-batch cuối.

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 6×10−86 \times 10^{-8} và lớn nhất 65 504. Gradient 10−810^{-8} bị làm tròn thành 0.
  • Khoảng cách giữa 1 và số FP16 kế tiếp là khoảng 0.0010.001. Cộng một bước cập nhật 10−410^{-4} 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:

  1. 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.
  2. Loss scaling: nhân loss với một hệ số ss, ví dụ s=1024s = 1024, trước khi lan truyền ngược. Theo quy tắc dây chuyền, mọi gradient cũng được nhân ss, nên 10−810^{-8} thành khoảng 10−510^{-5}, nằm trong vùng FP16 biểu diễn được. Trước khi cập nhật thì chia lại cho ss ở FP32.
  3. 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ố ss cố định, và ở phần kết luận gợi ý chọn ss 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 ss, lâu không tràn thì tăng ss.

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.

Song song dữ liệu chép cả mô hình lên mỗi GPU, nên không giúp gì khi 16Ψ16\Psi 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ì NN bản giống nhau, chia trạng thái bộ tối ưu, rồi gradient, rồi cả tham số ra NN phần. Ở mức chia nhiều nhất, mỗi GPU còn khoảng 16Ψ/N16\Psi/N 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.

  • 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 η\eta 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.

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.

Data parallelism: split the batchLayer 1Layer 2Layer 3Layer 4GPU 1GPU 2Tensor parallelism: split within a layerLayer 1Layer 2Layer 3Layer 4GPU 1GPU 2Pipeline parallelism: split the layersmicro-batchesLayer 1Layer 2Layer 3Layer 4GPU 1GPU 2Half AHalf Bsum gradientsFullbatchsum results4321activationsgradients
The same 4-layer model on 2 GPUs; the highlighted part is what gets split. ZeRO/FSDP (which also splits the state of data parallelism) and context parallelism are not in the figure.

Bảng dưới gom lại thứ mỗi cách chia và thứ phải trao đổi:

CáchMỗi GPU giữTrao đổi gì, khi nàoGiả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ướcChậm
Tích lũy gradientCả mô hình, một micro-batch mỗi lầnKhông trao đổi (một GPU)Lô không vừa bộ nhớ
ZeRO / FSDPMột phần tham số, gradient, trạng tháiGom tham số từng lớp, chia gradientMô hình không vừa, vẫn chia lô
Song song tensorMột phần ma trận của mọi lớpKết quả của từng lớp, mọi lớpLớp quá lớn
Song song pipelineMột nhóm lớp liên tiếpKích hoạt và gradient giữa các nhómMô hình không vừa (chia theo chiều sâu)

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.

Ô 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 g/4\mathbf{g}/4; 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ố worker
X = 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ình
shards = 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 / N
acc = np.zeros(n)
for i in shards:
acc += grad(X[i], y[i]) / N
print(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ệch
uneven = 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) / m
print(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}")

The first run downloads Python, about 10 MB.

Đọc kết quả:

  • Data parallel và tích lũy gradient đều lệch gradient cả lô khoảng 4.4×10−164.4 \times 10^{-16}, 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ố Bkm\frac{B_k}{m}: lại về sai số làm tròn, 8.9×10−168.9 \times 10^{-16}.

Ô 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ải
import numpy as np
rng = np.random.default_rng(0)
N, size = 4, 12 # số worker, số phần tử của mỗi gradient
grads = [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 = 0
for 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}")

The first run downloads Python, about 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 2(N−1)N=2⋅34\frac{2(N-1)}{N} = \frac{2 \cdot 3}{4}. 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.

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.spawn tạo 2 tiến trình, mỗi tiến trình đóng vai một GPU và chạy hàm worker(rank) với rank là 0 hoặc 1. Lời gọi mp.spawn phải nằm trong if __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ọi mp.spawn và Python báo lỗi RuntimeError.
  • 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. Backend gloo chạy trên CPU và có trên cả Windows; với GPU NVIDIA thì người ta dùng nccl.
  • 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 forward nhậ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.
  • DistributedSampler chia 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. Trong loss.backward(), DDP all-reduce và lấy trung bình gradient.
  • Sau 30 epoch, all_gather gom vectơ tham số của hai tiến trình về để so.

Run on your machine

DDP: 2 tiến trình CPU cùng huấn luyện một GPT tí hon

Install
pip install torch
Hardware
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 gloo
import torch
import torch.distributed as dist
import torch.multiprocessing as mp
from torch import nn
from torch.nn.parallel import DistributedDataParallel as DDP
from torch.utils.data import DataLoader, DistributedSampler, TensorDataset
# 4 câu đầu Truyện Kiều làm dữ liệu
TEXT = ("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ình
ADDR = "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)

Sample output

From one real run.

rank 1 epoch 10 loss 1.615
rank 1 epoch 20 loss 0.649
rank 1 epoch 30 loss 0.261
rank 0 epoch 10 loss 1.692
rank 0 epoch 20 loss 0.611
rank 0 epoch 30 loss 0.276
57 windows/rank, 15751 params
max |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 ln⁡39≈3.66\ln 39 \approx 3.66, 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 trong ADDR.

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.

Question 1 of 5 Bạn cắt một đoạn 513 token thành đầu vào 512 token và nhãn 512 token (nhãn là đầu vào dịch đi một vị trí). Một lần lan truyền xuôi của GPT cho bao nhiêu số hạng trong loss? Với BERT che 15% của một chuỗi 512 token thì khoảng bao nhiêu?
Question 2 of 5 Hai worker cùng tính gradient cho một lô 40 mẫu. Worker 1 có 30 mẫu, gradient của loss trung bình trên phần của nó là g1\mathbf{g}_1. Worker 2 có 10 mẫu, gradient là g2\mathbf{g}_2. Gradient của loss trung bình trên cả 40 mẫu là gì?
Question 3 of 5 Bạn huấn luyện bằng DDP trên 16 GPU. Mỗi GPU chạy micro-batch 8 chuỗi, tích lũy gradient qua 4 micro-batch rồi mới cập nhật. Một bước cập nhật dùng gradient của bao nhiêu chuỗi?
Question 4 of 5 Gradient của một mô hình chiếm 2 GB. Với ring all-reduce trên 8 GPU, mỗi GPU gửi đi khoảng bao nhiêu dữ liệu cho một lần all-reduce? Lên 64 GPU thì sao?
Question 5 of 5 Mô hình 7 tỉ tham số, huấn luyện bằng Adam với độ chính xác hỗn hợp (16 byte mỗi tham số, chưa kể kích hoạt). Bạn có 8 GPU 80 GB. Điều nào đúng?

Ý chính:

  • GPT là Transformer chỉ có decoder, học p(xt∣x<t)p(x_t \mid x_{<t}). 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 16Ψ16\Psi 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ô; ∇L=∑kBkmgk\nabla\mathcal{L} = \sum_k \frac{B_k}{m}\mathbf{g}_k, bằng trung bình đơn giản khi các shard bằng nhau. Ring all-reduce cho mỗi GPU gửi 2(N−1)NG\frac{2(N-1)}{N}G, với GG 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 ss 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:

Content licensed under CC BY-NC-SA 4.0. Code under the MIT license.About