Last week I fine-tuned a 0.6-billion-parameter language model on my laptop. It went from failing a task 87% of the time to nailing it 100% of the time — in about 40 seconds, using 3 GB of memory, by training just 0.2% of the model’s parameters.

Every number in that sentence should feel slightly impossible. Fine-tuning a model usually means updating all its weights — that’s the whole idea of “tuning.” How do you change a model’s behavior by leaving 99.8% of it frozen? What are you even training, if not the model?

The answer is LoRA — Low-Rank Adaptation — and it’s one of those ideas that feels like a magic trick until you see the mechanism, at which point it becomes obviously, almost boringly, correct. This post builds it from the ground up: what fine-tuning actually is, why it’s so expensive, the one bet LoRA makes, and exactly how two tiny matrices pull it off. Two playgrounds along the way let you feel the trick instead of taking my word for it.

Let’s start before LoRA, with plain fine-tuning.

Fine-tuning is just W + ΔW

A neural network is a stack of weight matrices. Running it is a chain of matrix multiplies: an input vector xx meets a weight matrix WW, producing an output h=Wxh = Wx that flows to the next layer. Those WW matrices — hundreds of millions to billions of numbers — are the model. All its knowledge lives in them.

Fine-tuning changes that knowledge. You show the model examples of the behavior you want, and for each weight matrix you compute an update — call it ΔW\Delta W — that nudges the model toward the target. Then you apply it:

W=W+ΔWW' = W + \Delta W

That’s the whole loop: run a forward pass, use backpropagation to compute a ΔW\Delta W that lowers the loss, add it to WW, repeat.

1 · forward2 · backprop3 · updateWpretrainedget ΔWvia gradientsW′ = W + ΔW
Ordinary fine-tuning: a forward pass, backprop produces a weight update ΔW, and the weights become W′ = W + ΔW. Note ΔW is a full matrix — exactly the same shape as W.

This works perfectly. It’s how essentially every fine-tuned model was made for years. But look closely at that ΔW\Delta W: it has exactly the same shape as WW. If WW is a 1000×10001000 \times 1000 matrix, then ΔW\Delta W is a million numbers too. You’re learning and storing a second copy of the model’s size, for every matrix, plus optimizer bookkeeping on top. This is full fine-tuning, and its cost is the whole problem.

The problem: ΔW is as big as the model

Here’s what “as big as the model” costs in practice. For each trainable number you don’t just store the number — you store its gradient and (for the Adam optimizer) two running statistics, roughly ~16 bytes per parameter during training.

ModelParametersFull-tune training memory (rough)
0.6B600M~10 GB
7B7B~112 GB (multiple GPUs)
70B70B~1.1 TB (a cluster)

That’s why full fine-tuning a 7B model doesn’t fit on your laptop, or even on one datacenter GPU. And it feels wasteful: to teach a model to emit clean JSON, do we really need to relearn a fresh million-number update for every weight matrix? That question — is the update really as complex as the model? — is the crack LoRA pries open.

The bet: the update is simpler than the model

LoRA’s founding bet is a claim about ΔW\Delta W, not about WW:

Note
The pretrained model is genuinely complex — it took trillions of tokens to build. But the change needed to adapt it to one narrow task is simple. In linear-algebra terms, ΔW\Delta W is low-rank: its information lives in only a handful of directions, even though it’s written as a big matrix.

“Low-rank” has a precise, visual meaning, and it’s worth seeing before we use it. Any matrix can be built up as a sum of simple rank-1 pieces, ordered from most to least important. A low-rank matrix is one where the first few pieces already capture almost everything — the rest is nearly noise.

Drag the rank slider below. On the left is a full matrix; on the right is the same matrix rebuilt from only its first rr pieces. Watch how few you need before the two are indistinguishable:

What rank captures
full matrix · rank 14
rebuilt from rank 3
how much each rank contributes (its singular value)
energy captured
98.4%
reconstruction error
12.5%
numbers used
84 / 196
Drag r from 1. The first bars are tall — those ranks hold almost all the matrix's energy — so the rebuilt grid matches the original almost immediately, while the later, tiny bars barely change anything. That is LoRA's bet: a fine-tune's update is low-rank, so a small r captures nearly all of it.
What rank captures
Drag r from 1. The first bars are tall — those ranks hold almost all the matrix's energy — so the rebuilt grid matches the original almost immediately, while the later, tiny bars barely change anything. That is LoRA's bet: a fine-tune's update is low-rank, so a small r captures nearly all of it.

Notice the collapse: by rank 4 or 5 the reconstruction is essentially perfect, at a fraction of the numbers. If a fine-tune’s ΔW\Delta W has this property — and empirically it does — then storing the full ΔW\Delta W is enormously wasteful. You could store just the first few pieces. That’s precisely what LoRA does.

The trick: write ΔW as A · B

Instead of learning a full ΔW\Delta W, LoRA learns it already factored into two skinny matrices whose product reconstructs a low-rank update:

ΔWAB,ARdout×r,BRr×din\Delta W \approx A \, B, \qquad A \in \mathbb{R}^{\,d_{out}\times r}, \quad B \in \mathbb{R}^{\,r\times d_{in}}

The shared inner dimension rr — the rank — is small (think 8, 16, 32). One matrix is tall and skinny, the other short and wide, and they meet at the tiny rr:

ΔWd_out × d_in=Ad_out × r×Br × d_inr (small)
ΔW (d_out × d_in) is never stored directly. It is the product of a tall A and a wide B that meet at a small shared dimension r — the rank. Applied to an input, B first squeezes it down to r numbers, then A expands back to full size.

Why two matrices instead of one? Because a single matrix the size of ΔW\Delta W would just be ΔW\Delta W — no savings. The magic is that a tall × wide product reconstructs a big matrix from few numbers, and forcing them through the narrow rr is exactly what makes the result low-rank. A·B isn’t notation for a single thing; it’s a genuine factorization that trades a big matrix for two small factors.

Note
Letter conventions differ between sources — some write ΔW=BA\Delta W = BA with the letters swapped. Don’t track the letters; track the shapes: one matrix is (d_out × r), the other (r × d_in), and they share the small r. Applied to an input, the wide one squeezes it down to r numbers, the tall one expands it back up.

And crucially, you never choose the big dimensions. doutd_{out} and dind_{in} come from the model’s architecture; the library reads them off each weight and sizes AA and BB automatically. The only number you pick is rr.

The bottleneck: shrink to r, then expand

This factorization has a physical meaning worth holding onto, because it is the intuition for why LoRA works. Reading the adapter in the order it actually runs — right to left, since ABx=A(Bx)ABx = A(Bx) — it does two things:

  • BB is a _down-projection_. It takes the full dind_{in}-dimensional input and squeezes it into just rr numbers. With only rr slots to write into, it is forced to keep the few input directions that matter for this task and discard the rest — it writes a summary.
  • AA is an _up-projection_. It takes those rr summary numbers and expands them back into a full doutd_{out}-dimensional adjustment — deciding how each captured direction should nudge the output.

So the update can never be arbitrary: it must pass through an rr-wide bottleneck — read at most rr features going in, write at most rr patterns coming out. That squeeze is the low-rank property, enforced by construction. And it’s enough, because — as the reconstruction playground showed — the change a fine-tune actually needs lives in only a handful of directions. LoRA doesn’t hope the update is simple; it builds a pipe too narrow for it to be anything else, and lets training find the best simple update that fits through.

Where the savings actually come from

Now the payoff — and an honest surprise. Count the numbers. A full update is dout×dind_{out} \times d_{in} parameters. The LoRA version is AA plus BB: r×(dout+din)r \times (d_{out} + d_{in}). So the saving is real only when

r×(dout+din)  <  dout×dinr \times (d_{out} + d_{in}) \;<\; d_{out} \times d_{in}

For a square d×dd \times d matrix, that simplifies to a clean rule: LoRA saves when r<d/2r < d/2.

The surprise: on a small matrix, LoRA saves nothing. A 4×44\times4 update is 16 numbers; factored at r=2r=2 it’s 2×(4+4)=162\times(4+4) = 16 — identical. That’s the break-even point. The savings are a big-matrix phenomenon, and they grow explosively with size. Feel it:

The LoRA budget
weight matrix (d × d)
full ΔW = d × d16.78M params
LoRA = 2 · r · d131.1K params
d = 4096, r = 16 → full = 16.78M, LoRA = 131.1K. LoRA is 128.0× smaller.
Break-even is r = d/2. Try toy · 4 at r=2 (break-even — the savings vanish), then Llama attn · 4096 at r=16 (~128× smaller). Real matrices are thousands wide, so a small r always wins big.
The LoRA budget
Break-even is r = d/2. Try toy · 4 at r=2 (break-even — the savings vanish), then Llama attn · 4096 at r=16 (~128× smaller). Real matrices are thousands wide, so a small r always wins big.

Set it to the toy 44 at r=2r=2 and the bars are equal — no win. Now jump to a real attention matrix (40964096) at r=16r=16: the full update is 16.7 million numbers, LoRA’s is 131 thousand — 128× smaller, for the same-shaped update. Because real weight matrices are thousands wide and rr stays tiny, LoRA sits far below the d/2d/2 break-even and the savings are enormous. That 0.2% from the opening is this ratio, summed over every matrix in the model.

This also answers “why not just make AA and BB full-size?” Full-size factors would be more numbers than ΔW\Delta W itself — and no longer low-rank. The whole point is the skinny shared bottleneck.

Why untrained matrices don’t break the model

Here’s the objection that trips everyone up: AA and BB are new matrices. They know nothing — not language, not your task. If we bolt them onto a carefully pretrained model, shouldn’t they inject garbage and wreck it?

The fix is a beautiful one-liner. In our letter convention, AA starts random and BB starts as all zeros (some libraries swap the letters). Because one factor is zero, the product is exactly zero:

ΔW=AB=A0=0h=Wx+0=Wx\Delta W = A\,B = A \cdot 0 = 0 \quad\Longrightarrow\quad h = Wx + 0 = Wx

At step zero, the adapter contributes nothing. The model behaves exactly like the untouched base. Training then moves the zero matrix off zero and reshapes the random matrix with it, so the adapter grows from no-op to useful correction.

MomentAdapter mathWhat the model receives
Before trainingB=0B = 0, so ABx=0ABx = 0h=Wxh = Wx — exactly the frozen base
During trainingBB becomes nonzero, AA also adjustsh=Wx+ABxh = Wx + ABx — base output plus a learned nudge
After trainingABAB is the learned low-rank updateSame base model, different activations flowing forward

So the untrained adapter never starts as “random garbage that corrupts the model.” It starts as a zero delta. The only thing training is allowed to learn is the extra nudge the frozen model needs: “given the features the frozen model already computed here, push the activation this way.”

How it’s wired: a side path, not a new layer

So where does the adapter physically sit? Not as a new layer stacked on top, and not as a separate model running in parallel. It sits beside each weight matrix it adapts. The same input xx flows into both the frozen WW and the adapter, and their outputs are summed:

h=Wx+ABx=(W+ΔW)xh = W x + A B x = (W + \Delta W)\,x
xW · x (frozen)Bdown → rAup → d_out+h
LoRA is a side path. The input feeds both the frozen W and the low-rank adapter (down to r, back up); the two outputs are added. The base weights never change — but the activation h passed to the next layer does.

The static diagram shows the wiring. The animation below shows what happens inside the lower path: BB compresses many input features into rr bottleneck slots, AA expands those few slots into a full-size correction, and the correction gets added to the frozen output. Toggle the adapter off to see the step-0 case, where B=0B = 0 makes every bottleneck slot zero.

The side path, inside the bottleneck
xd_in featuresfrozen W · not trainedd_out × d_in · denseWxBr × d_in · widez = BxAd_out × r · tallABx+hh = Wx + ABx
B squeezes x down to the r-dimensional bottleneck z; A expands z back into the correction ABx, which is added to the frozen Wx. Every correction must pass through those r nodes. Toggle the adapter off (B = 0) for the step-0 case, where h = Wx
The side path, inside the bottleneck
B squeezes x down to the r-dimensional bottleneck z; A expands z back into the correction ABx, which is added to the frozen Wx. Every correction must pass through those r nodes. Toggle the adapter off (B = 0) for the step-0 case, where h = Wx

This settles two common misreadings at once:

  • The base weights WW never change — they stay frozen, read-only.
  • But the activation hh passed to the next layer does change — it’s the original WxWx plus the adapter’s nudge. The correction ripples forward through the rest of the frozen network.

One more detail: “adapting the last few layers” doesn’t mean one adapter per layer. Each targeted weight matrix (in a transformer: attention’s query/key/value/output, the MLP projections) gets its own AABB pair. “The adapter” you save at the end is the whole collection of these small pairs — a few megabytes total.

Zooming all the way out, here’s where those pairs actually live inside the model:

tokenstransformer block · ×N layersattentionQKVOMLPupdownoutputfrozen weight (W)LoRA adapter (A·B) — trainable
The bigger picture. Inside every transformer block, LoRA leaves each real weight matrix (attention's Q/K/V/O and the MLP projections) frozen, and clips a small trainable A·B beside it. The block repeats N times, so the adapters ride along in every layer. Everything grey is frozen; only the small accent pieces train.

What rank controls

You met rr as the bottleneck width. It’s the one real knob, and it trades capacity against cost:

  • small rr (say 8) → a simpler update, fewer parameters, less memory — but it may underfit a genuinely complex change.
  • large rr (say 128) → a richer update, more parameters — often overkill for a narrow task.

The reason small rr usually suffices is exactly what the reconstruction playground showed: the update a fine-tune needs lives in a few directions, so a few ranks rebuild almost all of it. Start at 16; raise it only if the model can’t fit the task. (Its partner α\alpha, lora_alpha, just scales the adapter’s contribution — a rule of thumb is α2r\alpha \approx 2r.)

What you actually set in code

Most LoRA libraries hide the matrix shapes for you. You don’t manually create ARdout×rA \in \mathbb{R}^{d_{out}\times r} and BRr×dinB \in \mathbb{R}^{r\times d_{in}}; you choose a few knobs, point LoRA at some existing weight matrices, and the library clips the right adapter beside each one.

In a typical Hugging Face PEFT setup, the config looks like this:

from peft import LoraConfig

lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["q_proj", "v_proj", "o_proj", "up_proj", "down_proj"],
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
)

Each field maps back to the picture you’ve already built:

  • r is the bottleneck width. It decides how many directions the update can use.
  • lora_alpha is a scale on the adapter output. Many implementations apply the adapter as Wx+αrABxWx + \frac{\alpha}{r}ABx, so alpha = 2r makes the LoRA path start with a scale of about 2 once it has learned something.
  • target_modules decides where adapters are attached. Attention projections (q_proj, k_proj, v_proj, o_proj) change how tokens read and write information; MLP projections (up_proj, down_proj, sometimes gate_proj) change the feed-forward transformations. More targets means more trainable parameters and often more capacity.
  • lora_dropout randomly drops part of the adapter path during training. It can help small datasets, and is often set to 0 or 0.05.
  • bias="none" means only the LoRA matrices train. Some setups also train biases, but the clean LoRA story is: freeze the base, train the low-rank side paths.

The important thing: target_modules does not mean “replace those modules.” It means “for every matching frozen matrix, add a small AABB side path.” If you target five matrix names across 24 transformer blocks, you get 120 little adapters — still tiny compared with the base model.

Two useful starting recipes:

SituationGood first try
Clean formatting / extraction / styler=8 or 16, attention only
Harder instruction tuningr=16 or 32, attention + MLP
Very tiny datasetlower r, add lora_dropout=0.05
Underfitting after trainingraise r, add more target modules, or train longer
Memorizing / unstable evallower r, add dropout, improve data

That is the practical loop: start small, check whether the model can fit the training task and generalize to held-out examples, then increase rank or target more matrices only when the adapter is clearly too weak.

After training: merge, or keep a stack of adapters

Because ΔW=AB\Delta W = AB is just a matrix, you have two choices when you’re done:

  1. Merge it in: compute W=W+ABW' = W + AB once, and you have an ordinary model with zero extra inference cost — indistinguishable from a fully fine-tuned one.
  2. Keep it separate: ship the tiny AABB files as an adapter and snap them onto the frozen base at load time.

Option 2 is quietly powerful. One base model can host many swappable adapters — one per task, per customer, per style — each only megabytes. Serving “500 fine-tunes” becomes serving one base model plus 500 small adapters, hot-swapped per request. That economics is only possible because the adapter is low-rank.

One base, many adapters
frozen base · shared
Qwen3-0.6B
1.2 GB · loaded once
+
adapter · swapped
JSON extractor
3.9 MB · hot-swappable
input
Ava Kim, 41, Sales, joined 2019-03-02
output · same frozen base, JSON extractor adapter
{"name":"Ava Kim","age":41,"department":"Sales","start_date":"2019-03-02"}
The 1.2 GB base loads once and never changes. Each skill is a ~4 MB adapter snapped on top and swapped per request — so a serving stack can offer hundreds of fine-tunes without hundreds of full models.
One base, many adapters
The 1.2 GB base loads once and never changes. Each skill is a ~4 MB adapter snapped on top and swapped per request — so a serving stack can offer hundreds of fine-tunes without hundreds of full models.

The real numbers: a 0.6B model on a laptop

Back to the opening. The model was Qwen3-0.6B — 596 million parameters. With LoRA at r=16r=16 on its linear layers, the trainable adapter was 1.4 million parameters — 0.24% of the model. Training touched only those; the 596M base stayed frozen, so the memory that would have gone to gradients and optimizer state for 596M parameters simply never got allocated. Peak memory: ~3 GB. Wall-clock: ~40 seconds for a few hundred steps.

Push it further and you get QLoRA: quantize the frozen base to 4-bit (it’s read-only, so precision matters less), and even the frozen copy shrinks ~4×. That’s how a 7B model fine-tunes in ~8 GB — a single consumer GPU — instead of ~112 GB.

When not to reach for LoRA

LoRA is a low-rank adjustment to existing knowledge. It shines at installing behaviors and skills the base can already almost do. It’s the wrong tool when:

  • You need to inject large-scale new knowledge (a new language, a whole domain the base never saw). That’s a high-rank change; you likely need continued pretraining or full fine-tuning.
  • Your rank is too low for a genuinely complex task — the adapter underfits. Raise rr, or reconsider.

The honest framing: LoRA bets your change is simple. When that bet holds — which is most day-to-day fine-tuning — you get 99% of full fine-tuning’s benefit for ~0.2% of its cost. When it doesn’t, no bottleneck will save you.


Recap — the whole idea in one breath

Fine-tuning adds an update ΔW\Delta W to each weight. Full fine-tuning learns that update as a matrix as big as the model — expensive. LoRA bets the update is low-rank (it lives in a few directions), so it learns ΔW=AB\Delta W = A B — two skinny matrices sharing a small rank rr — instead. One is zero-initialized so the adapter starts as a no-op and grows a delta; it rides on top of the frozen model, changing only the activation passed forward, never the weights themselves. On real (large) matrices this is dozens to hundreds of times cheaper, which is how you fine-tune a billion-parameter model, on a laptop, by changing 0.2% of it.

That’s not a magic trick. It’s a bet about the shape of change — and it’s usually right.