14. Bài tập#
Chín bài tập của giáo trình kèm lời giải chi tiết, bộ câu hỏi trắc nghiệm theo chương và các bài luyện tính tay.
Lượng tử dải sang uint8. Tính , , rồi lượng tử và giải lượng tử các giá trị ; ; ; . Giá trị nào chịu sai số cắt?
Gợi ý của đề
Xem lời giải chi tiết
Bước 1 — kiểm tra dải. Dải đã chứa số 0 nên không phải mở rộng (Mục 3.4).
Bước 2 — tính .
Con số này rất dễ chịu: đúng bằng số nguyên, nên mọi phép chia phía dưới đều tròn.
Bước 3 — tính .
Đây chính là chỗ gợi ý của đề nhắc: rơi đúng vào điểm giữa. NumPy (và cả round() của Python) dùng quy tắc làm tròn nửa về số chẵn, nên cho 42 chứ không phải 43.
Hệ quả quan trọng. Vì bị làm tròn xuống, dải thực sự biểu diễn được không còn là mà là
tức toàn bộ lưới bị đẩy sang phải . Đúng hiện tượng đã mô tả ở Mục 3.3 với Hình 4.
Bước 4 — lượng tử và giải lượng tử.
| Sai số | Loại sai số | ||||
|---|---|---|---|---|---|
| làm tròn, đúng bằng | |||||
| không có | |||||
| không có | |||||
| cắt |
Trả lời câu hỏi của đề. Chỉ chịu sai số cắt: phép clamp thực sự can thiệp (297 bị kéo về 255) và sai số lớn gấp 84 lần sai số làm tròn tối đa.
Hai giá trị đáng chú ý khác:
- và không sai số chút nào. Với là do thiết kế (, Mục 3.4); với là may mắn số học: nguyên nên 1 nằm đúng trên lưới.
- không bị
clamp(vì vẫn nằm trong ) nhưng sai số của nó đúng bằng — mức làm tròn lớn nhất có thể. Nó nằm ngay ngoài dải biểu diễn được, đúng ở ranh giới giữa hai loại sai số.
Biến thể đáng thử. Nếu thư viện của bạn làm tròn nửa ra xa số 0 thì , dải biểu diễn thành , và khi đó mới là giá trị bị cắt còn thì không. Quy ước làm tròn không phải chi tiết vụn vặt.
import numpy as np
S = 3 / 255
Z = int(np.round(0 - (-0.5) / S)) # 42
x = np.array([-0.5, 0.0, 1.0, 3.0])
q = np.clip(np.round(x / S) + Z, 0, 255).astype(np.int32)
xh = S * (q - Z)
print(S, Z); print(q); print(xh); print(xh - x)
Hãy giải thích vì sao np.float16(2049.0) bằng 2048, và tìm số nguyên dương nhỏ nhất không biểu diễn chính xác được bằng FP16.
Gợi ý của đề
Xem lời giải chi tiết
Phần a — vì sao np.float16(2049.0) bằng 2048.
FP16 có 10 bit mantissa được lưu, cộng bit ẩn là bit độ chính xác (Mục 2.2). Với một số nằm trong , hai số FP16 liền kề cách nhau
Số 2049 nằm trong , nên và . Hai số biểu diễn được kề nó là 2048 và 2050, còn 2049 rơi đúng điểm giữa.
IEEE 754 mặc định làm tròn về số gần nhất, hoà thì chọn mantissa chẵn:
- — bit cuối mantissa là 0 (chẵn),
- — bit cuối mantissa là 1 (lẻ).
Nên kết quả là 2048. Đây cùng một quy tắc đã khiến ở Bài 1, và khiến np.float16(1993.5) = 1994 ở Mục 2.3.
Phần b — số nguyên dương nhỏ nhất không biểu diễn chính xác được.
Một số nguyên biểu diễn được chính xác khi và chỉ khi nó viết được dạng với nguyên và . Nói cách khác: cần không quá chữ số có nghĩa trong hệ nhị phân.
- Mọi đều được: chỉ cần 1 chữ số có nghĩa.
- cần 12 chữ số có nghĩa, và không rút gọn được vì nó lẻ.
Vậy đáp số là , đúng như gợi ý của đề.
Tổng quát hoá. Với định dạng có bit độ chính xác, số nguyên dương nhỏ nhất không biểu diễn chính xác được luôn là :
| Kiểu | Mantissa lưu | ||
|---|---|---|---|
| FP32 | 23 | 24 | |
| FP16 | 10 | 11 | |
| BF16 | 7 | 8 | |
| FP8 E4M3 | 3 | 4 |
Bảng này giải thích một sự cố rất hay gặp trong thực tế: BF16 không đếm nổi quá 256. Bộ đếm bước huấn luyện, chỉ số token hay ID mà để ở BF16 sẽ đứng yên từ 257 trở đi. Cũng vì vậy mà bộ cộng dồn trong Chương 6 phải là int32 chứ không thể là một kiểu dấu phẩy động ngắn.
import numpy as np
n = 1
while np.float16(n) == n:
n += 1
print(n) # 2049
Chứng minh rằng với lưới đối xứng đầy đủ và dữ liệu đều trên đúng dải biểu diễn, SQNR bằng dB. So sánh với kết quả ở Mục 4.2 và giải thích sự khác biệt.
Xem lời giải chi tiết
Thiết lập. Lưới đối xứng đầy đủ có đúng mức, bước nhảy . Mỗi mức "phụ trách" một ô rộng , nên vùng mà lưới phủ được có bề rộng tổng cộng
Đề cho dữ liệu phân phối đều trên đúng dải biểu diễn, tức đều trên một đoạn rộng .
Công suất tín hiệu. Biến ngẫu nhiên đều trên đoạn rộng (lấy tâm làm gốc) có
Công suất nhiễu. Mọi giá trị đều nằm trong dải nên không có sai số cắt; chỉ còn sai số làm tròn, phân phối đều trên (Mục 4.1):
Kết quả.
So sánh với ở Mục 4.2. Hai giả thiết khác nhau ở đúng hai chỗ, và cả hai đều làm giảm công suất tín hiệu chứ không đụng tới nhiễu:
- Bỏ bớt một mã. Lưới hạn chế dùng , tức vứt giá trị để lưới đối xứng hoàn hảo. Còn mức thay vì .
- Đo dải theo mức chứ không theo ô. Mục 4.2 lấy dữ liệu đều trên với , , tức từ mức thấp nhất đến mức cao nhất, bề rộng . So với bề rộng phủ ô , cách này hụt nửa ô ở mỗi đầu.
Chênh lệch giữa hai kết quả vì thế đúng bằng tỉ số biên độ:
| 4 | 24,08 dB | 22,92 dB | dB |
| 8 | 48,16 dB | 48,09 dB | dB |
| 16 | 96,33 dB | 96,33 dB | dB |
Hai cột giữa khớp đúng bảng số liệu ở Mục 4.2.
Điều cần rút ra. Hằng số cộng thêm phụ thuộc quy ước, nhưng độ dốc thì không: cả hai đều cho dB mỗi bit. Khi lớn, và hai công thức trùng nhau. Chênh lệch chỉ đáng kể ở số bit rất thấp — đúng vùng mà mọi thứ khác (chọn ngưỡng, granularity, QAT) cũng bắt đầu quan trọng.
Viết lại lập luận ở Mục 5.2 cho phép tích chập 2D và chỉ ra trục nào của tensor trọng số có thể mang scale riêng mà vẫn tính được bằng số nguyên. Riêng với depthwise convolution (), điều gì thay đổi?
Xem lời giải chi tiết
Viết lại phép tích chập. Với trọng số có hình :
Tiêu chuẩn duy nhất (đúng như lập luận ở Mục 5.2): một hệ số scale đưa được ra ngoài tổng khi và chỉ khi nó không phụ thuộc chỉ số bị lấy tổng. Ở đây các chỉ số bị lấy tổng là , , .
Xét từng trục của tensor trọng số:
| Trục | Bị lấy tổng? | Được mang scale riêng? |
|---|---|---|
| (chỉ số ) | không | có — đây chính là per-channel chuẩn |
| (chỉ số ) | có | không |
| (chỉ số ) | có | không |
| (chỉ số ) | có | không |
Vậy với tích chập 2D thông thường, chỉ trục mang được scale riêng mà vẫn giữ được bộ cộng dồn thuần số nguyên:
Còn activation thì sao? Chỉ số của là . Chỉ số bị lấy tổng nên không được lượng tử activation theo kênh. Chỉ số batch thì không bị lấy tổng, nên scale theo từng mẫu là hợp lệ — đây đúng là bản sao của "per-token" ở LLM (Mục 11.2).
Depthwise convolution. Khi , trọng số có hình và mỗi kênh đầu ra chỉ đọc đúng một kênh đầu vào :
Hai thay đổi:
- Tổng không còn chạy theo . Chỉ số kênh đầu vào giờ được xác định bởi chứ không bị lấy tổng. Về mặt toán học, activation được phép mang scale theo kênh ở riêng lớp này, vì đưa được ra ngoài tổng:
Lưu ý thực tế: đây là điều đúng nhưng hiếm được framework hỗ trợ, vì cùng tensor activation đó thường còn chảy vào các lớp pointwise/Linear phía sau — và ở đó kênh lại bị lấy tổng.
- Per-channel trở nên tối quan trọng. Mỗi kênh đầu ra chỉ có trọng số, và sau khi gộp BatchNorm mỗi kênh bị nhân với khác nhau (Mục 7.3). Dải giữa các kênh vì thế chênh nhau rất mạnh — chính là hiện tượng đo được ở Mục 5.3 và Hình 8, và là lý do lượng tử per-tensor 8 bit làm MobileNetV2 sụp đổ.
Per-group thì sao? Chia trục thành các nhóm liên tiếp (Mục 5.1) vẫn đặt scale bên trong tổng. Nó chạy được là vì ta chấp nhận tách tổng thành từng nhóm: cộng dồn số nguyên trong mỗi nhóm, rồi nhân scale của nhóm đó và cộng các nhóm lại bằng số thực (hoặc int32 sau requantize). Đổi lại độ chính xác cao hơn là một lần nhân scale cho mỗi nhóm — chấp nhận được khi nhóm đủ lớn (32–128), và đó là lý do per-group là chuẩn cho LLM 4 bit.
Trong code/numpy_experiments.py, đổi trọng số sang lượng tử bất đối xứng (). Bổ sung đúng các số hạng ở Mục 6.2 để kết quả số nguyên vẫn trùng khớp từng bit với mô phỏng. Đếm số phép toán phát sinh thêm.
Xem lời giải chi tiết
Công thức đầy đủ. Khai triển ở Mục 6.2 không giả định gì về :
Mã trong code/numpy_experiments.py bỏ (3) và (4) vì trọng số đối xứng. Muốn dùng trọng số bất đối xứng thì phải cộng lại đúng hai số hạng đó.
Cài đặt. Với hình và hình :
# Trọng số bất đối xứng: dùng hết miền int8 [-128, 127], Z_w tính như activation
S_w, Z_w = affine_params(W.min(), W.max(), -128, 127)
q_w = np.clip(np.round(W / S_w) + Z_w, -128, 127).astype(np.int32)
row_w = q_w.sum(axis=1) # [N] - TÍNH TRƯỚC được (chỉ phụ thuộc W)
row_x = q_x.sum(axis=1, keepdims=True) # [M,1] - phải tính LÚC CHẠY
acc = (q_x @ q_w.T # (1) M*N*K phép nhân-cộng int8 -> int32
- Z_x * row_w # (2) tính trước, gộp thẳng vào q_b
- Z_w * row_x # (3) phải tính lúc chạy
+ K * Z_w * Z_x # (4) hằng số, gộp vào q_b
+ q_b)
M0, shift = quantize_multiplier(S_w * S_x / S_y)
q_y = np.clip(Z_y + rounding_right_shift(acc.astype(np.int64) * M0, 31 + shift), 0, 255)
Mẹo gộp. Cả (2) và (4) chỉ phụ thuộc trọng số và các hằng , nên gộp hẳn vào bias lúc chuyển đổi mô hình:
Sau khi gộp, lúc chạy chỉ còn dư đúng số hạng (3).
Đếm phép toán phát sinh thêm (so với trọng số đối xứng):
| Việc | Chi phí | Khi nào |
|---|---|---|
| (2) | phép cộng + phép nhân | offline, 0 lúc chạy |
| (4) | 2 phép nhân | offline, 0 lúc chạy |
| (3) | cộng + nhân + cộng để phát tán | lúc chạy |
So với phép nhân-cộng của số hạng (1), phần dư lúc chạy chiếm tỉ lệ
Với lớp trong thí nghiệm (, ): .
Nhưng vì sao thực tế vẫn tránh? Ba lý do mà phép đếm FLOP không thấy:
- Số hạng (3) buộc phải quét thêm một lượt toàn bộ ma trận để lấy tổng theo hàng. Với bài toán memory-bound (Mục 1.3), thêm một lượt đọc đắt hơn nhiều so với 2% phép tính.
- Tổng theo hàng không gộp được vào vòng lặp GEMM đã tối ưu sẵn của thư viện, nên thường thành một kernel riêng.
- Trọng số vốn phân bố khá đối xứng quanh 0 (Mục 3.5), nên gần như không đổi lại được độ chính xác nào. Trả chi phí mà không nhận được gì.
Kiểm chứng. Sau khi sửa, max |q_y(integer) - q_y(float sim)| vẫn phải bằng 0 LSB. Nếu khác 0, gần như chắc chắn bạn quên một trong bốn số hạng, hoặc dùng miền clamp không khớp giữa đường số nguyên và đường mô phỏng số thực (nhớ rằng bất đối xứng dùng hết , còn đối xứng chỉ dùng ).
Một nguồn lệch nữa, không liên quan tới bốn số hạng: quy ước làm tròn của bước dịch phải. Hàm rounding_right_shift trong bài làm tròn nửa về phía , còn gemmlowp/TFLite làm tròn nửa ra xa số 0; hai quy ước chỉ khác nhau ở đúng điểm giữa của giá trị âm và lệch tối đa 1 LSB. Nếu bạn so kết quả với một backend thật mà thấy vài phần tử lệch đúng 1 LSB thì hãy nghi chỗ này trước — xem ghi chú Mục 6.4.
Chạy lại thí nghiệm Mục 8.2 nhưng thêm vào tập calibration 1% ảnh nhiễu có biên độ lớn gấp 10 lần bình thường. Phương pháp nào bị ảnh hưởng nhiều nhất? Vì sao?
Xem lời giải chi tiết
Chưa chạy
Bài này cần chạy lại thí nghiệm trong
code/torch_experiments.py, tức cần PyTorch và scikit-learn. Phần dưới vì thế là phân tích dự đoán kèm cách kiểm chứng, không phải số liệu đã đo — khác với Bài 8 là bài duy nhất trong nhóm này đã chạy thật. Hãy chạy rồi đối chiếu với dự đoán.
Thay đổi cần làm. Trong phần calibration của code/torch_experiments.py, làm bẩn tập calibration:
idx = torch.randperm(len(X_calib))[: max(1, len(X_calib) // 100)] # 1% số ảnh
X_calib_bad = X_calib.clone()
X_calib_bad[idx] *= 10.0
rồi chạy lại cả bốn phương pháp và ghi lại ngưỡng chọn được lẫn độ chính xác W4A4.
Dự đoán, xếp theo mức thiệt hại.
-
Min–max — hỏng nặng nhất. Theo định nghĩa nó lấy giá trị lớn nhất trên toàn tập, nên nhảy lên khoảng 10 lần. Bước nhảy thô lên 10 lần, sai số làm tròn tăng 10 lần, công suất nhiễu tăng 100 lần (Mục 4.1) — tương đương mất hơn 3 bit theo quy tắc 6 dB. Ở 4 bit chỉ còn 16 mức, mà 99% dữ liệu giờ nằm gọn trong khoảng 1/10 dưới của dải, tức thực chất chỉ còn 1–2 mức hữu ích. Đây là minh hoạ sống động cho Hình 7: min–max không hề "an toàn".
-
Percentile 99,99% — hỏng nhiều hơn người ta tưởng. Phân vị chỉ an toàn khi ta cắt nhiều hơn tỉ lệ dữ liệu bị nhiễm. Ở đây 1% số ảnh bị nhiễm, và mỗi ảnh bẩn sinh ra rất nhiều activation lớn, nên tỉ lệ giá trị nằm trong vùng outlier vượt xa 0,01%. Phân vị 99,99% vì thế vẫn rơi vào vùng nhiễu. Bài học: con số phân vị phải chọn theo tỉ lệ nhiễm, mà tỉ lệ đó thì ta không biết trước.
-
MSE — chịu được một phần. Nó tối thiểu , mà cắt 1% giá trị ở biên độ tốn khoảng sai số bình phương — không nhỏ, nên ngưỡng tối ưu có dịch lên. Nhưng đây là một phép đánh đổi trơn chứ không phải yêu cầu cứng "phải phủ hết", nên mức xấu đi nhẹ hơn hẳn min–max.
-
KL (entropy) — bền nhất. Nó so sánh hình dạng phân phối. 1% khối lượng nằm ở đuôi xa đóng góp rất ít vào , trong khi làm thô vùng trung tâm dày dữ liệu thì phạt rất nặng. Ngưỡng chọn được sẽ gần với ngưỡng trên tập sạch.
Thứ tự dự đoán: min–max percentile 99,99% MSE KL (thiệt hại giảm dần).
Hai cảnh báo khi đọc kết quả.
- Mô hình thí nghiệm rất nhỏ và bài toán rất dễ. Mục 8.2 cho thấy ngưỡng chênh nhau 1,7 lần mà độ chính xác gần như không đổi. Rất có thể độ chính xác không phản ánh rõ thứ tự trên; hãy nhìn cả ngưỡng chọn được và MSE của từng lớp, đó mới là đại lượng nhạy.
- Mỗi ảnh test đổi kết quả 0,19 điểm phần trăm. Đừng kết luận từ chênh lệch dưới 1 điểm nếu chỉ chạy một lần.
Bài học thực hành (Mục 8.3). Lỗi thật sự trong bài này không nằm ở phương pháp calibration mà ở tập calibration. Một tập bị nhiễm bẩn phá min–max từ rất lâu trước khi nó phá mô hình. Trong quy trình sản xuất, luôn kiểm tra histogram activation trước khi tin vào ngưỡng.
Thay dải activation cố định bằng EMA trong qat() của code/torch_experiments.py bằng scale học được theo LSQ (Mục 9.2). So sánh độ chính xác ở W3A3 và W2A2.
Xem lời giải chi tiết
Chưa chạy
Bài này cần huấn luyện lại bằng PyTorch. Phần dưới là hướng dẫn cài đặt đầy đủ cùng dự đoán, không phải số liệu đã đo. Khi báo cáo kết quả, hãy ghi rõ đâu là số đo được và đâu là kỳ vọng.
Ý tưởng LSQ (Mục 9.2). Thay vì để bám theo EMA của min/max, ta coi là tham số huấn luyện được và cho nó nhận gradient từ chính hàm mất mát. Với lượng tử đối xứng
đạo hàm theo (vẫn dùng STE cho phép làm tròn) là
Cài đặt trong PyTorch. Hai hàm STE nhỏ là đủ, không cần viết autograd.Function:
def round_ste(x): # lượt xuôi: làm tròn; lượt ngược: đồng nhất
return (x.round() - x).detach() + x
def grad_scale(x, g): # lượt xuôi: x; lượt ngược: nhân gradient với g
return (x - x * g).detach() + x * g
class LsqQuant(nn.Module):
def __init__(self, n_bits, signed):
super().__init__()
self.Qn = 2 ** (n_bits - 1) if signed else 0
self.Qp = 2 ** (n_bits - 1) - 1 if signed else 2 ** n_bits - 1
self.S = nn.Parameter(torch.tensor(1.0))
self.inited = False
def forward(self, x):
if not self.inited: # khởi tạo theo bài báo LSQ
self.S.data = 2 * x.detach().abs().mean() / (self.Qp ** 0.5)
self.inited = True
g = 1.0 / (x.numel() * self.Qp) ** 0.5 # hệ số chỉnh gradient
s = grad_scale(self.S, g)
return torch.clamp(round_ste(x / s), -self.Qn, self.Qp) * s
Ba chi tiết quyết định thành bại.
- Hệ số chỉnh gradient , với là số phần tử dùng chung scale đó. Không có nó, gradient của lớn hơn gradient của từng trọng số khoảng lần và quá trình huấn luyện dao động rồi phân kỳ. Đây là phần hay bị bỏ sót nhất khi cài lại LSQ.
- Khởi tạo. Dùng trên batch đầu, hoặc khởi tạo từ ngưỡng PTQ–MSE như
qat()đang làm. Khởi tạo tệ làm LSQ mất vài epoch chỉ để bò về vùng hợp lý. - QAT là fine-tune. Giữ nguyên bài học ở Mục 10.1.3: xuất phát từ mô hình FP32 đã hội tụ, learning rate nhỏ (), vài epoch. Có thể đặt learning rate riêng, lớn hơn một chút, cho các tham số .
Dự đoán kết quả. Mốc so sánh là QAT dùng EMA ở Mục 9.3: 94,01% tại W3A3 và 49,44% tại W2A2 (FP32: 99,26%).
- W3A3: kỳ vọng cải thiện ít. Ở 3 bit, EMA đã cho ngưỡng gần đủ tốt và mạng còn dư sức bù bằng trọng số.
- W2A2: kỳ vọng cải thiện rõ nhất. Chỉ còn 4 mức, nên vị trí đặt mức quyết định gần như tất cả, và đó đúng là thứ LSQ tối ưu trực tiếp.
Cách báo cáo cho trung thực. Chạy 3 hạt giống như Mục 9.3 và báo cáo trung bình kèm khoảng dao động. Ở W2A2 độ chính xác dao động rất mạnh giữa các lần chạy; một con số đơn lẻ không nói lên điều gì. Nhớ rằng 1 ảnh test = 0,19 điểm phần trăm.
Hướng mở rộng. LSQ+ bổ sung thêm offset học được (tức cũng học), hợp với activation bất đối xứng — đáng thử ở W2A2, nơi mọi mức đều quý.
Trong mô phỏng ở Mục 11.3, quét từ 0 đến 1 với bước 0,1 và vẽ sai số đầu ra theo . Giải thích vì sao cả hai đầu và đều không tốt.
Xem lời giải chi tiết
Bài này đã được chạy thật
Script
code/sweep_alpha.pydựng lại đúng thí nghiệm ở Mục 11.3 — cùng hạt giống 42 và cùng thứ tự rút số ngẫu nhiên — nên nó tái lập chính xác hai mốc đã in trong giáo trình: per-tensor 7,7435% và SmoothQuant cho 1,4430%. Mọi con số dưới đây lấy từ lần chạy đó, không phải dự đoán.
Cách quét.
ax = np.abs(Xl).max(axis=0) # biên độ activation theo kênh đầu vào
aw = np.abs(Wl).max(axis=0) # biên độ trọng số theo kênh đầu vào
for alpha in np.arange(0.0, 1.001, 0.05):
s = ax ** alpha / aw ** (1 - alpha)
Xs, Ws = Xl / s, Wl * s
assert np.allclose(Xs @ Ws.T, Yl) # biến đổi tương đương
err = rel(a8_per_tensor(Xs) @ w8_per_channel(Ws).T, Yl)
Chạy bằng python code/sweep_alpha.py; có matplotlib thì script vẽ luôn đường cong.
Kết quả đo được.
| Sai số đầu ra | Chênh lệch giữa các kênh | Chênh lệch giữa các cột | |
|---|---|---|---|
| 0,00 | 8,02% | 91,3× | 1,0× |
| 0,20 | 3,60% | 37,0× | 2,5× |
| 0,30 | 2,48% | 23,6× | 3,9× |
| 0,40 | 1,81% | 15,0× | 6,1× |
| 0,50 | 1,44% | 9,6× | 9,6× |
| 0,60 | 1,34% | 6,1× | 15,0× |
| 0,70 | 1,46% | 3,9× | 23,6× |
| 0,80 | 1,84% | 2,5× | 37,0× |
| 1,00 | 3,64% | 1,0× | 91,3× |
Đường cong đúng là chữ U như trực giác dự đoán. Nhưng hai chi tiết chỉ lộ ra khi đo thật.
Chi tiết 1 — không phải "không làm gì"#
Không biến đổi gì cả: 7,74%. Đặt : 8,02%. Tức là tệ hơn một chút.
Khi thì , nên mỗi kênh activation bị nhân với . Đây là một phép nhân thật, nên:
- Nó không xoá được cấu trúc outlier, vì chẳng liên quan gì tới việc kênh có phải outlier hay không. Sáu kênh lớn vẫn lớn.
- Nhưng nó vẫn làm đổi tỉ lệ giữa các kênh: tỉ lệ giữa kênh và kênh bị nhân thêm . Ở lần chạy này chênh nhau 1,94 lần giữa các cột, và kết quả là chênh lệch giữa các kênh activation tăng từ 77,4× lên 91,3×.
Nhận xét
Đây là chỗ dễ nói sai nhất của bài. "Hệ số không tương quan với outlier" không đồng nghĩa với "tỉ lệ giữa các kênh không đổi". Không tương quan chỉ có nghĩa là phép nhân ấy không giúp gì; nó vẫn là một phép nhân và vẫn làm xáo trộn tỉ lệ, theo hướng nào thì tuỳ dữ liệu.
Chạy thêm hai hạt giống khác cho 8,48% và 8,43%, so với 8,05% và 8,52% khi không biến đổi. Kết luận bền vững là: gần như không mang lại gì, chứ không phải lúc nào cũng có hại. Toàn bộ độ khó vẫn nằm ở activation, nơi ta chỉ có một scale duy nhất cho cả tensor.
Chi tiết 2 — cực tiểu nằm ở , không phải 0,50#
Ở , hai vế có biên độ bằng nhau đúng như lý thuyết: sau biến đổi cả hai đều bằng
tức trung bình nhân — bảng trên cho thấy cả hai dòng đều là 9,6×. Đây đúng là ý tưởng cân bằng giữa các lớp (CLE) ở Mục 9.5, áp dụng giữa activation và trọng số.
Nhưng biên độ bằng nhau không có nghĩa sai số bằng nhau. Tách riêng từng nguồn sai số:
| chỉ lượng tử | chỉ lượng tử | cả hai | |
|---|---|---|---|
| 0,0 | 0,66% | 7,99% | 8,02% |
| 0,5 | 0,60% | 1,31% | 1,44% |
| 1,0 | 3,55% | 0,80% | 3,64% |
Ở , activation vẫn là vế đắt gấp hơn hai lần trọng số. Vì vậy đẩy thêm chút độ khó sang phía trọng số còn có lợi, và cực tiểu dịch sang 0,60.
Vì sao trọng số chịu đựng giỏi hơn với cùng mức chênh lệch? Vì granularity của hai vế khác nhau (Chương 5):
- Trọng số được lượng tử per-kênh-đầu-ra: 512 scale, mỗi hàng một cái.
- Activation được lượng tử per-tensor: đúng một scale cho toàn bộ tensor.
Cùng một mức chênh lệch 9,6×, vế có 512 scale hấp thụ rẻ hơn hẳn vế chỉ có 1 scale. Quy tắc mang đi được: cực tiểu luôn lệch về phía tensor có granularity mịn hơn. Nếu đổi activation sang per-token (nhiều scale hơn), cực tiểu sẽ dịch ngược lại gần 0,5.
Cực tiểu ở 0,60 lặp lại y hệt trên cả ba hạt giống đã thử, nên đây không phải chuyện may rủi của một lần rút số.
Vì sao cũng không tốt#
Khi đó :
- : mọi kênh activation về biên độ 1 — activation phẳng lý tưởng, sai số chỉ còn 0,80%.
- : cột của trọng số bị nhân lên , nên toàn bộ độ chênh 91,3× chuyển sang trọng số. Chỗ chết người: độ chênh vừa tạo ra nằm dọc theo cột (kênh đầu vào), trong khi scale của trọng số lại theo hàng (kênh đầu ra). Vậy là độ chênh nằm gọn bên trong phạm vi của một scale, đúng chỗ mà per-channel không với tới. Sai số do trọng số nhảy từ 0,60% lên 3,55%.
Những gì mang đi được#
- Hình dạng chữ U và lý do tạo ra nó: hai vế cùng chia nhau một lượng độ khó cố định, ép hết về một phía đều đắt.
- Đáy khá phẳng: mọi đều dưới 1,5%. Chọn 0,5 như bài báo chỉ đắt hơn cực tiểu 8%, không đáng để dò kỹ.
- Cực tiểu lệch về phía có granularity mịn hơn — đây mới là quy tắc dùng lại được cho mô hình thật.
- Số tuyệt đối thì không mang đi được. Dữ liệu ở đây là tổng hợp, chỉ 6 kênh outlier, biên độ gấp đúng 60 lần. Bài báo SmoothQuant lấy làm mặc định và ghi nhận mô hình có outlier nặng hơn cần giá trị lớn hơn; muốn biết con số cho mô hình của mình thì phải quét trên chính mô hình đó.
Một mô hình 13 tỉ tham số, 40 lớp, 40 head, , không dùng GQA. Tính dung lượng trọng số ở dạng GGUF Q4_K và dung lượng KV cache FP16 cho batch 4, ngữ cảnh 8192 token. Thành phần nào lớn hơn?
Xem lời giải chi tiết
Dữ kiện. 13 tỉ tham số; ; ; ; không dùng GQA nên . Batch 4, ngữ cảnh 8192 token.
a) Dung lượng trọng số ở GGUF Q4_K#
Bảng ở Mục 11.8 cho số bit thực tế mỗi trọng số của Q4_K:
Đối chiếu thực tế: một file Q4_K_M 13B thật thường lớn hơn con số này một chút (quãng 7,5–8 GiB), vì llama.cpp giữ embedding và lớp đầu ra ở độ chính xác cao hơn và trộn Q6_K cho một số tensor — đúng như cảnh báo cuối Mục 11.8.
b) Dung lượng KV cache FP16#
Áp công thức ở Mục 11.7:
Tính theo từng bậc cho dễ kiểm tra:
| Đại lượng | Phép tính | Kết quả |
|---|---|---|
| Mỗi token, mỗi chuỗi | B MiB | |
| Một chuỗi 8192 token | GB GiB | |
| Batch 4 | GiB |
c) Thành phần nào lớn hơn#
KV cache lớn hơn trọng số 3,67 lần (26,84 GB so với 7,31 GB). Tổng cộng khoảng 34 GB, tức không vừa một GPU 24 GB, dù "mô hình 4 bit" nghe như chỉ tốn 7 GB.
Điều cần rút ra. Với ngữ cảnh dài và batch không nhỏ, lượng tử trọng số thôi là chưa đủ. Các hướng xử lý, theo đúng Mục 11.7:
| Cách | Hiệu quả trong ví dụ này |
|---|---|
| Lượng tử KV cache sang FP8/INT8 | 26,84 GB → 13,4 GB |
| Lượng tử KV cache sang INT4 (kiểu KIVI) | 26,84 GB → 6,7 GB |
| Dùng GQA với 8 KV head | 26,84 GB → 5,4 GB (giảm 5 lần) |
| Giảm batch xuống 1 | 26,84 GB → 6,7 GB |
Chính phép tính này giải thích vì sao GQA (và trước đó là MQA) trở thành lựa chọn phổ biến ở các mô hình mở cỡ lớn ra đời từ 2023 trở đi: nó cắt KV cache ngay từ kiến trúc, tức trước khi phải nhờ tới lượng tử hoá. Nhưng đây là một xu hướng, không phải quy luật và cũng không phải cách duy nhất — vẫn có mô hình giữ MHA đầy đủ, và các hướng khác như lượng tử KV cache, nén KV cache hay attention thưa đều nhắm vào cùng nút thắt. Khi đọc một mô hình cụ thể, hãy tra trong file cấu hình của nó thay vì suy đoán từ năm phát hành.
Luyện tính tay#
Mỗi lần bấm Câu khác sẽ sinh một đề mới với số liệu ngẫu nhiên. Mọi phép làm tròn đều theo quy tắc nửa về số chẵn giống NumPy.
Trắc nghiệm theo chương#
48 câu bám sát nội dung và số liệu của từng chương. Đáp án được xáo lại mỗi lần tải trang.
Chương 1 — Vì sao cần quantization
3 câu
1Theo Hình 1, đọc 64 bit từ DRAM tốn năng lượng gấp khoảng bao nhiêu lần một phép nhân FP32?
Vì sao: Đọc 64 bit từ DRAM tốn 1300–2600 pJ, còn một phép nhân FP32 tốn 3,7 pJ — chênh nhau hàng trăm lần. Đây là lý do giảm số byte phải đọc thường có lợi hơn việc làm phép tính rẻ đi.2Sinh từng token của một LLM với batch nhỏ là bài toán thuộc loại nào?
Vì sao: Mỗi trọng số được đọc một lần nhưng chỉ tham gia rất ít phép tính, nên cường độ số học rất thấp. Vì vậy chỉ cần nén trọng số (weight-only) là đã nhanh hơn gần tỉ lệ với mức nén, kể cả khi phép nhân vẫn làm bằng FP16.3"Lượng tử hoá luôn làm mô hình chạy nhanh hơn." Phát biểu này:
Vì sao: Mục 10.1.5 đo đượcInt8WeightOnlychạy eager chậm hơn FP32 hơn 2 lần, vì không cótorch.compilethì trọng số bị giải lượng tử về số thực ở mỗi lần gọi. Không có kernel phù hợp thì mô hình chỉ nhẹ hơn chứ không nhanh hơn.
Đã trả lời 0/3
Chương 2 — Máy tính biểu diễn số
3 câu
1Phát biểu nào đúng về cách FP32 lưu một số?
Vì sao: Không hề có "bit dành cho phần nguyên". Giá trị là , nên dấu chấm trôi theo số mũ — đó chính là nghĩa của "dấu phẩy động".2Vì sao BF16 được ưa dùng để huấn luyện hơn FP16?
Vì sao: Cả hai đều 16 bit. FP16 chỉ có 5 bit exponent, giá trị lớn nhất 65 504 nên rất dễ tràn. BF16 đánh đổi mantissa ngắn hơn (7 bit) để lấy dải rộng bằng FP32.3Khác biệt cốt lõi giữa lưới của số dấu phẩy động và của số nguyên là gì?
Vì sao: Chính vì số nguyên có lưới cách đều mà ta phải chọn khéo vị trí và độ rộng của lưới cho khớp dữ liệu — đó là vai trò của và .
Đã trả lời 0/3
Chương 3 — Uniform affine quantization
4 câu
1Công thức đúng của bước nhảy là gì?
Vì sao: là bước nhảy giữa hai mức liền kề tính bằng đơn vị số thực, nên nó phải là (độ rộng dải thực) chia cho (số khoảng trong miền số nguyên).2Vì sao bắt buộc phải là một số nguyên?
Vì sao: Số 0 xuất hiện dày đặc: zero-padding, đầu ra ReLU, phần tử bị mask trong attention. Nếu 0 lệch thành thì sai số có hệ thống và cộng dồn qua hàng nghìn phần tử. Ép nguyên bảo đảm .3Quy ước phổ biến trong TFLite và PyTorch là gì?
Vì sao: Trọng số phân bố quanh 0 nên đối xứng là hợp (và rẻ hơn khi tính, Mục 6.2). Activation sau ReLU lệch hẳn một phía nên bất đối xứng mới không phí mức.4Dùng lưới đối xứng cho activation sau ReLU ở 3 bit thì lãng phí bao nhiêu mức?
Vì sao: Hình 5 vẽ đúng hiện tượng này. Đo trên 200 000 giá trị sau ReLU, MSE của đối xứng so với bất đối xứng là so với ở 4 bit — gấp 4,6 lần.
Đã trả lời 0/4
Chương 4 — Phân tích sai số
3 câu
1Thêm một bit vào độ rộng bit thì SQNR thay đổi thế nào?
Vì sao: Thêm một bit làm giảm một nửa, công suất nhiễu giảm 4 lần, tức dB.2Theo mô hình nhiễu đều, kỳ vọng bình phương sai số làm tròn bằng bao nhiêu?
Vì sao: Sai số phân phối đều trên , nên . Điểm mấu chốt: sai số tỉ lệ với bình phương bước nhảy.3Trên trọng số Laplace lượng tử INT4 đối xứng, dùng dải min–max thay vì ngưỡng tối ưu làm MSE tăng bao nhiêu lần?
Vì sao: Hình 7: ngưỡng tối ưu cho MSE ; min–max () cho MSE . "Không cắt giá trị nào" nghe an toàn nhưng phải trả bằng bước nhảy thô cho toàn bộ dữ liệu.
Đã trả lời 0/3
Chương 5 — Độ mịn (granularity)
4 câu
1Vì sao scale per-channel của trọng số phải đặt theo trục đầu ra?
Vì sao: Với , scale phụ thuộc thì rút ra ngoài được; phụ thuộc thì nằm kẹt bên trong và phải nhân số thực cho từng phần tử.2Phát biểu nào chặt nhất về việc lượng tử activation theo kênh?
Vì sao: Ràng buộc nằm ở trục, không ở tensor. Chấp nhận tách tổng thành từng nhóm (per-group) là đặt được scale trên trục thu gọn — đó là cách lượng tử 4 bit cho LLM vẫn chạy. Depthwise convolution thì kênh đầu vào không hề bị lấy tổng nên miễn phí luôn. Cái không làm được là có scale activation theo kênh miễn phí trong một GEMM chuẩn — và đúng ràng buộc đó sinh ra bài toán outlier của LLM (Mục 11.1).3Vì sao lượng tử per-group (nhóm 32–128) đặt được scale trên trục thu gọn?
Vì sao: Cái giá phải trả là một phép nhân scale cho mỗi nhóm thay vì mỗi kênh đầu ra — chấp nhận được khi nhóm đủ lớn. Đây là lý do "mô hình 4 bit" của LLM tốn 4,125–4,5 bit mỗi trọng số (Mục 11.8) chứ không phải đúng 4.4Trong thí nghiệm Mục 5.3, granularity ảnh hưởng ra sao ở 8 bit so với 4 bit?
Vì sao: Ở 8 bit bước nhảy đã đủ mịn nên chênh lệch dải giữa các kênh không kịp gây hại. Ở 4 bit (91,30% so với 97,59%) thì có.
Đã trả lời 0/4
Chương 6 — Suy luận bằng số nguyên
7 câu
1Vì sao bộ cộng dồn trong phép nhân ma trận INT8 phải là int32?
Vì sao: Giới hạn int16 là 32 767. Cận trên lý thuyết vẫn nằm rất xa giới hạn int32 (), nên int32 là lựa chọn vừa đủ.2Trọng số lượng tử đối xứng () mang lại lợi ích tính toán gì?
Vì sao: Số hạng phụ thuộc đầu vào nên phải tính lúc chạy; bỏ được nó là bỏ hẳn một lượt quét toàn bộ ma trận activation. Đây là lý do kỹ thuật khiến trọng số gần như luôn đối xứng.3Bias được lưu như thế nào trong đường tính toán số nguyên?
Vì sao: Nhờ cùng scale với bộ cộng dồn, bias cộng thẳng vào kết quả int32 mà không cần đổi đơn vị. Nó dùng 32 bit vì rất ít tham số nhưng ảnh hưởng trực tiếp lên mọi đầu ra.4Requantization nhân với hệ số thực bằng cách nào mà không dùng số thực?
Vì sao: và . Toàn bộ đường đi vẫn thuần số nguyên nên chạy được trên NPU/DSP không có FPU.5Đoạn mã
quantize_multiplierở Mục 6.4 dùng được cho hệ số nào?Vì sao: Vòng lặpwhile M < 0.5chỉ chuẩn hoá lên. Cài đặt thật (QuantizeMultipliercủa gemmlowp/TFLite) cho phépshiftâm, tức một phép dịch trái, nên mọi đều xử lý được; chỉ cần thêmwhile M >= 1: M /= 2; shift -= 1. Xem ghi chú Mục 6.4.6Hàm dịch phải có làm tròn trong Mục 6.4 xử lý giá trị âm theo quy ước nào?
Vì sao:>>của Python là dịch số học, tương đương lấy sàn, nên làm tròn nửa lên. Hai quy ước chỉ khác nhau ở đúng điểm giữa của giá trị âm: cho ở đây nhưng ở gemmlowp. Lệch tối đa 1 LSB — vô hại với lập luận của chương, nhưng phải khớp nếu bạn cần kết quả trùng từng bit với một backend cụ thể.7Kết quả "trùng khớp từng bit" ở Mục 6.5 chứng minh điều gì?
Vì sao: Đây là phép tự đối chiếu giữa đường số nguyên và bản mô phỏng số thực của cùng một tác giả, không phải phép đối chiếu với TFLite hay ONNX Runtime. Mức khớp với backend thật còn phụ thuộc quy ước làm tròn, độ chính xác bước requantization, cách gộp lớp và op nào rơi về FP32 — nên mục 8 của danh sách kiểm tra (Mục 12.3) vẫn bắt buộc đo trên thiết bị đích.
Đã trả lời 0/7
Chương 7 — Lượng tử cả một mạng
3 câu
1BatchNorm phải được gộp vào tích chập ở thời điểm nào?
Vì sao: Trên phần cứng BN không tồn tại như một lớp riêng. Nếu lượng tử rồi mới gộp thì trọng số thật sự chạy trên thiết bị là với dải khác hẳn. Đây cũng là nguyên nhân làm dải giữa các kênh chênh lệch, vì khác nhau theo kênh.2Nút fake-quant làm gì?
Vì sao: Đó là hàm hợp quantize → dequantize. Mục 6.5 đã kiểm chứng cách mô phỏng này trùng khớp từng bit với phép tính số nguyên thật, nên nó dự đoán đúng độ chính xác khi triển khai.3Chế độ nào không cần dữ liệu calibration?
Vì sao: Dynamic lượng tử activation lúc chạy với dải tính từ chính tensor đó. Đổi lại phải tốn chi phí tìm min/max mỗi lần chạy, và không chạy thuần số nguyên được như Hình 9.
Đã trả lời 0/3
Chương 8 — Calibration
3 câu
1Tập calibration thường cần bao nhiêu mẫu?
Vì sao: Nhiều hơn 1000 ít khi cải thiện thêm. Quan trọng hơn số lượng là tính đại diện: đủ các lớp, đủ điều kiện, và tiền xử lý giống hệt lúc suy luận, với mô hình ở chế độeval().2Phương pháp calibration nào nhạy nhất với outlier?
Vì sao: Min–max lấy đúng giá trị lớn nhất nên chỉ một mẫu lạc cũng kéo dải ra rất rộng, làm bước nhảy thô cho toàn bộ phần còn lại.3Ý tưởng của calibration theo KL divergence (entropy) là gì?
Vì sao: Với mỗi ngưỡng ứng viên, ta lập histogram tham chiếu (dồn phần bị cắt vào bin cuối), gộp bin để được , rồi chọn ngưỡng cho nhỏ nhất.
Đã trả lời 0/3
Chương 9 — PTQ và QAT
4 câu
1Straight-Through Estimator giải quyết vấn đề gì?
Vì sao: Nếu dùng đạo hàm thật, gradient về trọng số luôn bằng 0 và mạng không học được gì. STE đặt bên trong dải và 0 ở vùng bị cắt. Trong PyTorch viết gọn:x + (fake_quant(x) - x).detach().2Ở W8A8, QAT so với PTQ như thế nào trong thí nghiệm Mục 9.3?
Vì sao: PTQ 99,26% so với QAT 99,07%; chênh 0,19 điểm đúng bằng 1 ảnh trên 540 ảnh test. Quy tắc: luôn bắt đầu bằng PTQ.3Ở W3A3, kết quả ra sao?
Vì sao: Đây chính là khoảng mà QAT thực sự đáng giá. Ở 2 bit thì ngay cả QAT cũng chỉ còn 49,44%, cần thêm kỹ thuật chuyên biệt như học scale kiểu LSQ hoặc chưng cất tri thức.4Cross-Layer Equalization dựa trên tính chất nào của ReLU?
Vì sao: Nhờ tính thuần nhất dương đó, ta chia kênh của lớp trước cho và nhân kênh đầu vào của lớp sau với mà đầu ra không đổi. Chọn khéo thì dải giữa các kênh đồng đều hơn. SmoothQuant dùng đúng ý tưởng này.
Đã trả lời 0/4
Chương 10 — PyTorch và TFLite
3 câu
1
torch.ao.quantization.quantize_dynamiclượng tử những lớp nào?Vì sao: Mô hình tích chập cần static quantization. Đây là một trong những hiểu lầm được liệt kê ở Chương 13.2Vì sao
Int8WeightOnlycủa torchao chạy eager lại chậm hơn FP32?Vì sao: Tốn thêm công giải lượng tử mà không bớt được phép tính nào. torchao được thiết kế để dùng cùngtorch.compile.3Một báo cáo lượng tử hoá đáng tin cần đủ những con số nào?
Vì sao: Đostate_dicttrong bộ nhớ thay vì file triển khai, hay đo độ trễ trên máy dev thay vì thiết bị đích, là hai cách phổ biến nhất để tự lừa mình.
Đã trả lời 0/3
Chương 11 — Quantization cho LLM
7 câu
1Vì sao weight-only 4 bit (W4A16) tăng tốc LLM gần tỉ lệ với mức nén?
Vì sao: Phép nhân vẫn làm ở FP16. Cái được cải thiện là số byte phải kéo từ bộ nhớ về — đúng nhận định ở Mục 1.3.2SmoothQuant làm gì?
Vì sao: . Phép chia cho được gộp sẵn vào LayerNorm đứng trước nên lúc chạy không tốn thêm gì. Phương án "tách outlier sang FP16" là LLM.int8().3Kết quả nào của GPTQ nghe nghịch lý nhưng rất đáng suy ngẫm?
Vì sao: Các sai số được sắp xếp để triệt tiêu nhau theo những hướng mà dữ liệu thực sự đi qua. Cái cần bảo toàn là hàm số mà lớp biểu diễn, không phải từng con số trong ma trận.4NF4 đặt 16 mức lượng tử ở đâu?
Vì sao: Trọng số đã huấn luyện xấp xỉ phân phối chuẩn; sau khi chia mỗi khối 64 phần tử cho absmax, chúng nằm trong . Tập mức bất đối xứng (8 dương, 7 âm) để vừa có số 0 chính xác vừa dùng hết 16 mã. NF4 là định dạng lưu trữ, không phải định dạng tính toán.5"Mô hình 4 bit" trong thực tế tốn bao nhiêu bit mỗi trọng số?
Vì sao: Ví dụ: INT4 nhóm 128 scale FP16 là ; GGUFQ4_0vàQ4_Kđều là 4,5. Cộng thêm một số lớp (embedding, lớp đầu ra) thường giữ ở độ chính xác cao hơn.6Quét của SmoothQuant trên chính dữ liệu của Mục 11.3 cho kết quả nào?
Vì sao: Đo thật bằngcode/sweep_alpha.py: 8,02% tại , 1,44% tại 0,5, 1,34% tại 0,60, 3,64% tại 1,0. Biến đổi tuy tương đương về mặt toán học nhưng nó đổi dữ liệu đem đi lượng tử, nên sai số đổi theo. Xem Bài 8.7Vì sao cực tiểu lại lệch sang phải điểm cân bằng ?
Vì sao: Đo riêng từng nguồn ở : sai số do trọng số 0,60%, do activation 1,31%. Vế có 512 scale hấp thụ cùng một mức chênh lệch rẻ hơn hẳn vế chỉ có 1 scale, nên đẩy thêm độ khó sang trọng số vẫn còn lợi. Quy tắc mang đi được: cực tiểu lệch về phía tensor có granularity mịn hơn.
Đã trả lời 0/7
Chương 12 — Quy trình làm việc
2 câu
1Trong phân tích độ nhạy ở Mục 12.2, lớp nào nhạy nhất?
Vì sao: Kết quả này trái với quy tắc kinh nghiệm "lớp đầu và lớp cuối nhạy nhất": ở đây lớp cuối không nhạy chút nào (giảm 0,00 điểm). Quy tắc kinh nghiệm chỉ là điểm xuất phát.2Cách đơn giản và hiệu quả nhất để tìm lớp gây mất độ chính xác là gì?
Vì sao: Cách này tốn lần đánh giá nhưng cho kết quả trực tiếp trên chính đại lượng ta quan tâm. Có kết quả rồi mới quyết định mixed precision hay QAT.
Đã trả lời 0/2
Chương 13 — Những hiểu lầm thường gặp
2 câu
1"INT8 luôn giảm kích thước file đúng 4 lần." Phát biểu này:
Vì sao: Tỉ lệ 4 lần đúng với phần trọng số. Mô hình càng lớn thì tỉ lệ thực tế càng tiến gần 4.2"Sai số trọng số càng nhỏ thì mô hình càng chính xác." Phát biểu này:
Vì sao: Đây là hiểu lầm nền tảng nhất trong danh sách, vì nó quyết định ta tối ưu hàm mục tiêu nào. AdaRound và GPTQ đều xuất phát từ việc sửa lại mục tiêu này.
Đã trả lời 0/2