← All Posts

LoRA & QLoRA: Parameter-Efficient Fine-Tuning

Why Parameter-Efficient Fine-Tuning?

Full fine-tuning of a 7B-parameter model requires storing a complete copy of all weights plus optimizer states. With Adam, that means 3× the model size in GPU memory: the parameters themselves, plus first and second moment estimates. For a 7B model in FP16 that's roughly 7B × 2 bytes × 3 ≈ 42 GB — far exceeding a single A100 40 GB.

Parameter-efficient fine-tuning (PEFT) methods address this by updating only a small subset of parameters while keeping the pretrained base frozen. The key insight is that the weight updates during fine-tuning live in a low-dimensional subspace, so we can parameterize them compactly.

Key insight: Aghajanyan et al. (2020) showed that pretrained language models have a low intrinsic dimensionality — fine-tuning a 175B model effectively only modifies O(hundreds) of degrees of freedom, not billions.

The major PEFT approaches include:

LoRA has become the dominant method because it adds zero inference latency — the low-rank matrices can be merged back into the base weights at deployment time.

LoRA: Low-Rank Adaptation

LoRA (Low-Rank Adaptation) works by freezing all pretrained weights and injecting trainable rank-decomposition matrices into each target layer. Instead of updating the full weight matrix W ∈ ℝd×k, we learn a low-rank update ΔW = BA where:

h = W0x → h = W0x + BAx

During training, only B and A receive gradients. The base weight W0 remains frozen. At initialization:

Zero initialization trick: Because B starts as zeros, the model output at the beginning of fine-tuning is identical to the pretrained model. Training starts from an exact pretrained checkpoint, not a perturbed one.

The scaling factor α/r controls the magnitude of the LoRA update. The output becomes:

h = W0x + (α/r) · BAx

Typically α = 2r (e.g., α = 16 when r = 8). This scaling ensures the learning rate doesn't need to change much across different rank choices.

Mathematical Foundation

Consider a pretrained weight matrix W0 ∈ ℝd×d (e.g., a query/key/value projection in a transformer with d = 4096). Full fine-tuning learns:

W = W0 + ΔW

where ΔW ∈ ℝd×d has d² = 16.7M trainable parameters per layer. LoRA constrains ΔW to rank r:

ΔW = BA where B ∈ ℝd×r, A ∈ ℝr×d

The number of trainable parameters drops to 2 × d × r:

Rank (r)Params per layer (d=4096)Reduction vs full
432,768512×
865,536256×
16131,072128×
64524,28832×
d (full)16,777,2161×

Which layers get LoRA adapters? The original paper applies LoRA to query (Wq) and value (Wv) projections in each attention block. Subsequent work shows applying to all linear layers (Q, K, V, O, and MLP up/down/gate) often yields better results:

# Target modules for different architectures

# LLaMA / Mistral
target_modules = [
    "q_proj", "k_proj", "v_proj", "o_proj",
    "gate_proj", "up_proj", "down_proj"
]

# GPT-2 / GPT-NeoX
target_modules = ["c_attn", "c_proj", "c_fc"]

# Falcon
target_modules = ["query_key_value", "dense", "dense_h_to_4h", "dense_4h_to_h"]
Merge at inference: After training, compute W = W0 + (α/r)·BA and store the merged weight. This means zero additional latency at serving time — the model has the same architecture as the original.

Rank Selection Tradeoffs

The rank r is the most important hyperparameter in LoRA. It controls the expressiveness of the update and the memory/compute budget:

r = 4 (Low Rank)

  • Trainable params: ~0.1% of base
  • Memory: Minimal overhead (~10 MB for 7B)
  • Best for: Simple domain adaptation, style transfer
  • Risk: May underfit on complex tasks
  • Training speed: Fastest

r = 8 (Default)

  • Trainable params: ~0.2% of base
  • Memory: ~20 MB for 7B
  • Best for: General instruction tuning, chat
  • Risk: Good balance for most tasks
  • Training speed: Fast

r = 16 (Medium Rank)

  • Trainable params: ~0.4% of base
  • Memory: ~40 MB for 7B
  • Best for: Complex reasoning, math, code
  • Risk: Slight overfitting on small datasets
  • Training speed: Moderate

r = 64 (High Rank)

  • Trainable params: ~1.6% of base
  • Memory: ~160 MB for 7B
  • Best for: Significant capability changes, multilingual
  • Risk: Higher overfitting risk, diminishing returns
  • Training speed: Slower
Diminishing returns: The original LoRA paper showed that performance often plateaus beyond r = 8 for many tasks. Always start with r = 8 and increase only if validation loss stalls. Higher rank ≠ better model.

Empirical guidelines from community experience:

QLoRA: Quantized LoRA

QLoRA (Dettmers et al., 2023) pushes memory efficiency further by combining LoRA with aggressive base-model quantization. The three key innovations are:

1. 4-bit NormalFloat (NF4) Quantization

The base model weights are quantized to 4-bit using a new data type called NF4 — an information-theoretically optimal quantization for normally distributed weights. Each weight is stored as a 4-bit index into a shared codebook per block of 64 weights.

2. Double Quantization

The quantization constants themselves (one FP32 scale per 64-weight block) are further quantized to 8-bit, saving an additional ~0.37 bits per parameter. For a 65B model, this saves ~3 GB.

3. Paged Optimizers

Optimizer states are offloaded to CPU RAM using unified memory (CUDA unified memory / paging), preventing OOM errors during gradient spikes from long sequences.

The QLoRA pipeline: Base model stored in NF4 (4-bit) → dequantized to BF16 on-the-fly for forward/backward pass → LoRA adapters trained in BF16 → only LoRA gradients are computed and stored.

The forward pass for a QLoRA layer:

h = dequant(WNF4)x + (α/r)·BAx

Dequantization happens block-by-block during matrix multiplication — the full FP16 weight is never materialized in memory at once. This is why QLoRA achieves near-4-bit memory usage while maintaining BF16 training precision for the LoRA parameters.

Memory & Compute Comparison

The following table shows GPU memory requirements for fine-tuning LLaMA-2 models with different methods (including optimizer states and activations for batch size 1, sequence length 2048):

Method 7B Model 13B Model 70B Model Trainable Params
Full FT (FP16) ~42 GB ~78 GB ~420 GB 100%
Full FT (BF16 + DeepSpeed ZeRO-3) ~18 GB × 4 GPUs ~30 GB × 4 GPUs ~60 GB × 8 GPUs 100%
LoRA (FP16, r=8) ~18 GB ~32 GB ~160 GB ~0.2%
QLoRA (NF4, r=8) ~6 GB ~10 GB ~38 GB ~0.2%
QLoRA (NF4, r=64) ~7 GB ~12 GB ~42 GB ~1.6%

Full Fine-Tuning

  • GPU: 2–8× A100 80 GB for 7B+
  • Optimizer mem: 2× model (Adam moments)
  • Gradient mem: 1× model size
  • Pros: Maximum quality ceiling
  • Cons: Extremely expensive, catastrophic forgetting risk

LoRA (FP16)

  • GPU: 1× A100 40 GB for 7B
  • Optimizer mem: Only for r×d params
  • Gradient mem: ~0.2% of full
  • Pros: Mergeable, multi-adapter serving
  • Cons: Still loads full FP16 model

QLoRA (NF4)

  • GPU: 1× RTX 3090 24 GB for 7B
  • Optimizer mem: Only for r×d params
  • Gradient mem: ~0.2% of full
  • Pros: Consumer GPU fine-tuning
  • Cons: ~5% slower from dequantization

Quality Comparison

  • Full FT: 100% (baseline)
  • LoRA r=8: ~97–99% of full FT
  • QLoRA r=8: ~96–98% of full FT
  • QLoRA r=64: matches full FT on many benchmarks
  • QLoRA trained Guanaco 65B → #2 on Vicuna benchmark

Interactive: Weight Decomposition

Step through the LoRA decomposition process. See how a large weight update matrix is decomposed into two small low-rank matrices:

Step 1 / 5
W₀ d × d (frozen) + ΔW d × d 16.7M params = B d × r × A r × d merge back: W = W₀ + (α/r)·BA W (merged) zero extra latency!

PEFT Library Implementation

The Hugging Face PEFT library provides a clean API for applying LoRA and QLoRA to any Hugging Face model. Here's a complete training setup:

Basic LoRA Configuration

from peft import LoraConfig, get_peft_model, TaskType
from transformers import AutoModelForCausalLM, AutoTokenizer

# Load base model
model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Llama-2-7b-hf",
    torch_dtype=torch.float16,
    device_map="auto"
)

# Define LoRA config
lora_config = LoraConfig(
    task_type=TaskType.CAUSAL_LM,
    r=8,                           # rank
    lora_alpha=16,                 # scaling factor (α)
    lora_dropout=0.05,             # dropout on adapter
    target_modules=[               # which layers to adapt
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj"
    ],
    bias="none",                    # don't train biases
)

# Wrap model with LoRA
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# Output: trainable params: 4,194,304 || all params: 6,742,609,920 || trainable%: 0.0622%

QLoRA Configuration (4-bit Training)

from transformers import BitsAndBytesConfig
import torch

# 4-bit quantization config
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",           # NormalFloat4
    bnb_4bit_compute_dtype=torch.bfloat16, # compute in BF16
    bnb_4bit_use_double_quant=True,      # double quantization
)

# Load model in 4-bit
model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Llama-2-7b-hf",
    quantization_config=bnb_config,
    device_map="auto",
)

# Prepare model for k-bit training
from peft import prepare_model_for_kbit_training
model = prepare_model_for_kbit_training(model)

# Apply LoRA on top of quantized model
model = get_peft_model(model, lora_config)
# Now: base weights in NF4 (~3.5 GB), LoRA adapters in BF16 (~20 MB)

Training with SFTTrainer

from trl import SFTTrainer
from transformers import TrainingArguments

training_args = TrainingArguments(
    output_dir="./lora-llama2-7b",
    num_train_epochs=3,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,     # effective batch = 16
    learning_rate=2e-4,                # higher LR for LoRA
    weight_decay=0.01,
    warmup_ratio=0.03,
    lr_scheduler_type="cosine",
    logging_steps=10,
    save_strategy="epoch",
    bf16=True,                         # BF16 mixed precision
    gradient_checkpointing=True,       # save activation memory
    optim="paged_adamw_8bit",           # paged optimizer for QLoRA
)

trainer = SFTTrainer(
    model=model,
    train_dataset=dataset,
    args=training_args,
    max_seq_length=2048,
    dataset_text_field="text",
)

trainer.train()

Merging and Saving

# After training — merge LoRA weights into base
merged_model = model.merge_and_unload()

# Save merged model (same architecture as original)
merged_model.save_pretrained("./llama2-7b-merged")
tokenizer.save_pretrained("./llama2-7b-merged")

# Or save just the adapter (~20MB vs 14GB)
model.save_pretrained("./llama2-7b-lora-adapter")

# Load adapter later on any compatible base
from peft import PeftModel
base = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-2-7b-hf")
model = PeftModel.from_pretrained(base, "./llama2-7b-lora-adapter")

Best Practices & Pitfalls

Hyperparameter Guidelines

Multi-Adapter Serving

One of LoRA's most powerful production features: serve multiple task-specific adapters on a single base model.

# Load base model once in GPU memory
base_model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-2-7b-hf")

# Hot-swap adapters per request (each ~20MB)
model = PeftModel.from_pretrained(base_model, "./chat-adapter")
model.load_adapter("./code-adapter", adapter_name="code")
model.load_adapter("./medical-adapter", adapter_name="medical")

# Switch at inference time
model.set_adapter("code")        # ~0ms switch, no model reload
output = model.generate(input_ids)
Production pattern: Use a single base model with a registry of LoRA adapters. Route requests to the appropriate adapter based on task type. This enables serving 50+ specialized models on a single GPU — each adapter adds only ~20 MB.

Common Pitfalls

❌ Pitfall

  • Using the same LR as full fine-tuning
  • Setting rank too high "just to be safe"
  • Forgetting to call prepare_model_for_kbit_training
  • Training for too many epochs on small data
  • Not using gradient checkpointing with QLoRA

✅ Fix

  • Use 2–5× higher LR (1e-4 to 3e-4)
  • Start with r=8, increase only if val loss stalls
  • Always prepare for kbit before applying LoRA
  • 1–3 epochs max; monitor val loss closely
  • Always enable gradient_checkpointing=True
Interview tip: Be ready to explain why LoRA has zero inference overhead after merging — this is the #1 advantage over adapters and prefix tuning. Also know that QLoRA's NF4 quantization is information-theoretically optimal for normal distributions, which is why it works better than simple INT4 rounding.

Continue with worked examples

Continue with these focused notes: