Llama bắt đầu từ con số 0: cách hiện thực hóa bài báo mà không phải vừa làm vừa khóc
(blog.briankitano.com)- Brian Kitano tự xây dựng một phiên bản Llama thu nhỏ với TinyShakespeare, và rút ra rằng để hiện thực hóa bài báo một cách an toàn thì nên bắt đầu từ mô hình nhỏ, thay từng thành phần một và huấn luyện/đánh giá sau mỗi lần thay đổi
- Trước hết, ông chuẩn bị các hàm hỗ trợ kiểm chứng như chia dữ liệu, tạo batch, đánh giá loss và hàm sinh văn bản; sau đó xác nhận một mô hình đơn giản có thể compile và huấn luyện rồi mới thêm các thành phần của Llama
- Khi lần lượt gắn RMSNorm, RoPE, SwiGLU, ông kiểm tra tensor shape, tính chất công thức và attention map để xác nhận từng layer hoạt động đúng như kỳ vọng
- Khi bỏ causal mask trong attention dùng RoPE, validation loss giảm xuống tới 0.16 nhưng chất lượng sinh văn bản lại tệ đi; nguyên nhân là rò rỉ thông tin do mô hình nhìn thấy token tương lai
- Phiên bản Llama thu nhỏ cuối cùng có 4 block, khoảng 2.37 triệu tham số, hạ validation loss xuống khoảng 1.0; đồng thời cũng cần kiểm tra cả luồng gradient và lịch learning rate
Bắt đầu nhỏ và lặp lại để tích lũy độ chắc chắn
- Cốt lõi khi hiện thực hóa bài báo là bắt đầu từ mô hình nhỏ, thay từng thành phần một, và mỗi lần thay đều lặp lại quá trình huấn luyện và đánh giá
- Trước tiên cần chuẩn bị các hàm hỗ trợ để kiểm tra mô hình một cách định lượng
- chia dữ liệu
- vòng lặp huấn luyện
- trực quan hóa loss
- đánh giá validation loss
- Thay vì chuyển toàn bộ các thành phần trong bài báo sang cùng lúc, ông còn chuẩn bị một hàm đánh giá định tính để xem trực tiếp kết quả sinh văn bản từ một mô hình đơn giản, nhanh và đã có kinh nghiệm hiện thực hóa
- Với các layer tensor, ông kiểm tra bằng
.shape,assert,plt.imshow; thay vì tối ưu hóa phép nhân ma trận ngay từ đầu, ông tự tính tay kết quả kỳ vọng để đối chiếu rồi mới tối ưu bằng các hàm củatorch - Cần kiểm thử bằng cách thay đổi batch size, độ dài chuỗi và số chiều embedding; mã chỉ đúng với một kích thước có thể sẽ hỏng ở thời điểm suy luận
Dataset và thiết lập cơ bản
- Đối tượng hiện thực hóa là một phiên bản thu nhỏ rất mạnh của Llama từ Meta AI, và dữ liệu huấn luyện là TinyShakespeare
- Llama gốc được huấn luyện với 1.4T token, nhưng ở đây sử dụng TinyShakespeare với quy mô khoảng 1.11 triệu ký tự
- Llama gốc dùng tokenizer byte-pair encoding của SentencePiece, nhưng bản hiện thực này dùng tokenizer theo ký tự đơn giản
- vocabulary size là 65
- do dataset nhỏ nên không tối ưu riêng cách lưu dữ liệu trong bộ nhớ
- Từ điển
MASTER_CONFIGdùng để quản lý các cấu hình mô hình nhưvocab_size,batch_size,context_window,d_model- mục tiêu là giảm hằng số và magic number, đồng thời giúp mã dễ đọc hơn
- Hàm
get_batcheschia dữ liệu thành train 80%, val 10%, test 10%, rồi tạo đầu vàoxvà nhãnylệch sau một ký tự từ các điểm bắt đầu ngẫu nhiên
Xác nhận compile và huấn luyện bằng mô hình cơ bản
- Mô hình đầu tiên là
SimpleBrokenModel, gồm embedding và một mạng feed-forward đơn giảnnn.EmbeddingLinearReLULinear
- Trong việc hiện thực hóa bài báo, nói mô hình “chạy được” nghĩa là phải thỏa cả hai điều kiện
- compile: tensor shape khớp giữa các layer
- huấn luyện: loss thực sự giảm
- Hàm
evaluate_losslấy mẫu 10 batch từ train và val split để tính loss trung bình - Sau 1000 epoch huấn luyện,
SimpleBrokenModelcó validation loss ở mức 3.94, hầu như không giảm nhiều so với cross-entropy ban đầu là 4.17 - Nguyên nhân là ông đã đưa giá trị đã qua softmax vào
F.cross_entropyF.cross_entropycủa PyTorch nhận trực tiếp logits chưa chuẩn hóaSimpleModelsau khi bỏ softmax đã hạ validation loss xuống khoảng 2.51
- Sau đó ông thêm hàm
generateđể kiểm tra trực tiếp các ký tự mô hình tạo ra; mô hình cơ bản chưa hoàn hảo nhưng đã ở trạng thái validation loss giảm đúng hướng
Thành phần Llama 1: RMSNorm
- So với Transformer gốc, Llama dùng ba thay đổi kiến trúc chính
- RMSNorm pre-normalization
- Rotary embeddings
- hàm kích hoạt SwiGLU
- Transformer gốc dùng BatchNormalization, còn Llama dùng RMSNorm, tức là scale theo variance mà không centering vector
- Trong khi Transformer gốc áp dụng normalization theo kiểu post-normalization ở đầu ra của attention layer, Llama dùng pre-normalization, tức là áp dụng trước trên đầu vào
RMSNormđược hiện thực với giả định input shape là(batch, seq_len, d_model)- Kết quả RMSNorm được kiểm thử bằng tính chất norm của layer bằng căn bậc hai của số phần tử trong layer
assert- row-wise comparison
torch.allclose
SimpleModel_RMS, tức mô hình cơ bản có thêm RMSNorm, giảm validation loss nhẹ xuống khoảng 2.5015
Thành phần Llama 2: RoPE và causal mask
- RoPE là phương pháp positional encoding cho Transformer, biểu diễn vị trí token bằng phép quay embedding
get_rotary_matrixtạo ma trận quay theo từng vị trí cho context window và embedding dimension- Việc hiện thực RoPE được kiểm thử bằng tính chất sau
- tích vô hướng của hai vector đã quay tại vị trí
m,nphải khớp với phép quay theo vị trí tương đốin-m
- tích vô hướng của hai vector đã quay tại vị trí
RoPEAttentionHeadtạow_q,w_k,w_v, áp dụng phép quay RoPE cho query và key, rồi dùngF.scaled_dot_product_attention- Cần chú ý tới khác biệt tensor shape giữa thời điểm huấn luyện và suy luận
- khi huấn luyện thường là shape khớp cấu hình như
(config['batch_size'], config['context_window'], config['d_model']) - khi suy luận có thể xử lý một ví dụ đơn lẻ như
(1, 1, config['d_model']) - trong
forward, cần index theo shape lấy từ input chứ không phải theo giá trị cấu hình của mô hình
- khi huấn luyện thường là shape khớp cấu hình như
- Mô hình thêm multi-head attention dùng RoPE nhưng không có causal mask đã làm validation loss giảm mạnh xuống 0.1623, nhưng kết quả sinh văn bản lại kém như
OOOO...,IIII... - Khi kiểm tra attention map, mọi vị trí đều tham chiếu tới mọi vị trí khác, gây ra rò rỉ thông tin vì mô hình nhìn được token tương lai trong tác vụ dự đoán token tiếp theo
- Khi đổi sang
RoPEMaskedAttentionHeadvớiis_causal=TruetrongF.scaled_dot_product_attention, attention ở phần tam giác trên tương ứng với tương lai gần như về 0 - Sau khi áp dụng causal mask, validation loss là 2.0815, và khi huấn luyện lâu hơn thì giảm xuống 1.8985
Thành phần Llama 3: SwiGLU và chồng block
- Llama thay phi tuyến ReLU bằng hàm kích hoạt SwiGLU
SwiGLUđược hiện thực như một Swish-gated linear unit, dùng hai phép biến đổi tuyến tính và tham sốbetacó thể học đượcRopeModelvới SwiGLU trong phần feed-forward có 592,706 tham số và validation loss ở mức 1.8963- Sau đó ông tạo
LlamaBlockđể gom cấu trúc sau thành một block- RMSNorm pre-normalization
- masked RoPE multi-head attention
- residual connection
- RMSNorm pre-normalization
- feed-forward SwiGLU
- residual connection
- Mô hình
Llamacuối cùng đặtn_layers=4và chồng 4LlamaBlockbằngnn.Sequentialdựa trênOrderedDict - Tổng số tham số của mô hình cuối cùng là 2,370,246, và kết quả huấn luyện như sau
- sau lần huấn luyện đầu tiên với 4 layer, validation loss là 1.5532
- sau khi huấn luyện thêm tới 10,000 epoch, validation loss là 1.1479
- sau khi huấn luyện thêm nữa, validation loss là 0.9997
- loss của một batch trong test split là 1.2358
Kết quả sinh văn bản và các điểm cần debug
- Mô hình cuối cùng có thể tạo ra tên riêng, xuống dòng và các mảnh từ giống phong cách Shakespeare, nhưng chất lượng câu thực tế vẫn còn hạn chế
- Có thể trực quan hóa cross-entropy loss dưới góc độ lựa chọn token
- loss ban đầu 4.17 gần tương đương chọn ngẫu nhiên trong vocabulary size 65
- loss 1.08 có thể hiểu như chọn ngẫu nhiên trong khoảng 2.9 token
- Luồng gradient được kiểm tra bằng hàm
show_grads- hàm này tính tỷ lệ gradient có giá trị tuyệt đối nhỏ tại từng tham số
- nếu phần lớn gradient của các tham số không gần 0 thì luồng gradient đang ở trạng thái ổn
- Llama gốc dùng lịch learning Cosine Annealing, nhưng trong bản hiện thực này kết quả thực nghiệm lại kém hơn
- Trong thí nghiệm với Cosine Annealing, ngay cả ở tolerance rất thấp, attention bias gần như không nhận được tín hiệu; nguyên nhân chưa rõ, nên trong hiện thực thực tế, bắt đầu đơn giản vẫn là cách an toàn hơn
1 bình luận
Ý kiến trên Hacker News
Có vẻ có lỗi trong phần triển khai SwiGLU: trong bài báo tham khảo, beta của feed-forward network là một hằng số chứ không phải giá trị có thể học, và được đặt là
FFnSwiGLU = Swish1...Dựa theo công thức 6 trong https://arxiv.org/pdf/2002.05202.pdf
Trong bản triển khai llama chính thức, hằng số beta cũng đã bị loại bỏ: https://github.com/facebookresearch/llama/blob/main/llama/mo...
Nhìn các dòng
"feedforward.1.beta', 0.0"trong log của blog thì beta đã suy biến về 0 trong quá trình huấn luyện, trong khi lẽ ra phải là hằng số 1Mạng cũng thường thích nghi với các thay đổi dù có chủ ý hay không, và sau khi huấn luyện, nhiều biến thể kiến trúc cũng hoạt động tương tự nhau, nên đôi khi không rõ có nhất thiết phải khớp với bản gốc hay không
Một cách để tìm các lỗi kiểu này là khớp chính xác đầu ra với bản triển khai tham chiếu. Ngay cả với trọng số ngẫu nhiên như các mô hình tiny-random của HuggingFace, đầu ra vẫn phải giống hệt; nếu khác thì đó là tín hiệu có lỗi
Tuy nhiên cách này chủ yếu hiệu quả với lỗi phát sinh khi suy luận; các vấn đề chỉ xảy ra trong xử lý dữ liệu, optimizer hoặc khi huấn luyện thì khó bắt hơn
Cá nhân tôi nghĩ là do tính tự hồi quy và đặc tính giống ODE, nhưng cũng chưa đủ chắc chắn
Công việc rất tuyệt, nhưng
SimpleBrokenModelvàSimpleModelban đầu có khá nhiều phép tính lãng phí. Thứ tự làembedding 65 -> 128,linear 128 -> 128,ReLU,linear 128 -> 65; giữa hai tầng đầu không có phi tuyến tính và cả hai đều tuyến tính, nên tầng tuyến tính thứ hai thực chất vô dụngMô hình này rốt cuộc tương đương với một MLP một tầng ẩn cổ điển, và tính theo FLOPS thì
128*128=16kphép tính bị lãng phí trên tổng128*128+65*128=24kTầng embedding là cấu trúc đặc biệt để chuyển chỉ số token thành vector embedding, nên có lẽ không thể bỏ nó
Nhìn chung, bài này minh họa tốt các nguyên tắc cơ bản. Tôi đặc biệt thích câu “hãy dùng
.shapemột cách sùng đạo.assertvàplt.imshowlà bạn của bạn”, và nên luôn assert các điều kiện trước/sau của shapeTôi cũng thắc mắc liệu
bearhaytypeguardcó hỗ trợ các kiểm tra này bằng decorator khôngTuy vậy, đoạn “hãy chọn một mô hình nhỏ, đơn giản, nhanh và tạo helper để đánh giá định tính” có lẽ ý là đánh giá định lượng. Như vậy mới có baseline bằng số để so sánh với các kỹ thuật cao cấp hơn
Lời khuyên triển khai từng thành phần của bài báo cũng nên chính xác hơn. Bài báo thường thử nhiều thay đổi cùng lúc rồi dùng ablation study để chỉ ra đóng góp của từng yếu tố, nên tốt hơn là bắt đầu từ thay đổi kiến trúc cốt lõi, rồi theo thứ tự ảnh hưởng lớn trong ablation study, giữ đúng quan hệ phụ thuộc và đánh giá sau mỗi thay đổi nguyên tử
bearhaytypeguard, nhờ https://peps.python.org/pep-0646/ có thể đẩy một phần việc này trực tiếp vào chú thích kiểu của PythonVí dụ có thể biểu diễn shape theo từng trục trong kiểu như
ndarray[float, Dim1, *Shape], và overload shape trả về tùy theo giá trịaxisbear/typeguardDù vậy, Python có vẻ khó mà tốt bằng Julia. Hệ thống kiểu của Julia có thể đảm bảo kích thước ma trận khớp nhau dễ dàng hơn nhiều
Tôi tò mò nguyên tắc dùng SwiGLU thay cho ReLU là gì. Không biết các tác giả chỉ thử hết các hàm phi tuyến khả dĩ, hay có lý do sâu xa hơn
bearblog đang bị DDoS nên để lại repository: https://github.com/bkitano/llama-from-scratch
Từ góc độ người đang học AI, tôi thử tóm tắt ngắn gọn các thuật ngữ xuất hiện trong bài. Token là một định danh số nguyên biểu thị một mảnh văn bản; trong LLM, các mảnh ký tự thường dùng được gom lại để sử dụng trong phạm vi kích thước từ vựng giới hạn
Hàm mất mát là giá trị đo chênh lệch giữa dự đoán và đáp án đúng, càng thấp càng tốt. PyTorch là thư viện để xử lý tensor và mạng nơ-ron, còn tensor là mảng số đa chiều bao gồm vô hướng, vector và ma trận
Mạng nơ-ron là cấu trúc liên kết các neuron có trọng số và bias, còn lớp tuyến tính là cấu trúc đơn giản trong đó mọi đầu vào và đầu ra đều được kết nối. ReLU là một hàm kích hoạt như
Math.max(0, x); nếu chỉ xếp chồng các lớp tuyến tính thì cuối cùng cũng tương đương một hàm tuyến tính duy nhất, nên nó thêm tính phi tuyến để tăng khả năng họcGradient là lượng thay đổi số được tính trong quá trình huấn luyện để làm mô hình chính xác hơn, còn chuẩn hóa batch là phương pháp điều chỉnh các con số đang truyền qua để hỗ trợ việc học. Mã hóa vị trí cho biết vị trí tương đối của các token bằng vector
Toán tử
@trong Python là alias của__matmul__, dùng cho phép nhân ma trận. Epoch là việc huấn luyện một lượt trên toàn bộ dataset, còn batch là số lượng dữ liệu đưa vào cùng lúc trước khi cập nhật tham sốAttention là cốt lõi giúp LLM hoạt động: nó xử lý song song các token đầu vào để tạo tensor trung gian rồi dùng chúng để sinh các token đầu ra
Ví dụ
writ, phần chung trongwriting,written,writer, có thể trở thành một token, vàwritercó thể được token hóa thànhwritvàerEmbedding là bước chuyển những token như vậy thành biểu diễn số riêng biệt
Nếu có bản triển khai và checkpoint hiện có của mô hình, cách hiệu quả nhất để kiểm tra triển khai của mình có đúng không là tải checkpoint đó và so sánh giá trị đầu ra
Nếu đầu ra không khớp, thường là do triển khai sai ở chi tiết nào đó; bạn có thể lần theo từng lớp một cách có hệ thống để tìm khác biệt thực tế. Trong quá trình đó cũng có thể phát hiện điểm kỳ lạ trong bản triển khai hiện có
Đây là chuyện về chính mô hình, còn huấn luyện là một trục riêng. Dù vậy, nếu đã chỉnh hyperparameter tương đối giống, khi triển khai mô hình chính xác thì nhìn chung sẽ ổn
Cả cách đọc paper lẫn nội dung paper này đều hay, và tôi cũng khuyên xem series Makemore của Karpathy
Các lời khuyên tóm tắt đều rất hay, và tôi nghĩ lời khuyên assert shape của tensor áp dụng được cho bất kỳ thư viện đại số tuyến tính phổ biến nào. Khi viết mã đại số tuyến tính phức tạp, đi từng bước nhỏ và lập trình phòng thủ là cực kỳ quan trọng
Việc lập trình đại số tuyến tính trong các ngôn ngữ chủ đạo thật kinh khủng vì không có kiểm tra shape tại compile time. Shape của tensor nên là một phần của kiểu, và nếu cố nhân
3x4với3x4mà không chuyển vị thì đáng lẽ không nên biên dịch được ngay từ đầuChạy một phép tính dài rồi mới thất bại ở một phép toán không khớp chiều thật sự là tệ nhất
Tôi cũng nghĩ thiết bị của tensor PyTorch nên được kiểu hóa tĩnh. Hiện tại, nếu cố nhân tensor trong bộ nhớ CPU với tensor trong bộ nhớ GPU thì sẽ gặp lỗi runtime