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:
- 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. - Visible PEFT trade-offs at a deliberately short budget. The recorded run uses
PRETRAIN_EPOCHS=2andADAPT_EPOCHS=1to 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\):
\(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
8.12.3.2 DoRA¶
DoRA decomposes the effective weight into a per-output-row magnitude \(m\) and a direction matrix \(V\):
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(or1underSMOKE_TEST=1). - Adapt epochs:
1(or1underSMOKE_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:
- 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.
- 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.
- 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=1to stay Tier-A. At this budget full fine-tune wins by a wide margin. Real PEFT wins land on well-converged bases — extendPRETRAIN_EPOCHSto 10+ andADAPT_EPOCHSto 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_*_torequires at least one name-pattern. Bothapply_lora_to(net)andapply_dora_to(net)with no patterns raiseValueError("at least one ..."). The pattern"layers.*"matches everyLinearinsideFeedFwdNN.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())andm.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. Doublingalphadoubles the effective adapter learning rate without changing the trainable-parameter count. count_trainablevs% of basedenominators. The notebook reports% of baseas 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=5closes 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_*_toAPI is architecture-agnostic — point the pattern at attention/projectLinears 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_weightsAPI is the in-repo surface for this recipe.