Trong software engineering, có một pattern phổ biến: thay vì viết lại class lớn, ta tạo một adapter nhỏ wrap nó. Decorator trong Python, middleware trong Express, mixin trong Django đều là biến thể của ý tưởng “không sửa core, chỉ thêm layer mỏng”.
LoRA là chính xác cùng pattern, áp dụng cho LLM. Model gốc 8 tỷ params đóng băng. Ta thêm vào đó vài adapter matrix nhỏ (vài triệu params), chỉ train phần adapter. Output: cùng quality fine-tune, nhưng tốn 1.5% memory và 1.5% disk.
Năm 2021, Microsoft publish paper “LoRA: Low-Rank Adaptation of Large Language Models”. Năm 2023, một team UWashington publish QLoRA, thêm 4-bit quantization vào LoRA. Hai paper này đã democratize fine-tuning: bạn có thể fine-tune Llama-3-8B trên một con RTX 3060 12GB ở nhà.
Bài này giải thích cơ chế bên dưới, code triển khai bằng peft, và compare LoRA / QLoRA / full fine-tune.
Mental model: low-rank update là gì
Một matrix W có shape [d, k] có rank tối đa min(d, k). Rank là “chiều thực sự” của thông tin trong matrix. Một matrix [1000, 1000] có thể có rank 1000 (full rank) hoặc rank 5 (gần như chỉ chứa 5 chiều thông tin).
Phát hiện của LoRA paper:
Khi fine-tune một model, sự thay đổi của weight
ΔWcó intrinsic rank thấp.
Tức là ΔW = W_finetuned - W_pretrained thực sự chỉ có vài chiều “có ích”, phần còn lại gần như zero. Ta không cần lưu cả ma trận ΔW đầy đủ, chỉ cần lưu một biểu diễn rank thấp.
Decompose:
ΔW [d x k] = B [d x r] @ A [r x k]
Với r << min(d, k). Ví dụ d = k = 4096, ta chọn r = 8. Thay vì lưu 4096 * 4096 = 16M params, ta lưu 4096 * 8 + 8 * 4096 = 65K params. Giảm 246 lần.
Visual:
W (pretrained, frozen) ΔW (trainable)
+--------+ +--+
| | |B |
| d x k | + | | @ +----------+
| | | | | A |
| | | | | r x k |
+--------+ +--+ +----------+
d x k d x r
Forward pass:
y = x @ W + x @ B @ A (B@A là ΔW)
Trong training:
Wđóng băng, không update.A,Btrainable, update qua optimizer.
Trong serving:
- Có thể keep adapter tách (swap khác adapter cho task khác)
- Hoặc merge:
W' = W + B @ A, deploy single matrixW'(no inference overhead)
Phần 1: Tại sao LoRA hoạt động
Paper original của Microsoft (Hu et al., 2021) train một loạt experiment, mỗi cái với rank r khác nhau. Kết quả: r = 8 đủ tốt cho hầu hết task. Tăng lên 16, 32, 64 cải thiện rất ít.
Lý do trực giác: pretrained LLM đã học rất nhiều “skill general”. Khi fine-tune cho task cụ thể (ví dụ Vietnamese instruction following), ta chỉ cần “twist” một vài chiều, không cần rebuild từ đầu. Vài chiều đó = rank thấp.
Một analogy: pretrained model là một thư viện 10 triệu hàm. Fine-tune cho task X là viết một wrapper 1000 dòng để call đúng các hàm có sẵn cho X. Không cần viết lại 10 triệu hàm.
So sánh memory:
| Approach | Trainable params (Llama-3-8B) | VRAM training | Adapter disk |
|---|---|---|---|
| Full fine-tune | 8B (100%) | 60-80GB | 32GB |
| LoRA r=8 | 7M (0.09%) | 24GB | 28MB |
| LoRA r=16 | 14M (0.18%) | 26GB | 56MB |
| LoRA r=64 | 56M (0.7%) | 30GB | 224MB |
| QLoRA r=64 | 56M (0.7%) | 8-10GB | 224MB |
LoRA cắt VRAM ~70%. QLoRA cắt thêm 60% nữa.
Phần 2: Triển khai LoRA bằng PyTorch thuần
Trước khi dùng peft library, hãy code LoRA layer từ zero để hiểu:
import torch
import torch.nn as nn
class LoRALinear(nn.Module):
def __init__(self, original_linear, r=8, alpha=16):
super().__init__()
self.original = original_linear
self.original.weight.requires_grad = False
if self.original.bias is not None:
self.original.bias.requires_grad = False
in_features = original_linear.in_features
out_features = original_linear.out_features
self.lora_A = nn.Parameter(torch.zeros(r, in_features))
self.lora_B = nn.Parameter(torch.zeros(out_features, r))
nn.init.kaiming_uniform_(self.lora_A, a=5**0.5)
self.scaling = alpha / r
def forward(self, x):
original_out = self.original(x)
lora_out = x @ self.lora_A.T @ self.lora_B.T
return original_out + lora_out * self.scaling
Hai điểm cần ý:
Init lora_A random, lora_B zero. Tại sao? Vì lúc bắt đầu training, ta muốn B @ A = 0 để model không bị xáo trộn ngay. Sau đó training sẽ từ từ làm B khác zero.
Scaling alpha / r. Hyperparameter alpha (thường = 2*r hoặc = r) cho phép tách rank r và “magnitude” của adapter. Cho phép tune độc lập.
Apply LoRA vào model:
def apply_lora_to_model(model, r=8, alpha=16):
for name, module in model.named_modules():
if isinstance(module, nn.Linear) and "qkv" in name:
parent_name = name.rsplit(".", 1)[0]
parent = model.get_submodule(parent_name) if parent_name else model
child_name = name.rsplit(".", 1)[-1]
new_module = LoRALinear(module, r=r, alpha=alpha)
setattr(parent, child_name, new_module)
return model
Code trên replace mọi nn.Linear có “qkv” trong name (typical attention projection). Trong thực tế, paper LoRA suggest apply LoRA vào q_proj và v_proj của attention layer, không cần k_proj hay o_proj hay FFN.
Phần 3: Dùng peft library
Code raw ở trên hữu ích để hiểu, nhưng production thì dùng peft:
pip install peft transformers bitsandbytes
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig, get_peft_model, TaskType
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Meta-Llama-3-8B",
torch_dtype=torch.bfloat16,
device_map="auto",
)
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Meta-Llama-3-8B")
lora_config = LoraConfig(
task_type=TaskType.CAUSAL_LM,
r=8,
lora_alpha=16,
lora_dropout=0.05,
bias="none",
target_modules=["q_proj", "v_proj"],
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
Output:
trainable params: 6,815,744 || all params: 8,036,667,392 || trainable%: 0.0848
8M trainable trên 8B total. 0.08%.
Training loop dùng Trainer của HuggingFace như bình thường:
from transformers import Trainer, TrainingArguments
trainer = Trainer(
model=model,
args=TrainingArguments(
output_dir="./lora-out",
per_device_train_batch_size=4,
gradient_accumulation_steps=8,
num_train_epochs=3,
learning_rate=2e-4,
bf16=True,
logging_steps=10,
save_steps=100,
),
train_dataset=train_dataset,
)
trainer.train()
Save adapter:
model.save_pretrained("./lora-adapter")
Output folder ~28MB. Đây là adapter, ta share cho người khác mà không cần share full model 16GB.
Load adapter để inference:
from peft import PeftModel
base = AutoModelForCausalLM.from_pretrained("meta-llama/Meta-Llama-3-8B")
model = PeftModel.from_pretrained(base, "./lora-adapter")
Hoặc merge để deploy:
model = model.merge_and_unload()
model.save_pretrained("./merged-model")
Sau merge, không có overhead adapter ở inference time.
Phần 4: QLoRA, thêm 4-bit quantization
QLoRA (Dettmers et al., 2023) thêm vào LoRA hai trick:
1. Quantize base model xuống 4-bit (NF4). Llama-3-8B BF16 = 16GB. NF4 = 4GB. Giảm 4 lần.
2. Backprop qua quantized weight. Đây là phần khó. Quantized weight không có gradient. QLoRA giữ adapter ở BF16 (vì adapter mới là phần được train), backprop chỉ qua adapter. Base model dùng để tính forward + activation, gradient không flow back vào base.
3. Paged optimizer. Khi VRAM gần đầy, swap optimizer state ra system RAM. Tránh OOM lúc memory spike.
Code:
from transformers import BitsAndBytesConfig
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Meta-Llama-3-8B",
quantization_config=bnb_config,
device_map="auto",
)
from peft import prepare_model_for_kbit_training
model = prepare_model_for_kbit_training(model)
lora_config = LoraConfig(
task_type=TaskType.CAUSAL_LM,
r=64,
lora_alpha=16,
lora_dropout=0.05,
bias="none",
target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
)
model = get_peft_model(model, lora_config)
Khác biệt với LoRA thường:
BitsAndBytesConfigload model ở 4-bit NF4.prepare_model_for_kbit_training()thiết lập forward đúng cho quantized model.r=64cao hơn LoRA thường để bù precision loss của quantization.- Target nhiều module hơn (tất cả linear trong block), không chỉ q_proj/v_proj.
Memory thực tế khi training Llama-3-8B với QLoRA:
| Component | Size |
|---|---|
| Base model NF4 | ~4 GB |
| Adapter BF16 (r=64) | ~0.1 GB |
| Gradient adapter | ~0.1 GB |
| Optimizer state (AdamW) | ~0.4 GB |
| Activation (batch=4, seq=2048) | ~3-4 GB |
| Total | ~8 GB |
Fit trên RTX 3060 12GB hoặc Colab free T4 16GB.
Phần 5: Khi nào dùng cái nào
| Setup | Use case |
|---|---|
| Full fine-tune | Có 80GB+ VRAM, cần maximum quality, model nhỏ < 1B |
| LoRA r=8 | Quick experiment, validate task khả thi |
| LoRA r=32-64 | Production fine-tune, balance quality và resource |
| QLoRA r=64 | Consumer GPU, hobby project, không có A100 |
| QLoRA r=128 | Maximum quality trên consumer GPU |
So sánh quality (paper QLoRA report):
| Method | MMLU score (Llama2-65B fine-tune) |
|---|---|
| Full fine-tune | 63.5 |
| LoRA r=64 | 63.1 (gần như giống) |
| QLoRA r=64 | 63.0 (chỉ thua 0.1) |
Quality gap thực tế của QLoRA vs full fine-tune < 1% cho hầu hết task. Trade-off rất hấp dẫn.
Pitfall: target_modules sai
Một dev mới làm LoRA hay default target_modules=["q_proj", "v_proj"] (paper original). Đây là default cho GPT-2 style architecture. Llama / Mistral / Qwen có architecture hơi khác:
Llama block:
Attention: q_proj, k_proj, v_proj, o_proj
FFN (SwiGLU): gate_proj, up_proj, down_proj
Total: 7 linear layers
Nếu chỉ apply LoRA vào q_proj và v_proj, ta miss 5 linear layer khác. Quality kém hơn đáng kể.
Recommend cho Llama / Mistral:
target_modules = ["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"]
Hoặc đơn giản:
target_modules = "all-linear"
(peft >= 0.7 support string “all-linear” để auto target mọi linear.)
Một dev có lần fine-tune Llama-2-7B với chỉ q_proj/v_proj, perplexity giảm 5%. Sau khi sửa target_modules lên all-linear, perplexity giảm 18%. Khác biệt rất lớn.
Cheatsheet
| Concept | Definition |
|---|---|
Rank r | Số chiều của adapter, thường 8-64 |
| Alpha | Scaling factor, thường 16 hoặc 2*r |
| Target modules | Linear layer nào apply LoRA |
| Adapter | Cặp matrix A, B |
| Merge | Tính W' = W + B@A, deploy single matrix |
| LoRA hyperparam khuyến nghị |
|---|
| r = 8: quick test |
| r = 16-32: balanced |
| r = 64-128: maximum quality |
| alpha = 2*r |
| lora_dropout = 0.05-0.1 |
| learning_rate = 1e-4 đến 3e-4 (cao hơn full fine-tune) |
| target_modules = all-linear cho Llama-style |
| Library |
|---|
peft - HuggingFace, default choice |
bitsandbytes - 4-bit/8-bit quantization, dùng với QLoRA |
accelerate - distributed training, dùng với peft |
trl - SFT trainer wrapper, dùng cho instruction tuning |
unsloth - 2x faster LoRA trên consumer GPU |
| Memory rule of thumb (LoRA) | Memory rule of thumb (QLoRA) |
|---|---|
| ~25% VRAM full fine-tune | ~10% VRAM full fine-tune |
| Disk: vài MB đến vài trăm MB | Same |
Lời kết
LoRA và QLoRA đã thay đổi cách dev tiếp cận fine-tuning. Trước 2021, fine-tune 7B model là việc của team có $50K hardware budget. Sau 2023, một sinh viên với laptop có RTX 3060 12GB có thể fine-tune model 7B trong vài giờ. Đây là một trong những kỹ thuật quan trọng nhất trong LLM thực hành.
Hands-on song song:
- Pip install
peft,bitsandbytes,transformers,accelerate. Trên Colab free T4 16GB, loadmeta-llama/Llama-3.2-1B(1B nhỏ, fit dễ). Apply LoRA r=8 vào q_proj, v_proj. In số trainable params, verify ~1M. - Train LoRA adapter trên dataset
databricks/databricks-dolly-15k(15K instruction English, public). Run 1 epoch, batch_size=2, lr=2e-4. Khoảng 30-45 phút. Save adapter, load lại, generate vài câu xem có behavior instruction-following không. - Lặp lại với QLoRA: thay 1B model bằng
Llama-3.1-8B, load 4-bit NF4. Verify VRAM < 12GB. Đây là proof of concept rằng bạn có thể fine-tune 8B model trên Colab free. - Đọc paper QLoRA (Dettmers 2023), đặc biệt Section 3 (4-bit NormalFloat) và Section 4 (Paged Optimizer). Hai trick này là innovation chính so với LoRA.
Bài 19 sẽ vào SFT (Supervised Fine-Tuning) với instruction dataset: data format, loss masking, chat template. Bài này là context cho bài 19, mà 19 là context cho 20 (DPO/RLHF) và 21 (hands-on Llama-3 VN). Theo thứ tự sẽ thấy tự nhiên.