The Big Question
Suppose a language model has parameters. If each parameter is stored in 16-bit floating point, the weights alone require:
Training needs more than weights. It may also store gradients, Adam optimizer states, activations, and temporary buffers. Under a simplified full-fine-tuning accounting, weights may be 14 GB, gradients another 14 GB, and Adam's two moment estimates can add roughly 28 GB more before activations are counted.
Do we really need to change all seven billion parameters to teach one new style, domain, or task?
Core Intuition
A large pretrained model is a vast existing map of useful behaviours. Full fine-tuning redraws the whole map. Parameter-efficient fine-tuning adds a small overlay that redirects behaviour for the new task.
The base model stays frozen. A small number of new parameters learn the update:
is the frozen pretrained matrix. is the learned task-specific modification. The PEFT question is whether that update can be represented much more cheaply than a full matrix.
Interactive Demo
Fine-tuning method
Transformer block sketch
Adapter equation
W0 frozen, A and B trainable
PEFT preserves the expensive pretrained model and learns a small specialised update around it.
LoRA From First Principles
A transformer layer contains weight matrices. Let one frozen matrix be:
Full fine-tuning learns every element of a full update . LoRA constrains that update to be low rank:
The adapted layer computes:
Only and are trained. is the rank. scales the update.
Parameter Count
For a matrix, a full update has:
With LoRA rank , trainable parameters are:
LoRA trains about 0.39% as many values for that matrix. The update is cheaper because it can move the model along only independent directions.
Why is low rank? For any input , . The vector has only coordinates, so the result is a combination of at most columns of .
Tiny Forward Pass
LoRA leaves the original output in place and adds a learned low-rank correction. Commonly, one LoRA matrix is initialised randomly and the other to zero, so at the start and the model initially behaves like the base model.
QLoRA, Adapters, And Prompts
LoRA reduces trainable parameters, but the frozen base model still occupies memory. QLoRA stores the frozen base in low precision, often 4-bit, while training LoRA adapters at higher precision. The base is quantised and frozen; the adapter is trained.
This uniform quantiser is only introductory intuition. Real QLoRA systems use more specialised formats, metadata, and compute paths.
Bottleneck adapters insert small modules into the network:
Prompt tuning learns virtual input embeddings . Prefix tuning injects learned vectors into attention states across layers.
Method Comparison
| Method | What changes? | Advantage | Limitation |
|---|---|---|---|
| LoRA | Low-rank weight updates | Strong quality-efficiency balance | Rank and target layers matter |
| QLoRA | Quantised base plus LoRA | Much lower base memory | Quantisation adds complexity |
| Adapters | Inserted bottleneck modules | Modular and expressive | Can add inference operations |
| Prompt tuning | Trainable input vectors | Extremely few parameters | Can be weaker on some tasks |
| Prefix tuning | Trainable attention prefixes | Controls multiple layers | More implementation complexity |
Implementation
import math
import torch
from torch import nn
class LoRALinear(nn.Module):
def __init__(self, base: nn.Linear, rank=8, alpha=16, dropout=0.0):
super().__init__()
assert isinstance(base, nn.Linear)
self.base = base
self.rank = rank
self.scale = alpha / rank
self.dropout = nn.Dropout(dropout)
for param in self.base.parameters():
param.requires_grad = False
self.A = nn.Parameter(torch.empty(rank, base.in_features))
self.B = nn.Parameter(torch.zeros(base.out_features, rank))
nn.init.kaiming_uniform_(self.A, a=math.sqrt(5))
def forward(self, x):
base_out = self.base(x)
update = (self.dropout(x) @ self.A.T) @ self.B.T
return base_out + self.scale * update
def trainable_parameters(self):
return sum(p.numel() for p in self.parameters() if p.requires_grad)
base = nn.Linear(4096, 4096, bias=False)
lora = LoRALinear(base, rank=8)
x = torch.randn(2, 4096)
assert torch.allclose(lora(x), base(x), atol=1e-5) # B starts at zero
print(lora.trainable_parameters())from peft import LoraConfig, get_peft_model
config = LoraConfig(
r=8,
lora_alpha=16,
target_modules=["q_proj", "v_proj"],
lora_dropout=0.05,
)
model = get_peft_model(base_model, config)
model.print_trainable_parameters()
# Train normally, then save only adapter weights.
model.save_pretrained("medical-research-assistant-lora")Interview Discussion
What stays frozen in LoRA?
The original pretrained weights. Only the low-rank adapter matrices are trained.
Why does LoRA save memory?
It avoids gradients and optimizer states for most base parameters.
What is the key difference between LoRA and QLoRA?
QLoRA also stores the frozen base model in low precision to reduce memory.
Is PEFT always as good as full fine-tuning?
No. Tasks requiring large representational changes may need more capacity.
Active Recall
1. Why is full fine-tuning memory expensive?
2. Write the LoRA adapted-layer equation.
3. Compute LoRA parameters for a 4096 by 4096 matrix with rank 8.
4. Why is the rank of BA at most r?
5. What is the difference between quantising the base model and training the adapter?