WORKBENCH
06 · TrainingLesson 6.7

Fine-tuning and LoRA.

How do you adapt a big model by training only a tiny fraction of it?

Listen · 15 min0:00 / 15:07

Fine-tuning is taking a model that already knows a lot and training it a little more so it gets good at one particular job, and the trick here is to leave the whole original model untouched and train a small, slim add-on next to it instead.

That trick decides whether you can do the job on your Spark at all. Retraining every number in an 8-billion-number model the ordinary way needs about as much memory as the whole machine has, before it even starts. The add-on version fits with room to spare, saves as a file small enough to email, and once you fold it back in, the model runs exactly as fast as before. That is how you would teach an 8B model to judge query-page relevance for your ranking work in a strict, machine-readable format, without it opening with “Sure! Here’s my assessment:” one time in twenty.

It builds on How much memory does a model need? and Why training needs so much memory, which give you the memory bill this trick dodges, and on Quantization for squeezing the frozen model into fewer bits. If you did Train your own draft model, this is a much cheaper way to run that same kind of retraining. Next, Why are evals separate from training? is about trusting the score you get, and the add-on comes back in DPO and GRPO, the cheap way to train models from preferences and rewards on one machine.

Today

You will want How much memory does a model need? for bytes per number, and ideally Why training needs so much memory for the 16-bytes-per-number bill. Quantization helps for the 4-bit part. Plan on one evening for the from-scratch layer on your Mac, then one or two sessions on the Spark.

The Problem

Your cross-encoder ranks documents well, but you want something it can’t do: a model that reads a query and a page and returns a strict, machine-readable judgement, {"label": 2, "reason": "..."}, for labelling training data or for a second-stage check. You try prompting Llama 3.1 8B Instruct. It is right most of the time. It also, about one time in twenty, opens with “Sure! Here’s my assessment:”, forgets a field, or invents a label 4 on a 0-3 scale. Your parser falls over, at 3 a.m., on the one batch that mattered.

The obvious fix is to train the model on a few thousand examples of exactly the output you want. That’s fine-tuningTaking a model that is already trained (pretrained) and training it a bit more on a smaller, specific dataset so it gets good at one job.. So you do the arithmetic from the training-memory lesson. Training with Adam in mixed precision costs about 16 bytes per parameter before you store a single activation. 8 billion parameters × 16 bytes = 128 GB. The Spark has 128 GB of memory in total (spec-sheet number), shared with the operating system and the CPU. It doesn’t fit. Not “fits badly”: doesn’t fit.

And suppose it did. Every task you fine-tune for gives you a new 16 GB copy of the model. A relevance judge, an extractor, a query rewriter: three copies, 48 GB of disk, and if you want to serve all three you need three models in memory.

Here’s the strange thing. The change fine-tuning makes to the weights turns out to be tiny and simple. You’re paying to update 8 billion numbers when the change you need could often be written down with a few million.

The Idea

Fine-tuning is a small edit to a big matrix

Strip a transformer down and most of its parameters live in a handful of big linear layerA layer that computes y = Wx (plus an optional bias): multiply the input vector by a weight matrix. In PyTorch, nn.Linear.s per block: the attention projections q_proj, k_proj, v_proj, o_proj, and the MLP’s gate_proj, up_proj, down_proj. Each is a matrix W, and the layer computes h = W x.

Fine-tuning starts from the pretrained W and nudges it. After training you have a new matrix, which you can always write as the old one plus a difference:

W_new = W + ΔW

Full fine-tuning learns ΔW with no restrictions: every one of its entries is free. For a 4096 × 4096 projection that’s 16.8 million numbers, for one matrix, in one layer. LoRA’s whole idea is to put a restriction on the shape of ΔW so it can be stored and trained with far fewer numbers.

The bill for doing it the full way

Before the trick, the cost of not using it. When you train every weight with AdamThe standard optimizer for transformers. For every parameter it keeps two running averages: of the gradient (momentum) and of the squared gradient (to scale each step). AdamW is Adam with decoupled weight decay. in mixed precisionRunning the forward and backward passes in a 16-bit format (bf16) for speed, while keeping a 32-bit “master” copy of the weights so tiny updates don’t round away., every parameter carries:

WhatBytes per parameter
bf16 weight (used in forward/backward)2
bf16 gradient2
fp32 master weight4
Adam first moment (fp32)4
Adam second moment (fp32)4
Total, before activations16

Implementations differ a little (some keep fp32 gradients, some drop the bf16 copy), so treat 16 as the standard round number; Why training needs so much memory takes it apart. On top of this you need activationsThe intermediate outputs of every layer from the forward pass, kept in memory because the backward pass needs them to compute gradients. Grows with batch size × sequence length., which grow with batch size times sequence length.

Now look at what a frozen weight costs. If a parameter doesn’t train, it needs no gradient, no master copy and no optimizer state. It costs just its 2 bf16 bytes, or about half a byte if you store it in 4 bits. So the game is: freeze almost everything, and make the part that trains tiny.

FIG_001[ MEMORY TO FINE-TUNE, BEFORE ACTIVATIONS ]
8B MODEL · GBSPARK 128 GB (SPEC)064FULL FT, 8BW 16GRAD 16MASTER 32ADAM m, v 64 · +ACTLORA, 8B+ACT16 GB FROZEN BF16 + 0.7 GB ADAPTERQLORA, 8B+ACT~5.7 GB FROZEN 4-BIT + 0.7 GB ADAPTERFULL FT, 1B+ACT1.24B × 16 B ≈ 20 GB
Approximate memory for model state only. Full fine-tuning an 8B model needs about 128 GB, all of the Spark's spec-sheet memory, before a single activation. LoRA keeps the 8B base frozen in bf16 (16 GB) and trains ~42M adapter parameters (r = 16, every linear layer), whose full Adam bill is under 1 GB. QLoRA stores the frozen base in 4 bits. Activations come on top of every bar.

The “~5.7 GB” for QLoRA is my estimate, not a measurement: the transformer layers at a little over 4 bits per weight (~3.6 GB), plus the input embedding and output head, which are normally left in bf16 (~1 GB each for Llama 3’s 128k vocabulary). You’ll measure the real number.

The low-rank bet

Here is the restriction. Instead of learning ΔW (shape d × d) directly, learn two thin matrices and multiply them:

ΔW = B · A        B is d × r,  A is r × d,  and r is small (8, 16, 64)

Think of what B · A does to an input x. First A squeezes the d-dimensional x down to just r numbers. Then B spreads those r numbers back out to d dimensions. Whatever the update does, it has to pass through an r-number bottleneck. So the update can only push the output in r independent directions. That is exactly what the rankThe number of independent directions a matrix can output. A d × d matrix has rank at most d. A product B·A with an r-wide middle has rank at most r, however big d is. of a matrix means, and why B · A is called a low-rankA matrix whose rank is much smaller than its size: it can be written exactly as a product of a tall-thin and a short-wide matrix. update.

Count the numbers. B has d × r, A has r × d, so together 2dr. Against d² for the full matrix:

drfull ΔW (d²)LoRA (2dr)fraction
4096816,777,21665,5360.39%
40961616,777,216131,0720.78%
40966416,777,216524,2883.1%

For a non-square matrix (input size k, output size d, like Llama’s MLP at 4096 → 14336) the same logic gives r × (d + k). Summed over every linear layer of Llama 3.1 8B at r = 16, that’s 41,943,040 trainable parameters, about 0.52% of the model. Only q_proj and v_proj at r = 16: 6.8 million, under 0.1%.

The bet is that the update fine-tuning needs is close to low-rank, so this restriction costs little. Whether that’s true depends on the update. Play with it.

FIG_002[ A LOW-RANK FIT OF AN UPDATE ]
TARGET ΔWB · A (RANK 4)ERROR ΔW − B·ASHAPES TO SCALE · d = 24W d×dd² = 576vsBA192ERROR VS RANKr = 1r = 24 (FULL)100%0SINGULAR VALUES OF ΔW · KEPT IN BLUE
update
error ‖ΔW−BA‖/‖ΔW‖ -toy params: d² 576 vs 2dr -
at d = 4096: d² 16.8M vs 2dr - (-)
A toy 24 × 24 update ΔW and the best possible rank-r approximation B·A (from the truncated SVD, so no training run can beat it at that rank). Blue is positive, dark is negative. With 'few directions', rank 4 recovers it exactly with a third of the numbers. With 'many directions', every extra rank buys a little, and you only get it all at full rank. Real fine-tuning updates for narrow tasks look more like the first two; teaching lots of new facts looks more like the third.

Three things to notice while you drag the slider.

  1. The shapes. At r = 4, B and A are slivers next to W, and their combined count is a third of d² even at this toy size. At d = 4096 the same r is 0.2%. The savings grow with d, because d² grows faster than 2dr.
  2. The error curve’s shape is the whole story. If the update has a few strong directions, the error falls off a cliff at the right rank and then sits at zero. Rank beyond that is wasted. If the update has energy spread over many directions, the curve is a slow slope, and any small rank leaves most of it on the table.
  3. The spectrum bars. The singular valuesFor any matrix, the strengths of its independent directions, sorted largest first (from the SVD). Rank = how many are non-zero. Keeping the top r gives the best rank-r approximation (the Eckart-Young theorem). are those strengths, sorted. LoRA can only ever capture the top r of them (the blue ones). Everything grey is gone.
Predict firstwrite it down

Pretend you fine-tuned a model fully, then took ΔW = W_new − W for one attention projection and computed its singular values. For a narrow task (say, always answer in a JSON format), do you expect the spectrum to look like “few directions” or “many directions”? What about continued pretraining on 20 billion tokens of code?

Reveal

For the narrow task, closer to “few directions”, with a tail. The LoRA paper looked at exactly this and found that the update has a very low “intrinsic rank”: a rank-1 to rank-8 adapter captured most of what mattered on their tasks, and the directions LoRA learned amplified features already present in W but not emphasised.

For 20B tokens of code, much closer to “many directions”. Biderman et al. (2024) measured the spectrum of full fine-tuning updates in that setting and found they needed a rank 10-100× higher than typical LoRA configurations to explain 90% of their variance. Learning a whole new domain is not a small edit.

Why a tiny rank is often enough

The deeper reason comes from a 2020 paper by Armen Aghajanyan, Luke Zettlemoyer and Sonal Gupta, “Intrinsic Dimensionality Explains the Effectiveness of Language Model Fine-Tuning”. They asked: how many free numbers do you actually need to fine-tune a pretrained model to good accuracy?

Their method: freeze the model, pick a random way to map a small vector of k numbers to a change in all the weights (a fixed random projection), and train only those k numbers. Then find the smallest k that still gets you 90% of full fine-tuning’s accuracy. That k is the task’s intrinsic dimensionThe smallest number of free parameters that, mapped into the full weight space, can still solve the task to a set fraction (here 90%) of full fine-tuning’s quality. Li et al. 2018 introduced the measurement..

The numbers were startling. For a RoBERTa model on a paraphrase task (MRPC), about 200 trainable numbers, randomly projected into all of its hundreds of millions of weights, got 90% of full fine-tuning’s performance. And bigger, better-pretrained models had smaller intrinsic dimension: pretraining makes the model so ready for language tasks that adapting it is a short walk in a few directions.

LoRA turns that into an engineering choice: instead of a random projection, a structured one (low rank, per matrix) that is cheap to train and can be multiplied out at the end.

The LoRA layer, piece by piece

Here’s what one LoRA-wrapped linear layer computes:

h = W x  +  (α / r) · B (A x)
FIG_003[ ONE LORA LAYER ]
xd = 4096W (d × d)FROZEN · NO GRAD · NO ADAMbf16 or 4-bitAA: r × dRANDOM INITrBB: d × rZEROS× α/r+hTRAINED: ONLY A AND B
The frozen weight W does its usual work. Beside it, A squeezes the input to r numbers and B expands it back; the result is scaled by α/r and added. B starts at zero, so at step 0 the side path adds exactly nothing and the model is the base model. Only A and B receive gradients and optimizer state.

Three design details hide in that line, and each one is there for a reason.

B starts at zero. With B = 0, the side path outputs zero, so h = W x exactly: on step 0 the model is the base model, with all its abilities. Training starts from a known-good point and moves away only as far as the data pushes it. A is initialised randomly (the paper used a Gaussian; Hugging Face’s PEFT library uses the same Kaiming-uniform init as nn.Linear). Why not both zero? Look at the gradients. The gradient for B is proportional to A x, and the gradient for A is proportional to Bᵀ times the incoming gradient. If both start at zero, both gradients are zero, forever, and nothing ever trains. One of them must be random so the other gets a signal. You’ll see this exact behaviour in Break It: at step 0, A’s gradient is exactly zero and B’s is not.

The scale α / r. α (alpha) is a fixed number you pick, usually r or 2r. The point of dividing by r is to keep the size of the update roughly steady as you change r, so that you can try rank 8 and rank 32 without retuning the learning rate from scratch. The LoRA paper simply set α to the first r they tried and never tuned it. A later analysis, rsLoRARank-stabilised LoRA (Kalajdzievski, 2023): scale by α/√r instead of α/r, which keeps learning stable at large ranks. A flag in PEFT (use_rslora)., argued that α / √r behaves better at large ranks, and PEFT supports both. In practice: fix α relative to r (say α = 2r), and tune the learning rate.

LoRA dropoutDropout applied to the input of the A·B side path only (not to the frozen path), to regularise the adapter. Typical values 0 to 0.1.. Optional dropout on the input to the side path, usually 0.05 to 0.1, as a mild guard against overfitting small datasets.

Predict firstwrite it down

LoRA trains 0.5% of the parameters. Does a training step take 0.5% of the time of full fine-tuning? Roughly what fraction of the compute does it take?

Reveal

No. Roughly two thirds, and sometimes barely less than full fine-tuning.

A training step for a transformer costs about 6 FLOPs per parameter per token: 2 for the forward pass, 2 for the backward pass to get the gradient with respect to each layer’s input (needed so the gradient can keep flowing to earlier layers), and 2 to get the gradient with respect to each layer’s weights. Freezing W saves only the last part. The forward pass still runs through every frozen weight, and the backward pass still has to go back through every frozen layer, because the adapters in layer 1 need the gradient that flows back from layer 32. So LoRA is about 4 FLOPs per parameter per token instead of 6, plus a little for the adapters themselves. The big wins are memory (no gradients or Adam states for frozen weights) and storage, not FLOPs. QLoRA is usually slower per step than bf16 LoRA, because every frozen weight is unpacked from 4 bits on the fly in both the forward and backward passes.

Which matrices to adapt

You choose which linear layers get a LoRA wrapper. The LoRA paper, short on memory for GPT-3, adapted only the query and value projections (W_q, W_v), and found that spreading a fixed budget over both beat putting it all in one. That became the default in a lot of tutorials.

It’s not the best default anymore. QLoRA found that adapting every linear layer, attention and MLP, was needed to match full fine-tuning quality, and that once you do, the rank matters much less. The “LoRA Learns Less and Forgets Less” study found the same, and Thinking Machines’ 2025 write-up “LoRA Without Regret” found that MLP layers matter most: attention-only LoRA underperformed even at matched parameter count. Most of a transformer’s parameters live in the MLP, so that’s not surprising.

Practical default: target all linear layers in the blocks (PEFTHugging Face’s library for parameter-efficient fine-tuning: LoRA and its relatives, wrapped around Transformers models. accepts target_modules="all-linear"), leave the embeddings and the output head frozen, and start at r = 16.

Merging: an adapter that costs nothing to run

After training you have W (frozen, unchanged) and a small B, A per adapted layer. You can serve that as is, computing the side path on every token. Or you can multiply it out once:

W_merged = W + (α / r) · B · A

W_merged has exactly the shape of W. The model is now an ordinary model, same architecture, same speed, nothing extra on the forward path. That’s the property that made LoRA beat adapters: zero added latency at inference, because there’s nothing left to add. PEFT calls it merge_and_unload(). You can also go back: subtract the same product and you have the base model again (up to rounding).

Merging has one trap with QLoRA. You trained the adapter against a 4-bit copy of the base. If you merge into the 4-bit weights, the sum has to be re-quantized and much of your tiny update rounds away. The usual practice is to load the base in bf16, merge the adapter into that, and save a bf16 model. That model is very slightly different from the one you trained (bf16 base instead of 4-bit), so always run your eval on the merged model, not just the training checkpoint.

Predict firstwrite it down

How big is the saved adapter file for Llama 3.1 8B with r = 16 on every linear layer, stored in bf16? And the merged model?

Reveal

41,943,040 parameters × 2 bytes ≈ 84 MB for the adapter (plus a small JSON config). The merged model is a full model again: 8.03B × 2 bytes ≈ 16 GB. That 200× gap is why you ship adapters around and merge only at the end, and why one server can hold hundreds of them.

Serving many adapters on one base

Merging bakes one task in. But the other choice, not merging, has a superpower: the base weights are shared, and each adapter is tiny (tens of megabytes). So one server can hold one copy of the base and hundreds of adapters, and pick an adapter per request.

FIG_004[ MERGE ONE, OR SERVE MANY ]
MERGE (ONE TASK)W+α/r · B·A=W’SAME SHAPE, SAME SPEEDNO SIDE PATH AT RUNTIMEONE MODEL PER TASKSERVE MANY (S-LORA, VLLM)BATCH1 · judge2 · extract3 · judge4 · baseSHARED BASE WONE MATMUL, ALL ROWSPER-ROW B·A KERNELROW i: ADAPTER a(i)ADAPTER POOLjudgeextractrewrite…SOLID = ON GPU · DASHED = IN CPU RAM,LOADED WHEN A REQUEST ASKS FOR IT
Left: merging folds B·A into W once, giving an ordinary model with no extra work per token, for one task. Right: multi-adapter serving keeps one shared base in memory and a pool of small adapters; each request in a batch names its adapter, the base matmul runs once for the whole batch, and a special kernel applies each row's own B·A. This is what S-LoRA, Punica and vLLM's LoRA support do.

The hard part is the per-row step: a batch where row 1 uses adapter “judge” and row 2 uses “extract” can’t be one plain matmul. PunicaA 2023 system (Chen et al.) for serving many LoRA adapters on one GPU. Introduced a kernel (SGMV) that applies a different adapter to each request in a batch. wrote a custom kernel that does a different small matmul per segment of the batch, and S-LoRA“Serving Thousands of Concurrent LoRA Adapters” (Sheng et al., 2023). Keeps adapters in CPU memory, pages active ones onto the GPU in a pool shared with the KV cache, and batches requests across adapters. added paging: adapters live in CPU memory and move into a GPU pool shared with the KV cache as requests arrive, the same paging idea as vLLM’s KV blocks from What’s inside a serving engine?. vLLM supports this directly: enable LoRA, register adapters by name, and a request picks one through its model field (flags like --enable-lora and --lora-modules name=path; check vLLM’s LoRA docs for your version). The side path now runs on every token, so unmerged adapters cost a little speed: the price of flexibility.

QLoRA: freeze the base in 4 bits

LoRA got rid of the gradients and optimizer states for frozen weights. The frozen weights themselves are still 2 bytes each: 16 GB for 8B, 130 GB for 65B. In 2023 Tim Dettmers, Artidoro Pagnoni, Ari Holtzman and Luke Zettlemoyer asked: the base is frozen, so why store it in 16 bits at all? QLoRAQuantized LoRA (Dettmers et al., 2023): store the frozen base in 4-bit NF4, dequantize each weight to bf16 on the fly for the matmul, and train bf16 LoRA adapters on top. Fine-tuned a 65B model on a single 48 GB GPU. fine-tuned a 65B model on a single 48 GB GPU, and matched 16-bit fine-tuning quality on their benchmarks. Three pieces made it work.

NF4. From Quantization you know a 4-bit format has 16 levels and the question is where to put them. Pretrained weights are roughly bell-shaped (normal) around zero. NF44-bit NormalFloat. The 16 levels are placed at quantiles of a normal distribution, so each level is used about equally often by normally distributed weights. Each block of 64 weights has its own scale (its absolute max). places the 16 levels at the quantiles of a normal distribution, so each level gets used by roughly the same number of weights: dense near zero where most weights live, sparse in the tails. Each block of 64 weights gets its own scale. For the matmul, the block is unpacked to bf16 on the fly; the adapter math is in bf16 throughout. Gradients flow through the 4-bit weights (to reach earlier layers) but never update them.

Double quantization. Every block of 64 weights stores one fp32 scale: 32 bits / 64 weights = 0.5 extra bits per weight. That’s 4 GB for a 65B model, just for scales. QLoRA quantizes the scales too: to 8 bits, in blocks of 256 scales, each with its own fp32 second-level scale. Cost per weight becomes 8/64 + 32/(64 × 256) ≈ 0.127 bits, saving about 0.37 bits per weight. About 3 GB on a 65B model.

Paged optimizers. Memory use during training spikes, for example on an unusually long batch, and a spike at the wrong moment is an out-of-memory crash. QLoRA put the optimizer states in NVIDIA unified memoryCUDA memory that can automatically move pages between GPU and CPU memory as they’re touched, like an OS swapping pages to disk., so on a spike they get paged out to CPU RAM and paged back when needed, like the OS swapping from How does memory really work?. On the Spark, CPU and GPU already share one pool of memory, so there’s less to page between, but the same option protects you from a crash.

The cost: every frozen weight is dequantizedUnpacked from its stored low-bit form back to a normal float (here bf16), using its block’s scale, just before the matmul that needs it. on every forward and backward pass, so QLoRA steps are slower than bf16 LoRA. On the Spark, bf16 LoRA on an 8B fits easily; QLoRA is a choice until you try a 70B.

What LoRA can and can’t learn

Here’s the honest part, and it decides when to reach for LoRA at all.

In 2024, Dan Biderman, Jacob Portes, Jose Javier Gonzalez Ortiz and colleagues (Columbia and Databricks) ran the careful comparison in “LoRA Learns Less and Forgets Less”. They trained Llama-2-7B on code and on math, in two regimes: continued pretrainingTraining further on large amounts of raw text from a new domain (billions of tokens), to teach the model knowledge, not just a format. (billions of unlabeled tokens) and instruction fine-tuningTraining on prompt-response pairs so the model learns to follow instructions or a task format. Usually thousands to a few hundred thousand examples. (prompt-response pairs). They compared full fine-tuning with LoRA at various ranks. What they found:

That matches Figure 002. A new output format, a style, a narrow classification or extraction task: “few directions”, and LoRA is close to full fine-tuning. Everything about your company’s internal APIs, or a new language: “many directions”, and LoRA leaves much of it out. For knowledge, retrieval (facts in the prompt) usually beats fine-tuning anyway. Thinking Machines’ “LoRA Without Regret” (2025) refines the boundary: with all layers adapted and the right learning rate, LoRA matched full fine-tuning on small-to-medium post-training datasets, and fell behind once the dataset carried more information than the adapter had capacity for.

The flip side, forgetting, has a name. catastrophic forgettingWhen training on a new task overwrites what a network knew before, so its ability on old tasks drops sharply. Named by McCloskey and Cohen (1989) for small networks; still very real for LLMs. is what happens when your fine-tune gets great at relevance labels and quietly gets worse at everything else: it stops following other instructions, its general knowledge slips, it answers every question with a relevance label. Lower rank, fewer steps, a lower learning rate, and mixing a little general instruction data into your training set all reduce it. And you only know how much you lost if you measure it, which is why the eval below has two halves.

Data: chat templates and loss masking

The model learns from tokens, so the most common fine-tuning bugs are about tokens, not math.

Use the model’s own chat templateThe exact text layout, including special marker tokens, that an instruct model was trained to see conversations in. Stored with the tokenizer and applied by tokenizer.apply_chat_template.. An instruct model was trained to see conversations wrapped in specific special tokens. For Llama 3 a user turn looks like <|start_header_id|>user<|end_header_id|>, a blank line, the text, then <|eot_id|>. Other model families use other markers. If you train on raw “Query: … Answer: …” text and then serve through the chat endpoint (which applies the template), the model sees a format at inference that it never saw in training. Always build training examples with tokenizer.apply_chat_template(...), and serve the same way.

Mask the prompt out of the loss. In a training example, the prompt (system message, query, a long document) might be 500 tokens and the answer 15. If you train on every token, 97% of the gradient goes into teaching the model to predict your prompts, which it doesn’t need to generate. You want loss only on the answer. In Hugging Face models you do that with the labels: copy the input token IDs into labels, then set every prompt position to -100. The cross-entropy loss ignores any position labelled -100 (ignore_index in PyTorch’s F.cross_entropy). Keep the end-of-turn token in the loss, or the model never learns to stop.

FIG_005[ LOSS MASK ON ONE EXAMPLE ]
INPUT TOKENSBEGINUSERquery + page …EOTASSISTANTlabel: 2 …EOTLABELS-100 -100 -100 … -100 (IGNORED BY THE LOSS)TOKEN IDSPROMPT: ~500 TOKENS, MASKEDANSWER: ~15 TOKENS, TRAINEDEOT IS TRAINED TOO, OR THE MODEL NEVER LEARNS TO STOP
One training example in Llama 3's chat format. Every token is fed to the model as input. Only the answer and its end-of-turn token carry a label; prompt positions are set to -100 and contribute nothing to the loss. Drop the mask and most of the gradient goes into predicting your prompt.

One subtle trap when you build the mask: tokenize the prompt and the full conversation separately and check that the full one starts with the prompt’s tokens. Tokenizers can merge characters across the boundary, so “prompt length” in tokens isn’t always where you think it is. The from-scratch code below asserts this.

Libraries do the masking for you (TRL’s SFTTrainer has options for training only on the completion or the assistant turns; their names have changed across versions, so check its docs). Do it by hand once anyway, so you can check the library is doing it.

Hyperparameters, and how overfitting shows up

The knobs, with starting points that are widely used, not laws:

KnobStarting pointWhy
Learning rate1e-4 to 2e-4 for LoRA (full FT: ~1e-5 to 2e-5)Adapters start at zero and are tiny; they need bigger steps. “LoRA Without Regret” found the best LoRA rate about 10× the best full fine-tuning rate.
Rank r16 (try 8 and 64)Narrow tasks saturate early. Watch the eval, not the train loss, when choosing.
α2r (or r)Fix the ratio and tune the learning rate instead.
Targetsall linear layersMLP layers carry much of the benefit.
Epochs1-3 over a few thousand examplesSmall datasets overfit fast.
Scheduleshort warmup, then cosine or linear decayStability at the start, small steps at the end.

overfittingWhen a model fits the training examples better and better while getting worse on new ones: it memorises rather than learns the pattern. in fine-tuning looks like this:

Evaluating a fine-tune

“The loss went down” is not an evaluation. A proper one has two halves.

1. Did it learn the task? A held-out setExamples kept completely out of training (and out of any decisions you tune on), used only to measure. If you tune on it, it stops being held out. scored with the metric you actually care about: label accuracy and macro-F1F1 score (the balance of precision and recall) computed per class and then averaged, so rare classes count as much as common ones. Plain accuracy can hide a model that always predicts the majority label. for relevance grades, exact-match and field-level accuracy for extraction, plus “fraction of outputs that parse”. Compare against the base model with your best prompt: if prompting already gets 95%, a fine-tune that gets 96% may not be worth owning. Split carefully. For ranking data, split by query, not by row: if the same query appears in train and test with different documents, the model can learn the query, and your test score lies. (Why are evals separate from training? goes deeper.)

2. Did it break anything else? A general-capability check, run identically on base and fine-tune: a small slice of standard benchmarks (EleutherAI’s lm-evaluation-harness runs MMLU, ARC, HellaSwag and friends) and a fixed set of 30-50 everyday prompts you read side by side. You’re looking for drops, not gains.

And check it on the model you’ll actually serve: the merged one, through your server, with the same chat template and decoding settings.

Build It

Two halves. On your Mac: build LoRA yourself, small enough to understand every number. On the Spark: use the real library on a real model for a task from your work, then merge, serve and measure.

lora/
  lora_layer.py        # your LoRALinear + inject() + merge()
  toy_train.py         # inject into SmolLM2-135M, train a toy extraction task
  data/build.py        # your task data -> train/val/test JSONL (split by query)
  data/*.jsonl
  peft_train.py        # PEFT LoRA / QLoRA / full FT, one script, flags
  eval.py              # task eval + general check, any model dir or adapter
  merge.py
  results/*.json
Mac setup: PyTorch, Transformers and a tiny modelAgent: fine
cd ~/workbench && mkdir -p lora && cd lora
python3 -m venv .venv && source .venv/bin/activate
pip install torch transformers
python - <<'EOF'
from transformers import AutoModelForCausalLM, AutoTokenizer
mid = "HuggingFaceTB/SmolLM2-135M-Instruct"
tok = AutoTokenizer.from_pretrained(mid)
m = AutoModelForCausalLM.from_pretrained(mid)
print(sum(p.numel() for p in m.parameters()))   # ~134.5M
print(m.model.layers[0])                         # find the nn.Linear names
EOF

SmolLM2-135M-Instruct is a small Llama-architecture model: 30 layers, hidden size 576, MLP size 1536, 9 query heads and 3 key/value heads (so k_proj and v_proj output 192, not 576). Its projections are plain nn.Linear, which is what your wrapper expects. (GPT-2 in Hugging Face uses a Conv1D class instead, which is why we’re not using it here.)

Write LoRALinear from scratchHands only

Wrap an existing nn.Linear. The shape:

# lora_layer.py
import math
import torch
import torch.nn as nn

class LoRALinear(nn.Module):
    def __init__(self, base: nn.Linear, r: int = 8, alpha: float = 16, dropout: float = 0.0):
        super().__init__()
        # keep `base`, freeze its weight (and bias, if any)
        # A: (r, in_features), random init: nn.init.kaiming_uniform_(A, a=math.sqrt(5))
        # B: (out_features, r), zeros
        # self.scale = alpha / r
        ...

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # base(x) + scale * (dropout(x) @ A^T @ B^T)
        # order matters for cost: (x @ A^T) first makes an r-wide intermediate.
        ...

    @torch.no_grad()
    def merged(self) -> nn.Linear:
        """Return a plain nn.Linear whose weight is W + scale * B @ A."""
        ...

def inject(model: nn.Module, targets: tuple[str, ...], r: int, alpha: float) -> int:
    """Replace every nn.Linear whose attribute name is in `targets` with LoRALinear.
    Freeze every other parameter in the model. Return the expected count of trainable
    params from the formula r * (in_features + out_features), summed."""
    ...

def merge_all(model: nn.Module) -> None:
    """Replace every LoRALinear in the model with its merged nn.Linear."""
    ...

Hints:

  • Walk model.named_modules() and, for each module, its named_children(); replace with setattr(parent, child_name, LoRALinear(child, ...)). Collect the list first, then replace, so you don’t mutate while iterating.
  • Freeze everything first (for p in model.parameters(): p.requires_grad_(False)), then inject. New nn.Parameters default to requires_grad=True.
  • Think about which order of multiplications is cheap. x @ (B @ A)ᵀ builds a full d × d matrix every forward pass. (x @ Aᵀ) @ Bᵀ never does.
Prove it: grads, counts, identity at init, exact mergeHands only

Inject into SmolLM2 with targets = ("q_proj", "v_proj"), r = 8, then again with all seven projections. Before training anything, write four checks as asserts:

  1. Count. Trainable parameters (sum of numel() over params with requires_grad) equals the formula from inject. Work it out by hand first from the config: r × (in + out) per matrix, 30 layers.
  2. Only A and B get gradients. Run one forward and backward on a short input. Every parameter whose name ends in .A or .B has a .grad; every other parameter has grad is None. Also print A.grad.abs().sum() and B.grad.abs().sum() for one layer, and explain why one of them is exactly zero.
  3. Identity at init. logitsThe raw scores the model outputs for every token in the vocabulary, before softmax turns them into probabilities. from the injected model equal logits from an untouched copy of the base (torch.allclose, tight tolerance), because B = 0.
  4. Merge is exact. After a few training steps (next task), merge_all and compare logits before and after merging. They should match to within float rounding (around 1e-5 in fp32).
Train the toy: sentence to JSONHands only

A task you can grade exactly, where the base model knows the content but not your exact output format. Generate synthetic examples:

# toy_train.py (the data part; take it as is)
import json, random
names = ["Ana", "Bo", "Chen", "Dara", "Eli", "Femi", "Gus", "Hana", "Ivo", "Jun", "Kai", "Lena"]
cities = ["Lisbon", "Osaka", "Lima", "Oslo", "Accra", "Pune", "Quito", "Riga", "Seoul", "Tunis"]
templates = ["{n} is {a} and lives in {c}.", "Meet {n}, a {a}-year-old from {c}.",
             "{c} resident {n} turned {a} this year.", "At {a}, {n} moved to {c}."]

def example(rng: random.Random) -> tuple[str, str]:
    n, c, a = rng.choice(names), rng.choice(cities), rng.randint(18, 90)
    prompt = "Extract name, age, city as JSON: " + rng.choice(templates).format(n=n, a=a, c=c)
    answer = json.dumps({"name": n, "age": a, "city": c})
    return prompt, answer

Now the parts that teach, yours to write:

def encode(tok, prompt: str, answer: str) -> tuple[list[int], list[int]]:
    """input_ids for the full chat (user prompt + assistant answer + end token),
    and labels with every prompt position set to -100.
    Build the prompt with tok.apply_chat_template(..., add_generation_prompt=True, tokenize=False)
    and assert the full sequence starts with the prompt's token ids."""
    ...

def collate(batch, pad_id: int):
    """Right-pad input_ids with pad_id, labels with -100, and build attention_mask."""
    ...

def exact_match(model, tok, examples, device) -> float:
    """Greedy-generate up to 40 new tokens for each prompt; fraction whose
    stripped output equals the answer string exactly."""
    ...

Train with plain PyTorch: AdamW over only the trainable params, lr = 2e-4, batch 16, 250 steps, all seven projections at r = 8, on MPS if available (torch.device("mps")). Report exact match on 50 held-out examples before and after, and the time per step. Then try targets=("q_proj","v_proj") and compare.

Spark setup: PEFT, and check bitsandbytesAgent: fine

Use the same NGC PyTorch container approach as in Baseline: my Spark vs Fireworks so you keep NVIDIA’s torch build. Inside it:

pip install transformers peft accelerate datasets
pip install bitsandbytes   # for QLoRA; see below
python -c "import peft, transformers; print(peft.__version__, transformers.__version__)"
python -c "import bitsandbytes as bnb, torch; print(bnb.__version__); \
  l = bnb.nn.Linear4bit(64, 64, quant_type='nf4', compute_dtype=torch.bfloat16).cuda(); \
  print(l(torch.randn(2, 64, device='cuda', dtype=torch.bfloat16)).shape)"

The last line is the real test: bitsandbytes needs a build that supports both ARM64 and the GB10 GPU. If it fails, check NVIDIA’s DGX Spark playbooks and the bitsandbytes install docs for a supported wheel or build-from-source steps. Don’t let pip replace the container’s torch. If QLoRA won’t run, do everything else with bf16 LoRA; on 128 GB you can.

Build a real task and its evalHands only

Pick one task from your work. The suggested one: query-document relevance with a reason, in a strict JSON format, for labelling or checking ranker output. Input: a query and a passage (truncate long pages to ~400 tokens). Output: {"label": 0-3, "reason": "<one sentence>"}. Source: your own labelled pairs from the cross-encoder project, or a public set (MS MARCO passages with qrels, or a TREC Deep Learning track set with graded labels). The other good choice is structured extraction from scraped pages into a fixed schema.

Write data/build.py to produce train.jsonl (2,000-10,000 rows), val.jsonl (~300) and test.jsonl (~500), each row {"messages": [...]} in chat format. Split by query. Balance or at least report the label distribution. If your data has labels but no reasons, train only {"label": n}: don’t invent reasons with a bigger model unless you’re doing that on purpose (that’s distillation, a different lesson).

Write eval.py that takes a model directory (or base + adapter) and reports, on test.jsonl: parse rate, label accuracy, macro-F1, and a confusion matrix; plus a general check (20-50 fixed everyday prompts with outputs saved to a file you read side by side, and optionally a small lm-evaluation-harness run such as ARC-Easy and a slice of MMLU, identical for base and fine-tune). Run it on the base instruct model with your best prompt first. That’s the number to beat.

Fine-tune with PEFT: LoRA, QLoRA, and full FT on a small oneAgent: review after

One script, three modes. The PEFT shape (check the PEFT and Transformers docs; argument names drift):

import torch
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from transformers import AutoModelForCausalLM, BitsAndBytesConfig

lora_cfg = LoraConfig(r=16, lora_alpha=32, lora_dropout=0.05,
                      target_modules="all-linear", task_type="CAUSAL_LM")

if mode == "qlora":
    bnb = BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4",
                             bnb_4bit_use_double_quant=True,
                             bnb_4bit_compute_dtype=torch.bfloat16)
    model = AutoModelForCausalLM.from_pretrained(MODEL_ID, quantization_config=bnb, device_map="cuda")
    model = prepare_model_for_kbit_training(model)   # also enables gradient checkpointing
else:
    model = AutoModelForCausalLM.from_pretrained(MODEL_ID, dtype=torch.bfloat16).to("cuda")

if mode in ("lora", "qlora"):
    model = get_peft_model(model, lora_cfg)
    model.print_trainable_parameters()   # check against your formula

Train with your own loop from t3 (reusing your encode and collate) or with Hugging Face’s Trainer; either way, evaluate on val.jsonl every ~50 steps and keep the best checkpoint. Use a paged optimizer for QLoRA if you like (optim="paged_adamw_8bit" in TrainingArguments, or bnb.optim.PagedAdamW).

Runs:

  1. LoRA, bf16, Llama 3.1 8B Instruct (or another 7-8B instruct model), r = 16, all-linear, lr = 2e-4, 1-2 epochs.
  2. QLoRA, same model, same settings.
  3. On a ~1B model (Llama 3.2 1B Instruct or Qwen2.5 1.5B Instruct): LoRA and full fine-tuning, same data. Full FT with lr ≈ 1e-5 and plain AdamW. This is the comparison the 8B can’t fit.

Before each run, write down your predicted peak memory and step time (see Measure). During each, record torch.cuda.max_memory_allocated() and the median step time over 20 steps after warmup. Have an agent review the script for unfairness between runs (different sequence lengths or batch sizes, timing the first steps, eval included in step time).

Merge and serve it with your serverHands only

Write merge.py: load the base in bf16 (even for the QLoRA adapter), load the adapter with PeftModel.from_pretrained(base, adapter_dir), call merge_and_unload(), and save_pretrained the result along with the tokenizer. Check the merged directory’s size against your prediction.

Then serve it with your own server_naive.py from the baseline lesson: it reads MODEL_ID from the environment, so point it at the merged directory. Run eval.py through the server (the OpenAI-shaped endpoint) so the chat template and decoding are exactly what production would use, and run your bench.py to confirm TTFT and tokens/s match the unmerged base model. They should: same shapes, same math.

Stretch: serve the unmerged adapter with vLLM’s LoRA support next to the base, send requests for both, and compare decode speed against the merged model.

Done when
  • Your LoRALinear passes the four checks in t2, and you can explain why A.grad is exactly zero at step 0.
  • The toy extraction task goes from near-zero exact match to near-perfect on held-out examples, on your Mac.
  • On the Spark you have task and general-check numbers for: base + prompt, LoRA 8B, QLoRA 8B, LoRA 1B and full FT 1B.
  • A merged model is served by your server and passes the same eval through the HTTP endpoint.
  • The Measure table has a prediction next to every measured number, and you can explain each gap.

Measure

Fill in. Predictions first: memory from bytes per parameter (16 for trained, 2 for frozen bf16, ~0.5 for frozen 4-bit, plus activations, which you estimate or measure with a tiny batch), step time from about 6 FLOPs per parameter per token for full FT and about 4 for LoRA, divided by the bf16 matmul throughput you actually measure on the Spark (time one big torch.matmul in bf16 first).

RunTrainable params (formula / printed)Peak memory (pred / measured)Step time (pred / measured)Adapter or checkpoint sizeTask metric (test)General check
Base 8B + prompt0
LoRA 8B bf16, r=1641.9M /
QLoRA 8B, r=1641.9M /
LoRA 1B, r=16/
Full FT 1B/
Merged LoRA 8B via serverTTFT, tok/s vs base:

For reference, the toy on my M-series MacBook (SmolLM2-135M-Instruct in fp32 on MPS, all seven projections at r = 8, batch 16, 250 steps, lr = 2e-4, α = 2r). Machine was busy with other work, so treat the times as rough:

Toy runTrainableExact match before → after (held out)Train loss, step 0 → 200Time per step
All 7 projections, r = 82,442,240 (1.78%)0/20 → 50/500.24 → 0.0001~1.0 s
q_proj + v_proj, r = 8460,800 (0.34%)0/20 → 50/500.24 → 0.0009~0.75 s

Both trainable counts matched the formula exactly. The base model’s zero is instructive: in the outputs I read, it got the name, age and city right but pretty-printed the JSON across several lines, so exact match failed. Its starting loss of 0.24 says the same thing, it already “knew” the answer. What LoRA taught it was the format. That’s the “few directions” case, which is why even 0.34% of the parameters was enough here. Notice also that the q,v run was not 5× faster for 5× fewer trainable parameters: the frozen layers’ forward and backward passes dominate the step.

What the numbers should tell you:

Trainable parameters match your formula exactly. If PEFT’s printout disagrees with your hand count, you targeted something you didn’t mean to (or missed something). This is the cheapest bug check in the whole lesson.

Memory: LoRA’s peak is mostly the frozen base plus activations. For the 8B in bf16, expect ~16 GB of weights plus activations that depend heavily on batch size × sequence length and whether gradient checkpointingSaving only some activations during the forward pass and recomputing the rest during backward. Trades roughly one extra forward pass of compute for much less activation memory. is on. The adapter’s own weights, gradients and Adam states are under 1 GB. QLoRA should cut the weights part to roughly a third. Full FT on the 1B lands near 20 GB plus activations. If your measured number is far above prediction, the gap is almost always activations: try halving the sequence length and watch it move.

Step time: LoRA is not 200× faster. Expect LoRA to take roughly two thirds of a full fine-tuning step at the same model size, and QLoRA to be slower than bf16 LoRA because of dequantizing every weight twice per step. If QLoRA is much slower, check that the compute dtype is bf16 and not fp32.

Task metric: LoRA ≈ full FT, both ≫ base on parse rate. For a narrow format-and-label task, expect LoRA and full fine-tuning on the 1B to land close to each other, and the fine-tunes to fix the format failures almost completely. Label accuracy improves less dramatically than parse rate: the format is “few directions”, judgement is harder.

General check: full FT forgets more. If anything drops, it should drop more for full FT than LoRA, and more with more epochs. Look at your 30 everyday prompts: does the fine-tune answer them as relevance judgements?

Merged vs base through the server: identical speed. If TTFT or tokens/s differs measurably, something isn’t merged (you’re still running the PEFT wrapper) or you saved in a different dtype.

Break It

1. Initialise both A and B to zero. In your LoRALinear, zero A too, and train the toy. The loss never moves, not even a little. Print the gradients: both are exactly zero at every step, because each one’s gradient is multiplied by the other. Then do the opposite: initialise B randomly too. Training runs, but check 3 from t2 fails: at step 0 the model is no longer the base model, it’s the base model plus a random perturbation in every layer, and early training spends its effort undoing damage. Zero B is the cheap way to start exactly where you want.

2. Use the wrong learning rate in both directions. Train the toy LoRA at lr = 2e-5 (a typical full fine-tuning rate): after 250 steps it has barely learned the format. Then full fine-tune SmolLM2 at lr = 2e-4 (a typical LoRA rate): loss spikes or goes to nan, or it learns the task and forgets how to say anything else. The same number means different things depending on how many parameters it moves and where they start.

3. Forget the loss mask. Set labels = input_ids for the whole sequence in the toy (or on your real task). The loss you print is now mostly about predicting your prompt template, which falls fast and looks great. The task metric improves much more slowly per step. Then the inverse bug: mask the end-of-turn token too. The model gets the JSON right and keeps going, generating a second turn or rambling until max_new_tokens.

4. Train in one format, serve in another. Train the toy on raw text ("Extract ...: <sentence>\n<answer>", no chat template), then evaluate through apply_chat_template (which is what your server does). Exact match drops sharply. Nothing is wrong with the weights; the input at serving time just isn’t the input the adapter learned to respond to.

5. Merge into the 4-bit weights. Take your QLoRA adapter, merge it into the 4-bit model and re-quantize (or simulate: merge in bf16, then round-trip the merged weights through NF4), and run the task eval. Compare with merging into a bf16 base. The update is small next to the 4-bit grid’s step size, so a lot of it rounds away, and the metric falls back toward the base model.

Explain It Back

Answer out loud, in plain words, as if in an interview. Then check.

1. What is LoRA, in one minute, with the parameter count?

Fine-tuning changes each weight matrix W to W + ΔW. LoRA freezes W and restricts ΔW to be a product B · A, where B is d × r and A is r × d, with r much smaller than d (like 16 vs 4096). The update can only change the output in r directions, and it takes 2dr numbers instead of d²: for d = 4096, r = 16, 131k instead of 16.8M, under 1%. Only A and B get gradients and optimizer state, so training memory is the frozen model plus a little. B starts at zero so training starts exactly at the base model, the update is scaled by α/r, and at the end you can add B · A into W and serve an ordinary model at full speed.

2. Why can’t you full fine-tune an 8B model on a 128 GB machine, and how do LoRA and QLoRA get around it?

Full fine-tuning with Adam in mixed precision needs about 16 bytes per trained parameter: bf16 weight and gradient (2 + 2), an fp32 master weight (4), and two fp32 Adam moments (4 + 4). 8B × 16 = 128 GB before activations, which is the whole machine. LoRA trains ~0.5% of the parameters, so the 16-byte bill applies only to ~42M of them (under 1 GB), and the frozen 8B costs only its 2-byte bf16 weights (16 GB). QLoRA stores those frozen weights in 4-bit NF4 with double-quantized block scales, about half a byte each, so the base drops to ~5-6 GB, at the cost of slower steps because weights are dequantized on the fly.

3. How does this scale: what happens to LoRA’s savings, and its quality, as models get bigger and tasks get broader?

The parameter savings grow with model width: 2dr / d² = 2r/d, so at fixed r the adapter is a smaller fraction of each bigger matrix. Bigger pretrained models also have smaller intrinsic dimension (Aghajanyan et al.), so a fixed small rank goes further on them. Memory still scales with the frozen base (2 or ~0.5 bytes per parameter) plus activations, so the base, not the adapter, is what decides whether it fits; for a 70B, QLoRA is what makes one machine enough. Compute per step does not shrink much: still ~4 FLOPs per parameter per token versus 6. On quality, savings scale the other way with task breadth: for a narrow format or task, small ranks match full fine-tuning; as the task carries more new information (continued pretraining on a domain, big datasets), full fine-tuning’s update becomes high-rank and LoRA falls behind (“LoRA Learns Less and Forgets Less”). In serving, unmerged adapters scale to thousands per base model (S-LoRA), because each is tens of MB.

4. Your relevance fine-tune scores 94% on your test set. What else do you check before you trust it?

That the test set is really held out: split by query, no near-duplicate pages across splits, and I didn’t tune hyperparameters on it. That 94% beats the base model with my best prompt by enough to matter, and that the gain isn’t just parse rate. Per-class numbers and the confusion matrix, since accuracy hides a model that always predicts the majority label. A general-capability check against the base model, to see what it forgot. And all of that on the merged model served through my real server with the production chat template and decoding settings, because merging (especially from QLoRA) and template mismatches can each change the numbers.

5. Why does B start at zero, and what’s the α/r scale for?

With B = 0, B · A = 0, so the fine-tuned model starts as exactly the base model and only moves as far as the data pushes it. A must be random, because each matrix’s gradient is multiplied by the other: if both were zero, both gradients would be zero forever. The α/r scale keeps the size of the update roughly constant when you change r, so you can change the rank without retuning the learning rate from scratch; in practice you fix α as a multiple of r and tune the learning rate.

Go Deeper

Connections

This builds on How much memory does a model need? (bytes per parameter for frozen weights) and Why training needs so much memory (the 16-byte bill LoRA dodges), and borrows NF4’s idea from Quantization: put the levels where the weights are. The multi-adapter serving picture is the paging idea from What’s inside a serving engine? applied to adapters instead of KV blocks, and it’s why prefix caching has to include the adapter ID in its cache key.

Next, the evaluation half deserves its own lessons: Why are evals separate from training? and Statistics for evals (is 94% vs 92% on 500 examples even a real difference?). And LoRA is the default way to run preference and RL fine-tuning on one machine, so it comes back in DPO and GRPO, where “forgets less” turns out to be a feature.