AI & ML — ADVANCED
LoRA in Production: Rank Selection, Merging, and Serving Multiple Adapters
The Intermediate level covered what LoRA freezes and trains, and why that’s cheaper. This level gets concrete: the actual matrix math behind the LoRA update, what the rank hyperparameter really controls, the real trade-off between merging an adapter into the base model and keeping it separate, and when the extra cost of full fine-tuning is still worth paying.
The Quick Answer
LoRA computes W_new = W_original + (B @ A) * scaling, where B and A are small trained matrices sharing an inner dimension called rank. Lower rank means fewer trainable parameters and less representational capacity; higher rank means the opposite. A trained adapter can either be merged into the base weights (zero inference overhead, but locked to one adapter) or kept separate (small runtime overhead, but multiple adapters can be hot-swapped against the same frozen base). Full fine-tuning is still worth it when a task needs a representational shift too large for a low-rank update to capture.
The Actual LoRA Update Rule
Hu et al. define the modified forward pass, for a frozen pretrained weight matrix W₀ of shape d × k, as:
h = W0 x + delta_W x = W0 x + (B A) x
where B has shape d × r, A has shape r × k, and r (the rank) is chosen to be much smaller than either d or k. The product BA is scaled by α/r (a constant α divided by the rank) before being added — the paper notes tuning α behaves roughly like tuning a learning rate, and that this scaling reduces how much hyperparameters need retuning when rank is changed. Only B and A ever receive gradients — W₀ stays frozen throughout training, exactly as covered in the Intermediate level.
Worked as illustrative Python, with small, checkable numbers rather than a real transformer’s actual weights:
import numpy as np
d, k, r = 4, 4, 2 # tiny illustrative shapes; real layers use much larger d, k
alpha = 8 # constant chosen alongside r, per Hu et al.
scaling = alpha / r # scaling = alpha / r, applied to B @ A
W0 = np.array([ # frozen original weight matrix (never updated)
[0.10, 0.20, 0.00, 0.30],
[0.05, 0.15, 0.10, 0.00],
[0.00, 0.10, 0.20, 0.05],
[0.20, 0.00, 0.05, 0.10],
])
B = np.zeros((d, r)) # LoRA convention: B starts at all zeros
A = np.random.randn(r, k) * 0.01 # A starts small-random (Hu et al.'s init scheme)
# At initialization, B @ A is all zeros, so W_new == W0 exactly —
# training starts from the frozen model's original behavior, unchanged.
delta_W = (B @ A) * scaling
W_new = W0 + delta_W
assert np.allclose(W_new, W0) # true only before any training step
# After some training updates B and A (illustrative, not a real gradient step):
B_trained = np.array([[0.4, -0.1], [0.0, 0.3], [-0.2, 0.1], [0.1, 0.2]])
A_trained = np.array([[0.5, -0.3, 0.2, 0.1], [0.1, 0.4, -0.2, 0.3]])
delta_W_trained = (B_trained @ A_trained) * scaling
W_new_trained = W0 + delta_W_trained
# W_new_trained is now W0 plus a rank-2 correction — never a full d x k
# matrix's worth of independently trained numbers.Two details in that code matter beyond the shapes. First, B is initialized to all zeros (with A started small and random), so B @ A is exactly zero before training starts — the adapted model behaves identically to the original frozen model on step zero, and only diverges as training updates B and A. Second, the product of a d × r matrix and an r × k matrix is mathematically constrained to be at most rank r — this is the literal reason the technique is called Low-Rank Adaptation.
Source: Hu et al., “LoRA: Low-Rank Adaptation of Large Language Models,” arXiv:2106.09685
What Rank Actually Controls
Rank (r) is the shared inner dimension of B (d × r) and A (r × k). Every other dimension in those matrices is fixed by the layer’s existing shape — rank is the one number an engineer actually chooses, and it directly sets both matrices’ total parameter count and how rich a correction they can represent:
| Rank choice | Trade-off |
|---|---|
| Low (e.g. r = 4–8) | Fewest trainable parameters, fastest and cheapest to train and store. Best when the target task is a fairly narrow adjustment to existing behavior. |
| Medium (e.g. r = 16–32) | More capacity to represent complex changes, at a proportionally larger — but still small relative to full fine-tuning — parameter and storage cost. |
| High (e.g. r = 64+) | Closer in spirit to full fine-tuning’s expressiveness, but starts eating into the efficiency LoRA exists to provide. Worth it only if lower ranks measurably underperform. |
Hu et al.’s own ablation in the LoRA paper found that increasing rank well beyond small values often produced little additional benefit for the tasks they tested — a finding that has made small ranks (roughly 4 to 64) a common practical starting range, rather than something requiring a search across hundreds of possible values. Try different ranks below and see exactly how the adapter matrices’ dimensions and parameter count change:
Merging Into the Base Model vs. Keeping Adapters Separate
Once B and A are trained, there are two ways to deploy them. Merging computes W_new = W_original + (B @ A) * scaling once, ahead of time, and saves the result as a single ordinary weight matrix — at inference time there is no adapter left to apply, just one dense matrix identical in shape to the original. Keeping the adapter separate means storing B and A apart from W₀, and computing the adapted output at every forward pass.
| Approach | Inference cost | Flexibility |
|---|---|---|
| Merged (W_new baked in) | Zero extra latency — identical to a normal dense model at inference time. | Locked into one adapter. Switching tasks means loading an entirely different merged model. |
| Kept separate (adapter applied at runtime) | Small added compute per forward pass to apply the adapter on top of the frozen base. | Can hot-swap between many task-specific adapters against the same base model, without reloading the whole model each time. |
The real trade-off is concrete, not abstract: a customer support deployment that only ever needs one fine-tuned behavior can merge and get zero-overhead inference. A platform serving many customers, each with their own task-specific adapter trained against the same base model, needs to keep adapters separate — merging would mean maintaining a full separate copy of the entire base model’s weights per customer, instead of one shared frozen base plus many small adapters swapped in per request.
When Full Fine-tuning Is Still Worth It
LoRA’s efficiency comes from a real constraint: the update it can apply to any given weight matrix is mathematically limited to rank r, no matter how it’s trained. For tasks where the needed change in behavior is a narrow adjustment to what the pretrained model can already mostly do, that constraint costs little — which is why LoRA performs on par with full fine-tuning on many of the tasks Hu et al. tested. But for tasks that require a genuinely large representational shift from the pretrained model’s existing behavior — learning something the base model’s pretraining gave it little foundation for — a low-rank update may simply not have enough capacity to represent the needed change, however it’s trained. In that situation, full fine-tuning’s ability to move every weight independently is a real advantage worth its much higher training cost, not just a more expensive way to reach the same result LoRA already gets.
Fun Fact
Because B is initialized to all zeros, a freshly created (untrained) LoRA adapter changes nothing about the model it’s attached to — B @ A is exactly zero before a single training step runs. Every LoRA adapter starts life as a mathematically perfect no-op.
Test Yourself
In the LoRA update W_new = W_original + (A @ B) * scaling, what does a lower value of rank (r) mean for the adapter matrices?
What is the main advantage of merging a trained LoRA adapter into the base model weights?
When does full fine-tuning still tend to outperform LoRA, despite its far higher cost?