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 ΔWintrinsic 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, B trainable, 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 matrix W' (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:

ApproachTrainable params (Llama-3-8B)VRAM trainingAdapter disk
Full fine-tune8B (100%)60-80GB32GB
LoRA r=87M (0.09%)24GB28MB
LoRA r=1614M (0.18%)26GB56MB
LoRA r=6456M (0.7%)30GB224MB
QLoRA r=6456M (0.7%)8-10GB224MB

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_projv_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:

  • BitsAndBytesConfig load model ở 4-bit NF4.
  • prepare_model_for_kbit_training() thiết lập forward đúng cho quantized model.
  • r=64 cao 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:

ComponentSize
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

SetupUse case
Full fine-tuneCó 80GB+ VRAM, cần maximum quality, model nhỏ < 1B
LoRA r=8Quick experiment, validate task khả thi
LoRA r=32-64Production fine-tune, balance quality và resource
QLoRA r=64Consumer GPU, hobby project, không có A100
QLoRA r=128Maximum quality trên consumer GPU

So sánh quality (paper QLoRA report):

MethodMMLU score (Llama2-65B fine-tune)
Full fine-tune63.5
LoRA r=6463.1 (gần như giống)
QLoRA r=6463.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

ConceptDefinition
Rank rSố chiều của adapter, thường 8-64
AlphaScaling factor, thường 16 hoặc 2*r
Target modulesLinear layer nào apply LoRA
AdapterCặp matrix A, B
MergeTí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 MBSame

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:

  1. Pip install peft, bitsandbytes, transformers, accelerate. Trên Colab free T4 16GB, load meta-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.
  2. 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.
  3. 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.
  4. Đọ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.