LoRA: Low-Rank Adaptation of Large Language Models
Explained as a git patch, because a fine-tune is a diff and this paper is about how small the diff is.
Download lora_from_scratch.py — everything below runs from it. NumPy only.
I'd shipped LoRA adapters before I understood them. r=8,
lora_alpha=16, target_modules=["q_proj", "v_proj"], copied
from a blog post, and it worked. I couldn't have told you what r was, why
alpha existed, or why the README promised zero inference latency.
Nobody told me the whole paper is one observation about a diff.
A fine-tune is a patch, not a fork
You know this. A full fine-tune is a fork: copy every weight, edit every weight, keep the copy.
W_task = W_base.copy() # 4096 x 4096 floats, per matrix, per task
W_task += learned_change # every entry is allowed to move
save(W_task) # another full copy on disk
LoRA is the branch that stores only the patch. W_base stays read-only;
the change lives next to it. And the paper's observation is that the patch is
thin: rank r, where r can be 8, or even 1, on a matrix 4096 wide.
A = random(d_in, r) # r = 8, not 4096
B = zeros(r, d_out) # the patch starts empty
h = x @ W_base + (x @ A) @ B * (alpha / r)
W_merged = W_base + A @ B * (alpha / r) # ship time: apply the patch
Two thin matrices instead of one fat one. At ship time, apply the patch and serve one matrix, as before. That's the paper.
| Term | Means | Git analogy |
|---|---|---|
| W0 | The pretrained weight. Frozen. | main, read-only |
| ΔW | What a fine-tune changes | The diff |
| r | Rank: how many independent directions the diff may have | How thin the patch is |
| B, A | The diff stored as two thin matrices, ΔW = BA | The patch, stored compressed |
| α | A constant. The patch is scaled by α/r | A learning-rate knob for the patch |
Why this beat adapters and full fine-tuning
The setting is GPT-3, 175 billion parameters. A full fine-tune per task is a full copy of the weights per task — the paper puts one checkpoint at 350GB — plus gradients and optimiser state for all of them while you train. Adapters were the existing fix: a small two-layer MLP bolted after each layer. But that is a new layer in the sequential path with a nonlinearity inside, so it can't be folded away and it costs latency on every request. The paper measures this in its "aren't existing solutions good enough?" section: noticeable at batch size 1, where online inference lives.
LoRA's patch is linear, so it folds into the weight. Same latency as the base. Switching tasks is subtract one patch, add another. The checkpoint per task is the patch alone: the paper reports 35MB where a full fine-tune was 350GB.
The one formula
Read it as pipeline stages:
| Step | Shape | What it does |
|---|---|---|
W0x | (d,) | The frozen base. What the model already does. |
Ax | (r,) | Squeeze x down to r numbers. Random Gaussian at init. |
B(Ax) | (d,) | Expand back out. Zero at init, so the patch starts empty. |
× α/r | scalar | Keeps the step size fixed when you change r. Not in equation 3; in the paragraph after it. |
W0 + BA | (d, k) | The merge. Done once before serving. One matrix again. |
Two notes. The paper writes BA with column vectors; my x is a
row, so the patch reads (x @ A) @ B, A then B, and my A @ B is
the paper's BA transposed. And "rank": a rank-1 matrix is one column times
one row, d×k entries but d+k numbers of information; rank r is r of those added up.
np.linalg.svd lists a matrix's singular values, biggest direction first.
How many numbers are you training?
A full fine-tune of one d×d matrix trains d² numbers. LoRA trains r·(d + d). The paper's GPT-3 numbers fall out of the same formula: 96 layers, dmodel 12288.
Don't take my word for it. Here's the actual output:
one square weight, d = 4096. Full fine-tune trains d*d = 16,777,216
r LoRA params full / LoRA
1 8,192 2,048x
4 32,768 512x
8 65,536 256x
64 524,288 32x
GPT-3 175B shape: 96 layers, d_model 12288. LoRA on Wq and Wv only:
r = 1: 4,718,592 trainable = 4.7M (175,000M full, ratio 37,087x)
r = 8: 37,748,736 trainable = 37.7M (175,000M full, ratio 4,636x)
4.7M and 37.7M are the two LoRA rows in the paper's GPT-3 results table; the abstract's "10,000 times" is this ratio, rounded. Note what they adapted: Wq and Wv in attention, nothing else; the MLP was frozen. Now the thing the paper is honest about and blog posts are not: LoRA shrinks what you train, not what you load.
What LoRA does NOT shrink (d = 4096, r = 8, fp16 weights, fp32 Adam):
the frozen W itself ............ 33.6 MB either way
full FT grads + Adam m, v ..... 167.8 MB
LoRA grads + Adam m, v ..... 0.7 MB
The diff is low-rank — and here's what that means
I can't fine-tune GPT-3 on a laptop, so here is the honest version for one linear layer. Least squares gives the ΔW a full fine-tune would converge to — closed form, no training loop — and an SVD says how many directions it has. The task's true ΔW is rank 4 plus a little noise, the paper's claim about real diffs. d = 64, 512 rows.
singular values of the fitted dW (first 10):
[3.001 2. 0.986 0.512 0.156 0.151 0.146 0.14 0.137 0.135]
rank r dW error test RMSE
none 0.4852 ← the base model
1 0.6199 0.3026
2 0.3330 0.1694
4 0.1628 0.0907 ← the cliff
8 0.1432 0.0840
64 0.0000 0.0533 ← full fine-tune
Four real singular values — 3, 2, 1, 0.5 — then a noise floor at 0.15. The fitted ΔW has 4096 entries and 4 directions that matter. Rank 4 takes the test error from 0.49 to 0.09; the full fine-tune gets 0.05, and the gap is 60 tiny noise directions rank 8 can't reach and doesn't need. Now break it: same size of ΔW, random Gaussian.
BREAK IT: same size of dW, but full-rank (random Gaussian):
[0.928 0.878 0.847 0.828 0.803 0.793 0.774 0.75 0.737 0.719] ← no cliff
rank r dW error test RMSE
8 0.7909 0.3828 ← rank 8 barely helps
64 0.0000 0.0536 ← full fine-tune
No cliff, no LoRA: rank 8 leaves 79% of the diff on the floor. LoRA works because real fine-tune diffs look like the first table — an empirical claim, not a theorem. The paper's evidence is section 7: on a fine-tuned GPT-3 the top directions of an r = 64 update overlap heavily with the r = 8 one, and r = 1 to 4 already matched full fine-tuning on the tasks tested. The best part of the paper; everyone skips it.
Merge it and pay nothing at inference
x @ (W + AB) and x @ W + (x @ A) @ B are the same number —
distributivity. So before serving, add the patch into the weight once. The served
model is one matmul, the same one it always was.
max |unmerged - merged| = 3.55e-15
flops per token, base W alone ......... 8,192
flops per token, base + unmerged patch 10,240
flops per token, merged ............... 8,192 ← same as base
un-merge, max |W_back - W| = 5.55e-17 ← base recovered
best single matrix that reproduces a LoRA layer: residual 1.83e-15
best single matrix that reproduces an adapter: residual 5.44e-01
The last two lines are the whole argument against adapters. I asked least squares for the one matrix that best reproduces each layer. For LoRA it finds one exactly. For an adapter — two matmuls with a relu between — the best single matrix is off by 54%, because no linear map reproduces a relu. So it stays as extra layers, and extra layers are extra latency.
Why B starts at zero (and not A)
With B = 0 the patch is zero and step 0 of your fine-tune is bit-for-bit the base model. Not close — identical. It can only move away from something that works.
A random, B zero: identical to base? True max |diff| = 0.0
A random, B random: identical to base? False outputs moved 31.5%, untrained
init |dL/dA| |dL/dB| what happens
A random, B zero 0.000 11.009 B moves first, then A. Trains.
A zero, B zero 0.000 0.000 both zero forever. Stuck.
A random, B random 10.724 11.009 trains, but step 0 is wrong.
Why zero B rather than A, or both? The second table is one gradient, by hand. dL/dA is multiplied by B, so with B = 0 A sits still for one step — but B gets a real gradient, and once B moves, A follows. Zero both and every gradient is zero; the patch never leaves the origin. Random both and you broke the model before training started.
What α is for
The paper scales the patch by α/r and says: set α to the first r you try, then leave it. Why that works: Adam's first step is exactly lr × sign(grad), the same size for every parameter. B has r rows and the patch sums over all of them, so a bigger r means a bigger step for the same learning rate — unless you divide by r.
one Adam step (lr = 0.001) on B, from B = 0. alpha = 4.
r no scale: |dh| loss drop alpha/r: |dh| loss drop
1 0.1031 0.00014 0.4125 0.00054
4 0.2553 0.00063 0.2553 0.00063
16 0.5219 0.00246 0.1305 0.00062
64 1.3644 0.01066 0.0853 0.00067
loss drop at r = 64 vs r = 1: no scale 78x alpha/r 1.2x
Without the scale, going from r = 1 to 64 makes the same optimiser step do 78× more to the loss: you silently changed your learning rate. With α/r the loss drop holds within 20%. (The r = 4 row is the sanity check: α/r = 1 there, so both halves match.) One honest caveat: under α/r the norm column still shrinks with r; it is the loss change that holds, and a 2023 follow-up, rsLoRA, argues for α/√r on those grounds. In practice α is a learning-rate knob for the patch — that is all the common "α = 2r" rule of thumb is.
LoRA adapters-ஐப் புரிந்துகொள்வதற்கு முன்பே நான் அவற்றை ship செய்திருக்கிறேன்.
r=8, lora_alpha=16,
target_modules=["q_proj", "v_proj"] — ஒரு blog post-லிருந்து copy
செய்தது, வேலையும் செய்தது. r என்றால் என்ன, alpha ஏன்
இருக்கிறது, README ஏன் zero inference latency உறுதியளிக்கிறது — எதுவும் என்னால்
சொல்லியிருக்க முடியாது. இந்த paper முழுவதுமே ஒரு diff பற்றிய ஒரே ஒரு
அவதானிப்புதான் என்று யாரும் சொல்லவில்லை.
Fine-tune என்பது ஒரு patch, fork அல்ல
இது உங்களுக்குத் தெரியும். முழு fine-tune ஒரு fork: ஒவ்வொரு weight-ஐயும் copy செய்து, ஒவ்வொன்றையும் திருத்தி, அந்த copy-ஐ வைத்துக்கொள்வது.
W_task = W_base.copy() # task ஒன்றுக்கு 4096 x 4096 floats
W_task += learned_change # ஒவ்வொரு entry-யும் நகரலாம்
save(W_task) # disk-ல் இன்னொரு முழு copy
LoRA என்பது patch-ஐ மட்டும் சேமிக்கும் branch. W_base read-only-ஆக
இருக்கிறது; மாற்றம் அதற்குப் பக்கத்தில் வாழ்கிறது. Paper-ன் அவதானிப்பு என்னவென்றால்
அந்த patch மெல்லியது: rank r, அதாவது 4096 அகலமான matrix-க்கு
r = 8, சில நேரம் 1 கூடப் போதும்.
A = random(d_in, r) # r = 8, 4096 அல்ல
B = zeros(r, d_out) # patch காலியாகத் தொடங்குகிறது
h = x @ W_base + (x @ A) @ B * (alpha / r)
W_merged = W_base + A @ B * (alpha / r) # ship நேரம்: patch-ஐ apply செய்
ஒரு பருமனான matrix-க்குப் பதிலாக இரண்டு மெல்லிய matrices. Ship செய்யும்போது patch-ஐ apply செய்து, முன்பு போலவே ஒரே matrix-ஐ serve செய்யுங்கள். இதுதான் paper.
| சொல் | அர்த்தம் | Git ஒப்பீடு |
|---|---|---|
| W0 | Pretrained weight. Frozen. | main, read-only |
| ΔW | Fine-tune மாற்றுவது எது | அந்த diff |
| r | Rank: diff-க்கு எத்தனை சுயாதீன திசைகள் அனுமதிக்கப்படுகின்றன | Patch எவ்வளவு மெல்லியது |
| B, A | Diff இரண்டு மெல்லிய matrices-ஆக, ΔW = BA | Patch, சுருக்கிச் சேமிக்கப்பட்டது |
| α | ஒரு மாறிலி. Patch α/r-ஆல் scale செய்யப்படுகிறது | Patch-க்கான learning-rate knob |
Full fine-tune oru fork — ellaa weights-um copy. LoRA oru branch, patch mattum save pannudhu. Antha patch thin: rank r, 4096 wide matrix-kku r = 8 podhum.
இது adapters-ஐயும் முழு fine-tuning-ஐயும் ஏன் வென்றது
களம் GPT-3, 175 பில்லியன் parameters. Task ஒன்றுக்கு ஒரு முழு fine-tune என்பது task ஒன்றுக்கு weights-ன் முழு copy — paper ஒரு checkpoint-ஐ 350GB என்று சொல்கிறது — அத்துடன் train செய்யும்போது அவை அனைத்துக்கும் gradients மற்றும் optimiser state. Adapters-தான் அப்போதைய தீர்வு: ஒவ்வொரு layer-க்கும் பிறகு பொருத்தப்பட்ட சிறிய two-layer MLP. ஆனால் அது sequential path-ல் ஒரு புதிய layer, உள்ளே ஒரு nonlinearity உண்டு; எனவே அதை மடித்து விட முடியாது, ஒவ்வொரு request-க்கும் latency செலவாகிறது. Paper இதை "aren't existing solutions good enough?" பகுதியில் அளக்கிறது: batch size 1-ல் இது தெளிவாகத் தெரியும் — online inference வாழ்வதே அங்குதான்.
LoRA-ன் patch linear, எனவே அது weight-க்குள் மடிந்துவிடும். Base-ன் அதே latency. Task மாற்றுவது என்பது ஒரு patch-ஐக் கழித்து, இன்னொன்றைக் கூட்டுவது. Task ஒன்றுக்கான checkpoint patch மட்டுமே: முழு fine-tune 350GB இருந்த இடத்தில் paper 35MB என்று பதிவு செய்கிறது.
அந்த ஒரு சூத்திரம்
இதை pipeline stages-ஆகப் படியுங்கள்:
| படி | Shape | என்ன செய்கிறது |
|---|---|---|
W0x | (d,) | Frozen base. Model ஏற்கனவே செய்வது. |
Ax | (r,) | x-ஐ r எண்களுக்கு அழுத்து. Init-ல் random Gaussian. |
B(Ax) | (d,) | மீண்டும் விரி. Init-ல் zero, எனவே patch காலியாகத் தொடங்குகிறது. |
× α/r | scalar | r-ஐ மாற்றும்போது step size நிலையாக இருக்கச் செய்கிறது. Equation 3-ல் இல்லை; அதற்கு அடுத்த பத்தியில். |
W0 + BA | (d, k) | அந்த merge. Serve செய்வதற்கு முன் ஒருமுறை. மீண்டும் ஒரே matrix. |
இரண்டு குறிப்புகள். Paper BA-ஐ column vectors-உடன் எழுதுகிறது; என்
x ஒரு row, எனவே patch (x @ A) @ B என்று படிக்கப்படுகிறது
— A பிறகு B — மேலும் என் A @ B என்பது paper-ன் BA-ன்
transpose. பிறகு "rank": rank-1 matrix என்பது ஒரு column-ஐ ஒரு row-ஆல் பெருக்கியது
— d×k entries, ஆனால் d+k எண்கள் அளவு தகவல்; rank r என்பது அப்படி r-ஐக் கூட்டியது.
np.linalg.svd ஒரு matrix-ன் singular values-ஐப் பட்டியலிடும், மிகப்
பெரிய திசை முதலில்.
நீங்கள் எத்தனை எண்களை train செய்கிறீர்கள்?
d×d matrix ஒன்றின் முழு fine-tune d² எண்களை train செய்கிறது. LoRA r·(d + d) train செய்கிறது. Paper-ன் GPT-3 எண்கள் இதே சூத்திரத்திலிருந்துதான் வருகின்றன: 96 layers, dmodel 12288.
என் சொல்லை நம்ப வேண்டாம். இதோ உண்மையான output:
one square weight, d = 4096. Full fine-tune trains d*d = 16,777,216
r LoRA params full / LoRA
1 8,192 2,048x
4 32,768 512x
8 65,536 256x
64 524,288 32x
GPT-3 175B shape: 96 layers, d_model 12288. LoRA on Wq and Wv only:
r = 1: 4,718,592 trainable = 4.7M (175,000M full, ratio 37,087x)
r = 8: 37,748,736 trainable = 37.7M (175,000M full, ratio 4,636x)
4.7M மற்றும் 37.7M என்பவை paper-ன் GPT-3 results table-ல் உள்ள இரண்டு LoRA வரிசைகள்; abstract-ன் "10,000 times" என்பது இதே விகிதம், வட்டமிடப்பட்டது. அவர்கள் எதை adapt செய்தார்கள் என்பதைக் கவனியுங்கள்: attention-ல் Wq மற்றும் Wv மட்டும், வேறு எதுவும் இல்லை; MLP frozen. இப்போது paper நேர்மையாகச் சொல்லும், blog posts சொல்லாத விஷயம்: நீங்கள் train செய்வதை LoRA சுருக்குகிறது, நீங்கள் load செய்வதை அல்ல.
What LoRA does NOT shrink (d = 4096, r = 8, fp16 weights, fp32 Adam):
the frozen W itself ............ 33.6 MB either way
full FT grads + Adam m, v ..... 167.8 MB
LoRA grads + Adam m, v ..... 0.7 MB
Diff low-rank — அதன் அர்த்தம் இதுதான்
Laptop-ல் GPT-3-ஐ fine-tune செய்ய முடியாது, எனவே ஒரே ஒரு linear layer-க்கான நேர்மையான பதிப்பு இதோ. முழு fine-tune எந்த ΔW-க்கு converge ஆகுமோ அதை least squares தருகிறது — closed form, training loop இல்லை — அதற்கு எத்தனை திசைகள் உள்ளன என்பதை SVD சொல்கிறது. Task-ன் உண்மையான ΔW rank 4 கூட சிறிது noise; இதுதான் உண்மையான diffs பற்றிய paper-ன் கூற்று. d = 64, 512 rows.
singular values of the fitted dW (first 10):
[3.001 2. 0.986 0.512 0.156 0.151 0.146 0.14 0.137 0.135]
rank r dW error test RMSE
none 0.4852 ← base model
1 0.6199 0.3026
2 0.3330 0.1694
4 0.1628 0.0907 ← அந்தப் பள்ளம்
8 0.1432 0.0840
64 0.0000 0.0533 ← முழு fine-tune
நான்கு உண்மையான singular values — 3, 2, 1, 0.5 — பிறகு 0.15-ல் ஒரு noise floor. Fit செய்யப்பட்ட ΔW-ல் 4096 entries, ஆனால் முக்கியமான திசைகள் 4. Rank 4 test error-ஐ 0.49-லிருந்து 0.09-க்குக் கொண்டு வருகிறது; முழு fine-tune 0.05 தருகிறது, அந்த இடைவெளி rank 8-ஆல் எட்ட முடியாத, தேவையும் இல்லாத 60 சிறு noise திசைகள். இப்போது இதை உடையுங்கள்: அதே அளவு ΔW, ஆனால் random Gaussian.
BREAK IT: same size of dW, but full-rank (random Gaussian):
[0.928 0.878 0.847 0.828 0.803 0.793 0.774 0.75 0.737 0.719] ← பள்ளம் இல்லை
rank r dW error test RMSE
8 0.7909 0.3828 ← rank 8 அரிதாகவே உதவுகிறது
64 0.0000 0.0536 ← முழு fine-tune
பள்ளம் இல்லை என்றால் LoRA இல்லை: rank 8, diff-ல் 79%-ஐத் தரையிலேயே விட்டுவிடுகிறது. உண்மையான fine-tune diffs முதல் table போலத் தோன்றுவதால்தான் LoRA வேலை செய்கிறது — இது ஒரு அனுபவபூர்வமான கூற்று, theorem அல்ல. Paper-ன் ஆதாரம் section 7: fine-tune செய்யப்பட்ட GPT-3-ல், r = 64 update-ன் மேல் திசைகள் r = 8-ன் திசைகளுடன் அதிகமாகப் பொருந்துகின்றன, மேலும் r = 1 முதல் 4 வரையே சோதிக்கப்பட்ட tasks-ல் முழு fine-tuning-க்கு இணையாக இருந்தது. Paper-ன் சிறந்த பகுதி; எல்லோரும் தவறவிடுகிறார்கள்.
Real fine-tune diff-la nalla direction naalu dhaan, meethi ellaam noise. Adhanaala rank 4 podhum. Random full-rank dW-ku antha cliff illa, rank 8 fail aagudhu.
Merge செய்யுங்கள், inference-ல் எதுவும் செலவில்லை
x @ (W + AB)-ம் x @ W + (x @ A) @ B-ம் ஒரே எண் —
distributivity. எனவே serve செய்வதற்கு முன், patch-ஐ weight-க்குள் ஒருமுறை
கூட்டிவிடுங்கள். Serve செய்யப்படும் model ஒரே ஒரு matmul, எப்போதும் இருந்தது போலவே.
max |unmerged - merged| = 3.55e-15
flops per token, base W alone ......... 8,192
flops per token, base + unmerged patch 10,240
flops per token, merged ............... 8,192 ← same as base
un-merge, max |W_back - W| = 5.55e-17 ← base recovered
best single matrix that reproduces a LoRA layer: residual 1.83e-15
best single matrix that reproduces an adapter: residual 5.44e-01
கடைசி இரண்டு வரிகள்தான் adapters-க்கு எதிரான முழு வாதம். ஒவ்வொரு layer-ஐயும் சிறப்பாக மீட்டுருவாக்கும் ஒரே matrix எது என்று least squares-ஐக் கேட்டேன். LoRA-க்கு அது சரியாக ஒன்றைக் கண்டுபிடிக்கிறது. Adapter-க்கு — இடையில் ஒரு relu உள்ள இரண்டு matmuls — சிறந்த ஒற்றை matrix 54% தவறாக இருக்கிறது, ஏனெனில் எந்த linear map-ம் relu-ஐ மீட்டுருவாக்காது. எனவே அது கூடுதல் layers-ஆகவே இருக்கிறது, கூடுதல் layers என்பது கூடுதல் latency.
Patch linear-aa irukkarathaala serve pannradhukku munnaadi adha W-kkulla merge panniralaam. Adapter-la relu irukku, adhanaala merge aagaadhu — latency varum.
B ஏன் zero-ல் தொடங்குகிறது (A அல்ல)
B = 0 எனில் patch zero, உங்கள் fine-tune-ன் step 0 base model-உடன் bit-for-bit ஒன்றாக இருக்கும். கிட்டத்தட்ட அல்ல — அப்படியே. வேலை செய்யும் ஒன்றிலிருந்து மட்டுமே அது விலகிச் செல்ல முடியும்.
A random, B zero: identical to base? True max |diff| = 0.0
A random, B random: identical to base? False outputs moved 31.5%, untrained
init |dL/dA| |dL/dB| what happens
A random, B zero 0.000 11.009 B moves first, then A. Trains.
A zero, B zero 0.000 0.000 both zero forever. Stuck.
A random, B random 10.724 11.009 trains, but step 0 is wrong.
A-ஐ அல்ல, B-ஐ ஏன் zero ஆக்குகிறோம்? இரண்டையும் ஏன் ஆக்கக் கூடாது? இரண்டாவது table கையால் கணக்கிட்ட ஒரே ஒரு gradient. dL/dA என்பது B-ஆல் பெருக்கப்படுகிறது, எனவே B = 0 எனில் A ஒரு step அசையாமல் இருக்கும் — ஆனால் B-க்கு உண்மையான gradient கிடைக்கிறது, B நகர்ந்தவுடன் A பின்தொடர்கிறது. இரண்டையும் zero ஆக்கினால் ஒவ்வொரு gradient-ம் zero; patch தொடக்கப் புள்ளியை விட்டு நகராது. இரண்டையும் random ஆக்கினால், training தொடங்கும் முன்பே model-ஐ உடைத்துவிட்டீர்கள்.
α எதற்காக
Paper patch-ஐ α/r-ஆல் scale செய்து சொல்கிறது: நீங்கள் முதலில் முயற்சிக்கும் r-க்கு α-வை அமைத்து, பிறகு அதை விட்டுவிடுங்கள். இது ஏன் வேலை செய்கிறது: Adam-ன் முதல் step சரியாக lr × sign(grad), ஒவ்வொரு parameter-க்கும் ஒரே அளவு. B-க்கு r rows உள்ளன, patch அவை அனைத்தையும் கூட்டுகிறது; எனவே அதே learning rate-க்கு பெரிய r என்றால் பெரிய step — நீங்கள் r-ஆல் வகுக்கவில்லை என்றால்.
one Adam step (lr = 0.001) on B, from B = 0. alpha = 4.
r no scale: |dh| loss drop alpha/r: |dh| loss drop
1 0.1031 0.00014 0.4125 0.00054
4 0.2553 0.00063 0.2553 0.00063
16 0.5219 0.00246 0.1305 0.00062
64 1.3644 0.01066 0.0853 0.00067
loss drop at r = 64 vs r = 1: no scale 78x alpha/r 1.2x
Scale இல்லாமல், r = 1-லிருந்து 64-க்குப் போனால் அதே optimiser step loss-க்கு 78× அதிகம் செய்கிறது: நீங்கள் அமைதியாக learning rate-ஐ மாற்றிவிட்டீர்கள். α/r-உடன் loss drop 20%-க்குள் நிலைத்திருக்கிறது. (r = 4 வரிசைதான் sanity check: அங்கு α/r = 1, எனவே இரு பாதிகளும் பொருந்துகின்றன.) ஒரு நேர்மையான எச்சரிக்கை: α/r-ன் கீழ் norm column இன்னும் r-உடன் சுருங்குகிறது; நிலைத்திருப்பது loss மாற்றம்தான். இதே காரணத்தால் 2023-ன் ஒரு தொடர்ச்சியான வேலை, rsLoRA, α/√r-ஐ ஆதரிக்கிறது. நடைமுறையில் α என்பது patch-க்கான learning-rate knob — பரவலான "α = 2r" கட்டைவிரல் விதி இவ்வளவுதான்.
LoRA adapters मैंने समझने से पहले ही ship कर दिए थे। r=8,
lora_alpha=16, target_modules=["q_proj", "v_proj"] —
किसी blog post से copy किया, और चल भी गया। r क्या है,
alpha क्यों है, README zero inference latency का वादा क्यों करता है
— कुछ भी मैं आपको नहीं बता सकता था। किसी ने नहीं बताया कि पूरा paper एक diff के
बारे में की गई एक ही observation है।
Fine-tune एक patch है, fork नहीं
यह आप जानते हैं। पूरा fine-tune एक fork है: हर weight को copy करो, हर weight को बदलो, और वह copy अपने पास रखो।
W_task = W_base.copy() # हर task के लिए 4096 x 4096 floats
W_task += learned_change # हर entry हिल सकती है
save(W_task) # disk पर एक और पूरी copy
LoRA वह branch है जो सिर्फ़ patch रखता है। W_base read-only रहता है;
बदलाव उसके बग़ल में रहता है। और paper की observation यह है कि वह patch
पतला है: rank r, जहाँ 4096 चौड़े matrix पर r
8 हो सकता है, या 1 भी।
A = random(d_in, r) # r = 8, 4096 नहीं
B = zeros(r, d_out) # patch ख़ाली शुरू होता है
h = x @ W_base + (x @ A) @ B * (alpha / r)
W_merged = W_base + A @ B * (alpha / r) # ship समय: patch apply करो
एक मोटे matrix की जगह दो पतले matrices। Ship करते समय patch apply करो और पहले की तरह एक ही matrix serve करो। यही पूरा paper है।
| शब्द | मतलब | Git उपमा |
|---|---|---|
| W0 | Pretrained weight. Frozen. | main, read-only |
| ΔW | Fine-tune जो बदलता है | वह diff |
| r | Rank: diff को कितनी स्वतंत्र दिशाएँ मिल सकती हैं | Patch कितना पतला है |
| B, A | Diff दो पतले matrices में, ΔW = BA | Patch, compressed रूप में |
| α | एक स्थिरांक। Patch α/r से scale होता है | Patch के लिए learning-rate knob |
यह adapters और पूरे fine-tuning से बेहतर क्यों निकला
सन्दर्भ है GPT-3, 175 अरब parameters. हर task के लिए पूरा fine-tune यानी हर task के लिए weights की पूरी copy — paper एक checkpoint को 350GB बताता है — और ऊपर से training के दौरान उन सबके gradients और optimiser state. Adapters उस समय का उपाय थे: हर layer के बाद लगा एक छोटा two-layer MLP. लेकिन वह sequential path में एक नई layer है जिसके अंदर nonlinearity है, इसलिए उसे मोड़कर हटाया नहीं जा सकता और हर request पर latency लगती है। Paper इसे अपने "aren't existing solutions good enough?" वाले हिस्से में नापता है: batch size 1 पर साफ़ दिखता है — और online inference वहीं रहता है।
LoRA का patch linear है, इसलिए वह weight में मुड़ जाता है। Base जितनी ही latency. Task बदलना यानी एक patch घटाओ, दूसरा जोड़ो। हर task का checkpoint सिर्फ़ patch है: जहाँ पूरा fine-tune 350GB था, वहाँ paper 35MB बताता है।
वह एक सूत्र
इसे pipeline stages की तरह पढ़िए:
| चरण | Shape | यह क्या करता है |
|---|---|---|
W0x | (d,) | Frozen base. जो model पहले से करता है। |
Ax | (r,) | x को r संख्याओं तक निचोड़ो। Init पर random Gaussian. |
B(Ax) | (d,) | वापस फैलाओ। Init पर zero, इसलिए patch ख़ाली शुरू होता है। |
× α/r | scalar | r बदलने पर step size स्थिर रखता है। Equation 3 में नहीं; उसके बाद वाले पैराग्राफ़ में। |
W0 + BA | (d, k) | वह merge. Serve करने से पहले एक बार। फिर से एक ही matrix. |
दो बातें। Paper BA को column vectors के साथ लिखता है; मेरा
x एक row है, इसलिए patch (x @ A) @ B पढ़ा जाता है —
पहले A, फिर B — और मेरा A @ B paper के BA का transpose
है। और "rank": rank-1 matrix यानी एक column गुणा एक row — d×k entries, पर जानकारी
सिर्फ़ d+k संख्याओं की; rank r यानी ऐसी r चीज़ें जोड़ दी गईं।
np.linalg.svd matrix के singular values गिनाता है, सबसे बड़ी दिशा पहले।
आप कितनी संख्याएँ train कर रहे हैं?
एक d×d matrix का पूरा fine-tune d² संख्याएँ train करता है। LoRA r·(d + d) करता है। Paper के GPT-3 आँकड़े इसी सूत्र से निकलते हैं: 96 layers, dmodel 12288.
मेरी बात पर मत जाइए। यह रहा असली output:
one square weight, d = 4096. Full fine-tune trains d*d = 16,777,216
r LoRA params full / LoRA
1 8,192 2,048x
4 32,768 512x
8 65,536 256x
64 524,288 32x
GPT-3 175B shape: 96 layers, d_model 12288. LoRA on Wq and Wv only:
r = 1: 4,718,592 trainable = 4.7M (175,000M full, ratio 37,087x)
r = 8: 37,748,736 trainable = 37.7M (175,000M full, ratio 4,636x)
4.7M और 37.7M paper की GPT-3 results table की दो LoRA पंक्तियाँ हैं; abstract का "10,000 times" यही अनुपात है, गोल किया हुआ। ध्यान दीजिए उन्होंने adapt क्या किया: attention में Wq और Wv, और कुछ नहीं; MLP frozen था। अब वह बात जिस पर paper ईमानदार है और blog posts नहीं: LoRA वह घटाता है जो आप train करते हैं, वह नहीं जो आप load करते हैं।
What LoRA does NOT shrink (d = 4096, r = 8, fp16 weights, fp32 Adam):
the frozen W itself ............ 33.6 MB either way
full FT grads + Adam m, v ..... 167.8 MB
LoRA grads + Adam m, v ..... 0.7 MB
Diff low-rank है — और इसका मतलब यह है
Laptop पर GPT-3 fine-tune नहीं कर सकता, इसलिए एक linear layer के लिए ईमानदार संस्करण यह रहा। Least squares वह ΔW देता है जिस पर पूरा fine-tune पहुँचता — closed form, कोई training loop नहीं — और SVD बताता है कि उसमें कितनी दिशाएँ हैं। Task का असली ΔW rank 4 है और थोड़ा noise; असली diffs को लेकर paper का यही दावा है। d = 64, 512 rows.
singular values of the fitted dW (first 10):
[3.001 2. 0.986 0.512 0.156 0.151 0.146 0.14 0.137 0.135]
rank r dW error test RMSE
none 0.4852 ← base model
1 0.6199 0.3026
2 0.3330 0.1694
4 0.1628 0.0907 ← वह ढलान
8 0.1432 0.0840
64 0.0000 0.0533 ← पूरा fine-tune
चार असली singular values — 3, 2, 1, 0.5 — फिर 0.15 पर एक noise floor. Fit किए गए ΔW में 4096 entries हैं और मायने रखने वाली दिशाएँ 4. Rank 4 test error को 0.49 से 0.09 पर ले आता है; पूरा fine-tune 0.05 देता है, और वह अंतर 60 नन्ही noise दिशाएँ हैं जिन तक rank 8 पहुँच नहीं सकता और जिनकी ज़रूरत भी नहीं। अब इसे तोड़िए: उतना ही बड़ा ΔW, पर random Gaussian.
BREAK IT: same size of dW, but full-rank (random Gaussian):
[0.928 0.878 0.847 0.828 0.803 0.793 0.774 0.75 0.737 0.719] ← ढलान नहीं
rank r dW error test RMSE
8 0.7909 0.3828 ← rank 8 से बात नहीं बनी
64 0.0000 0.0536 ← पूरा fine-tune
ढलान नहीं तो LoRA नहीं: rank 8 diff का 79% ज़मीन पर छोड़ देता है। LoRA इसलिए काम करता है क्योंकि असली fine-tune diffs पहली table जैसे दिखते हैं — यह अनुभव से निकला दावा है, कोई theorem नहीं। Paper का सबूत section 7 है: fine-tune किए गए GPT-3 पर r = 64 वाले update की ऊपरी दिशाएँ r = 8 वाली दिशाओं से काफ़ी हद तक मेल खाती हैं, और जिन tasks पर परखा गया उन पर r = 1 से 4 ही पूरे fine-tuning की बराबरी कर रहा था। Paper का सबसे अच्छा हिस्सा; और यही सब छोड़ देते हैं।
Merge कीजिए और inference पर कुछ मत चुकाइए
x @ (W + AB) और x @ W + (x @ A) @ B एक ही संख्या हैं —
distributivity. इसलिए serve करने से पहले patch को weight में एक बार जोड़ दीजिए।
Serve होने वाला model एक ही matmul है, वही जो वह हमेशा से था।
max |unmerged - merged| = 3.55e-15
flops per token, base W alone ......... 8,192
flops per token, base + unmerged patch 10,240
flops per token, merged ............... 8,192 ← same as base
un-merge, max |W_back - W| = 5.55e-17 ← base recovered
best single matrix that reproduces a LoRA layer: residual 1.83e-15
best single matrix that reproduces an adapter: residual 5.44e-01
आख़िरी दो पंक्तियाँ ही adapters के ख़िलाफ़ पूरा तर्क हैं। मैंने least squares से पूछा कि हर layer को सबसे अच्छे ढंग से दोहराने वाला एक matrix कौन-सा है। LoRA के लिए वह उसे ठीक-ठीक ढूँढ़ लेता है। Adapter के लिए — बीच में relu वाले दो matmuls — सबसे अच्छा अकेला matrix 54% ग़लत है, क्योंकि कोई भी linear map relu को दोहरा नहीं सकता। इसलिए वह अतिरिक्त layers ही बना रहता है, और अतिरिक्त layers यानी अतिरिक्त latency.
B zero से क्यों शुरू होता है (A से क्यों नहीं)
B = 0 पर patch zero है और आपके fine-tune का step 0 base model के बिल्कुल बराबर है — क़रीब नहीं, हूबहू। वह किसी ऐसी चीज़ से ही दूर जा सकता है जो काम करती है।
A random, B zero: identical to base? True max |diff| = 0.0
A random, B random: identical to base? False outputs moved 31.5%, untrained
init |dL/dA| |dL/dB| what happens
A random, B zero 0.000 11.009 B moves first, then A. Trains.
A zero, B zero 0.000 0.000 both zero forever. Stuck.
A random, B random 10.724 11.009 trains, but step 0 is wrong.
B को zero क्यों, A को क्यों नहीं, या दोनों को क्यों नहीं? दूसरी table हाथ से निकाला गया एक gradient है। dL/dA में B का गुणा होता है, इसलिए B = 0 पर A एक step तक टिका रहता है — पर B को असली gradient मिलता है, और B के हिलते ही A पीछे-पीछे चल पड़ता है। दोनों zero कर दीजिए तो हर gradient zero है; patch कभी origin छोड़ता ही नहीं। दोनों random कर दीजिए तो training शुरू होने से पहले ही आपने model तोड़ दिया।
α किस लिए है
Paper patch को α/r से scale करता है और कहता है: जो पहला r आज़माएँ उसी पर α सेट कर दीजिए, फिर उसे छोड़ दीजिए। यह क्यों चलता है: Adam का पहला step ठीक lr × sign(grad) होता है, हर parameter के लिए एक जितना। B में r rows हैं और patch उन सब को जोड़ता है, तो उसी learning rate पर बड़ा r यानी बड़ा step — जब तक आप r से भाग न दें।
one Adam step (lr = 0.001) on B, from B = 0. alpha = 4.
r no scale: |dh| loss drop alpha/r: |dh| loss drop
1 0.1031 0.00014 0.4125 0.00054
4 0.2553 0.00063 0.2553 0.00063
16 0.5219 0.00246 0.1305 0.00062
64 1.3644 0.01066 0.0853 0.00067
loss drop at r = 64 vs r = 1: no scale 78x alpha/r 1.2x
Scale के बिना, r = 1 से 64 पर जाने से वही optimiser step loss पर 78× ज़्यादा असर करता है: आपने चुपचाप अपनी learning rate बदल दी। α/r के साथ loss drop 20% के भीतर टिका रहता है। (r = 4 वाली पंक्ति sanity check है: वहाँ α/r = 1 है, तो दोनों आधे मिलते हैं।) एक ईमानदार चेतावनी: α/r के तहत norm वाला column अब भी r के साथ घटता है; टिकता वह loss का बदलाव है — और इसी आधार पर 2023 का एक follow-up, rsLoRA, α/√r की वकालत करता है। व्यवहार में α patch के लिए एक learning-rate knob है — प्रचलित "α = 2r" वाला नियम बस इतना ही है।
The code
Pure NumPy. No torch, no autograd, no training loop — the fine-tune is a least-squares solve and the low-rank part is an SVD, so you can watch the diff get compressed.
Download the full script — about 100 lines of mechanism, every function under 12 lines, plus five demos. Then:
python3 lora_from_scratch.py
import numpy as np
def lora_init(d_in, d_out, r, seed=0):
rng = np.random.default_rng(seed)
A = rng.normal(0, 1 / np.sqrt(d_in), (d_in, r)) # random, like any layer
B = np.zeros((r, d_out)) # zero: the patch is empty
return A, B
def lora_forward(x, W, A, B, alpha, r):
# (1) BASE: the frozen pretrained layer, untouched
base = x @ W
# (2) PATCH: squeeze x down to r numbers, expand back out. Two thin matmuls.
patch = (x @ A) @ B
# (3) SCALE by alpha/r, so changing r doesn't change how big a step is.
# Section 4.1: "we simply set alpha to the first r we try".
return base + (alpha / r) * patch
def merge(W, A, B, alpha, r):
"""Fold the patch into the weight. One matrix again: zero extra latency."""
return W + (alpha / r) * (A @ B)
def fit_delta(X, Y, W):
"""What full fine-tuning would learn: the dW that best maps X to Y."""
residual = Y - X @ W # what the base gets wrong
dW, *_ = np.linalg.lstsq(X, residual, rcond=None)
return dW
def low_rank(dW, r):
"""Keep the top-r directions of dW. Returns A (d_in, r), B (r, d_out)."""
U, s, Vt = np.linalg.svd(dW, full_matrices=False)
A = U[:, :r] * s[:r] # fold the singular values into A
B = Vt[:r]
return A, B, s # A @ B is the best rank-r approximation of dW
def lora_grads(x, A, B, G, alpha, r):
s = alpha / r
grad_A = s * x.T @ G @ B.T
grad_B = s * (x @ A).T @ G
return grad_A, grad_B
Now break it
This is the part that actually makes it stick. Reading won't do it. You debug for a living — reverse-engineering from broken behaviour is your native mode. Every row is a one-line edit, or something the script already prints.
| Break this | Watch for |
|---|---|
In lora_init, replace np.zeros((r, d_out)) with a random matrix | Demo 4: identical to base? False, outputs moved 31.5% before a single training step. Your fine-tune starts by breaking the model. |
In demo 4's init table, zero A as well: np.zeros_like(A) for both | |dL/dA| = 0.000, |dL/dB| = 0.000. Nothing ever moves. B = 0 only works because A is random. |
In lora_forward and merge, replace (alpha / r) with 1 | Demo 5: the same Adam step drops the loss 78× more at r = 64 than at r = 1. Every change of r is now a change of learning rate. |
In demo 2, hand make_task the full-rank dW_full instead of dW_true | Singular values 0.93, 0.88, 0.85… no cliff. Rank 8 leaves the test error at 0.38 against 0.05 for the full fine-tune. No small r saves a full-rank task. |
In demo 3, delete the np.maximum(…, 0) from the adapter | The adapter residual falls from 5.44e-01 to float precision. Remove the relu and an adapter is a LoRA — it folds into W. |
| In demo 1, read the "every matrix" block: attention, MLP, embeddings, LM head | 37.7M becomes 170.9M trainable, 4.5×. The paper adapted Wq and Wv only and froze the MLP; under a fixed budget, section 7 found Wq + Wv at r = 4 beat Wq alone at r = 8. |
On the maths anxiety
Matrix shapes, one outer product, and the word rank. Rank is the number of
independent directions in a matrix; the SVD is the library call that measures it.
Least squares is np.linalg.lstsq, another library call. That is the whole
paper. The one gradient I wrote by hand is the chain rule for a linear layer — two
matmuls — and you never need to write it again; loss.backward() does it,
the same way you don't hand-roll TCP.
What this page did give you is a habit: look at a weight matrix and ask how many of its directions matter. That question comes back in quantisation, pruning, KV-cache compression and every "why is this model smaller" interview answer. Not research maths.
Where to go next
This page gave you one thing: the diff is thin, in code you can break. I skipped the training loop and the hyperparameter sweeps. Two other people cover those properly.
-
LoRA: Low-Rank Adaptation of Large Language Models
Section 4 first — equation 3 and the two paragraphs around it are this page. Then section 7, the part I could only fake with a 64×64 toy: what ΔW looks like on a real fine-tuned GPT-3, which matrices to adapt, and why r = 1 was enough.
-
Improving LoRA: Implementing DoRA from Scratch
The training loop I refused to write. A LoRA layer as an
nn.Modulewrapped around a real model and trained on real data — then DoRA, the 2024 refinement that splits the update into a magnitude and a direction. -
Practical Tips for Finetuning LLMs Using LoRA
The hyperparameters, from someone who ran the sweeps: what r and α to pick, where "α = 2r" comes from, whether to adapt every layer (usually yes, which is not what the paper did), and how much run-to-run noise to expect.