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.

https://dev48.in/dl/day83-prefix-tuning.html