n4nAI

LoRA explained: fine-tuning LLMs efficiently

LoRA fine-tuning explained: low-rank adaptation mechanics, rank selection, memory savings, and practical trade-offs for engineers shipping LLM customization.

n4n Team6 min read1,245 words

Audio narration

Coming soon — every post will get a voice note here.

LoRA (Low-Rank Adaptation) freezes a pretrained model’s weights and injects trainable low-rank matrices into selected layers, reducing trainable parameters by orders of magnitude while preserving most full fine-tuning performance. Instead of updating all 7B or 70B parameters, you train two small matrices per adapted layer — typically adding 0.1–1% extra parameters. The result: fine-tuning that fits on a single GPU, finishes in hours instead of days, and produces adapters you can swap at inference time without reloading the base model.

How LoRA works

The core insight: weight updates during fine-tuning tend to have low intrinsic rank. A full weight matrix $W \in \mathbb{R}^{d \times k}$ gets an additive update $\Delta W$. LoRA constrains $\Delta W = BA$ where $B \in \mathbb{R}^{d \times r}$ and $A \in \mathbb{R}^{r \times k}$ with $r \ll \min(d, k)$. During training, only $A$ and $B$ receive gradients; $W$ stays frozen.

# Conceptual LoRA layer (PyTorch-style)
class LoRALinear(nn.Module):
    def __init__(self, in_features, out_features, rank=16, alpha=32, dropout=0.0):
        super().__init__()
        self.base = nn.Linear(in_features, out_features, bias=False)
        self.base.weight.requires_grad = False  # frozen

        self.lora_A = nn.Parameter(torch.zeros(rank, in_features))
        self.lora_B = nn.Parameter(torch.zeros(out_features, rank))
        self.scaling = alpha / rank
        self.dropout = nn.Dropout(dropout)

        # init: A ~ N(0, 1), B = 0 so initial output = base only
        nn.init.normal_(self.lora_A, std=1/rank)
        nn.init.zeros_(self.lora_B)

    def forward(self, x):
        base_out = self.base(x)
        lora_out = (self.dropout(x) @ self.lora_A.T @ self.lora_B.T) * self.scaling
        return base_out + lora_out

At inference, you can fuse the adapter into the base weights: $W’ = W + \frac{\alpha}{r}BA$. This adds zero latency — the model runs at base speed. You can also keep adapters separate and hot-swap them per request, which is useful for multi-tenant serving.

Where the matrices go

Most implementations target attention projections (q, k, v, o) and sometimes MLP up/down projections. The original LoRA paper adapted only query and value projections in attention; later work (LoRA+, DoRA, VeRA) showed adapting all linear layers often helps. A typical LLaMA-7B with rank 16 on all linear layers adds ~4.2M parameters (0.06% of base).

{
  "model": "llama-7b",
  "base_params": 6.74e9,
  "lora_config": {
    "rank": 16,
    "alpha": 32,
    "target_modules": ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]
  },
  "trainable_params": 4_194_304,
  "trainable_pct": 0.062
}

Why it matters

Memory and compute

Full fine-tuning a 7B model in FP16 needs ~56 GB GPU memory for optimizer states alone (AdamW: 2x params for moments + gradients + model). LoRA at rank 16 drops that to ~6–8 GB — feasible on a single A10G or 24 GB consumer card. Training time scales similarly: fewer parameters, less gradient computation, smaller optimizer step.

Adapter composability

Because adapters are additive and small (tens of MB), you can:

  • Store dozens of task-specific adapters alongside one base model
  • Load the base once, swap adapters per request
  • Merge multiple adapters via weighted sum: $W’ = W + \sum_i \lambda_i \Delta W_i$

This enables practical multi-tenant serving without per-tenant model copies.

Portability

A LoRA adapter trained on one base model checkpoint transfers to other checkpoints of the same architecture (e.g., LLaMA-2-7B → LLaMA-2-7B-Chat) with minimal degradation. You can also stack LoRA on top of other PEFT methods (prefix tuning, adapters) — though stacking adds complexity.

Rank selection and scaling

Rank $r$ controls capacity. Common values: 8, 16, 32, 64, 128. Higher rank = more capacity but diminishing returns and more memory. The scaling factor $\alpha/r$ matters: $\alpha$ is typically set to $2r$ or $r$, making effective learning rate for LoRA parameters $\eta \cdot \alpha/r$. If you change rank, adjust $\alpha$ proportionally to keep effective LR stable.

# Practical rank sweep (pseudocode)
for rank in [8, 16, 32, 64, 128]:
    config = LoraConfig(
        r=rank,
        lora_alpha=rank * 2,  # alpha = 2r is a solid default
        target_modules=["q_proj", "v_proj"],  # start minimal
        lora_dropout=0.05,
        bias="none",
    )
    trainer = Trainer(model, config, ...)
    trainer.train()
    eval_metrics = trainer.evaluate()
    log(rank, eval_metrics)

Empirical pattern: rank 16–32 often hits 95%+ of full fine-tuning performance on downstream tasks. Rank 8 works for simple style/format adaptation. Rank 64+ rarely justified unless the task is far from pretraining distribution (e.g., coding on a base model never exposed to code).

LoRA+ and stabilized variants

LoRA+ uses different learning rates for $A$ and $B$ (typically $\eta_B = \eta_A / \text{rank}$) to balance gradient magnitudes. DoRA (Weight-Decomposed Low-Rank Adaptation) separates magnitude and direction updates, matching full fine-tuning more closely at similar parameter count. VeRA shares frozen random projections across layers and only learns per-layer scaling vectors — extremely parameter-efficient (0.01%) but slightly lower ceiling.

Concrete example: instruction tuning LLaMA-3-8B

Scenario: adapt a base LLaMA-3-8B to follow chat formatting and refuse unsafe requests. Dataset: 50k examples (Alpaca + safety mix). Hardware: 1× H100 80GB.

# Using Hugging Face PEFT + TRL
python train.py \
  --model_name_or_path meta-llama/Meta-Llama-3-8B \
  --dataset_name tatsu-lab/alpaca \
  --peft_config r=32,lora_alpha=64,target_modules=q_proj,v_proj,k_proj,o_proj \
  --learning_rate 2e-4 \
  --num_train_epochs 3 \
  --per_device_train_batch_size 4 \
  --gradient_accumulation_steps 4 \
  --bf16 \
  --output_dir llama3-8b-lora-alpaca

Results typical for this setup:

  • Trainable params: ~6.3M (0.08%)
  • Peak GPU memory: ~18 GB (vs ~80 GB for full FT)
  • Training time: ~2.5 hours
  • Eval: MT-Bench 7.2 (base: 6.1), GSM8K 48% (base: 32%)

The adapter checkpoint is ~25 MB. At inference, you load the base model once and apply the adapter per request — or merge it for zero-overhead deployment.

Merging for deployment

from peft import PeftModel

base = AutoModelForCausalLM.from_pretrained("meta-llama/Meta-Llama-3-8B", torch_dtype=torch.bfloat16)
adapter = PeftModel.from_pretrained(base, "llama3-8b-lora-alpaca")
merged = adapter.merge_and_unload()  # fuses LoRA into base weights
merged.save_pretrained("llama3-8b-alpaca-merged")

The merged model runs at native speed with no PEFT dependency. Trade-off: you lose hot-swapping. For single-tenant production, merge. For multi-tenant or A/B testing, keep separate.

Common misconceptions

“LoRA always matches full fine-tuning”

False. On tasks requiring substantial new knowledge (e.g., learning a new language, memorizing large proprietary corpora), LoRA underperforms full fine-tuning. The low-rank constraint limits how much the model’s internal representations can shift. For knowledge injection, consider full fine-tuning, LoRA with higher rank (128–256), or hybrid approaches (LoRA + continued pretraining).

“You only need to adapt attention”

Early papers adapted only q/v. For instruction tuning and reasoning tasks, adapting MLP layers (gate/up/down) often adds 1–3 points on benchmarks. The parameter cost is modest (~2x). Default to all linear layers unless memory forces you to restrict.

“Alpha doesn’t matter if you tune learning rate”

$\alpha$ controls initialization scale relative to base weights. With $\alpha = r$, initial LoRA output variance matches base layer output variance (assuming He init on base). If you set $\alpha=1$ and crank LR, you change the optimization trajectory — not equivalent. Treat $\alpha$ as part of the architecture, not a hyperparameter to absorb LR changes.

“LoRA works the same at any scale”

At 70B+, rank 16 on all layers adds ~40M params (0.06%) — still small but optimizer states grow. Gradient checkpointing becomes essential. Some teams use rank 8 or target only attention at 70B+ to keep memory manageable. The scaling laws for LoRA rank vs model size are not fully characterized; treat as empirical.

“Quantization and LoRA don’t mix”

They do. QLoRA (4-bit base + LoRA) is standard practice. The base loads in 4-bit NF4, LoRA adapters stay in BF16/FP32 for training. At inference, you can quantize the merged model to 4-bit or 8-bit. The PEFT library handles this automatically:

from transformers import BitsAndBytesConfig

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,
)

model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Meta-Llama-3-70B",
    quantization_config=bnb_config,
    device_map="auto",
)
model = prepare_model_for_kbit_training(model)
model = get_peft_model(model, lora_config)

When to choose LoRA vs alternatives

Scenario Recommendation
Instruction tuning, style transfer, format compliance LoRA (rank 16–32)
New language / massive domain knowledge Full FT or continued pretraining + LoRA
Multi-tenant serving, rapid A/B LoRA (unmerged, hot-swap)
Single-tenant, latency-critical Merge LoRA, then quantize
Extreme parameter budget (<0.01%) VeRA or LoRA rank 4–8
Need exact full-FT parity DoRA or full FT

Practical checklist

  1. Start with rank 16, alpha 32, target all linear layers. This is the modern default that works across 7B–70B.
  2. Use BF16 compute, FP32 for LoRA params. Prevents gradient underflow in low-rank matrices.
  3. Set dropout 0.05–0.1 on LoRA path. Regularizes the adapter; base model stays deterministic.
  4. LR 1e-4 to 5e-4 for LoRA params. Base model LR = 0 (frozen). Cosine decay with 10% warmup.
  5. Gradient checkpointing on. Saves 30–40% activation memory.
  6. Eval every 500 steps. LoRA can overfit fast on small datasets; early stopping matters.
  7. Save adapter only. trainer.save_model() with PEFT writes adapter_model.safetensors + adapter_config.json — ~25 MB for 7B.
  8. Test merge before deploy. merge_and_unload() can surface dtype mismatches (e.g., base in BF16, adapter in FP32).

Closing note

LoRA turned fine-tuning from a research-cluster operation into a standard engineering workflow. The technique is mature, the tooling (PEFT, TRL, Axolotl, Unsloth) is production-grade, and the trade-offs are well understood. If you’re customizing an LLM in 2024, LoRA should be your first attempt — not because it’s trendy, but because it solves the right constraints: memory, time, and operational flexibility.

Tagslorafine-tuningpeftdefinition

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All fine-tuning fundamentals posts →