LoRA is one of the most important innovations enabling LLM training on consumer hardware. Train ~50M parameters instead of 70B to achieve 95% quality.
The Core Math (Simplified)
Instead of training a full W [4096 Γ 4096] matrix, train two smaller ones:
Output = W Γ Input + Ξ± Γ (A @ B) Γ Input
ββ Frozen ββ βββ Trainable βββ
A: [4096 Γ r] where r = rank (typically 16)
B: [r Γ 4096]
Total trainable: r Γ (4096 + 4096) = 32K (for r=16)
vs Full W: 4096 Γ 4096 = 16M
β 500Γ fewer parameters!
Why this works: The changes needed for task adaptation live in a low-rank subspace. GPT-3 β German isn't all 70B parameters changing; it's a smaller "German adaptation" in a 16-dimensional subspace.
Rank Selection
Small Rank (r=4-8)
- Parameters: ~32K
- Training: 1-2 hours
- Quality: ~85% of full fine-tuning
- Use: Small style changes, tiny datasets
Standard Rank (r=16)
- Parameters: ~128K
- Training: 2-4 hours
- Quality: ~95% of full fine-tuning
- Use: Industry standard, most tasks
- Why 16? Empirically optimal, diminishing returns after
Large Rank (r=32-64)
- Parameters: ~256K-512K
- Training: 4-8 hours
- Quality: ~98% of full fine-tuning
- Use: Complex domain adaptation, large datasets
- Problem: After r=32, each doubling gives only +1-2% improvement for 2Γ time
Which Layers to Train?
# Standard (recommended)
target_modules = ["q_proj", "v_proj"] # Query + Value
# Aggressive (everything)
target_modules = ["q_proj", "k_proj", "v_proj", "o_proj", "up_proj", "down_proj"]
# Knowledge-heavy
target_modules = ["up_proj", "down_proj"] # Feed-Forward only
Query + Value is best balance: captures semantics, reasonable parameters.
QLoRA vs DoRA
QLoRA (Quantized LoRA)
- Quantize base model to INT4 (35 GB β 35 GB but 4-bit)
- Train LoRA on top in FP16
- Total RAM: ~70 GB vs 175 GB for standard LoRA
- Quality loss: 1-2% (quantization noise)
- Result: Works on RTX 4090!
model, tokenizer = FastLanguageModel.from_pretrained(
"llama-2-7b",
load_in_4bit=True, # QLoRA flag
)
DoRA (Decomposed LoRA)
- Separate magnitude scaling from direction
- ~1-2% quality improvement vs LoRA
- Similar RAM/cost to standard LoRA
- Newer, fewer tools yet
# Not yet widespread, tools still implementing
config.use_dora = True # In Axolotl
Merging LoRA
After training, combine LoRA with original model:
from peft import AutoPeftModelForCausalLM
model = AutoPeftModelForCausalLM.from_pretrained(
"lora_path",
device_map="cpu",
torch_dtype=torch.float16,
)
merged = model.merge_and_unload()
merged.save_pretrained("merged_model")
What happens:
W_new = W + Ξ± Γ (A @ B)
= W + 0.125 Γ (A @ B)
Tradeoff: Can't un-merge, but simpler deployment.
Quick 30-Minute Example
Data
[
{
"instruction": "Translate to English",
"input": "Der Hund springt",
"output": "The dog jumps"
},
... // ~1000 examples
]
Code
from unsloth import FastLanguageModel
model, tokenizer = FastLanguageModel.from_pretrained(
"unsloth/llama-2-7b",
max_seq_length=1024,
load_in_4bit=True,
)
model = FastLanguageModel.get_peft_model(
model,
r=16,
lora_alpha=16,
target_modules=["q_proj", "v_proj"],
lora_dropout=0.05,
bias="none",
use_gradient_checkpointing=True,
)
from trl import SFTTrainer
from transformers import TrainingArguments
trainer = SFTTrainer(
model=model,
train_dataset=dataset["train"],
args=TrainingArguments(
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
warmup_steps=100,
num_train_epochs=1,
learning_rate=2e-4,
output_dir="lora-output",
optim="adamw_8bit",
max_steps=500, # ~30 min
),
packing=True,
max_seq_length=1024,
)
trainer.train()
model.save_pretrained("lora-weights")
Timeline: ~30 minutes start to finish on RTX 4090.
Common Pitfalls
Pitfall 1: Target Modules Mismatch
# WRONG
target_modules = ["all"] # Doesn't exist
# RIGHT
target_modules = ["q_proj", "v_proj"] # For Llama
target_modules = ["query", "value"] # For other models
Check with: for name, module in model.named_modules(): print(name)
Pitfall 2: Rank Too Small for Complex Task
Task: Learn entire medical domain with 10K examples
Solution: r=4 # β TOO SMALL!
Result: Hallucinations
Fix: r=32 or r=64 for complex tasks
Pitfall 3: Wrong Learning Rate
learning_rate = 1e-4 # β Too small for LoRA!
# LoRA needs HIGHER learning rate than full FT
# Full FT: 5e-5
# LoRA: 2e-4
# QLoRA: 2e-4 to 1e-3
Pitfall 4: Too Many Targets with Small Rank
r=8 with 6 target modules = 8 Γ 6 Γ 4096 Γ 2 β 400K parameters
That's not "small" anymore! Better to use r=16 with q_proj + v_proj only
When Merging vs Keeping Separate
| Scenario | Merge | Keep Separate |
|---|---|---|
| Single deployment | β | β |
| Multiple tasks | β | β |
| Storage important | β | β |
| Flexibility important | β | β |
| Production | β (Simpler) | β |
| Development | β (Keep original) | β |
LoRA is practical magic: 500Γ fewer parameters, only 2-4 hour training, 95% quality. Standard approach in production now.
Sources and Links
Advanced: Multiple LoRA Modules
Combining LoRA Adapters (Multi-Task Learning)
from peft import PeftModel, PeftConfig
import torch
# Load base model once
base_model = AutoModelForCausalLM.from_pretrained("llama-2-7b")
# Combine multiple LoRA adapters
config_task1 = PeftConfig.from_pretrained("lora/medical-task")
config_task2 = PeftConfig.from_pretrained("lora/legal-task")
model_medical = PeftModel.from_pretrained(base_model, "lora/medical-task")
model_legal = PeftModel.from_pretrained(base_model, "lora/legal-task")
# For inference, load the right one:
# Medical Q: model_medical.generate(...)
# Legal Q: model_legal.generate(...)
Advantages:
- Single base model, multiple adapters
- Memory: Load one adapter at a time (~500MB)
- Modularity: Extend without retraining base
Training Stability & Hyperparameters
Critical Hyperparameters
config_variations = {
"conservative": {
"r": 8,
"lora_alpha": 16,
"learning_rate": 1e-4,
"num_train_epochs": 2,
"gradient_accumulation_steps": 8,
"warmup_ratio": 0.1,
},
"aggressive": {
"r": 32,
"lora_alpha": 64,
"learning_rate": 5e-4,
"num_train_epochs": 3,
"gradient_accumulation_steps": 2,
"warmup_ratio": 0.05,
},
"stable": { # RECOMMENDED
"r": 16,
"lora_alpha": 16,
"learning_rate": 2e-4,
"num_train_epochs": 1,
"gradient_accumulation_steps": 4,
"warmup_ratio": 0.1,
"gradient_checkpointing": True,
"max_grad_norm": 1.0,
}
}
Why these work:
lora_alpha = r: Linear scalinglearning_rate = 2e-4: LoRA-specific (higher than full FT)warmup_ratio = 0.1: Stable initializationgradient_checkpointing = True: Memory efficiency
Detecting Training Issues
def diagnose_training(loss_history, eval_metrics):
"""Detect common LoRA training problems."""
issues = []
# Check 1: Diverging loss
if any(l > 1000 for l in loss_history[-10:]):
issues.append("Loss exploding. Fix: Lower learning_rate (try 1e-4)")
# Check 2: No improvement
if loss_history[-1] == loss_history[0]:
issues.append("No learning. Fix: Increase learning_rate (try 5e-4)")
# Check 3: Overfitting (if eval_loss > train_loss by 2Γ)
train_loss = loss_history[-1]
eval_loss = eval_metrics["loss"]
if eval_loss > train_loss * 2:
issues.append("Overfitting. Fix: Increase dropout, reduce r")
# Check 4: Slow convergence
if loss_history[-1] > loss_history[0] * 0.8:
issues.append("Slow. Fix: Increase learning_rate or batch_size")
return issues
LoRA vs Full Fine-Tuning Tradeoffs
Scenario | LoRA | Full FT
----------------------------|-----------|----------
Small dataset (<1K) | LoRA 10% | FT +5%
Medium dataset (1-10K) | LoRA 5% | FT Β±0%
Large dataset (100K+) | LoRA 2% | FT +3%
Memory (7B model) | 20GB | 70GB
Training time (1 epoch) | 2h | 8h
Inference latency | Same | Same
Deployment complexity | Simpler* | Merged
Modularity | β
Excellent | β Poor
*Actually simpler: One base model + many small adapters
Production Deployment Patterns
Pattern 1: Merged Adapters (Simplest)
# Training
def train_and_merge():
# ... training code ...
merged = model.merge_and_unload()
merged.save_pretrained("production/model")
# Deployment
def load_for_inference():
return AutoModelForCausalLM.from_pretrained("production/model")
# Normal inference, no special handling
Pros: Simple, fast, no LoRA overhead Cons: Can't mix adapters, irreversible
Pattern 2: Separate Adapters (Flexible)
# Training (keep separate)
model.save_pretrained("lora-weights/domain-xyz")
# Deployment
class MultiAdapterModel:
def __init__(self, base_model_path):
self.base = AutoModelForCausalLM.from_pretrained(base_model_path)
self.adapters = {}
def load_adapter(self, name, path):
adapter_model = PeftModel.from_pretrained(self.base, path)
self.adapters[name] = adapter_model
def generate(self, text, adapter_name):
return self.adapters[adapter_name].generate(text)
# Usage
model = MultiAdapterModel("llama-2-7b")
model.load_adapter("medical", "lora/medical")
model.load_adapter("legal", "lora/legal")
medical_answer = model.generate("Symptoms of flu?", "medical")
legal_answer = model.generate("What is liability?", "legal")
Pros: Flexible, memory-efficient, modular Cons: Slightly more complex
Pattern 3: Dynamically Loaded (Cloud-Ready)
import asyncio
from typing import Dict
class CloudAdapterManager:
def __init__(self, base_model_path, adapter_cache_dir="/tmp/adapters"):
self.base_model = AutoModelForCausalLM.from_pretrained(base_model_path)
self.cache_dir = adapter_cache_dir
self.loaded = {} # In-memory cache
async def get_adapter(self, adapter_id: str):
"""Load from cache, or download if needed."""
if adapter_id in self.loaded:
return self.loaded[adapter_id]
# Download from S3/cloud storage
adapter_path = await self.download_adapter(adapter_id)
model = PeftModel.from_pretrained(self.base_model, adapter_path)
self.loaded[adapter_id] = model
# Evict oldest if cache too large
if len(self.loaded) > 3:
oldest = min(self.loaded.items(), key=lambda x: x[1]['loaded_at'])
del self.loaded[oldest[0]]
return model
async def generate(self, text: str, adapter_id: str):
model = await self.get_adapter(adapter_id)
return model.generate(text)
Quantization + LoRA (QLoRA Deep Dive)
Matching GPU VRAM Requirements
def estimate_memory(model_size_b, rank, num_adapters=1):
"""Estimate GPU memory needed."""
# Base model in INT4 (4 bits)
model_memory = (model_size_b * 1_000_000_000 * 4) / 8 / 1_000_000_000 # GB
# LoRA in FP16 (2 bytes per param)
# Per layer: r Γ (hidden_size + hidden_size) Γ 2
# Rough estimate: r=16, hidden=4096, ~96 layers
lora_memory = (rank * 2 * 4096 * 2 * 96 * num_adapters) / 1_000_000_000
# Activations & optimizers
misc_memory = 5 # GB (activations, gradient buffers)
total = model_memory + lora_memory + misc_memory
return total
# Examples
print(estimate_memory(7, rank=16)) # 7B model, r=16 β ~22GB
print(estimate_memory(13, rank=16)) # 13B model, r=16 β ~32GB
print(estimate_memory(70, rank=32)) # 70B model, r=32 β ~65GB
Actual QLoRA Training Loop
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
from peft import prepare_model_for_kbit_training, get_peft_model, LoraConfig
from bitsandbytes.optim import AdamW8bit
def setup_qlora_model(model_name, rank=16):
"""Full QLoRA setup (4-bit quantization + LoRA)."""
# Load in 4-bit
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
)
model = AutoModelForCausalLM.from_pretrained(
model_name,
quantization_config=bnb_config,
device_map="auto",
)
# Prepare for training
model = prepare_model_for_kbit_training(model)
# LoRA config
peft_config = LoraConfig(
r=rank,
lora_alpha=rank,
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
target_modules=["q_proj", "v_proj"],
)
model = get_peft_model(model, peft_config)
# Optimizer (8-bit for memory efficiency)
optimizer = AdamW8bit(model.parameters(), lr=2e-4)
return model, optimizer
# Memory savings with QLoRA:
# 70B model:
# Full FP32: 280GB
# FP16 LoRA: 140GB + 2GB (LoRA) = 142GB
# INT4 LoRA: 35GB + 2GB (LoRA) = 37GB β 87% savings!
Common Production Gotchas
Gotcha 1: LoRA Inference Different from Training
# In training: model in .train() mode
model.train()
output = model.generate(text) # Fast because gradients off
# In inference: model in .eval() mode
model.eval()
with torch.no_grad():
output = model.generate(text) # SLIGHTLY different results!
# Fix: Always save both train & eval checkpoints, test both
Gotcha 2: Rank Too Small Hurts Downstream
# Training with r=4:
train_accuracy = 0.85 # Decent
# But: Hard to build on (poor zero-shot, few-shot)
# Solution: Use r=16 minimum in production
# Rule: r β₯ model_width / 1024
# 7B model (4096 hidden) β r β₯ 4
# 70B model (8192 hidden) β r β₯ 8
# Always use r=16+ for safety
Gotcha 3: Alpha Schedule
# Constant alpha (wrong for most LoRA)
"lora_alpha": 16
# Better: Decay alpha over time
# Early training: Large alpha (plastic learning)
# Late training: Small alpha (fine-tuning)
# LambdaLR schedule
def alpha_schedule(epoch):
return max(0.5, 1.0 - epoch / 10) # Decay from 1.0 to 0.5
scheduler = LambdaLR(optimizer, alpha_schedule)
Benchmark: LoRA Training on Different GPUs
GPU | VRAM | 7B (r=16) | 13B (r=16) | 70B (r=32)
---------------|------|-----------|------------|----------
RTX 4090 | 24GB | β
30min | β οΈ 2h | β OOM
RTX 6000 Ada | 48GB | β
20min | β
45min | β οΈ 8h
A100 (80GB) | 80GB | β
10min | β
20min | β
1.5h
A100 (40GB) | 40GB | β
20min | β οΈ 1h | β OOM
H100 | 80GB | β
8min | β
15min | β
1h
Legend: β
Comfortable | β οΈ Tight | β Requires QLoRA
When to NOT Use LoRA
β Poor fit:
- Specialization on domain heavily different from pretraining (use full FT)
- Extremely small models (< 1B, overhead not worth it)
- Need to change embedding dimensions (LoRA can't do this)
- Binary classifiers (overkill, logistic regression better)
β Perfect fit:
- Style/tone adaptation
- Domain specialization (medical, legal, code)
- Multi-task models
- When you have limited GPU
- Production where flexibility matters
