LoRA from Scratch — Fine-Tuning a Giant by Training Two Thin Matrices
Full fine-tuning of a 7B model needs over 100 GB of optimizer state for a job whose actual content is small. LoRA's claim is that the *change* a model needs is low-rank, so it can be stored as a product of two thin matrices, ΔW = BA. This article builds that from the cost problem, takes the equations apart one line at a time, explains why B starts at zero and what α/r is for, counts the parameters for a real model, and ends on the three decisions that actually matter: the rank, the target layers, and what merging does at inference time.
Key points
- Full fine-tuning needs about 16 bytes per parameter, which for 6.74 billion parameters is on the order of 108 GB.
- LoRA freezes and learns the update as the product of two thin matrices, with .
- starts at zero and is random, so at step 0 and the model begins as an exact copy of the pretrained one.
- Update strength depends only on the ratio , so raising without raising shrinks the update.
- At on q and v of a 7B model, trainable parameters are about 4.2M (0.062 %), and the saved adapter is about 16 MB.
LoRA: Low-Rank Adaptation of Large Language Models
Primary source — what this article is built on
undefined2021-06-17→undefined2026-09-235y 3mo later
LoRA: Low-Rank Adaptation of Large Language ModelsEdward J. Hu, Yelong Shen, Phillip Wallis et al. · 2021-06-17 · v2arXiv:2106.09685Paper page·PDFIntrinsic Dimensionality Explains the Effectiveness of Language Model Fine-TuningarXiv:2012.13255Paper page·PDF
undefined
An important paradigm of natural language processing consists of large-scale pre-training on general domain data and adaptation to particular tasks or domains. As we pre-train larger models, full fine-tuning, which retrains all model parameters, becomes less feasible. Using GPT-3 175B as an example -- deploying independent instances of fine-tuned models, each with 175B parameters, is prohibitively expensive. We propose Low-Rank Adaptation, or LoRA, which freezes the pre-trained model weights and injects trainable rank decomposition matrices into each layer of the Transformer architecture, greatly reducing the number of trainable parameters for downstream tasks. Compared to GPT-3 175B fine-tuned with Adam, LoRA can reduce the number of trainable parameters by 10,000 times and the GPU memory requirement by 3 times. LoRA performs on-par or better than fine-tuning in model quality on RoBERTa, DeBERTa, GPT-2, and GPT-3, despite having fewer trainable parameters, a higher training throughput, and, unlike adapters, no additional inference latency. We also provide an empirical investigation into rank-deficiency in language model adaptation, which sheds light on the efficacy of LoRA. We release a package that facilitates the integration of LoRA with PyTorch models and provide our implementations and model checkpoints for RoBERTa, DeBERTa, and GPT-2 at https://github.com/microsoft/LoRA.
The cost problem that LoRA exists to solve
You have a 7-billion-parameter language model and a few thousand examples of the tone you want it to write in. The job is small. The bill is not.
Full fine-tuning means updating every one of those 7 billion numbers, and for each one the optimizer has to keep company. With Adam in mixed precision the standard tally per parameter is roughly 4 bytes of master weights, 4 bytes of gradient, and 8 bytes of Adam's two moment estimates — about 16 bytes per parameter, which for 6.74 billion parameters is on the order of 108 GB before you have stored a single activation. That does not fit on one 80 GB accelerator. Then, having paid for it, you get a full copy of the model as output: another 14 GB on disk per task, per customer, per experiment.
Something is obviously out of proportion. Teaching a model a tone, a format, or a domain vocabulary is not remotely as much information as the model already contains, yet full fine-tuning charges you as if it were. LoRA (Low-Rank Adaptation), introduced by Hu et al. in 2021, is the observation that you can pay in proportion to the change rather than in proportion to the model — and a concrete parameterisation that does it.
This article builds it from the ground: what "low rank" means and why anyone believed the update would be, the two-matrix substitution taken apart line by line, why one of the matrices starts at exactly zero, what the mysterious lora_alpha divides, the parameter count for a real model, and then the three knobs you will actually turn.
Prerequisite: rank, and what a low-rank matrix is missing
A quick definition, because everything below rests on it. The rank of a matrix is the number of linearly independent directions in it — the number of genuinely distinct rows (equivalently, columns), once you discard any that are just combinations of the others. A matrix can have rank up to 4096; it is low-rank if its rank is far below that.
The useful consequence is a factorisation. Any matrix of rank can be written as a product of a tall thin matrix and a short wide one:
In words: any rank- matrix can be rewritten, exactly, as the product of two thin matrices.
Count what that saves. Storing directly takes numbers. Storing the pair takes . For and : 16,777,216 numbers become 65,536 — a factor of 256. And the arithmetic runs the other way too: whatever and contain, their product cannot have rank above , because the information has to squeeze through an -dimensional bottleneck on the way. That is the constraint LoRA imposes, and the whole question is whether it is a constraint the task can live with.
The intuition: the update is low-rank even though the model is not
Here is the leap. Nobody claims a pretrained weight matrix is low-rank — it is dense and uses its full capacity. LoRA's claim is about the difference between the fine-tuned weights and the pretrained ones:
In words: is simply how far fine-tuning moved the weights.
The bet is that this is low-rank, or close enough. Why would it be? Because pretraining has already installed the general machinery — grammar, world knowledge, the ability to follow a pattern. Adapting to a downstream task does not rebuild that machinery; it reweights and steers it. Steering is a much lower-dimensional act than building.
That intuition has an empirical antecedent. Aghajanyan et al. (arXiv:2012.13255) measured the intrinsic dimensionality of fine-tuning: they constrained updates to a randomly chosen low-dimensional subspace and found that surprisingly few dimensions were enough to reach most of full fine-tuning's performance — and that larger pretrained models had lower intrinsic dimension. If a task's update genuinely lives in a small subspace, then forcing to be low-rank is not throwing away something you needed; it is declining to pay for capacity the task never uses.
So the design follows: do not train . Train a low-rank and add it.
The method, one line at a time
Line 1: the update is added, not overwritten
In words: the fine-tuned weights are the original weights plus a correction, and the original weights are left alone.
is the pretrained weight matrix, shape . is the weight the fine-tuned model uses. is the correction. Nothing has been gained yet — this is just naming the difference — but it sets up the key move: is never modified. It stays exactly as pretraining left it, and all learning happens inside .
Line 2: the correction is forced to be low-rank
In words: the correction can only be built by passing through a narrow channel of width .
This is the whole idea in one line. Read it right to left, the way the data flows: takes a -dimensional input and compresses it to dimensions; takes those numbers and expands them back out to . The number is the rank — the width of the bottleneck — and it is the one hyperparameter that defines LoRA's capacity. Because everything must pass through dimensions, is guaranteed by construction rather than by a penalty term.
Line 3: what actually runs in the forward pass
You never form as a matrix. For an input , expand the product:
In words: the layer's original output, plus the output of a small side path.
Two paths, summed. is the original layer, untouched and computed exactly as before. is the LoRA branch, evaluated as — first the cheap compression to numbers, then the expansion. The cost of the branch is multiply-adds against the original layer's , so at and the extra work is well under 1 %.
Line 4: freeze the base, train only the pair
In words: to lower the loss you may move only and ; is treated as a constant.
This is the line that produces every saving in the article. receives no gradient and no optimizer state; it is a constant. Gradients flow only into and , so the optimizer's Adam moments — the 8 bytes per parameter that dominated the bill — are allocated for numbers per layer instead of .
Note what does not get cheaper: the forward pass still runs through the full model, and activations must still be stored for backpropagation. LoRA cuts optimizer and gradient memory, not the base model's weight memory (you still load all 14 GB in bf16) and not the activation memory. This is the single most common misreading of what LoRA buys. The LoRA paper reports for GPT-3 175B a 10,000× reduction in trainable parameters and about a 3× reduction in GPU memory — the gap between those two numbers — more than three orders of magnitude — is exactly this distinction.
Line 5: initialisation — why starts at zero
is initialised randomly (Gaussian, or Kaiming-uniform in the reference implementation) and is initialised to all zeros. Therefore at step 0:
In words: at the moment training starts, the model produces exactly the same outputs as it would without LoRA.
The fine-tuned model begins as an exact copy of the pretrained one. No random noise is injected into a carefully trained network, so there is no initial loss spike and no need to warm back up to the base model's quality — training starts from the best available point and goes uphill from there.
Why zero only one of them, and not both? If and were both zero, the gradient of the loss with respect to each would be zero as well (each one's gradient is proportional to the other), and the pair would be stuck at zero forever — a dead symmetry. Zeroing exactly one breaks it: makes the output zero, while keeps 's gradient alive, so the first step moves off zero and the branch comes to life.
Line 6: the scaling factor
In practice the branch is scaled:
In words: sets how strong the correction is, and dividing by keeps that strength from drifting when you change the rank.
(lora_alpha in every library) is a constant you set; it is divided by the rank. The reason is that 's typical magnitude grows with — a wider bottleneck sums more terms — so without the division, doubling the rank would also roughly double the effective size of the update, and your carefully tuned learning rate would no longer apply. Dividing by makes the learning rate approximately transferable across ranks, which is what turns into a capacity dial rather than a capacity-and-step-size dial.
The practical consequence is that only the ratio matters for update strength. r=8, alpha=16 and r=16, alpha=32 have the same scale of 2 but different capacity. A frequent accident: someone raises from 8 to 64 to give the adapter more room, leaves at 16, and silently shrinks the effective update by 8×, then concludes that higher rank "did nothing."
Counting it out on a real model
Take a 7B Llama-style model: hidden size , 32 transformer layers. Apply LoRA at to the query and value projections, the paper's recommended pair. Each of those is .
Per adapted matrix:
In words: each 4096×4096 matrix needs only about 65 thousand trainable numbers.
Two matrices per layer, 32 layers:
In words: across the whole model, training touches only about 4.2 million numbers.
Against 6.74 billion total, that is 0.062 % — roughly one parameter in 1,600. And the consequences compound:
| Full fine-tuning | LoRA (, q and v) | |
|---|---|---|
| Trainable parameters | 6.74 B | 4.2 M |
| Optimizer + gradient state | ~108 GB | ~50 MB |
| Artifact saved per task | ~14 GB | ~16 MB |
| Base model weights in memory | ~14 GB | ~14 GB (frozen) |
The last row is the honest part: LoRA does not shrink the model. What it eliminates is the optimizer state, and what it changes operationally is the last-but-one row — a 16 MB adapter instead of a 14 GB checkpoint. That is why a single served base model can host hundreds of task-specific adapters, swapped per request, which is the deployment pattern LoRA really unlocked.
In code the substitution is unremarkable, which is part of the appeal:
import torch
import torch.nn as nn
class LoRALinear(nn.Module):
def __init__(self, base: nn.Linear, r=8, alpha=16):
super().__init__()
self.base = base
for p in self.base.parameters(): # freeze the pretrained weights
p.requires_grad = False
self.A = nn.Parameter(torch.randn(r, base.in_features) * 0.01)
self.B = nn.Parameter(torch.zeros(base.out_features, r)) # zero => ΔW = 0
self.scale = alpha / r
def forward(self, x):
return self.base(x) + self.scale * (x @ self.A.T) @ self.B.T
Three details carry the whole method: requires_grad = False on the base, zeros for B, and alpha / r as the scale.
Three pitfalls, which are also the only three decisions
Choosing the rank
is a capacity ceiling, and both directions have a failure mode. Too small and the update physically cannot express what the task needs — the model underfits no matter how long you train, because you have constrained the solution to a subspace that does not contain a good answer. Too large and you lose LoRA's advantages (more parameters, bigger adapter, more room to overfit a small dataset) without necessarily gaining quality.
The empirical finding that surprises people is how small "enough" usually is. The LoRA paper reports that was already competitive on their tasks, and that the top singular directions of adapters trained at and overlapped heavily — the extra rank was largely spent on directions that contributed little. The rule of thumb that follows: start at or for style, format, and tone; consider – when teaching genuinely new domain content. And since raising without raising shrinks the update, change them together or hold the ratio fixed on purpose.
If you see underfitting at low rank, do not raise first — check the learning rate. LoRA typically wants a learning rate an order of magnitude higher than full fine-tuning (say rather than ), because the parameters being trained are few, freshly initialised, and scaled down by . A great many "LoRA cannot learn this" reports are a full-fine-tuning learning rate left in the config.
Choosing which layers to adapt
LoRA is applied per matrix, so you decide which ones. The original paper, working under a fixed parameter budget, found that spreading a small budget over and beat concentrating it on one matrix at higher rank — coverage mattered more than depth. Current practice has moved further in that direction: targeting all the linear projections, attention () and the MLP ones, generally works better when budget is not the binding constraint, and this is what tools mean by target_modules="all-linear".
The trap is silent under-application. If target_modules names a pattern that matches nothing in your architecture — easy to do, since projection names differ between model families — you get a model that trains, logs a falling loss from the handful of matrices that did match, and learns far less than you intended. Print the trainable parameter count before every run (print_trainable_parameters() in PEFT). If it reads 0.01 % when you expected 0.5 %, your pattern missed.
A second trap concerns anything the task needs that no adapted matrix can reach. If you extend the tokenizer with new tokens, the embedding and output-head rows for those tokens are frozen and random, and no amount of attention-layer adaptation fixes them; those modules have to be trained (or the tokens avoided).
Merging at inference time
Because the branch is linear and added, it can be folded into the base weights once training is done:
In words: once training ends, the correction can be added into the original weights, leaving one ordinary matrix.
Do that and the LoRA matrices disappear. The served model has the original architecture, the original parameter count, and — this is LoRA's headline advantage over bottleneck adapter layers, which insert extra modules into the forward pass — exactly zero added inference latency.
Three things go wrong here in practice.
Merging into a quantized base loses precision. With QLoRA the base weights are stored in 4-bit while and train in higher precision. Adding a 16-bit update into a 4-bit tensor means re-quantising, and the small update can be partly rounded away, which shows up as a quality drop between your evaluation (adapter attached to a dequantised base) and production (merged). Either keep the adapter unmerged, or merge into the original full-precision weights rather than the quantized copy.
Merging forfeits the thing you wanted LoRA for. An unmerged adapter can be swapped per request, letting one loaded base model serve many tasks; a merged one is a whole new model. Merge when you serve one task at high volume, keep adapters separate when you serve many.
Two adapters do not simply add. is well-defined arithmetic but not a reliable composition of two behaviours: the updates were each trained assuming the other was absent, and their directions can conflict. Concatenating two rank-8 adapters also gives something of rank up to 16, not rank 8. Treat multi-adapter merging as an experiment to evaluate, not an operation that works by construction.
How it is used in practice
The people who touch LoRA day to day are machine learning engineers adapting a published foundation model to their own tone, output format, or domain vocabulary. The typical trigger is one of two situations: prompt engineering alone will not make the output format stable, or there is no room to store and serve a full copy of the model per task. The same mechanism is also widely used with image-generation models to teach a style or a character.
The settings you actually touch. In Hugging Face PEFT, a handful of LoraConfig fields is the whole surface:
randlora_alpha— capacity and strength. Move them together with the ratio in mind, as abovetarget_modules— which linear layers to adapt. Names differ between model families, so check the real names withmodel.named_modules()before writing the patternlora_dropout— dropout applied to the LoRA branch's input; a guard against overfitting when data is scarcemodules_to_save— modules to train and save in full rather than through LoRA (for example the embeddings and output head after adding tokens)- Check the fraction with
print_trainable_parameters(), save withsave_pretrained()(which writes only the adapter weights andadapter_config.json), and merge withmerge_and_unload()
Pitfalls that cause incidents if you do not know them.
- An adapter only fits the base model it was trained on. It stores nothing but the difference , so loading it onto a different base — including a different revision under the same name, or a different quantized build — will, if the shapes match, load without any error and simply produce wrong outputs. Keep
base_model_name_or_pathand the revision inadapter_config.jsonin sync with what you serve. - Do not change the prompt format between training and inference. The adapter learns the training data's format along with its content (chat template, delimiters). Serve with a different format and the adapter looks like it is doing nothing.
- Evaluate the exact artifact you ship. As shown above, merged vs. unmerged and quantized vs. not can be different models.
Questions you get in interviews and design reviews.
- "Where does LoRA save memory, and where does it not?" → It saves gradients and optimizer state. Base weights and activations are not reduced
- "Why initialise to zero, and why not both?" → So training starts identical to the base model. With both at zero, each gradient is zero because of the other, and nothing moves
- "You raised
rand nothing improved. What do you suspect?" → That was left alone and the effective scale shrank, that the learning rate is still the full-fine-tuning one, or thattarget_modulesis not actually matching - "Should you merge?" → Merge for zero latency when serving one task at volume; keep adapters separate and swap them when serving many tasks
Summary
- Full fine-tuning charges ~16 bytes of optimizer state per parameter — over 100 GB for a 7B model — for a change that is far smaller than the model
- LoRA freezes and learns the update as a product of two thin matrices: , with forcing the update to be low-rank by construction
- starts at zero so training begins as an exact copy of the pretrained model; is random so the symmetry breaks and gradients can flow
- scaling makes the learning rate roughly transferable across ranks — only the ratio sets update strength, which is why raising alone can weaken the update
- At on and , a 7B model trains 4.2 M parameters (0.062 %) and ships a ~16 MB adapter instead of a 14 GB checkpoint — but the base weights and activations still occupy full memory
- The three real decisions: the rank, which matrices to target (verify the count, do not trust the pattern), and whether to merge — merging buys zero latency but gives up per-request adapter swapping and degrades on a quantized base
For the trade-off against full fine-tuning in practice, see LoRA vs. Full Fine-Tuning. For a close reading of the original paper's own measurements, see the LoRA paper deep dive. If the rank and factorisation language felt shaky, start with Linear Algebra for AI.
Exercises
- A layer is (a Llama MLP projection). How many trainable parameters does a LoRA adapter at add, and what fraction is that of the original matrix?
- You train with
r=8, lora_alpha=16and the results are good. A colleague copies your config, setsr=64to "give it more capacity", changes nothing else, and reports that the model barely moved from the base. What happened, and what value oflora_alpharestores the original update strength? - Explain why initialising both and to zero would prevent learning entirely, while initialising alone to zero does not.
- Your evaluation runs the adapter attached to a 4-bit quantized base and scores well. You merge the adapter for production and the score drops. Give the likely cause and two ways to avoid it.
Answers
- is and is , so parameters. The original matrix has , so the adapter is about 0.54 % of it.
- Only the ratio sets the update scale. The original scale was ; after the change it is , an 8× weaker update, so the model stays close to the base. To restore a scale of 2 at , set
lora_alpha = 128. (Note that this restores the strength, not the original behaviour — the adapter now also has 8× the capacity, and the learning rate may want retuning.) - The LoRA branch output is . The gradient with respect to is proportional to , and the gradient with respect to is proportional to . If both are zero, both gradients are zero, so neither ever moves — the pair is stuck at the origin permanently. Setting alone makes the branch's output zero (so the model starts as an exact copy of the pretrained one) while keeps 's gradient nonzero, so the first optimizer step lifts off zero and both matrices become trainable thereafter.
- Merging adds a higher-precision update into 4-bit weights, so the sum must be re-quantised and part of the small update is rounded away — the merged model is not the model you evaluated. Avoid it either by keeping the adapter unmerged at serving time (evaluate and serve the same configuration), or by merging into the original full-precision weights instead of the quantized copy. In every case, re-run the evaluation on the exact artifact you intend to ship.
Comments
Sign in to comment