LLM Engineering · lesson 20 of 27
Beginner
8Intermediate
11- 8 Structured Outputs in Production
- 9 LLM APIs and Provider Abstraction
- 10 LLM Tool Use and Function Calling
- 11 RAG — Retrieval-Augmented Generation
- 12 RAG: Advanced Retrieval and Reranking
- 13 LLM Embeddings and Vector Search
- 14 RAG Failure Diagnosis
- 15 Multimodal LLMs
- 16 LLM Context Engineering
- 17 Long-Context Economics and Prompt Caching
- 18 RAG vs. Fine-Tuning vs. Prompting
Advanced
8Also part of LLM Engineering
4LoRA, QLoRA, and PEFT
Attach a LoRA adapter, train only 0.18% of a model's parameters, and measure the real 549x smaller checkpoint that results.
By SkillByExample · Published
Fine-tuning every one of a 7B-parameter model’s weights needs enough GPU memory to hold gradients and optimizer state for all 7 billion of them — far more than the weights alone. LoRA fine-tunes a few hundred thousand new parameters instead, bolted onto a frozen model, and gets most of the practical benefit for a fraction of the memory and a checkpoint that’s two orders of magnitude smaller.
Attaching a LoRA Adapter
from transformers import AutoModelForCausalLM
from peft import LoraConfig, get_peft_model
model = AutoModelForCausalLM.from_pretrained("distilgpt2")
total_full = sum(p.numel() for p in model.parameters())
print(f"Full model parameters: {total_full:,}")
config = LoraConfig(
r=8, # rank of the low-rank update matrices
lora_alpha=16, # scaling factor for the update
target_modules=["c_attn"], # which layers get an adapter
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
)
peft_model = get_peft_model(model, config)
peft_model.print_trainable_parameters()
$ python lora_attach.py
Full model parameters: 81,912,576
trainable params: 147,456 || all params: 82,060,032 || trainable%: 0.1797
147,456 trainable parameters out of 82 million — 0.18%. Every original weight in distilgpt2
is frozen (requires_grad=False); LoRA adds new, small, trainable matrices next to the
attention projections and only those get gradients during training.
Training Updates Only the Adapter, and the Loss Really Drops
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig, get_peft_model
tok = AutoTokenizer.from_pretrained("distilgpt2")
tok.pad_token = tok.eos_token
model = AutoModelForCausalLM.from_pretrained("distilgpt2")
config = LoraConfig(r=8, lora_alpha=16, target_modules=["c_attn"], lora_dropout=0.05,
bias="none", task_type="CAUSAL_LM")
peft_model = get_peft_model(model, config)
# Teach the model to always answer a greeting in one fixed format
examples = [
"Q: Hi there\nA: [GREETING] Hello! How can I help?",
"Q: Hello\nA: [GREETING] Hello! How can I help?",
"Q: Hey\nA: [GREETING] Hello! How can I help?",
"Q: Good morning\nA: [GREETING] Hello! How can I help?",
]
optimizer = torch.optim.AdamW(peft_model.parameters(), lr=1e-3)
peft_model.train()
losses = []
for step in range(20):
total_loss = 0.0
for ex in examples:
ids = tok(ex, return_tensors="pt")
out = peft_model(**ids, labels=ids["input_ids"])
out.loss.backward()
total_loss += out.loss.item()
optimizer.step()
optimizer.zero_grad()
avg = total_loss / len(examples)
losses.append(avg)
if step % 4 == 0 or step == 19:
print(f"step {step:2}: loss={avg:.4f}")
print(f"\nLoss went from {losses[0]:.4f} to {losses[-1]:.4f}")
$ python lora_train.py
step 0: loss=4.3832
step 4: loss=3.8969
step 8: loss=3.1515
step 12: loss=2.3736
step 16: loss=1.4581
step 19: loss=1.0434
Loss went from 4.3832 to 1.0434
20 steps over 4 tiny examples, and the loss drops by more than 4x — with 147K trainable parameters, not 82 million. This toy example trains on 4 examples for 20 steps purely to make the loss curve visible quickly; a real task needs far more varied examples to generalize rather than memorize, exactly as the Fine-Tuning lesson’s data-preparation guidance covers.
The Checkpoint Is Genuinely Smaller
import os
from transformers import AutoModelForCausalLM
from peft import LoraConfig, get_peft_model
model = AutoModelForCausalLM.from_pretrained("distilgpt2")
config = LoraConfig(r=8, lora_alpha=16, target_modules=["c_attn"], lora_dropout=0.05,
bias="none", task_type="CAUSAL_LM")
peft_model = get_peft_model(model, config)
peft_model.save_pretrained("lora_adapter")
model.save_pretrained("full_model")
def dir_size(path):
return sum(os.path.getsize(os.path.join(root, f))
for root, _, files in os.walk(path) for f in files)
adapter_size = dir_size("lora_adapter")
full_size = dir_size("full_model")
print(f"LoRA adapter on disk: {adapter_size:,} bytes = {adapter_size/1024:.1f} KB")
print(f"Full model on disk: {full_size:,} bytes = {full_size/1024**2:.1f} MB")
print(f"Adapter is {full_size/adapter_size:.0f}x smaller")
$ python lora_size.py
LoRA adapter on disk: 597,643 bytes = 583.6 KB
Full model on disk: 328,250,559 bytes = 313.0 MB
Adapter is 549x smaller
584KB instead of 313MB for the same fine-tuning result, on this tiny model — the ratio only gets more favorable at larger scale, since a LoRA adapter’s size depends on the rank and target layers, not the base model’s total parameter count. This is what makes it practical to keep a separate adapter per customer, per task, or per style, all sharing one loaded base model.
Where QLoRA Fits
QLoRA is this exact recipe with one change: the frozen base model is loaded in 4-bit quantized precision (see Quantization for what that costs in accuracy) instead of full precision, and only the small LoRA matrices train in higher precision. A 7B model that needs ~28GB in FP32 needs roughly 3.5-5GB in 4-bit — combined with LoRA’s <1% trainable parameters, that’s the difference between “needs a multi-GPU server” and “runs on one consumer GPU.” Loading and quantizing a full 7B model isn’t reproducible in this environment’s CPU-only setup, but the mechanism is exactly the two lessons combined: freeze and quantize the base (Quantization), train a small low-rank delta on top (this lesson).
Practice
1. Add a second target module (the MLP's `c_fc` layer) and see how much the trainable parameter count changes.
from transformers import AutoModelForCausalLM
from peft import LoraConfig, get_peft_model
model = AutoModelForCausalLM.from_pretrained("distilgpt2")
config = LoraConfig(r=8, lora_alpha=16, target_modules=["c_attn", "c_fc"],
lora_dropout=0.05, bias="none", task_type="CAUSAL_LM")
peft_model = get_peft_model(model, config)
peft_model.print_trainable_parameters()
trainable params: 331,776 || all params: 82,244,352 || trainable%: 0.4034
Adding c_fc roughly doubles the trainable count (147K to 332K) — the MLP layer is wider
than the attention projection, so its low-rank adapter matrices are bigger too. Still under
1% of the full model.
2. Compare rank 4 vs rank 32 and confirm trainable parameters scale linearly with rank.
from transformers import AutoModelForCausalLM
from peft import LoraConfig, get_peft_model
for r in [4, 32]:
model = AutoModelForCausalLM.from_pretrained("distilgpt2")
config = LoraConfig(r=r, lora_alpha=16, target_modules=["c_attn"],
lora_dropout=0.05, bias="none", task_type="CAUSAL_LM")
peft_model = get_peft_model(model, config)
trainable = sum(p.numel() for p in peft_model.parameters() if p.requires_grad)
print(f"r={r}: {trainable:,} trainable parameters")
r=4: 73,728 trainable parameters
r=32: 589,824 trainable parameters
Exactly 8x more parameters for 8x the rank (4 to 32) — LoRA’s trainable parameter count is
rank x (input_dim + output_dim) per target module, so it scales linearly with rank by
construction. Higher rank means more capacity to adapt, and proportionally more to train.
3. Freeze the LoRA adapter (set requires_grad=False on it too) and confirm nothing trains.
from transformers import AutoModelForCausalLM
from peft import LoraConfig, get_peft_model
model = AutoModelForCausalLM.from_pretrained("distilgpt2")
config = LoraConfig(r=8, lora_alpha=16, target_modules=["c_attn"],
lora_dropout=0.05, bias="none", task_type="CAUSAL_LM")
peft_model = get_peft_model(model, config)
for p in peft_model.parameters():
p.requires_grad = False
trainable = sum(p.numel() for p in peft_model.parameters() if p.requires_grad)
print(f"Trainable parameters after freezing everything: {trainable:,}")
Trainable parameters after freezing everything: 0
Zero — confirming there’s nothing magic about LoRA parameters being trainable by default,
it’s the same requires_grad mechanism as any PyTorch parameter. get_peft_model just sets
it to True on the new adapter matrices and False on everything else when it builds the
model.