QLoRA
QLoRA - LoRA lượng tử hóa
Mô hình 70 tỷ tham số ở FP16 chiếm 140GB VRAM cho inference. Full fine-tuning cần ~840GB. Bạn chỉ có 1 GPU 48GB. Có thể fine-tune được không?
Ẩn dụ: Bảo tàng tranh và lớp kính nhận xét
Hãy tưởng tượng bạn là một học viên được giao bài tập "chỉnh lại" một bảo tàng 70.000 bức tranh khổng lồ (mô hình 70B). Phòng làm việc của bạn (GPU) chỉ rộng bằng 1/3 bảo tàng. Có ba chiến lược:
Full Fine-tuning — sơn lại mọi bức
Bạn mang từng bức tranh gốc về phòng, sơn lại, rồi trả về. Cần kho lớn gấp 4 lần bảo tàng (tranh gốc + bản vẽ thử + giấy nháp Adam). Không khả thi.
LoRA — đặt tấm kính lên mỗi bức
Bạn không sửa bức gốc. Thay vào đó, đặt một lớp kính mỏng trong suốt lên mỗi bức, chỉ vẽ những chi tiết cần thay đổi lên kính. Kho cần = bảo tàng + vài kg kính. Vẫn hơi chật.
QLoRA — chụp ảnh nhỏ bức gốc + kính
Bạn chụp ảnh 4K (NF4) của mỗi bức để tham khảo, giữ bức gốc trong kho khác. Lớp kính vẫn ở độ nét cao. Khi cần so sánh, phóng ảnh lên (dequantize). Phòng vừa.
Nén bức tranh gốc (quantize 4-bit) để vừa căn phòng nhỏ, rồi vẽ chi tiết mới trên lớp kính mỏng (LoRA adapter). Đó chính là QLoRA.
Hình minh họa
Phân tích bộ nhớ QLoRA — tương tác
Chọn phương pháp, kích thước mô hình, và GPU. Xem cột bộ nhớ thay đổi và kiểm tra xem có vừa VRAM không.
Phân tích bộ nhớ khi fine-tune Llama 70B (chú thích: GB)
QLoRA (NF4 base + LoRA FP16)
Base model nén xuống NF4 (4-bit). LoRA adapter FP16 học delta. Paged optimizer xử lý spike bộ nhớ.
Vừa: 1×A100 48GB (hoặc 1×RTX 6000 Ada) · Chất lượng: 99% · Tốc độ tương đối: 0.55x
Thử kích thước mô hình & GPU — xem có vừa không
Full FT
Cần: 840 GB
VRAM: 80 GB
Tràn bộ nhớ (OOM)
Thiếu: 760.0 GB
LoRA
Cần: 152 GB
VRAM: 80 GB
Tràn bộ nhớ (OOM)
Thiếu: 72.0 GB
QLoRA
Cần: 48 GB
VRAM: 80 GB
Vừa thoải mái
Headroom: 32.0 GB
Layout NF4 — 16 mức lượng tử theo phân bố chuẩn
Xanh lá: 16 mức NF4 đặt đặc ở gần 0 (nơi mật độ trọng số cao nhất), thưa ở đuôi. Đỏ: 16 mức INT4 đều. Cùng 4 bit, NF4 biểu diễn chính xác hơn vùng quan trọng.
Double Quantization — nén luôn hằng số scale
Mỗi block 64 trọng số có 1 hằng số scale ở FP32 (32 bit). Nếu nén thêm các scale này xuống FP8 (mỗi 256 block 1 scale phụ), ta tiết kiệm ~0.37 bit/tham số.
Số bit hiệu dụng / tham số
4.13 bit
Tiết kiệm thêm (70B)
3.3 GB
Tại sao QLoRA dùng NF4 (NormalFloat 4-bit) thay vì INT4 thông thường?
Bạn muốn fine-tune Llama 33B bằng QLoRA. VRAM tối thiểu cần là bao nhiêu?
Giải thích
Định nghĩa. QLoRA (Quantized LoRA, Dettmers et al. 2023) là phương pháp parameter-efficient fine-tuning: nén base model xuống NF4 (NormalFloat 4-bit) và đóng băng, rồi huấn luyện LoRA adapter ở BF16 để học delta thích ứng. Cần ba đổi mới:
- NF4 datatype — kiểu 4-bit tối ưu information-theoretic cho tensor có phân bố chuẩn.
- Double Quantization — quantize tiếp các hằng số quantization, tiết kiệm thêm ~0.37 bit/tham số.
- Paged Optimizers — dùng NVIDIA Unified Memory để tránh OOM khi có gradient spike bất thường.
Công thức — bộ nhớ lưu trọng số sau NF4:
Trong đó là số tham số và là kích thước block (mặc định 64). Với : base , scale overhead .
Với Double Quantization: hằng số scale FP32 được quantize xuống FP8 với scale thứ cấp mỗi 256 block:
Tiết kiệm ~0.37 bit/param, tương đương ~3 GB trên 70B.
Forward pass QLoRA: cho mỗi layer linear với đóng băng và adapter , :
Gradient chỉ chảy vào . Base model hoàn toàn đóng băng nhưng vẫn dequantize mỗi forward — đó là lý do QLoRA chậm hơn LoRA thuần.
Ví dụ code 1 — PEFT + bitsandbytes (QLoRA chuẩn):
# pip install "transformers>=4.41" "peft>=0.11" "bitsandbytes>=0.43" accelerate datasets
import torch
from transformers import (
AutoModelForCausalLM, AutoTokenizer,
BitsAndBytesConfig, TrainingArguments,
)
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer
from datasets import load_dataset
MODEL_ID = "meta-llama/Llama-3-70B"
# Bước 1: Cấu hình QLoRA — NF4 + Double Quantization + BF16 compute
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4", # NormalFloat 4-bit (không phải "fp4"!)
bnb_4bit_use_double_quant=True, # tiết kiệm thêm ~0.37 bit/param
bnb_4bit_compute_dtype=torch.bfloat16, # tính toán bằng BF16 sau dequant
)
# Bước 2: Load base model ở 4-bit
model = AutoModelForCausalLM.from_pretrained(
MODEL_ID,
quantization_config=bnb_config,
device_map="auto",
torch_dtype=torch.bfloat16,
trust_remote_code=False,
)
# 70B × 4.125 bit = ~36GB + overhead ≈ 38GB VRAM cho weights
# Bước 3: Chuẩn bị cho k-bit training (cast LN sang FP32, bật gradient checkpointing)
model = prepare_model_for_kbit_training(
model, use_gradient_checkpointing=True
)
# Bước 4: Gắn LoRA adapter ở BF16 — chỉ phần này có gradient
lora_config = LoraConfig(
r=16, # rank; 8-64 là phổ biến
lora_alpha=32, # thường = 2 × r
target_modules=[ # Llama/Mistral: thường đủ 7 proj
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj",
],
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# trainable: ~30M (0.04% của 70B) → gradient + optimizer rất nhẹ
# Bước 5: Training args — dùng paged optimizer
training_args = TrainingArguments(
output_dir="./qlora-llama3-70b",
per_device_train_batch_size=1,
gradient_accumulation_steps=16,
learning_rate=2e-4, # LR cao hơn full FT vì adapter nhỏ
max_steps=1000,
optim="paged_adamw_8bit", # PAGED — trick tránh OOM
lr_scheduler_type="cosine",
warmup_ratio=0.03,
bf16=True, # tính toán BF16
logging_steps=10,
save_steps=200,
gradient_checkpointing=True,
)
# Bước 6: Train
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
dataset = load_dataset("timdettmers/openassistant-guanaco", split="train")
trainer = SFTTrainer(
model=model,
tokenizer=tokenizer,
train_dataset=dataset,
args=training_args,
dataset_text_field="text",
max_seq_length=2048,
peft_config=lora_config,
)
trainer.train()
# Bước 7: Lưu — chỉ adapter ~100MB
model.save_pretrained("./qlora-adapter")
# Inference sau này: load base model (có thể 4-bit) + merge adapterVí dụ code 2 — bitsandbytes low-level (hiểu NF4 dequant):
import torch
import bitsandbytes as bnb
from bitsandbytes.functional import quantize_nf4, dequantize_nf4
# Giả lập 1 layer linear weight 4096x4096 (giống Llama hidden size)
W_fp16 = torch.randn(4096, 4096, dtype=torch.float16, device="cuda") * 0.02
# Quantize sang NF4 — block size 64
W_nf4, quant_state = quantize_nf4(W_fp16, blocksize=64)
print(f"FP16 size: {W_fp16.nelement() * 2 / 1e6:.2f} MB")
print(f"NF4 size: {W_nf4.nelement() / 2 / 1e6:.2f} MB (chia 2 vì pack 2 val/byte)")
print(f"→ Tỉ lệ nén: {2 * W_fp16.nelement() / W_nf4.nelement():.2f}x")
# Dequantize lại — chỉ dùng khi cần tính toán
W_recon = dequantize_nf4(W_nf4, quant_state)
err = (W_fp16 - W_recon).abs().mean().item()
print(f"Sai số trung bình |W - W_recon|: {err:.5f}")
print(f"Tương đối: {err / W_fp16.abs().mean().item() * 100:.2f}%")
# Forward giả: x @ W — cần dequant trước
x = torch.randn(1, 4096, dtype=torch.float16, device="cuda")
y_fp16 = x @ W_fp16
y_nf4 = x @ W_recon
print(f"|y_fp16 - y_nf4| mean: {(y_fp16 - y_nf4).abs().mean():.5f}")
# Trong thực tế, bnb.nn.Linear4bit tự làm bước dequant trong CUDA kernel
layer = bnb.nn.Linear4bit(
4096, 4096,
bias=False,
compute_dtype=torch.bfloat16,
quant_type="nf4",
)
layer.weight.data = W_fp16 # trước khi .cuda() được tự quantizeprepare_model_for_kbit_training cast LN về FP32.Ứng dụng thực tế:
- Indie fine-tuning: cá nhân / startup fine-tune Llama 70B cho domain tiếng Việt, y tế, luật trên 1 RTX 6000 Ada 48GB.
- Enterprise SFT: nội bộ công ty, thay vì cluster 8 GPU, chỉ cần 1-2 A100 cho fine-tune hàng loạt.
- Research prototyping: thử nghiệm nhanh ý tưởng trên model lớn mà không chờ queue cluster.
- Domain adapter hub: mỗi adapter chỉ ~100MB — có thể host hàng ngàn adapter chuyên biệt trên 1 base model.
- Instruction tuning: Guanaco, OpenAssistant, Alpaca-style dataset — hầu hết open-source đều train bằng QLoRA.
Pitfall thường gặp:
- Quên prepare_model_for_kbit_training: LN và LM head phải cast FP32/FP16 để gradient stable. Thiếu bước này → NaN loss sau vài step.
- Target modules sai: chỉ gắn LoRA vào q_proj/v_proj bỏ qua MLP → chất lượng kém 2-3%. Thực nghiệm: gắn đủ 7 proj tốt hơn hẳn.
- Batch size quá lớn: gradient checkpointing + paged optimizer vẫn có giới hạn. Bắt đầu với batch 1 + grad accum 16-32.
- Dùng SDPA sai kernel: flash-attention-2 không hỗ trợ 4-bit trực tiếp — phải dùng fallback. Đo speed trước khi ép buộc attention backend.
- Merge adapter sai cách: merge vào base NF4 sẽ mất chất lượng (quantize 2 lần). Luôn merge vào base FP16/BF16.
- LR sai: LR cho QLoRA thường 2e-4 đến 3e-4 — cao hơn full FT (1e-5) vì adapter nhỏ. Dùng LR full-FT → underfit nặng.
- QLoRA = Quantize base model xuống NF4 (4-bit) + LoRA adapter BF16 — tiết kiệm ~70% VRAM so với LoRA FP16, ~90% so với full fine-tuning.
- Ba đổi mới cốt lõi: NF4 (info-theoretic optimal cho Gaussian), Double Quantization (~0.37 bit/param extra), Paged Optimizers (chống OOM spike).
- Cho phép fine-tune 70B trên 1 GPU 48GB, 33B trên RTX 4090 24GB — dân chủ hoá fine-tuning LLM lớn cho cá nhân và startup.
- Trade-off: chậm hơn LoRA thuần 30-50% do dequant-on-the-fly, nhưng chất lượng gần tương đương full FT (Guanaco 65B đạt 99.3% ChatGPT).
- Pipeline chuẩn: BitsAndBytesConfig(load_in_4bit, nf4, double_quant, bf16 compute) + prepare_model_for_kbit_training + LoRA(r=16) + paged_adamw_8bit.
- Pitfall: quên prepare k-bit training, target modules thiếu MLP, LR quá thấp (dùng 2e-4 chứ không phải 1e-5), merge adapter vào base NF4.
Kiểm tra hiểu biết
QLoRA kết hợp hai kỹ thuật nào?