LoRA and QLoRA, mathematically

Full fine-tuning of a 4B model on a 16GB card is a non-starter, and the arithmetic says so before I even try: the weights alone in BF16 are 8 GiB, and the Adam optimizer state (two FP32 moments plus an FP32 master copy) is another four to six times the weight footprint, so I am tens of GiB over budget before a single activation is stored. LoRA and its quantized cousin QLoRA are the two ideas that make training on this hardware possible at all, and they are not hacks, they rest on a specific and testable hypothesis about the geometry of fine-tuning updates and a specific and clever data format for 4-bit weights. This chapter derives both: the low-rank reparameterization that turns a billion-parameter update into a few-million-parameter one, and the NF4 quantization that squeezes the frozen base into 4 bits without the usual catastrophe. Then it does the thing the whole book is about, a rank sweep measured with real eval deltas through the chapter 3.7 machinery, so the choice of rank stops being folklore and becomes a number with a confidence interval.

Theory

The low-rank update hypothesis

Fine-tuning changes a weight matrix into . The hypothesis behind LoRA is that for adapting a pretrained model to a downstream task, the update has low intrinsic rank, even though itself is full-rank. Intuitively, pretraining already learned the hard, high-dimensional structure of language; adapting it to "answer instructions" or "reason in this format" is a comparatively low-dimensional nudge, a rotation into a few task-relevant directions rather than a wholesale rewrite. If really lives near a rank- subspace with , then I never need to represent the full update, I can factor it.

The LoRA reparameterization

Freeze the pretrained weight and constrain the update to be a product of two thin matrices,

The adapted layer computes, for input ,

Only and are trainable; never receives a gradient. Count the parameters: the full update has entries, the factored update has . For an illustrative attention projection () with , that is versus , an 80-times reduction per matrix, and it compounds over every targeted layer. Across the whole model, LoRA typically makes well under 1% of parameters trainable.

Two design choices make equation (6.3.2) behave. Initialization: is initialized from a small random Gaussian and is initialized to zero, so at step 0, and the adapted model is exactly the pretrained model. Training starts from the base behavior and moves away, rather than starting from a random perturbation, which is why LoRA fine-tunes are stable from the first step. The scaling: the factor decouples the learning-rate-like magnitude of the update from the rank. Without it, doubling would roughly double the norm of for the same scale, so you would have to re-tune the learning rate every time you changed rank. With it, sets the effective update strength and sets the capacity, and the two knobs are (approximately) independent. The gradient only flows into and :

and crucially the optimizer state (the memory-hungry part) is now sized to and , not to . That is the whole memory win: I still hold in VRAM to compute the forward pass, but I do not hold gradients or Adam moments for it.

Rank, alpha, and target modules

Three knobs follow from equation (6.3.1). Rank is the capacity of the update: how many independent directions can move in. Too small and the adapter underfits (it cannot express the task nudge); too large and you spend memory and risk overfitting a small dataset, with diminishing returns once exceeds the true intrinsic rank of the update. Common values are 8 to 64, and the rank sweep in the lab is exactly the experiment that tells you where the returns flatten for your task. Alpha is the update strength via the scaling; the common convention keeps the effective scale roughly constant as you vary rank, which is why the lab holds that ratio fixed so the sweep isolates capacity. Target modules is the set of weight matrices you attach adapters to. The attention projections (q_proj, k_proj, v_proj, o_proj) are the classic minimal set; adding the MLP projections (gate_proj, up_proj, down_proj) covers where most of a transformer's parameters and most of its task-specific capacity actually live, at more memory. For reasoning fine-tunes I target all seven, because the MLP is where a lot of the procedural knowledge sits.

NF4: quantizing the frozen base

LoRA shrinks the trainable footprint but I still hold the full in VRAM. QLoRA's second idea is to hold it in 4 bits instead of 16, using a format designed for the specific distribution neural-network weights actually follow. Pretrained weights are approximately zero-mean Gaussian, and a naive linear 4-bit grid (16 evenly spaced levels) wastes most of its levels in the tails where almost no weights live while under-resolving the dense center. NF4 (4-bit NormalFloat) fixes this by placing its 16 levels at the quantiles of a normal distribution, so each level is equally likely to be used, which is information-theoretically optimal for a Gaussian source.

The NF4 construction

NF4 is a per-block, quantile-based, 4-bit code. Build it in three moves.

1. Quantile levels. For a source with CDF , an information-optimal -level code puts its levels at evenly spaced quantiles. NF4 uses levels. To guarantee an exact zero (so that a true zero weight quantizes to zero, which preserves sparsity and symmetry), the levels are built from two one-sided quantile grids, levels for the negative side and for the non-negative side, merged and de-duplicated at zero:

evaluated on each side and then normalized so the levels span . The result is a fixed 16-entry lookup table, denser near zero and sparser in the tails, exactly matching where Gaussian weights concentrate.

2. Per-block absmax scaling. Real weights are not unit-variance, and their scale varies across the tensor, so NF4 normalizes in small blocks. Partition the weight tensor into contiguous blocks of elements (QLoRA uses ). For each block, compute the absolute maximum and store it as the block's scale. Each weight is normalized to and mapped to the nearest quantile level,

Storage per weight is 4 bits for the index, and the block shares one scale.

3. Double quantization. The block scales are themselves numbers, one FP32 per 64 weights, which is bits per weight of overhead, not negligible. Double quantization quantizes the scales too: it takes the FP32 block scales, groups 256 of them, and quantizes that group to 8-bit with its own single FP32 scale. The scale overhead drops from 32 bits per 64 weights to bits per weight, saving roughly bits per weight, about GiB on a 8B-parameter model, which on a 16GB card is real.

The net cost is bits per weight, and the reconstruction is used only in the forward and backward matmuls; the stored master remains the 4-bit code. Gradients flow through the frozen untouched (it is frozen) and into the BF16 LoRA adapters via equation (6.3.3), which is why QLoRA keeps 16-bit-quality adapters on top of a 4-bit base.

4B QLoRA on the RTX 5080 16GB, line by line

Take Qwen3-4B: call it parameters. All figures in GiB ( bytes). "Measured on the baseline machine, record value, date, driver" applies to every peak-VRAM claim; the numbers below are the arithmetic floor you reconcile against.

  • Frozen base in NF4: bits/param bytes/param. B GiB.
  • LoRA adapters (BF16), , all 7 target modules: roughly of trainable is a loose upper bound; concretely, summing over the targeted matrices lands near M params. Take B MB GiB.
  • Adapter gradients (BF16): same shape as adapters, GiB.
  • Adam state on adapters (FP32 , , + FP32 master): B GB GiB.
  • Activations for the backward pass: the variable term, set by batch size sequence length hidden layers, and slashed by gradient checkpointing. For batch 2, seq 2048, checkpointed, budget on the order of GiB.
  • CUDA context + kernels + fragmentation: GiB of overhead you do not control.

Floor sum: GiB, comfortably inside 16 GiB, with the activation term as the knob you turn (batch size, sequence length, checkpointing) if you approach the ceiling. Contrast full BF16 fine-tuning: weights GiB + Adam state GiB + activations, which is roughly + GiB and simply does not fit. That gap, GiB versus GiB, is why every training lab in this book is QLoRA.

The merge math, and when merging changes behavior

At inference I often want to fold the adapter back into the weights so there is no runtime overhead. From equation (6.3.2), the merge is exact in full precision:

If is stored in BF16, equation (6.3.6) is a lossless (to BF16 rounding) fold and the merged model is behaviorally identical to the adapter-on-base model. The subtlety is QLoRA. There, is stored in NF4, and the honest merge is : you must dequantize the base to BF16, add the BF16 update, and keep the result in BF16, because has structure far finer than the NF4 grid and re-quantizing the sum to NF4 would round the delta away. So merging a QLoRA adapter changes the base dtype (NF4 to BF16), which quadruples the base footprint and is exactly what you want for a clean deployable checkpoint, but it means "merge then re-quantize to 4-bit" is not the same model as "keep the adapter separate on the 4-bit base," and I have watched people lose their whole fine-tune to that round-trip. Keep the adapter separate for iteration; merge to BF16 only when you freeze a release.

Tooling

PEFT implements equations (6.3.1)–(6.3.3) as a LoraConfig (rank r, lora_alpha, target_modules, dropout) wrapped around a base model; bitsandbytes implements the NF4 code of equations (6.3.4)–(6.3.5) with double quantization behind load_in_4bit=True and the bnb_4bit_* flags; and Unsloth fuses the two so the NF4 dequant and the LoRA matmul happen in one custom kernel rather than two eager ops, which is where its speed and its extra memory headroom come from. merge_and_unload() on a PEFT model performs equation (6.3.6). The reused evaluation piece is chapter 3.7's evalstats: after each rank's fine-tune I run the frozen thesis task suite and pass paired per-item scores to evalstats.bootstrap_paired_diff (and evalstats.mcnemar for the paired p-value) to get a delta-versus-base with a 95% bootstrap CI, so the sweep produces intervals, not point estimates.

Lab

The lab sweeps LoRA rank over , fine-tunes the 4B QLoRA model at each rank on the same small reasoning-flavored set, evaluates each on the frozen thesis suite, and reports the eval delta versus the untuned base with bootstrap confidence intervals. The artifact is a CSV and a plot showing where accuracy stops improving with rank, which is the empirical version of "find the intrinsic rank of your task's update."

This is a uv project in the training environment. From the repo root:

uv init labs/lora-rank-sweep
cd labs/lora-rank-sweep
uv add "unsloth[cu124]" trl peft datasets matplotlib
# evalstats is the chapter-3.7 module, installed editable from the repo:
uv add --editable ../../packages/evalstats
"""LoRA rank sweep with eval deltas via the chapter-3.7 evalstats module.

For each rank in RANKS: QLoRA-SFT a 4B model, score it on the frozen thesis
suite, and compute the paired bootstrap delta vs the untuned base. Writes
artifacts/rank_sweep.csv and artifacts/rank_sweep.png.
"""
from pathlib import Path
import csv

import torch
from datasets import load_dataset
from trl import SFTConfig, SFTTrainer
from unsloth import FastLanguageModel

# chapter-3.7 machinery: paired bootstrap CI + McNemar over per-item scores.
import evalstats as es
# chapter-3.9 frozen suite: returns per-item 0/1 scores for a model.
from thesis_suite import score_model

MODEL = "unsloth/Qwen3-4B-Base"
RANKS = [4, 8, 16, 32, 64]
MAX_SEQ = 2048
OUT = Path("artifacts")
OUT.mkdir(exist_ok=True)


def train_one(rank: int):
    model, tok = FastLanguageModel.from_pretrained(
        model_name=MODEL, max_seq_length=MAX_SEQ, load_in_4bit=True, dtype=None,
    )
    model = FastLanguageModel.get_peft_model(
        model, r=rank, lora_alpha=2 * rank,   # hold alpha = 2r fixed
        lora_dropout=0.0,
        target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
                        "gate_proj", "up_proj", "down_proj"],
        use_gradient_checkpointing="unsloth", random_state=3407,
    )
    ds = load_dataset("yahma/alpaca-cleaned", split="train[:3000]")
    # Prompt/completion message lists: SFTTrainer renders + masks the prompt.
    ds = ds.map(lambda ex: {
        "prompt": [{"role": "user", "content": ex["instruction"]}],
        "completion": [{"role": "assistant", "content": ex["output"]}]},
        remove_columns=ds.column_names)
    cfg = SFTConfig(
        output_dir=str(OUT / f"run_r{rank}"), max_seq_length=MAX_SEQ,
        completion_only_loss=True,
        per_device_train_batch_size=2, gradient_accumulation_steps=8,
        num_train_epochs=1, learning_rate=2e-4, warmup_ratio=0.03,
        lr_scheduler_type="cosine", bf16=True, logging_steps=25,
        report_to="none", seed=3407,
    )
    SFTTrainer(model=model, args=cfg, train_dataset=ds).train()
    FastLanguageModel.for_inference(model)
    return model, tok


def main() -> None:
    # Baseline scores once, from the untuned base model.
    base, tok = FastLanguageModel.from_pretrained(
        model_name=MODEL, max_seq_length=MAX_SEQ, load_in_4bit=True, dtype=None)
    FastLanguageModel.for_inference(base)
    base_scores = score_model(base, tok)          # list[int], per item
    del base
    torch.cuda.empty_cache()

    rows = []
    for rank in RANKS:
        model, tok = train_one(rank)
        tuned_scores = score_model(model, tok)     # paired, same item order
        # Estimate(point, ci_low, ci_high): paired bootstrap of the delta.
        est = es.bootstrap_paired_diff(tuned_scores, base_scores, level=0.95)
        # McNemar for the paired p-value on binary correctness.
        mc = es.mcnemar(tuned_scores, base_scores, exact=True)
        rows.append({
            "rank": rank,
            "base_acc": sum(base_scores) / len(base_scores),
            "tuned_acc": sum(tuned_scores) / len(tuned_scores),
            "delta": est.point, "ci_lo": est.ci_low, "ci_hi": est.ci_high,
            "p_value": mc.pvalue,
        })
        print(f"r={rank:3d}  delta={est.point:+.3f}  "
              f"95% CI [{est.ci_low:+.3f}, {est.ci_high:+.3f}]  p={mc.pvalue:.3f}")
        del model
        torch.cuda.empty_cache()

    csv_path = OUT / "rank_sweep.csv"
    with csv_path.open("w", newline="") as f:
        w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
        w.writeheader()
        w.writerows(rows)

    # Plot delta vs rank with CI whiskers.
    import matplotlib.pyplot as plt
    xs = [r["rank"] for r in rows]
    ys = [r["delta"] for r in rows]
    lo = [r["delta"] - r["ci_lo"] for r in rows]
    hi = [r["ci_hi"] - r["delta"] for r in rows]
    plt.errorbar(xs, ys, yerr=[lo, hi], marker="o", capsize=4)
    plt.axhline(0, color="grey", lw=0.8)
    plt.xscale("log", base=2)
    plt.xlabel("LoRA rank r"); plt.ylabel("accuracy delta vs base")
    plt.title("Rank sweep: eval delta with 95% bootstrap CI")
    plt.tight_layout(); plt.savefig(OUT / "rank_sweep.png", dpi=150)
    print(f"Artifacts: {csv_path.resolve()} and {(OUT/'rank_sweep.png').resolve()}")


if __name__ == "__main__":
    main()

Run it:

uv run python sweep.py

Note

score_model must return per-item scores in the same item order for the base and every tuned model, because bootstrap_paired_diff and mcnemar compare matched pairs, that pairing is the entire reason the CI is tight enough to see a small delta (chapter 3.7 derives why paired resampling beats comparing two independent accuracy numbers). If you shuffle the eval set between runs, or the suite's sampling is nondeterministic, the pairing breaks and the CIs blow up. Freeze the suite (chapter 3.9 froze v1.0 for exactly this) and fix the eval seed so the only thing changing between runs is the rank.

What you should see. The CSV and plot show the eval delta versus the untuned base rising with rank and then flattening, the empirical signature of the low-rank hypothesis: once exceeds the task's intrinsic update rank, more capacity buys nothing and the confidence intervals of adjacent ranks overlap. Typically the delta from to is real (CI excludes zero) and the delta from to is within noise (CIs overlap), which tells you is the honest choice for this task, more is just memory. All five fine-tunes fit in 16GB by the vram-budget above, with peak VRAM creeping up only slightly with rank because the adapter and its optimizer state grow linearly in but are tiny next to the frozen base. Record peak VRAM per rank and total sweep wall-clock (measured on the baseline machine, record value, date, driver). The headline artifact is a plot that turns "what rank should I use" from a Reddit argument into a measured curve with error bars.

Note

This chapter is math-forward and mostly self-contained, but read it against [RLHF] ch. 4's treatment of parameter-efficient fine-tuning for the framing of why PEFT methods are the default in the post-training pipeline, and cross-reference the quantization theory in Part II ch. 3 of this book, where the general quantization-error math behind equation (6.3.5) is derived; NF4 is the special case of that theory tuned to a Gaussian weight prior.

Note

"You are not fine-tuning four billion parameters. You are fine-tuning about thirty million, and the other 99 percent are frozen and squeezed into four bits each." A post that derives the two tricks that make single-GPU fine-tuning real: the low-rank hypothesis (equation 6.3.1, fine-tuning is a low-dimensional nudge, so factor it) and NF4 (equation 6.3.4, put your 16 quantization levels at the quantiles of a bell curve because that is where the weights actually are). End on the rank sweep with error bars, the measurement that shows the update really is low-rank, and the punchline that the whole 40-GiB-to-7-GiB gap between what fits and what does not is just these two ideas stacked.