Bộ 06 · Bên trong model
Những chi tiết transformer mà người phỏng vấn hay đào sâu: toán attention, mask, KV cache, sampling và normalization. Bài 05, 07, 10–14, 17. Trả lời thành tiếng mỗi câu trong khoảng một phút, rồi mới xem thẻ.
Q1 · Vì sao ÷ √d, và vì sao trừ max trong softmax? ưu tiên
Bài: 05, 11
Ghi chú
Attention tính q · k ÷ √d trước softmax. Không có ÷ √d (d = 128) thì hỏng chỗ nào? Kernel softmax trừ giá trị lớn nhất của hàng trước khi lấy e^x: vì sao, và nó có làm thay đổi kết quả không?
Trả lời trong 60 giây
- Vì sao điểm tăng theo d: tích vô hướng của hai vector d chiều có các phần tử phương sai đơn vị là tổng của d số hạng, nên phương sai của nó là d và độ trải là √d. Với d = 128 đó là √128 ≈ 11.
- Không có ÷ √d: điểm trải khoảng 11 làm softmax gần như one-hot, nghĩa là attention khoá vào một token duy nhất và gradient biến mất.
- ÷ √d làm gì: chia cho √d đưa độ trải về khoảng 1. Nó chỉ co giãn độ trải; nó không căn giữa thứ gì.
- Vì sao trừ max: e^x tràn số rất nhanh ở FP16, vì giá trị lớn nhất của FP16 là 65.504, nên ngay cả e^12 đã quá lớn. Trừ max của hàng làm mọi số mũ ≤ 0, nên mọi e^x tối đa là 1 và không thể tràn.
- Nó không làm thay đổi kết quả: softmax(x − c) = softmax(x), vì trừ c là nhân mọi số hạng, cả tử lẫn mẫu, với e^(−c), và thừa số đó triệt tiêu.
Các con số chính: softmax(x)ᵢ = e^(xᵢ − max) ÷ Σⱼ e^(xⱼ − max).
Câu hỏi tiếp theo hay gặp: "FlashAttention thấy điểm theo từng khối. Làm sao nó có softmax chính xác?" Nó dùng online softmax: giữ một max chạy dần và một tổng chạy dần khi mỗi khối đến. Khi một khối mới làm max tăng lên, nó co giãn lại tổng cũ và bộ tích luỹ đầu ra bằng e^(m_cũ − m_mới), nên kết quả giống hệt như khi dùng max cuối cùng ngay từ đầu.
Mẹo
Tôi đã nói √d "căn giữa giá trị trung bình". Trong code online softmax, tôi quên rằng max chạy dần không bao giờ được giảm, và quên co giãn lại bộ tích luỹ.
Q2 · Causal mask, và vì sao KV cache là hợp lệ vững
Bài: 11, 14, 17
Ghi chú
Vị trí tương lai được mask bằng cách cộng −∞ trước softmax: vì sao −∞ mà không phải 0? Vì sao các hàng trước của lưới điểm không bao giờ cần tính lại khi decode, và vòng lặp ngây thơ "đưa lại mọi thứ" lãng phí bao nhiêu với prompt 512 token và câu trả lời 256 token?
Trả lời trong 60 giây
- Vì sao −∞ mà không phải 0: mask được cộng trước softmax, và e^(−∞) = 0, nên token bị mask nhận trọng số đúng bằng 0. Cộng 0 thì được e⁰ = 1, tức trọng số trọn vẹn cho token tương lai.
- Vì sao các hàng trước không bao giờ đổi: một token mới nằm trong tương lai đã bị mask của mọi hàng trước, nên các hàng đó không bao giờ thấy nó. Nghĩa là các hàng trước, và K, V đã tính cho các token trước, không bao giờ thay đổi, đó là lý do cache K và V là chính xác tuyệt đối.
- Chi phí của vòng lặp ngây thơ: mỗi bước trong N = 256 bước đưa lại P = 512 token prompt cộng mọi token đã sinh tới giờ, nên khối lượng là N × P + N(N − 1) ÷ 2 = 256 × 512 + 32.640 = 131.072 + 32.640 ≈ 164 nghìn lượt token.
- Thực sự cần bao nhiêu: mỗi token chỉ cần xử lý một lần, nên khối lượng là P + N = 512 + 256 = 768 lượt. Vì 164 nghìn ÷ 768 ≈ 213, vòng lặp ngây thơ lãng phí khoảng 213 lần.
- Cache mua được gì: KV cache biến mỗi bước decode thành một token mới attend vào phần quá khứ đã cache, thay vì tính lại cả chuỗi.
Các con số chính: khối lượng ngây thơ = N × P + N(N − 1) ÷ 2; cần thiết = P + N.
Câu hỏi tiếp theo hay gặp: "Cache tốn gì?" Nó tốn bộ nhớ: 128 KiB mỗi token với Llama 3 8B. Bộ nhớ đó là thứ giới hạn bao nhiêu người dùng chạy được cùng lúc (bộ 02).
Mẹo
Đưa ra lý do, không chỉ sự kiện: e⁰ = 1 để giải thích vì sao 0 không được, và "token mới nằm trong tương lai đã bị mask của chúng" để giải thích vì sao các hàng cũ vẫn hợp lệ.
Q3 · Ba lỗi KV cache, và một vòng decode hỏng ưu tiên
Bài: 10, 13, 17
Ghi chú
Một KV cache cấp phát trước có ba lỗi: (1) token đầu tiên ổn, phần còn lại vô nghĩa; (2) đầu ra tệ đi một cách khó nhận ra, với buffer toàn số 0 bị đọc trọn vẹn; (3) một câu trả lời nhắc tới cuộc hội thoại của người dùng trước. Và một vòng decode dùng out.logits[:, 0, :].argmax(-1) và bỏ qua prompt. Mỗi cái sai ở đâu?
Trả lời trong 60 giây
- Lỗi 1, sai vị trí: các token decode được mã hoá ở sai vị trí, vì vị trí RoPE không được tăng lên cho mỗi token mới. Prefill dùng đúng vị trí, nên token đầu tiên ra ổn và mọi thứ sau đó vô nghĩa.
- Lỗi 2, slot chưa lấp: attention đọc các slot trống toàn số 0 của buffer, và key bằng 0 vẫn nhận một ít trọng số sau softmax, nên đầu ra tệ đi một cách khó nhận ra. Cách sửa là chỉ attend vào phần đã lấp.
- Lỗi 3, không reset: cache không được xoá giữa các request, nên request mới attend vào token của người dùng trước. Đó vừa là lỗi tính đúng vừa là lỗi quyền riêng tư.
- Vòng decode: logits có shape (batch, T, vocab), và vị trí 0 dự đoán thứ đi sau token đầu tiên, nên vòng lặp bỏ qua phần còn lại của prompt. Cách sửa là
logits[:, -1, :], lấy mẫu từ vị trí cuối. - Vì sao engine chỉ tính hàng cuối: logits FP32 đầy đủ cho 8.192 token là 8.192 × 128.256 × 4 B ≈ 4,2 GB, trong khi một hàng là 128.256 × 4 B ≈ 0,5 MB. Chỉ cần hàng cuối để chọn token tiếp theo, nên tính phần còn lại là lãng phí hoàn toàn.
Các con số chính: shape logits (batch, T, vocab); lấy mẫu từ vị trí cuối.
Câu hỏi tiếp theo hay gặp: "Bạn test một KV cache thế nào?" Chạy vòng lặp có cache và vòng lặp ngây thơ trên cùng một prompt và so đầu ra từng token. Chúng phải khớp tuyệt đối, vì cache K và V là chính xác.
Mẹo
Với lỗi 1 tôi đã đoán "lưu sai K/V" (điều đó cũng sẽ làm hỏng token decode đầu tiên), và nói logits có shape hidden × vocab (đó là ma trận trọng số của LM head).
Q4 · Truyện lặp lại so với từ ngữ kỳ quặc ưu tiên
Bài: 13
Ghi chú
Một trợ lý viết truyện ở temperature 0 lặp lại cùng một cụm từ trong các truyện dài. Đổi sang temperature 1,2 mà không chỉnh gì khác thì thỉnh thoảng có từ kỳ quặc. Nguyên nhân và cách sửa cho từng trường hợp?
Trả lời trong 60 giây
- Lặp lại ở temperature 0: temperature 0 là decode greedy, luôn lấy token cao nhất. Nên một khi một cụm từ thành cách viết tiếp có khả năng nhất, model cứ chọn nó và rơi vào vòng lặp.
- Sửa lỗi lặp: lấy mẫu thay vào đó, với temperature khoảng 0,7–1,0, và tuỳ chọn thêm repetition penalty.
- Từ kỳ quặc ở 1,2: temperature trên 1 làm phẳng phân phối, nâng phần đuôi dài các token rác lên. Không có cấu hình nào khác thì không gì loại bỏ phần đuôi đó, nên token rác thỉnh thoảng được rút trúng.
- Sửa token rác: cắt phần đuôi bằng top-p, giữ tập nhỏ nhất các token đạt xác suất p, hoặc bằng min-p, bỏ các token dưới một tỷ lệ của xác suất token cao nhất.
- Thứ tự quan trọng: áp temperature trước, rồi softmax, rồi cắt đuôi, rồi chuẩn hoá lại, rồi rút.
Các con số chính: p ∝ e^(logit ÷ T); T → 0 là greedy.
Câu hỏi tiếp theo hay gặp: "Vì sao min-p thường tốt hơn top-p ở temperature cao?" Vì ngưỡng cắt của min-p co giãn theo độ tự tin của token cao nhất. Nên nó cắt mạnh khi model chắc chắn, và để ngỏ khi model không chắc.
Mẹo
Tôi đã nói "cắt đuôi rồi mới áp temperature" (ngược lại mới đúng) và nói temperature làm các kết quả ngang nhau (nó làm phẳng).
Q5 · Residual stream và RMSNorm chuẩn hoá cái gì ưu tiên
Bài: 12
Ghi chú
Vì sao mỗi layer làm x ← x + f(x) thay vì x ← f(x)? RMSNorm có chuẩn hoá bản thân residual stream không, và vì sao điều đó quan trọng?
Trả lời trong 60 giây
- Vì sao x + f(x): kết nối residual cho một đường đồng nhất (identity), nên gradient chảy thẳng qua các chồng layer sâu. Nghĩa là mỗi layer chỉ phải thêm một tinh chỉnh lên trên x thay vì xây lại nó.
- Cái gì được chuẩn hoá: model hiện đại là pre-norm, nên mỗi layer tính x ← x + f(RMSNorm(x)). RMSNorm chỉ chuẩn hoá bản sao đưa vào mỗi sublayer, nghĩa là bản thân stream vẫn giữ giá trị thô và tiếp tục tích luỹ.
- RMSNorm làm gì: nó chia cho căn bậc hai trung bình bình phương và áp một hệ số gain học được, không trừ trung bình. Bỏ qua trung bình là thứ làm nó rẻ hơn LayerNorm.
Các con số chính: RMSNorm(x) = x ÷ √(mean(x²) + ε) × g.
Câu hỏi tiếp theo hay gặp: "Final norm nằm ở đâu?" Có một lần RMSNorm cuối cùng ngay trước LM head, vì stream thô đã lớn dần qua mọi layer và bản thân nó chưa bao giờ được chuẩn hoá.
Mẹo
Tôi đã không nhớ ra rằng chỉ đầu vào của sublayer được chuẩn hoá, không phải stream.
Tự xây
exercises/05_07_online_softmax.py: softmax chính xác tính theo từng khối, rồi đầu ra attention trong một lượt, như FlashAttention làm. Chạyuv run exercises/05_07_online_softmax.py.exercises/10_13_sampler.py: một sampler từ logits thô (greedy, temperature, top-p, min-p, chuẩn hoá lại, rút). Chạyuv run exercises/10_13_sampler.py.exercises/14_17_kv_cache.py: một vòng attention có cache với buffer cấp phát trước, được chứng minh khớp tuyệt đối với vòng lặp ngây thơ, cộng một demo lỗi slot chưa lấp. Chạyuv run exercises/14_17_kv_cache.py.