Fine-Tuning with PEFT — LoRA and Adapters
Tune less than 1% of a model's parameters and get 95% of the performance. LoRA, adapters, and prefix tuning — when and how to use each.
Full fine-tuning a 7B parameter model requires 28GB of GPU memory just to store the weights. LoRA fine-tunes the same model using 16MB of trainable parameters — on a single consumer GPU.
Module 50 showed full fine-tuning — update all 110M parameters of BERT for 3 epochs. That costs 4GB of GPU memory and 30 minutes. Acceptable for BERT. Completely impractical for LLaMA-3 (8B), Mistral (7B), or Falcon (40B). Full fine-tuning a 7B model requires storing the model weights (28GB in fp32), the gradients (another 28GB), and the optimiser states (56GB for Adam). Total: 112GB VRAM. No consumer GPU has that.
PEFT (Parameter-Efficient Fine-Tuning) solves this by updating only a tiny fraction of the model's parameters while freezing the rest. LoRA — the most popular PEFT method — adds small low-rank matrices alongside the frozen weight matrices. Only the small matrices are trained. Total trainable parameters: typically 0.1–1% of the full model. GPU memory required: a fraction of full fine-tuning. Quality: 90–95% of full fine-tuning.
A senior engineer at Stripe knows everything about payments. You want to teach them your company's specific internal processes. You do not re-hire them and retrain them from scratch — you give them a small notebook of company-specific notes to carry alongside their existing expertise. LoRA is that notebook — small, lightweight, task-specific, sits alongside the frozen base model.
At inference time: base model knowledge + LoRA notebook = specialised expert. You can swap notebooks — same base model, different LoRA adapters for different tasks. One GPU, many specialists.
LoRA — Low-Rank Adaptation — the math in plain English
A weight matrix W in a Transformer has shape (d_out, d_in). For BERT's attention layers, d_in = d_out = 768. That is 768 × 768 = 589,824 parameters per matrix. LoRA's key insight: the change needed to adapt a pretrained model to a new task has low intrinsic rank — it lives in a much smaller subspace than the full matrix dimension.
Instead of updating W directly, LoRA adds two small matrices: A of shape (r, d_in) and B of shape (d_out, r) where r is the rank — typically 4, 8, or 16. The effective weight update is B @ A — a rank-r matrix. Training only A and B requires r × (d_in + d_out) parameters instead of d_in × d_out. With r=8, d=768: 8 × 1536 = 12,288 parameters vs 589,824. That is a 48× reduction per matrix.
HuggingFace PEFT library — LoRA in three lines of code
The PEFT library wraps any HuggingFace model with LoRA in three steps: define a LoraConfig, call get_peft_model(), done. PEFT automatically identifies which layers to apply LoRA to, freezes everything else, and gives you a model where only the LoRA matrices require gradients.
LoRA + quantisation — fine-tuning a 7B model on a single GPU
LoRA's main value is not for BERT (110M) — you can full fine-tune BERT easily. The value is for 7B, 13B, and 70B parameter models where full fine-tuning is impossible on consumer hardware. Combine LoRA with quantisation (4-bit or 8-bit weights via bitsandbytes) and you can fine-tune a 7B model on a 16GB GPU. This is QLoRA — Quantised LoRA.
Adapters, prefix tuning, and prompt tuning — when each is appropriate
LoRA is the most popular PEFT method but not the only one. Three other methods are widely used in production, each with different trade-offs between parameter count, training stability, and inference overhead.
Low-rank matrices added alongside frozen attention weights. Merged into weights at inference — zero latency overhead.
Small bottleneck MLP inserted between Transformer layers. Frozen base, only adapters train. Original method from Houlsby et al. 2019.
Prepend trainable virtual tokens (prefix) to the key and value in every attention layer. Only the prefix vectors are trained.
Prepend trainable soft tokens to the INPUT only (not every layer). Simplest PEFT method — only a few thousand parameters.
Merging LoRA weights — zero inference overhead in production
During training, LoRA runs a separate forward pass through B @ A and adds it to the frozen W output. At inference this adds latency. LoRA can be merged: the weight update B @ A is computed once and added directly to W — producing a standard model with no extra computation. Merged model = full fine-tuned quality at full fine-tuned speed. The LoRA matrices can be discarded after merging.
Every common PEFT mistake — explained and fixed
Multi-tenant LoRA serving — one GPU, hundreds of customer-specific models
A B2B SaaS company offering an LLM-powered feature to a thousand customers faces a scaling problem full fine-tuning cannot solve economically: full fine-tuning one 7B model per customer means a thousand copies of a 14GB model, a thousand GPUs (or a thousand cold-loads competing for a shared pool), and a thousand full retraining runs every time the base model or a customer's data updates. Most of those thousand models differ from the base model by less than one percent of their parameters. LoRA is what makes serving this economically possible: one shared base model in GPU memory, and a few megabytes of adapter weights per customer, loaded and swapped per request.
This is a materially different problem from the training-side LoRA workflow covered earlier in this module. Training produces one adapter. Production serving has to keep hundreds or thousands of trained adapters on hand and route each incoming request to the right one, without paying the cost of a full model reload per customer and without one customer's traffic starving another's latency.
Adapter versioning matters as much as adapter serving. Each retrain of a customer's adapter gets a new version tag (acme_corp-v3 above), the previous version stays available, and a bad retrain can be rolled back by pointing the router at the prior version — no base-model redeploy required, because the base model never changed.
Five things people get wrong about LoRA and PEFT
This suggests LoRA first computes (or could compute) the full-rank weight update and then squeezes it down to rank r — like a JPEG of a photo. That is backwards. LoRA never computes a full-rank update at all; it constrains the trainable update to be low-rank from the very first gradient step, based on the empirical observation (from the original LoRA paper) that the useful update for adapting a pretrained model to a new task tends to live in a low-dimensional subspace. There is nothing to compress because the full-rank version is never formed. If the task genuinely needs a high-rank update, LoRA at low r will underfit — not "lose information" the way compression does.
Rank controls capacity, and more capacity sounds like it should always help — but in practice most tasks saturate well before r=64, and pushing rank higher mostly adds trainable parameters and compute without a measurable accuracy gain, since the intrinsic rank of the needed update is a property of the task, not something you can buy more of. Past that saturation point, a larger r increases the risk of overfitting on small fine-tuning datasets and slows training for no return. The right instinct is to sweep r across 4, 8, 16, and 32 on a validation set for your specific task, not to default to the largest rank you can afford.
During training this is true — the forward pass runs W×x and B@A×x as two separate operations and adds them. But LoRA's defining trick is that B@A is just a matrix, the same shape as W, so it can be added directly into W once training is done: W_merged = W + (α/r)·B@A. After merge_and_unload(), the model has exactly the same architecture and the same number of weight matrices as the original — no adapter, no extra addition, no latency overhead at all. The "extra layer" framing only applies to a few other PEFT methods (adapters, prefix tuning) that genuinely cannot be merged away because they change the computation graph, not just the weights.
LoRA reliably reaches 90–95% of full fine-tuning quality for tasks that adapt the model's style, format, or narrow behaviour — the update genuinely is low-rank there. It is a worse fit when the task requires injecting large amounts of new factual knowledge or substantially shifting the model's underlying capabilities (e.g. teaching a language model an entirely new language it saw little of in pretraining) — that kind of change often needs a higher-rank, more expressive update than LoRA can efficiently represent. Full fine-tuning, continued pretraining, or RAG-for-knowledge remain the better tool in those cases; LoRA did not replace them, it expanded the set of tasks where you no longer need them.
Because each adapter is "just" a small B@A matrix, it is tempting to assume adding two of them (or averaging their weights) gives you a model that is good at both tasks at once. In practice, adapters trained independently occupy overlapping but different subspaces of the weight space, and naively summing them causes destructive interference — the merged behaviour is often worse at both tasks than either adapter alone. Serving multiple tasks from one base model safely means keeping adapters separate and swapping which one is active per request (exactly the pattern PeftModel.from_pretrained shown earlier supports), or using purpose-built multi-adapter composition methods, not ad hoc addition.
PEFT and LoRA — 5 questions interviewers actually ask
The hypothesis, backed empirically by the original LoRA paper (and earlier work on intrinsic dimensionality of fine-tuning), is that the change a pretrained model needs to specialise for a downstream task lives in a much lower-dimensional subspace than the full weight matrix it is applied to — even though the base weight itself is high-rank and uses its full capacity to encode broad pretraining knowledge. Concretely: adapting a 768×768 attention matrix to a new task does not require exploring all 589,824 independent directions of change; a rank-8 or rank-16 subspace captures nearly all of the useful signal. LoRA exploits this directly by parameterising the update as B@A with rank r, training only 2×r×d parameters instead of d², and getting comparable task performance because it was never necessary to search the full-rank space in the first place.
Rank r sets the dimensionality of the low-rank update — it is the actual capacity knob: higher r means more trainable parameters and a richer space of representable updates, at the cost of more compute and overfitting risk on small datasets. Alpha is a separate scaling factor applied to the LoRA output as α/r, controlling how strongly the update perturbs the frozen base weights relative to its own magnitude — it does not add capacity, it tunes how aggressively that capacity is expressed. In practice most teams do not tune them independently: a common heuristic is alpha = 2×r (so the effective scale stays roughly constant as rank changes), then sweep r itself — typically 8 or 16 for classification-style tasks and up to 32-64 for more complex generation tasks — against a validation metric.
The decision axis is how much you need to change the model versus how much compute and data you have. Full fine-tuning updates every weight — highest ceiling on quality and the only real option when the task requires deep, broad changes to the model's behaviour or knowledge, but it needs the most GPU memory (model + gradients + optimiser states for every parameter) and the most labelled data to avoid overfitting all those parameters. LoRA sits in the middle: 90-95% of full fine-tuning quality at a fraction of the memory, because it exploits the low-rank structure of most task adaptations, and it is the default choice for the vast majority of production fine-tuning today. Prompt tuning goes further — training only a handful of soft input tokens with no per-layer weight changes at all — which underperforms LoRA at small-to-medium model scale but becomes surprisingly competitive at 10B+ parameters, where the frozen base model is already so capable that a small nudge to its input is enough to steer behaviour.
The forward pass during training is h = W×x + (α/r)·(B@A)×x, where W is frozen and A, B are the only trainable matrices. Because matrix multiplication distributes over addition, this is algebraically identical to h = (W + (α/r)·B@A)×x — a single matrix, the same shape as W, multiplied once against x. So after training finishes, you compute W_merged = W + (α/r)·B@A exactly once, write it back in place of W, and discard A and B entirely. The resulting model has the identical architecture, weight shapes, and forward pass as a model that was fully fine-tuned from scratch — there is no separate adapter branch left to execute, so inference cost is exactly the same as the unmodified base model. This only works for LoRA (and similar linear reparameterisations) specifically because the adaptation is additive and linear in x; adapters and prefix tuning change the computation graph itself and cannot be collapsed this way.
QLoRA combines LoRA with 4-bit quantisation of the frozen base model weights (NF4 — NormalFloat4 — plus double quantisation of the quantisation constants themselves), which shrinks a 7B model's weight storage from roughly 14-28GB down to about 3.5GB. Because only the small LoRA matrices are trained, gradients and optimiser states are computed and stored only for those — a fraction of a percent of total parameters — so the two most memory-hungry pieces of full fine-tuning (per-parameter gradients and per-parameter Adam state) barely register. The catch is that 4-bit quantisation is inherently lossy: it introduces small numerical errors into every frozen weight, which in principle could hurt output quality. In practice, the QLoRA paper showed this loss is largely absorbed because the LoRA adapter is trained on top of the quantised weights and learns to compensate — but it does mean QLoRA models can be marginally less precise on tasks sensitive to exact numerical behaviour, and it adds a dequantisation cost at inference unless you also merge and requantise afterward.
You can fine-tune any model efficiently. Next: give any LLM access to your own documents.
Fine-tuning teaches a model new behaviour patterns from labelled data. But what if you want the model to answer questions about documents it has never seen — your company's internal knowledge base, a legal corpus, a product catalogue? Fine-tuning cannot help here — the model still cannot access documents not in its weights. Retrieval-Augmented Generation (RAG) solves this by combining a retriever (find relevant documents from a vector database) with a generator (produce an answer grounded in those documents). Module 52 builds a complete RAG pipeline for a Stripe knowledge base.
Vector databases, semantic search, chunking strategies, and the full RAG pipeline from document to answer.
🎯 Key Takeaways
- ✓Full fine-tuning a 7B model requires 112GB VRAM. LoRA fine-tunes the same model with 0.1–1% of parameters — fitting on a single 16GB GPU. The trade-off: 90–95% of full fine-tuning quality at 1% of the cost.
- ✓LoRA adds two small matrices A (r × d_in) and B (d_out × r) alongside each frozen weight matrix W. The effective update is B @ A — a rank-r approximation of the full weight change. B is initialised to zeros so LoRA starts identical to the pretrained model and gradually diverges.
- ✓QLoRA combines LoRA with 4-bit quantisation (bitsandbytes NF4) — the frozen base model weights are stored in 4-bit, reducing a 7B model from 28GB to 3.5GB. Only the LoRA matrices are stored in fp16. This enables 7B fine-tuning on a single consumer GPU.
- ✓PEFT library workflow: LoraConfig → get_peft_model(base_model, config) → standard Trainer. Three lines to convert any HuggingFace model to LoRA. Always call model.print_trainable_parameters() to verify the right layers are being trained.
- ✓Target modules must match your model family exactly: BERT → ["query","value"], DistilBERT → ["q_lin","v_lin"], LLaMA → ["q_proj","v_proj","k_proj","o_proj"], GPT-2 → ["c_attn"]. Use target_modules="all-linear" as a safe fallback when unsure.
- ✓Merge LoRA weights before production deployment: model.merge_and_unload() adds B @ A directly into W and discards the LoRA matrices. The merged model runs at full speed with no PEFT overhead — indistinguishable from a fully fine-tuned model at inference time.
Discussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.