datarekha

LoRA and QLoRA fine-tuning

Why fine-tuning a 7B model used to cost $50k — and how LoRA cut it to $50. The low-rank adapter trick that made fine-tuning practical.

8 min read Advanced NLP & Transformers Lesson 36 of 44

What you'll learn

  • The math behind LoRA's low-rank decomposition
  • How QLoRA combines 4-bit quantization with LoRA
  • When to reach for plain LoRA vs QLoRA — the memory/speed trade-off
  • When to fine-tune vs just prompt engineer

Before you start

Fine-tuning a 7B model the old-fashioned way means updating 7 billion parameters — needing optimizer state for each (often 4x in fp32), plus gradients, plus activations. Full fine-tuning a 7B model fits on a single A100 only if you’re careful, and the resulting checkpoint is the same size as the original. Now imagine doing this for 50 different domains.

LoRA changed this. Instead of updating the weight matrices directly, train tiny adapter matrices alongside them (an adapter is a small add-on module that sits beside a layer without changing its weights). The original model stays frozen (its weights are locked and never updated) — you only train and ship the adapters, which are typically less than 1% of the parameters.

TryLoRA rank

Drag the rank to see how few parameters LoRA trains

W₀ is frozen. LoRA learns two thin matrices A (r×d) and B (d×r) whose product B·A is the low-rank update added to W₀. Raise rank r to see the update grow richer — and watch the parameter count stay linear in r, not quadratic.

LoRA: 96 params  (2·d·r = 2·12·4)Full: 14466.7%
W₀ (frozen)
-.4
.2
-.6
.3
.7
-.7
-.5
.1
.4
-.2
-.1
.6
.5
-.1
.1
-.4
-.0
.4
.7
-.1
.8
-.6
-.5
.4
-.2
-.5
-.3
-.7
.7
-.1
-.3
-.5
-.4
-.3
-.3
.3
.7
-.0
-.5
.6
-.6
-.4
-.5
-.5
.7
.3
.1
-.8
.4
-.8
-.1
-.5
.2
.2
.2
.5
-.1
-.6
-.7
.2
-.6
-.7
-.3
.1
-.4
-.7
.0
.8
-.1
.7
-.5
-.6
.2
-.6
.1
-.6
.2
.3
.5
-.7
-.4
.7
-.7
.1
-.5
.5
.4
.3
.4
.1
.0
.3
-.3
.5
-.3
-.7
-.8
.3
.2
-.7
.5
.0
.7
.1
.4
.3
-.2
.6
.3
.5
-.7
.4
.1
.6
.6
.5
-.8
-.5
-.0
.6
.4
-.7
.1
-.5
-.0
.6
.6
.1
.4
.8
.4
-.1
-.7
.5
.1
-.7
-.5
.4
.1
.3
.7
.3
-.4
.5
12×12
dd
B (d×r)
-.1
-.5
-.6
-.7
-.2
-.1
-.4
-.7
-.6
.6
.3
-.3
.5
-.3
.6
.2
.2
.5
.7
.6
.2
.1
.2
.7
-.3
.0
-.2
-.2
-.2
.1
.7
-.6
-.3
-.2
-.2
.3
-.7
.1
.4
.5
-.1
-.2
-.1
.5
.6
.4
.0
-.7
12×4
dr
A (r×d)
-.5
.3
.7
.0
-.2
-.0
.1
-.0
-.0
.2
.2
.3
.2
-.4
-.2
.0
.5
.0
.1
-.3
-.7
.5
.1
.5
.3
-.2
-.1
.7
-.1
.1
.7
.2
-.5
-.6
.6
.5
.3
-.5
-.2
.1
.6
.7
.8
-.3
.7
.1
-.5
.2
4×12
rd
W ≈ W₀ + B·A
-.5
.4
-.5
.2
.5
-.9
-.8
.1
.4
-.2
-.1
.4
.4
.0
.1
-.5
-.1
.2
.4
-.0
.7
-.6
-.4
.3
-.1
-.6
-.5
-.6
.7
-.2
-.3
-.6
-.6
-.3
-.2
.4
.6
-.0
-.4
.7
-.7
-.3
-.3
-.5
.8
.2
.2
-.7
.5
-.9
-.1
-.4
.3
.3
.5
.4
-.2
-.6
-.6
.4
-.6
-.8
-.3
.2
-.3
-.6
.3
.7
.0
.7
-.6
-.5
.3
-.6
.0
-.7
.2
.3
.4
-.7
-.4
.7
-.7
.1
-.5
.5
.4
.5
.3
-.0
.0
.4
-.5
.4
-.1
-.6
-.8
.3
.2
-.7
.6
.1
.7
.1
.6
.3
-.3
.5
.5
.4
-.9
.5
.2
.7
.8
.4
-.7
-.6
-.1
.6
.4
-.7
.1
-.5
.0
.7
.7
.1
.5
.8
.3
-.1
-.8
.6
.2
-.7
-.6
.3
.0
.3
.5
.3
-.2
.6
12×12
dd
At r = 4, 96 trainable params vs 144 full — 1.5× fewer

The math

For a frozen weight matrix W ∈ ℝ^(d × k), LoRA learns two small matrices:

A ∈ ℝ^(d × r)        (small)
B ∈ ℝ^(r × k)        (small)

where r is much smaller than d or k — typically r = 8, 16, or 64. The model uses W + α·A·B instead of W. Only A and B are trained; W is frozen.

The product A·B has shape (d, k) — same as W — but its rank (the number of independent directions it can represent) is at most r. Because r is tiny, this is called a low-rank update. The bet: useful fine-tuning changes lie in a low-rank subspace, so a small A·B captures them with far fewer parameters than rewriting all of W.

LoRA: a frozen W plus a low-rank update α·A·Bfrozen (locked)Wd × k+Ad × r·Br × k=ΔWd × krank ≤ rd·k = 1,048,576never trained(d+k)·r = 32,768 trainedsame shape as Wr = 16 is the bottleneck rank → 32× fewer trainable parameters (here d = k = 1024)
W stays frozen; only the tall A and wide B are learned. Their product is a full d×k update but with rank capped at the tiny r, so the trainable count collapses from d·k to (d+k)·r.
import numpy as np

# Imagine a frozen weight: d=1024, k=1024 -> 1,048,576 params
d, k = 1024, 1024
W = np.random.randn(d, k).astype(np.float32) * 0.01

# Full fine-tuning would train d*k = 1,048,576 params
# LoRA with rank r=16 trains d*r + r*k = 1024*16 + 16*1024 = 32,768 params
# That's 32x fewer!

r = 16
A = np.random.randn(d, r).astype(np.float32) * 0.01
B = np.zeros((r, k), dtype=np.float32)    # init B = 0 so A@B = 0 at start

total_full = d * k
total_lora = d * r + r * k
print(f"Full fine-tune params: {total_full:,}")
print(f"LoRA params (r={r}): {total_lora:,}")
print(f"Reduction: {total_full / total_lora:.1f}x")

# During inference, you can either keep them separate (W + A@B)
# or merge them: W_merged = W + alpha * A @ B  (one-time cost, no runtime overhead)
alpha = 16
delta = alpha * (A @ B) / r       # alpha/r scaling is conventional
print(f"\nDelta shape (same as W): {delta.shape}")
print(f"Delta rank: at most r = {r}")
Full fine-tune params: 1,048,576
LoRA params (r=16): 32,768
Reduction: 32.0x

Delta shape (same as W): (1024, 1024)
Delta rank: at most r = 16

The shapes and counts are what matter: the update A·B is the full (1024, 1024) size of W, yet it is built from only 32,768 trainable numbers — a 32× saving at r = 16, and the saving grows as the matrices get bigger.

For a 7B model with LoRA at r=16 applied to all attention projections, you’re typically training 20–40 million parameters — 0.3% of the total. Optimizer state, gradients, and checkpoints all shrink proportionally.

QLoRA — even more squeeze

QLoRA combines LoRA with 4-bit quantization of the base model. The frozen W is stored in 4-bit (NF4 format), saving 8x memory vs fp32. LoRA adapters stay in bf16 — they’re tiny anyway.

Result: fine-tune a 65B model on a single 48GB GPU. The 2023 QLoRA paper made open-source fine-tuning real.

Approach7B model memory (training)65B model memory (training)
Full fine-tune (fp32)~120 GBimpossible on single GPU
Full fine-tune (bf16)~60 GB~520 GB
LoRA (bf16 base)~16 GB~140 GB
QLoRA (4-bit base)~6 GB~48 GB

LoRA vs QLoRA — which should you reach for?

Both train the same tiny adapters. The only thing that changes is how the frozen base model is stored while you train — and that one choice decides whether the job fits on your GPU, at the cost of a little speed when it does.

LoRAQLoRA
Base stored asbf16 — 2 bytes/param4-bit NF4 — 0.5 bytes/param
7B training VRAM~16 GB~6 GB
65B training VRAM~140 GB (2× A100)~48 GB (one A100)
Training speedbaseline~30% slower — weights de-quantized every forward pass
Final qualitybaselinewithin ~1 point on most benchmarks
Adapters producedidenticalidentical

The mental model: QLoRA buys memory with time. Squeezing the frozen weights to 4 bits frees the VRAM to fit a bigger model — but the GPU must de-quantize them back to bf16 on every step, so each step runs a little slower.

So the rule is simple:

  • Fits in bf16? Use plain LoRA — it’s faster and marginally higher quality. If your 7B fine-tune sits comfortably on a 24 GB card, there’s no reason to quantize the base.
  • Memory-bound? Use QLoRA — when bf16 would overflow the GPU (a 13B on a 16 GB card, a 70B on a single A100), 4-bit is what lets the job start at all. A slower run beats one that can’t begin.

In code

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

# 4-bit quantization for the base model
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
)

model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Llama-3.1-8B",
    quantization_config=bnb_config,
    device_map="auto",
)

# LoRA config
lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
)

model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# trainable params: 13M | total params: 8B | trainable%: 0.17%

lora_alpha is the scaling factor; the effective update is (alpha/r) * A @ B. A common choice is alpha = 2 * r. target_modules is the list of layer names where LoRA adapters get attached. Conventional choice: all four attention projections (q_proj, k_proj, v_proj, o_proj). More aggressive: also include MLP layers (gate_proj, up_proj, down_proj).

After training, you save just the adapters:

model.save_pretrained("./lora-adapter")   # tens of MB, not GB

At inference time you load the base model and apply the adapter:

from peft import PeftModel

base = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-3.1-8B")
model = PeftModel.from_pretrained(base, "./lora-adapter")

Or merge for deployment:

merged = model.merge_and_unload()
# `merged` is now a normal HF model — A@B has been added to W

LoRA, QLoRA, and the future

Variants keep appearing — DoRA, VeRA, AdaLoRA — each squeezing a bit more quality, but the core idea is unchanged. The PEFT library implements them all behind a unified interface.

For production today: QLoRA is the default fine-tuning recipe unless you specifically need full fine-tuning.

In one breath

  • Full fine-tuning updates every weight (plus optimizer state and a full-size checkpoint per domain) — expensive and unwieldy.
  • LoRA freezes W and learns a low-rank update α·A·B (with A: d×r, B: r×k, r tiny), betting useful fine-tuning changes live in a low-rank subspace — so you train under 1% of the parameters and ship adapters of tens of MB.
  • QLoRA stores the frozen base in 4-bit NF4 while adapters stay bf16 — it buys memory with a little time (de-quantize each step), fitting a 65B fine-tune on a single 48 GB GPU. Rule: fits in bf16 → plain LoRA; memory-bound → QLoRA.
  • Adapters merge back into the base (merge_and_unload()) for zero-overhead inference; and fine-tune at all only when prompting + RAG plateau — its strongest case is cutting inference cost by matching a frontier model on your distribution with a small open one.

Quick check

Quick check

0/2
Q1LoRA with rank r=16 on a (4096, 4096) weight matrix: how many trainable parameters per layer?
Q2What does QLoRA add on top of LoRA?

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
How does LoRA work and why is it preferred over full fine-tuning for large models?

LoRA (Low-Rank Adaptation) freezes the original model weights and injects trainable low-rank decomposition matrices into attention layers. This cuts the number of trainable parameters by 100x-1000x while matching or approaching full fine-tuning quality, making it practical on a single GPU.

What is catastrophic forgetting and how does parameter-efficient fine-tuning help avoid it?

Catastrophic forgetting is when fine-tuning on a new task overwrites weights and erases previously learned capabilities. Parameter-efficient methods like LoRA freeze the base weights and train only small added parameters, preserving the original knowledge while adapting behavior, and techniques like lower learning rates, replay data, and adapter isolation further reduce forgetting.

What distinguishes QLoRA from LoRA?

QLoRA quantizes the frozen base model to 4-bit using NF4 and double quantization, then trains LoRA adapters on top, with paged optimizers to handle memory spikes. This dramatically lowers the memory footprint, enabling fine-tuning of large models on a single consumer GPU with little quality loss versus 16-bit LoRA.

When should you use prompt engineering versus fine-tuning to adapt an LLM?

Prompt engineering is the right starting point when the task can be described in natural language, the required knowledge already exists in the base model, and iteration speed matters — no training required. Fine-tuning is warranted when you need consistent output format at scale, domain-specific style that prompts cannot reliably impose, or when latency and token costs from long system prompts are prohibitive.

What is LoRA and how does it make fine-tuning parameter-efficient?

LoRA freezes the pretrained weights and injects small trainable low-rank matrices into selected layers, learning the weight update as their low-rank product. This trains a tiny fraction of parameters, slashing memory and storage while approximating full fine-tuning, and the adapters can be merged back at inference.

Why are smaller language models (SLMs) sometimes preferable to larger ones?

Smaller models win on latency, inference cost, on-device deployment, and fine-tuning feasibility. When trained on high-quality, curated data and aligned for a narrow task, a 7B–13B model can match or exceed a general-purpose 70B+ model on that specific workload while using a fraction of the compute budget.

Related lessons

Explore further

Skip to content