Skip to content

8.12 PEFT — LoRA vs DoRA cross-task adaptation

A comprehensive walk-through of notebooks/peft-mnist-to-fmnist-dora-vs-lora-pytorch/ — the in-repo exercise of the nnx PEFT API (apply_lora_to, apply_dora_to, save_lora_weights, load_lora_weights, LoRALinear, DoRALinear). This page is the deep-dive companion to the task notebook: it states the problem, builds the math, dissects the architecture, reads the code top to bottom, reports the measured results, and catalogues the pitfalls and extensions that govern the recipe.

The notebook is Tier-A — CPU re-runs in roughly twenty-five seconds and it is re-executed end-to-end in CI on every pull request. It runs LoRA and DoRA side by side at the same rank on the same MNIST → Fashion-MNIST cross-task adaptation, so the recipe-level trade-off is directly readable: how much accuracy does each adapter recover at what fraction of the trainable-parameter budget, and how does the magnitude-vector decomposition separate DoRA from LoRA. A full fine-tune control and a save/load round-trip complete the comparison.

The committed outputs and repeated adaptation IDs are preserved NNx 0.2.0-era evidence. The Issue #61 0.2.2 trial used temporary outputs; the final 0.2.0-compatible notebook deliberately contains no 0.2.2-only identity salt.

8.12.1 Problem & motivation

When you have a model trained on task A and want it on task B with minimal extra training, parameter-efficient fine-tuning (PEFT) is the standard recipe. You freeze the pretrained weights and add a small set of trainable adapter parameters that bend the model toward task B. LoRA (Hu et al., 2021) is the dominant adapter: each Linear \(W_0 \in \mathbb{R}^{d \times k}\) is augmented with a rank-\(r\) update \((\alpha/r) B A\), where \(B \in \mathbb{R}^{d \times r}\), \(A \in \mathbb{R}^{r \times k}\), and only \(A\), \(B\) are trainable. With \(r \ll \min(d, k)\), the adapter is a tiny fraction of the original parameters.

DoRA (Liu et al., NVIDIA ICML 2024 Oral) refines LoRA with a magnitude/direction decomposition: \(W' = m \cdot V / \lVert V \rVert_c\), where \(V = W_0 + (\alpha/r) B A\) is the LoRA-augmented direction and \(m\) is a trainable per-output-row magnitude vector initialized from \(\lVert W_0 \rVert_c\). At step 0, both adapters preserve the base forward exactly (LoRA's \(B\) is zero-init; DoRA's \(m\) is \(\lVert W_0 \rVert_c\)-init). DoRA often beats LoRA at the same rank because the magnitude vector decouples how much each output channel is scaled from what direction the adapter pushes it.

The nnx API is symmetric: apply_lora_to(net, "layers.*", r=8, alpha=16) wraps every matched Linear in a LoRALinear, freezes the base, and returns the wrap count. apply_dora_to(...) is the magnitude-augmented counterpart. save_lora_weights / load_lora_weights persist only the adapter tensors — the base weights stay where they are, so adapter files are a few dozen KB regardless of base model size.

This notebook exists for two reasons:

  1. Apples-to-apples LoRA vs DoRA at the same rank. Both adapters wrap the same base (hidden_dims=[128, 64], pretrained on MNIST) and adapt to Fashion-MNIST with the same optimizer and learning rate. The only varying axis is the magnitude vector — the marginal contribution of DoRA over LoRA reads off directly.
  2. Visible PEFT trade-offs at a deliberately short budget. The recorded run uses PRETRAIN_EPOCHS=2 and ADAPT_EPOCHS=1 to stay Tier-A. At this budget the full-fine-tune control beats both adapters by a wide margin — which is the pedagogical point: PEFT wins land on well-converged bases, not on barely-pretrained ones.

The falsifiable hypothesis tested by the notebook is that, at the short adapt budget, LoRA and DoRA expose roughly two-orders-of-magnitude fewer trainable parameters than full fine-tune while recovering only a fraction of the cross-task accuracy — and that the LoRA save/load round-trip restores the trained adapter's accuracy exactly (verifying the "adapter tensors only" persistence contract).

8.12.2 Concepts

Concept Where it shows up
Parameter-efficient fine-tuning (PEFT) Freeze base weights; train only adapters
LoRA Rank-\(r\) update \((\alpha/r) BA\) added to each Linear
DoRA Magnitude/direction decomposition \(m \cdot V / \lVert V \rVert_c\)
Rank, scaling r=8, alpha=16 → scaling \(\alpha/r = 2.0\)
Zero-init preservation \(B\) zero-init in LoRA; \(m = \lVert W_0 \rVert_c\)-init in DoRA → step-0 forward equals base
Cross-task transfer MNIST (digits) → Fashion-MNIST (apparel); same input_dim=784, output_dim=10
Full fine-tune control All 109,386 params trainable; the ceiling the adapters chase
Adapter persistence save_lora_weights / load_lora_weights — adapter tensors only
Reproducibility nnx.set_seed(0)

The nnx surface consumed is apply_lora_to, apply_dora_to, save_lora_weights, load_lora_weights, LoRALinear, DoRALinear, NNModel, NNDataset, NNModelParams, NNParams, NNTrainParams, NNOptimParams, Activations, Devices, Losses, Nets, Optims, and set_seed. The pattern "layers.*" matches every Linear inside FeedFwdNN.layers — three layers for hidden_dims=[128, 64].

8.12.3 Mathematical formulation

8.12.3.1 LoRA

Each frozen base weight \(W_0 \in \mathbb{R}^{d \times k}\) is augmented with a rank-\(r\) update. With scaling factor \(s = \alpha/r\):

\[ W_{\text{LoRA}} = W_0 + s \cdot B A, \qquad B \in \mathbb{R}^{d \times r},\ A \in \mathbb{R}^{r \times k}, \qquad s = \frac{\alpha}{r}. \]

\(A\) is initialized as \(\mathcal{N}(0, \sigma^2)\); \(B\) is initialized to zero. So at step 0, \(BA = 0\) and the forward equals the base forward exactly. Trainable parameters per layer: \(dr + rk = r(d + k)\).

The augmented forward is

\[ y = W_0 x + \frac{\alpha}{r} B (A x). \]

8.12.3.2 DoRA

DoRA decomposes the effective weight into a per-output-row magnitude \(m\) and a direction matrix \(V\):

\[ W_{\text{DoRA}} = m \odot \frac{V}{\lVert V \rVert_c}, \qquad V = W_0 + \frac{\alpha}{r} B A, \]

where \(\lVert V \rVert_c\) is the per-column (per-output-row) L2 norm and \(m \in \mathbb{R}^{d}\) is a trainable per-output magnitude vector. At initialization \(m_j = \lVert W_0[:,j] \rVert_2\) and \(BA = 0\), so \(V = W_0\) and the forward equals the base forward exactly. Trainable parameters per layer: \(r(d + k) + d\) — LoRA's count plus the \(d\)-dimensional magnitude vector.

8.12.3.3 Parameter accounting for hidden_dims=[128, 64], r=8

The three Linear layers are \(784 \to 128\), \(128 \to 64\), \(64 \to 10\). The per-layer trainable counts are:

Layer Shape LoRA \((r \cdot (d+k))\) DoRA \((+d)\)
0 784 → 128 \(8 \cdot (128 + 784) = 7{,}296\) \(7{,}296 + 128 = 7{,}424\)
1 128 → 64 \(8 \cdot (64 + 128) = 1{,}536\) \(1{,}536 + 64 = 1{,}600\)
2 64 → 10 \(8 \cdot (10 + 64) = 592\) \(592 + 64 = 656\)
Total 9,424 9,626

These match the notebook's recorded counts exactly. The base is 109,386 parameters, so LoRA trains 8.6% of the base and DoRA trains 8.8% — the extra 0.2% is the per-layer magnitude vectors. The supervised cross-entropy objective is unchanged; only the trainable-parameter mask differs.

8.12.4 Architecture

The base model is a small feed-forward network shared across pretraining and adaptation:

Stage Shape Role
Input 784 Flattened 28×28 MNIST / Fashion-MNIST pixel vector
Hidden 0 784 → 128 Linear + ReLU (becomes LoRALinear / DoRALinear under adapters)
Hidden 1 128 → 64 Linear + ReLU
Output 64 → 10 Linear + softmax + cross-entropy
Total params 109,386

Both datasets share input_dim=784, output_dim=10, so cross-task transfer needs no architectural surgery — only the meaning of the ten output classes changes (digits vs apparel). The shared training contract:

  • Net: Nets.FEED_FWD, hidden_dims=[128, 64], Activations.RELU.
  • Loss: Losses.CROSS_ENTROPY.
  • Pretrain optimizer: Optims.ADAM, max_lr=1e-3, momentum=(0.9, 0.999), weight_decay=0.0.
  • Adapt optimizer: Optims.ADAM, max_lr=5e-3 (LoRA / DoRA tolerate higher LR since the base is frozen).
  • Device: Devices.CPU.
  • Pretrain epochs: 2 (or 1 under SMOKE_TEST=1).
  • Adapt epochs: 1 (or 1 under SMOKE_TEST=1).
  • Adapter rank / alpha: r=8, alpha=16 → scaling \(s = 2.0\).
  • Normalization: (mean=0.1307, std=0.3081) reused across both datasets (see pitfalls).
  • Seed: 0.

The four adaptation paths share the same pretrained base (deep-copied via state_dict), so the only varying axis is which parameters are trainable. The a priori expectation: at one adapt epoch on a barely-pretrained base, full fine-tune should lead by a wide margin; LoRA and DoRA should be essentially tied; and the LoRA save/load round-trip should reproduce the trained adapter's accuracy exactly.

8.12.5 Code walkthrough

8.12.5.1 Pretrain + snapshot

pretrained = make_model()
pretrain_run = pretrained.train(params=train_params(
    loader=mnist_ds.train_loader, val_loader=mnist_ds.val_loader,
    n_epochs=PRETRAIN_EPOCHS, lr=LR_PRETRAIN))
pretrained_state = copy.deepcopy(pretrained.net.state_dict())

def build_from_pretrained():
    m = make_model()
    m.net.load_state_dict(pretrained_state)
    return m

Deep-copying the state_dict and reloading it into a fresh NNModel per adaptation path guarantees every adapter starts from the same pretrained init. Without this, each path would inherit a different RNG state and the controlled comparison breaks.

8.12.5.2 Apply adapter

nnx.set_seed(0)
lora_model = build_from_pretrained()
n_wrapped_lora = apply_lora_to(lora_model.net, "layers.*", r=LORA_RANK, alpha=LORA_ALPHA)
n_trainable_lora = count_trainable(lora_model.net)
# ...
lora_run = lora_model.train(params=train_params(
    loader=fmnist_ds.train_loader, val_loader=fmnist_ds.val_loader,
    n_epochs=ADAPT_EPOCHS, lr=LR_ADAPT))

apply_lora_to wraps every Linear matched by the pattern, sets requires_grad=False on the base weights, sets requires_grad=True on A and B, and returns the wrap count (3 here). apply_dora_to is the same plus the magnitude vector. Both return the wrap count so the caller can assert the adapter actually attached — assert any(isinstance(m, LoRALinear) for m in lora_model.net.modules()) is the belt-and-suspenders check.

8.12.5.3 Save/load round-trip

lora_ckpt_path = os.path.join(tempfile.mkdtemp(prefix="lora_ckpt_"), "lora.pt")
save_lora_weights(lora_model.net, lora_ckpt_path)

fresh_lora = build_from_pretrained()
apply_lora_to(fresh_lora.net, "layers.*", r=LORA_RANK, alpha=LORA_ALPHA)
n_loaded = load_lora_weights(fresh_lora.net, lora_ckpt_path)

save_lora_weights walks the net and saves only the LoRALinear.A / LoRALinear.B tensors — the base weights stay where they are. The recorded checkpoint is 39.2 KB for six tensors (two per layer × three layers), versus the ~440 KB a full state_dict would occupy. Reloading into a fresh adapter built from the same pretrained base should reproduce the trained adapter's accuracy exactly.

8.12.5.4 Comparison table

acc_full = full_ft.evaluate(fmnist_ds.val_loader).accuracy
acc_lora = lora_model.evaluate(fmnist_ds.val_loader).accuracy
acc_dora = dora_model.evaluate(fmnist_ds.val_loader).accuracy
acc_fresh_lora = fresh_lora.evaluate(fmnist_ds.val_loader).accuracy

model.evaluate(loader) runs one forward pass over the held-out val split and returns an evaluation data point carrying accuracy. The four rows of the comparison table are these four numbers — the operational definition of "did the adapter recover cross-task accuracy?"

8.12.6 Results & analysis

On the recorded two-epoch MNIST pretrain + one-epoch Fashion-MNIST adapt run (seed 0, r=8, alpha=16):

Recipe Trainable params % of base Fashion-MNIST val acc
Full fine-tune (control) 109,386 100.0% 46.33%
LoRA r=8 (frozen base) 9,424 8.6% 13.28%
DoRA r=8 (frozen base) 9,626 8.8% 13.40%
LoRA round-trip (save/load) n/a — reloaded n/a 13.28%

Supporting metrics: pretrain final val_loss 2.1403; full fine-tune final val_loss 2.0136; LoRA final val_loss 2.3353; DoRA final val_loss 2.3326; LoRA checkpoint 39.2 KB (6 tensors).

Three observations:

  1. Full fine-tune leads by a wide margin at this budget. At one adapt epoch on a two-epoch-pretrained base, every weight gets to move and the model catches up to Fashion-MNIST quickly (46.33% val accuracy). LoRA and DoRA, with ~8.6% of the params trainable, lag far behind — they cannot push a barely-pretrained base into a new label space in a single epoch.
  2. LoRA and DoRA are essentially tied at this budget. DoRA's 13.40% vs LoRA's 13.28% is inside seed-level noise. The magnitude vector's marginal contribution typically shows up as ~0.5–2 pp on cross-task benchmarks at long horizons; at one adapt epoch it is invisible.
  3. The LoRA save/load round-trip restores accuracy exactly. 13.28% before and after — six adapter tensors (two per layer × three layers) persisted as 39.2 KB, reloaded into a fresh adapter built from the same pretrained base. This verifies the "adapter tensors only" persistence contract directly.

The pedagogical headline: PEFT adapters expose roughly two orders of magnitude fewer trainable parameters, with the adapter weight being a tiny standalone artifact. Whether they match full fine-tune is budget-dependent — the next section explains how to close the gap.

8.12.7 Pitfalls & edge cases

  • Short training budget hides the PEFT win. The recorded run uses PRETRAIN_EPOCHS=2, ADAPT_EPOCHS=1 to stay Tier-A. At this budget full fine-tune wins by a wide margin. Real PEFT wins land on well-converged bases — extend PRETRAIN_EPOCHS to 10+ and ADAPT_EPOCHS to 5+ and LoRA / DoRA accuracy closes most of the gap. Read the 13% / 46% gap as a budget artifact, not a recipe verdict.
  • DoRA vs LoRA is too tight to distinguish at this budget. At long horizons DoRA typically beats LoRA by ~0.5–2 pp; at one adapt epoch the two are tied. Not a defect — a known scale lever.
  • Same normalization across datasets. The notebook reuses MNIST's (0.1307, 0.3081) mean/std for Fashion-MNIST so the only cross-task variable is the label space. In production you would use per-dataset statistics; the shared normalization is a controlled-comparison device, not a deployment recommendation.
  • apply_*_to requires at least one name-pattern. Both apply_lora_to(net) and apply_dora_to(net) with no patterns raise ValueError("at least one ..."). The pattern "layers.*" matches every Linear inside FeedFwdNN.layers; missing the pattern is the most common cause of "adapter didn't attach."
  • Each adapter must start from the same pretrained init. Use copy.deepcopy(pretrained.net.state_dict()) and m.net.load_state_dict(pretrained_state) per path. Re-initializing the base per path silently breaks the controlled comparison.
  • The LoRA scaling is \(\alpha/r\), not \(\alpha\). With r=8, alpha=16, the effective scale is 2.0. Doubling alpha doubles the effective adapter learning rate without changing the trainable-parameter count.
  • count_trainable vs % of base denominators. The notebook reports % of base as trainable / 109,386 (the base param count, adapter overhead excluded). Reporting trainable / total-after-wrapping gives a slightly smaller percentage (~7.9% for LoRA) — pick the denominator and stick to it across rows.

8.12.8 Extensions & references

  • Extend the budget. PRETRAIN_EPOCHS=10, ADAPT_EPOCHS=5 closes most of the gap between the adapters and the full-fine-tune control. The PEFT win emerges in this regime.
  • Sweep rank. r ∈ {2, 4, 8, 16, 32} maps the capacity/accuracy trade-off for both recipes; DoRA's advantage typically widens at lower rank.
  • Apply to a Transformer (not just an MLP). Real PEFT wins land on LLM fine-tuning where the base has billions of parameters. The apply_*_to API is architecture-agnostic — point the pattern at attention/project Linears and the recipe transfers directly.
  • Compare against prefix-tuning or full adapters. LoRA / DoRA are rank-decomposition adapters; prefix-tuning prepends learnable tokens to the KV cache, and "classic" adapter modules insert small bottleneck MLPs. Each has a different trainable-parameter / accuracy profile.
  • References. Hu et al., LoRA: Low-Rank Adaptation of Large Language Models (ICLR 2022) — the rank-\(r\) adapter recipe and zero-init preservation. Liu et al., DoRA: Weight-Decomposed Low-Rank Adaptation (ICML 2024, Oral) — the magnitude/direction decomposition. The nnx.apply_lora_to / apply_dora_to / save_lora_weights / load_lora_weights API is the in-repo surface for this recipe.