genaipros← The path
Line A · Model02 · Fine-Tuning (SFT)

Fine-Tuning LLMs — stage workbook

Decide · Data · Train · Evaluate · Compress · Serve · Monitor  |  ends at SFT

Stage 00 · the map

Fine-tuning is a choice, not a default

Six sessions that take you from “should I even tune this?” to a preemption-safe QLoRA job on Kubernetes with an eval gate in front of release. Three ideas hold the whole stage together.

1 · RAG adds knowledge. Tuning adds behaviour.

You already know the ladder prompt → RAG → fine-tune. Sharpen it into a test you can apply in one sentence: if the facts changed, retrieve; if the behaviour must change, tune. Format compliance, house voice, a skill, a domain register — those are behaviour. Yesterday’s filing — that’s knowledge.

2 · The memory wall is the villain.

Training memory is four terms — weights, gradients, optimizer states, activations. With Adam that’s roughly 16 bytes per parameter before you’ve loaded a single token. Every PEFT trick in this stage is an attack on one of those four terms. The arithmetic is why LoRA and QLoRA exist.

3 · Two quantizations, never confuse them.

Quantizing to train cheaply (NF4 / QLoRA — frozen base, trainable adapters, dequantize on every forward pass) is a different job from quantizing to serve cheaply (GPTQ / AWQ / FP8 — the thing you already know from the inference stage). Same word, opposite goals, different artifacts.

the loop The fine-tuning lifecycle

Everything in this stage plugs into one loop. Click a stage to see what it means and jump to the session that owns it.

Fig 0.1 — the lifecycle loop · click any stage

DECIDE tune or don’t S1 DATA format & curate S3 TRAIN SFT + adapters S2 · S3 EVALUATE curves + gate S3 · S6 COMPRESS serve-side quant S5 SERVE merge or multi-LoRA S5 MONITOR drift S6 drift, new behaviour requirements, a better base model → decide again HARD BOUNDARY — this stage stops at SFT. Preference tuning (DPO / RLHF) is the next stage.
Hover or click a stage.
The loop is the unit of work, not the training run. A tuning run that ships without the evaluate and monitor arcs is an experiment, not a deployment.

the math The memory wall, in one number

Before any session, feel this. A parameter being trained costs far more than a parameter being served. Serving a 4B model in bf16 costs 8 GB. Training the same model costs about eight times that — and none of that extra is the model.

Fig 0.2 — bytes per parameter, full fine-tuning with Adam · click a band

FULL FINE-TUNE · FP32 · ADAM weights 4 bytes/param gradients 4 bytes/param optimizer states (Adam m + v) 8 bytes/param act. ? ≈ 16 bytes per parameter, before a single token is loaded a 4B model → ~64 GB · an NVIDIA L4 has 24 GB · the gap is not a tuning problem, it is an arithmetic problem 24 GB L4 ceiling everything to the right of this box is why you are reading this stage
Click a band to see which technique attacks it.
weights gradients optimizer states activations hardware ceiling
These four colours mean the same four things in every diagram in this workbook. When you see brass, you are looking at optimizer state.

outcomes What you’ll be able to do

Defend the call

Argue tune-vs-don’t-tune using the RAG-versus-behaviour test, with examples from your own fintech corpus, and name the cheaper alternative you rejected.

Do the memory math cold

Given any model and any GPU, work out term by term whether full FT, LoRA or QLoRA fits — and prove that full FT of a 4B model does not fit on a 24 GB L4.

Build a template-correct SFT set

Turn earnings-report pipeline output into chat-templated, label-masked, packed training data — and catch the template bug before it costs you a run.

Read loss curves like an operator

Tell overfitting from underfitting from a broken label mask, and pick the checkpoint you actually ship rather than the last one written.

Know when not to be generative

Recognise the tasks where a 110M tuned classifier beats a fine-tuned 4B generator on accuracy, latency and cost simultaneously.

Compress and serve correctly

Pick the right quantization for the right purpose, then decide merge-versus-multi-LoRA on evidence rather than fashion.

Run it as a real job

Schedule the run on Kubernetes with queueing, gang scheduling and spot GPUs, and survive preemption without losing the run.

Know where this stage stops

SFT makes the model follow instructions. Making it prefer good answers is preference tuning — the next stage, deliberately out of scope here.

navigate Session map

How to use this workbook

Each session runs the same five beats: why it exists → concepts from zero, three passes → a reality check with real numbers and current sources → the lab against your stack → a two-line bridge. Concepts come before code everywhere; full configs live only in the lab sections.

Where a source source’s claim has aged — a renamed API, a changed default, a superseded recommendation — you’ll see a source has aged panel with the current replacement and a link.

Session 1 · decide

To tune or not — and the memory wall

Two skills, both defensive. First: recognising the small number of problems that actually require fine-tuning. Second: doing the arithmetic that tells you, before you rent a GPU, whether the run you have in mind can physically happen.

01 · why The problem this session solves

Your fintech pipeline takes earnings reports and SEC filings in, and is supposed to emit structured outputs: sentiment, risk scores, trading signals, compliance flags. Two failure modes look identical from the outside and have completely different fixes.

In the first, the model says Q3 revenue was $4.1bn when the filing says $4.6bn. In the second, the model gets every number right and returns them wrapped in a chatty paragraph — “Certainly! Here’s my analysis of the risk factors…” — instead of the strict JSON your gateway is trying to parse. Both are “the model is wrong.” Only one of them is a fine-tuning problem.

Fig 1.1 — two failures, two fixes · click either path

filing arrives 10-Q, 80 pages “Q3 revenue was $4.1bn” …the filing says $4.6bn KNOWLEDGE gap RETRIEVE put the passage in the context re-index in minutes “Certainly! Here’s my analysis…” …your parser wanted strict JSON BEHAVIOUR gap FINE-TUNE put the habit in the weights retrain in hours
Click either path.
The same complaint — “the output is wrong” — routes to opposite interventions. Getting this triage right is most of Session 1.

02 · concepts Core concepts, from zero

Sharpening the ladder: knowledge versus behaviour

Pass 1 · intuition

Picture hiring a junior equity analyst. On day one there are two different things they can be missing.

They might not know this company’s numbers. The fix is a filing cabinet: you don’t send them back to school, you give them the documents and teach them to look things up. That is RAG — retrieval-augmented generation — and its defining property is that when the numbers change you swap the documents, not the analyst.

Or they might know everything and still write like a chatbot: burying the recommendation in three paragraphs of hedging when your desk wants a two-line note in a fixed format. No filing cabinet fixes that. You need them to internalise the house style until producing it is automatic. That is fine-tuning: you are changing the analyst, not their reference material.

Pass 2 · mechanism

Mechanically the two interventions touch different parts of the request path. RAG edits the input at request time — it prepends tokens. Fine-tuning edits the weights ahead of time — the token stream at request time gets shorter, not longer.

That second-order effect matters more than people expect. Every behaviour you push into the weights is a behaviour you stop paying for in the prompt on every single request. A 600-token system prompt full of formatting rules and few-shot examples, retired into the weights, is 600 tokens of prefill you never buy again. Generative AI on Kubernetes makes exactly this argument for model customization: a bank embeds its slow-changing knowledge about loans, trading and credit risk into the model so it does not have to ship that context with every request, cutting both context length and inference cost.

Pass 3 · trade-offs and where it breaks
They are not alternatives

The question is never “RAG or fine-tune.” It is which combination. A tuned model that emits perfect JSON still needs retrieval to know what happened last Tuesday. Your stage-two RAG pipeline and this stage’s adapter are meant to run together on the same request.

Tuning ages, retrieval doesn’t

Behaviour baked into weights is frozen at training time. If your compliance flag taxonomy changes quarterly, you have signed up for a quarterly retrain. Knowledge that changes faster than you can retrain must live in retrieval, no matter how tempting it is to bake it in.

Catastrophic forgetting

Catastrophic forgetting is when training on new data degrades capabilities the model already had. Tune hard enough on terse JSON and the model may lose the ability to explain itself in prose. Full fine-tuning is far more exposed to this than adapter methods, because adapters leave the base weights untouched and can simply be switched off.

The honest failure mode

Most fine-tuning projects that disappoint were data problems wearing a modelling costume. If prompting gets you to 80% and you cannot articulate what the remaining 20% looks like as training examples, tuning will not rescue you — it will just be a slower way to discover you never defined the task.

Decision tree 1 · prompt → RAG → fine-tune → distill

Read top to bottom. Follow “no ↓” until a “yes” exits right. The bottom-left box is the default you land on if nothing above fires.

Is the output wrong because the model lacks a fact it could not have known — a specific filing, a current price, a policy published last month?
yes RAG. Index it, retrieve it, cite it. Do not tune; you will be retraining forever.

no ↓

Have you actually tried a careful prompt — explicit schema, two or three worked examples, a system prompt that names the role and the boundaries — and measured it on your golden set?
no Do that first. It costs an afternoon. It frequently ends the project, and when it doesn’t it gives you the baseline number your tuning run has to beat.

yes, and it isn’t enough ↓

Is the gap a behaviour — output format, house voice, a domain register, a repeated skill — that you can demonstrate with several hundred input/output pairs?
yes Fine-tune. This is the case the rest of this stage is about.

no ↓

Is the real problem that a big model gets it right but costs too much or is too slow, while a small model gets it wrong?
yes Distill. Use the big model to generate the training set, tune the small model on it. You are buying the big model’s behaviour at the small model’s price.

no ↓

Is the task actually classification or ranking wearing a generative costume?
yes Train a classifier or an embedding model instead — often two orders of magnitude smaller. Session 4 is entirely about this case.

no ↓

defaultKeep prompting, and go fix your evaluation instead. If you cannot name the behaviour precisely enough to write 300 examples of it, the bottleneck is task definition, not model capability.

The memory wall: four terms

Pass 1 · intuition

Serving a model and training a model feel like the same activity — load weights, push tokens through — but their memory profiles are not remotely comparable. Serving is like reading a source: you need the material. Training is like editing it: you need the material, a marked-up copy showing every proposed change, a set of notes tracking how each sentence has been trending across all previous edits, and a desk covered in the intermediate drafts you’ll need when you work backwards through the section.

The material is the smallest item on that desk.

Pass 2 · mechanism

Walk one training step and watch memory accumulate. the guide’s framing in the applied fine-tuning reference is the clean one: load the model, forward pass, backward pass, optimizer step, zero the gradients, repeat. Memory demand climbs through the first four and only falls at the end.

Weights — 4 bytes per parameter in FP32

The weights are the parameters themselves. This is the only term you also pay at inference time, which is exactly why it is the term everyone underestimates: your intuition for “how big is this model” was built serving it, and serving is the cheap case.

Attack surface: quantize the base. FP32 → NF4 takes 4 bytes to 0.5, an eightfold cut. This is what the “Q” in QLoRA does.

Activations — “it depends”, and it depends a lot

Activations are the intermediate outputs of every layer, kept in memory because the backward pass needs them to compute gradients via the chain rule. Unlike the other three terms this one is not a multiple of the parameter count — it scales with batch size (linearly) and sequence length.

Sequence length is the trap. With the original “eager” attention implementation, memory is quadratic in sequence length: double the context, quadruple the activations. FlashAttention-2 and PyTorch’s SDPA tile the computation and make it linear, which removes the term that grows fastest.

Attack surface: shorter sequences, smaller micro-batches, an efficient attention kernel, and above all gradient checkpointing — discard most activations and recompute them during the backward pass. It trades compute for memory and the guide calls it, correctly, the single most effective lever for shrinking a training loop.

Gradients — 4 bytes per trainable parameter

One gradient per trainable parameter, same dtype as the parameter. Note the word trainable — that single qualifier is what makes adapter methods work.

Subtlety worth internalising: freezing a layer removes the need to store its gradients, but it does not remove that layer from the backward pass. Backpropagation runs end-to-end from the loss; a trainable layer early in the network still needs the chain of derivatives to flow back through every frozen layer above it. Frozen layers are cheap, not free.

Attack surface: freeze almost everything. LoRA’s first kill.

Optimizer states — 8 bytes per trainable parameter

Optimizer states are the running statistics the optimizer keeps between steps. Adam and AdamW track a first moment (a running mean of the gradient) and a second moment (a running variance) for every trainable parameter, both in FP32. That is two extra full-precision copies of everything you are training.

This is the largest of the four fixed terms and the most common OOM culprit, because it does not appear until the first optimizer step — your job survives the forward pass, survives the backward pass, and dies at step one.

Attack surface: two options. Quantize the optimizer (paged_adamw_8bit from bitsandbytes) — genuinely useful for full fine-tuning. Or make almost nothing trainable, in which case the choice of optimizer stops mattering. LoRA’s second kill, and the reason the guide notes that under LoRA the optimizer choice is basically inconsequential.

Peak is what kills you, not average

Stages 4 and 5 need no new memory. The point of walking the loop is that OOM is a peak-memory event: your job dies at the single worst moment in the step, so the number to compute is the sum at stage 3, not a comfortable average.

It also explains the shape of every technique that follows. Each one targets a specific stage: quantization hits stage 0, LoRA hits stages 2 and 3, gradient checkpointing and attention kernels hit stage 1, gradient accumulation lets you keep an effective batch size while only ever materialising a micro-batch.

Pass 3 · the three profiles, side by side

Now the picture that should stay with you. Same 4B model, same GPU, three training strategies.

Fig 1.2 — where the memory goes · 4B parameters · click any band

24 GB — L4 ceiling 70 GB FULL FT fp32 + Adam 16 GB 16 GB 32 GB ≈ 65 GB LoRA bf16 base, r=16 8 GB ≈ 10 GB gradients + optimizer are now hairlines QLoRA NF4 base, r=16 ≈ 5–6 GB pale steel = unquantized embeddings / lm_head / norms moss = activations, now the biggest single term 0 24 48 64 GB Activations assume micro-batch 2 · sequence 2048 · gradient checkpointing on · SDPA attention. Change any of those and the moss band moves.
Click a bar to see the arithmetic behind it.
weights gradients optimizer activations ceiling
Full FT and QLoRA differ by roughly 12× on the same model. Notice what does not shrink between LoRA and QLoRA: activations. Adapters buy you nothing on that term.

The dtype zoo

Pass 1 · intuition

A floating-point format spends its bits on two competing jobs: range (how big and how small a number it can represent at all) and precision (how finely it can distinguish nearby numbers). Fewer bits means giving something up. Which thing you give up turns out to matter enormously.

Pass 2 · mechanism

Every float splits into a sign bit, an exponent field (range) and a mantissa field (precision). FP16 and BF16 are both 16 bits and make opposite bets.

Fig 1.3 — bit budgets and what they buy · click a format

sign · exponent (range) · mantissa (precision) FP32 1 · 8 · 23  →  4 bytes  |  range ±3.4e38  |  the reference BF16 1 · 8 · 7  →  2 bytes  |  range ±3.4e38  |  keeps range, drops precision FP16 1 · 5 · 10  →  2 bytes  |  max 65,504  |  overflow → inf, underflow → 0 INT8 8 bits  →  1 byte  |  256 bins + scale  |  compute forced to FP16 NF4 4 bits  →  0.5 bytes  |  16 quantile-spaced levels  |  the training-side default RMSE of a quantized-then-restored weight tensor (the guide, §2): 2-bit 0.0615  ·  4-bit 0.0152  ·  8-bit 0.0010  ·  16-bit binning 0.0001  ·  but a plain cast to FP16: 0.0000142 — which is why nobody uses 16-bit binning: at that width, just cast to a real 16-bit float.
Click a format.
Blue = exponent bits (range), green = mantissa bits (precision). BF16 and FP16 are the same size and completely different animals.
How quantization actually works: it’s a histogram

the guide’s explanation is the one to keep, because it demystifies the whole subject: quantization is binning. Take the range your weights occupy, chop it into n equal bins, and replace each weight with its bin index. Store the bin width and the first bin’s value alongside, and you can approximately reconstruct any weight as index × width + first.

The number of bits follows directly from the number of bins: 4 bins is 2-bit, 256 bins is 8-bit, 16 bins is 4-bit. The error follows too — fewer bins, coarser approximation.

Two things make this work far better on real models than it has any right to. First, the weights of large linear layers cluster in a very narrow, zero-centred range with few outliers, so evenly-spaced bins are not as wasteful as they sound. Second, NF4 drops the “evenly spaced” assumption entirely and places its 16 levels at normal-distribution quantiles, matching the shape of the data.

And there is a second-order trick. Every block of weights needs its own scale constant, and those constants are themselves FP32 numbers taking up space. Double quantization quantizes the quantization constants — nesting the idea one level deep — for a further saving of roughly 0.4 bits per parameter. It is free accuracy-wise and you should always turn it on.

Loading a quantized model: bitsandbytes

Pass 2 · the mechanism is the artifact

bitsandbytes is the library that implements this inside the Hugging Face stack. You never call it directly; you hand a configuration object to from_pretrained and it swaps every eligible Linear layer for a Linear4bit (or Linear8bitLt) as the weights land on the GPU.

# the four decisions that matter — everything else can stay default
bnb = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",          # quantile-spaced, not plain fp4
    bnb_4bit_use_double_quant=True,     # ~0.4 bits/param back, free
    bnb_4bit_compute_dtype=torch.bfloat16, # dequantize to bf16 to multiply
)

Five lines that decide roughly 6 GB. The last one is the one people get wrong.

That fourth argument is the conceptual key. A quantized layer stores 4-bit indices but cannot compute in 4 bits. On every forward pass it dequantizes its weights to the compute dtype, multiplies, and casts the result back out. So there are three dtypes in play at once: storage (4-bit), compute (bnb_4bit_compute_dtype), and the dtype of everything that was never quantized (torch_dtype). Mismatch the last two and you get a warning about slow training — and it means it.

Three things bitsandbytes will not quantize

Layers with tied weights, the model’s final layer, and anything named lm_head. This is deliberate — quantizing the head costs quality — but it has a memory consequence you must budget for. Gemma’s vocabulary is very large, so the embedding table is enormous, and it stays in 16-bit. On a 4B-class Gemma that is over a gigabyte of unquantized weight sitting inside your “4-bit” model. It is the pale steel block in Fig 1.2 and it is why a 4-bit 4B model does not weigh 2 GB.

The two quantizations — never confuse them

You already know quantization from the inference stage: FP8 and AWQ on your vLLM endpoint, chosen to fit more KV cache and serve more tokens per second. This stage introduces a different use of the same word, and conflating them is the single most common confusion at this point in the curriculum.

Fig 1.4 — same word, two jobs · click either column

quantize to TRAIN cheaply NF4 · QLoRA · bitsandbytes goalfit the training run on the GPU you have what’s quantizedthe frozen base model only what stays precisethe trainable adapters, in bf16 when it happensat load time, before training starts calibration datanone — it’s a direct numeric mapping speed effectslightly slower (dequantize every pass) the artifactan adapter, a few tens of MB Sessions 1 and 2 quantize to SERVE cheaply GPTQ · AWQ · FP8 · GGUF goalmore tokens/sec, more KV cache, less $ what’s quantizedthe finished model you are shipping what stays precisenothing — it’s all product when it happensafter training, as a build step calibration datayes — GPTQ/AWQ need a sample set speed effectfaster (that is the entire point) the artifacta full quantized checkpoint Session 5
Click a column.
A useful tell: if it needs a calibration set, it is serve-side. If it is deliberately slower and produces an adapter, it is train-side.

Decision tree 2 · full FT vs LoRA vs QLoRA

Assumes you have already exited tree 1 at “fine-tune”. Compute the four terms before you start, not after the OOM.

Do 16 × params bytes plus activations fit in your VRAM, and do you have hundreds of thousands of labelled examples, and do you need to change the model more deeply than an adapter can?
yes to all three Full fine-tune. Rare outside labs. Budget for catastrophic forgetting and for a dedicated GPU to serve the result, since a fully-tuned model cannot be layered onto a shared base.

no ↓

Does the bf16 base plus adapters plus activations fit comfortably — with room for the batch size and sequence length your data actually needs?
yes Plain LoRA. Keeps the base at full quality, trains faster than QLoRA (no dequantize step), and merges cleanly afterwards. Prefer this whenever it fits.

no ↓

Does the NF4 base plus adapters plus activations fit?
yes QLoRA. Accept ~20–30% slower steps and a small quality cost on the frozen base in exchange for the run existing at all. This is your L4 answer.

no ↓

Have you already turned on gradient checkpointing, dropped to micro-batch 1 with accumulation, cut max_length to the shortest that fits your data, and switched to SDPA or FlashAttention-2?
no Do those four first. They are free, they compose, and together they routinely turn an OOM into a working run without touching the model.

yes, all four, still doesn’t fit ↓

defaultUse a smaller base model, or rent a bigger GPU for the run. There is no clever configuration that fits a model which does not fit. Note that renting an A100 for four hours is usually cheaper than two days spent making an L4 work.

03 · reality check The numbers, on your hardware

Worked example: a ~4B Gemma on a 24 GB L4

Term by term, with 4.0B parameters and the activation assumptions from Fig 1.2 (micro-batch 2, sequence 2048, gradient checkpointing on, SDPA attention).

TermFull FT (fp32+Adam)Full FT (bf16 mixed)LoRA r=16QLoRA r=16
Weights16.0 GB8.0 + 16.0 master8.0 GB2.1 GB
Unquantized embed/headincl.incl.incl.~1.1 GB
Gradients16.0 GB8.0 GB0.045 GB0.045 GB
Optimizer (Adam)32.0 GB32.0 GB0.18 GB0.18 GB
Activations~1.5 GB~1.5 GB~1.5 GB~1.5 GB
Peak≈ 65.5 GB≈ 65.5 GB≈ 9.7 GB≈ 4.9 GB
Against a 24 GB L42.7× over2.7× overfitsfits, roomy
The result that matters: mixed precision does not rescue full fine-tuning. You swap FP32 weights for bf16 weights plus an FP32 master copy and land in the same place, because the Adam states were always the problem.
Check your real parameter count before trusting any of this

Gemma’s E4B naming denotes an effective parameter count — the compute footprint per token — and the number of parameters actually stored on disk and loaded into VRAM can be substantially higher. Your memory bill is paid on the stored count, not the effective one. Before you plan a run, load the config and count: sum(p.numel for p in model.parameters). Treat every table on this page as a method, not as your answer.

Worked example: how many parameters does LoRA actually train?

The formula is small enough to do in your head. For one linear layer of shape d_in × d_out, LoRA at rank r adds r × (d_in + d_out) parameters. Sum over every targeted layer.

Using an illustrative 4B-class decoder — hidden 2048, 32 layers, intermediate 8192, grouped-query attention with k/v projections at 512:

ConfigurationTrainable params% of 4BGrad + optimizerAdapter file (bf16)
q_proj, v_proj · r=16 (the old default)3.4 M0.085%0.03 GB7 MB
all-linear · r=1622.5 M0.56%0.23 GB45 MB
all-linear · r=6490.2 M2.3%0.90 GB180 MB
all-linear · r=256 (current SFT advice)360.7 M9.0%3.6 GB721 MB
Rank is not free. Going from r=16 to r=256 on all-linear moves 3.4 GB of gradients and optimizer state back onto your GPU — most of the headroom QLoRA just bought you. Session 2 works out where the real sweet spot is.

Worked example: what does the run actually cost?

Take 20,000 SFT examples averaging ~900 tokens after templating, packed into 2048-token sequences, three epochs. That is ~18M tokens per epoch, ~54M tokens total.

L4, on-demand

≈ 5 hours at roughly 3,000 tok/s for QLoRA on a 4B with checkpointing.
At the $0.45/hr your own benchmarks used: ≈ $2.25.

L4, spot

Spot and preemptible GPU capacity currently runs 40–90% below on-demand depending on provider and instance.
Same run: ≈ $0.80.

A100 40 GB, on-demand

Roughly 4–5× the throughput, so ≈ 1 hour — and it fits plain LoRA, not just QLoRA.
≈ $1.10–2.00.

The uncomfortable conclusion

At this model size the dollar difference between every option above is a couple of dollars. Optimising it is a rounding error against one engineer-hour. What actually costs you is iteration count — ten configurations at five hours each is a week of wall clock on an L4 and an afternoon on an A100. Spend money on wall-clock time during exploration; spend the spot-instance savings later, in Session 6, where the runs are long enough and numerous enough for the discount to mean something.

What teams actually did

Databricks · tuned, and it paid

Fine-tuned a Llama-3.1-8B on internal code written by their own engineers and ran a live A/B test against GPT-4o. Reported roughly 1.4× higher acceptance rate on bug fixes and 2× lower inference latency. The training data was interaction logs the existing assistant was already generating — the differentiator was owning behaviour-shaped data, not the framework.

Source: Databricks, The Power of Fine-Tuning on Your Data, via the 2026 framework comparison at theaiengineer.substack.com

Stripe · the task was the bug

Fine-tuned an LLM to generate code fixes. Latency was high and accuracy low. They reframed the task: instead of generating a fix, have the model pick from a set of known fix patterns. Latency dropped, accuracy rose. The bottleneck was task framing, not the model, the data or the framework.

Source: same 2026 comparison write-up. This example returns in Session 4, where it is the whole argument.

The tune-nothing case

Generative AI on Kubernetes is blunt that RAG plus prompt engineering is “often sufficient,” and that flexible retrieval — refresh the vector DB in minutes — “is taking over significant portions of the model customization space.” Their argument for customizing anyway is cost-shaped: embed the slow-changing knowledge so you stop paying for a giant context window on every request.

Source: applied guides, “When to Use Model Customization”.

Where the field landed on LoRA

The 2026 practitioner consensus is explicitly hybrid and matches the framing at the top of this tab: RAG for facts, fine-tuning for behaviour. Fine-tuning teaches the model how to respond; retrieval supplies what to say.

Source: LoRA/QLoRA practitioner guide, March 2026

bnb_4bit_compute_dtype and the FP32 fallback

the guide hedges on BF16 because he targets readers on older or free-tier GPUs, and recommends falling back to torch.float32 for computation when BF16 is unsupported. On your L4 (Ada Lovelace) BF16 is fully supported, so the hedge does not apply — take torch.bfloat16 and the memory it saves. Keep the runtime check in your code anyway, because it costs one line and makes the script portable: torch.cuda.is_bf16_supported.

04 · lab Apply to my stack

Two jobs. Measure your real parameter count and memory footprint instead of trusting the table above, and get a 4-bit Gemma loaded and inspected on an L4 before you go anywhere near a trainer.

Lab 1.1 — the memory ledger, on the real model
# memory_ledger.py — run this on the L4 before planning any run.
import torch, json
from transformers import AutoModelForCausalLM, AutoConfig, BitsAndBytesConfig

MODEL = "google/gemma-4-E4B-it"   # your served base
GPU_GB = 24

def ledger(n_params, n_trainable, bytes_per_weight, opt_bytes=8, grad_bytes=2, act_gb=1.5):
    w  = n_params    * bytes_per_weight / 1e9
    g  = n_trainable * grad_bytes       / 1e9
    o  = n_trainable * opt_bytes        / 1e9
    return {"weights": w, "gradients": g, "optimizer": o,
            "activations": act_gb, "peak": w + g + o + act_gb}

# --- 1. the true stored parameter count (NOT the "E4B" marketing number) ---
cfg = AutoConfig.from_pretrained(MODEL)
model = AutoModelForCausalLM.from_pretrained(MODEL, torch_dtype=torch.bfloat16,
                                             device_map="cuda:0")
n = sum(p.numel for p in model.parameters)
print(f"stored parameters: {n/1e9:.2f}B")
print(f"bf16 footprint:    {model.get_memory_footprint/1e9:.2f} GB")

# --- 2. the embedding table you will NOT be able to quantize ---
emb = model.get_input_embeddings.weight.numel
print(f"embedding params:  {emb/1e6:.0f}M  ({emb/n:.1%} of the model)")
print(f"  → stays 16-bit:  {emb*2/1e9:.2f} GB inside your '4-bit' model")

# --- 3. the three profiles, with YOUR numbers ---
for name, bpw, trainable in [
        ("full FT  (fp32+Adam)", 4,   n),
        ("LoRA r16 (bf16 base)", 2,   22_500_000),
        ("QLoRA r16 (nf4 base)", 0.6, 22_500_000)]:   # 0.6 ≈ nf4 + double-quant overhead
    L = ledger(n, trainable, bpw, grad_bytes=(4 if bpw == 4 else 2))
    fits = "FITS" if L["peak"] < GPU_GB * 0.92 else f"OVER by {L['peak']-GPU_GB:.1f} GB"
    print(f"{name}: peak {L['peak']:5.1f} GB  [{fits}]  "
          f"w={L['weights']:.1f} g={L['gradients']:.2f} o={L['optimizer']:.2f}")
Lab 1.2 — load Gemma in NF4 and confirm what got quantized
# nf4_load.py — the config from Fig 1.4's left column, made real.
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig

MODEL = "google/gemma-4-E4B-it"
compute_dtype = torch.bfloat16 if torch.cuda.is_bf16_supported else torch.float32

bnb = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_use_double_quant=True,
    bnb_4bit_compute_dtype=compute_dtype,
)

model = AutoModelForCausalLM.from_pretrained(
    MODEL,
    device_map="cuda:0",
    quantization_config=bnb,
    torch_dtype=compute_dtype,   # MUST match compute_dtype or training crawls
    attn_implementation="sdpa",  # linear-in-seq-len activations, not quadratic
)
tok = AutoTokenizer.from_pretrained(MODEL)

print(f"4-bit footprint: {model.get_memory_footprint/1e9:.2f} GB")

# which layers are actually 4-bit, and which escaped?
from collections import Counter
kinds = Counter(type(m).__name__ for m in model.modules
                if "Linear" in type(m).__name__ or "Embedding" in type(m).__name__)
print(kinds)   # expect many Linear4bit + a plain Embedding + a plain Linear (lm_head)

unquantized = [(nm, p.dtype, p.numel)
               for nm, p in model.named_parameters if p.dtype != torch.uint8]
big = sorted(unquantized, key=lambda t: -t[2])[:5]
print("largest unquantized tensors:")
for nm, dt, cnt in big:
    print(f"  {nm:52s} {str(dt):16s} {cnt/1e6:8.1f}M  = {cnt*2/1e9:.2f} GB")

Expect the embedding table at the top of that last list. That is the pale steel block from Fig 1.2, and now you know its exact size on your model.

Optional exercise — argue the case in writing

Pick three concrete asks that could plausibly land on your fintech pipeline and route each one through Decision tree 1, in writing, with the number that decides it:

  1. “The risk scores are stale — it doesn’t know about the March 8-K.”
  2. “Compliance rejected the output; they need every flag as a JSON enum, never prose, 100% of the time.”
  3. “Latency is 2.4s and finance wants it under 500ms at the same quality.”

One is RAG, one is fine-tuning, one is neither. Write the memory-wall arithmetic for whichever ones survive to tree 2. If you can do this cold in a design review, this session has done its job.

Bridge to Session 2

You now know that gradients and optimizer states are the two terms that make full fine-tuning impossible on your L4 — and that both terms are indexed on trainable parameters, not total ones.

Session 2 is the technique that exploits exactly that loophole: freeze everything, train two small matrices per layer, and let the arithmetic collapse.

Session 2 · train

LoRA and QLoRA, all the way down

Session 1 established that gradients and optimizer states are indexed on trainable parameters. This session is the technique built entirely around abusing that fact — and the four knobs that decide whether it works or quietly underperforms.

01 · why The problem this session solves

The memory arithmetic left you in an awkward place. You cannot train all 4 billion parameters. But the parameters you most want to change — the projections inside every attention block — are precisely the big ones you cannot afford to make trainable.

The obvious escape is to freeze most of the network and train only the last few layers, the way people fine-tuned image classifiers for a decade. It works badly here. A language model’s behaviour is distributed through every block; freezing the bottom 90% leaves you adjusting a thin output film over a model that still thinks the way it always did.

LoRA takes a stranger route. Leave every original weight frozen — and add a small, cheap, trainable detour alongside each one. You are not choosing which layers to train. You are training a tiny shadow of all of them.

Fig 2.1 — three ways to make a model trainable on a small GPU

full fine-tune everything moves 65 GB · won’t fit freeze the bottom only the top moves fits · underfits badly LoRA every layer gets a small detour fits · reaches every block grey = frozen, plum = trainable LoRA’s trick is not choosing where to train. It is changing how much each place can move, everywhere at once.
Depth of reach matters more than count of trainable parameters. That single observation is why LoRA beat the layer-freezing approaches it replaced.

02 · concepts Core concepts, from zero

The low-rank hypothesis

Pass 1 · intuition

Think about what fine-tuning actually asks of a weight matrix. You are not rebuilding the model’s understanding of language — that already works. You are nudging it: be terser, always emit JSON, use the register of a sell-side analyst. Those are narrow, systematic adjustments.

Here is the analogy worth keeping. A published source has been fully typeset; every letter is set. To issue a corrected edition you do not re-typeset the material — you print an errata sheet. The corrections are systematic (“wherever we wrote *colour*, read *color*”), so the sheet is a page long even though the material is 600. The reader carries both and applies one to the other.

LoRA is the errata sheet. The base model is the printed source, frozen. The adapter is a tiny set of systematic corrections applied on top. The low-rank hypothesis is the claim that the corrections fine-tuning wants to make really are that systematic — that the update matrix has low rank, meaning most of its rows are just combinations of a few underlying patterns rather than 4,096 independent ones.

Pass 2 · mechanism

Concretely. A projection inside an attention block might be a 2048 × 2048 matrix — about 4.2 million weights. Full fine-tuning learns a 4.2-million-element update to add to it. LoRA instead learns two skinny matrices whose product has the same shape:

Fig 2.2 — the decomposition · click each piece

ΔW 2048 × 2048 4,194,304 params what full FT learns = B 2048 × 16 up × A 16 × 2048 down 65,536 parameters instead of 4,194,304 r × (d_in + d_out) = 16 × (2048 + 2048) 64× fewer trainable weights · on this layer alone the product is still 2048×2048 — but its rank is 16 Why not train the model this way from scratch? Because low rank is a constraint. It is fine for nudging a model that already works; it would cripple one still learning language.
Click a matrix.
the guide’s framing is worth repeating: you are not decomposing the existing layer — decomposition loses information. You start with two small matrices and let training fill them in.

The forward pass never builds that big product. Matrix multiplication distributes, so instead of computing x @ (W + BA)ᵀ you compute x @ Wᵀ and x @ Aᵀ @ Bᵀ separately and add the results. Two cheap passes through skinny matrices, one addition. That is also why the adapter can be switched off at inference — just skip the second path.

Fig 2.3 — where the adapters actually sit inside a decoder block · click a module

ONE DECODER BLOCK ×32 SELF-ATTENTION q_proj + adapter v_proj + adapter k_proj often skipped o_proj often skipped → attention scores → weighted sum of values → RMSNorm (never quantized, never adapted) MLP — where most of the parameters actually live gate_proj 2048 → 8192 up_proj 2048 → 8192 down_proj 8192 → 2048 These three hold roughly two-thirds of a modern decoder’s parameters. Leaving them un-adapted is leaving most of the model on the table. embed_tokens · lm_head outside every block · not quantized adapt only via modules_to_save inside one adapted layer frozen W A → B x·Wᵀ (α/r)·x·Aᵀ·Bᵀ output
Click any module to see whether it should get an adapter.
Deep plum = adapted under most current recipes. Pale plum = adapted only if you target all-linear. Steel = special cases requiring modules_to_save.

The four knobs

Pass 2 · what each one does

A LoraConfig is short. Every field in it is load-bearing.

# the artifact IS the mechanism — this object is the whole technique
cfg = LoraConfig(
    r=16,                          # rank — the bottleneck width
    lora_alpha=32,                 # scaling numerator; effective gain = alpha/r
    lora_dropout=0.05,             # dropout on the adapter path only
    target_modules="all-linear",   # which layers get a detour
    bias="none",                    # leave biases frozen so the base stays recoverable
    task_type="CAUSAL_LM",
)
r — rank, the capacity dial

How many independent directions the adapter can express. Trainable parameters scale linearly with r, so doubling the rank doubles the adapter, its gradients, its optimizer state and its file size.

Too low and the adapter physically cannot represent the behaviour you are teaching — it will plateau at a loss floor no amount of training fixes. Too high and you pay memory for capacity you never use, and start reintroducing the memory wall you just escaped.

lora_alpha — how loud the adapter is

The adapter’s output is multiplied by alpha / r before being added to the base. It is a volume knob, not a capacity knob. The long-standing convention is alpha = 2r, giving a gain of 2.

The reason it is expressed as a fraction rather than a plain number is subtle and useful: dividing by r means that when you change the rank, the scale of the adapter’s contribution stays roughly constant — so your learning rate does not need retuning every time you sweep rank.

lora_dropout — regularisation on the detour

Standard dropout, applied only on the adapter path. Typical values are small — 0.0 to 0.1, commonly 0.05. Its job is to stop a small adapter memorising a small dataset. If your dataset is genuinely large, you can often take it to zero.

target_modules — the one that matters most

Which layers get adapters. PEFT ships built-in defaults per architecture, which is why you can often omit it — and also why you can silently get a much narrower configuration than you intended. Pass "all-linear" to adapt every linear layer except the output head.

If PEFT does not recognise your architecture you get ValueError: Please specify target_modules. That error is a gift; the silent default is the dangerous case.

Both sources recommend a configuration the evidence has moved past

the guide demonstrates r=8, alpha=16, and PEFT’s built-in per-architecture target lists — which for many models means only q_proj and v_proj. Standard treatments describe LoRA in terms of the attention matrices. That was the mainstream 2023–24 recipe.

Current guidance, now shipped as an official page in Hugging Face’s own TRL documentation, is materially different on three points: target all linear layers, not just attention; use a much higher rank for SFT than the old defaults; and keep the effective batch size below 32. It also reports that a correctly-configured LoRA can match full fine-tuning while using around 67% of the compute — reversing the older assumption that PEFT always costs you quality.

Source: “LoRA Without Regret”, TRL documentation, reproducing Schulman et al. / Thinking Machines Lab (2025). Read the numbers in Fig 2.4 before you adopt r=256 wholesale.

Pass 3 · rank, honestly

The rank recommendation deserves scrutiny rather than obedience, because the memory arithmetic from Session 1 does not stop applying just because a good paper said 256.

Fig 2.4 — what rank costs you on a 24 GB L4 · 4B model, all-linear · click a bar

rank trainable params · gradient + optimizer cost · adapter file r = 8 11.3M · 0.11 GB · 23 MB adapter r = 16 22.5M · 0.23 GB · 45 MB adapter r = 64 90.2M · 0.90 GB · 180 MB adapter r = 256 360.7M · 3.6 GB · 721 MB adapter the published rank table is indexed on DATASET SIZE, not on how much you want it to work TRL’s guidance: SFT at “post-training scale” → rank 256 · reinforcement learning at any scale → rank 1–32. The stated reason RL needs so little: policy-gradient methods extract on the order of one bit of information per episode, so they demand almost no parameter capacity. Your fintech SFT set is not post-training scale. Match the rank to the data you have, then sweep upward only if the loss floor tells you to.
Click a rank.
Rank is the one knob where the memory arithmetic from Session 1 comes straight back. Sweep it deliberately; do not inherit it.
rsLoRA, DoRA, and the initialisation family — the variants worth knowing
rsLoRA — rank-stabilized scaling

Standard LoRA scales the adapter by alpha / r. At high rank that fraction becomes very small, and the adapter’s contribution gets damped just when you have paid for the most capacity. rsLoRA changes the divisor to √r instead, which keeps the effective scale stable as rank grows. Enable with use_rslora=True. Practitioner guidance in 2026 is consistent: if you are going above about r=64, turn it on; without it, high ranks can show gradient instability rather than the improvement you paid for.

DoRA — weight-decomposed adaptation

DoRA splits a weight update into two independent parts: direction (handled by ordinary LoRA) and magnitude (handled by a separate small learnable vector). The intuition is that LoRA conflates “point this weight somewhere else” with “make this weight bigger,” and separating them helps — reportedly most at low ranks, which is where memory-constrained people live. Enable with use_dora=True.

Two caveats to carry. First, DoRA adds runtime overhead versus plain LoRA, so PEFT recommends merging for inference rather than serving it as a live adapter. Second — and this is the sharp edge — merge correctness is version-dependent: on older PEFT releases a DoRA adapter will merge without error but apply the magnitude component incorrectly, silently degrading the model. Pin your PEFT version and verify the merged model against the unmerged one on your golden set. Also note that PEFT’s support matrix for DoRA on quantized layers has moved over time; check the version you actually installed rather than trusting a tutorial.

Initialisation: PiSSA and LoftQ

By default B starts at zero and A is random. Two alternatives initialise the adapter from the base model’s own structure instead. PiSSA initialises A and B from the principal singular components of the frozen weight, so training begins in the subspace that already matters. LoftQ initialises the adapter to compensate for the error introduced by quantizing the base — aimed squarely at QLoRA, where the frozen base is lossy. The practical advice: if QLoRA quality is visibly below LoRA quality on your task, try LoftQ or PiSSA initialisation before reaching for a bigger rank.

QLoRA: assembling the parts

Pass 1 · intuition

QLoRA is not a new algorithm. It is LoRA plus a specific set of memory economies, chosen so that the pieces do not fight each other. The insight: since the base is frozen anyway, its precision matters far less than you would expect — nobody is computing gradients for it. So compress the thing you are not training, and keep full precision only for the thing you are.

Pass 2 · mechanism

Fig 2.5 — QLoRA, part by part · walk the assembly

NF4 — the frozen base at 4 bits

Every eligible linear layer becomes a Linear4bit holding quantile-spaced 4-bit indices. On each forward pass those weights are dequantized to the compute dtype, multiplied, and the result cast back. You pay a little time on every pass to save a lot of space permanently.

Attacks: ■ weights, 8× reduction.

Double quantization — quantizing the quantizers

Each block of weights carries its own FP32 scale constant, and at a block size of 64 those constants are not negligible. Double quantization compresses them too, recovering roughly 0.4 bits per parameter. On a 4B model that is a few hundred megabytes back, for no measurable quality cost. Always on.

Attacks: ■ weights, the overhead term.

prepare_model_for_kbit_training — numerical hygiene

A quantized model is not immediately trainable, and this one function call is what makes it so. Per the PEFT documentation it: freezes all parameters; casts layer norms to FP32; makes the embedding layer’s output require gradients; upcasts lm_head to FP32; and enables gradient checkpointing.

The reason for the FP32 casts is stability. Layer norms and the output head are the numerically touchiest parts of the network, and running them in low precision beside a 4-bit base is how you get a NaN loss at step 400. Note the side effect the guide flags: this increases the model’s reported memory footprint, because you have just upcast several layers. That is expected and worth it.

Attacks: nothing — it costs memory. It buys you a run that converges.

bf16 adapters — precision where it counts

The adapters stay in bf16, not NF4. This is the whole design: gradients flow only through the adapters, so the adapters are the one place where numerical precision genuinely affects learning. Quantizing them would save a rounding error of memory and cost you the optimization.

This is also the cleanest one-sentence definition of QLoRA to have ready in an interview: a 4-bit frozen base with 16-bit trainable adapters.

Attacks: nothing — it is the part you deliberately do not compress.

Paged optimizer — the OOM airbag

paged_adamw_8bit quantizes optimizer state and lets it page to CPU memory under pressure, so a transient spike does not kill an eight-hour job. the guide’s honest note applies: under LoRA the optimizer state is already tiny, so the saving is negligible and the choice is “relatively inconsequential.” Keep it for the paging behaviour on long unattended runs, not for the memory.

Attacks: ■ optimizer — but only meaningfully in full fine-tuning.

The assembled result

A 4B model trainable in about 5 GB instead of 65 GB, at roughly 20–30% slower per step than plain LoRA because of the dequantize-on-every-forward-pass tax.

And note what the artifact is: a bf16 adapter of a few tens of megabytes. The quantized base was scaffolding. You will very likely throw it away and re-apply the adapter to a full-precision base for serving — which is Session 5’s opening problem.

Merge, or keep separate?

Training gives you a base plus an adapter. Two artifacts. Before serving you must choose whether to fold them together permanently.

Merging computes W + (alpha/r)·B·A for every adapted layer and writes the result as ordinary weights. You get one self-contained model with zero adapter overhead — and you can no longer switch the behaviour off, swap it, or serve a second one beside it.

Keeping them separate means the serving engine loads one base and applies adapters per request. This is multi-LoRA serving: many tuned behaviours on one GPU’s worth of base weights.

Decision tree 3 · merge the adapter, or serve it separately

Session 5 executes this against your vLLM endpoint. Decide the shape now, because it changes what you save at the end of training.

Will you serve more than one tuned behaviour off the same base — per-desk, per-customer, per-task, or a champion and a challenger side by side?
yes Keep separate. This is the entire economic case for adapters: one set of base weights in VRAM, many behaviours layered on it. Merging throws that away.

no ↓

Do you need to A/B the tuned behaviour against the untuned base on live traffic, or roll it back in seconds without a redeploy?
yes Keep separate. Turning an adapter off is a request-level flag; un-merging a model is a redeploy. This is why bias="none" matters — it guarantees disabling the adapter reproduces the base exactly.

no ↓

Does your serving stack actually support LoRA for this specific model architecture? (Verify — do not assume. LoRA support in vLLM is wired in per architecture.)
no Merge. You have no choice, and it is fine. Merge, then quantize the merged model for serving in Session 5.

yes ↓

Do you need the absolute lowest latency, or to export to a format that has no concept of adapters — GGUF for llama.cpp, a single quantized production checkpoint?
yes Merge. Adapter overhead is small — under about 1% per layer — but a merged model has exactly zero, and some export paths simply require one set of weights.

no ↓

defaultKeep the adapter separate and serve it multi-LoRA. It preserves every option — rollback, A/B, additional behaviours later — and the cost is a small, measurable overhead. You can always merge later; you cannot easily un-merge.
One merge caveat that will bite you

Do not merge a bf16 adapter into a quantized base and expect the original behaviour. the guide flags the warning PEFT itself raises here: merging LoRA into a 4-bit or 8-bit layer “may get different generations.” The base was rounded to 16 levels; adding a precise update to a lossy weight and re-rounding is not the same arithmetic you trained. The correct sequence is train against the quantized base, then merge into the full-precision base, then quantize the merged result if you need to. Session 5 walks it.

03 · reality check The numbers

What the current evidence actually says

FindingWhat it changesSource
LoRA matches full fine-tuning when configured correctly, at ~67% of the computeKills the old “PEFT is a quality compromise” framing. Configuration, not the method, was the limiter.TRL / Thinking Machines
Apply LoRA to all weight matrices, not just attentionAttention-only underperforms even at a higher rank chosen to match parameter count. Raising r does not compensate for narrow targeting.TRL / Thinking Machines
Optimal learning rate is roughly rank-independentBecause of the 1/r scaling. You can sweep rank without re-sweeping LR — a large practical saving.TRL / Thinking Machines
LoRA wants a higher LR than full FTTheir reproduction used 1.0e-5 for LoRA against 1.0e-6 for full FT. Counterintuitive, and worth respecting.TRL / Thinking Machines
Keep effective batch size below ~32LoRA is less tolerant of large batches than full FT, and raising the rank does not mitigate it.TRL / Thinking Machines
Unsloth: ~2× training speed on Llama-3.1-8B, QLoRA r=32 all-linearKernel-level gains on a single GPU. Free speed if your architecture is on their supported list.Unsloth benchmarks, 2026
Adapters are 1–10% of base model sizeWhich is what makes one base + many behaviours economically obvious.applied guides
LoRA might tune under 1% of parameters on an 8B modelThe material’s own framing, and consistent with the 0.56% you computed for r=16 all-linear in Session 1.applied guides
Note the tension between rows 2–3 and your 24 GB constraint: the evidence pushes toward broad targeting and high rank, the hardware pushes back. Broad targeting is nearly free; high rank is not. Resolve it by taking all-linear first and buying rank second.

A configuration you can defend

Start here

r=16, alpha=32, dropout=0.05, target_modules="all-linear", bias="none".

22.5M trainable parameters, 0.23 GB of gradient and optimizer state, a 45 MB adapter. Broad coverage at a rank your L4 does not notice.

Escalate to here

r=64, alpha=128, use_rslora=True.

Do this when the training loss plateaus above where you need it — the signature of insufficient adapter capacity — not because a blog post said so. 0.9 GB, still comfortable.

Only with the data to justify it

r=256, use_rslora=True.

3.6 GB of gradients and optimizer, a 721 MB adapter, and a serving-side cost because multi-LoRA pre-allocates buffers sized to your maximum rank. Reserve for genuine post-training-scale datasets.

Real production shape: NVIDIA’s managed path does exactly this

Worth knowing as a comparison point for your NVIDIA-stack direction. NeMo Customizer runs a LoRA customization job that produces an adapter attached to a model entity, not a new set of weights — and with deployment_config set, that adapter is automatically served by a NIM deployment of the base model, enabled by default. NIM supports dynamic loading of LoRA adapters without restarting the container, from either NeMo-trained or Hugging Face PEFT-trained adapters, with per-request adapter selection out of an adapter store. Full SFT, by contrast, creates a whole new model entity. The managed stack has made the same merge-versus-separate bet the decision tree above recommends.

Sources: NeMo Customizer LoRA job docs NVIDIA NIM PEFT documentation

04 · lab Apply to my stack

Lab 2.1 — the QLoRA config for Gemma on the L4, with the parameter count printed
# qlora_setup.py — everything from Fig 2.5, assembled and verified.
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training

MODEL = "google/gemma-4-E4B-it"
compute_dtype = torch.bfloat16 if torch.cuda.is_bf16_supported else torch.float32

# --- part 1 + 2: NF4 base with double quantization ---
bnb = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_use_double_quant=True,
    bnb_4bit_compute_dtype=compute_dtype,
)
model = AutoModelForCausalLM.from_pretrained(
    MODEL, device_map="cuda:0", quantization_config=bnb,
    torch_dtype=compute_dtype, attn_implementation="sdpa",
)

# --- part 3: numerical hygiene. Do this BEFORE attaching adapters. ---
model = prepare_model_for_kbit_training(
    model, use_gradient_checkpointing=True,
    gradient_checkpointing_kwargs={"use_reentrant": False},
)

# --- part 4: bf16 adapters, targeting every linear layer ---
lora = LoraConfig(
    r=16,
    lora_alpha=32,
    lora_dropout=0.05,
    target_modules="all-linear",   # NOT the PEFT per-architecture default
    bias="none",                  # keeps the base exactly recoverable
    task_type="CAUSAL_LM",
    # use_rslora=True,          # switch on if you go above r≈64
    # modules_to_save=["lm_head","embed_tokens"],  # ONLY if adding new tokens (S3)
)
model = get_peft_model(model, lora, adapter_name="fintech-json-v1")

# --- verify before you spend five hours ---
model.print_trainable_parameters
print(f"footprint: {model.get_memory_footprint/1e9:.2f} GB")

# which modules ACTUALLY received an adapter? assumptions die here.
adapted = sorted({nm.split(".lora_A")[0].split(".")[-1]
                  for nm, _ in model.named_parameters if "lora_A" in nm})
print("adapted modules:", adapted)
assert "down_proj" in adapted, "MLP not adapted — target_modules did not apply"

That final assertion is the point of the lab. The most common silent LoRA failure is a config that looked right and quietly adapted four attention projections.

Lab 2.2 — a rank sweep that reports memory as well as loss
# rank_sweep.py — sweep r, hold everything else fixed, record the loss FLOOR.
# Because LoRA's optimal LR is ~rank-independent, one LR works across the sweep.
import torch, json, gc

RANKS = [8, 16, 32, 64]
results = []

for r in RANKS:
    torch.cuda.reset_peak_memory_stats
    model = build_qlora_model(r=r, alpha=2*r, rslora=(r > 64))  # from Lab 2.1
    trainer = build_trainer(model, max_steps=300)                # short probe, not a full run
    trainer.train

    hist = [h for h in trainer.state.log_history if "eval_loss" in h]
    results.append({
        "rank": r,
        "trainable": sum(p.numel for p in model.parameters if p.requires_grad),
        "best_eval_loss": min(h["eval_loss"] for h in hist),
        "peak_gb": torch.cuda.max_memory_allocated / 1e9,
        "adapter_mb": sum(p.numel for n, p in model.named_parameters
                            if "lora_" in n) * 2 / 1e6,
    })
    del model, trainer; gc.collect; torch.cuda.empty_cache

print(json.dumps(results, indent=2))
# READ IT LIKE THIS:
#   loss floor keeps dropping as r rises  -> you are capacity-limited, go higher
#   loss floor flattens between two ranks -> take the LOWER one, the rest is waste
#   loss floor gets WORSE at high r       -> instability; set use_rslora=True and retry
Optional exercise — prove the adapter is a no-op at initialisation

Before training, generate from the base model and from the freshly-adapted model on the same prompt with greedy decoding. The outputs should be token-for-token identical, because B is initialised to zeros and B·A is exactly zero.

Then flip with model.disable_adapter: after training and confirm you recover base behaviour again. If either check fails, something in your config is modifying the base path — most often bias set to something other than "none", or modules_to_save touching layers you did not intend. Five minutes here saves a day of confused evaluation later.

Bridge to Session 3

You can now fit the run and configure the adapter — which means every remaining failure is a data failure, and the most expensive one is invisible: a chat template that does not match the one the model was instruction-tuned with.

Session 3 is the data: templates, masking, packing, and reading the loss curve that tells you which of those you got wrong.

Session 3 · data

Data and SFT in practice

The memory maths is solved and the adapter is configured. Every remaining way this run can fail is a data failure — and the worst of them produces no error, no warning, and a loss curve that looks fine.

01 · why The problem this session solves

Here is a failure that has cost more GPU-hours than any OOM. A team fine-tunes a small Gemma on a clean, curated, domain-specific dataset. The hardware is happy. The loss curve descends smoothly. Training completes. Then they query the model and it produces circular nonsense — the reported example is a medical model answering “anemia is anemia; anemia is caused by anemia”.

Nothing was broken. Three tokens were missing.

Every instruction-tuned model ships with a chat template: a precise sequence of special tokens marking who is speaking and where the model’s turn begins. It is not a formatting preference. It is a contract burned into the weights during the model’s own instruction tuning. Train on data that does not honour it and you are teaching the model a second, conflicting convention — while at inference time it still expects the first.

Fig 3.1 — the silent failure · click either side

what people write Analyse the risk factors in this filing. {"sentiment": "negative", "risk": 0.71, ...} no <start_of_turn> · no role · no <end_of_turn> no signal for “stop generating here” loss descends · model babbles what the model expects <bos><start_of_turn>user Analyse the risk factors in this filing. <end_of_turn> <start_of_turn>model {"sentiment": "negative", "risk": 0.71, ...} <end_of_turn> same loss curve · model works Both runs look identical in your training logs. Only one of them ships.
Click either side.
The loss curve cannot tell you about this. Only decoding an actual training batch can — which is why the lab at the end of this session is an assertion, not a script.

02 · concepts Core concepts, from zero

From rows to a training batch

Pass 2 · mechanism, end to end

Walk the pipeline once. Every stage has a characteristic failure, and knowing which stage produced a symptom is most of debugging SFT.

Raw rows — two supported shapes

SFT data is pairs: an input and the output you wish the model had produced. Two encodings dominate.

Instruction / prompt-completion format — two columns, prompt and completion. Simple, and correct for one-shot tasks like yours: a filing goes in, structured JSON comes out. There is no conversation.

Conversational format — a messages list of {"role", "content"} objects, with roles system, user, assistant. Necessary for multi-turn, and the format chat templates are actually designed around.

Failure at this stage: silently inconsistent outputs. If 3% of your completions use different key ordering or an extra whitespace convention, you are teaching the model that the format is negotiable — and it will negotiate, in production, at 3am.

Chat template — the contract

The template is a Jinja string stored on the tokenizer, at tokenizer.chat_template. Calling apply_chat_template(messages) renders your message list into the exact token sequence the model was instruction-tuned on.

Gemma’s contract looks like this:

<bos><start_of_turn>user
Analyse the risk factors in this filing.<end_of_turn>
<start_of_turn>model
{"sentiment":"negative","risk_score":0.71}<end_of_turn>

Compare Llama 3’s <|begin_of_text|> scaffold or Mistral’s [INST]…[/INST]. Every family is different, they change between versions, and the current one lives in the tokenizer config — not in the model card, and definitely not in a tutorial.

Failure at this stage: Fig 3.1. Also the double-BOS bug — apply_chat_template already emits <bos>, so tokenizing its output again with add_special_tokens=True gives you two, and the model has never seen two.

Tokenize — and mind the two special tokens

Text becomes token IDs. Two of them decide whether your model knows how to stop.

The EOS token (end of sequence) is appended to the completion during training. It is the model’s only signal for “this answer is finished” — without it in your labels, the model never learns to terminate and generates until it hits max_new_tokens.

The PAD token fills short sequences to a uniform length, and is masked out of the loss because it carries no meaning.

Failure at this stage: the EOS/PAD collision. Some models use the same token for both — the guide flags Phi-3 specifically. Padding gets masked from the loss, so the EOS token gets masked too, so the model never learns to stop. His fix is to point PAD at a different token entirely, such as UNK, keeping EOS unique. Check tokenizer.eos_token_id == tokenizer.pad_token_id before every run.

Mask labels — decide what the model is graded on

The loss is computed by comparing predictions against labels. Anywhere you write -100, the position is ignored. That gives you a choice.

Train on everything (the default): labels are a copy of the input IDs, so the model is graded on predicting the prompt as well as the answer. Right when you are teaching a base model the shape of instructions in general.

Train on completions only: mask the prompt to -100 so gradient flows only from the answer tokens. Right when the model is already instruction-tuned and you are teaching it a specific answering behaviour — which is your case exactly. You do not need Gemma to get better at predicting SEC filings; you need it to get better at responding to them.

Failure at this stage: a mask that silently matches nothing. If the response template you search for tokenizes differently depending on surrounding whitespace, the masker finds no boundary and either errors or masks everything. the guide devotes a subsection to this. Always decode a batch and look.

Pack or pad — the throughput decision

Sequence packing concatenates examples end to end with a separator and slices the stream into fixed-length chunks, so almost every token in a batch is a real token. Padding instead fills each sequence out to the batch maximum with meaningless PAD tokens that occupy VRAM and contribute nothing.

Packing’s cost is that some examples get cut across a chunk boundary. the guide’s rule of thumb: the higher the ratio of packed length to average example length, the fewer examples are affected — so packing wins on long-sequence datasets and padding can be faster on short ones.

Failure at this stage: cross-contamination. If you pack naively, attention in example 2 can attend to example 1 — “undesired cross-example attention that reduces quality and convergence.” The fix is position_ids: DataCollatorWithFlattening emits them so FlashAttention-2 knows where each original sequence starts and stops.

Forward and backward — where activations live

The batch flows through the frozen base and the adapters, producing logits; cross-entropy against the shifted labels gives the loss; backpropagation walks it back through every layer.

Note the shift, because it confuses people: the model predicts token n+1 from tokens up to n, so logits and labels must be offset by one. Inside the Hugging Face ecosystem this is handled for you — the model does it internally. Do not shift your labels by hand.

Failure at this stage: OOM, and it is a Session 1 problem wearing a data costume. max_length is the argument most likely to cause it. the guide’s recommendation: 25–50% of the model’s maximum sequence length, and as short as your data genuinely allows.

Adapter update — and then evaluate

The optimizer updates only the LoRA matrices. Everything else is untouched, which is why a checkpoint here is a few tens of megabytes rather than several gigabytes — and why you can afford to save often.

That last point matters more than it sounds. Cheap checkpoints mean you can evaluate frequently, keep the best rather than the last, and survive preemption in Session 6.

Curation and size: how much data, and how good?

Pass 1 · intuition

SFT is demonstration learning. Every row is you saying “when you see this, do that.” The model has no way to know which parts of your demonstration were the point — so if a fifth of your examples demonstrate something slightly different, you have taught inconsistency and it will reproduce it faithfully.

Which reframes the size question. You are not gathering volume. You are gathering agreement.

Pass 3 · the numbers people actually use
RegimeExamplesWhat it can teachSource
PEFT / LoRA on an aligned model100 – 1,000A format, a voice, a narrow skill. Enough for “always emit this JSON in this register”.applied guides §6
Careful curation at small scale~1,000LIMA showed 1,000 well-chosen examples reaching teacher-level behaviour. Curation substituting for volume.LIMA, via KD survey
Comfortable adapter training5,000 – 50,000Multiple related behaviours, better coverage of edge cases. Where most production adapters live.practice
Full fine-tuning100,000“At least hundreds of thousands of new examples” to shift a model enough to justify the cost.applied guides §6
Post-training scale (rank-256 territory)100k – 1MThe regime the high-rank LoRA advice was measured in. Diverse instruction mixtures, not one narrow task.TRL / Thinking Machines
Epoch heuristics from the guide: 1–2 epochs for large datasets (over ~2M samples), 3–4+ for small ones. Small dataset plus many epochs is the classic overfitting recipe — which is exactly why you watch the eval curve rather than the train curve.
Synthetic data for SFT — and the one step everybody skips

You will not hand-write 20,000 analyst-grade JSON annotations. Nobody does any more. The 2026 pattern is consistent across write-ups: a small real seed → expansion by a strong teacher model → a judge filter → JSONL into TRL or Unsloth.

The RLHF literature is now fairly blunt about the state of this: for instruction data, synthetic generation “has largely won” — distillation from stronger models produces higher-quality completions than most human writers can supply at scale, with exceptions at the hardest reasoning frontier. The cost asymmetry drives it: a single human preference judgement runs on the order of a dollar or more, an equivalent model judgement well under a cent.

The step people skip is the judge filter, and the consensus is emphatic that it is the one that matters: an unfiltered synthetic dataset is worse than a smaller filtered one. Generation is cheap, which means bad generation is also cheap and arrives in bulk.

For your fintech set, the filter is unusually easy to build and unusually strong, because your target output is machine-checkable. You do not need an LLM judge for the first pass — you need a schema validator. That idea is Session 5’s evaluation gate, and it comes straight from the source material, which builds exactly this shape of automated check for generated code: syntax validity first, then static analysis of API usage, then does it actually execute. Substitute parses as JSONvalidates against the schemaare the numeric fields in range and consistent with the source filing, and you have the same three-stage gate for structured financial output.

Sources: the literature, RLHF field text, ch. on synthetic data & distillation 2026 synthetic-data-for-fine-tuning survey · Iozzia, Domain-Specific SLMs, §3.4

Reading the loss curve

Pass 2 · what the shapes mean

Your evaluation stage already covered loss, perplexity and checkpoint selection as concepts. Here is where they get cashed in — as an operational skill, watched live, with a decision attached to each shape.

Fig 3.2 — four curves and what to do about each · click a panel

healthy ship the eval minimum overfitting ship the turn, not the end underfitting raise rank / widen targets broken NaN read a batch, not the config solid = training loss · dashed = eval loss The training curve alone tells you almost nothing. Every one of these four looks acceptable if you only plot the solid line — which is why an eval split and a non-trivial eval_steps are not optional extras but the instrument you are steering by.
Click a curve.
Loss is a proxy. A model can improve on loss while getting worse at your actual task — which is why the release gate in Session 6 is your golden-set harness, not a number from the trainer.
Checkpoint selection, concretely

Set eval_strategy="steps", save_strategy="steps", the same eval_steps and save_steps, load_best_model_at_end=True and metric_for_best_model="eval_loss". Without that last pair, save_model writes whatever the model looked like when training stopped — which, on any run with a visible eval turn, is the wrong one. Early stopping (via EarlyStoppingCallback) then ends the run automatically once eval loss has failed to improve for a set number of evaluations, so you stop paying for epochs that are actively making the model worse.

Decision tree 4 · packing, padding and which collator

Adapted from the guide’s flow, updated for TRL v1. Get this wrong and you either waste half your VRAM on padding or silently corrupt attention across example boundaries.

Are you fine-tuning an already instruction-tuned model to answer in a particular way — rather than teaching a base model to follow instructions at all?
yes Train on completions only. Mask the prompt to -100. Gradient should come from the answer, not from re-learning to predict SEC prose. This is your case.

no — teaching instruction-following from a base model ↓

Are your sequences long relative to the padded batch width — enough that padding would waste a large fraction of every batch?
yes Pack. Use a packing-aware collator that emits position_ids so attention respects the original sequence boundaries.

no — sequences are short and similar in length ↓

Is your model on the list that exposes position_ids for FlashAttention-2, and are you using FA2?
no, and I want FA2 Do not use naive packing. Either turn packing off, or switch to SDPA. Naive packing plus FA2 causes cross-example attention that quietly degrades convergence.

yes ↓

defaultPad, with a padding collator, and set max_length to the shortest value that fits your data. Simple, correct, debuggable. Optimise for throughput only after the run is producing a model that passes your eval gate.
TRL has moved to v1 — three API changes that will break the materials’ code

Both the guide and applied guides were written against TRL 0.x. TRL v1.0 shipped in March 2026 and the library is now well into v1.9.x. Three changes matter for the code in those section:

  • tokenizer=processing_class= on SFTTrainer. Renamed to generalise beyond text tokenizers to image processors and audio feature extractors. Passing tokenizer= now raises a TypeError.
  • Truncation moved out of the collator into dataset preparation, so SFT and DPO now truncate at the same phase. If you wrote a custom collator against the old behaviour, re-check it.
  • Assistant-only loss is now largely automatic. assistant_only_loss=True needs the chat template to carry {% generation %} / {% endgeneration %} markers, and very few models ship them — which used to produce a cryptic error. SFTTrainer now swaps in a patched training template when the original lacks the markers.

Also worth knowing: TRL v1 integrates Unsloth kernels, with reported gains of roughly 2× training speed and up to 70% lower memory for SFT and DPO versus the standard implementation.

Sources: TRL documentation TRL release notes “TRL v1”, March 2026

03 · reality check The numbers

Sizing your actual run

Take 20,000 fintech SFT examples. A filing excerpt plus instruction runs ~700 tokens; the JSON completion ~200. Call it 900 tokens per example after templating.

Tokens / epoch

18.0 M
20,000 × 900

Packed sequences

8,790
at max_length 2048

Optimizer steps

~1,650
3 epochs, effective batch 16

Wall clock, L4

~5 h
QLoRA, checkpointing on

Two consequences fall out of those numbers. First, at ~1,650 steps an eval_steps of 50 gives you 33 evaluation points — enough resolution to see a turn. An eval_steps of 500 gives you three, and you will miss it. Second, the packing saving is real: at 900 tokens average packed into 2048, padding each example to the batch maximum would waste roughly half of every batch on PAD tokens — and that waste is paid in activation memory, the one term LoRA never reduced.

The effective batch size trap

Your micro-batch is limited by VRAM; your effective batch is what the optimizer sees:

effective batch = per_device_train_batch_size × gradient_accumulation_steps × num_devices

On one L4 with a micro-batch of 2 and 8 accumulation steps, that is 16. Which is comfortably inside the current recommendation to keep LoRA’s effective batch below about 32 — a finding that surprised the people who measured it, and which raising the rank does not fix. If you scale to multiple GPUs in Session 6, remember that num_devices multiplies in, and an eight-GPU job at the same per-device settings gives you an effective batch of 128. Turn the accumulation steps down when you scale out, or you will silently leave the regime where LoRA works well.

Gemma-specific gotchas, from the field

The <bos> requirement

Gemma requires inputs to begin with <bos>, and this has been a live source of bugs since the first release — including a documented episode where the community’s standard ChatML formatting was subtly incompatible with Gemma for exactly this reason. apply_chat_template emits it. Tokenizing that rendered string again with add_special_tokens=True gives you two, and a double BOS is a token sequence the model has never seen.

Source: Gemma + ChatML + TRL write-up

New special tokens need modules_to_save

Google’s own current Gemma QLoRA recipe sets modules_to_save=["lm_head", "embed_tokens"] with the explicit comment that this is required when you train special tokens. If your fintech schema introduces new delimiters, those tokens start with random embeddings and stay random unless the embedding table and head are trainable. The cost is real — those are the largest tensors in the model — so only do it if you actually added tokens.

Source: Google AI, Fine-Tune Gemma with QLoRA

Templates change between versions

The clinical-model write-up that opened this session makes the general point sharply: the Gemma 3 contract is not in the model description, it is buried in the tokenizer config, and templates change silently between versions. Never hard-code a template string. Render it from the tokenizer of the exact checkpoint you are training, every run.

Source: “Your fine-tune isn’t broken. You skipped three tokens.”, May 2026

Masking is standard practice, not a nicety

NVIDIA’s own NeMo AutoModel guide for fine-tuning Gemma builds a loss mask that excludes prompts and special tokens so only answer tokens contribute to the loss — the same completion-only decision the tree above routes you to, arrived at independently by a different stack.

Source: NVIDIA NeMo AutoModel, Gemma fine-tuning

04 · lab Apply to my stack

Lab 3.1 — build the fintech SFT set, and assert the template is right
# build_sft.py — pipeline output -> chat-templated, verified training data.
import json
from datasets import Dataset
from transformers import AutoTokenizer

MODEL = "google/gemma-4-E4B-it"
tok = AutoTokenizer.from_pretrained(MODEL)

# ---- 0. THE PRE-FLIGHT CHECKS. Never skip these. ----
assert tok.chat_template is not None, "no chat template on this tokenizer"
if tok.pad_token_id == tok.eos_token_id:
    # EOS/PAD collision: padding is masked from the loss, so EOS would be too,
    # and the model would never learn to stop. Point PAD somewhere else.
    tok.pad_token = tok.unk_token or "<pad>"
    print("WARNING: EOS==PAD, remapped PAD ->", tok.pad_token)

SYSTEM = ("You are a sell-side equity analyst. Read the filing excerpt and return "
          "ONLY a JSON object with keys: sentiment, risk_score, signal, compliance_flags.")

def to_messages(row):
    # Gemma has no dedicated system role — fold instructions into the first user turn.
    # Check this against YOUR checkpoint's template rather than assuming.
    return {"messages": [
        {"role": "user",
         "content": f"{SYSTEM}\n\n---\n{row['filing_excerpt']}"},
        {"role": "assistant",
         # canonical form: sorted keys, no stray whitespace. Consistency IS the lesson.
         "content": json.dumps(row["structured_output"], sort_keys=True,
                              separators=(",", ":"))},
    ]}

rows = [json.loads(l) for l in open("fintech_pairs.jsonl")]
ds = Dataset.from_list(rows).map(to_messages,
        remove_columns=["filing_excerpt", "structured_output"])

# ---- 1. QUALITY GATE: every completion must be valid against the schema. ----
#      Unfiltered synthetic data is worse than a smaller filtered set.
import jsonschema
SCHEMA = json.load(open("signal_schema.json"))

def valid(ex):
    try:
        jsonschema.validate(json.loads(ex["messages"][1]["content"]), SCHEMA)
        return True
    except Exception:
        return False

before = len(ds); ds = ds.filter(valid)
print(f"schema filter: {before} -> {len(ds)}  ({1-len(ds)/before:.1%} dropped)")

# ---- 2. RENDER AND LOOK AT IT WITH YOUR OWN EYES. ----
rendered = tok.apply_chat_template(ds[0]["messages"], tokenize=False)
print(repr(rendered))

# ---- 3. THE ASSERTIONS THAT WOULD HAVE CAUGHT FIG 3.1 ----
ids = tok.apply_chat_template(ds[0]["messages"], tokenize=True)
assert rendered.count(tok.bos_token) == 1, "double BOS — do not re-tokenize with add_special_tokens"
assert "<start_of_turn>model" in rendered, "no generation prompt: model won't know it's its turn"
assert rendered.rstrip.endswith(("<end_of_turn>", tok.eos_token)), "no terminator: model won't stop"

# ---- 4. token budget, so max_length is measured rather than guessed ----
lens = [len(tok.apply_chat_template(m, tokenize=True)) for m in ds["messages"][:2000]]
lens.sort
print(f"tokens/example  p50={lens[len(lens)//2]}  "
      f"p95={lens[int(len(lens)*.95)]}  max={lens[-1]}")
print("-> set max_length just above p95; truncating the tail beats paying for it on every batch")

ds.train_test_split(test_size=0.05, seed=42).save_to_disk("fintech_sft")
Lab 3.2 — the SFTConfig, TRL v1 syntax, tuned for a 24 GB L4
# train_sft.py — pairs with Lab 2.1's model. TRL v1.x API.
from datasets import load_from_disk
from transformers import EarlyStoppingCallback
from trl import SFTConfig, SFTTrainer

ds = load_from_disk("fintech_sft")

cfg = SFTConfig(
    output_dir="gemma-fintech-json-v1",

    # --- memory (Session 1's four terms, as arguments) ---
    gradient_checkpointing=True,
    gradient_checkpointing_kwargs={"use_reentrant": False},
    per_device_train_batch_size=2,      # micro-batch: VRAM decides this
    gradient_accumulation_steps=8,      # effective batch 16 — under the ~32 ceiling
    per_device_eval_batch_size=4,       # no grads at eval, so this can be larger
    bf16=True,

    # --- data ---
    max_length=2048,                    # from the p95 measured in Lab 3.1
    packing=True,
    packing_strategy="wrapped",
    assistant_only_loss=True,           # completions-only; v1 patches the template if needed

    # --- schedule ---
    num_train_epochs=3,                  # small dataset -> 3-4; watch the eval turn
    learning_rate=2e-4,                  # LoRA wants MORE than full FT's 2e-5
    lr_scheduler_type="cosine",
    warmup_ratio=0.03,
    optim="paged_adamw_8bit",            # negligible saving under LoRA; paging is the point

    # --- the instrument panel: 33 eval points across ~1,650 steps ---
    eval_strategy="steps",   eval_steps=50,
    save_strategy="steps",   save_steps=50,
    load_best_model_at_end=True,        # WITHOUT THIS you ship the last checkpoint
    metric_for_best_model="eval_loss",
    greater_is_better=False,
    save_total_limit=3,
    logging_steps=10,
    report_to="tensorboard",
    seed=42,
)

trainer = SFTTrainer(
    model=model,                          # the PEFT model from Lab 2.1
    args=cfg,
    train_dataset=ds["train"],
    eval_dataset=ds["test"],
    processing_class=tok,                 # v1: NOT tokenizer=
    callbacks=[EarlyStoppingCallback(early_stopping_patience=5)],
)

# ===== THE CHECK THAT SAVES THE RUN — decode a real batch before training =====
batch = next(iter(trainer.get_train_dataloader))
labels = batch["labels"][0]
supervised = labels[labels >= 0]
print("=== TOKENS THE MODEL IS GRADED ON ===")
print(tok.decode(supervised))
# Expect: the JSON completion and its terminator. NOT the filing text.
# All -100  -> mask matched nothing.  Everything visible -> completions-only is off.
assert len(supervised) > 0,               "every label masked — nothing to learn from"
assert len(supervised) < len(labels) * 0.8, "prompt does not appear to be masked"

trainer.train
trainer.save_model("gemma-fintech-json-v1")   # adapter only, ~45 MB

Those three lines that decode labels[labels >= 0] are the highest-value code in this session. They catch template bugs, mask bugs and the EOS/PAD collision, before you spend five hours finding out the slow way.

Optional exercise — deliberately break it, then recognise the break

Train two 200-step probes on 500 examples. Run A uses apply_chat_template. Run B concatenates prompt + "\n" + completion with no template at all.

Compare three things: the loss curves (they will look similarly healthy — this is the lesson), the decoded supervised tokens from each batch, and the generations from each checkpoint on five held-out filings. Run B will produce output that does not terminate, or that continues into an invented follow-up question.

Now you have seen the failure with your own eyes on your own data, which is the only reliable way to make the pre-flight assertions in Lab 3.1 feel non-negotiable rather than fussy.

Bridge to Session 4

You can now train a generative model to produce exactly the structured output you want — which raises an awkward question you should be able to answer before you spend the GPU hours.

Session 4: for some of these tasks, a 110M-parameter classifier beats a tuned 4B generator on accuracy, latency and cost simultaneously — and your router’s keyword classifier is one of them.

Session 4 · represent

The representation side — when you should tune the embedder, not the generator

Half the "fine-tuning" problems that land on your desk are not generation problems at all. They are retrieval problems wearing a generation costume: the model writes fine, but it was handed the wrong context. This session is about recognising that case and tuning the cheaper model — the embedder.

04 · why The problem this session solves

Your RAG pipeline returns confident answers built on the wrong paragraphs. The instinct is to fine-tune the generator to "understand the domain." But the generator never saw the right evidence — no amount of tuning fixes what retrieval never fetched. The lever that actually moves the metric is the embedding model: the network that turns queries and documents into vectors. It is typically 100–400× smaller than your generator, which means the entire memory-wall arithmetic from Session 1 collapses into "fits on the GPU you already have."

04 · what Bi-encoders, cross-encoders, and where tuning lands

A bi-encoder embeds query and document independently, so document vectors can be pre-computed and searched at scale — this is your index. A cross-encoder reads query and document together and scores the pair — far more accurate, far too slow to run over a corpus, perfect as a reranker over the top-k. Fine-tuning applies to both, but the training signal differs: bi-encoders learn with contrastive objectives (pull the right pair together, push wrong pairs apart), while cross-encoders train like ordinary classifiers on relevance labels.

The contrastive recipe has one ingredient that decides success more than any hyperparameter: hard negatives. A random wrong document teaches the model almost nothing — it was already far away. A document that looks right (same product name, wrong version; same statute, wrong year) forces the embedder to encode the distinction your domain actually cares about. Mining hard negatives from your own retrieval logs — documents the current system ranked highly but users rejected — is the highest-yield data work in this whole stage.

04 · how LoRA applies here too — and the run is tiny

Everything from Session 2 transfers: attach low-rank adapters to the attention projections of the embedding model, freeze the base, train on (query, positive, negatives) triplets with an InfoNCE-style loss. A 0.3B–1.5B embedder with LoRA trains comfortably inside a single consumer GPU, in minutes to hours rather than days. Batch size matters more than usual — contrastive learning uses the other items in the batch as free negatives, so bigger batches teach faster. If memory pinches, gradient-cache techniques recover large effective batches at the cost of a second forward pass.

04 · prove Evaluation is recall, not vibes

Generation quality metrics do not apply here. Build a small gold set of (query → correct document) pairs from real traffic — even 200 pairs is enough to see movement — and report recall@k and MRR before and after tuning. The number that matters downstream is recall at your pipeline's actual k: if your generator reads 5 chunks, recall@5 is the truth. A tuned embedder that lifts recall@5 from 0.61 to 0.83 will do more for answer quality than any generator-side fine-tune, at a fraction of the cost. Stage 04 picks this thread up and runs the full retrieval-quality playbook.

Session 5 · compress & serve

Compress & serve — from adapter checkpoint to tokens per second

Training ends with a folder of adapter weights. Nobody can use a folder. This session walks the artifact from checkpoint to a served endpoint: merge or hot-load, quantize with your eyes open, and re-run the evaluation gate before anything touches traffic.

05 · why The problem this session solves

The run finished, the loss curve looks beautiful, and now three decisions stand between you and production: whether to merge the LoRA into the base weights or serve it as a detachable adapter; whether to quantize, and to what precision; and how to package the result so the serving layer can load it deterministically. Each choice trades quality, latency, VRAM, and operational flexibility against the others — and each one is reversible only if you kept the pieces.

05 · what Merge vs. hot-load — one model or many personalities

Merging folds the low-rank update into the base matrices, producing a single ordinary checkpoint: simplest to serve, zero runtime overhead, but the base is now permanently "flavored" — serving ten variants means ten full copies. Runtime adapters keep the base frozen and attach LoRA weights per request: modern servers like vLLM can multiplex many adapters over one base, paying a small latency tax for enormous VRAM savings. The decision rule is simple: one product, one voice → merge; many tenants, many voices → hot-load. Either way, keep the unmerged adapter in artifact storage forever — it is your ability to re-merge onto a newer base later.

05 · how Quantization after tuning — the honest sequence

Quantize after the merge, then evaluate — never trust that a recipe which was harmless on the base model is harmless on your tuned one. Weight-only 4-bit schemes in the GPTQ/AWQ family typically cost a point or less on general benchmarks while cutting VRAM roughly 3–4×; your narrow fine-tuned skill can be more fragile than the average benchmark, which is exactly why the gate below exists. The arithmetic callback to Session 1: a merged 8B model at 4-bit weighs in near 5 GB — the same hardware that could barely train with QLoRA now serves with headroom for a real KV-cache.

05 · prove The gate: same eval, new artifact

Re-run the exact evaluation set from your training loop against the served artifact — merged, quantized, behind the real inference stack — not against the training-time checkpoint. Differences here are not noise; they are the cost of every packaging decision you just made, measured. Only when the served numbers clear the bar does the artifact earn a version tag and a place in the registry. Stage 05 goes deep on the serving stack itself; Stage 06 wraps it in an API worth exposing.

Session 6 · operate

Fine-tuning as a Kubernetes Job — the interchange preview

On your laptop, training is a script. In a team, it is a workload: schedulable, restartable, observable, and gone when it finishes. Kubernetes Jobs are the smallest honest way to run one — enough platform to run a real tune on a cluster without pretending to be a platform course.

06 · why The problem this session solves

The GPU box under the desk has three problems: someone else wants it, nothing restarts your run when it dies at 3 a.m., and nobody can reproduce what you did. A Kubernetes Job answers all three: it requests a GPU through the scheduler, restarts on failure with a policy you chose, and its YAML is the reproduction recipe.

06 · what The five lines that matter in the manifest

A training Job is an ordinary Job with a handful of load-bearing fields. resources.limits."nvidia.com/gpu": 1 is the actual GPU request — schedulers treat GPUs as extended resources, and requests equal limits for them. A nodeSelector or toleration steers the pod onto GPU nodes (which are usually tainted so CPU workloads stay off). backoffLimit caps retries; activeDeadlineSeconds caps runaway spend. And the container's command is your training script — the same one from Session 3, now pinned inside an image.

06 · how Checkpoints make preemption boring

Cheap GPU capacity is preemptible capacity, so design for interruption instead of hoping. Write checkpoints (model, optimizer, step counter) to object storage every N steps; on start, the script checks for the latest checkpoint and resumes. With that in place, a spot eviction costs you minutes, not the run — and the Job's restart policy turns "the node vanished" into "the pod moved." Secrets (your model-hub token, bucket credentials) come from Kubernetes Secret objects mounted as env vars, never baked into the image. The finished adapter is pushed to the registry as an artifact, closing the loop with Session 5's packaging.

06 · prove Where this thread continues

You have now touched images, scheduling, GPU resources, and secrets — one honest slice of the platform. The production treatment lives in Stage 08 — K8s & Infra for LLMs: model weights as cluster data, GPU scheduling at fleet scale, and the serving topologies that carry real traffic. And the cloud that all of it runs on is the Platform track — accounts to GPUs to pipelines.

← 01The path
Next stage · 03 →genaipros · 02 · Fine-Tuning (SFT)AI for Everyone ↗