LoRA computes W_eff = W + BA. It changes a weight matrix by a low-rank delta, and at serve time you can add the delta in and forget it was ever separate.
Prefix tuning never forms a delta. It leaves every weight bit-for-bit where it was and prepends P learned key rows and P learned value rows to the attention of every layer. Two concat lines, and the softmax does the rest.
That difference is the whole subject, and it is easy to lose in the phrase "parameter-efficient fine-tuning", which gets applied to both.
👉 Live, with the training run executing in your browser: https://dev48.in/dl/day83-prefix-tuning.html
🔴 The model is a toy and the numbers are a property of it
The page builds a miniature decoder from a fixed seed: d = 8, 2 attention layers, 6 tokens per sequence, a 12-token vocabulary, 2 classes. 624 numbers in total. Re-running reproduces it exactly.
What that costs: every point estimate below is a property of this model, and would move on a real one. What transfers is the shape — where the gradient stops, what the parameter count is a function of, and what happens when the prefix is left to grow unbounded.
The whole method
const K = Pk.concat(k); // P learned key rows, then the real tokens const V = Pv.concat(v); // P learned value rows, then the real tokens No W is read differently. No W is written. Every query now scores against P + T candidates instead of T, and a softmax that must sum to one has no choice but to spend some of its mass on the new rows.
That is the leverage. The prefix does not add a term to the output — it takes mass away from the real tokens and redirects it to vectors you control.
You cannot prepend to q. A query belongs to a token, and you are not adding tokens. That asymmetry is why this is key/value prefixing and not "prompt tuning".
Where the gradient stops
After the softmax derivative you hold a gradient for all P + T key rows and all P + T value rows.
The first P of each were not computed from anything. They are the parameters, so their gradient is the answer and the chain ends there. The remaining T came out of W_k and W_v, so their gradient keeps travelling — back through those matrices, into x, and down to the layer below, because layer 0's prefix is only reachable through layer 1's attention.
dWq, dWk, dWv, dWo are never formed. Not computed and then zeroed — the lines that would build them are absent, and the page checks its own backward source for that.
Backprop is written out by hand, and every coordinate is checked against a central finite difference of the loss, which knows nothing about the derivation. All 64 coordinates of a P = 2 prefix agree to better than 1e-5 relative.
It trains, and the base comes back untouched
The task is built so the frozen model cannot already do it: one signal token hidden among filler, label = which signal. The base sits at 0.6941 against a chance value of ln(2) = 0.6931, and gets 62.5% right.
200 steps of SGD with momentum, moving 128 numbers:
| value | |
|---|---|
| loss, before | 0.6941 |
| loss, after 200 steps | 0.00337 |
| training accuracy | 100% |
| held-out accuracy, 24 unseen examples | 100% |
| frozen numbers changed | 0 of 624 |
The last row is the claim of the method stated as a hash. All 624 frozen numbers are fingerprinted before the loop and again afterwards, compared element by element, and the base object is handed to Object.freeze recursively first — so an accidental write could not land even if a line tried.
And the control that makes the rest mean something: with P = 0 the prefix-aware forward pass returns bit-identical logits to a forward pass written without any prefix code at all, compared with Object.is. With P = 0, 80 steps at a learning rate of 5 leave the loss exactly where it started.
Longer is not better
Every extra slot is more capacity and more attention mass stolen from the input. The same loop, the same seed, at each P:
| P | trainable | final loss | first step under 0.05 | attention mass on prefix |
|---|---|---|---|---|
| 0 | 0 | 0.69412 | never | 0.000 |
| 1 | 32 | 0.01390 | 127 | 0.144 |
| 2 | 64 | 0.00342 | 51 | 0.560 |
| 4 | 128 | 0.00337 | 58 | 0.656 |
| 8 | 256 | 0.00492 | 83 | 0.738 |
| 16 | 512 | 0.00775 | 134 | 0.839 |
A single slot — 32 numbers — already solves it, just slower. The curve bottoms out at P = 4 and then turns around: by P = 16 the loss is worse than at P = 4, while 0.839 of the attention mass is going to the prefix. The model is spending most of its softmax on vectors instead of on its own input.
What the reparameterisation trick is actually for
Li & Liang reported that optimising the table directly was unstable and generated it from a small embedding through an MLP instead, discarding the MLP afterwards. On this page the generator's output layer is bounded, so |prefix| ≤ 45.255 is arithmetic rather than a hope.
Running both parameterisations at the same seed across a learning-rate sweep:
| learning rate | direct loss | direct |prefix| | direct mass on prefix | reparam loss | reparam |prefix| |
|---|---|---|---|---|---|
| 0.6 | 0.0034 | 15 | 0.656 | 0.0002 | 32.7 |
| 3 | 0.0003 | 23 | 0.710 | 0.7514 | 45.1 |
| 12 | 0.0000 | 40 | 0.832 | 0.7991 | 45.3 |
| 50 | 0.0000 | 79 | 0.957 | 0.5086 | 45.3 |
| 100 | 2.5643 | 12,450 | 1.000 | 1.3659 | 45.3 |
| 200 | 0.0000 | 6,880 | 1.000 | 1.1548 | 45.3 |
The column that matters is not the loss, it is the norm. The direct prefix is free coordinates and nothing bounds them: at lr = 100 the run ends at 2.5643, above the chance value of 0.6931, with 1.000 of the attention mass on the prefix. That last number is the mechanism of the failure — one prefix key grows until it wins the softmax outright, the real tokens get nothing, and the model is no longer reading its input.
Be honest about the other half. Bounding does not make it better everywhere: at lr = 0.6 the reparameterised run reaches 0.00020 against the direct run's 0.00337, but high up the sweep it stops learning rather than exploding. It trades a blow-up for a ceiling.
There is also a parameter count that gets quoted two ways. With the generator you train 640 numbers and ship 128, because the MLP is run once at the end and thrown away. Without it you train and ship the same 128.
Why the ratio gets better as the model gets bigger
A prefix costs 2 × L × P × d. The vocabulary is not in that formula, and neither is the feed-forward width — which is where most of a transformer's parameters actually live.
| model shape | prefix | full fine-tune | prefix params | share |
|---|---|---|---|---|
| this page's toy | P = 4 | 624 | 128 | 20.5128% |
| d=768, L=12, V=50k | P = 10 | 123,532,032 | 184,320 | 0.1492% |
| d=4096, L=32, V=32k | P = 16 | 6,573,522,944 | 4,194,304 | 0.0638% |
| d=8192, L=80, V=32k | P = 16 | 64,686,653,440 | 20,971,520 | 0.0324% |
The toy row is the honest one to be suspicious of — 20.5% of a 624-number model is not a headline. The share falls to 0.0324% by the bottom row because the prefix grows with L × d while the model grows with L × d².
The trade, stated plainly
| LoRA | prefix tuning | |
|---|---|---|
| the edit | W_eff = W + BA | K = [Pk ; xWk] |
| trainable here | 256 (r=4, on Wq and Wv) | 128 (P = 4) |
| where the gradient stops | in A and B, inside the product | in the first P rows of K and V |
| can it be folded into W? | yes, add BA once | no — it is extra KV rows |
| cost at inference | none, once folded | P extra keys/values, every layer, forever |
| two tasks in one batch | one adapter at a time | a different prefix per row |
Read the last three rows together, because they are the whole trade. LoRA's delta disappears into the weights at serve time and costs nothing afterwards — but you can only have one folded in. A prefix never disappears: it occupies P slots of every attention, in every layer, for every token, forever. What you buy with that is the last row — two requests in the same batch can carry two different prefixes, because the prefix is data, not weights.
And it is not a prompt
| a discrete prompt of 4 tokens | a soft prefix, P = 4 | |
|---|---|---|
| lives in | the 12 embedding points the vocab has | all of R⁸ |
| distinct settings reachable | 20,736 | continuous |
| enters the network at | layer 0 only | every one of the 2 layers |
| deeper layers see it | second-hand, via the layers below | directly, with its own learned vectors |
| numbers you train | 0 — you pick token ids | 128 |
A four-token prompt over this vocabulary has 20,736 possible settings and you would have to try them. The soft version is continuous, differentiable, and — decisively — injected into every layer rather than only the first.
The page carries 38 in-browser assertions covering all of this: the frozen weights, the gradient check, the P = 0 identity, the bound, and the divergence. All 38 pass, with the engine on the page being the same one every table above was computed by.
