LoRA
Fine-tune a big model by training small low-rank updates.
Create a free account Try part of it here
Why it matters
Fine-tuning every weight of an 8-billion-parameter model takes well over 100 GB of GPU memory. Each task you train for also leaves you a new 16 GB copy of the model. LoRA trains less than 1% of the numbers instead. It fits on one GPU, and each task's result is a small file, measured in megabytes, that sits on top of one shared model. LoRA is why small teams can afford to fine-tune open models. It's the usual way people customize them today.
What you'll be able to do
- Explain how LoRA replaces a full weight update with two small low-rank matrices
- Count the trainable parameters and memory LoRA saves on a real model size
- Implement a LoRA layer, fine-tune with it, and merge it back into the weights
- Choose rank, scaling, target layers and learning rate, and know when QLoRA helps
What it covers
- Sticky notes instead of a new book
- The formula
- Worked example: a 3 × 4 table with a rank-1 adapter
- What it saves on a real model
- A LoRA layer in code
- Merge it, or keep it separate
- LoRA against full fine-tuning
- Choosing the settings
Sticky notes instead of a new book
Imagine a 1,000-page cookbook. You want to change a few recipes for your restaurant. You could reprint the whole book with your changes. Or you could leave the book as it is and add a few sticky notes. The notes are cheap, and you can swap them for a different set later.
Full fine-tuning is the reprint. It changes every number in the model and saves a complete new copy. LoRA is the sticky notes. The name stands for "low-rank adaptation". Researchers at Microsoft (Hu and colleagues) published it in 2021.
Here's the plan. A model is mostly made of weight tables, one per layer, as you saw in Phase 4. LoRA freezes those tables: training never touches them. Next to each one, it adds a small trainable add-on called an adapter. Only the adapters learn. The adapter's job is to hold the change that fine-tuning would have made to the table.
A big change built from two thin tables
The trick is how the adapter stores that change. A full change to a table with 4,096 rows and 4,096 columns would need 16.8 million numbers. LoRA builds it from two thin tables instead.
- One table is tall and thin: 4,096 rows but only a few columns.
- The other is short and wide: a few rows but 4,096 columns.
Multiply them together and you get a full-size 4,096 × 4,096 table. But you only had to store and train the two thin ones.
The size of that thin side is called the rank. It's the few columns of the tall table, and the same number of rows in the wide one. You met rank in the SVD and PCA lesson. It's how many truly separate directions a table holds. A table built from two thin tables can hold no more directions than that thin side allows. A rank-16 adapter for that table needs 16 × (4,096 + 4,096) = 131,072 numbers. That's under 1% of 16.8 million.
print(8 * (4096 + 4096))
Show the answer
65536
Two thin tables, 8 wide, give 8 × 8,192 = 65,536, which prints as 65536. That's about 0.4% of the 16.8 million in the full table.
Why should a thin change be enough? The LoRA authors observed that the changes fine-tuning makes to large models tend to be low-rank. So a thin version captures most of what matters. This was something they measured, not a proof.
In plain words: LoRA keeps the model's weights fixed. For each weight table, it learns the change as two thin tables that multiply into a full-size one.
The formula
Here is the same idea written as math. We'll take the symbols one at a time.
is one of the model's weight tables, the frozen one. (say "W prime") is the table after fine-tuning. is the tall, thin table. is the short, wide table. The letter is the rank, the thin side of both. It's small, usually somewhere from 4 to 64. Finally, (alpha) is a number you pick that sets how strong the change is.
In plain words: the new table is the old table plus a change. The change is times , multiplied by the number . Only and are trained.
Two details matter.
- starts as all zeros. Zero times anything is zero, so at the start the change is nothing. Training begins exactly at the original model and moves away from it gradually.
- The scale is a plain number, like 2. People often set to or to twice , so the scale is 1 or 2.
Show the answer
So the change starts at nothing, and the model first behaves exactly as it did
Training begins from the working model and moves away only as far as the data asks.
To count what LoRA trains, add up the two thin tables. Say the layer takes in numbers (read "d in"). Say it gives out numbers. Then holds numbers, and holds .
In plain words: an adapter has trainable numbers. The full table has . For big tables and a small rank, that's a tiny fraction.
Go deeper The full formula, with sizes
The paper writes the formula with the size of each table attached:
Read it as: " is a table of ordinary numbers with rows and columns. has rows and columns." The symbol means "real numbers", the everyday kind with decimals. The sign means "is a member of".
Why divide by ? A bigger rank adds more terms to each entry of , so the change tends to grow. Dividing by keeps its size steadier when you try a different rank. Then you don't have to retune the learning rate every time you change .
Worked example: a 3 × 4 table with a rank-1 adapter
Take a tiny layer with 4 inputs and 3 outputs. Its frozen table is 3 × 4, all ones. Use rank 1, so is 3 × 1 and is 1 × 4. Pick , so the scale is .
Say training has set to the column 1, 2, 3 and to the row 1, 0, 2, 1. Then is a 3 × 4 table. Each row is times one entry of :
- row 1: 1 × [1, 0, 2, 1] = [1, 0, 2, 1]
- row 2: 2 × [1, 0, 2, 1] = [2, 0, 4, 2]
- row 3: 3 × [1, 0, 2, 1] = [3, 0, 6, 3]
Multiply by the scale 2 to get the change. Its first row is [2, 0, 4, 2]. Add the ones from , and the new first row is [3, 1, 5, 3].
The full change has 3 × 4 = 12 numbers. The adapter stored only 3 + 4 = 7. Predict the three printouts, then run it.
import numpy as np W = np.ones((3, 4)) # frozen weights: 3 outputs, 4 inputs B = np.array([[1.0], [2.0], [3.0]]) # tall and thin: 3 rows, 1 column A = np.array([[1.0, 0.0, 2.0, 1.0]]) # short and wide: 1 row, 4 columns alpha, r = 2, 1 change = (alpha / r) * B @ A # full-size change, built from 7 numbers print(change) print(W + change) print("full change:", W.size, "numbers LoRA:", B.size + A.size, "numbers")
The first table is the change, . Every row is a multiple of [1, 0, 2, 1], which is what rank 1 means: one direction, scaled up or down. The second table is , the old weights plus the change. The last line shows the saving: 7 numbers instead of 12. Here the saving is small. For a 4,096 × 4,096 table at rank 16 it's 131,072 numbers instead of 16,777,216.
out_dim, in_dim, r = 8, 5, 2 print(r * (out_dim + in_dim), out_dim * in_dim)
Show the answer
26 40
A is 2 × 5 = 10 numbers and B is 8 × 2 = 16, so 26 in all. The full table is 8 × 5 = 40.
What it saves on a real model
Now try a real size: Meta's Llama 3 8B, a model with about 8 billion weights. Here's what you need to know about its shape.
- Each token travels through the model as a list of 4,096 numbers. That's its width.
- It has 32 layers, called transformer blocks in Phase 5.
- Each block has four attention tables:
q,k,vando. Thekandvtables are narrower, with 1,024 outputs. That's a memory-saving trick called grouped-query attention. - Each block also has three MLP tables:
gate,upanddown. Their middle width is 14,336.
The code counts LoRA's trainable numbers for each table, at ranks 8, 16 and 64. It compares two choices: adapters on the attention tables only, or on every table. Then it estimates memory.
d, kv, mlp, layers, total = 4096, 1024, 14336, 32, 8.03e9 matrices = {"q": (d, d), "k": (kv, d), "v": (kv, d), "o": (d, d), # (out, in) per layer "gate": (mlp, d), "up": (mlp, d), "down": (d, mlp)} def lora_params(out_dim, in_dim, r): return r * (out_dim + in_dim) # B is out × r, A is r × in for r in (8, 16, 64): attention = layers * sum(lora_params(o, i, r) for name, (o, i) in matrices.items() if name in ("q", "k", "v", "o")) everything = layers * sum(lora_params(o, i, r) for o, i in matrices.values()) print(f"rank {r:>2}: attention only {attention / 1e6:5.1f}M ({attention / total:.2%}) all linear layers {everything / 1e6:6.1f}M ({everything / total:.2%})") print(f"\none full 4096 x 4096 update: {d * d:,} numbers; rank 16: {lora_params(d, d, 16):,}") gb = 1e9 print(f"memory to fine-tune everything with Adam (about 16 bytes per parameter): {total * 16 / gb:.0f} GB") adapters = layers * sum(lora_params(o, i, 16) for o, i in matrices.values()) print(f"memory for LoRA (frozen 16-bit weights + rank-16 adapters with Adam): {(total * 2 + adapters * 16) / gb:.0f} GB")
Even with adapters on every table at rank 16, LoRA trains 41.9 million numbers. That's 0.52% of the model. Memory drops a lot too: about 128 GB for full fine-tuning against about 17 GB for LoRA. That's what decides whether the job fits on one GPU.
Why so much for full fine-tuning? Training doesn't only store the weights. For every weight, it also keeps a gradient and two running averages that the Adam optimizer uses. Some of these are kept at extra precision. Together that's around 16 bytes per weight. LoRA stores the frozen weights once, at 2 bytes each. It needs the full 16 bytes only for the small adapters.
The 17 GB leaves out some working memory that training also needs. In practice an 8B model fits on a single GPU with 24 to 48 GB. The saved adapter is a few hundred megabytes at most, instead of a 16 GB copy of the model.
Go deeper Where "16 bytes per weight" comes from
A common setup trains in mixed precision. For each weight it keeps:
- the weight in 16-bit form: 2 bytes;
- its gradient in 16-bit form: 2 bytes;
- a 32-bit master copy of the weight: 4 bytes;
- Adam's two running averages, 32-bit each: 8 bytes.
That adds up to 16 bytes. Exact setups vary, so treat 16 as a rule of thumb. For 8.03 billion weights, 8.03 × 16 ≈ 128 GB.
A rank-16 LoRA on a 4,096 × 4,096 table trains under 1% of the numbers. Raise the rank and see the cost grow only in a straight line.
A LoRA layer in code
Here's an adapter wrapped around one ordinary layer. The class LoRALinear does four things.
- It keeps the original layer as
baseand freezes it.requires_grad_(False)tells PyTorch not to train those numbers. - It creates
Awith small random numbers. - It creates
Bfull of zeros, so the adapter starts by adding nothing. - In
forward, it returns the base layer's answer plus the scaled adapter's answer.
The cell then runs three tests. It checks that the new layer starts out identical to the frozen one. Then it pretends training changed B and merges the adapter into one plain layer. Finally it counts the trainable numbers.
import torch import torch.nn as nn class LoRALinear(nn.Module): """A frozen Linear layer plus a trainable low-rank update: W x + (alpha / r) B A x.""" def __init__(self, base, r=4, alpha=8): super().__init__() self.base = base for p in base.parameters(): p.requires_grad_(False) self.A = nn.Parameter(torch.randn(r, base.weight.shape[1]) / r) self.B = nn.Parameter(torch.zeros(base.weight.shape[0], r)) # zero: starts as the original layer self.scale = alpha / r def forward(self, x): return self.base(x) + (x @ self.A.T @ self.B.T) * self.scale torch.manual_seed(0) layer = LoRALinear(nn.Linear(32, 32)) x = torch.randn(5, 32) print("starts identical to the frozen layer:", torch.allclose(layer(x), layer.base(x))) with torch.no_grad(): layer.B.normal_() # pretend training changed B merged = nn.Linear(32, 32) merged.weight.copy_(layer.base.weight + layer.scale * layer.B @ layer.A) merged.bias.copy_(layer.base.bias) print("merged single layer gives the same output:", torch.allclose(merged(x), layer(x), atol=1e-5)) print("trainable:", sum(p.numel() for p in layer.parameters() if p.requires_grad), "of", sum(p.numel() for p in layer.parameters()))
Both tests print True. The last line shows that only 256 of the layer's 1,312 numbers are trainable: A and B hold 128 each, and the frozen base holds the rest. (torch here is the course's in-browser PyTorch subset.)
Go deeper Why merging gives exactly the same answers
In the formula, the layer turns an input into . With the adapter it gives . Multiplying works the same whether you add the tables first or add the answers afterwards:
In plain words: running the old table and the adapter separately, then adding, is the same as adding the adapter into the table once and running that.
With small numbers: if , the change is and , then and .
The code writes x @ self.A.T @ self.B.T because it stores each input as a row. That's the same calculation as , written for rows.
Show the answer
So B·A is zero and training starts exactly from the original model's behavior
With B = 0 the adapter adds nothing at first. Training then moves it away from the pretrained model gradually.
Merge it, or keep it separate
After training you have two choices.
- Merge the adapter into the weights. You compute once and save it as an ordinary model. It runs at exactly the old speed, with no extra cost per token.
- Keep adapters separate. You load one base model and many small adapters, one per customer or task. Each request uses the adapter it needs.
Back to the cookbook: merging is copying your sticky notes into the pages. Keeping them separate means one shared book, with a different set of notes for each reader.
Show the answer
Merge the adapter for the single client; keep adapters separate for the 30 clients
A merged model has no extra cost per token. Separate adapters let 30 clients share one base model, with the right adapter picked per request.
LoRA against full fine-tuning
Now a real contest. The model is tiny-gpt-chat, the course's small chat model. Right now it answers with a full product description. The task is to teach it a new style: answer with the first sentence only. The training data is the same 300 chats as in the SFT lesson.
One copy gets full fine-tuning, so every weight trains. A second copy gets rank-4 LoRA adapters on every weight table inside its transformer blocks. Everything else in that copy stays frozen. Both are then tested on 20 products that aren't in the catalog.
The cell has four parts.
LoRALinearandadd_lora. The second one freezes every weight and wraps each table in an adapter.- The data. It builds one chat per product, with the prompt labeled -100 so only the reply counts.
score. It asks about the 20 unseen products and counts the replies that name the product, are one sentence long, and stop properly.finetune. This is the usual training loop: 201 steps of 16 chats each.
It takes a while to run. The Run button counts the seconds.
import re import requests import torch import torch.nn as nn from tinygpt import load_pretrained class LoRALinear(nn.Module): def __init__(self, base, r=4, alpha=8): super().__init__() self.base = base self.A = nn.Parameter(torch.randn(r, base.weight.shape[1]) / r) self.B = nn.Parameter(torch.zeros(base.weight.shape[0], r)) self.scale = alpha / r def forward(self, x): return self.base(x) + (x @ self.A.T @ self.B.T) * self.scale def add_lora(model): for p in model.parameters(): p.requires_grad_(False) # freeze every original weight for blk in model.blocks: blk.qkv, blk.proj = LoRALinear(blk.qkv), LoRALinear(blk.proj) blk.mlp[0], blk.mlp[2] = LoRALinear(blk.mlp[0]), LoRALinear(blk.mlp[2]) return model _, tok = load_pretrained("tiny-gpt-chat") end = tok.token_id("<end>") docs = requests.get("https://api.gradient.test/v1/corpus").json()["text"].strip().split("\n") product = re.compile(r"^(?:Meet the|Our|The) ([A-Z][a-z]+) (.+?)(?:,| is| are) made") rows = [] for d in docs: # the new style: first sentence only name, kind = product.match(d).groups() prompt = tok.encode(f"<user> Describe the {name} {kind} . <assistant>") answer = tok.encode(d.split(". ")[0] + ".") + [end] rows.append((prompt + answer, [-100] * len(prompt) + answer)) T = max(len(ids) for ids, _ in rows) X = torch.tensor([ids + [end] * (T - len(ids)) for ids, _ in rows]) Y = torch.tensor([lab + [-100] * (T - len(lab)) for _, lab in rows]) seen = {product.match(d).groups() for d in docs} names, kinds = sorted({n for n, _ in seen}), sorted({k for _, k in seen}) unseen = [(n, k) for n in names for k in kinds if (n, k) not in seen][::8] @torch.no_grad() def score(model): torch.manual_seed(0) good = 0 for n, k in unseen: ids = torch.tensor([tok.encode(f"<user> Describe the {n} {k} . <assistant>")]) reply = tok.decode(model.generate(ids, 30, temperature=0.7, stop_id=end)[0, ids.shape[1]:].tolist()) good += f"{n} {k}" in reply and reply.endswith("<end>") and reply.count(".") == 1 return good / len(unseen) def finetune(model, lr, steps=201): trainable = [p for p in model.parameters() if p.requires_grad] opt = torch.optim.AdamW(trainable, lr=lr, weight_decay=0.0) torch.manual_seed(1) for step in range(steps): b = torch.randint(0, len(X), (16,)) logits = model(X[b][:, :-1]) loss = nn.functional.cross_entropy(logits.reshape(-1, logits.shape[-1]), Y[b][:, 1:].reshape(-1)) opt.zero_grad() loss.backward() opt.step() return sum(p.numel() for p in trainable) full, _ = load_pretrained("tiny-gpt-chat") print(f"before: {score(full):.0%} of {len(unseen)} new products answered in the one-sentence style") n = finetune(full, lr=1e-3) print(f"full fine-tuning: {score(full):.0%} ({n:,} parameters trained)") lora = add_lora(load_pretrained("tiny-gpt-chat")[0]) n = finetune(lora, lr=1e-2) print(f"LoRA, rank 4: {score(lora):.0%} ({n:,} parameters trained)")
LoRA trains about a tenth as many numbers here. It still gets most of the way: 50% of the new products against 65% for full fine-tuning, after the same number of steps. On large models the gap is usually much smaller. A big model needs only a small change for a task like this, compared with its size.
Look at the learning rates too. Full fine-tuning used 0.001 (1e-3), and LoRA used 0.01 (1e-2). The adapters start at zero and are small, so LoRA usually needs a learning rate about 10 times higher.
A trap: new tokens
One problem turned up while building this lesson. The same LoRA setup was tried on the base model, to teach it the chat format from scratch. It barely worked.
The reason was the chat tokens: <user>, <assistant> and <end>. They were new to the base model. Each token's starting vector comes from the embedding table, and that table was frozen. The new tokens' vectors had never been trained, and adapters on other layers couldn't fix that.
With real libraries the fix is straightforward. When you add new special tokens, also train the embedding table and the output layer.
Show the answer
The adapters start at zero and are small, so they need bigger steps to have an effect
LoRA typically needs a learning rate around 10 times higher than full fine-tuning, because the adapters begin at zero and stay small.
Choosing the settings
- Rank : 8 to 16 is a common place to start. Raise it for harder, bigger changes. is often set to or .
- Which tables: the original paper put adapters on the attention tables only. Putting them on every table, attention and MLP, usually works better. The extra cost is small.
- QLoRA (Dettmers and colleagues, 2023) goes further. It stores the frozen model in 4-bit form, which takes a quarter of the memory of 16-bit. The quantization lesson explains how. LoRA adapters then train on top. With QLoRA, a 65-billion-parameter model could be fine-tuned on a single 48 GB GPU.
In real projects you'd use Hugging Face's PEFT library. Adding adapters to a model takes a few lines:
from peft import LoraConfig, get_peft_model from transformers import AutoModelForCausalLM model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct") config = LoraConfig(r=16, lora_alpha=32, lora_dropout=0.05, task_type="CAUSAL_LM", target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]) model = get_peft_model(model, config) model.print_trainable_parameters() # well under 1% of the parameters
Here r=16 is the rank and lora_alpha=32 is , so the scale is 2. The list target_modules names the seven tables in each block that get adapters. The result trains with the same SFTTrainer or DPOTrainer you used before.
For marketers: LoRA is why "a fine-tuned model per brand or per client" is affordable. You keep one shared base model. Each brand's voice lives in a small adapter that loads when that brand's request comes in.
Practice what you just read
- 5 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
After training a LoRA adapter, what does merging it do?