Bạn muốn chạy Llama-3-70B trên laptop. Model gốc ở FP16 nặng 140GB. RAM laptop của bạn 32GB. Phép tính đơn giản: không xong.

Nhưng cộng đồng vẫn chạy được model 70B trên Mac M2 Max 64GB hoặc PC 64GB RAM. Cách họ làm: quantization. Nén weights từ 16-bit float xuống 4-bit integer, giảm dung lượng 4 lần, mất 1-2% chất lượng. Đáng đổi.

Bài này không dạy bạn cài Ollama. Bài này giải thích tại sao quantization hoạt động, chi phí thực sự là gì, và khi nào dùng format nào.

Dành cho: dev đã hiểu mental model LLM (bài 1) và muốn deploy model về local hoặc tối ưu chi phí GPU.

Mental model: weights là số, số nào cũng nén được

Một model LLM về bản chất là bộ sưu tập số thực. Llama-3-8B có 8 tỷ con số. Mỗi con số mặc định lưu dưới dạng FP16 (16-bit float), tốn 2 bytes. Tổng: 16GB.

Quantization là quá trình biểu diễn cùng những con số đó với ít bit hơn, chấp nhận sai số nhỏ.

FP16:  0.4523 -> 16 bits (2 bytes)
INT8:  46     -> 8 bits  (1 byte)    [scale 0.01, biểu diễn 0.46]
INT4:  3      -> 4 bits  (0.5 byte)  [scale 0.15, biểu diễn 0.45]

Trade-off: bit ít hơn -> sai số lớn hơn -> output có thể khác đi.

Câu hỏi quan trọng: sai số có ảnh hưởng đến chất lượng đầu ra của model không? Câu trả lời thực nghiệm: từ FP16 xuống INT8 hầu như không ảnh hưởng. Xuống INT4 mất 1-3% trên benchmark. Xuống INT2 hoặc thấp hơn thì model bắt đầu hỏng đáng kể, trừ khi dùng kỹ thuật đặc biệt như BitNet.

Phần 1: Tại sao quantization hoạt động

Có hai lý do chính.

Lý do 1: Weights phân phối quanh số 0. Hầu hết các weight trong một transformer layer đều rất nhỏ, gần 0. Không cần range của FP16 (từ -65504 đến +65504). Range thực tế chỉ cỡ -3 đến +3. Lãng phí.

Lý do 2: Neural network có redundancy. Nếu bạn làm tròn một weight từ 0.4523 xuống 0.45, output thay đổi rất nhỏ. Hàng tỷ weight cộng lại thì các sai số trung hòa nhau (theo central limit theorem). Đây là lý do nén được mà không hỏng.

Công thức quantization cơ bản:

# Float -> INT8
def quantize(weights, num_bits=8):
    scale = weights.abs().max() / (2**(num_bits-1) - 1)
    quantized = (weights / scale).round().clamp(-128, 127).to(torch.int8)
    return quantized, scale

# INT8 -> Float (lúc inference)
def dequantize(quantized, scale):
    return quantized.float() * scale

Mỗi nhóm weight (group, thường 32-128 weights) chia sẻ một scale. Lưu scale này dưới dạng FP16. Overhead nhỏ.

Phần 2: Per-tensor vs per-channel vs per-group

Câu hỏi: scale chung cho cả ma trận, cho mỗi channel, hay cho mỗi nhóm nhỏ?

GranularitySố scaleChất lượngTốc độ
Per-tensor1 cho cả layerTệ nhấtNhanh nhất
Per-channel1 cho mỗi hàng/cộtTrung bìnhTrung bình
Per-group1 cho mỗi 32-128 weightsTốt nhấtHơi chậm

Modern quantization (AWQ, GPTQ, GGUF Q4_K_M) đều dùng per-group với group size 32 hoặc 128. Đây là sweet spot giữa chất lượng và overhead.

Lý do: trong một layer, một số channel có outlier (giá trị cực lớn). Nếu dùng per-tensor scale, các weight bình thường bị “ép” vào range rất nhỏ vì scale phải đủ lớn cho outlier. Per-channel hoặc per-group cô lập outlier vào nhóm riêng, các nhóm khác giữ độ chính xác.

Phần 3: GPTQ vs AWQ vs GGUF

Đây là ba họ format phổ biến nhất 2024-2026.

GPTQ (2022): quantize từng layer một, sau mỗi lần lượng tử hóa, điều chỉnh các weight còn lại để bù lỗi. Cần calibration dataset (vài trăm sample text). Chất lượng tốt nhưng quá trình quantize chậm.

AWQ (2023): quan sát rằng không phải mọi weight đều quan trọng như nhau. Một số “salient weights” (1% top) ảnh hưởng output nhiều. AWQ giữ những weight này ở precision cao hơn, các weight còn lại lượng tử hóa mạnh. Kết quả: chất lượng tương đương GPTQ, quantize nhanh hơn, inference nhanh hơn trên GPU.

GGUF (2023, kế tục GGML): format file của llama.cpp. Không phải một thuật toán quantize mà là format chứa nhiều scheme (Q2_K, Q3_K_S, Q4_K_M, Q5_K_M, Q8_0). Tối ưu cho CPU inference và Apple Silicon. Q4_K_M là sweet spot phổ biến nhất.

So sánh ngắn:

FormatBackend chínhUse caseQuality (4-bit)
GPTQGPU (CUDA)Server inferenceTrung bình
AWQGPU (CUDA, vLLM)Server inference với throughput caoTốt
GGUFCPU + Apple Silicon + GPU partialLocal, laptop, M-series MacTốt nhất ở Q4_K_M
BNB NF4GPU (training time)QLoRA fine-tuningKhác biệt, dùng khi train

Quy tắc thực tế:

  • Có GPU NVIDIA, deploy server: AWQ
  • Mac M-series hoặc CPU only: GGUF Q4_K_M
  • Đang fine-tune (QLoRA): NF4 (bitsandbytes)

Phần 4: BitNet 1.58-bit, kỳ lạ và đầy hứa hẹn

Tháng 2/2024, Microsoft công bố BitNet b1.58: model mà mỗi weight chỉ là 1 trong 3 giá trị: -1, 0, +1. Không phải float, không phải integer thường, mà ternary.

Tại sao gọi 1.58-bit? Vì log2(3) ≈ 1.58.

Điểm hay: model training từ đầu với ternary weights cho kết quả tương đương model FP16 cùng kích thước. Không phải nén model FP16 xuống ternary (cách đó sẽ hỏng nặng) mà train với constraint ternary từ ban đầu.

Tại sao quan trọng:

  • Matrix multiplication với weights {-1, 0, +1} không cần phép nhân. Chỉ cần cộng/trừ/skip. Trên hardware tối ưu, nhanh hơn FP16 hàng chục lần.
  • Memory bandwidth giảm 10x. RAM bottleneck giảm tương ứng.
  • Có thể chạy model 70B trên CPU mà không cần GPU.

Microsoft đã release bitnet.cpp cuối 2024 (kế thừa từ llama.cpp). Hiện có vài model BitNet 3B-8B open source. Nếu hệ sinh thái này phát triển, AI inference sẽ thay đổi căn bản.

# Chạy BitNet 3B trên CPU (16GB RAM đủ)
git clone https://github.com/microsoft/BitNet
cd BitNet
python setup_env.py --hf-repo HF1BitLLM/Llama3-8B-1.58-100B-tokens -q i2_s
python run_inference.py -m models/Llama3-8B-1.58-100B-tokens/ggml-model-i2_s.gguf \
    -p "Viết function fibonacci" -n 256

Caveat: hệ sinh thái BitNet còn non. Số model có sẵn ít, công cụ fine-tune chưa hoàn chỉnh, benchmark chưa nhiều bằng quantization truyền thống. Theo dõi nhưng đừng all-in.

Phần 5: Pitfall thường gặp

Pitfall 1: Quantize xong test trên benchmark “không đại diện”.

Tôi từng quantize một model code-gen từ FP16 xuống Q4_K_M. Chạy MMLU thấy mất 1.5%, OK đẹp. Deploy lên prod, người dùng phản hồi: code Python sinh ra bắt đầu có lỗi import nhiều hơn. Run HumanEval thì mất tới 8%.

Lý do: MMLU đo kiến thức general, HumanEval đo code. Quantize mạnh có thể ảnh hưởng task-specific hơn task chung.

Fix: luôn test trên benchmark sát use case. Nếu dùng cho RAG thì test trên QA dataset. Nếu dùng cho code thì HumanEval / MBPP.

Pitfall 2: Confuse weight quantization với activation quantization.

Trong forward pass, có hai thứ là số: weights (cố định) và activations (output trung gian, thay đổi mỗi inference). Có thể quantize cả hai (W4A4) hoặc chỉ weights (W4A16 = weights INT4, activations FP16).

W4A16 phổ biến nhất, dễ làm, không mất chất lượng nhiều. W4A4 tiết kiệm memory bandwidth hơn nhưng cần kỹ thuật phức tạp, dễ hỏng.

GPTQ, AWQ, GGUF mặc định là weight-only. Khi đọc paper nói “4-bit quantization”, thường là W4A16.

Pitfall 3: Group size quá lớn hoặc quá nhỏ.

Group size 128 (default GPTQ): tốc độ tốt, chất lượng tạm. Group size 32: chất lượng tốt hơn rõ rệt, chậm hơn chút. Group size 1 (per-weight): không khác gì FP16, vô nghĩa. Group size = số weight trong layer (per-tensor): hỏng nặng với LLM lớn.

Group size 32-128 là sweet spot. Đừng tự ý đổi sang giá trị lạ.

Phần 6: Hands-on quantize một model

Cách nhanh nhất để hiểu là làm. Quantize Llama-3-8B từ FP16 xuống Q4_K_M bằng llama.cpp:

# Bước 1: clone llama.cpp
git clone https://github.com/ggerganov/llama.cpp
cd llama.cpp
make

# Bước 2: download model FP16 từ HuggingFace
huggingface-cli download meta-llama/Meta-Llama-3-8B-Instruct \
    --local-dir ./models/llama-3-8b-instruct

# Bước 3: convert sang GGUF FP16
python convert-hf-to-gguf.py ./models/llama-3-8b-instruct \
    --outfile ./models/llama-3-8b-f16.gguf \
    --outtype f16

# Bước 4: quantize xuống Q4_K_M
./quantize ./models/llama-3-8b-f16.gguf \
    ./models/llama-3-8b-q4km.gguf Q4_K_M

# Bước 5: chạy
./main -m ./models/llama-3-8b-q4km.gguf \
    -p "Viết function fibonacci bằng Python" \
    -n 256 -t 8

Kết quả: file FP16 16GB sẽ thành Q4_K_M ~4.8GB. Tốc độ trên Mac M2 Max khoảng 30-40 tokens/s. CPU x86 24-core khoảng 15-20 tokens/s.

Với AWQ trên GPU:

from awq import AutoAWQForCausalLM
from transformers import AutoTokenizer

model_path = "meta-llama/Meta-Llama-3-8B-Instruct"
quant_path = "llama-3-8b-awq"

model = AutoAWQForCausalLM.from_pretrained(model_path)
tokenizer = AutoTokenizer.from_pretrained(model_path)

# Calibration data (vài trăm sample)
quant_config = {
    "zero_point": True,
    "q_group_size": 128,
    "w_bit": 4,
    "version": "GEMM"
}

model.quantize(tokenizer, quant_config=quant_config)
model.save_quantized(quant_path)

Quantize Llama-3-8B mất ~20 phút trên A100. Output ~5GB. Chạy trên vLLM:

vllm serve ./llama-3-8b-awq --quantization awq --max-model-len 4096

Cheatsheet

Câu hỏiTrả lời nhanh
Model 70B chạy được trên 64GB RAM?Có, dùng GGUF Q4_K_M
AWQ hay GPTQ?AWQ nếu deploy vLLM, GPTQ nếu legacy
GGUF Q4_K_M mất bao nhiêu % chất lượng?1-3% trên general benchmark, có thể nhiều hơn trên task-specific
Nên dùng group size nào?128 default, 32 nếu muốn chất lượng cao hơn
INT2 có dùng được không?Hỏng nặng trừ BitNet (train từ đầu với ternary)
Quantize có cần calibration data không?GPTQ và AWQ có. GGUF k-quants không bắt buộc
W4A16 vs W4A4?W4A16 default, W4A4 chỉ khi có kỹ thuật bổ sung
Quantize xong test gì?Benchmark sát use case của bạn, không chỉ MMLU

Lời kết

Quantization là kỹ thuật không thể bỏ qua nếu bạn deploy LLM ngoài API call. Hiểu nó không chỉ giúp tiết kiệm chi phí GPU mà còn giúp debug khi chất lượng output có vấn đề bất ngờ.

Hands-on cho bạn:

  1. Download Llama-3-8B FP16 từ HuggingFace, quantize xuống Q4_K_M bằng llama.cpp. Đo dung lượng trước/sau, đo tốc độ inference.
  2. Tự nghĩ ra 20 câu hỏi đại diện cho use case bạn quan tâm. Hỏi cả model FP16 và Q4_K_M. So sánh thủ công xem mất chất lượng ở đâu.
  3. Nếu có GPU: thử AWQ trên cùng model. So sánh tốc độ inference giữa GGUF (llama.cpp) và AWQ (vLLM) ở cùng batch size.

Bài tiếp theo: Serving frameworks: vLLM, llama.cpp, Ollama, bitnet.cpp đối chiếu. Sau khi đã quantize, câu hỏi là chọn engine nào để serve. Mỗi framework tối ưu cho một use case khác nhau, chọn sai sẽ tốn gấp 5 lần chi phí mà không hay biết.