Bài thứ mười một kết thúc ở một chỗ khó chịu: mọi bước post-training đều fine-tune toàn bộ weights, và PPO còn phải giữ bốn bản model trong bộ nhớ cùng lúc. Bài này trả lời câu hỏi tiếp theo: làm sao fine-tune và chạy một model lớn trên một GPU?
Có hai ý tưởng, và chúng tác động lên hai thứ khác nhau:
- LoRA giữ nguyên weights gốc và chỉ học một cập nhật có hạng thấp. Base không cần gradient, nên phần gradient, master weights và optimizer state gần như biến mất.
- Quantization lưu chính weights gốc bằng ít bit hơn, từ 2 byte xuống khoảng nửa byte mỗi tham số.
QLoRA ghép hai ý lại: base 4-bit đóng băng, adapter LoRA train ở 16 bit. Phần cuối bài nói về cái giá: perplexity gần như không đổi chưa có nghĩa là model không mất gì, và tiếng Việt thường chịu thiệt nhiều hơn. Bài đi theo thứ tự của Hình 1 ở đầu trang.
01Bộ nhớ là nút thắt
Các bài trước đã có đủ mảnh ghép: bài 3, mục 1.2 dừng ở full fine-tuning; bài 2, mục 7 tính bộ nhớ chỉ cho weights (7B ở FP16 khoảng 14 GB, ở 4-bit khoảng 3,5 GB); bài 10, mục 8 đếm 16 byte cho mỗi tham số khi train với AdamW và mixed precision, nên một model 8B không train được trên một GPU vì thiếu bộ nhớ; bài 11, mục 6 cho thấy PPO còn tệ hơn.
Câu hỏi của bài này là: trong 16 byte đó, phần nào thật sự cần? Tách ra theo từng thành phần cho Llama 3.1 8B (8,03B tham số, phép đếm ở bài 8, mục 6):
| Thành phần | Full fine-tuning | LoRA (base bf16) | QLoRA (base NF4) |
|---|---|---|---|
| Weights của base | 16,1 GB (2 byte) | 16,1 GB | ≈ 5,7 GB |
| Gradient của base | 16,1 GB | 0 | 0 |
| Master weights fp32 | 32,1 GB | 0 | 0 |
| Adam momentum + variance | 64,2 GB | 0 | 0 |
| Adapter LoRA (weights, gradient, Adam, tất cả fp32) | – | ≈ 0,67 GB | ≈ 0,67 GB |
| Tổng model states | ≈ 128,5 GB | ≈ 16,7 GB | ≈ 6,4 GB |
| Activation | tăng theo batch × độ dài sequence, cả ba cột đều phải trả |
Các con số LoRA và QLoRA giả định adapter hạng r = 16 gắn vào mọi lớp tuyến tính trong block, tức 41.943.040 tham số (mục 3 đếm cụ thể). Cột QLoRA tính weights của các lớp tuyến tính ở khoảng 4,127 bit mỗi tham số (mục 7 giải thích con số này), còn embedding và lm_head (1,05B tham số) vẫn giữ 16 bit như cách bitsandbytes làm mặc định.
Đọc bảng theo cột:
- LoRA cắt ba dòng giữa: gradient, master weights và Adam state của base, tổng cộng 12 trên 16 byte, vì base không được cập nhật.
- Quantization cắt dòng đầu: 2 byte weights xuống khoảng 0,5 byte.
- QLoRA cắt cả hai, từ 128 GB xuống khoảng 6 GB, cộng activation.
Hai núm vặn này độc lập: LoRA không lượng tử (mục 3–4), lượng tử để deploy (mục 5–6), hoặc ghép cả hai (mục 7).
02Parameter-efficient fine-tuning: bức tranh chung
LoRA thuộc một họ phương pháp gọi chung là parameter-efficient fine-tuning (PEFT): giữ gần như toàn bộ model đóng băng, chỉ train một lượng nhỏ tham số mới. Có ba hướng chính trước LoRA.
Adapter (Houlsby et al. 2019) chèn vào mỗi block hai module adapter nhỏ dạng bottleneck (chiếu xuống vài chục chiều rồi chiếu lên lại), một sau attention và một sau FFN, và chỉ train các module này. Trên GLUE, adapter đạt kết quả cách full fine-tuning 0,4% mà chỉ thêm 3,6% tham số cho mỗi tác vụ (abstract). Nhược điểm là các lớp chèn thêm nằm nối tiếp trong luồng tính: trên GPT-2 medium với batch size 1, adapter làm một lượt forward chậm thêm 20,7–30,3% (paper LoRA, mục 3, Bảng 1).
Prefix tuning (Li & Liang 2021) và prompt tuning (Lester et al. 2021) không đụng vào weights mà thêm các vector “token ảo” có thể train vào đầu chuỗi, ở mọi layer hoặc chỉ ở lớp embedding. Đổi lại, các token ảo chiếm chỗ trong context và thường khó tối ưu hơn.
LoRA (Hu et al. 2021) thắng vì ba lý do thực dụng:
- Không chậm thêm khi inference. Cập nhật
B·Acộng thẳng được vàoW₀, nên sau khi merge model có đúng kiến trúc ban đầu (mục 4.1 của paper LoRA, đoạn “No Additional Inference Latency”). - Dễ thay adapter. Một adapter chỉ vài chục MB; cùng một base có thể đi kèm nhiều adapter cho nhiều tác vụ.
- Không đổi kiến trúc hay độ dài chuỗi. Mọi thứ khác của pipeline, từ tokenizer đến chat template của bài 11, giữ nguyên.
03LoRA: phần toán low-rank
Hạng của ma trận và xấp xỉ hạng thấp
Hạng (rank) của một ma trận d × k là số chiều của không gian mà các cột của nó trải ra. Một ma trận hạng r luôn viết được thành tích của hai ma trận mỏng: M = B·A với B ∈ ℝ^{d×r}, A ∈ ℝ^{r×k}. Khi r nhỏ hơn nhiều so với d và k, cách viết này tốn r(d + k) số thay vì d·k.
SVD (singular value decomposition) cho biết một ma trận “gần hạng thấp” đến mức nào. Mọi ma trận đều viết được thành M = U·Σ·Vᵀ, với các giá trị kỳ dị σ₁ ≥ σ₂ ≥ … trên đường chéo của Σ. Định lý Eckart–Young nói rằng xấp xỉ hạng r tốt nhất của M theo chuẩn Frobenius chính là giữ lại r giá trị kỳ dị lớn nhất, và sai số còn lại bằng σ²_{r+1} + σ²_{r+2} + …. Vì vậy tỉ lệ (σ₁² + … + σ_r²) / Σ σ_i², gọi là phần năng lượng nằm trong r giá trị đầu, là một thước đo trực tiếp: nếu 8 giá trị đầu đã chiếm 90% năng lượng thì ma trận đó gần như có hạng 8.
Giả thuyết: cập nhật khi fine-tune có hạng thấp
Aghajanyan et al. 2020 đo intrinsic dimension của fine-tuning: chỉ tối ưu d tham số, rồi chiếu ngẫu nhiên chúng ngược về không gian đầy đủ của model, cần d nhỏ đến đâu để đạt 90% kết quả của full fine-tuning. Với RoBERTa-large trên MRPC, con số này chỉ khoảng 200 tham số (abstract; chính xác là 207 ở mục 4.2, Bảng 1). Model pretrain càng tốt thì con số này càng nhỏ: fine-tuning chỉ cần đi trong một không gian con rất nhỏ.
LoRA biến quan sát đó thành một giả thuyết cụ thể: phần cập nhật ΔW khi fine-tune có “hạng nội tại” thấp (mục 4.1 của paper LoRA). Chú ý chữ “phần cập nhật”. LoRA không nói model có hạng thấp. W₀ vẫn là ma trận đầy đủ, chỉ ΔW bị ép hạng thấp.
Công thức
Với một lớp tuyến tính có weights W₀ ∈ ℝ^{d×k}, LoRA thay forward h = W₀x bằng:
h = W₀·x + (α/r)·B·A·x B ∈ ℝ^{d×r}, A ∈ ℝ^{r×k}, r ≪ min(d, k)
W₀đóng băng, không có gradient. ChỉAvàBđược train.- Khởi tạo:
Angẫu nhiên (Gaussian),B = 0(mục 4.1, ngay sau phương trình 3). VìB = 0nên lúc bắt đầuB·A = 0và model cho output y hệt bản gốc. Train bắt đầu đúng từ điểm của base model, không có cú sốc nào. - Hệ số
α/r: paper chia chorđể khi đổirthì ít phải chỉnh lại learning rate hơn (mục 4.1). Kalajdzievski 2023 (rsLoRA) chỉ ra rằng vớirlớn, chia chorlàm gradient quá nhỏ, và đề xuất chia cho√rthay vìr(mục 3, phương trình 4). PEFT bật cách này bằnguse_rslora=True.
Đếm tham số
Với Llama 3.1 8B, dùng đúng cấu hình đã đếm ở bài 8, mục 6 (d = 4096, d_ff = 14336, 8 head KV nên W_K, W_V chỉ có 1024 hàng), mỗi ma trận d × k có r(d + k) tham số LoRA. Shape trong bảng ghi theo quy ước của PyTorch, out × in. Với r = 16:
| Ma trận | Shape | Tham số gốc | Tham số LoRA (r = 16) |
|---|---|---|---|
W_Q, W_O |
4096 × 4096 | 16.777.216 mỗi cái | 131.072 mỗi cái |
W_K, W_V |
1024 × 4096 | 4.194.304 mỗi cái | 81.920 mỗi cái |
W_gate, W_up / W_down |
14336 × 4096 / 4096 × 14336 | 58.720.256 mỗi cái | 294.912 mỗi cái |
| Một block (chỉ lớp tuyến tính) | 218.103.808 | 1.310.720 | |
| 32 block | 6,98B | 41.943.040 (0,52% của 8,03B) |
Nếu chỉ gắn vào W_Q và W_V như phần lớn thí nghiệm của paper gốc, con số giảm còn 6.815.744. Mục 4 sẽ cho thấy hai cách chọn này cũng khác nhau về chất lượng.
Ở quy mô lớn nhất, paper LoRA báo cáo: so với fine-tune GPT-3 175B bằng Adam, LoRA giảm số tham số train khoảng 10.000 lần và bộ nhớ GPU 3 lần (abstract). VRAM khi train giảm từ 1,2 TB xuống 350 GB, checkpoint từ 350 GB xuống 35 MB (mục 4.2, đoạn “Practical Benefits and Limitations”). Bộ nhớ chỉ giảm 3 lần vì weights 16 bit của base vẫn nằm trên GPU, đúng như cột LoRA ở mục 1.
Merge và unmerge
Sau khi train, W = W₀ + (α/r)·B·A là một ma trận d × k bình thường. Có hai cách dùng:
- Merge: cộng
B·AvàoW₀một lần, xoá nhánh LoRA. Model có đúng kiến trúc và tốc độ của base, vàW₀ + B·Anói chung có hạng đầy đủ. - Giữ riêng adapter: lưu
A,Bthành một file nhỏ, nạp lên cùng base khi cần. Muốn đổi tác vụ thì trừB·Ara (unmerge) hoặc nạp adapter khác.
Code mẫu 1a: ΔW thật có hạng thấp không?
Giả thuyết của LoRA nói về chính bản cập nhật khi fine-tune. Vậy khi full fine-tune, ΔW mà optimizer tìm ra có thật có hạng thấp không? Có sẵn một cặp để kiểm tra: Qwen2.5-0.5B (base) và Qwen2.5-0.5B-Instruct, được post-train đầy đủ từ chính base đó. Đoạn code dưới đây tính ΔW = W_instruct − W_base ở vài ma trận, đo phần năng lượng trong 8 và 64 giá trị kỳ dị đầu, và số giá trị kỳ dị cần để giữ 90% năng lượng. Cột cuối là con số đó cho một ma trận Gaussian ngẫu nhiên cùng shape, làm mốc so sánh.
# Python 3.12, torch 2.14.1+cu130, transformers 5.18.0 — SVD chạy trên GPU (RTX 2080 Ti), fp32
import torch
from transformers import AutoModelForCausalLM
from transformers.utils import logging
logging.disable_progress_bar()
base = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-0.5B", dtype=torch.float32)
inst = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct", dtype=torch.float32)
W0 = dict(base.named_parameters())
W1 = dict(inst.named_parameters())
def energy(s, r): # phần "năng lượng" ‖·‖²_F nằm trong r giá trị kỳ dị lớn nhất
e = s.pow(2)
return (e[:r].sum() / e.sum()).item()
def rank_for(s, frac): # số giá trị kỳ dị cần để giữ `frac` năng lượng
c = s.pow(2).cumsum(0) / s.pow(2).sum()
return int((c < frac).sum().item()) + 1
print(f"{'ma trận':<26}{'shape':>12}{'‖ΔW‖/‖W₀‖':>11}{'r=8':>8}{'r=64':>8}{'r cho 90%':>11}{'ngẫu nhiên':>12}")
for layer in (0, 12, 23):
for proj in ("self_attn.q_proj", "self_attn.v_proj", "mlp.down_proj"):
name = f"model.layers.{layer}.{proj}.weight"
w0 = W0[name].detach().cuda()
dW = W1[name].detach().cuda() - w0
s = torch.linalg.svdvals(dW)
torch.manual_seed(0)
s_rand = torch.linalg.svdvals(torch.randn_like(dW)) # baseline: ma trận Gaussian cùng shape
print(f"L{layer:<2} {proj:<22}{str(tuple(dW.shape)):>12}"
f"{(dW.norm() / w0.norm()).item():>11.3f}"
f"{energy(s, 8):>8.1%}{energy(s, 64):>8.1%}"
f"{rank_for(s, 0.9):>11}{rank_for(s_rand, 0.9):>12}")
Output khi chạy:
ma trận shape ‖ΔW‖/‖W₀‖ r=8 r=64 r cho 90% ngẫu nhiên
L0 self_attn.q_proj (896, 896) 0.072 53.6% 83.7% 105 457
L0 self_attn.v_proj (128, 896) 0.087 32.5% 81.6% 86 104
L0 mlp.down_proj (896, 4864) 0.060 4.1% 15.7% 698 707
L12 self_attn.q_proj (896, 896) 0.088 5.5% 31.3% 402 457
L12 self_attn.v_proj (128, 896) 0.047 12.4% 68.1% 102 104
L12 mlp.down_proj (896, 4864) 0.082 2.0% 14.1% 696 707
L23 self_attn.q_proj (896, 896) 0.103 7.0% 36.2% 371 457
L23 self_attn.v_proj (128, 896) 0.055 15.8% 78.2% 89 104
L23 mlp.down_proj (896, 4864) 0.072 4.5% 17.0% 685 707
Đọc output:
ΔWnhỏ so vớiW₀: chuẩn của nó chỉ bằng 5–10% chuẩn của weights gốc. Post-training đúng là chỉ “đẩy nhẹ” model, khớp với ý “post-training gần như không thêm tri thức” ở bài 11.- Nhưng theo nghĩa năng lượng, phần lớn
ΔWkhông có hạng thấp. Ởmlp.down_proj, cần gần 700 trên 896 giá trị kỳ dị để giữ 90% năng lượng, gần bằng một ma trận ngẫu nhiên (707).q_projở layer 12 và 23 cũng cần gần 400. Chỉq_projở layer 0 là có cấu trúc hạng thấp rõ: 8 giá trị đầu đã chiếm 54% năng lượng. - Điều này khớp với Biderman et al. 2024: full fine-tuning tìm ra các thay đổi weights có hạng cao hơn 10–100 lần so với cấu hình LoRA thông thường. Qwen2.5-Instruct còn được post-train trên lượng dữ liệu rất lớn: hơn 1 triệu mẫu SFT, rồi DPO trên khoảng 150 nghìn cặp và RL trực tuyến bằng GRPO (Qwen2.5 Technical Report, mục 4.1–4.3).
Kết quả này đi ngược với giả thuyết gốc. Paper LoRA nói thẳng về bản cập nhật thật: “the updates to the weights also have a low intrinsic rank during adaptation” (mục 4.1). Với một lần post-training nặng như của Qwen2.5-Instruct, giả thuyết đó không đúng theo nghĩa năng lượng. Nhưng có hai điểm cần cân nhắc. Thứ nhất, thước đo “90% năng lượng” rất khắt khe: ngay W₀ của L0 mlp.down_proj cũng cần 680 trên 896 giá trị kỳ dị (Biderman et al. 2024 cũng ghi nhận điều này, mục 4.6). Thứ hai, năng lượng không phải là hành vi: giữ vài hướng đầu của ΔW thì lấy lại được bao nhiêu phần hành vi của Instruct?
Đoạn code dưới đây chạy tiếp ngay sau đoạn trên. Nó tạo một bản lai: mọi tham số lấy từ Instruct, riêng mọi lớp tuyến tính trong block dùng W₀ + ΔW_r, với ΔW_r là xấp xỉ hạng r tốt nhất của ΔW. Instruct trả lời 10 câu hỏi chat, rồi code đo bản lai lệch bao nhiêu so với Instruct trên đúng các token trả lời đó, bằng KL và tỉ lệ flip top-1 (hai thước đo mục 8 cũng dùng).
# Chạy tiếp ngay sau đoạn trên (dùng lại W0, W1, inst, energy, rank_for) — cùng GPU, fp32
import copy
import torch.nn.functional as F
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")
inst = inst.cuda().eval()
hyb = copy.deepcopy(inst) # bản lai: giống hệt Instruct, trừ lớp tuyến tính dùng W₀ + (ΔW cắt còn hạng r)
names = [n for n, m in hyb.named_modules() if isinstance(m, torch.nn.Linear) and ".layers." in n]
svd = {}
for n in names:
w0 = W0[n + ".weight"].detach().cuda()
svd[n] = (w0, *torch.linalg.svd(W1[n + ".weight"].detach().cuda() - w0, full_matrices=False))
# 10 câu hỏi chat (5 en, 5 vi); Instruct tự sinh câu trả lời greedy, rồi so phân phối trên đúng các token đó
prompts = ["What does a compiler do? Answer in two sentences.", "Write a short poem about the sea.",
"List three uses of a paperclip.", "Why do leaves change color in autumn?",
"Convert 5 kilometers to miles and explain.",
"Giải thích ngắn gọn vì sao trời có mưa.", "Viết một lời cảm ơn gửi thầy giáo.",
"Kể tên ba món ăn nổi tiếng của Hà Nội.", "Làm sao để ngủ ngon hơn? Trả lời ngắn.",
"Tóm tắt truyện Tấm Cám trong hai câu."]
seqs = []
for p in prompts:
ids = tok.apply_chat_template([{"role": "user", "content": p}], add_generation_prompt=True,
return_tensors="pt", return_dict=True)["input_ids"].cuda()
with torch.no_grad():
seqs.append((inst.generate(ids, max_new_tokens=64, do_sample=False), ids.shape[1]))
@torch.no_grad()
def answer_logprobs(model): # log-prob tại mọi vị trí của câu trả lời
return torch.cat([F.log_softmax(model(o).logits[0, L - 1:-1], -1) for o, L in seqs])
ref = answer_logprobs(inst)
print("W₀ của L0 mlp.down_proj cần", rank_for(torch.linalg.svdvals(svd["model.layers.0.mlp.down_proj"][0]), 0.9),
"giá trị kỳ dị để giữ 90% năng lượng")
print(f"{'ΔW giữ hạng':<14}{'năng lượng (TB)':>16}{'KL(inst ‖ ·)':>14}{'flip top-1':>12}")
for r in (0, 8, 64, 256):
kept = []
for n in names:
w0, U, S, Vh = svd[n]
hyb.get_submodule(n).weight.data = w0 + (U[:, :r] * S[:r]) @ Vh[:r]
kept.append(energy(S, r))
lp = answer_logprobs(hyb)
kl = (ref.exp() * (ref - lp)).sum(-1).mean().item()
flip = (lp.argmax(-1) != ref.argmax(-1)).float().mean().item()
print(f"{'r = ' + str(r):<14}{sum(kept) / len(kept):>16.1%}{kl:>14.3f}{flip:>12.1%}")
Output khi chạy:
W₀ của L0 mlp.down_proj cần 680 giá trị kỳ dị để giữ 90% năng lượng
ΔW giữ hạng năng lượng (TB) KL(inst ‖ ·) flip top-1
r = 0 0.0% 0.407 26.2%
r = 8 7.6% 0.198 20.8%
r = 64 36.4% 0.120 17.2%
r = 256 70.3% 0.054 11.0%
Số KL và flip cụ thể đổi theo tập câu hỏi, còn mức giảm tương đối thì ổn định; cột năng lượng chỉ phụ thuộc weights nên không đổi.
Đọc output:
r = 0là lớp tuyến tính của base ghép với phần còn lại của Instruct. Bản này lệch khỏi Instruct với KL 0,407 nats mỗi token, và 26% token top-1 khác.- Hạng 8 chỉ giữ 7,6% năng lượng của
ΔW, nhưng đã giảm KL một nửa (0,407 xuống 0,198). Hạng 64 giữ 36% năng lượng mà giảm KL 71%. Phần cập nhật quan trọng cho hành vi tập trung ở vài hướng đầu nhiều hơn hẳn phần năng lượng của chúng. - Cắt hạng không miễn phí: ở hạng 8 vẫn còn 21% token top-1 khác. Nhưng đây là cắt SVD sau khi train. Một adapter LoRA được train trực tiếp sẽ dùng
rhướng của nó tốt hơn, nên các con số này chỉ là cận dưới.
Vì vậy LoRA vẫn hiệu quả dù ΔW của full fine-tuning không có hạng thấp theo nghĩa năng lượng: một cập nhật hạng thấp giữ được phần lớn thay đổi về hành vi. Cái nó mất phụ thuộc vào lượng dữ liệu và độ khó của tác vụ, và mục 4 đo điều đó bằng các thí nghiệm có kiểm soát.
Code mẫu 1b: gắn LoRA bằng PEFT
Đoạn code tiếp theo dùng PEFT để: (1) đếm số tham số train khi áp LoRA vào q, v so với mọi lớp tuyến tính; (2) kiểm tra rằng lúc khởi tạo model có LoRA cho output trùng bản gốc; (3) train vài bước; (4) tắt adapter và merge, rồi đo sai khác logits. Model và adapter giữ ở fp32 để các phép so sánh không lẫn sai số làm tròn.
# Python 3.12, torch 2.14.1+cu130, transformers 5.18.0, peft 0.21.2
# GPU: RTX 2080 Ti 11 GB; model và adapter giữ ở fp32 để phép so sánh logits không lẫn sai số làm tròn
import copy
import torch
from peft import LoraConfig, get_peft_model
from transformers import AutoModelForCausalLM, AutoTokenizer
from transformers.utils import logging
logging.disable_progress_bar()
torch.manual_seed(0)
name = "Qwen/Qwen2.5-0.5B"
tok = AutoTokenizer.from_pretrained(name)
base = AutoModelForCausalLM.from_pretrained(name, dtype=torch.float32).cuda().eval()
n_total = sum(p.numel() for p in base.parameters())
# 1. Số tham số train: chỉ q,v (như paper LoRA) so với mọi lớp tuyến tính
for targets in (["q_proj", "v_proj"], "all-linear"):
cfg = LoraConfig(r=16, lora_alpha=32, target_modules=targets, task_type="CAUSAL_LM")
m = get_peft_model(copy.deepcopy(base), cfg)
n_train = sum(p.numel() for p in m.parameters() if p.requires_grad)
print(f"target={str(targets):<22} trainable={n_train:>10,} ({n_train / n_total:.3%} của {n_total:,})")
del m
# 2. Lúc khởi tạo B = 0, nên model có LoRA cho output y hệt bản gốc
text = ("LoRA giữ nguyên weights gốc và chỉ học một cập nhật có hạng thấp. "
"Nhờ vậy optimizer chỉ phải lưu state cho vài triệu tham số thay vì cả model.")
batch = tok(text, return_tensors="pt").to("cuda")
with torch.no_grad():
ref_logits = base(**batch).logits
cfg = LoraConfig(r=16, lora_alpha=32, lora_dropout=0.0, target_modules="all-linear", task_type="CAUSAL_LM")
model = get_peft_model(base, cfg)
with torch.no_grad():
print("init: max |Δlogits| =", (model(**batch).logits - ref_logits).abs().max().item())
# 3. Train vài bước trên đúng một câu, chỉ để thấy adapter thay đổi model
opt = torch.optim.AdamW([p for p in model.parameters() if p.requires_grad], lr=1e-3)
model.train()
for step in range(30):
loss = model(**batch, labels=batch["input_ids"]).loss
loss.backward(); opt.step(); opt.zero_grad()
if step in (0, 9, 19, 29):
print(f"step {step + 1:>2} loss {loss.item():.4f}")
model.eval()
with torch.no_grad():
lora_logits = model(**batch).logits
with model.disable_adapter(): # tắt adapter = quay về base, không cần bản copy thứ hai
off_logits = model(**batch).logits
print("sau train: max |logits(LoRA) − logits(base)| =", (lora_logits - ref_logits).abs().max().item())
print("tắt adapter: max |logits − logits(base)| =", (off_logits - ref_logits).abs().max().item())
# 4. Merge BA vào W₀: inference không còn nhánh phụ, output gần như không đổi
merged = model.merge_and_unload()
with torch.no_grad():
merged_logits = merged(**batch).logits
print("merge: max |logits(merged) − logits(LoRA)| =", (merged_logits - lora_logits).abs().max().item())
print("lớp sau merge:", type(merged.model.layers[0].self_attn.q_proj).__name__)
Output khi chạy:
target=['q_proj', 'v_proj'] trainable= 1,081,344 (0.219% của 494,032,768)
target=all-linear trainable= 8,798,208 (1.781% của 494,032,768)
init: max |Δlogits| = 0.0
step 1 loss 5.2875
step 10 loss 0.0026
step 20 loss 0.0002
step 30 loss 0.0001
sau train: max |logits(LoRA) − logits(base)| = 41.41816711425781
tắt adapter: max |logits − logits(base)| = 0.0
merge: max |logits(merged) − logits(LoRA)| = 0.0001678466796875
lớp sau merge: Linear
Đọc output:
- Số tham số khớp công thức
r(d + k). Qwen2.5-0.5B cód = 896,d_ff = 4864, 2 head KV nênW_K,W_Vlà128 × 896. Vớir = 16, một block có16·(1792 + 1024 + 1024 + 1792 + 3·5760) = 366.592tham số LoRA, nhân 24 block ra đúng 8.798.208. Chỉ gắn vàoq, vthì còn 1.081.344, ít hơn 8 lần. Chú ý"all-linear"của PEFT không gắn vàolm_head(theo tài liệu PEFT). - Lúc khởi tạo, sai khác bằng đúng 0. Đây là hệ quả trực tiếp của
B = 0. - Train 30 bước trên một câu thì model học thuộc câu đó (loss từ 5,29 xuống 0,0001) và logits lệch tới 41 so với base. Đây là overfit có chủ đích, chỉ để thấy adapter thật sự thay đổi model.
- Tắt adapter cho lại đúng base, sai khác 0. Đây là điều mục 4 sẽ dùng cho DPO.
- Merge gần như không đổi output: sai khác lớn nhất 0,00017 trên logits có độ lớn hàng chục, là sai số làm tròn fp32 khi cộng hai ma trận. Sau merge, lớp
q_projlại làtorch.nn.Linearbình thường.
04LoRA trong thực tế
Áp vào lớp nào
Paper LoRA gốc đóng băng toàn bộ MLP và chỉ thử trên các ma trận attention, phần lớn thí nghiệm dùng W_Q và W_V (mục 4.2, 5.1). Các kết quả sau này đi ngược lại:
- QLoRA (Dettmers et al. 2023) thấy rằng siêu tham số quan trọng nhất là tổng số adapter, và phải gắn LoRA vào mọi lớp tuyến tính trong block mới bắt kịp full fine-tuning 16 bit (mục 4, Hình 2).
- LoRA Without Regret (John Schulman và Thinking Machines Lab, 29/09/2025) đi đến cùng kết luận: LoRA chỉ trên attention kém hơn rõ, kể cả khi số tham số train bằng nhau, và gắn vào MLP mới là quan trọng nhất (phần “Layers Where LoRA Is Applied”). Ví dụ: LoRA chỉ trên attention với
r = 256(0,25B tham số) vẫn thua LoRA chỉ trên MLP vớir = 128(0,24B tham số).
Lý do khá trực quan: FFN chiếm khoảng 80% tham số của mỗi block (bài 8).
Chọn r, α và learning rate
rnhỏ thường đủ. Với GPT-3 175B trên WikiSQL và MultiNLI, paper LoRA thấy hạng nhỏ tớir = 1đã đủ khi áp vào cảW_QlẫnW_V(mục 7.2, Bảng 6), dù chính paper lưu ý không nên kỳ vọng điều đó đúng với mọi tác vụ (chú thích 6). QLoRA còn thấy khi đã gắn LoRA vào mọi lớp thìrgần như không ảnh hưởng tới kết quả cuối (phụ lục A.1, Hình 4). Trong thực tế,rtừ 8 đến 64 là phổ biến; tăngrchỉ giúp khi dữ liệu đủ lớn.α: thường đặtα = rhoặcα = 2r, rồi chỉnh learning rate thay vì chỉnhα.- Learning rate cao hơn full fine-tuning: LoRA Without Regret thấy learning rate tối ưu của LoRA cao hơn khoảng 10 lần so với full fine-tuning trên cùng model (hệ số fit được là 9,8, phần “Optimal learning rates for LoRA vs. FullFT”; với run ngắn dưới khoảng 100 bước thì gần 15 lần, phần “Learning rates in short and long runs”). Lý do:
Bbắt đầu từ 0, và cập nhật hạng thấp cần bước lớn hơn để đi cùng một quãng.
LoRA có bằng full fine-tuning không
Câu trả lời ngắn: tuỳ lượng dữ liệu.
- Biderman et al. 2024 (“LoRA Learns Less and Forgets Less”) so sánh trên code và toán, ở cả continued pretraining (hàng tỉ token) lẫn instruction fine-tuning. Với các hạng thấp thường dùng, LoRA học kém hơn full fine-tuning rõ rệt (mục 4.1, Hình 1). Khoảng cách này không thu hẹp ở continued pretraining; ở instruction fine-tuning, hạng cao có thể bắt kịp (mục 1). Đổi lại, LoRA quên ít hơn: model giữ được năng lực ngoài miền tác vụ tốt hơn (mục 4.2), và tác dụng điều chuẩn này mạnh hơn cả weight decay hay dropout (mục 4.5, Hình 4).
- LoRA Without Regret cho bức tranh bổ sung: với các tập instruction-tuning và reasoning cỡ nhỏ và vừa, LoRA đạt ngang full fine-tuning; nó chỉ tụt lại khi dữ liệu vượt quá “dung lượng” của adapter (phần “What matters for LoRA”). Với RL, LoRA ngang full fine-tuning kể cả khi
r = 1, vì mỗi episode RL chỉ cung cấp rất ít bit thông tin (phần “Reinforcement learning”).
Ghép hai nguồn lại: LoRA là lựa chọn mặc định hợp lý cho SFT trên vài nghìn tới vài trăm nghìn mẫu và cho RL; khi muốn dạy model một miền mới với hàng tỉ token, full fine-tuning vẫn hơn.
DPO với LoRA: tắt adapter là có reference
Bài 11, mục 8 cho thấy loss DPO cần log-prob của cả policy lẫn reference model, và cách thông thường là giữ hai bản model. Với LoRA, reference chính là base với adapter tắt đi. Code mẫu 1b ở trên vừa kiểm tra điều này: disable_adapter() cho sai khác logits bằng 0 so với base. Vì vậy chỉ cần một bản weights trong bộ nhớ: bật adapter để tính log-prob của policy, tắt adapter để tính log-prob của reference. TRL dùng đúng mẹo này khi truyền peft_config cho DPOTrainer và không có ref_model.
Phục vụ nhiều adapter và các biến thể
Vì adapter nhỏ và không merge, một server có thể giữ một base và hàng nghìn adapter, ghép mỗi request với adapter của nó trong lúc tính, như S-LoRA (Sheng et al. 2023) làm. Các biến thể như rsLoRA (đổi hệ số), DoRA (tách độ lớn và hướng), LoRA+ (learning rate riêng cho B) và PiSSA (khởi tạo từ SVD của W₀) đều giữ ý chính “cập nhật hạng thấp, base đóng băng”, với mức cải thiện thường vài điểm phần trăm tuỳ tác vụ.
05Quantization: từ con số đến tensor
Bài 10, mục 8 đã có bảng các định dạng số thực fp32, fp16, bf16 và fp8. Mục này đi xuống thấp hơn: số nguyên 8 bit và 4 bit. Một số nguyên không có số mũ, nên muốn biểu diễn weights bằng INT8 hay INT4 ta phải tự chọn một thang đo (scale).
Absmax và zero-point
Cách đơn giản nhất là absmax (đối xứng). Với một nhóm giá trị x và b bit:
s = max|x| / (2^(b−1) − 1) # INT8: chia cho 127, INT4: chia cho 7
q = round(x / s) # số nguyên, lưu kèm s
x̂ = q · s # giải lượng tử (dequantize)
Giá trị lớn nhất về độ lớn được ánh xạ đúng vào mức cao nhất, mọi giá trị khác được làm tròn vào lưới đều giữa −max|x| và max|x|. Nếu phân phối lệch về một phía, nửa lưới bị phí. Cách zero-point (bất đối xứng) thêm một độ dời z: q = round(x/s) + z, để lưới phủ đúng đoạn [min x, max x]. Đổi lại phải lưu thêm z.
Theo tensor, theo kênh, theo nhóm
Câu hỏi quan trọng nhất không phải công thức mà là bao nhiêu giá trị dùng chung một scale:
- Theo tensor: cả ma trận một scale. Rẻ nhất, nhưng chỉ cần một giá trị rất lớn là toàn bộ lưới bị giãn ra, và phần lớn giá trị nhỏ bị làm tròn về 0.
- Theo kênh: mỗi hàng (hoặc cột) một scale.
- Theo nhóm: mỗi nhóm
ggiá trị liên tiếp một scale, thườngg = 64hoặc128. Đây là cách mọi định dạng 4-bit hiện đại đều dùng.
Scale không miễn phí. Với nhóm 128 và scale fp16, mỗi weight tốn thêm 16/128 = 0,125 bit; có thêm zero-point thì gấp đôi. Vì vậy một model “4-bit” thực tế tốn khoảng 4,1–4,9 bit mỗi weight (bảng ở mục 6).
Outlier: lý do có nhiều phương pháp như vậy
Nếu weights và activation đều có phân phối đẹp, absmax theo nhóm là đủ. Nhưng chúng không đẹp. LLM.int8() (Dettmers et al. 2022) phát hiện trong activation các outlier feature: một số rất ít chiều của hidden state có độ lớn vượt hẳn phần còn lại. Ở model nhỏ, chúng chỉ xuất hiện ở khoảng 25% số layer; tới khoảng 6,7B tham số có một bước chuyển pha, và chúng xuất hiện ở mọi layer và khoảng 75% vị trí trong chuỗi (mục 1, mục 4.2, Hình 3). Lượng tử thô thì các chiều này kéo giãn scale và phá hỏng model. LLM.int8() sửa bằng mixed-precision decomposition: các chiều outlier tính ở 16 bit, hơn 99,9% giá trị còn lại nhân ở 8 bit (abstract).
Đây là mấu chốt của cả mục 6: GPTQ, AWQ, SmoothQuant, k-quant đều là những cách khác nhau để xử lý chuyện một vài giá trị quan trọng hơn hẳn số còn lại.
Weight-only và weight + activation
- Weight-only (W4A16, W8A16): chỉ lượng tử weights; trước khi nhân, weights được giải lượng tử về 16 bit. Đây là kiểu phổ biến nhất và là trọng tâm của bài này.
- Weight + activation (W8A8): lượng tử cả hai để phép nhân ma trận chạy hẳn trên số nguyên 8 bit. SmoothQuant (Xiao et al. 2022) chuyển “độ khó” từ activation sang weights bằng cách chia activation và nhân weights cho cùng một hệ số theo kênh (mục 4), đạt tốc độ cao hơn tới 1,56 lần và giảm 2 lần bộ nhớ (abstract, mục 5.3).
Vì sao weight-only lại giúp tăng tốc khi phép nhân vẫn chạy ở 16 bit? Vì ở giai đoạn decode, mỗi token mới phải đọc toàn bộ weights từ bộ nhớ GPU mà chỉ làm rất ít phép tính trên mỗi byte đọc được. Decode bị giới hạn bởi băng thông bộ nhớ, không phải compute, nên weights nhỏ đi 4 lần là có thể nhanh lên đáng kể. Bài 13 sẽ đi vào chi tiết prefill và decode.
PTQ và QAT
Mọi phương pháp ở mục 6 là post-training quantization (PTQ): lượng tử một model đã train xong, có khi cần thêm một ít dữ liệu calibration. Ngược lại, quantization-aware training (QAT, Jacob et al. 2018) mô phỏng phép làm tròn ngay trong lúc train, cho gradient đi qua round bằng straight-through estimator (Bengio et al. 2013), nên tốt hơn ở bit thấp nhưng phải chạy lại một phần quá trình train.
06GPTQ, AWQ và GGUF
Ba cái tên này hay được liệt kê cạnh nhau như ba lựa chọn cùng loại. Thật ra chúng là ba thứ khác loại: GPTQ và AWQ là thuật toán chọn giá trị lượng tử, còn GGUF là định dạng file.
GPTQ: bù sai số bằng thông tin bậc hai
GPTQ (Frantar et al. 2022) xử lý từng lớp một. Với weights W và activation đầu vào X lấy từ một ít dữ liệu calibration, nó tìm Ŵ đã lượng tử sao cho output của lớp thay đổi ít nhất:
argmin_Ŵ ‖W·X − Ŵ·X‖²₂
Mấu chốt là sai số được đo trên output, không trên weights: làm tròn lệch một weight không sao nếu các weight khác bù lại được. GPTQ lượng tử từng cột, rồi dùng Hessian H = 2XXᵀ để cập nhật các cột chưa lượng tử, bù vào sai số vừa tạo ra (mục 3, phương trình 1–2, kế thừa từ Optimal Brain Quantization); mục 4 làm thuật toán đủ nhanh cho LLM bằng cách xử lý theo lô 128 cột và dùng phân tích Cholesky. Kết quả: lượng tử một model 175B xuống 3 hoặc 4 bit trong khoảng 4 GPU-giờ, với độ suy giảm rất nhỏ (abstract). Với OPT-175B ở 3 bit, inference nhanh hơn fp16 khoảng 3,24 lần trên A100 (mục 5, Bảng 6).
AWQ: bảo vệ 1% kênh quan trọng
AWQ (Lin et al. 2023) bắt đầu từ một quan sát: chỉ cần bảo vệ khoảng 1% weights quan trọng là sai số lượng tử giảm mạnh (abstract, mục 3.1). Trên OPT-6.7B ở INT3 nhóm 128, giữ 1% kênh đó ở fp16 kéo perplexity từ 43,2 về 13,0 (Hình 2). Kênh quan trọng được chọn theo độ lớn của activation đi vào nó, vì sai số ở kênh nhận activation lớn bị khuếch đại nhiều; chọn theo độ lớn của chính weights thì gần như không hơn chọn ngẫu nhiên (mục 3.1, Bảng 1). Giữ 1% đó ở fp16 cần phép nhân hỗn hợp, không thân thiện với phần cứng, nên AWQ nhân các kênh quan trọng với một hệ số trước khi lượng tử (và chia activation tương ứng) để chúng có sai số tương đối nhỏ hơn. Cách này không cần backpropagation, cũng không tái tạo output như GPTQ, nên ít overfit vào tập calibration (mục 3.2).
GGUF và k-quant
GGUF là định dạng file của llama.cpp: một file chứa weights, tokenizer và metadata, chạy được trên CPU, Apple Silicon và GPU. Bên trong GGUF có thể là nhiều kiểu lượng tử khác nhau. Phổ biến nhất là họ k-quant, giới thiệu trong PR #1684:
- Super-block: weights chia thành super-block 256 giá trị, mỗi super-block gồm các block nhỏ 16 hoặc 32 giá trị. Mỗi block có scale (và min) riêng, và chính các scale đó lại được lượng tử ở 6 hoặc 8 bit. Ví dụ
Q4_Kdùng 8 block × 32 weight, scale và min 6 bit, ra 4,5 bit mỗi weight. - Tên
Q4_K_Mđọc là: kiểu k-quant 4 bit, biến thể Medium. Biến thể S/M/L quyết định lớp nào được thêm bit. Theo mô tả gốc trong PR #1684,Q4_K_MdùngQ6_Kcho một nửa số tensorattention.wvvàfeed_forward.w2, còn lại dùngQ4_K. Logic này đã được chỉnh nhiều lần từ đó, nên con số thực tế nên lấy từ bảng đo. - Importance matrix (imatrix): tuỳ chọn chạy model trên một ít văn bản để biết weight nào quan trọng, rồi ưu tiên chúng khi làm tròn, cùng tinh thần với AWQ.
Bảng dưới lấy từ README của llama-quantize của llama.cpp, ở commit 5e03bdd trên nhánh master (ngày 06/10/2026), đo trên Llama-3.1-8B:
| Kiểu | Bit trung bình / weight | Kích thước (GiB) | Prompt processing (t/s, 512 token) | Text generation (t/s, 128 token) |
|---|---|---|---|---|
| Q3_K_M | 3,9960 | 3,74 | 783,44 | 71,68 |
| Q4_K_S | 4,6672 | 4,36 | 818,55 | 76,71 |
| Q4_K_M | 4,8944 | 4,58 | 821,81 | 71,93 |
| Q5_K_M | 5,7036 | 5,33 | 758,69 | 67,23 |
| Q6_K | 6,5633 | 6,14 | 812,01 | 58,67 |
| Q8_0 | 8,5008 | 7,95 | 865,09 | 50,93 |
| F16 | 16,0005 | 14,96 | 923,49 | 29,17 |
Ba điều đáng để ý:
- “4-bit” không phải 4 bit.
Q4_K_SdùngQ4_Kcho gần hết tensor, mà vẫn ra 4,67 bit trung bình, cao hơn con số 4,5 bit của riêngQ4_K, vì một số tensor như embedding và output được giữ ở kiểu nhiều bit hơn. - Decode nhanh lên như dự đoán ở mục 5. Text generation tăng khoảng 2,5 lần, từ 29 token/s ở F16 lên 72 token/s ở Q4_K_M, dù file nhỏ đi 3,3 lần nên chưa đạt đúng tỉ lệ kích thước.
- Prompt processing lại chậm đi một chút (923 xuống 822 token/s). Giai đoạn đó bị giới hạn bởi compute, nên weights nhỏ hơn không giúp gì, còn bước giải lượng tử thì tốn thêm. README không ghi phần cứng đo, nên chỉ nên đọc các tỉ lệ.
Chọn gì cho nhu cầu nào
| Cần calibration | Ý chính | Thường chạy bằng | |
|---|---|---|---|
| bitsandbytes NF4 / INT8 | không | lượng tử ngay khi nạp model | transformers, dùng cho QLoRA |
| GPTQ | có (vài trăm mẫu) | bù sai số bằng Hessian, từng cột | vLLM, transformers trên GPU |
| AWQ | có (ít mẫu) | scale kênh theo activation | vLLM, transformers trên GPU |
| GGUF k-quant | không (imatrix thì có) | super-block, scale lượng tử lồng | llama.cpp, Ollama; CPU, Mac, GPU |
Quy tắc thô: phục vụ trên GPU server thì dùng GPTQ hoặc AWQ qua một inference engine; máy cá nhân, CPU hoặc Mac thì dùng GGUF; fine-tune thì dùng bitsandbytes qua QLoRA ở mục sau.
07QLoRA: ghép hai ý lại
QLoRA (Dettmers et al. 2023) lượng tử base model xuống 4 bit, đóng băng nó, rồi train adapter LoRA ở 16 bit phía trên. Kết quả nổi tiếng nhất: fine-tune một model 65B trên một GPU 48 GB, trong khi fine-tune 16 bit cần hơn 780 GB (abstract, mục 1). Họ Guanaco train theo cách này đạt 99,3% điểm của ChatGPT trên Vicuna benchmark sau 24 giờ trên một GPU (abstract, Bảng 6). Con số này cần đọc kèm giới hạn chính paper nêu: benchmark chỉ có 80 câu hỏi, do GPT-4 chấm, GPT-4 ưu ái câu trả lời đứng trước (mục 5.2), và mức đồng thuận giữa GPT-4 với người chấm chỉ ở Fleiss κ = 0,25 (mục 6.2). Ba kỹ thuật làm nên QLoRA như sau.
NF4: đặt mức lượng tử theo phân phối chuẩn
Weights của một model đã train có phân phối gần chuẩn, tập trung quanh 0. Lưới INT4 đều đặn phí nhiều mức ở vùng đuôi hiếm có giá trị. NormalFloat4 (NF4) đặt 16 mức theo phân vị của phân phối chuẩn, để mỗi mức “chứa” xấp xỉ cùng số lượng weight. Paper gọi đây là kiểu dữ liệu tối ưu về mặt lý thuyết thông tin cho weights phân phối chuẩn (abstract; cách dựng ở mục 3, phương trình 4). Mỗi block 64 weight được chia cho absmax của block rồi làm tròn về mức NF4 gần nhất.
Lưới phải có một mức đúng bằng 0, nên NF4 bất đối xứng: 8 mức dương, 7 mức âm và số 0. Đoạn code dưới đây dựng lại bảng đó theo cách bitsandbytes làm (hàm create_normal_map), rồi so với bảng chính bitsandbytes dùng:
# Python 3.12, torch 2.14.1, bitsandbytes 0.50.2
import torch
import bitsandbytes.functional as bnbF
def nf4_levels(offset=0.9677083):
icdf = torch.distributions.Normal(0.0, 1.0).icdf
pos = icdf(torch.linspace(offset, 0.5, 9)[:-1]) # 8 mức dương
neg = -icdf(torch.linspace(offset, 0.5, 8)[:-1]) # 7 mức âm
v = torch.cat([pos, torch.zeros(1), neg]).sort().values
return v / v.abs().max() # chuẩn hoá về [-1, 1]
mine = nf4_levels()
print(mine)
print("sai khác lớn nhất so với bitsandbytes:", (mine - bnbF.get_4bit_type("nf4", device="cpu")).abs().max().item())
tensor([-1.0000, -0.6962, -0.5251, -0.3949, -0.2844, -0.1848, -0.0911, 0.0000,
0.0796, 0.1609, 0.2461, 0.3379, 0.4407, 0.5626, 0.7230, 1.0000])
sai khác lớn nhất so với bitsandbytes: 5.960464477539063e-08
Các mức dày đặc quanh 0 (cách nhau khoảng 0,08–0,09) và thưa dần ra hai đầu (từ 0,72 lên 1,0). Đó đúng là hình dạng ta muốn cho một phân phối tập trung quanh 0.
Double quantization
Mỗi block 64 weight có một hằng số scale (absmax) fp32, tức 32/64 = 0,5 bit mỗi weight. QLoRA lượng tử luôn các hằng số scale đó: gom 256 scale thành một nhóm, lượng tử chúng sang FP8 với một scale fp32 cho cả nhóm. Chi phí còn 8/64 + 32/(64·256) ≈ 0,127 bit mỗi weight, tiết kiệm khoảng 0,37 bit mỗi tham số, tức khoảng 3 GB cho model 65B (phép tính ở mục 3, đoạn “Double Quantization”; con số tròn ở mục 1). Đó là nguồn gốc con số 4,127 bit trong bảng ở mục 1.
Paged optimizer
Gradient checkpointing (tính lại activation trong backward thay vì giữ lại) làm bộ nhớ có những đỉnh ngắn, nhất là với batch có chuỗi dài. Paged optimizer dùng unified memory của NVIDIA để tự động chuyển optimizer state sang RAM của CPU khi GPU sắp hết bộ nhớ, rồi chuyển về khi cần cập nhật (mục 3, đoạn “Paged Optimizers”). Nó không làm giảm tổng bộ nhớ, chỉ tránh việc cả lần train chết vì một đỉnh ngắn.
Forward và backward
Hình 2 vẽ một lớp tuyến tính trong QLoRA:
Ba điều cần nhớ từ hình:
- Weights NF4 không bao giờ được dùng trực tiếp để nhân. Chúng chỉ là dạng lưu trữ; mỗi lần forward, từng lớp được giải lượng tử về compute dtype rồi mới nhân. Đây là lý do QLoRA chậm hơn LoRA trên base 16 bit.
- Gradient vẫn chảy qua
W₀. Gradient cho adapter ở layer 3 phải đi ngược qua layer 4, 5, …, kể cả phầnW₀đóng băng. Cái được bỏ đi là gradient củaW₀(đạo hàm theo chínhW₀), không phải gradient quaW₀. - Adapter train ở 16 hoặc 32 bit, không phải 4 bit. Paper dùng bf16 làm compute dtype (mục 3, phương trình 5–6). PEFT mặc định giữ weights adapter ở fp32.
Chạy thật trên RTX 2080 Ti
Đoạn code dưới đây train QLoRA thật trên Qwen2.5-1.5B: base NF4 với double quantization, LoRA r = 16 trên mọi lớp tuyến tính, paged AdamW, 40 bước, batch 4 × 512 token lấy từ wikitext-2. Để so sánh, cùng script chạy thêm một lần với base fp16 không lượng tử (LoRA thường).
Về dtype: RTX 2080 Ti là kiến trúc Turing, không có đơn vị tính bf16 ở mức phần cứng (bf16 có từ Ampere). Thú vị là torch.cuda.is_bf16_supported() vẫn trả về True trên card này, vì mặc định PyTorch tính cả bf16 giả lập; thêm đối số including_emulation=False thì hàm trả về False. Vì vậy script dùng fp16 và GradScaler cho loss scaling, đúng vấn đề fp16 đã nói ở bài 10, mục 8. Trên GPU Ampere trở lên, đổi cả ba chỗ torch.float16 (compute dtype của NF4, dtype nạp base ở nhánh lora-fp16 và dtype của autocast) sang torch.bfloat16, rồi bỏ GradScaler.
# Python 3.12, torch 2.14.1+cu130, transformers 5.18.0, peft 0.21.2, bitsandbytes 0.50.2,
# accelerate 1.15.0, datasets 5.1.0 — GPU: RTX 2080 Ti 11 GB (Turing, sm_75), driver 595.91.07
import sys, time
import torch, bitsandbytes as bnb
from datasets import load_dataset
from peft import LoraConfig, get_peft_model
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from transformers.utils import logging
logging.disable_progress_bar()
torch.manual_seed(0)
MODE = sys.argv[1] if len(sys.argv) > 1 else "qlora" # "qlora" hoặc "lora-fp16" để so sánh
name = "Qwen/Qwen2.5-1.5B"
tok = AutoTokenizer.from_pretrained(name)
if MODE == "qlora":
bnb_cfg = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4", # NormalFloat4
bnb_4bit_use_double_quant=True, # lượng tử luôn hằng số scale
bnb_4bit_compute_dtype=torch.float16, # Turing không có bf16 phần cứng → fp16
)
model = AutoModelForCausalLM.from_pretrained(name, quantization_config=bnb_cfg, device_map={"": 0})
else:
model = AutoModelForCausalLM.from_pretrained(name, dtype=torch.float16, device_map={"": 0})
print(f"[{MODE}] weights trên GPU: {torch.cuda.memory_allocated() / 2**30:.2f} GiB")
# Cả hai cấu hình dùng cùng gradient checkpointing để phép so sánh VRAM công bằng
model.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
cfg = LoraConfig(r=16, lora_alpha=32, lora_dropout=0.05, target_modules="all-linear", task_type="CAUSAL_LM")
model = get_peft_model(model, cfg)
n_train = sum(p.numel() for p in model.parameters() if p.requires_grad)
n_all = sum(p.numel() * (2 if p.dtype == torch.uint8 else 1) for p in model.parameters()) # 1 byte uint8 = 2 weight 4-bit
print(f"trainable {n_train:,} / {n_all:,} ({n_train / n_all:.2%}), dtype adapter: {next(p for p in model.parameters() if p.requires_grad).dtype}")
# Dữ liệu: wikitext-2, nối lại rồi cắt thành block 512 token
raw = load_dataset("Salesforce/wikitext", "wikitext-2-raw-v1", split="train")
ids = tok("\n".join(t for t in raw["text"][:4000] if t.strip()), return_tensors="pt").input_ids[0]
SEQ, BS, STEPS = 512, 4, 40
blocks = ids[: (len(ids) // SEQ) * SEQ].view(-1, SEQ)
opt = bnb.optim.PagedAdamW(model.parameters(), lr=2e-4) # paged optimizer, state fp32
scaler = torch.amp.GradScaler("cuda") # fp16 cần loss scaling
torch.cuda.reset_peak_memory_stats()
model.train(); t0 = time.time()
for step in range(STEPS):
x = blocks[step * BS:(step + 1) * BS].cuda()
with torch.autocast("cuda", dtype=torch.float16):
loss = model(input_ids=x, labels=x).loss
scaler.scale(loss).backward()
scaler.step(opt); scaler.update(); opt.zero_grad(set_to_none=True)
if step % 10 == 0 or step == STEPS - 1:
print(f"step {step + 1:>2} loss {loss.item():.4f}")
torch.cuda.synchronize()
print(f"{(time.time() - t0) / STEPS:.2f} s/step, VRAM đỉnh: {torch.cuda.max_memory_allocated() / 2**30:.2f} GiB")
Output khi chạy python qlora.py qlora rồi python qlora.py lora-fp16 (đã bỏ các dòng cảnh báo của thư viện):
[qlora] weights trên GPU: 1.07 GiB
trainable 18,464,768 / 1,562,179,072 (1.18%), dtype adapter: torch.float32
step 1 loss 3.0523
step 11 loss 2.7241
step 21 loss 2.5036
step 31 loss 2.3736
step 40 loss 2.4672
1.01 s/step, VRAM đỉnh: 5.46 GiB
[lora-fp16] weights trên GPU: 2.88 GiB
trainable 18,464,768 / 1,562,179,072 (1.18%), dtype adapter: torch.float32
step 1 loss 2.9875
step 11 loss 2.6644
step 21 loss 2.4293
step 31 loss 2.3209
step 40 loss 2.4173
0.81 s/step, VRAM đỉnh: 6.66 GiB
Đọc output:
- Weights khớp với phép tính ở mục 1. Qwen2.5-1.5B có 1,31B tham số trong các lớp tuyến tính của block và 233M tham số embedding (dùng chung với
lm_head). Tính1,31B × 4,127/8 + 233M × 2 byte ≈ 1,14 GB = 1,06 GiB, khớp với 1,07 GiB đo được. Bản fp16 là 2,88 GiB. - Số tham số train bằng nhau ở hai cấu hình (18,46 triệu, 1,18%), và adapter ở fp32 dù base là 4 bit.
- VRAM đỉnh chỉ chênh 1,2 GiB, ít hơn mức chênh 1,8 GiB của riêng weights. Với model nhỏ, phần lớn bộ nhớ là activation: riêng logits của batch đã là
4 × 512 × 151.936số, khoảng 1,16 GiB ở fp32. Tỉ lệ đó đảo ngược khi model lớn lên; ở 65B, weights là phần quyết định có vừa GPU hay không. - QLoRA chậm hơn khoảng 25% (1,01 so với 0,81 giây mỗi bước), vì bước dequantize ở mọi lớp, cả trong forward lẫn backward.
- Loss của QLoRA cao hơn một chút ngay từ bước đầu (3,052 so với 2,988 trên cùng batch, cùng adapter bằng 0). Đó chính là sai số lượng tử của base NF4, đo trực tiếp bằng loss. Mục 8 đo kỹ hơn.
- Loss có thể lệch ở chữ số thứ tư giữa các lần chạy, vì một số kernel trên GPU không tất định.
Với model này, LoRA thường cũng vừa 11 GB. QLoRA trở nên cần thiết khi model lớn hơn: base 7B ở fp16 đã chiếm 14 GB, không vừa card này; ở NF4, cùng model chỉ khoảng 4–5 GB.
Bảng bộ nhớ ở quy mô lớn
| Model | Weights 16 bit | Weights NF4 + DQ (khoảng 4,127 bit) | Full fine-tuning (16 byte) |
|---|---|---|---|
| 7B | 14 GB | ≈ 3,6 GB | ≈ 112 GB |
| 65B | 130 GB | ≈ 33,5 GB | ≈ 1.040 GB |
Bảng giả định mọi tham số đều được lượng tử; con số thật cao hơn một chút vì embedding thường giữ 16 bit. Ở 65B, base NF4 khoảng 33,5 GB cộng adapter và activation vẫn dưới 48 GB, khớp với paper.
Lưu ý khi merge
Adapter QLoRA được train trên base đã lượng tử, nên nó học cả cách bù cho sai số lượng tử của chính base đó. Có ba cách dùng:
- Giữ base NF4 + adapter: đúng với lúc train nhất, nhưng inference chậm vì phải dequantize.
- Merge vào base 16 bit gốc: chạy nhanh, nhưng adapter giờ gặp một base khác lúc train. Thường lệch ít, nhưng nên đo lại.
- Merge rồi lượng tử lại (ví dụ sang GGUF): sai số lượng tử mới không giống sai số adapter đã học để bù. Đây là cách dễ lệch nhất, nên luôn đo trước và sau.
08Model mất gì khi bị lượng tử hoá
Code mẫu 2: fake-quant trên Qwen2.5-0.5B
Để thấy rõ cái giá, đoạn code dưới đây tự viết hàm fake-quant: lượng tử rồi giải lượng tử ngay weights của mọi lớp tuyến tính trong block, bằng absmax đối xứng theo nhóm dọc chiều input. Có ba kiểu INT8, INT4 và NF4 (bảng ở mục 7), với các mức chia nhóm khác nhau, kể cả một bản cố ý không chia nhóm. Mỗi cấu hình được đo trên 50 đoạn văn song song tiếng Anh và tiếng Việt của FLORES-200 (lấy qua Belebele):
- bits-per-byte (bpb), cách đo không phụ thuộc tokenizer đã dùng ở bài 10, mục 2;
- KL trung bình mỗi token giữa phân phối của bản gốc và bản lượng tử,
KL(p_gốc ‖ p_lượng tử), tính bằng nats; - tỉ lệ flip top-1: phần trăm vị trí mà token có xác suất cao nhất bị đổi so với bản gốc;
- tỉ lệ weight bị làm tròn về 0.
Phép đo chạy trên GPU, bản tham chiếu ở fp32 vì Turing không có bf16 phần cứng.
# Python 3.12, torch 2.14.1+cu130, transformers 5.18.0, datasets 5.1.0
# Chạy trên GPU (RTX 2080 Ti); bản tham chiếu fp32 vì Turing không có bf16 phần cứng
import torch
import torch.nn.functional as F
from datasets import load_dataset
from transformers import AutoModelForCausalLM, AutoTokenizer
from transformers.utils import logging
logging.disable_progress_bar()
# --- NF4: 16 mức lấy từ phân vị của N(0, 1), dựng như bitsandbytes (create_normal_map) ---
def nf4_levels(offset=0.9677083):
icdf = torch.distributions.Normal(0.0, 1.0).icdf
pos = icdf(torch.linspace(offset, 0.5, 9)[:-1]) # 8 mức dương
neg = -icdf(torch.linspace(offset, 0.5, 8)[:-1]) # 7 mức âm
v = torch.cat([pos, torch.zeros(1), neg]).sort().values
return v / v.abs().max() # chuẩn hoá về [-1, 1]
NF4 = nf4_levels()
def fake_quant(w, kind, group):
"""Lượng tử rồi giải lượng tử ngay (fake-quant), absmax đối xứng, scale theo nhóm dọc chiều input."""
out_f, in_f = w.shape
g = in_f if group == "channel" else (w.numel() if group == "tensor" else group)
x = w.reshape(-1, g) # mỗi hàng là một nhóm dùng chung một scale
absmax = x.abs().amax(dim=1, keepdim=True).clamp(min=1e-8)
if kind == "nf4":
lv = NF4.to(w.device)
idx = (x / absmax).unsqueeze(-1).sub(lv).abs().argmin(-1) # mức NF4 gần nhất
q = lv[idx] * absmax
else:
qmax = 2 ** (int(kind[3:]) - 1) - 1 # int8 → 127, int4 → 7
scale = absmax / qmax
q = (x / scale).round().clamp(-qmax - 1, qmax) * scale
return q.reshape(out_f, in_f)
name = "Qwen/Qwen2.5-0.5B"
tok = AutoTokenizer.from_pretrained(name)
model = AutoModelForCausalLM.from_pretrained(name, dtype=torch.float32).cuda().eval()
linears = {n: m for n, m in model.named_modules()
if isinstance(m, torch.nn.Linear) and ".layers." in n} # chỉ lớp tuyến tính trong block
original = {n: m.weight.data.clone() for n, m in linears.items()}
# 50 đoạn văn song song en–vi của FLORES-200 (lấy qua Belebele)
texts = {}
for lang, cfg in (("en", "eng_Latn"), ("vi", "vie_Latn")):
rows = load_dataset("facebook/belebele", cfg, split="test")
seen, texts[lang] = set(), []
for r in rows:
if r["link"] not in seen and len(texts[lang]) < 50:
seen.add(r["link"]); texts[lang].append(r["flores_passage"])
@torch.no_grad()
def logprobs(text):
ids = tok(tok.eos_token + text, return_tensors="pt").input_ids.cuda()
return F.log_softmax(model(ids).logits[0, :-1].float(), -1), ids[0, 1:]
ref = {lang: [(lp.half(), tgt) for lp, tgt in map(logprobs, ts)] for lang, ts in texts.items()} # giữ fp16 cho vừa VRAM
def evaluate():
res = {}
for lang, ts in texts.items():
nll = kl = flips = n_tok = n_byte = 0
for t, (lp_ref, tgt) in zip(ts, ref[lang]):
lp, _ = logprobs(t)
lp_ref = lp_ref.float()
nll += -lp.gather(1, tgt[:, None]).sum().item()
kl += (lp_ref.exp() * (lp_ref - lp)).sum().item() # KL(p_ref ‖ p_q)
flips += (lp.argmax(-1) != lp_ref.argmax(-1)).sum().item() # top-1 đổi so với bản gốc
n_tok += tgt.numel(); n_byte += len(t.encode("utf-8"))
res[lang] = (nll / 0.6931471805599453 / n_byte, kl / n_tok, flips / n_tok)
return res
configs = [("fp32 (gốc)", None, None), ("int8 / channel", "int8", "channel"),
("int4 / g128", "int4", 128), ("nf4 / g128", "nf4", 128),
("int4 / g64", "int4", 64), ("nf4 / g64", "nf4", 64),
("int4 / channel", "int4", "channel"), ("int4 / tensor", "int4", "tensor")]
print(f"{'cấu hình':<16}{'về 0':>7}{'bpb en':>8}{'bpb vi':>8}{'KL en':>8}{'KL vi':>8}{'flip en':>9}{'flip vi':>9}")
for label, kind, group in configs:
for n, m in linears.items():
m.weight.data = original[n] if kind is None else fake_quant(original[n], kind, group)
zero = sum((m.weight == 0).sum().item() for m in linears.values()) / sum(w.numel() for w in original.values())
r = evaluate()
print(f"{label:<16}{zero:>7.1%}{r['en'][0]:>8.3f}{r['vi'][0]:>8.3f}{r['en'][1]:>8.3f}{r['vi'][1]:>8.3f}"
f"{r['en'][2]:>9.1%}{r['vi'][2]:>9.1%}")
Output khi chạy:
cấu hình về 0 bpb en bpb vi KL en KL vi flip en flip vi
fp32 (gốc) 0.0% 0.864 0.905 0.000 0.000 0.0% 0.0%
int8 / channel 1.5% 0.864 0.904 0.010 0.008 3.1% 4.2%
int4 / g128 18.5% 0.942 1.024 0.337 0.445 27.6% 37.8%
nf4 / g128 11.2% 0.913 0.966 0.212 0.254 21.0% 28.2%
int4 / g64 16.6% 0.925 0.984 0.258 0.336 24.1% 32.7%
nf4 / g64 10.0% 0.907 0.956 0.206 0.227 19.7% 25.5%
int4 / channel 25.5% 1.042 1.215 0.693 1.074 43.0% 58.4%
int4 / tensor 86.1% 5.305 5.181 15.678 14.196 100.0% 100.0%
Đọc output theo từng dòng:
- INT8 theo kênh gần như không mất gì về bpb (0,864 giữ nguyên, 0,905 thành 0,904), KL chỉ 0,01. Nhưng 3–4% vị trí vẫn đổi token top-1. Đó là các vị trí mà hai token đứng đầu gần ngang nhau, và một xáo trộn nhỏ là đủ đảo thứ tự.
- Ở 4 bit, cách chia nhóm quyết định tất cả. Cùng là INT4: theo nhóm 64 thì bpb tiếng Anh tăng 7%, theo kênh tăng 21%, theo cả tensor thì model hỏng hoàn toàn: bpb từ 0,86 lên 5,3, mọi token top-1 đều đổi. Cột “về 0” giải thích vì sao: với một scale cho cả ma trận, vài weight có độ lớn bất thường kéo giãn lưới đến mức 86% weight bị làm tròn về 0. Đây là phiên bản weight của chuyện outlier ở mục 5. LLM.int8() nói về outlier trong activation, còn ở đây là vài weight lớn bất thường, nhưng cơ chế giống nhau: một scale chung cho quá nhiều giá trị.
- NF4 tốt hơn INT4 ở cùng kích thước nhóm: ở nhóm 64, KL 0,206 so với 0,258 cho tiếng Anh, 0,227 so với 0,336 cho tiếng Việt. Chi phí bit của hai bên như nhau, cùng 4 bit cộng một scale mỗi nhóm. Lưới đặt theo phân phối chuẩn đặt nhiều mức hơn vào vùng có nhiều weight, nên ít weight bị làm tròn về 0 hơn (10% so với 16,6%).
- Perplexity che mất nhiều thay đổi. NF4 nhóm 64 chỉ làm bpb tiếng Anh tăng 5%, một con số nghe có vẻ chấp nhận được, nhưng gần 20% vị trí đổi token top-1.
- Tiếng Việt chịu thiệt nhiều hơn tiếng Anh ở mọi cấu hình 4 bit còn dùng được (trừ bản theo tensor đã hỏng hẳn), theo cả ba thước đo. Với INT4 theo kênh, bpb tiếng Việt tăng 34% trong khi tiếng Anh tăng 21%; flip 58% so với 43%. Đây là cùng một nội dung ở hai ngôn ngữ, nên khác biệt không đến từ độ khó của văn bản.
Ba giới hạn cần nói rõ. Đây là model 0,5B, nhạy với lượng tử hơn nhiều so với model 7B trở lên. Fake-quant tự viết không có các kỹ thuật như GPTQ hay AWQ, nên là cận dưới của chất lượng 4-bit. Và flip top-1 ở đây tính trên mọi vị trí của văn bản, không phải trên đáp án của một tác vụ, nên không trực tiếp cho biết model trả lời sai thêm bao nhiêu câu.
Bằng chứng từ các paper
Các paper đo trên model lớn hơn và tác vụ thật cho cùng bức tranh:
- Accuracy che flip. Dutta et al. 2024 (“Accuracy is Not All You Need”) đo tỉ lệ flip: phần trăm câu đổi từ đúng sang sai và từ sai sang đúng giữa model gốc và model nén (mục 2). Accuracy chỉ lệch 0–2% trong khi flip lên tới 13,6% (mục 1, Hình 1), vì hai chiều flip bù cho nhau trong con số trung bình. Trên MMLU, lượng tử 4 bit bằng bitsandbytes cho 8–16% flip; trên GSM8K là 12–30%, con số gộp cả BnB 8 bit và 4 bit (mục 4.1, 4.4).
- Ngôn ngữ không dùng chữ Latin chịu thiệt nhiều hơn, và thước đo tự động đánh giá thấp mức suy giảm. Marchisio et al. 2024 thấy ảnh hưởng nặng nhất ở các ngôn ngữ không dùng chữ Latin, và mức giảm trung bình 1,7% trên tác vụ tự động bằng tiếng Nhật tương ứng với mức giảm 16,0% khi người chấm đánh giá trên prompt thực tế (abstract; mục 4.7, Bảng 8).
- Model train trên nhiều token hơn thì nhạy hơn. Huang et al. 2024 thấy Llama 3 suy giảm đáng kể ở bit thấp (abstract). Cùng phép lượng tử RTN 4 bit nhóm 128, perplexity WikiText2 của LLaMA3-8B tăng từ 6,1 lên 8,5, trong khi LLaMA2-7B chỉ tăng từ 5,5 lên 5,7 (bản v3, Bảng 1 ở mục 2 và Bảng 5). Kumar et al. 2024 đưa hiện tượng này vào scaling law: thêm dữ liệu pretrain có thể làm model sau khi lượng tử tệ đi (abstract, mục 3.1, Hình 2); thí nghiệm của họ chỉ tới 1,7B tham số và 26B token, nên đây là xu hướng, chưa phải con số cho model lớn. Đây là mặt trái của xu hướng train vượt xa Chinchilla-optimal ở bài 10, mục 6.
- Ở chiều ngược lại, 4 bit thường là điểm cân bằng tốt. Dettmers & Zettlemoyer 2022 cho thấy nếu cố định tổng số bit, model lớn ở 4 bit gần như luôn tốt hơn model nhỏ ở 16 bit (abstract, Hình 1). Xuống 3 bit thì quan hệ này đảo ngược.
Checklist trước khi dùng một bản quantized
- Đo trên tác vụ của mình, không chỉ perplexity hay một benchmark chung. Nếu ứng dụng là tiếng Việt, đo bằng tiếng Việt.
- Đo flip, không chỉ accuracy: chạy cùng tập câu hỏi qua bản gốc và bản quantized, đếm số câu đổi đáp án theo cả hai chiều.
- So cùng chi phí bit: so
Q4_K_MvớiQ4_K_Mcủa model khác, hoặc so model lớn ở 4 bit với model nhỏ ở 16 bit cùng bộ nhớ. - Kiểm tra định dạng output và tác vụ dài: gọi tool, JSON, suy luận nhiều bước thường hỏng trước các tác vụ hỏi–đáp ngắn.
- Nếu tự lượng tử, giữ lại tập calibration và ghi rõ phương pháp, kích thước nhóm, phiên bản thư viện, để sau này lặp lại được.
09Những chỗ dễ hiểu sai
Hai hàng ngoài cùng của Hình 1 là tám hiểu lầm, mỗi cái nối với bước mà nó hiểu sai:
| Hiểu lầm | Thực tế |
|---|---|
| “LoRA làm model có hạng thấp” | Chỉ phần cập nhật ΔW = B·A có hạng thấp. W₀ giữ nguyên, và W₀ + B·A sau merge nói chung có hạng đầy đủ |
| “LoRA luôn kém hơn”, hoặc “LoRA luôn bằng full fine-tuning” | Tuỳ lượng dữ liệu: ngang với SFT cỡ vừa và RL; kém hơn khi dạy miền mới với rất nhiều token, nhưng quên ít hơn |
| “r càng lớn càng tốt” | r nhỏ thường đủ; gắn vào đúng lớp, nhất là FFN, quan trọng hơn tăng r |
| “4-bit nghĩa là đúng 4 bit mỗi weight” | Scale theo nhóm tốn thêm bit, một số tensor giữ nhiều bit hơn: Q4_K_M trung bình 4,89 bit, NF4 + DQ khoảng 4,127 bit |
| “GGUF là một phương pháp quantization” | GGUF là định dạng file của llama.cpp, bên trong có nhiều kiểu lượng tử (Q4_K_M, Q8_0, IQ…) |
| “Quantization luôn làm model nhanh hơn” | Decode nhanh hơn vì bị giới hạn bởi băng thông bộ nhớ; prefill có thể chậm đi vì phải dequantize; QLoRA train chậm hơn LoRA thường khoảng 25% ở mục 7 |
| “Perplexity gần như không đổi là model không mất gì” | Ở NF4 nhóm 64, bpb tiếng Anh chỉ tăng 5% nhưng gần 20% vị trí đổi token top-1, và tiếng Việt mất nhiều hơn tiếng Anh |
| “QLoRA cho ra một model 4-bit” | QLoRA cho ra một adapter 16 bit, train trên base 4-bit. Dùng nó với base nào là một quyết định riêng, mỗi cách một cái giá |
Tổng kết: hai núm vặn, mỗi núm cắt một phần bộ nhớ
Full fine-tuning một model 8B cần khoảng 128 GB chỉ cho model states. Có hai cách cắt con số đó.
- LoRA cắt phần gradient, master weights và Adam state của base, 12 trên 16 byte mỗi tham số, bằng cách đóng băng
W₀và chỉ họcΔW = (α/r)·B·A.B = 0lúc khởi tạo nên train bắt đầu đúng từ base; merge xong thì inference không chậm thêm. ΔWcủa full fine-tuning thật thường không có hạng thấp theo nghĩa năng lượng: với Qwen2.5-0.5B-Instruct,mlp.down_projcần gần 700 trên 896 giá trị kỳ dị để giữ 90% năng lượng. Nhưng phần quan trọng cho hành vi tập trung ở vài hướng đầu: giữ hạng 8 (7,6% năng lượng) đã giảm một nửa KL tới Instruct. Vì vậy cập nhật hạng thấp đủ tốt khi dữ liệu vừa phải, kém hơn khi dạy cả một miền mới.- Gắn LoRA vào mọi lớp tuyến tính, nhất là FFN; dùng learning rate cao hơn full fine-tuning khoảng 10 lần; với DPO, tắt adapter là có reference.
- Quantization cắt phần weights từ 2 byte xuống khoảng 0,5 byte. Phần khó là outlier, và mọi phương pháp tốt đều dùng scale theo nhóm nhỏ: “4-bit” thực tế là 4,1–4,9 bit.
- GPTQ và AWQ là thuật toán, GGUF là định dạng file. Weight-only giúp decode nhanh hơn vì decode bị giới hạn bởi băng thông bộ nhớ, nhưng không giúp prefill.
- QLoRA ghép cả hai: base NF4 với double quantization, adapter 16 bit, paged optimizer. Trên RTX 2080 Ti, Qwen2.5-1.5B train với VRAM đỉnh 5,46 GiB, chậm hơn LoRA thường khoảng 25%.
- Cái giá của quantization phải đo trên chính tác vụ và ngôn ngữ của mình. Perplexity gần như giữ nguyên trong khi hàng chục phần trăm token top-1 đã đổi, và tiếng Việt chịu thiệt nhiều hơn tiếng Anh.
Weights giờ đã nhỏ đi bốn lần. Nhưng khi phục vụ nhiều người dùng cùng lúc, bộ nhớ lại bị chiếm bởi KV cache, tăng theo số request và độ dài context. Và inference chia thành hai pha rất khác nhau, prefill bị giới hạn bởi compute và decode bị giới hạn bởi băng thông bộ nhớ, như bảng llama.cpp ở mục 6 đã hé lộ. Bài 13 sẽ đi vào hai pha đó.
Tài liệu tham khảo
Paper
- Houlsby et al. 2019 — Parameter-Efficient Transfer Learning for NLP
- Bengio et al. 2013 — Estimating or Propagating Gradients Through Stochastic Neurons for Conditional Computation
- Jacob et al. 2018 — Quantization and Training of Neural Networks for Efficient Integer-Arithmetic-Only Inference
- Aghajanyan et al. 2020 — Intrinsic Dimensionality Explains the Effectiveness of Language Model Fine-Tuning
- Li & Liang 2021 — Prefix-Tuning: Optimizing Continuous Prompts for Generation
- Lester et al. 2021 — The Power of Scale for Parameter-Efficient Prompt Tuning
- Hu et al. 2021 — LoRA: Low-Rank Adaptation of Large Language Models
- Dettmers et al. 2022 — LLM.int8(): 8-bit Matrix Multiplication for Transformers at Scale
- Frantar et al. 2022 — GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers
- Xiao et al. 2022 — SmoothQuant: Accurate and Efficient Post-Training Quantization for Large Language Models
- Dettmers & Zettlemoyer 2022 — The case for 4-bit precision: k-bit Inference Scaling Laws
- Dettmers et al. 2023 — QLoRA: Efficient Finetuning of Quantized LLMs
- Lin et al. 2023 — AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration
- Sheng et al. 2023 — S-LoRA: Serving Thousands of Concurrent LoRA Adapters
- Kalajdzievski 2023 — A Rank Stabilization Scaling Factor for Fine-Tuning with LoRA (rsLoRA)
- Liu et al. 2024 — DoRA: Weight-Decomposed Low-Rank Adaptation
- Hayou et al. 2024 — LoRA+: Efficient Low Rank Adaptation of Large Models
- Meng et al. 2024 — PiSSA: Principal Singular Values and Singular Vectors Adaptation of Large Language Models
- Huang et al. 2024 — An Empirical Study of LLaMA3 Quantization: From LLMs to MLLMs (bản v1 có tên “How Good Are Low-bit Quantized LLaMA3 Models? An Empirical Study”)
- Biderman et al. 2024 — LoRA Learns Less and Forgets Less
- Marchisio et al. 2024 — How Does Quantization Affect Multilingual LLMs?
- Dutta et al. 2024 — Accuracy is Not All You Need
- Kumar et al. 2024 — Scaling Laws for Precision
- Qwen Team 2024 — Qwen2.5 Technical Report
Blog và tài liệu
- John Schulman và Thinking Machines Lab — LoRA Without Regret (2025)
- Hugging Face — PEFT: LoRA và tham chiếu
LoraConfig - Hugging Face — bitsandbytes và Quantization trong
transformers - Hugging Face — TRL: DPO Trainer
- llama.cpp — README của
llama-quantize(commit5e03bdd, 06/10/2026) và PR #1684: k-quants - Hugging Face — Qwen/Qwen2.5-0.5B, Qwen/Qwen2.5-0.5B-Instruct, Qwen/Qwen2.5-1.5B
- Hugging Face — dataset facebook/belebele (đoạn văn FLORES-200) và Salesforce/wikitext