Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Suppose an access-policy assistant already handles routine key reviews, but a new rotation procedure changes how stale keys must be escalated. You have 5,000 labeled tickets and one 48 GB training GPU. Full fine-tuning asks that machine to hold gradients and Adam state for every base weight. A thin adapter looks affordable, but it still has to learn the new policy without damaging ordinary reviews. Which constraint fails first: memory, adaptation capacity, or regression quality?
policy-lora-v1 is the run we need. The base model already knows language and support patterns; this job should change one behavior slice. LoRA (Low-Rank Adaptation) freezes the pretrained weights and learns a small overlay inside selected linear layers. Full fine-tuning moves every eligible weight, so we're comparing a full update with a constrained update on the same base computation.
The original GPT-3 175B experiments make the pressure concrete: adapting only query and value projections at rank 4 reduced trainable parameters by 10,000x and GPU memory from 1.2 TB to 350 GB, about 3x relative to full fine-tuning with Adam.[1] Those are results from one paper setup, not a promise for every checkpoint. We’ll use them to build the accounting, then test where the constraint moves.
Why LoRA exists
Full fine-tuning a 70-billion parameter model is expensive for a specific reason: training stores more than model weights. It also keeps gradients and optimizer state for every trainable parameter. Before reading the table, fix the accounting recipe. It assumes low-precision parameters and gradients with two FP32 Adam moment tensors, but no separate FP32 master parameter copy.
Consider the memory breakdown for a 70B model using Adam with FP16 (half-precision) weights:
| Component | Memory | Formula |
|---|---|---|
| Model weights (FP16) | 140 GB | bytes |
| Gradients (FP16) | 140 GB | bytes |
| Adam optimizer (FP32, single-precision) | 280 GB | bytes |
| Adam optimizer (FP32, single-precision) | 280 GB | bytes |
| Total under this 12-byte recipe | 840 GB | Before activations and temporary buffers |
The 840 GB total covers model state only. A real run also stores activations and temporary buffers. If the optimizer keeps a separate FP32 master copy, add another 280 GB, giving the 1.12 TB, 16-byte recipe from the distributed training lesson. Keep those categories separate: a table of parameter state isn't a peak-memory measurement.
1params_billion = 70
2gb_per_byte_per_billion = 1
3
4recipe = {
5 "fp16_parameters": 2,
6 "fp16_gradients": 2,
7 "fp32_adam_moments": 8,
8}
9without_master = params_billion * sum(recipe.values()) * gb_per_byte_per_billion
10with_master = without_master + params_billion * 4
11
12print("bytes_per_parameter_without_master=", sum(recipe.values()))
13print("full_finetuning_states_without_master_GB=", without_master)
14print("full_finetuning_states_with_master_GB=", with_master)
15print("frozen_fp16_base_floor_for_lora_GB=", params_billion * recipe["fp16_parameters"])1bytes_per_parameter_without_master= 12
2full_finetuning_states_without_master_GB= 840
3full_finetuning_states_with_master_GB= 1120
4frozen_fp16_base_floor_for_lora_GB= 140840 GB of model state, before activations, already exceeds a single 80 GB GPU. The next question is whether the policy update really needs all 70 billion directions. Aghajanyan et al. found that useful fine-tuning changes often occupy a low-dimensional subspace of the full parameter space.[2] LoRA turns that observation into a constraint: learn the update with two thin matrices, while the full base matrix stays fixed.
What LoRA saves, and what it doesn't
LoRA removes gradients and optimizer state for the frozen base model. The base weights still occupy memory, and the adapter keeps its own small trainable state.
Run the policy example at 2,048 tokens per ticket and the apparent bargain changes. Activation memory isn't eliminated. Activations (intermediate tensors from the forward pass) still depend on sequence length, batch size, and model depth. Freezing weights can change which tensors autograd retains, so don't assume activation bytes are identical across implementations. Measure peak memory, then try checkpointing, smaller micro-batches, or memory-efficient attention if the long-context case is the failure.
Memory boundary: LoRA removes base-weight gradient and optimizer-state storage, not the need to execute the full base network. A batch of 2,048-token access-review tickets can still be dominated by activations. Measure peak memory under the intended context length and micro-batch size.
The running example uses 70B for planning, but QLoRA's published comparison uses 65B. Keep that boundary visible: under this 12-byte recipe, 65B full fine-tuning is 780 GB, while the QLoRA paper fine-tuned a 65B model on one 48 GB GPU.[3] The paper result is a demonstrated end-to-end setup, not a 48 GB rule for every model, sequence length, or batch.

The bars explain why policy-lora-v1 is worth testing. They don't tell us how much behavior the overlay can express. For one layer, full fine-tuning rewrites . LoRA keeps frozen and learns , scaled by , so the layer uses . The next section earns each symbol with a small layer.
The math: from a 4096×4096 layer
A layer small enough to count
Suppose one Transformer projection has a weight matrix. Before naming LoRA's factors, predict the budget: if the update has rank , will it contain millions of values like the base layer, or only thousands?
Full fine-tuning would train parameters. A rank-16 LoRA update instead uses:
- Matrix has shape and has parameters.
- Matrix has shape and has parameters.
- Total LoRA parameters:
That is 0.78% of the full matrix size, a 99.2% reduction. The capacity is smaller by design; the base layer still supplies the general representation.
Now follow one input vector . The frozen layer produces , while the adapter compresses into rank space, expands it back out, and adds that correction:
One path is the base response. A second path is the trainable overlay, scaled by , where controls its explicit strength. Because both paths share the same input, merging them later can recover one dense matrix.
One detail decides whether the first forward pass matches the checkpoint: the adapter product must start at zero. A common construction initializes randomly and to zeros, so before training. Initializing both factors randomly would perturb the pretrained behavior immediately.
Why the product is low rank
The worked layer used equal input and output widths. In general, a full update has the same dimensions as the base weights, . LoRA factorizes that update into two smaller matrices:
Read the shapes from right to left when checking the product:
- : the first matrix (maps from input to rank )
- : the second matrix (maps from rank to output)
- is the rank, a tiny number such as 8, 16, or 64 compared to the model's dimensions (often 4096 or more)[1]
Why does LoRA save parameters?
Answer
It learns two skinny matrices instead of one full-size update matrix. When is much smaller than and , and contain far fewer trainable values than .
In the figure, trace the dimensions: times recreates a matrix shaped like , but only and carry gradients. The visual also keeps the forward order straight: enters , then , even though the written product is .

Count the saved parameters
The same arithmetic scales to any square projection. Use the table to put capacity and storage side by side before checking the count in code.
Where is LoRA rank, and are matrix dimensions (equal to for square projection layers).
| Approach | Parameters | Ratio | Memory (FP16) |
|---|---|---|---|
| Full fine-tuning | 16.7M | 100% | 33.5 MB |
| LoRA () | 65.5K | 0.39% | 131 KB |
| LoRA () | 131K | 0.78% | 262 KB |
| LoRA () | 524K | 3.1% | 1 MB |
For , the adapter trains 131K values instead of 16.7M. The task gets a rank-16 update, and that layer no longer stores a full gradient plus Adam pair for its frozen base weights.
1def lora_parameter_count(d_in: int, d_out: int, rank: int) -> int:
2 return rank * d_in + d_out * rank
3
4d_in = d_out = 4096
5rank = 16
6full_params = d_in * d_out
7lora_params = lora_parameter_count(d_in, d_out, rank)
8ratio = lora_params / full_params
9
10print(f"full_params={full_params:,}")
11print(f"lora_params={lora_params:,}")
12print(f"LoRA r={rank} trains {lora_params:,} params, {ratio:.2%} of full fine-tuning.")1full_params=16,777,216
2lora_params=131,072
3LoRA r=16 trains 131,072 params, 0.78% of full fine-tuning.Start from the checkpoint
The first training step should not erase the behavior we paid to pretrain. If both and start non-zero, the adapter adds a random perturbation before it has seen a label.
The original LoRA setup initializes one factor randomly and the other to zero.[1] A common pattern is:
- starts with small random values.
- starts at zero.
That makes at step zero, so . policy-lora-v1 therefore starts with the checkpoint's behavior and learns a correction from there.
⚠️ Common mistake: Initializing both and with small random values because "random init worked for the base model." If both matrices start non-zero, the first forward pass outputs pretrained predictions plus random noise, and the model immediately drifts from its checkpoint. Always zero-initialize one factor.
Separate capacity from strength
Rank and scale answer different questions. Rank says how many independent directions the adapter can express; says how strongly the resulting correction enters the layer:
- (rank): determines adapter capacity.
- (alpha): determines the explicit scale of its signal.
If rank increases but stays fixed, what happens to ?
Answer
It gets smaller. Each rank component contributes less scale, which helps keep the adapter update from growing only because you chose a higher rank.
For ordinary LoRA scaling, and produce multipliers of 1 and 2. Holding constant makes a rank comparison easier to interpret, but it doesn't guarantee identical learned update magnitude. Sweep rank, scale, learning rate, and target-module coverage as coupled choices rather than universal defaults.
rsLoRA (rank-stabilized LoRA) changes the multiplier from to . The rsLoRA paper argues that the original scaling can collapse gradients as rank grows, making higher-rank adapters learn no better than small ones. Hugging Face PEFT exposes the variant with use_rslora=True.[4][5]
1import math
2
3configs = [
4 {"rank": 8, "alpha": 16},
5 {"rank": 32, "alpha": 16},
6 {"rank": 32, "alpha": 64},
7]
8
9for config in configs:
10 rank = config["rank"]
11 alpha = config["alpha"]
12 ordinary = alpha / rank
13 rslora = alpha / math.sqrt(rank)
14 print(
15 f"r={rank:>2} alpha={alpha:>2} "
16 f"ordinary={ordinary:.3f} rslora={rslora:.3f}"
17 )1r= 8 alpha=16 ordinary=2.000 rslora=5.657
2r=32 alpha=16 ordinary=0.500 rslora=2.828
3r=32 alpha=64 ordinary=2.000 rslora=11.314Where to inject LoRA
The rank tells us how much each adapter can express. We still need to choose where that capacity enters the block. Start with the narrow question: should policy-lora-v1 change only attention routing, or also the per-token transformation that follows it?

In the diagram, purple marks adapted weights and muted boxes stay frozen. The original LoRA paper focused on Query () and Value () projections in attention, which remains PEFT's default when target_modules is unset.[1][5] QLoRA's experiments instead applied adapters to every linear layer in each Transformer block and matched its full-fine-tuning baseline on the reported tasks.[3]
Common injection strategies
Target coverage trades adapter cost for capacity. Before choosing a larger rank, compare these three ways to spend the same kind of adapter budget:
| Strategy | Target modules | When to use |
|---|---|---|
| Original LoRA | only | Cheapest baseline matching the original paper's attention setup. |
| All attention | Attention-only comparison with more trainable capacity. | |
| All-linear | Attention projections + MLP gate/up/down projections | QLoRA-style starting point to compare when quality matters. |
Module names vary by architecture. Current Hugging Face PEFT exposes target_modules="all-linear" as a QLoRA-style shortcut, but on a PreTrainedModel that selection excludes the output projection (lm_head). Inspect the matched module names instead of trusting the shortcut, especially after adding special tokens.[5]
Why adapt MLP layers?
Attention layers () control how tokens route information to one another. MLP (Multi-Layer Perceptron) layers, also called FFNs (Feed-Forward Networks), transform each token after that routing. All-linear targeting gives the adapter access to both paths, but it costs more trainable state. If a Q/V adapter misses the new policy, test whether adding the MLP path fixes held-out examples before increasing rank everywhere.
The toy block below puts a number on that choice. With rank 16, how much more adapter state does all-linear targeting add than Q/V targeting?
1dimensions = {
2 "q_proj": (4096, 4096),
3 "k_proj": (4096, 4096),
4 "v_proj": (4096, 4096),
5 "o_proj": (4096, 4096),
6 "gate_proj": (4096, 11008),
7 "up_proj": (4096, 11008),
8 "down_proj": (11008, 4096),
9}
10rank = 16
11
12def adapter_params(names):
13 return sum(rank * (dimensions[name][0] + dimensions[name][1]) for name in names)
14
15qv = adapter_params(["q_proj", "v_proj"])
16all_linear = adapter_params(list(dimensions))
17print("qv_adapter_params=", qv)
18print("all_linear_adapter_params=", all_linear)
19print("all_linear_vs_qv_ratio=", round(all_linear / qv, 2))1qv_adapter_params= 262144
2all_linear_adapter_params= 1249280
3all_linear_vs_qv_ratio= 4.77When the trainable state isn't a weight update
LoRA isn't the only parameter-efficient fine-tuning method. Two older but still useful PEFT families learn prompt-like state instead of inserting low-rank updates inside weight matrices.
Prompt tuning trains a small set of continuous virtual tokens prepended to the input.[6] They aren't human-readable words; they are learned embedding vectors that steer the frozen model. For policy-lora-v1, that means the control signal enters through each ticket's context window, rather than changing internal projection weights. The approach is cheap, but that entry point limits how directly it can change internal transformations.
Prefix tuning trains continuous key/value prefixes for Transformer layers.[7] The base weights remain frozen, while each layer receives learned prefix state that attention can attend to. The control signal therefore reaches multiple layers directly, without becoming a LoRA weight update.
Choose by the path the new behavior needs to use:
| Method | What trains | Best fit |
|---|---|---|
| Prompt tuning / soft prompts | Learned input embeddings | Very cheap task steering, classification-like tasks, large base models |
| Prefix tuning | Learned per-layer prefix states | Generation tasks where a stronger steering signal helps |
| LoRA / QLoRA | Low-rank weight adapters | Domain adaptation, instruction tuning, tool behavior, stronger behavior changes |
Don't call every PEFT method "LoRA." LoRA modifies internal projections through low-rank adapters. Soft prompt tuning and prefix tuning leave those projections frozen and learn continuous prompt-like state instead. That distinction matters when you inspect a checkpoint or estimate what a new policy can change.
Implementing LoRA with Hugging Face PEFT
The peft (Parameter-Efficient Fine-Tuning) library wraps the factors, so you don't need to write by hand. Before reading the setup, predict its two important effects: all-linear should attach more adapters than Q/V targeting, and r=16, alpha=32 should make the ordinary scale .
This is a real training-setup fragment, not a local smoke test. To run it, install torch, transformers, peft, and accelerate on a machine that can load the target model. Let your trainer or distributed launcher handle placement. Accelerate documents device_map="auto" for Big Model Inference, not as a distributed-training strategy.[8]
1import torch
2from peft import LoraConfig, get_peft_model, TaskType
3from transformers import AutoModelForCausalLM
4
5model = AutoModelForCausalLM.from_pretrained(
6 "Qwen/Qwen2.5-7B",
7 dtype=torch.bfloat16,
8)
9
10# Configure LoRA
11lora_config = LoraConfig(
12 r=16, # rank
13 lora_alpha=32, # scaling factor alpha/r = 32/16 = 2.0
14 target_modules="all-linear", # QLoRA-style targeting
15 lora_dropout=0.05, # regularization
16 bias="none", # don't train biases
17 task_type=TaskType.CAUSAL_LM,
18)
19
20model = get_peft_model(model, lora_config)
21model.print_trainable_parameters()
22# Inspect this output rather than assuming how many modules matched.When you run print_trainable_parameters(), you get trainable parameters, total parameters, and the percentage for this exact checkpoint and module selection. Save that receipt with the run. A shortcut is useful only if it matched the intended projections, and all-linear won't attach an adapter to lm_head.
Merge when the serving contract is single-tenant
For a single-adapter deployment, you can merge the overlay into the base weights. That removes the extra adapter path and produces one ordinary checkpoint for inference engines that support the base architecture. Keep the separate artifact until evaluation and rollback checks pass.
1# After training completes
2merged_model = model.merge_and_unload()
3# merged_model is now a standard transformers model with no PEFT adapters
4merged_model.save_pretrained("./my-adapted-model")The merge_and_unload() call computes for every adapted linear layer and returns a base-model artifact with the update folded into its weights.[5] The result follows the ordinary dense path, with no separate adapter matrix multiply. Keep the adapters separate when one resident base must switch among tasks or tenants. Also remember that merge_and_unload() returns a model rather than mutating the original in place.
1base = [[1.0, 2.0], [3.0, 4.0]]
2A = [[1.0, -1.0]]
3B = [[0.5], [1.0]]
4scale = 2.0
5x = [2.0, 1.0]
6
7def matvec(matrix, vector):
8 return [sum(a * b for a, b in zip(row, vector)) for row in matrix]
9
10adapter_matrix = [
11 [scale * B[row][0] * A[0][col] for col in range(2)]
12 for row in range(2)
13]
14merged = [
15 [base[row][col] + adapter_matrix[row][col] for col in range(2)]
16 for row in range(2)
17]
18unmerged_output = [
19 value + delta
20 for value, delta in zip(matvec(base, x), matvec(adapter_matrix, x))
21]
22print("merged_weights=", merged)
23print("outputs_match=", matvec(merged, x) == unmerged_output)1merged_weights= [[2.0, 1.0], [5.0, 2.0]]
2outputs_match= TrueSee the two paths without a framework
The short standard-library layer below strips away PEFT's module hooks. It freezes , creates a thin matrix , starts at zero, and adds . Predict its three checks: the first pass should match the base, changing should change the output, and adding into should match the unmerged path.
1def matvec(matrix: list[list[float]], vector: list[float]) -> list[float]:
2 return [sum(a * b for a, b in zip(row, vector)) for row in matrix]
3
4def close(left: list[float], right: list[float], tol: float = 1e-9) -> bool:
5 return all(abs(a - b) <= tol for a, b in zip(left, right))
6
7class LoRALinear:
8 def __init__(self, weight: list[list[float]], rank: int, alpha: float):
9 d_out = len(weight)
10 d_in = len(weight[0])
11 self.weight = weight
12 self.A = [
13 [0.02 * ((i * d_in + j) % 7 - 3) for j in range(d_in)]
14 for i in range(rank)
15 ]
16 self.B = [[0.0 for _ in range(rank)] for _ in range(d_out)]
17 self.scaling = alpha / rank
18
19 def delta_w(self) -> list[list[float]]:
20 d_out = len(self.B)
21 d_in = len(self.A[0])
22 rank = len(self.A)
23 return [
24 [
25 self.scaling * sum(self.B[i][k] * self.A[k][j] for k in range(rank))
26 for j in range(d_in)
27 ]
28 for i in range(d_out)
29 ]
30
31 def forward(self, x: list[float]) -> list[float]:
32 base = matvec(self.weight, x)
33 adapter = matvec(self.delta_w(), x)
34 return [u + v for u, v in zip(base, adapter)]
35
36weight = [
37 [1.0, 0.0, -0.5, 0.25],
38 [0.0, 1.0, 0.5, -0.25],
39 [0.5, -0.5, 1.0, 0.0],
40]
41layer = LoRALinear(weight, rank=2, alpha=4)
42x = [1.0, -0.5, 0.25, 2.0]
43base_out = matvec(weight, x)
44starts_as_noop = close(layer.forward(x), base_out)
45
46layer.B[0][0] = 1.0
47layer.B[2][1] = -0.5
48moved = layer.forward(x)
49adapter_changed_output = not close(moved, base_out)
50
51merged = [
52 [w + d for w, d in zip(w_row, d_row)]
53 for w_row, d_row in zip(weight, layer.delta_w())
54]
55merge_matches = close(matvec(merged, x), moved)
56
57print(f"starts_as_noop={starts_as_noop}")
58print(f"adapter_changed_output={adapter_changed_output}")
59print(f"merge_matches={merge_matches}")
60print(f"base_out={[round(v, 4) for v in base_out]}")
61print(f"adapted_out={[round(v, 4) for v in moved]}")1starts_as_noop=True
2adapter_changed_output=True
3merge_matches=True
4base_out=[1.375, -0.875, 1.0]
5adapted_out=[1.285, -0.875, 1.105]Choose rank from evidence
policy-lora-v1 now has a working adapter path. The next question is whether rank 8 is enough or rank 64 is worth its cost. Rank controls adapter capacity, but module coverage, data quality, and learning rate can matter just as much. Hold target modules fixed and adapter parameters scale linearly with :
| Rank () | Relative adapter parameters | What to compare |
|---|---|---|
| 4 | 0.25x of rank 16 | Cheap capacity floor |
| 8 | 0.50x of rank 16 | Low-cost candidate |
| 16 | 1.00x | Reference candidate |
| 64 | 4.00x of rank 16 | Higher-capacity candidate only if evaluation warrants it |
| 256 | 16.00x of rank 16 | Expensive diagnostic, not an assumed improvement |
Use a held-out task set for the new policy and a regression set for capabilities you need to retain. A rank that lowers training loss while degrading general behavior isn't a better adapter. Look for the failure shape before buying capacity: underfit policy examples suggest a capacity or coverage problem, while regression suggests data, scale, or target-selection trouble.
1reference_rank = 16
2reference_adapter_parameters = 1_249_280 # toy all-linear block from the earlier example
3
4for rank in [4, 8, 16, 64, 256]:
5 params = reference_adapter_parameters * rank // reference_rank
6 multiplier = rank / reference_rank
7 print(f"r={rank:>3} params={params:>8,} relative_to_r16={multiplier:.2f}x")1r= 4 params= 312,320 relative_to_r16=0.25x
2r= 8 params= 624,640 relative_to_r16=0.50x
3r= 16 params=1,249,280 relative_to_r16=1.00x
4r= 64 params=4,997,120 relative_to_r16=4.00x
5r=256 params=19,988,480 relative_to_r16=16.00xYour platform team wants policy-lora-v1 to cover three new incident-runbook policies. You have 5,000 labeled access tickets, each averaging 512 tokens. Would you start with or ?
Answer
Compare a low-cost candidate such as or before paying four times rank-16 adapter state for . Choose from held-out policy accuracy, regression behavior, and memory/latency measurements rather than assuming a larger adapter fixes a data or prompt-format problem.
The LoRA and QLoRA papers report strong low-rank results on their studied tasks, but that doesn't make one rank universal.[1][3] Establish target coverage, define the two evaluation sets, then sweep rank only when the evidence says capacity is the bottleneck.
Decide after the first controlled comparison
By now, LoRA has bought a smaller trainable state, not a guaranteed answer. For policy-lora-v1, ask two operational questions: will the team need many policy variants, and does the new data require a broad change beyond the base model's learned domain? Dataset size alone can't answer either one.
The flow below turns those questions into a starting experiment. It points toward LoRA or QLoRA when iteration cost or memory dominates, and keeps full fine-tuning in the comparison when a large domain shift and sufficient hardware make its extra capacity plausible.

If many task variants, fast iteration, or cheap deployment matter, LoRA is a strong first comparison even with a large dataset. If the target documents sit far from the pretraining mix and the hardware can update everything, full fine-tuning may justify its extra cost. policy-lora-v1 looks narrower: the model already speaks the domain, and the rotation procedure changes one escalation behavior.
- LoRA keeps the original checkpoint recoverable, but an active adapter can still regress behavior. Fewer trainable parameters constrain the update and make rollback or adapter disablement a config change. They don't guarantee that enabled-adapter responses preserve general capabilities, so evaluate policy quality and regressions together.
- Full fine-tuning has higher capacity. With a large corpus, enough memory, and a target domain far from pretraining, every eligible weight can move. Pay that cost only when held-out results show that the narrower update can't express the required behavior.
Diagnose from the failure shape
An adapter run usually tells you which axis is wrong before it tells you which hyperparameter to change. Read the symptom against the path we just built: target modules affect where behavior can move, rank affects capacity, scale affects update strength, and merge affects serving. The following cases turn that map into concrete checks.
Validation loss is flat and the model isn't learning
If training loss barely moves, adapting only and may be too narrow for the behavior change. Compare all-linear targeting, then inspect the matched module list. QLoRA reported its full-fine-tuning match when applying adapters to all linear layers in each Transformer block, but that result belongs to its tasks and setup, not a universal target rule.[3]
Adapter cost rises with rank but held-out quality doesn't
Rank may not be the limiting axis, or ordinary scaling may make the comparison hard to read. Hold target coverage and scaling explicit, check data formatting, and try rsLoRA for a high-rank run with use_rslora=True instead of assuming more rank is enough.[4][5]
Changing rank causes overfitting or underfitting
Changing without changing also changes the explicit scale. For an isolated ordinary-LoRA rank comparison, hold constant. For an optimization search, log rank and alpha separately so a quality change has an interpretable cause. Don't present one alpha rule as universal.
Inference latency is higher than expected
If one adapter owns the deployment, a runtime adapter path may be unnecessary overhead. Evaluate a merged artifact after training: is a standard linear layer with no separate adapter path. Keep adapters separate intentionally when serving several tasks from one resident base.
A multi-GPU job uses device_map="auto" as its sharding plan
That mixes inference dispatch with distributed training. device_map="auto" belongs to Accelerate's Big Model Inference path. Let the trainer or launcher select supported training placement, and use FSDP, DeepSpeed, or a deliberate QLoRA setup when the model doesn't fit on one training device.[8]
LoRA on a sharded base
Adapters don't cancel the FSDP / ZeRO contract from distributed training. Build the optimizer from requires_grad=True tensors only, wrap at block granularity, and remember that a full-shard unit still gathers frozen weights for compute. Peak memory includes that materialization plus activations, so a 2,048-token policy-lora-v1 batch can OOM even when the adapter file is tiny. Resume, preemption, and deciding whether to continue or export belong to the next chapter.
PEFT notes say "LoRA" but the method trains prompt vectors
Soft prompt tuning learns input embeddings, prefix tuning learns per-layer prefix state, and LoRA learns low-rank updates inside weight matrices. Name the method by what trains. If the learned parameters don't form and inside a projection layer, it isn't LoRA.
New special tokens never appear in generated output
Resizing the tokenizer while leaving lm_head frozen can create this symptom. target_modules="all-linear" skips the output projection on a Hugging Face PreTrainedModel, so an adapter can shift hidden states while a frozen head still maps them into the old vocabulary. Add lm_head (and usually embed_tokens) to modules_to_save, target them explicitly, or use PEFT trainable_token_indices for the new token IDs.[5]
Shape checks fail only at runtime
and may be transposed or initialized inconsistently. For , use and so matches . Assert those dimensions while wrapping modules, and zero-initialize one factor so the adapter starts as a no-op.
Production sweep starting points
When policy-lora-v1 is ready for a run, don't start by sweeping every knob. Pick one reproducible baseline, change one or two axes, and log enough context to explain a win or regression. The candidates below are starting points, not production defaults:
| Hyperparameter | Initial candidates | Note |
|---|---|---|
| Learning Rate | 1e-4, 2e-4 | Adapter SFT commonly starts above full-weight SFT rates; select on evaluation. |
| Effective token batch | Measure and hold stable | Compare token throughput and quality, not example count alone. |
| Rank () | 8, 16 | Add a higher-rank candidate only when evaluation shows capacity pressure. |
| Alpha () | Choose an explicit comparison | Record scaling with rank; don't hide it inside a default. |
| Dropout | 0.0, 0.05 | Decide from validation behavior and dataset size. |
| Target Modules | Q/V baseline versus all-linear | QLoRA-style coverage costs more adapter state but may improve quality. |
| Gradient Checkpointing | Off/on comparison if memory is tight | It reduces activation storage by adding recomputation cost. |
During training, log task evaluation and regression evaluation beside validation loss. Fewer trainable parameters don't make overfitting impossible. If training loss falls while held-out policy quality falls, inspect data cleanup and dropout before rank. If neither loss nor policy quality moves, inspect formatting and target modules before buying more capacity.
QLoRA: going further
Standard LoRA often keeps its frozen base in BF16 or FP16. That would leave a 65B base at roughly 130 GB before activations, which doesn't fit the 48 GB machine in our scenario. QLoRA (Quantized LoRA)[3] stores the frozen base in 4-bit and dequantizes values as needed for computation. The trainable LoRA factors ( and ) stay in a higher precision such as BF16. Gradients still pass through the base computation to update the adapters, but the frozen weights don't receive gradient or optimizer-state tensors.
Use consistent model sizes when comparing numbers. For a 65B model, the raw storage floor and the paper's measured feasibility statement are different kinds of evidence:
| Method | Number you can defend | What it means |
|---|---|---|
| Full FT under the 12-byte recipe above | 780 GB model states | bytes, before activations |
| LoRA with a BF16/FP16 frozen base | 130 GB base-weight floor | bytes, plus adapters and runtime memory |
| QLoRA (NF4 base) | Fine-tuned 65B on one 48 GB GPU in the paper | Whole demonstrated setup, not a universal memory formula[3] |
The raw 4-bit payload is only one storage term. Quantization metadata, adapters, adapter optimizer state, activations, and temporary buffers still contribute to the QLoRA training footprint. Calculate the floor below, then compare it with peak allocation from the actual run:
1parameters_billion = 65
2bf16_base_gb = parameters_billion * 2
3nf4_raw_payload_gb = parameters_billion * 4 / 8
4double_quant_overhead_bits_per_param = 0.127 # QLoRA paper's post-double-quant estimate
5nf4_plus_constants_gb = parameters_billion * (4 + double_quant_overhead_bits_per_param) / 8
6
7print(f"bf16_frozen_base_floor_GB={bf16_base_gb:.1f}")
8print(f"nf4_raw_payload_GB={nf4_raw_payload_gb:.1f}")
9print(f"nf4_plus_quant_constants_GB={nf4_plus_constants_gb:.1f}")
10print("paper_device_capacity_GB=48")1bf16_frozen_base_floor_GB=130.0
2nf4_raw_payload_GB=32.5
3nf4_plus_quant_constants_GB=33.5
4paper_device_capacity_GB=48The output is a payload estimate, not a fit guarantee. Activations still scale with sequence length and micro-batch size, so a long-context QLoRA run can OOM even when the quantized base fits.
QLoRA combined three memory techniques. In the paper's evaluated tasks, that combination preserved 16-bit fine-tuning performance while making 4-bit adapter training practical:
- NF4 (Normal Float 4-bit): NF4 chooses quantile-based values for normally distributed weights. Under the QLoRA paper's assumptions, it's information-theoretically optimal for that distribution.
- Double quantization: Quantization needs constants that rescale blocks of 4-bit values for compute. QLoRA quantizes those constants too, reducing average overhead from bits per parameter to bits, a reduction of 0.373 bits per parameter.[3]
- Paged optimizers: Unified Memory can absorb optimizer-state spikes from long-sequence minibatches. It smooths those peaks; it doesn't make an under-sized device or an unbounded context safe.
QLoRA is a strong candidate when a frozen BF16 or FP16 base doesn't fit on the available training hardware. It trades memory for quantization complexity. Compare policy quality and regressions against an affordable higher-precision baseline so a memory win doesn't hide a behavior loss.
Current PEFT's documented QLoRA-style setup quantizes the base at load time, prepares it for k-bit training, and then adds adapters:[9]
1import torch
2from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
3from transformers import AutoModelForCausalLM, BitsAndBytesConfig
4
5quantization_config = BitsAndBytesConfig(
6 load_in_4bit=True,
7 bnb_4bit_quant_type="nf4",
8 bnb_4bit_use_double_quant=True,
9 bnb_4bit_compute_dtype=torch.bfloat16,
10)
11model = AutoModelForCausalLM.from_pretrained(
12 "Qwen/Qwen2.5-7B",
13 quantization_config=quantization_config,
14 dtype=torch.bfloat16,
15)
16model = prepare_model_for_kbit_training(model)
17model = get_peft_model(
18 model,
19 LoraConfig(r=16, lora_alpha=32, target_modules="all-linear", task_type="CAUSAL_LM"),
20)If you want a merged deployment artifact after QLoRA training, load the base model in BF16 or FP16, merge the adapter, and optionally re-quantize for inference. That sequence matters: merge against a higher-precision base, then measure the quantized serving artifact separately.
Keep the base shared when serving many adapters
Training and serving now pull in opposite directions. Merging makes one adapter easy to run, but it destroys the shared-base advantage as soon as another policy needs a different overlay. Keep policy-lora-v1 unmerged when several tasks should share one resident base.

In a merged-checkpoint setup, 50 clients means 50 full copies of the model. With LoRA, load the frozen base once and keep per-task adapters separately. Adapter size depends on rank, target modules, dtype, and model width, so calculate it instead of promising a fixed number. One resident base can serve access reviews, key-rotation exceptions, and quota-escalation cases.
Choose between two serving contracts:
- Single-tenant: merge once () and serve a standalone model with no separate adapter-path overhead.
- Multi-tenant: keep resident, load adapters on demand, and apply them during inference.
Systems like S-LoRA[10] push this pattern further by keeping many adapters in CPU memory, paging active ones to GPU memory, and batching requests that target different adapters in the same serving step. That design can share one base across many tenant adapters, but its batching and paging behavior must still be measured.
Predict the storage before reading the calculation: 50 merged 14 GB checkpoints should cost 700 GB, while one 14 GB base plus 50 small adapters should be much smaller.
1base_checkpoint_gb = 14
2adapter_mb = 128
3tenant_count = 50
4
5merged_full_models_gb = base_checkpoint_gb * tenant_count
6shared_base_plus_adapters_gb = base_checkpoint_gb + adapter_mb * tenant_count / 1024
7
8print(f"merged_full_models_GB={merged_full_models_gb:.1f}")
9print(f"shared_base_plus_adapters_GB={shared_base_plus_adapters_gb:.2f}")
10print(f"storage_reduction_x={merged_full_models_gb / shared_base_plus_adapters_gb:.1f}")1merged_full_models_GB=700.0
2shared_base_plus_adapters_GB=20.25
3storage_reduction_x=34.6Advanced: separate magnitude from direction
A LoRA variant called DoRA (Weight-Decomposed LoRA)[11] starts from a different question: what if an update needs to change how large a weight vector is and which direction it points, but ordinary LoRA entangles those changes? DoRA splits each pretrained weight into:
- a learnable magnitude vector (one scalar per column)
- a direction matrix that LoRA then adapts
Standard LoRA learns one additive update , so each column's length and direction change together. DoRA normalizes the updated direction, then restores a learned column magnitude:
Here is the column-wise vector norm. starts as the column norms of , and still starts at zero, so at step zero. With the article's convention , each column contains values, but there are columns. The magnitude therefore has scalars and broadcasts as a row. A framework that stores fan-in/fan-out weights transposed may name the corresponding axis differently, so check its stored shape before mapping dimension labels. After training, magnitude and direction can be folded into an ordinary dense matrix, so a merged single-adapter deployment has no separate DoRA path.
The DoRA paper reports gains over plain LoRA on its evaluated tasks, especially at low ranks, while aiming to close the gap to full fine-tuning.[11] The extra magnitude parameters are one scalar per column, which is cheap next to for a square layer. Current PEFT exposes DoRA with use_dora=True on LoraConfig. Its docs warn that DoRA has more runtime overhead than LoRA before merging and recommend merging for inference.[5] Treat it as a measured quality/cost experiment, not a free switch.
1import math
2
3d_in = 6
4d_out = 4
5rank = 2
6lora_params = rank * (d_in + d_out)
7dora_magnitude_params = d_in
8
9# A rectangular weight keeps a d_out substitution visible.
10weight = [
11 [1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
12 [0.0, 2.0, 0.0, 0.0, 0.0, 0.0],
13 [0.0, 0.0, 3.0, 0.0, 0.0, 0.0],
14 [0.0, 0.0, 0.0, 4.0, 1.0, 1.0],
15]
16column_norms = [
17 math.sqrt(
18 sum(weight[row][column] ** 2 for row in range(d_out))
19 )
20 for column in range(d_in)
21]
22assert d_in != d_out
23assert len(column_norms) == d_in
24assert dora_magnitude_params == len(column_norms)
25assert len(weight) == d_out
26assert all(len(row) == d_in for row in weight)
27
28square_d_in = square_d_out = 4096
29square_rank = 16
30square_lora_params = square_rank * (square_d_in + square_d_out)
31square_dora_magnitude_params = square_d_in
32
33print("lora_adapter_params=", lora_params)
34print("dora_extra_magnitude_params=", dora_magnitude_params)
35print(f"dora_extra_vs_lora={dora_magnitude_params / lora_params:.2%}")
36print("fixture_weight_shape=", (d_out, d_in))
37print("fixture_magnitude_shape=", (1, len(column_norms)))
38print("fixture_column_norms=", [round(value, 3) for value in column_norms])
39print("square_lora_adapter_params=", square_lora_params)
40print("square_dora_extra_magnitude_params=", square_dora_magnitude_params)
41print(f"square_dora_extra_vs_lora={square_dora_magnitude_params / square_lora_params:.2%}")1lora_adapter_params= 20
2dora_extra_magnitude_params= 6
3dora_extra_vs_lora=30.00%
4fixture_weight_shape= (4, 6)
5fixture_magnitude_shape= (1, 6)
6fixture_column_norms= [1.0, 2.0, 3.0, 4.0, 1.0, 1.0]
7square_lora_adapter_params= 131072
8square_dora_extra_magnitude_params= 4096
9square_dora_extra_vs_lora=3.12%The rectangular count makes the dimension visible: rank 2 on a four-by-six weight has 20 LoRA parameters and six magnitude scalars. The square lines keep the 4096-wide overhead comparison. If d_out replaces d_in in the magnitude count or column loop, an assertion fails and the printed (1, 6) shape exposes the mistake.
Run the calculator with your model's real projection shapes, target modules, rank, dtype, and adapter count. Save its parameter and storage table beside your evaluation receipt, then compare LoRA, QLoRA, DoRA, and full SFT against one quality gate before choosing a training policy.