Module 11 18 min

LoRA & QLoRA

How low-rank adapters make fine-tuning affordable, visualized.

So you've decided fine-tuning is the right tool. Bad news first: fully fine-tuning a 7-billion-parameter model means storing gradients and optimizer state for all 7 billion weights — north of 100 GB of GPU memory. Good news: a 2021 technique called LoRA lets you get most of the benefit while training well under 1% of the parameters, often on a single consumer GPU.

Why does this exist?

Full fine-tuning updates every weight, which means every weight needs a gradient and optimizer state in GPU memory — roughly 8× the model size with Adam in fp16. That priced out everyone without a datacenter. LoRA (Low-Rank Adaptation) exists because researchers noticed the change a fine-tune makes to a weight matrix is highly redundant — it can be captured by two tiny matrices instead of one huge one.

The core idea: don't change W, add to it

Take one weight matrix W in the model — say 4096×4096, about 16.8 million parameters. Full fine-tuning nudges all 16.8M values.

LoRA freezes W completely and learns a low-rank update instead:

W' = W + B·A

W : d×d      frozen  (16.8M params, untouched)
A : d×r      trainable
B : r×d      trainable

The rank r is tiny — typically 4 to 64. With d = 4096 and r = 8, the trainable parameters are 2 × 4096 × 8 = 65,5360.4% of the original matrix. Multiplying B·A produces a full d×d matrix, but one whose "information content" (rank) is limited to r. The bet — which holds remarkably well in practice — is that task adaptation doesn't need a full-rank change.

At inference you can either keep the adapter separate (and swap adapters per task on one shared base model!) or merge B·A into W once for zero extra latency.

See it

The visualizer below shows one 12×12 weight matrix. In full fine-tune mode every cell pulses — all 144 parameters trainable. Switch to LoRA: W dims (frozen), and two thin strips A and B appear. Drag the rank slider and watch the trainable count and savings change.

W (frozen)
B (2×12)
A (12×2)

Trainable params (this layer)

48

2 × 12 × 2 — 66.7% fewer than full

Frozen params

144

W never changes on disk

Training memory (7B-scale intuition)

~16 GB (fp16 frozen base + tiny fp16 adapters)

Notice that even at rank 4, you train 96 parameters instead of 144 — and the savings grow with matrix size: for real 4096-wide matrices the ratio is under 1%.

QLoRA: quantize the frozen part

LoRA shrinks the trainable memory, but the frozen base model still sits in GPU memory in 16-bit precision — 14 GB for a 7B model. QLoRA's insight: since W is frozen anyway, why store it precisely? Quantize it to 4 bits (using a format called NF4), keep the LoRA adapters in full precision, and dequantize on the fly during the forward pass.

Toggle QLoRA in the visualizer above — the frozen matrix snaps to a coarser color palette. That's the visual metaphor for 4-bit storage: fewer distinct values, ~4× less memory, nearly the same fine-tuned quality. QLoRA is what made "fine-tune a 7B–70B model on one GPU" a normal weekend activity.

The code

With Hugging Face peft, LoRA is a config object:

from peft import LoraConfig, get_peft_model
from transformers import AutoModelForCausalLM

model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Llama-3.1-8B",
    load_in_4bit=True,          # <- the "Q" in QLoRA
)

config = LoraConfig(
    r=16,                       # rank of the update
    lora_alpha=32,              # scaling factor (roughly: how loud the adapter is)
    target_modules=["q_proj", "v_proj"],   # which matrices get adapters
    lora_dropout=0.05,
    task_type="CAUSAL_LM",
)

model = get_peft_model(model, config)
model.print_trainable_parameters()
# trainable params: 6,815,744 || all params: 8,036,929,536 || trainable%: 0.0848

Training then proceeds like any fine-tune — same loss, same optimizer — but gradients flow only into the A and B matrices. The output artifact is an adapter file of a few dozen megabytes, not an 16 GB model copy.

Choosing r

Start small: r = 8 or 16 covers most style/format tasks. Raise r only if eval quality plateaus — bigger r means more capacity but more data needed and diminishing returns. Doubling r doubles adapter size but rarely doubles quality.

Adapters are composable infrastructure

Because the base model never changes, one deployed base can serve many products: load the "support-tone" adapter for the help desk, the "SQL-generator" adapter for analytics — hot-swapped in milliseconds. This is a genuinely different ops model from shipping N full fine-tuned models.

Build it yourself

  1. Take the 20 gold examples you wrote in the previous lesson and expand to ~200 (hand-write, or generate with a big model and review each one).
  2. QLoRA-tune a small open model (1–8B) with peft + bitsandbytes on a single GPU (Colab works).
  3. Log print_trainable_parameters() — verify you're under 1% trainable.
  4. Evaluate base vs tuned on 20 held-out examples. Also spot-check a few general questions to catch catastrophic forgetting.

Summary

  • Full fine-tuning needs gradients + optimizer state for every weight — ~8× model size in memory.
  • LoRA freezes W and trains a low-rank update B·A, typically under 1% of parameters, producing a tiny adapter file.
  • Rank r controls adapter capacity; 8–16 is a strong default, raise only when evals demand it.
  • QLoRA stores the frozen base in 4-bit, enabling large-model fine-tuning on a single GPU.
  • Adapters merge for zero-latency inference, or stay separate for hot-swappable multi-task serving.