LeetLLM
My PlanLearnGlossaryTracksPracticeBlog
LeetLLM

Your go-to resource for mastering AI & LLM systems.

Product

  • Learn
  • Glossary
  • Tracks
  • Practice
  • Blog
  • RSS

Legal

  • Terms of Service
  • Privacy Policy

© 2026 LeetLLM. All rights reserved.

All Topics
Your Progress
0%

0 of 196 articles completed

🛠️Computing Foundations0/9
Git, Shell, Linux for AIDocker for Reproducible AIPython for AI EngineeringNumPy and Tensor ShapesCUDA for ML TrainingMPS & Metal for ML on MacData Structures for AISQL and Data ModelingAlgorithms for ML Engineers
📊Math & Statistics0/9
Gradients and BackpropVectors, Matrices & TensorsLinear Algebra for MLAdam, Momentum, SchedulersProbability for Machine LearningBayesian Inference from EvidenceStatistics and UncertaintyDistributions and SamplingHypothesis Tests, Intervals, and pass@k
📚Preparation & Prerequisites0/13
Neural Networks from ScratchCNNs from ScratchTraining & BackpropagationSoftmax, Cross-Entropy & OptimizationRNNs, LSTMs, GRUs, and Sequence ModelingAutoencoders and VAEsThe Transformer Architecture End-to-EndLanguage Modeling & Next TokensFrom GPT to Modern LLMsPrompt Engineering FundamentalsCalling LLM APIs in ProductionFirst AI App End-to-EndThe LLM Lifecycle
🧮ML Algorithms & Evaluation0/15
Linear Regression from ScratchLogistic Regression and MetricsDecision Trees, Forests, and BoostingReinforcement Learning BasicsValidation and LeakageStatistical Learning and GeneralizationConformal Prediction and UncertaintyClustering and PCAProbabilistic Generative ModelsCore Retrieval AlgorithmsDecoding AlgorithmsExperiment Design and A/B TestingCausal Inference and CounterfactualsPyTorch Training LoopsDataset Pipelines and Data Quality
📦Production ML Systems0/6
Feature Engineering for Production MLBatch and Streaming Feature PipelinesGradient Boosted Trees in ProductionRanking and Recommendation SystemsForecasting and Anomaly DetectionMonitoring Predictive Models
🧪Core LLM Foundations0/8
The Bitter Lesson & ComputeBPE, WordPiece, and SentencePieceStatic to Contextual EmbeddingsPerplexity & Model EvaluationFile Ingestion for AIChunking StrategiesLLM Benchmarks & LimitationsInstruction Tuning & Chat Templates
🧰Applied LLM Engineering0/25
Dimensionality Reduction for EmbeddingsCoT, ToT & Self-Consistency PromptingFunction Calling & Tool UseMCP & Tool Protocol StandardsContext EngineeringPrompt Injection DefenseResponsible AI GovernancePrivacy-Preserving Machine LearningData Labeling and Human FeedbackEvaluating AI AgentsProduction RAG PipelinesHybrid Search: Dense + SparseReranking and Cross-Encoders for RAGRAG Evaluation for Reliable AnswersLLM-as-a-Judge EvaluationBias & Fairness in LLMsHallucination Detection & MitigationLLM Observability & MonitoringExperiment Tracking with MLflow and W&BPrompt Optimization with DSPyModel Versioning & DeploymentSemantic Caching & Cost OptimizationLLM Cost Engineering & Token EconomicsModel Gateways, Routing, and FallbacksDesign an Automated Support Agent
🎓Portfolio Capstones0/9
Capstone: Delivery ETA PredictionCapstone: Product RankingCapstone: Demand ForecastingCapstone: Image Damage ClassifierCapstone: Production ML PipelineCapstone: Document QACapstone: Eval DashboardCapstone: Fine-Tuned ClassifierCapstone: Reproducible ML Study
🧠Transformer Deep Dives0/8
Sentence Embeddings & Contrastive LossEmbedding Similarity & QuantizationScaled Dot-Product AttentionVision Transformers and Image EncodersPositional Encoding: RoPE & ALiBiLayer Normalization: Pre-LN vs Post-LNMechanistic InterpretabilityDecoding Strategies: Greedy to Nucleus
🧬Advanced Training & Adaptation0/17
Scaling Laws & Compute-Optimal TrainingPre-training Data at ScaleBuild GPT from Scratch LabJAX for PyTorch ResearchersContinued Pretraining for Domain ShiftSynthetic Data PipelinesSupervised Fine-Tuning PipelineMixed Precision TrainingDistributed Training: FSDP & ZeROLoRA & Parameter-Efficient TuningTraining Run OperationsReward Modeling from Preference DataRLHF & DPO AlignmentConstitutional AI & Red TeamingRLVR & Verifiable RewardsKnowledge Distillation for LLMsModel Merging and Weight Interpolation
🤖Advanced Agents & Retrieval0/16
Vector DB Internals: HNSW & IVFAdvanced RAG: HyDE & Self-RAGGraphRAG & Knowledge GraphsRAG Security & Access ControlStructured Output GenerationReAct & Plan-and-ExecuteGuardrails & Safety FiltersCode Generation & SandboxingComputer-Use / GUI / Browser AgentsHuman-in-the-Loop Agent ArchitectureAI Coding Workflow with AgentsAgent Memory & PersistenceAgent Failure & RecoveryRecursive Language Models (RLM)Multi-Agent OrchestrationCapstone: Production Agent
⚡Inference & Production Scale0/31
Inference: TTFT, TPS & KV CacheMulti-Query & Grouped-Query AttentionKV Cache & PagedAttentionPrefix Caching and Prompt CachingFlashAttention & Memory EfficiencyContinuous Batching & SchedulingScaling LLM InferenceModel Parallelism for LLM InferenceAccelerator Architecture Field GuideCorrect GPU Profiling and BenchmarkingGPU Kernel Performance Engineering LabGPU Parallel Primitives LabGPU Compilers and Kernel LanguagesGEMM From Naive CUDA to Tensor CoresLow-Precision GPU ProgrammingGPU Runtime Execution LabAI-Generated Kernel EngineeringModel Quantization: GPTQ, AWQ & GGUFLocal LLM DeploymentSLM Specialization & Edge DeploymentSpeculative DecodingLong Context Window ManagementMixture of Experts ArchitectureMamba & State Space ModelsReasoning & Test-Time ComputeAdvanced MLOps & DevOps for AIGPU Serving & AutoscalingGPU Failure OperationsDistributed Inference Data PlaneServing Benchmark EngineeringA/B Testing for LLMs
🏗️System Design Capstones0/9
Content Moderation SystemCode Completion SystemMulti-Tenant LLM PlatformLLM-Powered Search EngineVision-Language Models & CLIPMultimodal LLM ArchitectureDiffusion Models: Images & TextReal-Time Voice AI AgentReasoning Agent System Design
🎤AI Lab Interviewing0/4
AI Lab Coding Interview: Python SystemsAI Lab System Design InterviewAI Lab Behavioral InterviewAI Lab Technical Presentation
🔬Project Deep Dives0/17
Deep Dive - vLLMDeep Dive - SkyRLDeep Dive - FlashAttentionDeep Dive - FlashInferDeep Dive - DeepGEMMDeep Dive - NCCLDeep Dive - MegatronDeep Dive - DeepSpeedDeep Dive - RayDeep Dive - MLflowDeep Dive - PyTorchDeep Dive - TransformersDeep Dive - SGLangDeep Dive - slimeDeep Dive - DeepEPDeep Dive - TinkerDeep Dive - Light-PEFT
Back to Topics
LearnAdvanced Training & AdaptationLoRA & Parameter-Efficient Tuning
⚡HardFine-Tuning & Training

LoRA & Parameter-Efficient Tuning

Understand the mathematics of Low-Rank Adaptation (LoRA), modern adapter targeting strategies, and the real memory tradeoffs compared to full fine-tuning and QLoRA.

39 min read
Learning path
Step 112 of 196 in the full curriculum
Distributed Training: FSDP & ZeROTraining Run Operations

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]Reference 1LoRA: Low-Rank Adaptation of Large Language Models.https://arxiv.org/abs/2106.09685 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:

ComponentMemoryFormula
Model weights (FP16)140 GB70B×270\text{B} \times 270B×2 bytes
Gradients (FP16)140 GB70B×270\text{B} \times 270B×2 bytes
Adam optimizer mmm (FP32, single-precision)280 GB70B×470\text{B} \times 470B×4 bytes
Adam optimizer vvv (FP32, single-precision)280 GB70B×470\text{B} \times 470B×4 bytes
Total under this 12-byte recipe840 GBBefore 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.

full-finetuning-memory-recipe.py
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"])
Fine-tuning recipe output
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= 140

840 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]Reference 2Intrinsic Dimensionality Explains the Effectiveness of Language Model Fine-Tuning.https://arxiv.org/abs/2012.13255 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]Reference 3QLoRA: Efficient Finetuning of Quantized Language Models.https://arxiv.org/abs/2305.14314 The paper result is a demonstrated end-to-end setup, not a 48 GB rule for every model, sequence length, or batch.

65B comparable storage floors: full fine-tuning is 780 GB of model states under a 12-byte recipe, LoRA keeps a 130 GB FP16 frozen-base floor, and QLoRA NF4 plus double-quantization constants is a 33.5 GB payload. A stacked bar splits the 780 GB into 130 GB weights, 130 GB gradients, and 260 GB plus 260 GB Adam moments. The QLoRA paper ran the full 65B setup on one 48 GB GPU, which is device capacity, not the payload bar.
Read the three bars as comparable storage floors, not peak training memory. The stacked breakdown splits Full FT's 780 GB into W (weights), G (gradients), and Adam moments m and v. LoRA keeps the 130 GB FP16 base and drops G plus Adam. QLoRA's 33.5 GB bar is the NF4 payload plus quantization constants. The 48 GB GPU is the paper's demonstrated device for the whole run, including adapters and activations.

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 W0W_0W0​. LoRA keeps W0W_0W0​ frozen and learns ΔW=BA\Delta W = BAΔW=BA, scaled by α/r\alpha / rα/r, so the layer uses W0+αrBAW_0 + \frac{\alpha}{r}BAW0​+rα​BA. 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 4096×40964096 \times 40964096×4096 weight matrix. Before naming LoRA's factors, predict the budget: if the update has rank r=16r=16r=16, will it contain millions of values like the base layer, or only thousands?

Full fine-tuning would train 4096×4096=16,777,2164096 \times 4096 = 16,777,2164096×4096=16,777,216 parameters. A rank-16 LoRA update instead uses:

  • Matrix AAA has shape 16×409616 \times 409616×4096 and has 65,53665,53665,536 parameters.
  • Matrix BBB has shape 4096×164096 \times 164096×16 and has 65,53665,53665,536 parameters.
  • Total LoRA parameters: 131,072131,072131,072

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 xxx. The frozen layer produces W0xW_0xW0​x, while the adapter compresses xxx into rank space, expands it back out, and adds that correction:

h=W0x+αrBAxh = W_0 x + \frac{\alpha}{r} B A xh=W0​x+rα​BAx

One path is the base response. A second path is the trainable overlay, scaled by α/r\alpha/rα/r, where α\alphaα 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 AAA randomly and BBB to zeros, so BA=0BA=0BA=0 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 ΔW\Delta WΔW has the same dimensions as the base weights, dout×dind_{\text{out}} \times d_{\text{in}}dout​×din​. LoRA factorizes that update into two smaller matrices:

ΔW=B⋅A\Delta W = B \cdot AΔW=B⋅A

Read the shapes from right to left when checking the product:

  • A∈Rr×dinA \in \mathbb{R}^{r \times d_{\text{in}}}A∈Rr×din​: the first matrix (maps from input to rank rrr)
  • B∈Rdout×rB \in \mathbb{R}^{d_{\text{out}} \times r}B∈Rdout​×r: the second matrix (maps from rank to output)
  • rrr is the rank, a tiny number such as 8, 16, or 64 compared to the model's dimensions (often 4096 or more)[1]Reference 1LoRA: Low-Rank Adaptation of Large Language Models.https://arxiv.org/abs/2106.09685

Why does LoRA save parameters?

Answer

It learns two skinny matrices instead of one full-size update matrix. When rrr is much smaller than dind_{\text{in}}din​ and doutd_{\text{out}}dout​, AAA and BBB contain far fewer trainable values than ΔW\Delta WΔW.

In the figure, trace the dimensions: BBB times AAA recreates a matrix shaped like W0W_0W0​, but only AAA and BBB carry gradients. The visual also keeps the forward order straight: xxx enters AAA, then BBB, even though the written product is BABABA.

LoRA factorization for a 4096 by 4096 projection: frozen W0 stays a full square, while the trainable update is the product of B (d_out by r) and A (r by d_in). At r=16 that is 131K trainable values, 0.78% of the 16.7M-parameter matrix. B starts at zero so the adapter is a no-op until training moves it.
Read left to right as a product, not as the forward order. $B$ is $d_{\text{out}} \times r$ and $A$ is $r \times d_{\text{in}}$, so $BA$ matches $W_0$. The forward pass still runs $x$ through $A$ then $B$. $B$ starts at zero, so the first step is a no-op.

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.

LoRA params=r⋅din+dout⋅r=2r⋅d\text{LoRA params} = r \cdot d_{\text{in}} + d_{\text{out}} \cdot r = 2r \cdot dLoRA params=r⋅din​+dout​⋅r=2r⋅d

Where rrr is LoRA rank, and din,doutd_{\text{in}}, d_{\text{out}}din​,dout​ are matrix dimensions (equal to ddd for square projection layers).

ApproachParametersRatioMemory (FP16)
Full fine-tuning40962=4096^2 = 40962= 16.7M100%33.5 MB
LoRA (r=8r=8r=8)2×8×4096=2 \times 8 \times 4096 = 2×8×4096= 65.5K0.39%131 KB
LoRA (r=16r=16r=16)2×16×4096=2 \times 16 \times 4096 = 2×16×4096= 131K0.78%262 KB
LoRA (r=64r=64r=64)2×64×4096=2 \times 64 \times 4096 = 2×64×4096= 524K3.1%1 MB

For r=16r=16r=16, 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.

parameter-savings-concrete-calculation.py
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.")
Parameter savings output
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 AAA and BBB 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]Reference 1LoRA: Low-Rank Adaptation of Large Language Models.https://arxiv.org/abs/2106.09685 A common pattern is:

  • AAA starts with small random values.
  • BBB starts at zero.

That makes B⋅A=0B \cdot A = 0B⋅A=0 at step zero, so ΔW=0\Delta W = 0ΔW=0. policy-lora-v1 therefore starts with the checkpoint's behavior and learns a correction from there.

⚠️ Common mistake: Initializing both AAA and BBB 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; α/r\alpha/rα/r says how strongly the resulting correction enters the layer:

scaling=αr\text{scaling} = \frac{\alpha}{r}scaling=rα​

  • rrr (rank): determines adapter capacity.
  • α\alphaα (alpha): determines the explicit scale of its signal.

If rank increases but α\alphaα stays fixed, what happens to α/r\alpha/rα/r?

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, α=r\alpha=rα=r and α=2r\alpha=2rα=2r produce multipliers of 1 and 2. Holding α/r\alpha/rα/r 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 α/r\alpha/rα/r to α/r\alpha/\sqrt{r}α/r​. The rsLoRA paper argues that the original 1/r1/r1/r 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]Reference 4A Rank Stabilization Scaling Factor for Fine-Tuning with LoRA.https://arxiv.org/abs/2312.03732[5]Reference 5PEFT Documentation: LoRA Developer Guide.https://huggingface.co/docs/peft/main/developer_guides/lora

lora-scaling-factor.py
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 )
LoRA scaling output
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.314

Where 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?

Diagram showing Original LoRA, All attention projections, QLoRA-style: all linear layers, and q_proj, v_proj.
Original LoRA, All attention projections, QLoRA-style: all linear layers, and q_proj, v_proj.

In the diagram, purple marks adapted weights and muted boxes stay frozen. The original LoRA paper focused on Query (WQW_QWQ​) and Value (WVW_VWV​) projections in attention, which remains PEFT's default when target_modules is unset.[1]Reference 1LoRA: Low-Rank Adaptation of Large Language Models.https://arxiv.org/abs/2106.09685[5]Reference 5PEFT Documentation: LoRA Developer Guide.https://huggingface.co/docs/peft/main/developer_guides/lora 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]Reference 3QLoRA: Efficient Finetuning of Quantized Language Models.https://arxiv.org/abs/2305.14314

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:

StrategyTarget modulesWhen to use
Original LoRAWQ,WVW_Q, W_VWQ​,WV​ onlyCheapest baseline matching the original paper's attention setup.
All attentionWQ,WK,WV,WOW_Q, W_K, W_V, W_OWQ​,WK​,WV​,WO​Attention-only comparison with more trainable capacity.
All-linearAttention projections + MLP gate/up/down projectionsQLoRA-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]Reference 5PEFT Documentation: LoRA Developer Guide.https://huggingface.co/docs/peft/main/developer_guides/lora

Why adapt MLP layers?

Attention layers (WQ,WK,WV,WOW_Q, W_K, W_V, W_OWQ​,WK​,WV​,WO​) 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?

target-module-budget.py
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))
Target-module budget output
1qv_adapter_params= 262144 2all_linear_adapter_params= 1249280 3all_linear_vs_qv_ratio= 4.77

When 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]Reference 6The Power of Scale for Parameter-Efficient Prompt Tuning.https://arxiv.org/abs/2104.08691 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]Reference 7Prefix-Tuning: Optimizing Continuous Prompts for Generation.https://arxiv.org/abs/2101.00190 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:

MethodWhat trainsBest fit
Prompt tuning / soft promptsLearned input embeddingsVery cheap task steering, classification-like tasks, large base models
Prefix tuningLearned per-layer prefix statesGeneration tasks where a stronger steering signal helps
LoRA / QLoRALow-rank weight adaptersDomain 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 BAxBAxBAx 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 222.

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]Reference 8Accelerate Documentation: Big Model Inference.https://huggingface.co/docs/accelerate/usage_guides/big_modeling

implementing-lora-with-hugging-face-peft.py
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.

merging-the-adapter-path.py
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 Wmerged=W0+αrBAW_{\text{merged}} = W_0 + \frac{\alpha}{r}BAWmerged​=W0​+rα​BA for every adapted linear layer and returns a base-model artifact with the update folded into its weights.[5]Reference 5PEFT Documentation: LoRA Developer Guide.https://huggingface.co/docs/peft/main/developer_guides/lora 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.

merge-identity.py
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)
Adapter merge output
1merged_weights= [[2.0, 1.0], [5.0, 2.0]] 2outputs_match= True

See the two paths without a framework

The short standard-library layer below strips away PEFT's module hooks. It freezes W0W_0W0​, creates a thin r×dinr \times d_{\text{in}}r×din​ matrix AAA, starts BBB at zero, and adds αrBAx\frac{\alpha}{r}BAxrα​BAx. Predict its three checks: the first pass should match the base, changing BBB should change the output, and adding BABABA into W0W_0W0​ should match the unmerged path.

stdlib-lora-layer.py
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]}")
LoRA layer output
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 rrr 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 rrr:

Rank (rrr)Relative adapter parametersWhat to compare
40.25x of rank 16Cheap capacity floor
80.50x of rank 16Low-cost candidate
161.00xReference candidate
644.00x of rank 16Higher-capacity candidate only if evaluation warrants it
25616.00x of rank 16Expensive 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.

rank-budget-scaling.py
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")
Rank budget output
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.00x

Your 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 r=8r=8r=8 or r=64r=64r=64?

Answer

Compare a low-cost candidate such as r=8r=8r=8 or r=16r=16r=16 before paying four times rank-16 adapter state for r=64r=64r=64. 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]Reference 1LoRA: Low-Rank Adaptation of Large Language Models.https://arxiv.org/abs/2106.09685[3]Reference 3QLoRA: Efficient Finetuning of Quantized Language Models.https://arxiv.org/abs/2305.14314 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.

Diagram showing Need many variants or cheap iteration?, Yes, Evaluate LoRA / QLoRA first Cheap variant iteration, and No.
Need many variants or cheap iteration?, Yes, Evaluate LoRA / QLoRA first Cheap variant iteration, and No.

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 WQW_QWQ​ and WVW_VWV​ 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]Reference 3QLoRA: Efficient Finetuning of Quantized Language Models.https://arxiv.org/abs/2305.14314

Adapter cost rises with rank but held-out quality doesn't

Rank may not be the limiting axis, or ordinary α/r\alpha/rα/r 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]Reference 4A Rank Stabilization Scaling Factor for Fine-Tuning with LoRA.https://arxiv.org/abs/2312.03732[5]Reference 5PEFT Documentation: LoRA Developer Guide.https://huggingface.co/docs/peft/main/developer_guides/lora

Changing rank causes overfitting or underfitting

Changing rrr without changing α\alphaα also changes the explicit scale. For an isolated ordinary-LoRA rank comparison, hold α/r\alpha/rα/r 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: Wmerged=W0+αrBAW_{\text{merged}} = W_0 + \frac{\alpha}{r}BAWmerged​=W0​+rα​BA 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]Reference 8Accelerate Documentation: Big Model Inference.https://huggingface.co/docs/accelerate/usage_guides/big_modeling

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 AAA and BBB 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]Reference 5PEFT Documentation: LoRA Developer Guide.https://huggingface.co/docs/peft/main/developer_guides/lora

Shape checks fail only at runtime

AAA and BBB may be transposed or initialized inconsistently. For W0∈Rdout×dinW_0 \in \mathbb{R}^{d_{\text{out}} \times d_{\text{in}}}W0​∈Rdout​×din​, use A∈Rr×dinA \in \mathbb{R}^{r \times d_{\text{in}}}A∈Rr×din​ and B∈Rdout×rB \in \mathbb{R}^{d_{\text{out}} \times r}B∈Rdout​×r so BABABA matches W0W_0W0​. 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:

HyperparameterInitial candidatesNote
Learning Rate1e-4, 2e-4Adapter SFT commonly starts above full-weight SFT rates; select on evaluation.
Effective token batchMeasure and hold stableCompare token throughput and quality, not example count alone.
Rank (rrr)8, 16Add a higher-rank candidate only when evaluation shows capacity pressure.
Alpha (α\alphaα)Choose an explicit α/r\alpha/rα/r comparisonRecord scaling with rank; don't hide it inside a default.
Dropout0.0, 0.05Decide from validation behavior and dataset size.
Target ModulesQ/V baseline versus all-linearQLoRA-style coverage costs more adapter state but may improve quality.
Gradient CheckpointingOff/on comparison if memory is tightIt 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]Reference 3QLoRA: Efficient Finetuning of Quantized Language Models.https://arxiv.org/abs/2305.14314 stores the frozen base in 4-bit and dequantizes values as needed for computation. The trainable LoRA factors (AAA and BBB) 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:

MethodNumber you can defendWhat it means
Full FT under the 12-byte recipe above780 GB model states65B×1265\text{B} \times 1265B×12 bytes, before activations
LoRA with a BF16/FP16 frozen base130 GB base-weight floor65B×265\text{B} \times 265B×2 bytes, plus adapters and runtime memory
QLoRA (NF4 base)Fine-tuned 65B on one 48 GB GPU in the paperWhole demonstrated setup, not a universal memory formula[3]Reference 3QLoRA: Efficient Finetuning of Quantized Language Models.https://arxiv.org/abs/2305.14314

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:

qlora-storage-floor.py
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")
QLoRA storage floor output
1bf16_frozen_base_floor_GB=130.0 2nf4_raw_payload_GB=32.5 3nf4_plus_quant_constants_GB=33.5 4paper_device_capacity_GB=48

The 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:

  1. 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.
  2. Double quantization: Quantization needs constants that rescale blocks of 4-bit values for compute. QLoRA quantizes those constants too, reducing average overhead from 32/64=0.532/64 = 0.532/64=0.5 bits per parameter to 8/64+32/(64⋅256)=0.1278/64 + 32/(64 \cdot 256) = 0.1278/64+32/(64⋅256)=0.127 bits, a reduction of 0.373 bits per parameter.[3]Reference 3QLoRA: Efficient Finetuning of Quantized Language Models.https://arxiv.org/abs/2305.14314
  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]Reference 9PEFT Documentation: Quantization.https://huggingface.co/docs/peft/main/developer_guides/quantization

qlora-peft-setup.py
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.

Two LoRA serving modes for the access-policy assistant. Merge folds BA into W0 and serves one dense checkpoint. Multi-adapter serving keeps one 14 GB frozen base resident and swaps small adapters: access reviews, key rotation, and quota escalation. Fifty 128 MB adapters plus one base are 20.25 GB, versus 700 GB for fifty merged copies.
Merge `policy-lora-v1` when one adapter owns the deployment. Keep adapters separate when one frozen base must serve access reviews, key-rotation exceptions, and quota escalations without loading three full checkpoints.

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 (Wmerged=W0+αrBAW_{\text{merged}} = W_0 + \frac{\alpha}{r}BAWmerged​=W0​+rα​BA) and serve a standalone model with no separate adapter-path overhead.
  • Multi-tenant: keep W0W_0W0​ resident, load adapters on demand, and apply them during inference.

Systems like S-LoRA[10]Reference 10S-LoRA: Serving Thousands of Concurrent LoRA Adapters.https://arxiv.org/abs/2311.03285 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.

multi-adapter-storage.py
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}")
Adapter serving storage output
1merged_full_models_GB=700.0 2shared_base_plus_adapters_GB=20.25 3storage_reduction_x=34.6

Advanced: separate magnitude from direction

A LoRA variant called DoRA (Weight-Decomposed LoRA)[11]Reference 11DoRA: Weight-Decomposed Low-Rank Adaptation.https://arxiv.org/abs/2402.09353 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 mmm (one scalar per column)
  • a direction matrix that LoRA then adapts

Standard LoRA learns one additive update BABABA, so each column's length and direction change together. DoRA normalizes the updated direction, then restores a learned column magnitude:

W′=mW0+BA∥W0+BA∥cW' = m \frac{W_0 + BA}{\|W_0 + BA\|_c}W′=m∥W0​+BA∥c​W0​+BA​

Here ∥⋅∥c\|\cdot\|_c∥⋅∥c​ is the column-wise vector norm. mmm starts as the column norms of W0W_0W0​, and BBB still starts at zero, so W′=W0W' = W_0W′=W0​ at step zero. With the article's convention W0∈Rdout×dinW_0 \in \mathbb{R}^{d_{\text{out}} \times d_{\text{in}}}W0​∈Rdout​×din​, each column contains doutd_{\text{out}}dout​ values, but there are dind_{\text{in}}din​ columns. The magnitude therefore has dind_{\text{in}}din​ scalars and broadcasts as a 1×din1 \times d_{\text{in}}1×din​ 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]Reference 11DoRA: Weight-Decomposed Low-Rank Adaptation.https://arxiv.org/abs/2402.09353 The extra magnitude parameters are one scalar per column, which is cheap next to 2rd2rd2rd 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]Reference 5PEFT Documentation: LoRA Developer Guide.https://huggingface.co/docs/peft/main/developer_guides/lora Treat it as a measured quality/cost experiment, not a free switch.

dora-extra-parameters.py
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%}")
DoRA parameter output
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.

Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.For a 4096 x 4096 linear layer, how large is a rank-16 LoRA update relative to the base weight?

Correct answer: About 0.78%, because the two low-rank factors contain 16(4096 + 4096) parameters.

The factors have 4096 x 16 plus 16 x 4096 parameters, or 131,072 versus 16,777,216 base parameters. That ratio is about 0.78%, not rank divided by one matrix dimension alone.

2.What does the LoRA alpha and rank pair control in the adapted weight?

Correct answer: The update is scaled by alpha/r, so alpha changes update strength while rank changes adapter capacity.

Rank determines the dimension of the low-rank factors. Alpha is a separate scale, commonly applied as alpha divided by rank, so changing either alters the effective update for a different reason.

3.Which initialization preserves the base model's behavior at the start of LoRA training while leaving a usable gradient path?

Correct answer: Initialize A randomly and B to zero, making BA zero initially while one factor can begin learning.

With B zero, the initial low-rank product is zero, so the frozen base behavior is preserved. A random factor avoids the dead symmetry that occurs when both factors start at zero.

4.Which statement correctly describes QLoRA's memory advantage?

Correct answer: It keeps the base model quantized and frozen, dequantizing as needed for computation while adapter parameters remain trainable.

QLoRA reduces weight storage with quantized frozen base parameters and trains LoRA adapters. Quantization does not make activations or all optimizer state disappear, so those memory terms still need budgeting.

5.A 14 GB frozen base serves 50 adapters of 128 MB each. What storage model follows from the article?

Correct answer: One shared base plus adapters uses about 20.25 GB, whereas storing 50 merged 14 GB models would be about 700 GB.

The shared-base design pays 14 GB once and 50 x 128 MB, or 6.25 GB, for adapters. Merging each adapter into a separate full model destroys that sharing advantage.

6.What does freezing the base model change during LoRA training?

Correct answer: It prevents base gradients and optimizer states while the base still occupies memory and participates in forward computation.

Freezing saves gradient and optimizer-state memory for base parameters, but forward activations and the base weights are still needed to compute the model output. LoRA is parameter-efficient, not computation-free.

7.A tokenizer gains new vocabulary rows, but only attention projections receive LoRA adapters. Why can the new tokens remain unusable?

Correct answer: New vocabulary needs trainable embedding and output rows or an explicit trainable-token path beyond attention adapters.

Adding IDs changes input embeddings and often the tied output head. If those rows remain frozen and no trainable-token path is configured, attention adapters alone cannot learn their representations.

8.With the article's W₀ ∈ R^(d_out × d_in) convention, what does a column-wise DoRA magnitude vector contain for d_out=4 and d_in=6?

Correct answer: Six scalars, one for each column of W₀.

Each column is a vector with d_out values, and W₀ has d_in columns. A column norm therefore returns d_in values, which broadcast as (1, d_in) in this notation. The rectangular fixture uses six values, so a d_out-based count would fail its assertion.

9.How does DoRA differ from ordinary LoRA in the article's comparison?

Correct answer: DoRA separates magnitude from direction, which can improve adaptation but adds merge and serving considerations.

DoRA extends the low-rank idea by modeling weight magnitude separately from direction. That extra structure can help quality, but it changes implementation and merge overhead rather than being a free LoRA alias.

10.A small adapter is intended to specialize attention behavior with the article's target-module example. Which selection is most aligned?

Correct answer: Target the query and value projections, then verify quality before expanding to every linear layer.

The article presents targeted projection modules as a useful parameter-efficient starting point. Expanding coverage can raise capacity and memory, but should be driven by a measured quality need rather than assumed necessary.

10 questions remaining.

Next Step
Continue to Training Run Operations

`policy-lora-v1` showed which weights can move cheaply. Training ops is next because that adapter job still has to survive preemption, keep global batch and learning-rate scaling honest, and justify LoRA versus full SFT, QLoRA, continued pretraining, or distillation before preference training starts.

PreviousDistributed Training: FSDP & ZeRO
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
References

LoRA: Low-Rank Adaptation of Large Language Models.

Hu, E. J., et al. · 2021 · ICLR

https://arxiv.org/abs/2106.09685

Intrinsic Dimensionality Explains the Effectiveness of Language Model Fine-Tuning.

Aghajanyan, A., Gupta, S., & Zettlemoyer, L. · 2020 · ACL 2021

https://arxiv.org/abs/2012.13255

QLoRA: Efficient Finetuning of Quantized Language Models.

Dettmers, T., et al. · 2023 · NeurIPS

https://arxiv.org/abs/2305.14314

A Rank Stabilization Scaling Factor for Fine-Tuning with LoRA.

Kalajdzievski, D. · 2023 · arXiv preprint

https://arxiv.org/abs/2312.03732

PEFT Documentation: LoRA Developer Guide.

Hugging Face · 2026

https://huggingface.co/docs/peft/main/developer_guides/lora

The Power of Scale for Parameter-Efficient Prompt Tuning.

Lester, Al-Rfou & Constant · 2021

https://arxiv.org/abs/2104.08691

Prefix-Tuning: Optimizing Continuous Prompts for Generation.

Li, X. L. & Liang, P. · 2021 · ACL 2021

https://arxiv.org/abs/2101.00190

Accelerate Documentation: Big Model Inference.

Hugging Face · 2026

https://huggingface.co/docs/accelerate/usage_guides/big_modeling

PEFT Documentation: Quantization.

Hugging Face · 2026

https://huggingface.co/docs/peft/main/developer_guides/quantization

S-LoRA: Serving Thousands of Concurrent LoRA Adapters.

Sheng, Y., et al. · 2023 · arXiv preprint

https://arxiv.org/abs/2311.03285

DoRA: Weight-Decomposed Low-Rank Adaptation.

Liu, S., et al. · 2024 · ICML 2024

https://arxiv.org/abs/2402.09353

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.