Skip to content

LoRA and Parameter-Efficient Fine-Tuning

LoRA for one square linear layer

Frozen base W
d × d parameters
Trainable A
r × d parameters; projects the input into r coordinates
Trainable B
d × r parameters; maps those coordinates back to the output
Forward pass
y = Wx + (α/r) B(Ax)
Optional merge
W′ = W + (α/r)BA; rank(BA) ≤ r

For this layer, the adapter has 2rd trainable parameters. This ratio is not a training-compute ratio.

Under the mixed-precision Adam accounting used below, 70 billion trainable parameters need 1.12 TB of model state before activations. That is the combined nominal capacity of fourteen 80 GB GPUs, with no room yet for temporary buffers or computation.

A fine-tune may need to change behavior without updating every parameter. LoRA trains small matrices alongside frozen weights, reducing trainable state and making task-specific checkpoints much smaller.

Prerequisites

This post assumes you've read:


The full fine-tuning bill

For each trainable parameter during mixed-precision training with Adam, you typically carry:

  • The weight itself in fp16 (2 bytes).
  • Its gradient in fp16 (2 bytes).
  • Two optimizer moments in fp32: mm and vv (8 bytes combined).
  • A master copy of the weight in fp32 for the optimizer update (4 bytes).

That's roughly 16 bytes per trainable parameter, sometimes called the "Adam tax." For a 70B model, you're looking at:

70,000,000,000×16 bytes≈1.1 TB70{,}000{,}000{,}000 \times 16 \text{ bytes} \approx 1.1 \text{ TB}

This 16-byte accounting assumes a particular mixed-precision Adam implementation. Sharding distributes this state across devices; offloading moves it to other memory. Activation checkpointing reduces activation storage, which is not included in the 16 bytes.

Five separate 70B checkpoints require about 700 GB of 16-bit weight storage. Keeping every copy resident also requires that weight capacity across devices, plus serving overhead. Sharing a base and storing smaller task-specific updates can reduce the duplication.


Restricting the update

Aghajanyan et al. found that some language-model fine-tuning tasks could be learned in a low-dimensional parameter subspace. That motivates restricting updates, but it does not prove that each layer’s update matrix has low rank.

LoRA makes that additional modeling choice: represent an update as the product of two smaller matrices and test whether the constraint is sufficient for the task.


The math, stripped down

Take any linear layer in the model:

y=Wx,W∈Rd×dy = W x, \quad W \in \mathbb{R}^{d \times d}

In full fine-tuning, we let WW move around freely. The update is ΔW\Delta W, also d×dd \times d. That's d2d^2 trainable parameters per linear layer.

LoRA does this instead:

W′=W+ΔW,ΔW=BAW' = W + \Delta W, \quad \Delta W = B A

where B∈Rd×rB \in \mathbb{R}^{d \times r} and A∈Rr×dA \in \mathbb{R}^{r \times d} with r≪dr \ll d.

WW is frozen, so it needs neither parameter gradients nor Adam states.

We only train BB and AA. Together they have 2⋅r⋅d2 \cdot r \cdot d parameters. When we multiply them, we get a matrix with the same shape as WW, but whose rank is at most rr. That's the thing we add to WW to form the effective weight.

For a Llama-7B-style Linear layer with d=4096d = 4096:

  • Full FT: d2=16,777,216d^2 = 16{,}777{,}216 trainable params.
  • LoRA, r=8r = 8: 2rd=65,5362rd = 65{,}536 trainable params.
  • Ratio: 0.39%.
Rank-r decomposition · visualized d = 64
visual range: 1 – 32
W (frozen) · d × d
frozen
64² = 4096 cells rendered
and
B · d × r
256 cells
·
A · r × d
256 cells
=
ΔW · d × d, rank ≤ 4
low-rank sum of r outer products

The colored grid is ΔW = BA. The effective weight is W′ = W + (α/r)ΔW. Toy colors are normalized within each rank; the rank axis is enlarged for legibility.

full fine-tune
16.78M
d² trainable
LoRA, rank 4
32.77K
2·r·d trainable
ratio
0.20%
2r / d

Change the rank to compare the frozen base W with the trainable factors B and A. At d=4,096 and r=8, the factors contain about 65K parameters, versus 16M in the full layer: roughly 0.4%.

LoRA restricts the update’s rank, while full fine-tuning does not. The 0.39% figure counts trainable parameters in this layer; it is not the fraction of training compute. Forward and backward work through the base model still costs time and memory.


What changes inside the forward pass

The LoRA forward pass combines the frozen base layer with the trainable adapter branch.

At training time, given input xx:

y=Wx+BAxy = W x + B A x

You compute two things in parallel: the original WxWx (using the frozen weight) and the LoRA branch BAxBAx (using the trainable pair). You add them. With the efficient association B(Ax)B(Ax), this is three matrix multiplications: WxWx, AxAx, and B(Ax)B(Ax). The two added multiplications involve the small rank rr.

The optimizer does not need a gradient or Adam states for the frozen weight WW. Gradients with respect to its input can still pass through WxWx to earlier trainable adapters. Freezing parameters is not the same as detaching the whole branch.

LoRA also multiplies the adapter by α/r\alpha/r. This exposes a scale to tune alongside the rank, target modules, and learning rate.

So the full forward pass is:

y=Wx+αr⋅BAxy = W x + \frac{\alpha}{r} \cdot B A x

At inference time, separate adapters allow one frozen base to support different updates. Alternatively, merge Wmerged=W+(α/r)BAW_{\text{merged}} = W + (\alpha/r)BA and use one dense matmul.


Worked example

Say we have a Llama-2-7B model and we want to fine-tune it on internal company documentation.

7B model dimensions:

  • Hidden size d=4096d = 4096.
  • Number of transformer layers: 32.
  • Attention projections: WQW_Q, WKW_K, WVW_V, WOW_O, each d×dd \times d.
  • FFN: standard SwiGLU has three projections of size around d×(d⋅ff_mult)d \times (d \cdot \text{ff\_mult}).

In the original LoRA paper, they apply adapters to WQW_Q and WVW_V only. That's 2 Linear layers per block × 32 blocks = 64 Linear layers being LoRA-ized.

With r=8r = 8, each adapter adds:

2⋅r⋅d=2⋅8⋅4096=65,536 params2 \cdot r \cdot d = 2 \cdot 8 \cdot 4096 = 65{,}536 \text{ params}

Across 64 adapters: 64×65,536≈4.264 \times 65{,}536 \approx 4.2 million trainable parameters. The base model has about 6.7 billion parameters. So we're training 0.06% of the model.

For this example, frozen 16-bit base weights occupy about 13.4 GB. The 4.2M adapter parameters require about 67 MB under the earlier 16-byte training-state accounting. These are decimal units.

Activations, temporary buffers, and implementation overhead are additional. Their size depends on sequence length, batch size, checkpointing, attention kernels, and dtype; these parameter counts alone do not establish that a specific GPU can run the fine-tune.


Picking the rank

Rank limits the update matrix, and the target-module choice determines where those updates are allowed. A small rank may suffice for one task and underfit another. Sweep rank on a fixed evaluation set rather than treating 8 or 16 as universal thresholds.

d = 4096: 2rd = 65,536 trainable parameters (0.39% of d²)

Increasing rank expands the possible update space. It does not specify accuracy. Compare ranks on held-out tasks with the same data and training budget.

Exact trainable-parameter counts for a square layer with width 4,096. Changing rank changes capacity and storage; this calculator does not predict task accuracy.

At r≥dr\geq d, a square layer can represent a full-rank update, so LoRA does not have an accuracy ceiling that persists at every possible rank. At that point its parameter-efficiency advantage may disappear.


QLoRA: quantizing the frozen base

LoRA reduces trainable state but leaves frozen base weights to store. A 70B base in 16-bit precision occupies 140 GB before other allocations.

QLoRA, from Dettmers et al., combines several memory-saving choices:

  1. Quantize the base model to 4-bit using a format called NF4 (NormalFloat 4, which spaces values to match a Gaussian distribution rather than uniformly). This takes the 140 GB frozen base down to about 35 GB.
  2. Keep the LoRA adapters trainable in higher precision. The adapters occupy a small share of the state and retain higher precision for training.
  3. Double-quantize the quantization constants. The NF4 codebook has per-block scales. Quantizing those scales reduces their storage too.
  4. Page optimizer states to CPU when they don't fit in GPU memory, using NVIDIA's unified memory feature. The benefit depends on adapter size and peak allocations; paging also has transfer costs.

65B parameters: explicit model-state accounting

Full fine-tuning
FP16 weights 130 GB + FP16 gradients 130 GB + FP32 master weights 260 GB + two FP32 Adam moments 520 GB = 1,040 GB.
LoRA
Frozen FP16 base: 130 GB. Add the trainable adapters, their gradients, and optimizer states.
QLoRA
Raw 4-bit base payload: 32.5 GB. Add quantization metadata, adapters, their training state, and temporary buffers.

Decimal GB. Activations and workspace are excluded from all rows; their size depends on the same batch, sequence length, and checkpointing choices. These subtotals do not predict whether a complete run fits.

Persistent state for an assumed 65B model. Full fine-tuning uses 16 bytes per parameter (1,040 GB); a frozen 16-bit base uses 130 GB; raw 4-bit base values use 32.5 GB. Quantization metadata, adapters, activations, and workspace must also be budgeted.

QLoRA dequantizes base weights to a compute dtype such as bfloat16 for operations while retaining their compressed storage. Whether dequantization is fused into a matrix multiplication depends on the kernel and training implementation.

The QLoRA paper reports fine-tuning a 65B model on one 48 GB GPU and competitive results in its evaluated settings. That demonstrates a working memory-saving recipe, not a guarantee that any 65B model, sequence length, or batch fits in 48 GB without a quality change.


Merging, and what it buys you

Once training is done, you have a choice. You can keep the adapter separate and compose it with the base at inference time (as y=Wx+(α/r)BAxy = Wx + (\alpha/r)BAx). Or you can precompute W′=W+(α/r)BAW' = W + (\alpha/r)BA and replace WW in the model outright.

In exact arithmetic, merging gives the same linear map. Floating-point rounding and requantization can introduce differences. A merged dense layer removes the adapter’s extra operations; measured end-to-end latency still depends on serving details.

The separate form has two wins:

  1. One base model, many tasks. You keep one copy of WW resident, and you swap a small adapter (a few MB each) per request. Serving systems can batch or switch adapters while reusing the base.
  2. Smaller transfers. Switching a resident or cached adapter can avoid loading a full base checkpoint. Transfer time depends on adapter size, storage, memory residency, and the serving system.
Serving mode
req · adapter: code-pyreq · adapter: legal-esreq · adapter: math-frprompt 1routerfrozen base · 65 B paramsshared across all requests+BAcode-py+BAlegal-es+BAmath-frresponse · code-pydynamic: share the base; adapter overhead depends on workload

Scroll horizontally to read the full diagram.

Toggle between a shared base with separately selected adapters and a base merged with one adapter. The diagram shows model composition, not measured latency or a tenant limit.

S-LoRA evaluates shared-base serving, memory management, and batching across adapters under specified workloads and hardware. Use those conditions when comparing its reported overhead with another deployment.


The broader PEFT family

Other parameter-efficient methods place trainable parameters in different parts of the model:

What each method trains

BitFit
Bias parameters, where the architecture has them.
Prefix tuning
Learned key/value prefixes that condition attention at multiple layers.
Bottleneck adapters
Small inserted neural modules; the original backbone is frozen.
LoRA
Low-rank factors added to selected linear layers.
DoRA
Weight magnitudes and a low-rank adaptation of directions.
QLoRA
LoRA adapters over a frozen quantized base; adapter computation uses higher precision.

Trainable fractions depend on architecture and configuration, not just the method name.

Where different parameter-efficient methods put trainable parameters. Their storage, computation, and quality depend on model and configuration.
  • BitFit trains selected bias parameters. Its trainable fraction depends on the architecture.
  • Prompt tuning learns embeddings prepended at the input. Prefix tuning learns attention key/value prefixes across layers; these are different parameterizations.
  • Adapters insert trainable bottleneck modules into a frozen network, adding operations that generally cannot be absorbed into one existing linear weight.
  • LoRA trains an additive low-rank update.
  • DoRA separates magnitude and direction, using a low-rank direction update plus trainable magnitude. Compare it empirically with LoRA for the chosen setup.
  • QLoRA combines a quantized frozen base with higher-precision adapter training.

Choose the method against a memory budget and held-out task performance. Parameter count alone does not predict latency or quality.


Implementation sketch

This wrapper adds trainable factors AA and BB to a frozen linear layer.

import math
import torch
import torch.nn as nn
 
class LoRALinear(nn.Module):
    """
    Wraps an existing nn.Linear with a LoRA adapter.
    Freezes the base weight. Trains A and B only.
    """
 
    def __init__(self, base_linear: nn.Linear, r: int = 8, alpha: float = 16.0):
        super().__init__()
        if r <= 0:
            raise ValueError("r must be positive")
        self.base = base_linear
        self.r = r
        self.scale = alpha / r
 
        in_f = base_linear.in_features
        out_f = base_linear.out_features
 
        # A : (r, in_f), initialized small-random
        # B : (out_f, r), initialized to zero
        # At init, BA = 0, so the model behaves exactly like the base.
        self.A = nn.Parameter(base_linear.weight.new_empty(r, in_f))
        self.B = nn.Parameter(base_linear.weight.new_zeros(out_f, r))
        nn.init.kaiming_uniform_(self.A, a=math.sqrt(5))
 
        # Freeze the base weight and bias — only A, B will get gradients.
        for p in self.base.parameters():
            p.requires_grad = False
 
    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # Original Linear: (..., in_f) -> (..., out_f)
        base_out = self.base(x)
 
        # LoRA branch: x @ A.T -> (..., r), then @ B.T -> (..., out_f)
        # This order keeps the intermediate tensor r-wide, which is cheap.
        lora_out = (x @ self.A.T) @ self.B.T
 
        return base_out + self.scale * lora_out
 
    @torch.no_grad()
    def merge(self) -> nn.Linear:
        """Fold the adapter into a new nn.Linear. One-time, for inference."""
        delta = self.scale * (self.B @ self.A)  # (out_f, in_f)
        merged = nn.Linear(
            self.base.in_features,
            self.base.out_features,
            bias=self.base.bias is not None,
            device=self.base.weight.device,
            dtype=self.base.weight.dtype,
        )
        merged.weight.copy_(self.base.weight + delta)
        if self.base.bias is not None:
            merged.bias.copy_(self.base.bias)
        return merged

Zero-initializing BB makes the initial adapter update zero. This preserves the base layer at initialization. Computing B(Ax)B(Ax) avoids materializing a dense update on every forward pass. The wrapper preserves the base layer’s dtype and device and assumes an ordinary floating-point nn.Linear, not a quantized module.

Real libraries (Hugging Face's PEFT, LoRAX, FSDP-LoRA) have more machinery for state-dict handling, quantized-base support, target-module selection, and multi-adapter loading.


Misconceptions

"LoRA must recover full fine-tuning." A fixed-rank adapter may not represent the update a task needs, and optimization may find a different solution even when capacity is sufficient. Rank, target modules, data, and training settings all affect the result.

"More rank is always better." More rank permits more updates and consumes more trainable state. Whether it improves held-out quality needs measurement; learning rate and target-module selection can matter as much as rank.

"Any 4-bit scheme gives the same result as QLoRA." QLoRA combines NF4, double quantization, and paged optimizers in a particular recipe. Other quantizers and kernels can have different memory, speed, and quality trade-offs.

"You can’t merge an adapter trained with QLoRA." Merging into a higher-precision base is possible. Keeping that merged checkpoint in BF16 loses the base’s 4-bit storage saving; requantizing it can restore compressed storage but changes the numerical result. Serving the quantized base with a separate adapter is another option.

What's next

How do we tell whether these training choices helped? Arc 8 begins with loss vs. benchmarks: what each measurement tells us, and how to interpret disagreements between them.


Additional reading (and watching)