Models & Algorithms•SOTAAZ Lab••KR

Four LoRA Variants in a Few Lines Each: VeRA Trained 93% Fewer Numbers and Needed Slightly More Memory

Turn a 19-line LoRA layer into LoRA-FA, LoRA+, VeRA and Delta-LoRA, check three of them against PEFT on Qwen2.5-0.5B, and measure what each keeps on the GPU. VeRA trains 36,864 numbers against LoRA's 540,672 and still needs more activation memory: +0.227 GiB at 2,048 tokens, about 1% of the peak.

Four LoRA Variants in a Few Lines Each: VeRA Trained 93% Fewer Numbers and Needed Slightly More Memory

Four LoRA Variants in a Few Lines Each: VeRA Trained 93% Fewer Numbers and Needed Slightly More Memory

VeRA trains 36,864 numbers on Qwen2.5-0.5B. LoRA, on the same two projections, trains 540,672. So VeRA should be the lighter one to train. It was not: at batch 4 and 2,048 tokens in bf16, VeRA's peak was 0.227 GiB higher than LoRA's. We had predicted that before running it, from counting what each layer keeps for the backward pass, and the count came within 1% of the measured difference in three of four cases and missed the fourth by 15%.

This post is a tutorial. Part 2 put five variants of LoRA on one table, and Part 3 built LoRA itself in 19 lines and checked it against the PEFT library. Here you change that layer into the other four, a few lines each:

  • LoRA-FA freezes A.
  • LoRA+ gives B a larger learning rate than A.
  • VeRA shares one pair of random, frozen matrices across all layers and trains two vectors per layer.
  • Delta-LoRA also moves the frozen weight W, by the change in B A at each step.

For each one we say first why it should or should not save GPU memory, then show the code, then check it. Three of the four are in PEFT, so we compare against PEFT the same way as in Part 3. Delta-LoRA is not, so we check it against its own update rule.

What you need

The same setup as Part 3: PyTorch 2.7.0, Transformers 5.20.2 and PEFT 0.21.2, Qwen/Qwen2.5-0.5B-Instruct, adapters on q_proj and v_proj in all 24 layers. The checks run in fp32 with TF32 off and eager attention, on the same four-sentence batch, for 30 AdamW steps with weight decay 0. The memory runs use bf16, SDPA attention and batch 4, with the loop from Part 3's step 5. The memory runs at 2,048 tokens peak at about 23 GiB.

You also need Part 3's LoRALinear and add_lora. Everything below builds on them.

The animation shows what the optimizer touches in each variant.

The same layer drawn five times, one frame per variant, for q_proj and v_proj in Qwen2.5-0.5B. LoRA: W frozen, A (8 by 896) and B (out by 8) trained, 540,672 trained numbers. LoRA-FA: W and A frozen, only B trained, 196,608. LoRA+: same as LoRA, with A at learning rate lr and B at 4 times lr, 540,672. VeRA: W frozen, A (256 by 896) and B (896 by 256) shared and frozen, the vectors d (256) and b (one per output) trained, 36,864. Delta-LoRA: A and B trained, W moves without a gradient, 540,672 plus W.

Why some variants save memory and others cannot

Training memory has two parts. The fixed part is the weights plus, for each trained number, a gradient and two Adam states. The part that grows with tokens is what each layer keeps from the forward pass so it can compute gradients in the backward pass.

The two Adam states are what the AdamW optimizer carries for every trained number: a running average of its gradient and a running average of its squared gradient. AdamW uses the pair to choose each number's step size, larger when the gradient keeps pointing the same way and smaller when it jumps around. So every trained number brings three more values with it, the gradient included.

LoRA's trained numbers are already 0.11% of the model, so their gradient and Adam states are small. A variant that trains even fewer numbers saves almost nothing in the fixed part. The only place left to save is what each layer keeps per token.

So for each variant, the question is what it keeps. LoRA keeps the layer's input x (896 numbers per token), because A's gradient needs it, and A x (8 numbers) for B's gradient. q_proj and v_proj receive the same x, so x is kept once per layer. That is 896 + 8 + 8 = 912 numbers per token per layer.

LoRA-FA: freeze A

One LoRA-FA layer. The input x (896 numbers per token) splits in two. The top path goes through the frozen W. The bottom path goes through the frozen A (8 by 896) to A x (8 per token), then through the trained B (out by 8). The two paths add to give h. x is drawn as a dashed box with a note that it is not kept, because A needs no gradient. A x is kept for B's gradient. The bottom line gives the numbers kept per token per layer: LoRA 896 + 8 + 8 = 912, LoRA-FA 8 + 8 = 16.

Why it should save memory: if A gets no gradient, nothing needs x. Each layer keeps only A x for B, 16 numbers instead of 912.

The code is one loop on top of add_lora:

python
def add_lora_fa(model, r=8, alpha=16, targets=("q_proj", "v_proj")):
    add_lora(model, r, alpha, targets)
    for m in model.modules():
        if isinstance(m, LoRALinear):
            m.A.requires_grad_(False)   # A stays at its random start
    return model

A matrix product keeps its input only when the other side needs a gradient, so freezing A is enough; the forward pass does not change.

Trained numbers drop from 540,672 to 196,608, the B matrices alone. Our count, PEFT's count and the formula 24 × 8 × (896 + 128) agree.

Against PEFT, the training does not match, and it should not. PEFT's create_lorafa_optimizer does more than freeze A. It corrects B's gradient for the frozen A and uses its own AdamW. Ours freezes A and uses the ordinary torch.optim.AdamW. We predicted that the two would differ by more than 1e-3 at some step. The largest difference was 0.037, and the final losses were 0.3669 (ours) and 0.3686 (PEFT). Our A was bit-for-bit unchanged after 30 steps.

PEFT's optimizer does three things ours does not.

  • It corrects B's gradient. With A frozen, the change the adapter adds to W can only take the form B A. PEFT adjusts B's gradient so that this change is the closest one to the gradient W would have received in full fine-tuning. In symbols, it multiplies B's gradient by (A Aᵀ)⁻¹ and divides by (α/r)². A Aᵀ is 8 by 8, so recomputing it for every layer at every step is cheap.
  • It computes that correction in bf16. If the GPU supports bf16, the correction runs under bf16 autocast and the result is cast back to fp32. Our comparison is in fp32, but this one step is not.
  • It has its own AdamW. Its default eps is 1e-6 rather than the 1e-8 of torch.optim.AdamW, and it adds eps at a different point. The same gradient gives a slightly different step.

The 0.037 gap is these three together; we did not measure them one at a time. If you want the method in the current version of the LoRA-FA paper, the one PEFT's optimizer implements, use PEFT's optimizer. The memory saving comes from freezing A, which both versions do. By our count the correction adds only a few 8 by 8 matrices per layer, but we did not measure PEFT's memory.

LoRA+: a larger learning rate for B

One LoRA+ layer. The structure is LoRA's: the input x (896 per token) goes through the frozen W and through the trained A (8 by 896) to A x (8 per token) and the trained B (out by 8), and the two paths add to give h. x and A x are kept for the backward pass. The only difference is two blue boxes under A and B: A's learning rate is 2.5e-4 and B's is 4 times that, 1e-3. A note says there are two optimizer groups and nothing else changes.

Why it cannot save memory: it trains the same numbers as LoRA and keeps the same tensors. Only the optimizer changes.

The layer is untouched. You build the optimizer with two parameter groups:

python
def loraplus_optimizer(model, lr, ratio):
    layers = [m for m in model.modules() if isinstance(m, LoRALinear)]
    return torch.optim.AdamW([{"params": [m.A for m in layers], "lr": lr},
                              {"params": [m.B for m in layers], "lr": lr * ratio}], weight_decay=0.0)

We used A at 2.5e-4 with ratio 4, so B trains at 1e-3, the rate we gave LoRA in Part 3, Step 3: check it against PEFT.

PEFT's create_loraplus_optimizer with the same rate and ratio builds four parameter groups:

  • A: the A matrices, plus any trained parameter that fits none of the groups below, at lr.
  • Embedding: LoRA on embedding layers, at a rate of its own that defaults to 1e-6.
  • B: the B matrices and any trained vectors (1-D parameters), at lr × ratio.
  • B without weight decay: whatever would go into B that is a bias or a LayerNorm parameter, at lr × ratio.

We put no LoRA on embeddings and trained no bias or LayerNorm, so the embedding group and the last group were empty. The two filled groups matched ours: 48 A matrices at 2.5e-4 and 48 B matrices at 1e-3. From the same starting A and B, the two trained to the same loss at every one of 30 steps, a difference of 0.0.

One trap we found after the run: PEFT's LoRA+ helper ignores a weight_decay= argument. We passed weight_decay=0.01 and every group still had 0.0; the helper always gives the bias and LayerNorm group 0 and sets the weight decay of every other group from one argument of its own, loraplus_weight_decay, which defaults to 0. With loraplus_weight_decay=0.01, the A and B groups got 0.01. Our runs used weight decay 0, so this does not change the match above, but if you set weight decay through the usual name, check the optimizer's groups.

VeRA: shared random matrices, two trained vectors

Why it should cost memory, not save it: VeRA computes h = W x + b ⊙ (B (d ⊙ (A x))). A and B are random and frozen, so x is not kept. But to give d its gradient, the layer keeps A x, and at VeRA's rank of 256 that is 256 numbers. To give b its gradient, it keeps B (d ⊙ A x), one number per output. For q_proj that is 256 + 896, for v_proj 256 + 128: 1,536 numbers per token per layer against LoRA's 912.

A and B are made once, at the largest shapes any adapted layer needs, and every layer uses a slice. They come from a seed, so a saved VeRA adapter needs only the seed and the vectors.

python
import torch.nn.functional as F

class VeRALinear(nn.Module):
    def __init__(self, base: nn.Linear, A, B, d_init=0.1):
        super().__init__()
        self.base, self.A, self.B = base, A, B        # plain tensors: shared, never trained
        for p in self.base.parameters():
            p.requires_grad_(False)
        w = base.weight
        self.d = nn.Parameter(torch.full((A.shape[0],), d_init, device=w.device, dtype=w.dtype))
        self.b = nn.Parameter(torch.zeros(base.out_features, device=w.device, dtype=w.dtype))

    def forward(self, x):
        A = self.A[:, : self.base.in_features]
        B = self.B[: self.base.out_features, :]
        return self.base(x) + self.b * F.linear(self.d * F.linear(x, A), B)


def add_vera(model, r=256, targets=("q_proj", "v_proj"), seed=0, d_init=0.1):
    for p in model.parameters():
        p.requires_grad_(False)
    found = [(module, t, getattr(module, t)) for module in list(model.modules()) for t in targets
             if isinstance(getattr(module, t, None), nn.Linear)]
    w = found[0][2].weight
    g = torch.Generator().manual_seed(seed)          # the seed is all you need to rebuild A and B
    A = torch.empty(r, max(c.in_features for _, _, c in found))
    B = torch.empty(max(c.out_features for _, _, c in found), r)
    nn.init.kaiming_uniform_(A, generator=g); nn.init.kaiming_uniform_(B, generator=g)
    A, B = A.to(w.device, w.dtype), B.to(w.device, w.dtype)
    for module, t, child in found:
        setattr(module, t, VeRALinear(child, A, B, d_init))
    return model

b starts at zero, like LoRA's B, so the model starts as the original. d starts at 0.1; Part 2 showed why that starting value matters in the paper.

Trained numbers: 24 layers × ((256 + 896) + (256 + 128)) = 36,864, 93% fewer than LoRA. PEFT's VeraConfig(r=256, projection_prng_key=0) counts the same.

Against PEFT, it matched exactly. We had predicted that the same seed would give the same A and B as PEFT, and it did, with a largest difference of 0.0: PEFT builds them the same way, from a CPU generator seeded with the key, A first. With b filled with random values so the check tests something, the logits differed by 0.0, against logits as large as 26.2. Over 30 training steps at learning rate 4e-3, every loss matched, from 4.670 down to 2.722.

The loss falls much more slowly than LoRA's, which reached 0.009 on the same batch in 30 steps. VeRA ran at a different learning rate and trains far fewer numbers, and this batch is four sentences learned by heart, so this says nothing about which one fine-tunes better.

Loss per step for 30 steps, our code in thin orange over PEFT in thick blue. VeRA against PEFT: the curves lie on top of each other from 4.67 to 2.72, max difference 0.0 at every step. LoRA+ against PEFT: on top of each other, max difference 0.0. LoRA-FA ours against PEFT: close but not identical, max difference 0.037, final loss 0.3669 against 0.3686. A note says PEFT's LoRA-FA optimizer corrects B's gradient and uses its own AdamW, while ours only freezes A.

Delta-LoRA: W moves too

Why it should not change memory: A and B train exactly as in LoRA, and W still gets no gradient and no Adam state. After each step, W is moved by the change in B A that step produced: W += λ · (α/r) · (B A after − B A before). That needs B A for a moment, about 44 MB across all layers in bf16, after the backward pass has already freed the activations.

Delta-LoRA is not in PEFT. We wrap the optimizer's step instead:

python
class DeltaLoRA:
    def __init__(self, model, optimizer, K, lam):
        self.layers = [m for m in model.modules() if isinstance(m, LoRALinear)]
        self.opt, self.K, self.lam, self.t = optimizer, K, lam, 0

    @torch.no_grad()
    def step(self):
        self.t += 1
        if self.t <= self.K:              # the first K steps are plain LoRA
            self.opt.step()
            return
        before = [m.B @ m.A for m in self.layers]
        self.opt.step()
        for m, ba in zip(self.layers, before):
            m.base.weight += self.lam * m.scale * (m.B @ m.A - ba)

Call delta.step() where you would call opt.step(). The Delta-LoRA paper names the two matrices the other way round; we keep LoRA's names, with B starting at zero.

We used K = 10 and λ = 2, the λ from the paper's data-to-text setting, and started from the same A and B as a plain LoRA run.

The check is the update rule itself. If every step after K adds the change in B A, the changes add up: W after 30 steps should be the original W plus λ · (α/r) · (B A at step 30 − B A at step 10). It was, with a largest error of 2.8e-7. W had moved by up to 0.019.

The losses were identical to plain LoRA for the first 10 steps, as they must be, then fell faster: 0.0068 against 0.0089 at step 30.

What this changes is what you save. With LoRA you save A and B and put them on the original model. With Delta-LoRA that no longer gives you the trained model. We measured the trained model once more after training, then put its A and B on the original W: the loss went from 0.0062 to 0.0541, the logits moved by up to 14.7, and the top-1 token changed at 3.66% of positions. To keep the trained model you save W for every adapted projection, 22,020,096 numbers against 540,672 for A and B, about 41 times as many.

Left: loss on a log scale for 30 steps, LoRA in orange and Delta-LoRA dashed in pink. The two are the same up to step 10, marked K equals 10, and Delta-LoRA falls faster after it. Right, four boxes: trained model with W moved, loss 0.0062; A and B on the original W, loss 0.0541; top-1 token changed at 3.66% of positions; what you must save, W at 22,020,096 numbers against A and B at 540,672.

The memory, measured

All five in bf16 at batch 4, peak memory at the third step, with the loop from Part 3.

bf16, batch 41,024 tokensvs LoRA2,048 tokensvs LoRA
LoRA11.778 GiB22.609 GiB
LoRA-FA11.613 GiB−0.16522.280 GiB−0.329
LoRA+11.778 GiB022.609 GiB0
VeRA11.910 GiB+0.13222.836 GiB+0.227
Delta-LoRA (K = 0)11.778 GiB022.609 GiB0

For Delta-LoRA we set K = 0 so W moves from the first step. LoRA+ and Delta-LoRA matched LoRA's peak exactly at both lengths, so the 44 MB B A did not reach the peak.

The two that differ, differ by close to the amount the count predicts. Each difference in the peak was exactly the difference in memory held at the end of the forward pass, where the kept tensors sit.

What each layer keeps per token for the backward pass, adapter part only. LoRA: 912 numbers, x (896, shared by q and v) plus A x (8) twice. LoRA-FA: 16, A x twice; x is not needed because A gets no gradient. VeRA: 1,536; for q, A x (256) plus B(d A x) (896), for v, 256 plus 128. Against LoRA, LoRA-FA is −0.164 GiB calculated and −0.165 measured at 1,024 tokens, −0.328 and −0.329 at 2,048. VeRA is +0.114 calculated and +0.132 measured at 1,024, +0.229 and +0.227 at 2,048. A note says LoRA+ and Delta-LoRA keep exactly what LoRA keeps.

The calculation is the difference in numbers kept, times 4 sequences, times the length, times 2 bytes, times 24 layers. For LoRA-FA it was within 1% of the measurement at both lengths. For VeRA it was within 1% at 2,048 tokens, but at 1,024 the measurement was 15% above it; we have not found where the extra 0.017 GiB comes from. (Added after the run: the remaining 1% is the Adam state of the numbers one variant trains and the other does not. For LoRA-FA, adding A's Adam state, 1,376,256 bytes in bf16, makes the calculation match the measurement to the byte at both lengths; for VeRA at 2,048 it leaves 53,248 bytes. It does not explain VeRA at 1,024.)

Freezing A saved 1.4% of LoRA's peak at 1,024 tokens and 1.5% at 2,048. That is small because, in this model, LoRA's adapters are a small part of what the GPU holds at the peak; Part 3 showed most of it is the 24 layers' other activations and the logits. LoRA-FA's saving grows with the number of distinct inputs you adapt, and with how wide each input is. Adding k_proj would save nothing more, since it shares x with q and v; o_proj, the MLP's input (shared by gate_proj and up_proj) and down_proj each have an input of their own. We only adapted two projections of a 0.5B model.

Left: trained numbers, LoRA 540,672, LoRA-FA 196,608, VeRA 36,864. Right: peak VRAM minus LoRA's at batch 4 and 2,048 tokens in bf16, LoRA 0 as reference, LoRA-FA −0.329 GiB, VeRA +0.227 GiB. A note says LoRA's peak is 22.61 GiB and freezing A saves 1.5% of it.

Our predictions, scored

We committed these before running anything, together with the code above.

#PredictionResult
Q1Trained numbers, ours = PEFT = formula: LoRA 540,672, LoRA-FA 196,608, VeRA 36,864all three equal, for all threehit
Q2aVeRA: same seed gives PEFT's A and Bmax difference 0.0hit
Q2bVeRA: logits differ by at most 1e-50.0hit
Q2cVeRA: loss differs by at most 1e-4 at each of 30 steps0.0 at every stephit
Q3LoRA+: loss differs by at most 1e-4 at each step0.0 at every stephit
Q4LoRA-FA: ours and PEFT differ by more than 1e-3 at some step; our A unchanged0.037; A unchangedhit
Q5aDelta-LoRA: W moved by λ·(α/r)·(B A at 30 − B A at 10), error at most 1e-52.8e-7hit
Q5bDelta-LoRA: A and B on the original W differ from the trained model's logits by more than 1.014.7hit
Q6LoRA+ peak within 0.01 GiB of LoRAidenticalhit
Q7Delta-LoRA peak within 0.05 GiB of LoRAidenticalhit
Q8LoRA-FA holds 0.08 to 0.25 GiB less after the forward pass at 1,024 tokens, 0.16 to 0.49 at 2,0480.165 and 0.329hit
Q9LoRA-FA saves less than 3% of LoRA's peak1.40% and 1.46%hit
Q10VeRA holds 0.06 to 0.17 GiB more after the forward pass at 1,024, 0.11 to 0.34 at 2,0480.132 and 0.227hit

The ranges in Q8 and Q10 were half to one and a half times our calculation, wide enough that the calculation could be off by a fair amount and still hit. The closer test is the comparison above: within 1% in three of the four cells, and 15% off in the fourth.

What we did not check

  • One small model, two projections, one batch. Everything is Qwen2.5-0.5B with adapters on q_proj and v_proj. The training checks use the four memorized sentences from Part 3, so they show that two implementations compute the same thing, not which variant fine-tunes better.
  • Learning rates are ours. VeRA at 4e-3, LoRA+ at 2.5e-4 with ratio 4 and Delta-LoRA's K = 10 were chosen for these checks, not tuned.
  • We did not test our LoRA-FA against the paper's method, only against PEFT's, and the two are meant to differ.
  • Memory is PyTorch's peak allocated memory in pure bf16 at two lengths, not what nvidia-smi shows.
  • Delta-LoRA was checked in fp32 only. With bf16 weights, a small update to W can be lost to rounding, as Part 3 showed for merging.
  • VeRA's A and B are plain attributes, so .to() does not move them. Put the model on its device and dtype before calling add_vera.
  • Added after the predictions: the LoRA+ weight decay check, the Adam state account of the remaining 1%, and the version log, written after the runs.

Next

Part 5 takes these variants off the 0.5B model and onto the question that matters for a real run: on a card with 8, 12, 16 or 24 GB, how large a model can you fine-tune with LoRA, LoRA-FA and VeRA? The newsletter form below sends it when it is out.

Stay Updated

Follow us for the latest posts and tutorials

Subscribe to Newsletter