LoRA and QLoRA in depth: rank, alpha, target layers and 4-bit base models
How low-rank adapters work and why they are so small, with the maths run in code: parameter counts for an 8B model at every rank, the adapter-equals-merged-weights check, how much a low-rank update captures, how to choose rank, alpha and target modules, and how QLoRA stores the frozen base in 4 bits.
LoRA is the technique that made fine-tuning a large model something an individual can do. You freeze the pretrained weights and train two small matrices beside each large one. This lesson works through what that means numerically, how to choose its settings, and what QLoRA adds. You met the idea briefly in Fine-tuning, LoRA and instruction tuning and the decision of whether to fine-tune at all in When to fine-tune. Everything here runs in plain Python.
#The idea
A weight matrix W of shape out × in takes an input x to Wx. Full fine-tuning changes every entry of W. LoRA keeps W frozen and learns a small correction instead:
h = W·x + (alpha / r) · B·(A·x)
W : out × in frozen pretrained weights
A : r × in trainable, starts random (small Gaussian)
B : out × r trainable, starts at zero
r : the rank, usually 8 to 64
alpha : a scaling constant; the update is multiplied by alpha / rA squeezes the input down to r numbers and B expands it back. Their product B·A is a full-sized out × in matrix, but it can only have rank r, so it is described by r × (in + out) numbers instead of in × out.
Full matrix
16.8M
LoRA params
65.5K
Share trained
0.39%
W stays frozen. Only A (r×d) and B (d×r) train; the effective weight is W + B·A. Rank r is the knob; adapters stay below 1% of the matrix for typical settings.
#How small is small?
For a Llama-3.1-8B-shaped model (hidden size 4096, 32 layers, 8 KV heads, feed-forward width 14336), the cost of an adapter is r × (in + out) summed over every matrix you adapt:
hidden, layers, kv_dim, inter = 4096, 32, 1024, 14336
mats = {"q_proj": (hidden, hidden), "k_proj": (hidden, kv_dim), "v_proj": (hidden, kv_dim), "o_proj": (hidden, hidden),
"gate_proj": (hidden, inter), "up_proj": (hidden, inter), "down_proj": (inter, hidden)}
def lora_params(r, targets): return layers * sum(r * (mats[t][0] + mats[t][1]) for t in targets)base ≈ 8.03B params
rank attention only all linear layers
4 3.4M ( 0.042%) 10.5M ( 0.131%) adapter file ≈ 21 MB in BF16
8 6.8M ( 0.085%) 21.0M ( 0.261%) adapter file ≈ 42 MB in BF16
16 13.6M ( 0.170%) 41.9M ( 0.522%) adapter file ≈ 84 MB in BF16
64 54.5M ( 0.679%) 167.8M ( 2.089%) adapter file ≈ 336 MB in BF16
128 109.1M ( 1.358%) 335.5M ( 4.179%) adapter file ≈ 671 MB in BF16A rank-16 adapter on every linear layer is about 42 million parameters and an 84 MB file, against 16 GB for the base model in BF16. Three things follow:
- Small to train. Gradients and optimiser state exist only for the adapter, which is where most of the memory saving comes from (how much GPU you need).
- Small to store and ship. You can keep one base model and hundreds of adapters (serving many adapters).
- Cost grows linearly with rank. Doubling
rdoubles the adapter.
Try it with your own shape:
Model
Which layers get adapters
Rank r
Trainable parameters
41.9M
Share of the base model
0.52%
Adapter file (BF16)
84 MB
168 MB in FP32
Base weights (BF16)
16.1 GB
191× larger than the adapter
Doubling the rank doubles the adapter. Adapting more layers matters more than it looks: the feed-forward layers are the widest matrices, so "all linear layers" costs about 3.1× the attention-only adapter.
Each adapted matrix of shape (in × out) adds rank × (in + out) parameters instead of in × out. Shapes are Llama-family models; the base size is the full parameter count.
#The adapter and the merged weights are the same thing
The adapter path computes Wx and B(Ax) separately and adds them. Merging precomputes W + (alpha/r)·B·A once. They give identical outputs:
adapter_out = [w + scale * u for w, u in zip(matvec(W, x), matvec(B, matvec(A, x)))]
merged = [[W[i][j] + scale * BA[i][j] for j in range(d)] for i in range(d)]adapter path vs merged weights, max difference: 1.11e-16
at initialisation (B = 0) the update is exactly: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]The difference is floating-point rounding. So you can keep adapters separate (small files, hot-swappable, a little extra compute) or merge them into a normal model file with no inference overhead. And because B starts at zero, the first training step begins from exactly the base model's behaviour.
#Why a low-rank update can be enough
The bet LoRA makes is that the change needed to adapt a model to a task is low-rank: it lives in a small number of directions. You can see the effect by building an update with a known hidden rank of 4, adding noise, and approximating it with rank 1, 2, 3 and so on:
rank energy captured relative error
1 42.8% 0.757
2 67.5% 0.570
3 86.1% 0.373
4 98.2% 0.135
5 98.3% 0.128
6 98.5% 0.122
7 98.7% 0.116
8 98.8% 0.110Captured energy jumps until the rank reaches the true structure (4), then barely moves: extra rank is fitting noise. Real fine-tuning updates are not this clean, but the same pattern explains why ranks of 8 to 64 work for most tasks, and why very high ranks rarely help. Large domain shifts (for example teaching a model a new programming language from scratch) need more capacity, and that is where full fine-tuning can pull ahead (choosing a method).
#Choosing the settings
| Setting | What it does | Typical start | Notes |
|---|---|---|---|
Rank r |
Capacity of the update | 16 | Raise to 32 or 64 only if validation quality is capacity-limited; lower to 8 for small, simple tasks |
| Alpha | Scales the update by alpha / r |
2 × r (so the scale is 2) or = r |
Change alpha and the learning rate together; some libraries offer rank-stabilised scaling that uses alpha / sqrt(r) so a higher rank does not shrink the update |
| Target modules | Which matrices get adapters | All linear layers | Attention-only is cheaper but usually a little weaker; adapting the feed-forward layers matters for knowledge and style |
| Dropout | Regularises the adapter path | 0.0 to 0.1 | Helps on small datasets |
| Learning rate | Step size | 1e-4 to 3e-4 | Much higher than for full fine-tuning (see training recipe) |
Variants you will see in libraries: rank-stabilised LoRA, weight-decomposed LoRA (DoRA), and LoRA+ (different learning rates for the two matrices). Treat them as options to try after a plain LoRA baseline works, and measure the difference on your own evaluation set.
#QLoRA: a 4-bit frozen base
The frozen base is the biggest thing in memory, and it is never updated. QLoRA stores it at about 4 bits per weight while the adapters stay in 16 bits:
- 4-bit NormalFloat (NF4). Weights in a trained network are roughly bell-shaped, with most values near zero. A good 4-bit format puts more of its 16 levels near zero, where the weights are dense.
- Double quantisation. The per-block scale factors are themselves quantised, saving a fraction of a bit more per weight.
- Paged optimisers. Optimiser state can spill to CPU memory during memory spikes instead of crashing.
- Dequantise on the fly. Each layer's 4-bit weights are expanded to BF16 just for the matrix multiply, so gradients flow through to the adapters in higher precision.
The reason a bell-shaped grid helps can be checked directly. Quantise 20,000 normal-looking weights with 16 evenly spaced levels, and with 16 levels placed at equal-probability quantiles of the normal distribution:
4-bit uniform levels mean squared error 0.0218
4-bit quantile levels mean squared error 0.0120 (1.8x lower)NF4 is built on this idea; its exact level values are fixed constants in the library, so this is an illustration of the principle rather than a copy of NF4. In practice QLoRA gets close to the quality of 16-bit LoRA on most tasks, at the cost of slower steps (dequantising every layer) and a small extra quantisation error. If the model already fits in 16 bits on your GPU, plain LoRA is simpler and faster.
#Merge, keep separate, or quantise afterwards
- Merge into a 16-bit base for the best quality and no overhead, then quantise the merged model for serving (quantisation in depth).
- Do not merge into a 4-bit base. Rounding the merged weights back to 4 bits discards part of what the adapter learned. Merge into 16-bit weights first, then quantise.
- Keep adapters separate when you serve many variants from one base.
#Common mistakes
- Raising rank to fix a data problem. If outputs are wrong because the examples are inconsistent, no rank will fix it (data preparation).
- Changing
alphaand the learning rate without noticing both scale the update. - Adapting only attention layers and concluding LoRA cannot learn the task.
- Using the full-fine-tuning learning rate (around 1e-5) for LoRA, which then barely moves.
- Merging into a quantised base.
- Never testing a plain LoRA baseline before trying variants.
#Exercise
For the 8B shape above:
- How many trainable parameters does a rank-32 adapter on
q_projandv_projonly add? - A rank-16 adapter on all linear layers is stored in FP32 instead of BF16. How large is the file?
- You double the rank from 16 to 32 and keep
alpha = 32fixed. What happens to the scalealpha / r?
Answers:
- Per layer:
q_projis32 × (4096 + 4096) = 262,144;v_projis32 × (4096 + 1024) = 163,840. Total per layer425,984, times 32 layers =13,631,488(about 13.6M). 41.9M × 4 bytes ≈ 168 MB.- The scale halves from 2 to 1, so the update is half as strong. If you want the same strength at the higher rank, raise
alphato 64.
#Going deeper
- Read your library's LoRA configuration and list which module names it targets for your model.
- Next: how much GPU you need to fine-tune.
Quick quiz
+10 XP per correct answer, +25 XP for a perfect score
1. In LoRA, the effective weight is W + (alpha/r) · B · A. Why is B initialised to zeros?
2. A rank-16 LoRA on all linear layers of an 8B model adds about how many trainable parameters?
3. In the lesson's experiment the true update had rank 4 plus noise. What happened as the adapter rank went from 3 to 4 and beyond?
4. What does QLoRA do differently from plain LoRA?
5. Why does a 4-bit grid built from quantiles of a bell curve beat a uniform 4-bit grid on normal-looking weights?
Found this useful?
Send it to someone who is learning the same thing.