Parameter-Efficient Fine-Tuning (PEFT)¶
Full fine-tuning a 7B model requires storing and updating ~28GB of parameters in BF16, plus optimizer states (Adam = 2× parameter memory), totaling ~84GB of GPU RAM. That’s three A100s just to fit the model. PEFT methods break this constraint by updating a tiny fraction of parameters while keeping the rest frozen — achieving 80-95% of full fine-tune quality at 1-10% of the computational cost. This is not a compromise. For most domain adaptation tasks, it’s the correct engineering choice.
1. LoRA: Low-Rank Adaptation¶
Paper: Hu et al., ICLR 2022 — arXiv 2106.09685
The Mathematical Derivation¶
The core insight: pre-trained model weights contain a lot of “knowledge” in a high-dimensional space, but the update required for a specific task lives in a much lower-dimensional subspace. The hypothesis is that weight updates during fine-tuning have low intrinsic rank.
For a pre-trained weight matrix W₀ ∈ ℝ^(d×k), instead of computing the full update ΔW ∈ ℝ^(d×k), LoRA constrains it to a low-rank decomposition:
ΔW = BA
where:
B ∈ ℝ^(d×r) (d rows, r columns)
A ∈ ℝ^(r×k) (r rows, k columns)
r << min(d, k) — the rank, a hyperparameter
The modified forward pass:
h = W₀x + ΔWx = W₀x + BAx
Initialization: A is initialized with random Gaussian, B is initialized to zero. This ensures ΔW = 0 at the start of training — the model begins identical to the pretrained checkpoint.
Scaling: LoRA includes a scaling factor α/r:
h = W₀x + (α/r) · BAx
Setting lora_alpha = 2 × r is a common heuristic. The α/r scaling keeps the magnitude of the update independent of the rank choice.
Parameter Count¶
For a weight matrix W₀ ∈ ℝ^(4096×4096) (e.g., a Q or V projection in LLaMA-7B):
Full fine-tune: 4096 × 4096 = 16.8M parameters
LoRA with r=16: 4096×16 + 16×4096 = 131K parameters — 128× reduction
For a full LLaMA-7B model with LoRA applied to all attention projections (q, k, v, o) and both MLP projections across 32 layers at r=16:
Trainable parameters: ~8M (0.1% of 7B total)
Why It Works¶
Empirical evidence from the LoRA paper and subsequent work: the “intrinsic dimensionality” of fine-tuning tasks is surprisingly small. For most NLP tasks, rank r=4 captures the essential update. r=16 is the conservative-but-safe choice. r=64+ is rarely necessary and may overfit on small datasets.
A later analysis (Aghajanyan et al., 2020 — arXiv 2012.13255) measured intrinsic dimensionality directly: MRPC needs ~200 dimensions for 90% of full fine-tune performance. Out of 125M parameters in BERT, 200 effective dimensions. LoRA exploits this empirical fact.
Which Layers to Target¶
Standard practice (verified by ablations):
Attention projections (q_proj, k_proj, v_proj, o_proj): primary target, always include
MLP projections (gate_proj, up_proj, down_proj): adding these helps on code/math tasks
Embedding layers: rarely worth it for fine-tuning
LM head: only if changing vocabulary
target_modules = ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]
2. QLoRA: 4-bit Quantization + LoRA¶
Paper: Dettmers et al., NeurIPS 2023 — arXiv 2305.14314
QLoRA makes 7B model fine-tuning feasible on a single consumer GPU (RTX 3090/4090, 24GB). It combines three innovations:
Innovation 1: NF4 (NormalFloat4) Quantization¶
Standard 4-bit quantization (INT4) distributes 16 quantization levels uniformly. But neural network weights follow approximately a normal distribution — most values cluster near zero. Uniformly-spaced bins waste capacity in the tails.
NF4 places quantization levels at the quantiles of a standard normal distribution, optimally using all 16 bins:
Quantile levels: {-1.0, -0.6962, -0.5251, -0.3949, -0.2844, -0.1848, -0.0912, 0.0,
0.0796, 0.1609, 0.2461, 0.3379, 0.4407, 0.5626, 0.7230, 1.0}
This is information-theoretically optimal for normally distributed data. Result: NF4 matches BF16 model quality within 0.1-0.3 perplexity points on standard benchmarks — far better than INT4.
Innovation 2: Double Quantization¶
The quantization constants themselves (one per block of weights) consume memory: for a 7B model with 64-element blocks, that’s 7B/64 × 32 bits = ~437MB just for constants. Double quantization quantizes the quantization constants, saving ~0.4 bits per parameter. Minor in practice but adds up.
Innovation 3: Paged Optimizers¶
GPU memory spikes during gradient checkpointing when processing long sequences. QLoRA uses NVIDIA’s unified memory to page optimizer states to CPU RAM during spikes, preventing OOM errors. This is transparent — you don’t configure it explicitly.
VRAM Requirements Table¶
Model Size |
Full BF16 |
LoRA (BF16) |
QLoRA (NF4) |
QLoRA + Grad Ckpt |
|---|---|---|---|---|
1B |
~2GB |
~2GB + opt |
~0.5GB |
~0.5GB |
3B |
~6GB |
~6GB + opt |
~2GB |
~2GB |
7B |
~14GB |
~14GB + opt |
~5GB |
~5GB |
13B |
~26GB |
~26GB + opt |
~10GB |
~10GB |
34B |
~68GB |
~68GB + opt |
~20GB |
~20GB |
70B |
~140GB |
~140GB + opt |
~40GB |
~40GB |
Optimizer states (Adam): 2× parameter memory in FP32. Gradient checkpointing recomputes activations during backward pass to trade compute for memory.
Bottom line: A single RTX 4090 (24GB) can fine-tune a 7B model with QLoRA. A single A100 80GB can fine-tune a 34B model with QLoRA.
3. Adapter Layers¶
Adapters (Houlsby et al., 2019 — arXiv 1902.00751) insert small bottleneck modules into each transformer layer. The adapter is a down-projection → nonlinearity → up-projection with a residual connection:
h = h + W_up · ReLU(W_down · h)
where W_down ∈ ℝ^(d×r) and W_up ∈ ℝ^(r×d) with r << d.
Why LoRA has largely replaced adapters:
Adapters add inference latency (extra computation even after training)
LoRA can be merged into the base weights: W = W₀ + BA, resulting in zero inference overhead
LoRA typically achieves comparable quality with less overhead
Adapters remain relevant when you need to swap adaptation quickly (multi-task serving, switching between many fine-tuned variants on a single base model), as they can be swapped independently.
4. Prefix Tuning and Prompt Tuning¶
Prefix Tuning (Li & Liang, 2021 — arXiv 2101.00190) prepends trainable “virtual tokens” to the key-value cache at every layer. These aren’t real tokens — they’re directly-optimized embedding vectors that steer attention.
Prompt Tuning (Lester et al., 2021 — arXiv 2104.08691) is a simplified version: trainable embeddings prepended only to the input layer.
When they work: Large models (>10B parameters) on tasks where the base model already has the capability and only needs task direction. They fail badly on small models and fail catastrophically on tasks requiring new knowledge.
In practice: Rarely used in 2025. LoRA dominates because it works across model sizes, requires no architectural assumptions, and the merge-ability eliminates inference overhead. Include these in your knowledge base but don’t spend implementation time here.
5. When PEFT is Sufficient vs. When You Need Full Fine-Tuning¶
This is the most practically important question and the one most engineers get wrong.
Scenario |
Recommendation |
Reasoning |
|---|---|---|
New instruction format |
LoRA r=8-16 |
Format is low-rank; small update sufficient |
Domain vocabulary/style |
LoRA r=16-32 |
Style adaptation is low-rank |
Classification on domain text |
LoRA r=8 |
Task adaptation only |
Math/reasoning improvement |
GRPO + LoRA or full FT |
Capability improvements may need full FT |
Teaching truly new knowledge |
Full fine-tune |
New knowledge requires updating many parameters |
Multilingual → monolingual |
LoRA r=32-64 |
Language shift needs more capacity |
New modality integration |
Full fine-tune |
Structural changes required |
>1M training examples |
Full fine-tune |
At scale, full FT beats PEFT quality |
The honest threshold: If your task is “make the model behave differently given information it already knows” → PEFT is sufficient. If your task is “give the model knowledge it doesn’t have” → you need full fine-tuning, and even then, RAG is often the better answer (see 03_rag_systems.md).
Practical: Fine-tune LLaMA-3 on 8GB GPU with QLoRA¶
# requirements: pip install trl transformers peft accelerate bitsandbytes datasets
# Tested on RTX 3070 8GB, LLaMA-3.2-1B (use 3B or 7B on 16GB+)
import torch
from datasets import load_dataset
from transformers import (
AutoTokenizer,
AutoModelForCausalLM,
BitsAndBytesConfig,
TrainingArguments,
)
from peft import LoraConfig
from trl import SFTTrainer, SFTConfig, DataCollatorForCompletionOnlyLM
# ── Configuration ──────────────────────────────────────────────────────
MODEL_ID = "meta-llama/Llama-3.2-1B-Instruct" # swap to 3B if you have 16GB
DATASET_NAME = "HuggingFaceH4/ultrachat_200k"
MAX_SEQ_LENGTH = 1024
OUTPUT_DIR = "./qlora_output"
# ── 4-bit Quantization ─────────────────────────────────────────────────
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4", # NormalFloat4 — optimal for normal distributions
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True, # quantize the quantization constants too
)
# ── Model and Tokenizer ────────────────────────────────────────────────
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right" # required for SFT loss masking
model = AutoModelForCausalLM.from_pretrained(
MODEL_ID,
quantization_config=bnb_config,
device_map={"": 0}, # single GPU
torch_dtype=torch.bfloat16,
)
model.config.use_cache = False # disable KV cache during training
model.config.pretraining_tp = 1 # tensor parallelism = 1 for single GPU
# ── LoRA Config ────────────────────────────────────────────────────────
peft_config = LoraConfig(
r=16,
lora_alpha=32,
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
target_modules=[
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj", # include MLP for better performance
],
)
# ── Dataset ────────────────────────────────────────────────────────────
dataset = load_dataset(DATASET_NAME, split="train_sft[:2000]") # small for demo
def format_prompt(example):
"""Apply the model's chat template."""
return tokenizer.apply_chat_template(
example["messages"],
tokenize=False,
add_generation_prompt=False,
)
dataset = dataset.map(lambda x: {"text": format_prompt(x)})
# ── Training Arguments ─────────────────────────────────────────────────
training_args = SFTConfig(
output_dir=OUTPUT_DIR,
num_train_epochs=1,
per_device_train_batch_size=1, # 8GB constraint
gradient_accumulation_steps=16, # effective batch = 16
gradient_checkpointing=True, # trade compute for memory
optim="paged_adamw_32bit", # paged optimizer for spike handling
learning_rate=2e-4,
weight_decay=0.001,
fp16=False,
bf16=True,
max_grad_norm=0.3,
warmup_ratio=0.03,
group_by_length=True, # group similar lengths to minimize padding
lr_scheduler_type="cosine",
logging_steps=25,
save_steps=200,
max_seq_length=MAX_SEQ_LENGTH,
dataset_text_field="text",
packing=False,
report_to="tensorboard",
)
# ── Trainer ────────────────────────────────────────────────────────────
trainer = SFTTrainer(
model=model,
train_dataset=dataset,
peft_config=peft_config,
processing_class=tokenizer,
args=training_args,
)
trainer.train()
# ── Save and Merge ─────────────────────────────────────────────────────
# Save LoRA adapter only (~32MB for r=16)
trainer.save_model(f"{OUTPUT_DIR}/adapter")
# Optional: merge adapter into base weights for deployment (zero inference overhead)
# Requires loading base model in BF16 (not 4-bit) for merging
print("\nTo merge for deployment:")
print(" from peft import PeftModel")
print(" base = AutoModelForCausalLM.from_pretrained(MODEL_ID, torch_dtype=torch.bfloat16)")
print(" merged = PeftModel.from_pretrained(base, './qlora_output/adapter')")
print(" merged = merged.merge_and_unload()")
print(" merged.save_pretrained('./merged_model')")
# ── Expected GPU Memory Usage ──────────────────────────────────────────
# Model (4-bit NF4): ~0.5 GB for 1B, ~3.5 GB for 7B
# LoRA adapters (BF16): ~30 MB for 1B (r=16), ~120 MB for 7B (r=16)
# Optimizer states: ~60 MB for LoRA params (paged to CPU)
# Activations (grad ckpt): ~1-2 GB peak (depends on seq len and batch)
# Total for 1B on 8GB: ~3 GB — plenty of headroom
# Total for 7B on 8GB: ~6 GB — tight, requires batch_size=1 + grad_ckpt
Measured training speed (rough estimates, single GPU):
1B model, RTX 3070 8GB, batch_size=1, grad_acc=16: ~120 tokens/sec
7B model, RTX 4090 24GB, batch_size=1, grad_acc=8: ~90 tokens/sec
7B model, A100 40GB, batch_size=4, grad_acc=4: ~420 tokens/sec
What Most People Get Wrong¶
Confusing LoRA rank with quality. Engineers instinctively reach for r=64 or r=128, thinking “more capacity = better.” In most cases, r=8-16 is sufficient, and higher ranks just overfit on small datasets (< 50K examples). Run a sweep: train at r=4, r=8, r=16, r=32 on your dataset, evaluate on a held-out set. The quality difference between r=16 and r=64 is typically < 1% on most tasks. The compute difference is 4×. Do the math.
The second mistake: forgetting to disable the base model’s KV cache during training (
model.config.use_cache = False). Leaving it enabled silently corrupts gradients during gradient checkpointing. This is a known footgun. It’s in the TRL documentation but frequently missed.
Papers Reference¶
Paper |
ArXiv ID |
Key Contribution |
|---|---|---|
LoRA |
2106.09685 |
Low-rank weight decomposition for fine-tuning |
QLoRA |
2305.14314 |
NF4 quantization + LoRA, 7B on single GPU |
Adapter |
1902.00751 |
Original adapter method |
Prefix Tuning |
2101.00190 |
Trainable prefix tokens at all layers |
Prompt Tuning |
2104.08691 |
Input-layer soft prompts only |
Intrinsic Dimensionality |
2012.13255 |
Why low-rank updates work |
Return to 01_llm_alignment_and_finetuning.md · Next: 03_rag_systems.md